From 8646cc874802d3d8a05d3d9f4bc6662f4843c8ce Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 28 Jan 2026 07:47:47 +0900 Subject: [PATCH] refacto item-tool --- .../components/tools/item/item-tool.tsx | 650 +++++------------- .../components/tools/item/placement-math.ts | 86 +++ .../tools/item/placement-strategies.ts | 381 ++++++++++ .../components/tools/item/placement-types.ts | 110 +++ .../components/tools/item/use-draft-node.ts | 81 +++ .../ui/item-catalog/catalog-items.tsx | 2 +- .../core/src/systems/item/item-system.tsx | 12 +- 7 files changed, 856 insertions(+), 466 deletions(-) create mode 100644 apps/editor/components/tools/item/placement-math.ts create mode 100644 apps/editor/components/tools/item/placement-strategies.ts create mode 100644 apps/editor/components/tools/item/placement-types.ts create mode 100644 apps/editor/components/tools/item/use-draft-node.ts diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index 7d4f3f53..9707a2a6 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -1,489 +1,261 @@ import { type CeilingEvent, - type CeilingNode, emitter, type GridEvent, - ItemNode, - isObject, sceneRegistry, useScene, useSpatialQuery, type WallEvent, - type WallNode, } from '@pascal-app/core' - import { useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { BoxGeometry, type Mesh, type MeshStandardMaterial, Vector3 } from 'three' import useEditor from '@/store/use-editor' import { resolveLevelId } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync' - -/** - * Snaps a position to 0.5 grid, with an offset to align item edges to grid lines. - * For items with dimensions like 2.5, the center would be at 1.25 from the edge, - * which doesn't align with 0.5 grid. This adds an offset so edges align instead. - */ -function snapToGrid(position: number, dimension: number): number { - // Check if half the dimension has a 0.25 remainder (odd multiple of 0.5) - const halfDim = dimension / 2 - const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01 - const offset = needsOffset ? 0.25 : 0 - // Snap to 0.5 grid with offset - return Math.round((position - offset) * 2) / 2 + offset -} - -const stripTransient = (meta: any) => { - if (!isObject(meta)) return meta - const { isTransient, ...rest } = meta as Record - return rest -} - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/** - * Calculate cursor rotation in WORLD space from wall normal and orientation - */ -const calculateCursorRotation = ( - normal: [number, number, number] | undefined, - wallStart: [number, number], - wallEnd: [number, number], -): number => { - if (!normal) return 0 - - // Wall direction angle in world XZ plane - const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0]) - - // In local wall space, front face has normal.z < 0, back face has normal.z > 0 - if (normal[2] < 0) { - return -wallAngle - } else { - return Math.PI - wallAngle - } -} - -/** - * Calculate item rotation in WALL-LOCAL space from normal - * Items are children of the wall mesh, so their rotation is relative to wall's local space - */ -const calculateItemRotation = (normal: [number, number, number] | undefined): number => { - if (!normal) return 0 - - // In wall-local space: X along wall, Y up, Z perpendicular (thickness) - // Front face (normal.z < 0): item faces -Z local → rotation = 0 - // Back face (normal.z > 0): item faces +Z local → rotation = PI - return normal[2] < 0 ? 0 : Math.PI -} - -/** - * Determine which side of the wall based on the normal vector - * In wall-local space, the wall runs along X-axis, so the normal points along Z-axis - * Positive Z normal = 'back', Negative Z normal = 'front' (flipped due to orientation fix) - */ -const getSideFromNormal = (normal: [number, number, number] | undefined): 'front' | 'back' => { - if (!normal) return 'front' - // The Z component of the normal determines which side - // Flipped: positive Z = back, negative Z = front - return normal[2] >= 0 ? 'back' : 'front' -} - -/** - * Check if the normal indicates a valid wall side face (front or back) - * Filters out top face and thickness edges - * - * In wall-local geometry space (after ExtrudeGeometry + rotateX): - * - X axis: along wall direction - * - Y axis: up (height) - * - Z axis: perpendicular to wall (thickness direction) - * - * So valid side faces have normals pointing in ±Z direction (local space) - */ -const isValidWallSideFace = (normal: [number, number, number] | undefined): boolean => { - if (!normal) return false - - // Valid side faces have normals pointing in the local Z direction (perpendicular to wall) - // This filters out top faces (Y direction) and end caps/junctions (X direction) - return Math.abs(normal[2]) > 0.7 -} +import { + ceilingStrategy, + checkCanPlace, + floorStrategy, + wallStrategy, +} from './placement-strategies' +import type { PlacementState, TransitionResult } from './placement-types' +import { useDraftNode } from './use-draft-node' export const ItemTool: React.FC = () => { const cursorRef = useRef(null!) - const draftItem = useRef(null) const gridPosition = useRef(new Vector3(0, 0, 0)) + const placementState = useRef({ + surface: 'floor', + wallId: null, + ceilingId: null, + }) + const selectedItem = useEditor((state) => state.selectedItem) const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery() - const isOnWall = useRef(false) - const isOnCeiling = useRef(false) + const draftNode = useDraftNode() useEffect(() => { - if (!selectedItem) { - return + if (!selectedItem) return + + const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } + + // Reset placement state for new item + placementState.current = { surface: 'floor', wallId: null, ceilingId: null } + + // ---- Helpers ---- + + const getContext = () => ({ + asset: selectedItem, + levelId: useViewer.getState().selection.levelId, + draftItem: draftNode.current, + gridPosition: gridPosition.current, + state: { ...placementState.current }, + }) + + const revalidate = (): boolean => { + const placeable = checkCanPlace(getContext(), validators) + ;(cursorRef.current.material as MeshStandardMaterial).color.set( + placeable ? 'green' : 'red', + ) + return placeable } - let currentWallId: string | null = null - let currentCeilingId: string | null = null + const applyTransition = (result: TransitionResult) => { + Object.assign(placementState.current, result.stateUpdate) + gridPosition.current.set(...result.gridPosition) + cursorRef.current.position.set(...result.cursorPosition) + cursorRef.current.rotation.y = result.cursorRotationY - const checkCanPlace = () => { - const currentLevelId = useViewer.getState().selection.levelId - if (currentLevelId && draftItem.current) { - let placeable = true - if (draftItem.current.asset.attachTo === 'ceiling') { - if (!isOnCeiling.current || !currentCeilingId) { - placeable = false - } else { - const result = canPlaceOnCeiling( - currentCeilingId as CeilingNode['id'], - [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], - draftItem.current.asset.dimensions, - draftItem.current.rotation, - [draftItem.current.id], - ) - placeable = result.valid - } - } else if (draftItem.current.asset.attachTo) { - if (!isOnWall.current || !currentWallId) { - placeable = false - } else { - const result = canPlaceOnWall( - currentLevelId, - currentWallId as WallNode['id'], - gridPosition.current.x, - gridPosition.current.y, - draftItem.current.asset.dimensions, - draftItem.current.asset.attachTo as 'wall' | 'wall-side', - draftItem.current.side, - [draftItem.current.id], - ) - placeable = result.valid - } - } else { - placeable = canPlaceOnFloor( - currentLevelId, - [gridPosition.current.x, 0, gridPosition.current.z], - draftItem.current.asset.dimensions, - [0, 0, 0], - [draftItem.current.id], - ).valid - } - if (placeable) { - ;(cursorRef.current.material as MeshStandardMaterial).color.set('green') - return true - } else { - ;(cursorRef.current.material as MeshStandardMaterial).color.set('red') - return false - } + const draft = draftNode.current + if (draft) { + Object.assign(draft, result.nodeUpdate) + useScene.getState().updateNode(draft.id, result.nodeUpdate) } + revalidate() } - const createDraftItem = () => { - const currentLevelId = useViewer.getState().selection.levelId - if (!currentLevelId) { - return null - } - useScene.temporal.getState().pause() - draftItem.current = ItemNode.parse({ - position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], - name: selectedItem.name, - asset: selectedItem, - metadata: { - isTransient: true, - }, - }) - useScene.getState().createNode(draftItem.current, currentLevelId) - checkCanPlace() - } - createDraftItem() + + // ---- Create initial draft ---- + + draftNode.create(gridPosition.current, selectedItem) + revalidate() + + // ---- Floor Handlers ---- const onGridMove = (event: GridEvent) => { - if (!cursorRef.current) return + const result = floorStrategy.move(getContext(), event) + if (!result) return - if (isOnWall.current || isOnCeiling.current) return + gridPosition.current.set(...result.gridPosition) + cursorRef.current.position.set(...result.cursorPosition) - const [dimX, , dimZ] = selectedItem.dimensions - gridPosition.current.set( - snapToGrid(event.position[0], dimX), - 0, - snapToGrid(event.position[2], dimZ), - ) - cursorRef.current.position.set( - gridPosition.current.x, - event.position[1], - gridPosition.current.z, - ) - checkCanPlace() - if (draftItem.current) { - draftItem.current.position = [gridPosition.current.x, 0, gridPosition.current.z] - } + const draft = draftNode.current + if (draft) draft.position = result.gridPosition + + revalidate() } + const onGridClick = (event: GridEvent) => { - const currentLevelId = useViewer.getState().selection.levelId - if (isOnWall.current || isOnCeiling.current) return + const result = floorStrategy.click(getContext(), event, validators) + if (!result) return - if (!currentLevelId || !draftItem.current || !checkCanPlace()) return - - useScene.temporal.getState().resume() - - useScene.getState().updateNode(draftItem.current.id, { - position: [gridPosition.current.x, 0, gridPosition.current.z], - metadata: stripTransient(draftItem.current.metadata), - }) - draftItem.current = null - - useScene.temporal.getState().pause() - createDraftItem() + draftNode.commit(result.nodeUpdate) + draftNode.create(gridPosition.current, selectedItem) + revalidate() } + // ---- Wall Handlers ---- + const onWallEnter = (event: WallEvent) => { - if ( - useViewer.getState().selection.levelId !== - resolveLevelId(event.node, useScene.getState().nodes) - ) { - return - } - if ( - draftItem.current?.asset.attachTo === 'wall' || - draftItem.current?.asset.attachTo === 'wall-side' - ) { - console.log('Wall enter:', event.node.id, event.normal) - // Ignore top face and thickness edges - if (!isValidWallSideFace(event.normal)) return - - event.stopPropagation() - isOnWall.current = true - currentWallId = event.node.id - - // Determine side and rotation from normal - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) - - gridPosition.current.set( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[1] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, - ) - draftItem.current.parentId = event.node.id - draftItem.current.side = side - draftItem.current.rotation = [0, itemRotation, 0] - - useScene.getState().updateNode(draftItem.current.id, { - position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], - parentId: event.node.id, - side, - rotation: [0, itemRotation, 0], - }) - cursorRef.current.rotation.y = cursorRotation - checkCanPlace() - } - } - - const onWallLeave = (event: WallEvent) => { - if (!isOnWall.current) return - isOnWall.current = false - currentWallId = 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() - } - - const onWallClick = (event: WallEvent) => { - if (!isOnWall.current) return - - // Ignore top face and thickness edges - if (!isValidWallSideFace(event.normal)) return + const nodes = useScene.getState().nodes + const result = wallStrategy.enter(getContext(), event, resolveLevelId, nodes) + if (!result) return event.stopPropagation() - - const currentLevelId = useViewer.getState().selection.levelId - if (!currentLevelId || !draftItem.current || !checkCanPlace()) return - - // Get side and rotation from current draft item (already set by onWallMove) - const side = draftItem.current.side - const rotation = draftItem.current.rotation - - useScene.temporal.getState().resume() - useScene.getState().updateNode(draftItem.current.id, { - position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], - parentId: event.node.id, - side, - rotation, - metadata: stripTransient(draftItem.current.metadata), - }) - useScene.getState().dirtyNodes.add(event.node.id) - draftItem.current = null - - useScene.temporal.getState().pause() - createDraftItem() - checkCanPlace() + applyTransition(result) } const onWallMove = (event: WallEvent) => { - if (isOnWall.current === false) return - if (!draftItem.current) return - - // Ignore top face and thickness edges - if (!isValidWallSideFace(event.normal)) return + const result = wallStrategy.move(getContext(), event) + if (!result) return event.stopPropagation() + gridPosition.current.set(...result.gridPosition) + cursorRef.current.position.set(...result.cursorPosition) + cursorRef.current.rotation.y = result.cursorRotationY - // Determine side and rotation from normal - const side = getSideFromNormal(event.normal) - const itemRotation = calculateItemRotation(event.normal) - const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + // Sync side/rotation on draft ref (needed by checkCanPlace) + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('side' in result.nodeUpdate) draft.side = result.nodeUpdate.side + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + } - gridPosition.current.set( - Math.round(event.localPosition[0] * 2) / 2, - Math.round(event.localPosition[1] * 2) / 2, - Math.round(event.localPosition[2] * 2) / 2, - ) - cursorRef.current.position.set( - Math.round(event.position[0] * 2) / 2, - Math.round(event.position[1] * 2) / 2, - Math.round(event.position[2] * 2) / 2, - ) - cursorRef.current.rotation.y = cursorRotation + const placeable = revalidate() - // Update draft item side and rotation - draftItem.current.side = side - draftItem.current.rotation = [0, itemRotation, 0] - - const canPlace = checkCanPlace() - if (draftItem.current && canPlace) { - 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) - draftItemMesh.rotation.y = itemRotation + // Only update mesh + store when placement is valid + if (draft && placeable) { + draft.position = result.gridPosition + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.copy(gridPosition.current) + const rot = result.nodeUpdate?.rotation + if (rot) mesh.rotation.y = rot[1] + } + if (result.nodeUpdate) { + useScene.getState().updateNode(draft.id, result.nodeUpdate) + } + if (result.dirtyNodeId) { + useScene.getState().dirtyNodes.add(result.dirtyNodeId) } - - useScene.getState().updateNode(draftItem.current.id, { - side, - rotation: [0, itemRotation, 0], - }) - useScene.getState().dirtyNodes.add(event.node.id) } } - // ==================================================================== - // 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 - } + const onWallClick = (event: WallEvent) => { + const result = wallStrategy.click(getContext(), event, validators) + if (!result) return event.stopPropagation() - isOnCeiling.current = true - currentCeilingId = event.node.id + draftNode.commit(result.nodeUpdate) + if (result.dirtyNodeId) { + useScene.getState().dirtyNodes.add(result.dirtyNodeId) + } + draftNode.create(gridPosition.current, selectedItem) + revalidate() + } - const [dimX, , dimZ] = selectedItem.dimensions - const itemHeight = selectedItem.dimensions[1] + const onWallLeave = (event: WallEvent) => { + const result = wallStrategy.leave(getContext()) + if (!result) return - 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, - ) + event.stopPropagation() + applyTransition(result) + } - draftItem.current.parentId = event.node.id + // ---- Ceiling Handlers ---- - useScene.getState().updateNode(draftItem.current.id, { - position: [gridPosition.current.x, gridPosition.current.y, gridPosition.current.z], - parentId: event.node.id, - }) - checkCanPlace() + const onCeilingEnter = (event: CeilingEvent) => { + const nodes = useScene.getState().nodes + const result = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) + if (!result) return + + event.stopPropagation() + applyTransition(result) } const onCeilingMove = (event: CeilingEvent) => { - if (!isOnCeiling.current || !draftItem.current) return + const result = ceilingStrategy.move(getContext(), event) + if (!result) return event.stopPropagation() + gridPosition.current.set(...result.gridPosition) + cursorRef.current.position.set(...result.cursorPosition) - const [dimX, , dimZ] = selectedItem.dimensions - const itemHeight = selectedItem.dimensions[1] + revalidate() - 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 draft = draftNode.current + if (draft) { + draft.position = result.gridPosition + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) mesh.position.copy(gridPosition.current) } } const onCeilingClick = (event: CeilingEvent) => { - if (!isOnCeiling.current) return + const result = ceilingStrategy.click(getContext(), event, validators) + if (!result) 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() + draftNode.commit(result.nodeUpdate) + draftNode.create(gridPosition.current, selectedItem) + revalidate() } const onCeilingLeave = (event: CeilingEvent) => { - if (!isOnCeiling.current) return - isOnCeiling.current = false - currentCeilingId = null + const result = ceilingStrategy.leave(getContext()) + if (!result) return + 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() + applyTransition(result) } + // ---- Keyboard rotation ---- + + const ROTATION_STEP = Math.PI / 2 + const onKeyDown = (event: KeyboardEvent) => { + const draft = draftNode.current + if (!draft) return + + let rotationDelta = 0 + if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP + else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP + + if (rotationDelta !== 0) { + event.preventDefault() + const currentRotation = draft.rotation + const newRotationY = (currentRotation[1] ?? 0) + rotationDelta + draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]] + + useScene.getState().updateNode(draft.id, { rotation: draft.rotation }) + cursorRef.current.rotation.y = newRotationY + revalidate() + } + } + window.addEventListener('keydown', onKeyDown) + + // ---- Bounding box geometry ---- + + const boxGeometry = new BoxGeometry( + selectedItem.dimensions[0], + selectedItem.dimensions[1], + selectedItem.dimensions[2], + ) + boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0) + cursorRef.current.geometry = boxGeometry + + // ---- Subscribe ---- + emitter.on('grid:move', onGridMove) emitter.on('grid:click', onGridClick) emitter.on('wall:enter', onWallEnter) @@ -495,71 +267,27 @@ export const ItemTool: React.FC = () => { emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) - // Keyboard rotation handlers - const ROTATION_STEP = Math.PI / 2 // 90 degrees - const onKeyDown = (event: KeyboardEvent) => { - if (!draftItem.current) return - - let rotationDelta = 0 - if (event.key === 'r' || event.key === 'R') { - rotationDelta = ROTATION_STEP // Counter-clockwise - } else if (event.key === 't' || event.key === 'T') { - rotationDelta = -ROTATION_STEP // Clockwise - } - - if (rotationDelta !== 0) { - event.preventDefault() - const currentRotation = draftItem.current.rotation - const newRotationY = (currentRotation[1] ?? 0) + rotationDelta - draftItem.current.rotation = [currentRotation[0], newRotationY, currentRotation[2]] - - useScene.getState().updateNode(draftItem.current.id, { - rotation: draftItem.current.rotation, - }) - - // Update cursor rotation to match - cursorRef.current.rotation.y = newRotationY - - checkCanPlace() - } - } - window.addEventListener('keydown', onKeyDown) - - const setupBoundingBox = () => { - const boxGeometry = new BoxGeometry( - selectedItem.dimensions[0], - selectedItem.dimensions[1], - selectedItem.dimensions[2], - ) - boxGeometry.translate(0, selectedItem.dimensions[1] / 2, 0) - cursorRef.current.geometry = boxGeometry - } - setupBoundingBox() - return () => { - if (draftItem.current) { - useScene.getState().deleteNode(draftItem.current.id) - } - useScene.temporal.getState().resume() + draftNode.destroy() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('wall:enter', onWallEnter) - emitter.off('wall:leave', onWallLeave) - emitter.off('wall:click', onWallClick) emitter.off('wall:move', onWallMove) + emitter.off('wall:click', onWallClick) + emitter.off('wall:leave', onWallLeave) 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, canPlaceOnCeiling]) + }, [selectedItem, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode]) useFrame((_, delta) => { - if (draftItem.current && !isOnWall.current && !isOnCeiling.current) { - const draftItemMesh = sceneRegistry.nodes.get(draftItem.current.id) - if (draftItemMesh) { - draftItemMesh.position.lerp(gridPosition.current, delta * 20) + if (draftNode.current && placementState.current.surface === 'floor') { + const mesh = sceneRegistry.nodes.get(draftNode.current.id) + if (mesh) { + mesh.position.lerp(gridPosition.current, delta * 20) } } }) diff --git a/apps/editor/components/tools/item/placement-math.ts b/apps/editor/components/tools/item/placement-math.ts new file mode 100644 index 00000000..ee118a08 --- /dev/null +++ b/apps/editor/components/tools/item/placement-math.ts @@ -0,0 +1,86 @@ +import { isObject } from '@pascal-app/core' + +/** + * Snaps a position to 0.5 grid, with an offset to align item edges to grid lines. + * For items with dimensions like 2.5, the center would be at 1.25 from the edge, + * which doesn't align with 0.5 grid. This adds an offset so edges align instead. + */ +export function snapToGrid(position: number, dimension: number): number { + const halfDim = dimension / 2 + const needsOffset = Math.abs(((halfDim * 2) % 1) - 0.5) < 0.01 + const offset = needsOffset ? 0.25 : 0 + return Math.round((position - offset) * 2) / 2 + offset +} + +/** + * Snap a value to 0.5 increments (used for wall-local positions). + */ +export function snapToHalf(value: number): number { + return Math.round(value * 2) / 2 +} + +/** + * Calculate cursor rotation in WORLD space from wall normal and orientation. + */ +export function calculateCursorRotation( + normal: [number, number, number] | undefined, + wallStart: [number, number], + wallEnd: [number, number], +): number { + if (!normal) return 0 + + // Wall direction angle in world XZ plane + const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0]) + + // In local wall space, front face has normal.z < 0, back face has normal.z > 0 + if (normal[2] < 0) { + return -wallAngle + } else { + return Math.PI - wallAngle + } +} + +/** + * Calculate item rotation in WALL-LOCAL space from normal. + * Items are children of the wall mesh, so their rotation is relative to wall's local space. + */ +export function calculateItemRotation(normal: [number, number, number] | undefined): number { + if (!normal) return 0 + + return normal[2] > 0 ? 0 : Math.PI +} + +/** + * Determine which side of the wall based on the normal vector. + * In wall-local space, the wall runs along X-axis, so the normal points along Z-axis. + * Positive Z normal = 'front', Negative Z normal = 'back' + */ +export function getSideFromNormal(normal: [number, number, number] | undefined): 'front' | 'back' { + if (!normal) return 'front' + return normal[2] >= 0 ? 'front' : 'back' +} + +/** + * Check if the normal indicates a valid wall side face (front or back). + * Filters out top face and thickness edges. + * + * In wall-local geometry space (after ExtrudeGeometry + rotateX): + * - X axis: along wall direction + * - Y axis: up (height) + * - Z axis: perpendicular to wall (thickness direction) + * + * So valid side faces have normals pointing in ±Z direction (local space). + */ +export function isValidWallSideFace(normal: [number, number, number] | undefined): boolean { + if (!normal) return false + return Math.abs(normal[2]) > 0.7 +} + +/** + * Strip the `isTransient` flag from node metadata before committing. + */ +export function stripTransient(meta: any): any { + if (!isObject(meta)) return meta + const { isTransient, ...rest } = meta as Record + return rest +} diff --git a/apps/editor/components/tools/item/placement-strategies.ts b/apps/editor/components/tools/item/placement-strategies.ts new file mode 100644 index 00000000..e11025fc --- /dev/null +++ b/apps/editor/components/tools/item/placement-strategies.ts @@ -0,0 +1,381 @@ +import type { + AnyNode, + CeilingEvent, + CeilingNode, + GridEvent, + WallEvent, + WallNode, +} from '@pascal-app/core' +import type { + CommitResult, + LevelResolver, + PlacementContext, + PlacementResult, + SpatialValidators, + TransitionResult, +} from './placement-types' +import { + calculateCursorRotation, + calculateItemRotation, + getSideFromNormal, + isValidWallSideFace, + snapToGrid, + snapToHalf, + stripTransient, +} from './placement-math' + +// ============================================================================ +// FLOOR STRATEGY +// ============================================================================ + +export const floorStrategy = { + /** + * Handle grid:move — update position when on floor surface. + * Returns null if currently on wall/ceiling. + */ + move(ctx: PlacementContext, event: GridEvent): PlacementResult | null { + if (ctx.state.surface !== 'floor') return null + + const [dimX, , dimZ] = ctx.asset.dimensions + const x = snapToGrid(event.position[0], dimX) + const z = snapToGrid(event.position[2], dimZ) + + return { + gridPosition: [x, 0, z], + cursorPosition: [x, event.position[1], z], + cursorRotationY: 0, + nodeUpdate: { position: [x, 0, z] }, + stopPropagation: false, + dirtyNodeId: null, + } + }, + + /** + * Handle grid:click — commit placement on floor. + * Returns null if on wall/ceiling or validation fails. + */ + click(ctx: PlacementContext, _event: GridEvent, validators: SpatialValidators): CommitResult | null { + if (ctx.state.surface !== 'floor') return null + if (!ctx.levelId || !ctx.draftItem) return null + + const pos: [number, number, number] = [ctx.gridPosition.x, 0, ctx.gridPosition.z] + const valid = validators.canPlaceOnFloor( + ctx.levelId, + pos, + ctx.draftItem.asset.dimensions, + [0, 0, 0], + [ctx.draftItem.id], + ).valid + + if (!valid) return null + + return { + nodeUpdate: { + position: pos, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: false, + dirtyNodeId: null, + } + }, +} + +// ============================================================================ +// WALL STRATEGY +// ============================================================================ + +export const wallStrategy = { + /** + * Handle wall:enter — transition from floor to wall surface. + * Returns null if item doesn't attach to walls, face is invalid, or wrong level. + */ + enter( + ctx: PlacementContext, + event: WallEvent, + resolveLevelId: LevelResolver, + nodes: Record, + ): TransitionResult | null { + const attachTo = ctx.draftItem?.asset.attachTo + if (attachTo !== 'wall' && attachTo !== 'wall-side') return null + if (!isValidWallSideFace(event.normal)) return null + + // Level guard + const wallLevelId = resolveLevelId(event.node, nodes) + if (ctx.levelId !== wallLevelId) return null + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + + const x = snapToHalf(event.localPosition[0]) + const y = snapToHalf(event.localPosition[1]) + const z = snapToHalf(event.localPosition[2]) + + return { + stateUpdate: { surface: 'wall', wallId: event.node.id }, + nodeUpdate: { + position: [x, y, z], + parentId: event.node.id, + side, + rotation: [0, itemRotation, 0], + }, + cursorRotationY: cursorRotation, + gridPosition: [x, y, z], + cursorPosition: [x, y, z], + stopPropagation: true, + } + }, + + /** + * Handle wall:move — update position while on wall. + * Returns null if not on a wall or face is invalid. + */ + move(ctx: PlacementContext, event: WallEvent): PlacementResult | null { + if (ctx.state.surface !== 'wall') return null + if (!ctx.draftItem) return null + if (!isValidWallSideFace(event.normal)) return null + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + + return { + gridPosition: [ + snapToHalf(event.localPosition[0]), + snapToHalf(event.localPosition[1]), + snapToHalf(event.localPosition[2]), + ], + cursorPosition: [ + snapToHalf(event.position[0]), + snapToHalf(event.position[1]), + snapToHalf(event.position[2]), + ], + cursorRotationY: cursorRotation, + nodeUpdate: { + side, + rotation: [0, itemRotation, 0], + }, + stopPropagation: true, + dirtyNodeId: event.node.id, + } + }, + + /** + * Handle wall:click — commit placement on wall. + * Returns null if not on wall, face invalid, or validation fails. + */ + click(ctx: PlacementContext, event: WallEvent, validators: SpatialValidators): CommitResult | null { + if (ctx.state.surface !== 'wall') return null + if (!isValidWallSideFace(event.normal)) return null + if (!ctx.levelId || !ctx.draftItem) return null + + const valid = validators.canPlaceOnWall( + ctx.levelId, + ctx.state.wallId as WallNode['id'], + ctx.gridPosition.x, + ctx.gridPosition.y, + ctx.draftItem.asset.dimensions, + ctx.draftItem.asset.attachTo as 'wall' | 'wall-side', + ctx.draftItem.side, + [ctx.draftItem.id], + ).valid + + if (!valid) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: event.node.id, + side: ctx.draftItem.side, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: event.node.id, + } + }, + + /** + * Handle wall:leave — transition back to floor surface. + */ + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'wall') return null + + return { + stateUpdate: { surface: 'floor', wallId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + }, + cursorRotationY: 0, + gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + +// ============================================================================ +// CEILING STRATEGY +// ============================================================================ + +export const ceilingStrategy = { + /** + * Handle ceiling:enter — transition from floor to ceiling surface. + * Returns null if item doesn't attach to ceilings or wrong level. + */ + enter( + ctx: PlacementContext, + event: CeilingEvent, + resolveLevelId: LevelResolver, + nodes: Record, + ): TransitionResult | null { + if (ctx.draftItem?.asset.attachTo !== 'ceiling') return null + + // Level guard + const ceilingLevelId = resolveLevelId(event.node, nodes) + if (ctx.levelId !== ceilingLevelId) return null + + const [dimX, , dimZ] = ctx.asset.dimensions + const itemHeight = ctx.asset.dimensions[1] + + const x = snapToGrid(event.position[0], dimX) + const z = snapToGrid(event.position[2], dimZ) + + return { + stateUpdate: { surface: 'ceiling', ceilingId: event.node.id }, + nodeUpdate: { + position: [x, -itemHeight, z], + parentId: event.node.id, + }, + cursorRotationY: 0, + gridPosition: [x, -itemHeight, z], + cursorPosition: [x, event.position[1] - itemHeight, z], + stopPropagation: true, + } + }, + + /** + * Handle ceiling:move — update position while on ceiling. + */ + move(ctx: PlacementContext, event: CeilingEvent): PlacementResult | null { + if (ctx.state.surface !== 'ceiling') return null + if (!ctx.draftItem) return null + + const [dimX, , dimZ] = ctx.asset.dimensions + const itemHeight = ctx.asset.dimensions[1] + + const x = snapToGrid(event.position[0], dimX) + const z = snapToGrid(event.position[2], dimZ) + + return { + gridPosition: [x, -itemHeight, z], + cursorPosition: [x, event.position[1] - itemHeight, z], + cursorRotationY: 0, + nodeUpdate: null, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + /** + * Handle ceiling:click — commit placement on ceiling. + */ + click(ctx: PlacementContext, event: CeilingEvent, validators: SpatialValidators): CommitResult | null { + if (ctx.state.surface !== 'ceiling') return null + if (!ctx.draftItem) return null + + const pos: [number, number, number] = [ + ctx.gridPosition.x, + ctx.gridPosition.y, + ctx.gridPosition.z, + ] + + const valid = validators.canPlaceOnCeiling( + ctx.state.ceilingId as CeilingNode['id'], + pos, + ctx.draftItem.asset.dimensions, + ctx.draftItem.rotation, + [ctx.draftItem.id], + ).valid + + if (!valid) return null + + return { + nodeUpdate: { + position: pos, + parentId: event.node.id, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + /** + * Handle ceiling:leave — transition back to floor surface. + */ + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'ceiling') return null + + return { + stateUpdate: { surface: 'floor', ceilingId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + }, + cursorRotationY: 0, + gridPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + +// ============================================================================ +// VALIDATION +// ============================================================================ + +/** + * Unified validation: check if the current draft item can be placed at its current position. + * Switches on the active surface type and calls the appropriate spatial validator. + */ +export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidators): boolean { + if (!ctx.levelId || !ctx.draftItem) return false + + const attachTo = ctx.draftItem.asset.attachTo + + if (attachTo === 'ceiling') { + if (ctx.state.surface !== 'ceiling' || !ctx.state.ceilingId) return false + return validators.canPlaceOnCeiling( + ctx.state.ceilingId as CeilingNode['id'], + [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + ctx.draftItem.asset.dimensions, + ctx.draftItem.rotation, + [ctx.draftItem.id], + ).valid + } + + if (attachTo === 'wall' || attachTo === 'wall-side') { + if (ctx.state.surface !== 'wall' || !ctx.state.wallId) return false + return validators.canPlaceOnWall( + ctx.levelId, + ctx.state.wallId as WallNode['id'], + ctx.gridPosition.x, + ctx.gridPosition.y, + ctx.draftItem.asset.dimensions, + attachTo, + ctx.draftItem.side, + [ctx.draftItem.id], + ).valid + } + + // Floor (no attachTo) + return validators.canPlaceOnFloor( + ctx.levelId, + [ctx.gridPosition.x, 0, ctx.gridPosition.z], + ctx.draftItem.asset.dimensions, + [0, 0, 0], + [ctx.draftItem.id], + ).valid +} diff --git a/apps/editor/components/tools/item/placement-types.ts b/apps/editor/components/tools/item/placement-types.ts new file mode 100644 index 00000000..a0e7e806 --- /dev/null +++ b/apps/editor/components/tools/item/placement-types.ts @@ -0,0 +1,110 @@ +import type { AnyNode, CeilingNode, ItemNode, LevelNode, WallNode } from '@pascal-app/core' +import type { Vector3 } from 'three' +import type { Asset } from '../../../../../packages/core/src/schema/nodes/item' + +// ============================================================================ +// PLACEMENT STATE +// ============================================================================ + +export type SurfaceType = 'floor' | 'wall' | 'ceiling' + +/** + * Tracks which surface the draft item is currently on. + * Replaces the scattered isOnWall, isOnCeiling refs and currentWallId, currentCeilingId variables. + */ +export interface PlacementState { + surface: SurfaceType + wallId: string | null + ceilingId: string | null +} + +// ============================================================================ +// STRATEGY CONTEXT +// ============================================================================ + +/** + * Read-only snapshot passed to every strategy call. + */ +export interface PlacementContext { + asset: Asset + levelId: LevelNode['id'] | null + draftItem: ItemNode | null + gridPosition: Vector3 + state: PlacementState +} + +// ============================================================================ +// STRATEGY RESULTS +// ============================================================================ + +/** + * Returned by strategy move handlers. + */ +export interface PlacementResult { + gridPosition: [number, number, number] + cursorPosition: [number, number, number] + cursorRotationY: number + nodeUpdate: Partial | null + stopPropagation: boolean + dirtyNodeId: AnyNode['id'] | null +} + +/** + * Returned by enter/leave handlers (surface transitions). + */ +export interface TransitionResult { + stateUpdate: Partial + nodeUpdate: Partial + gridPosition: [number, number, number] + cursorPosition: [number, number, number] + cursorRotationY: number + stopPropagation: boolean +} + +/** + * Returned by click handlers (commit placement). + */ +export interface CommitResult { + nodeUpdate: Partial + stopPropagation: boolean + dirtyNodeId: AnyNode['id'] | null +} + +// ============================================================================ +// SPATIAL VALIDATORS +// ============================================================================ + +/** + * Type for the useSpatialQuery() return value. + */ +export interface SpatialValidators { + canPlaceOnFloor: ( + levelId: LevelNode['id'], + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds?: string[], + ) => { valid: boolean } + canPlaceOnWall: ( + levelId: LevelNode['id'], + wallId: WallNode['id'], + localX: number, + localY: number, + dimensions: [number, number, number], + attachType: 'wall' | 'wall-side', + side?: 'front' | 'back', + ignoreIds?: string[], + ) => { valid: boolean } + canPlaceOnCeiling: ( + ceilingId: CeilingNode['id'], + position: [number, number, number], + dimensions: [number, number, number], + rotation: [number, number, number], + ignoreIds?: string[], + ) => { valid: boolean } +} + +/** + * Resolver function type for finding a node's level. + */ +export type LevelResolver = (node: AnyNode, nodes: Record) => string diff --git a/apps/editor/components/tools/item/use-draft-node.ts b/apps/editor/components/tools/item/use-draft-node.ts new file mode 100644 index 00000000..33bc0b9e --- /dev/null +++ b/apps/editor/components/tools/item/use-draft-node.ts @@ -0,0 +1,81 @@ +import { useCallback, useMemo, useRef } from 'react' +import { ItemNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import type { Vector3 } from 'three' +import type { Asset } from '../../../../../packages/core/src/schema/nodes/item' +import { stripTransient } from './placement-math' + +export interface DraftNodeHandle { + /** Current draft item, or null */ + readonly current: ItemNode | null + /** Create a new draft item at the given position. Returns the created node or null. */ + create: (gridPosition: Vector3, asset: Asset) => ItemNode | null + /** Commit the current draft: resume temporal, strip transient, update node, clear ref. Returns committed ID. */ + commit: (finalUpdate: Partial) => string | null + /** Destroy the current draft: delete node, resume temporal. */ + destroy: () => void +} + +/** + * Hook that manages the lifecycle of a transient (draft) item node. + * Handles temporal pause/resume for undo/redo isolation. + */ +export function useDraftNode(): DraftNodeHandle { + const draftRef = useRef(null) + + const create = useCallback((gridPosition: Vector3, asset: Asset): ItemNode | null => { + const currentLevelId = useViewer.getState().selection.levelId + if (!currentLevelId) return null + + useScene.temporal.getState().pause() + + const node = ItemNode.parse({ + position: [gridPosition.x, gridPosition.y, gridPosition.z], + name: asset.name, + asset, + metadata: { isTransient: true }, + }) + + useScene.getState().createNode(node, currentLevelId) + draftRef.current = node + return node + }, []) + + const commit = useCallback((finalUpdate: Partial): string | null => { + const draft = draftRef.current + if (!draft) return null + + useScene.temporal.getState().resume() + + const update = { + ...finalUpdate, + metadata: finalUpdate.metadata ?? stripTransient(draft.metadata), + } + + useScene.getState().updateNode(draft.id, update) + const committedId = draft.id + draftRef.current = null + + return committedId + }, []) + + const destroy = useCallback(() => { + if (draftRef.current) { + useScene.getState().deleteNode(draftRef.current.id) + draftRef.current = null + } + useScene.temporal.getState().resume() + }, []) + + return useMemo( + () => ({ + get current() { + return draftRef.current + }, + create, + commit, + destroy, + }), + [create, commit, destroy], + ) +} diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx index 7724ed28..743f0c9a 100644 --- a/apps/editor/components/ui/item-catalog/catalog-items.tsx +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -1079,7 +1079,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ 0.5, 0.5000000000000003 ], - "attachTo": "wall" + "attachTo": "wall-side" }, { diff --git a/packages/core/src/systems/item/item-system.tsx b/packages/core/src/systems/item/item-system.tsx index 4722da9b..bc92953e 100644 --- a/packages/core/src/systems/item/item-system.tsx +++ b/packages/core/src/systems/item/item-system.tsx @@ -1,5 +1,5 @@ import { useFrame } from '@react-three/fiber' -import * as THREE from 'three' +import type * as THREE from 'three' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' @@ -24,18 +24,22 @@ export const ItemSystem = () => { const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D if (!mesh) return - if (item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side') { + if (item.asset.attachTo === 'wall-side') { // Wall-attached item: offset Z by half the parent wall's thickness const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined if (parentWall && parentWall.type === 'wall') { const wallThickness = (parentWall as WallNode).thickness ?? 0.1 - mesh.position.z = wallThickness / 2 + const side = item.side === 'front' ? 1 : -1 + 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, + levelId, + item.position, + item.asset.dimensions, + item.rotation, ) mesh.position.y = slabElevation }