From 6bd34762e5f4b52df875a446d64dc436d11909ea Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 28 Jan 2026 06:46:03 +0900 Subject: [PATCH] draft attach item to ceiling --- .../components/tools/item/item-tool.tsx | 130 +++++++++++++++++- packages/core/src/schema/nodes/ceiling.ts | 2 + .../renderers/ceiling/ceiling-renderer.tsx | 4 + .../viewer/src/components/viewer/index.tsx | 4 +- 4 files changed, 134 insertions(+), 6 deletions(-) diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index 05fb6c53..95829657 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -1,4 +1,5 @@ import { + type CeilingEvent, emitter, type GridEvent, ItemNode, @@ -113,6 +114,7 @@ export const ItemTool: React.FC = () => { const selectedItem = useEditor((state) => state.selectedItem) const { canPlaceOnFloor, canPlaceOnWall } = useSpatialQuery() const isOnWall = useRef(false) + const isOnCeiling = useRef(false) useEffect(() => { if (!selectedItem) { @@ -120,12 +122,15 @@ export const ItemTool: React.FC = () => { } let currentWallId: string | null = null + let currentCeilingId: string | null = null const checkCanPlace = () => { const currentLevelId = useViewer.getState().selection.levelId if (currentLevelId && draftItem.current) { let placeable = true - if (draftItem.current.asset.attachTo) { + if (draftItem.current.asset.attachTo === 'ceiling') { + placeable = isOnCeiling.current && !!currentCeilingId + } else if (draftItem.current.asset.attachTo) { if (!isOnWall.current || !currentWallId) { placeable = false } else { @@ -181,7 +186,7 @@ export const ItemTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return - if (isOnWall.current) return + if (isOnWall.current || isOnCeiling.current) return const [dimX, , dimZ] = selectedItem.dimensions gridPosition.current.set( @@ -201,7 +206,7 @@ export const ItemTool: React.FC = () => { } const onGridClick = (event: GridEvent) => { const currentLevelId = useViewer.getState().selection.levelId - if (isOnWall.current) return + if (isOnWall.current || isOnCeiling.current) return if (!currentLevelId || !draftItem.current || !checkCanPlace()) return @@ -358,12 +363,125 @@ export const ItemTool: React.FC = () => { } } + // ==================================================================== + // CEILING HANDLERS + // ==================================================================== + + const onCeilingEnter = (event: CeilingEvent) => { + if (draftItem.current?.asset.attachTo !== 'ceiling') return + if ( + useViewer.getState().selection.levelId !== + resolveLevelId(event.node, useScene.getState().nodes) + ) { + return + } + + event.stopPropagation() + isOnCeiling.current = true + currentCeilingId = event.node.id + + const [dimX, , dimZ] = selectedItem.dimensions + const itemHeight = selectedItem.dimensions[1] + + gridPosition.current.set( + snapToGrid(event.position[0], dimX), + -itemHeight, + snapToGrid(event.position[2], dimZ), + ) + cursorRef.current.position.set( + gridPosition.current.x, + event.position[1] - itemHeight, + gridPosition.current.z, + ) + + draftItem.current.parentId = event.node.id + + useScene.getState().updateNode(draftItem.current.id, { + position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], + parentId: event.node.id, + }) + checkCanPlace() + } + + const onCeilingMove = (event: CeilingEvent) => { + if (!isOnCeiling.current || !draftItem.current) return + + event.stopPropagation() + + const [dimX, , dimZ] = selectedItem.dimensions + const itemHeight = selectedItem.dimensions[1] + + gridPosition.current.set( + snapToGrid(event.position[0], dimX), + -itemHeight, + snapToGrid(event.position[2], dimZ), + ) + cursorRef.current.position.set( + gridPosition.current.x, + event.position[1] - itemHeight, + gridPosition.current.z, + ) + + checkCanPlace() + if (draftItem.current) { + draftItem.current.position = [ + gridPosition.current.x, + gridPosition.current.y, + gridPosition.current.z, + ] + const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id) + if (draftItemMesh) { + draftItemMesh.position.copy(gridPosition.current) + } + } + } + + const onCeilingClick = (event: CeilingEvent) => { + if (!isOnCeiling.current) return + + event.stopPropagation() + + const currentLevelId = useViewer.getState().selection.levelId + if (!currentLevelId || !draftItem.current || !checkCanPlace()) return + + useScene.temporal.getState().resume() + useScene.getState().updateNode(draftItem.current.id, { + position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], + parentId: event.node.id, + metadata: stripTransient(draftItem.current.metadata), + }) + draftItem.current = null + + useScene.temporal.getState().pause() + createDraftItem() + checkCanPlace() + } + + const onCeilingLeave = (event: CeilingEvent) => { + if (!isOnCeiling.current) return + isOnCeiling.current = false + currentCeilingId = null + event.stopPropagation() + if (!draftItem.current) return + const currentLevelId = useViewer.getState().selection.levelId + draftItem.current.parentId = currentLevelId + useScene.getState().updateNode(draftItem.current.id, { + position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], + parentId: currentLevelId, + }) + checkCanPlace() + } + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('wall:enter', onWallEnter) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) emitter.on('wall:leave', onWallLeave) + emitter.on('ceiling:enter', onCeilingEnter) + emitter.on('ceiling:move', onCeilingMove) + emitter.on('ceiling:click', onCeilingClick) + emitter.on('ceiling:leave', onCeilingLeave) // Keyboard rotation handlers const ROTATION_STEP = Math.PI / 2 // 90 degrees @@ -417,12 +535,16 @@ export const ItemTool: React.FC = () => { emitter.off('wall:leave', onWallLeave) emitter.off('wall:click', onWallClick) emitter.off('wall:move', onWallMove) + emitter.off('ceiling:enter', onCeilingEnter) + emitter.off('ceiling:move', onCeilingMove) + emitter.off('ceiling:click', onCeilingClick) + emitter.off('ceiling:leave', onCeilingLeave) window.removeEventListener('keydown', onKeyDown) } }, [selectedItem, canPlaceOnFloor, canPlaceOnWall]) useFrame((_, delta) => { - if (draftItem.current && !isOnWall.current) { + if (draftItem.current && !isOnWall.current && !isOnCeiling.current) { const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id) if (draftItemMesh) { draftItemMesh.position.lerp(gridPosition.current, delta * 20) diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index cd2adbc6..e9dc14b6 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -1,10 +1,12 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { ItemNode } from './item' export const CeilingNode = BaseNode.extend({ id: objectId('ceiling'), type: nodeType('ceiling'), + children: z.array(ItemNode.shape.id).default([]), // Specific props // Polygon boundary - array of [x, z] coordinates defining the ceiling polygon: z.array(z.tuple([z.number(), z.number()])), diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 8c89beb3..27636de2 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -3,6 +3,7 @@ import { useRef } from 'react' import { faceDirection, float, mix } from 'three/tsl' import { DoubleSide, type Mesh, MeshStandardNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' +import { NodeRenderer } from '../node-renderer' // TSL material that renders differently based on face direction: // - Back face (looking up at ceiling from below): solid @@ -27,6 +28,9 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { {/* CeilingSystem will replace this geometry in the next frame */} + {node.children.map((childId) => ( + + ))} ) } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 0b1751f8..fc4cfaa1 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -32,8 +32,8 @@ const Viewer: React.FC = ({ children }) => { > - - + +