From c0676f684e4b7b1831db22ffb42830cce076a479 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 18 Feb 2026 16:06:23 +0900 Subject: [PATCH] item surfaces --- .../tools/item/placement-strategies.ts | 109 ++++++++++++++++ .../components/tools/item/placement-types.ts | 3 +- .../tools/item/use-placement-coordinator.tsx | 120 +++++++++++++++++- .../ui/item-catalog/catalog-items.tsx | 24 ++++ .../panels/site-panel/item-tree-node.tsx | 16 ++- packages/core/src/schema/nodes/item.ts | 6 + .../core/src/systems/item/item-system.tsx | 22 ++-- .../renderers/item/item-renderer.tsx | 4 + 8 files changed, 288 insertions(+), 16 deletions(-) diff --git a/apps/editor/components/tools/item/placement-strategies.ts b/apps/editor/components/tools/item/placement-strategies.ts index dc48a204..cc7d7c8e 100644 --- a/apps/editor/components/tools/item/placement-strategies.ts +++ b/apps/editor/components/tools/item/placement-strategies.ts @@ -1,11 +1,16 @@ import type { AnyNode, + AnyNodeId, CeilingEvent, CeilingNode, GridEvent, + ItemEvent, + ItemNode, WallEvent, WallNode, } from '@pascal-app/core' +import { sceneRegistry, useScene } from '@pascal-app/core' +import { Vector3 } from 'three' import type { CommitResult, LevelResolver, @@ -373,6 +378,105 @@ export const ceilingStrategy = { }, } +// ============================================================================ +// ITEM SURFACE STRATEGY +// ============================================================================ + +export const itemSurfaceStrategy = { + /** + * Handle item:enter — transition from floor to an item surface. + * Returns null if: item has no surface, our item doesn't fit, or it's the draft itself. + */ + enter(ctx: PlacementContext, event: ItemEvent): TransitionResult | null { + // Only floor items can be placed on surfaces + if (ctx.asset.attachTo) return null + + const surfaceItem = event.node as ItemNode + // Don't surface-place on the draft itself + if (surfaceItem.id === ctx.draftItem?.id) return null + // Surface item must declare a surface + if (!surfaceItem.asset.surface) return null + + // Size check: our footprint must fit on surface item's footprint + const ourDims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS + const surfDims = surfaceItem.asset.dimensions + if (ourDims[0] > surfDims[0] || ourDims[2] > surfDims[2]) return null + + const surfaceMesh = sceneRegistry.nodes.get(surfaceItem.id) + if (!surfaceMesh) return null + + const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) + const localPos = surfaceMesh.worldToLocal(worldPos) + + const x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) + const y = surfaceItem.asset.surface.height + + const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) + + return { + stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id }, + nodeUpdate: { position: [x, y, z], parentId: surfaceItem.id }, + cursorRotationY: 0, + gridPosition: [x, y, z], + cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], + stopPropagation: true, + } + }, + + /** + * Handle item:move — update position while on an item surface. + */ + move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null { + if (ctx.state.surface !== 'item-surface') return null + if (!ctx.state.surfaceItemId || !ctx.draftItem) return null + + const nodes = useScene.getState().nodes + const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined + if (!surfaceItem?.asset.surface) return null + + const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId) + if (!surfaceMesh) return null + + const ourDims = ctx.asset.dimensions ?? DEFAULT_DIMENSIONS + const worldPos = new Vector3(event.position[0], event.position[1], event.position[2]) + const localPos = surfaceMesh.worldToLocal(worldPos) + + const x = snapToGrid(localPos.x, ourDims[0]) + const z = snapToGrid(localPos.z, ourDims[2]) + const y = surfaceItem.asset.surface.height + + const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z)) + + return { + gridPosition: [x, y, z], + cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], + cursorRotationY: 0, + nodeUpdate: { position: [x, y, z] }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + /** + * Handle item:click — commit placement on item surface. + */ + click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null { + if (ctx.state.surface !== 'item-surface') return null + if (!ctx.draftItem || !ctx.state.surfaceItemId) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.state.surfaceItemId, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -384,6 +488,11 @@ export const ceilingStrategy = { export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean { if (!ctx.levelId || !ctx.draftItem) return false + // Item surface: valid if we entered (size check was in enter) + if (ctx.state.surface === 'item-surface') { + return ctx.state.surfaceItemId !== null + } + const attachTo = ctx.draftItem.asset.attachTo if (attachTo === 'ceiling') { diff --git a/apps/editor/components/tools/item/placement-types.ts b/apps/editor/components/tools/item/placement-types.ts index a5d84559..827a6baf 100644 --- a/apps/editor/components/tools/item/placement-types.ts +++ b/apps/editor/components/tools/item/placement-types.ts @@ -5,7 +5,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' /** * Tracks which surface the draft item is currently on. @@ -15,6 +15,7 @@ export interface PlacementState { surface: SurfaceType wallId: string | null ceilingId: string | null + surfaceItemId: string | null } // ============================================================================ diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index 71c97732..c38293aa 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -4,6 +4,7 @@ import { type CeilingEvent, emitter, type GridEvent, + type ItemEvent, resolveLevelId, sceneRegistry, spatialGridManager, @@ -29,7 +30,7 @@ import { import { distance, smoothstep, uv, vec2 } from 'three/tsl' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { sfxEmitter } from '@/lib/sfx-bus' -import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies' +import { ceilingStrategy, checkCanPlace, floorStrategy, itemSurfaceStrategy, wallStrategy } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' import type { DraftNodeHandle } from './use-draft-node' @@ -93,6 +94,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surface: 'floor', wallId: null, ceilingId: null, + surfaceItemId: null, } // ---- Helpers ---- @@ -367,6 +369,114 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Item Surface Handlers ---- + + const onItemEnter = (event: ItemEvent) => { + if (event.node.id === draftNode.current?.id) return + const result = itemSurfaceStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + applyTransition(result) + + if (!draftNode.current) { + ensureDraft(result) + } else if (result.nodeUpdate.parentId) { + // Existing draft (move mode): reparent to surface item + useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate) + } + } + + const onItemMove = (event: ItemEvent) => { + if (event.node.id === draftNode.current?.id) return + const ctx = getContext() + + if (ctx.state.surface !== 'item-surface') { + // Try entering surface mode + const enterResult = itemSurfaceStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + applyTransition(enterResult) + if (draftNode.current && enterResult.nodeUpdate.parentId) { + useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate) + } + return + } + + if (!draftNode.current) { + const enterResult = itemSurfaceStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(enterResult) + return + } + + const result = itemSurfaceStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + gridPosition.current.set(...result.gridPosition) + cursorGroupRef.current.position.set(...result.cursorPosition) + cursorGroupRef.current.rotation.y = result.cursorRotationY + + const draft = draftNode.current + if (draft) { + draft.position = result.gridPosition + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) mesh.position.set(...result.gridPosition) + } + + revalidate() + } + + const onItemLeave = (event: ItemEvent) => { + if (event.node.id === draftNode.current?.id) return + if (placementState.current.surface !== 'item-surface') return + + event.stopPropagation() + + // Transition back to floor using event world position + const wx = Math.round(event.position[0] * 2) / 2 + const wz = Math.round(event.position[2] * 2) / 2 + const floorPos: [number, number, number] = [wx, 0, wz] + + Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null }) + gridPosition.current.set(wx, 0, wz) + cursorGroupRef.current.position.set(wx, event.position[1], wz) + + const draft = draftNode.current + if (draft) { + draft.position = floorPos + useScene.getState().updateNode(draft.id, { + parentId: useViewer.getState().selection.levelId as string, + position: floorPos, + }) + } + + revalidate() + } + + const onItemClick = (event: ItemEvent) => { + if (event.node.id === draftNode.current?.id) return + const result = itemSurfaceStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + // Try to set up next draft on the same surface + const enterResult = itemSurfaceStrategy.enter(getContext(), event) + if (enterResult) { + applyTransition(enterResult) + } else { + revalidate() + } + } + } + // ---- Ceiling Handlers ---- const onCeilingEnter = (event: CeilingEvent) => { @@ -546,6 +656,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) + emitter.on('item:enter', onItemEnter) + emitter.on('item:move', onItemMove) + emitter.on('item:leave', onItemLeave) + emitter.on('item:click', onItemClick) emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) @@ -560,6 +674,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea useScene.temporal.getState().resume() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) + emitter.off('item:enter', onItemEnter) + emitter.off('item:move', onItemMove) + emitter.off('item:leave', onItemLeave) + emitter.off('item:click', onItemClick) emitter.off('wall:enter', onWallEnter) emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx index b775301d..e393be83 100644 --- a/apps/editor/components/ui/item-catalog/catalog-items.tsx +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -435,6 +435,9 @@ export const CATALOG_ITEMS: AssetInput[] = [ offset: [0, 0, 0], rotation: [0, 0, 0], dimensions: [2, 0.8, 1], + surface: { + height: 0.75 + } }, { @@ -448,6 +451,9 @@ export const CATALOG_ITEMS: AssetInput[] = [ offset: [0, 0, 0], rotation: [0, 0, 0], dimensions: [2, 1.1, 1], + surface: { + height: 1.1 + } }, { @@ -1176,6 +1182,9 @@ export const CATALOG_ITEMS: AssetInput[] = [ rotation: [0, 0, 0], dimensions: [1, 0.5, 0.7], attachTo: "wall-side", + surface: { + height: 0.12 + } }, { @@ -1255,6 +1264,9 @@ export const CATALOG_ITEMS: AssetInput[] = [ offset: [0, 0, 0], rotation: [0, 0, 0], dimensions: [1.5, 0.8, 1], + surface: { + height: 0.8 + } }, { @@ -1385,6 +1397,9 @@ export const CATALOG_ITEMS: AssetInput[] = [ offset: [0, 0, -0.01], rotation: [0, 0, 0], dimensions: [0.5, 0.5, 0.5], + surface: { + height: 0.5 + } }, { @@ -1398,6 +1413,9 @@ export const CATALOG_ITEMS: AssetInput[] = [ offset: [0, 0, 0], rotation: [0, 0, 0], dimensions: [2, 0.4, 1.5], + surface: { + height: 0.3 + } }, { @@ -1411,6 +1429,9 @@ export const CATALOG_ITEMS: AssetInput[] = [ offset: [0, 0, 0], rotation: [0, 0, 0], dimensions: [2, 0.8, 1], + surface: { + height: 0.75 + } }, { @@ -1424,5 +1445,8 @@ export const CATALOG_ITEMS: AssetInput[] = [ offset: [0, 0, -0.01], rotation: [0, 0, 0], dimensions: [2.5, 0.8, 1], + surface: { + height: 0.8 + } }, ]; diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx index 3443ce30..efac1328 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx @@ -3,7 +3,7 @@ import { useViewer } from "@pascal-app/viewer"; import Image from "next/image"; import { useState } from "react"; import { RenamePopover } from "./rename-popover"; -import { TreeNodeWrapper } from "./tree-node"; +import { TreeNode, TreeNodeWrapper } from "./tree-node"; import { TreeNodeActions } from "./tree-node-actions"; const CATEGORY_ICONS: Record = { @@ -23,6 +23,7 @@ interface ItemTreeNodeProps { export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { const [renameOpen, setRenameOpen] = useState(false); + const [expanded, setExpanded] = useState(true); const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png"; const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)); const isHovered = useViewer((state) => state.hoveredId === node.id); @@ -46,6 +47,7 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { }; const defaultName = node.asset.name || "Item"; + const hasChildren = node.children && node.children.length > 0; return ( } label={node.name || defaultName} depth={depth} - hasChildren={false} - expanded={false} - onToggle={() => {}} + hasChildren={hasChildren} + expanded={expanded} + onToggle={() => setExpanded(!expanded)} onClick={handleClick} onDoubleClick={handleDoubleClick} onMouseEnter={handleMouseEnter} @@ -69,7 +71,11 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) { isHovered={isHovered} isVisible={node.visible !== false} actions={} - /> + > + {hasChildren && node.children.map((childId) => ( + + ))} + ); } diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index b4b3f7f5..9a879bb3 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -15,6 +15,11 @@ const assetSchema = z.object({ offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), + surface: z + .object({ + height: z.number(), // where things rest + }) + .optional(), // undefined = can't place things on it }) export type AssetInput = z.input @@ -26,6 +31,7 @@ export const ItemNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), side: z.enum(['front', 'back']).optional(), + children: z.array(z.string()).default([]), // Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side") wallId: z.string().optional(), diff --git a/packages/core/src/systems/item/item-system.tsx b/packages/core/src/systems/item/item-system.tsx index dd7b3745..5a70bacc 100644 --- a/packages/core/src/systems/item/item-system.tsx +++ b/packages/core/src/systems/item/item-system.tsx @@ -35,15 +35,19 @@ export const ItemSystem = () => { mesh.position.z = (wallThickness / 2) * side; } } else if (!item.asset.attachTo) { - // Floor item: elevate by slab height (using full footprint overlap) - const levelId = resolveLevelId(item, nodes) - const slabElevation = spatialGridManager.getSlabElevationForItem( - levelId, - item.position, - item.asset.dimensions, - item.rotation, - ) - mesh.position.y = slabElevation + item.position[1] + // If parented to another item (surface placement), R3F handles positioning via the hierarchy + const parentNode = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined + if (parentNode?.type !== 'item') { + // Floor item: elevate by slab height (using full footprint overlap) + const levelId = resolveLevelId(item, nodes) + const slabElevation = spatialGridManager.getSlabElevationForItem( + levelId, + item.position, + item.asset.dimensions, + item.rotation, + ) + mesh.position.y = slabElevation + item.position[1] + } } clearDirty(id as AnyNodeId) diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index c8d94dc5..92530528 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -7,6 +7,7 @@ import { positionLocal, smoothstep, time } from 'three/tsl' import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveCdnUrl } from '../../../lib/asset-url' +import { NodeRenderer } from '../node-renderer' // Shared materials to avoid creating new instances for every mesh const defaultMaterial = new MeshStandardNodeMaterial({ @@ -43,6 +44,9 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => { }> + {node.children?.map((childId) => ( + + ))} ) }