diff --git a/apps/editor/components/tools/item/move-tool.tsx b/apps/editor/components/tools/item/move-tool.tsx index 2a22802b..7d73c926 100644 --- a/apps/editor/components/tools/item/move-tool.tsx +++ b/apps/editor/components/tools/item/move-tool.tsx @@ -1,6 +1,8 @@ +import type { ItemNode, WindowNode } from '@pascal-app/core' import { Vector3 } from 'three' import { sfxEmitter } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' +import { MoveWindowTool } from '../window/move-window-tool' import type { PlacementState } from './placement-types' import { useDraftNode } from './use-draft-node' import { usePlacementCoordinator } from './use-placement-coordinator' @@ -19,34 +21,50 @@ function getInitialState(node: { return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } } -export const MoveTool: React.FC = () => { - const movingNode = useEditor((state) => state.movingNode) +function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { const draftNode = useDraftNode() - const exitMoveMode = () => { - useEditor.getState().setMovingNode(null) - } + const meta = (typeof movingNode.metadata === 'object' && movingNode.metadata !== null) + ? movingNode.metadata as Record + : {} + const isNew = !!meta.isNew const cursor = usePlacementCoordinator({ - asset: movingNode!.asset, + asset: movingNode.asset, draftNode, - initialState: movingNode ? getInitialState(movingNode) : undefined, + // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft + initialState: isNew ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } : getInitialState(movingNode), initDraft: (gridPosition) => { - if (!movingNode) return - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) + if (isNew) { + // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. + // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. + gridPosition.copy(new Vector3(...movingNode.position)) + if (!movingNode.asset.attachTo) { + draftNode.create(gridPosition, movingNode.asset, movingNode.rotation) + } + } else { + draftNode.adopt(movingNode) + gridPosition.copy(new Vector3(...movingNode.position)) + } }, onCommitted: () => { sfxEmitter.emit('sfx:item-place') - exitMoveMode() + useEditor.getState().setMovingNode(null) return false }, onCancel: () => { draftNode.destroy() - exitMoveMode() + useEditor.getState().setMovingNode(null) }, }) - if (!movingNode) return null return <>{cursor} } + +export const MoveTool: React.FC = () => { + const movingNode = useEditor((state) => state.movingNode) + + if (!movingNode) return null + if (movingNode.type === 'window') return + return +} diff --git a/apps/editor/components/tools/item/placement-strategies.ts b/apps/editor/components/tools/item/placement-strategies.ts index cc7d7c8e..08b782ef 100644 --- a/apps/editor/components/tools/item/placement-strategies.ts +++ b/apps/editor/components/tools/item/placement-strategies.ts @@ -80,6 +80,7 @@ export const floorStrategy = { return { nodeUpdate: { position: pos, + parentId: ctx.levelId, metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: false, diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index 27b136be..7f22a1ee 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -693,6 +693,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } }, [asset, canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling, draftNode]) + // Reparent floor draft to the new level when the user switches levels mid-placement. + // Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent). + const viewerLevelId = useViewer((s) => s.selection.levelId) + useEffect(() => { + const draft = draftNode.current + if (!draft || !viewerLevelId || asset.attachTo) return + if (draft.parentId === viewerLevelId) return + draft.parentId = viewerLevelId + useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId }) + }, [viewerLevelId, draftNode, asset]) + useFrame((_, delta) => { if (!draftNode.current) return const mesh = sceneRegistry.nodes.get(draftNode.current.id) @@ -724,7 +735,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.current.rotation, ) mesh.position.y = slabElevation - cursorGroupRef.current.position.y = slabElevation + // Cursor group is at the world root (not inside a level group), so add the + // level group's current world Y to convert from level-local to world space. + const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId) + cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0) } } }) diff --git a/apps/editor/components/tools/window/move-window-tool.tsx b/apps/editor/components/tools/window/move-window-tool.tsx new file mode 100644 index 00000000..be2d343f --- /dev/null +++ b/apps/editor/components/tools/window/move-window-tool.tsx @@ -0,0 +1,354 @@ +import { + type AnyNodeId, + emitter, + sceneRegistry, + useScene, + type WallEvent, + WindowNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef } from 'react' +import { BoxGeometry, EdgesGeometry, type Group } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { sfxEmitter } from '@/lib/sfx-bus' +import useEditor from '@/store/use-editor' +import { + calculateCursorRotation, + calculateItemRotation, + getSideFromNormal, + isValidWallSideFace, + snapToHalf, +} from '../item/placement-math' +import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' + +const edgeMaterial = new LineBasicNodeMaterial({ + color: 0xef4444, + linewidth: 3, + depthTest: false, + depthWrite: false, +}) + +/** + * Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool. + * + * Move mode (metadata.isNew falsy): + * Adopts the existing window, pauses temporal. On commit: restores original state + * (clean undo baseline) then resumes + updateNode (undo reverts to original position). + * On cancel: restores original state. + * + * Duplicate mode (metadata.isNew = true): + * The node is a freshly created transient copy. On commit: deletes transient + resumes + * + createNode (undo removes the new window entirely). On cancel: deletes the node. + */ +export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => { + const cursorGroupRef = useRef(null!) + + const exitMoveMode = () => { + useEditor.getState().setMovingNode(null) + } + + useEffect(() => { + useScene.temporal.getState().pause() + + const meta = (typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null) + ? movingWindowNode.metadata as Record + : {} + const isNew = !!meta.isNew + + // Save original state (only used in move mode) + const original = { + position: [...movingWindowNode.position] as [number, number, number], + rotation: [...movingWindowNode.rotation] as [number, number, number], + side: movingWindowNode.side, + parentId: movingWindowNode.parentId, + wallId: movingWindowNode.wallId, + metadata: movingWindowNode.metadata, + } + + if (!isNew) { + // Move mode: mark the existing window as transient so it hides while being repositioned + useScene.getState().updateNode(movingWindowNode.id, { + metadata: { ...meta, isTransient: true }, + }) + } + + let currentWallId: string | null = movingWindowNode.parentId + + const markWallDirty = (wallId: string | null) => { + if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) + } + + const getLevelYOffset = () => { + const id = useViewer.getState().selection.levelId + return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 + } + + const hideCursor = () => { + if (cursorGroupRef.current) cursorGroupRef.current.visible = false + } + + const updateCursor = ( + worldPosition: [number, number, number], + cursorRotationY: number, + valid: boolean, + ) => { + const group = cursorGroupRef.current + if (!group) return + group.visible = true + group.position.set(...worldPosition) + group.rotation.y = cursorRotationY + edgeMaterial.color.setHex(valid ? 0x22c55e : 0xef4444) + } + + const onWallEnter = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, localY, + movingWindowNode.width, movingWindowNode.height, + ) + + const prevWallId = currentWallId + currentWallId = event.node.id + + useScene.getState().updateNode(movingWindowNode.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) + + if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId) + markWallDirty(event.node.id) + + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, + movingWindowNode.width, movingWindowNode.height, + movingWindowNode.id, + ) + + updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid) + event.stopPropagation() + } + + const onWallMove = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, localY, + movingWindowNode.width, movingWindowNode.height, + ) + + useScene.getState().updateNode(movingWindowNode.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) + + if (currentWallId !== event.node.id) { + markWallDirty(currentWallId) + currentWallId = event.node.id + } + markWallDirty(event.node.id) + + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, + movingWindowNode.width, movingWindowNode.height, + movingWindowNode.id, + ) + + updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + + const localX = snapToHalf(event.localPosition[0]) + const localY = snapToHalf(event.localPosition[1]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, localY, + movingWindowNode.width, movingWindowNode.height, + ) + + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, + movingWindowNode.width, movingWindowNode.height, + movingWindowNode.id, + ) + if (!valid) return + + let placedId: string + + if (isNew) { + // Duplicate mode: delete transient + resume + createNode + // Undo will remove the newly created node entirely + useScene.getState().deleteNode(movingWindowNode.id) + useScene.temporal.getState().resume() + + const node = WindowNode.parse({ + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + wallId: event.node.id, + parentId: event.node.id, + width: movingWindowNode.width, + height: movingWindowNode.height, + frameThickness: movingWindowNode.frameThickness, + frameDepth: movingWindowNode.frameDepth, + columnRatios: movingWindowNode.columnRatios, + rowRatios: movingWindowNode.rowRatios, + columnDividerThickness: movingWindowNode.columnDividerThickness, + rowDividerThickness: movingWindowNode.rowDividerThickness, + sill: movingWindowNode.sill, + sillDepth: movingWindowNode.sillDepth, + sillThickness: movingWindowNode.sillThickness, + }) + useScene.getState().createNode(node, event.node.id as AnyNodeId) + placedId = node.id + } else { + // Move mode: restore original (clean baseline) + resume + updateNode + // Undo will revert to the original position + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + metadata: original.metadata, + }) + useScene.temporal.getState().resume() + + useScene.getState().updateNode(movingWindowNode.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + metadata: {}, + }) + + if (original.parentId && original.parentId !== event.node.id) { + markWallDirty(original.parentId) + } + placedId = movingWindowNode.id + } + + markWallDirty(event.node.id) + useScene.temporal.getState().pause() + + sfxEmitter.emit('sfx:item-place') + hideCursor() + useViewer.getState().setSelection({ selectedIds: [placedId] }) + exitMoveMode() + event.stopPropagation() + } + + const onWallLeave = () => { + hideCursor() + if (isNew) return // No original to restore for duplicates + // Move mode: restore to original position while off-wall + if (currentWallId && currentWallId !== original.parentId) { + markWallDirty(currentWallId) + } + currentWallId = original.parentId + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + }) + if (original.parentId) markWallDirty(original.parentId) + } + + const onCancel = () => { + if (isNew) { + useScene.getState().deleteNode(movingWindowNode.id) + if (currentWallId) markWallDirty(currentWallId) + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + metadata: original.metadata, + }) + if (original.parentId) markWallDirty(original.parentId) + } + useScene.temporal.getState().resume() + hideCursor() + exitMoveMode() + } + + emitter.on('wall:enter', onWallEnter) + emitter.on('wall:move', onWallMove) + emitter.on('wall:click', onWallClick) + emitter.on('wall:leave', onWallLeave) + emitter.on('tool:cancel', onCancel) + + return () => { + // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) + const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as WindowNode | undefined + const currentMeta = current?.metadata as Record | undefined + if (currentMeta?.isTransient) { + if (isNew) { + useScene.getState().deleteNode(movingWindowNode.id) + if (currentWallId) markWallDirty(currentWallId) + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: original.position, + rotation: original.rotation, + side: original.side, + parentId: original.parentId, + wallId: original.wallId, + metadata: original.metadata, + }) + if (original.parentId) markWallDirty(original.parentId) + } + } + useScene.temporal.getState().resume() + emitter.off('wall:enter', onWallEnter) + emitter.off('wall:move', onWallMove) + emitter.off('wall:click', onWallClick) + emitter.off('wall:leave', onWallLeave) + emitter.off('tool:cancel', onCancel) + } + }, [movingWindowNode]) + + const edgesGeo = useMemo(() => { + const boxGeo = new BoxGeometry( + movingWindowNode.width, + movingWindowNode.height, + movingWindowNode.frameDepth ?? 0.07, + ) + const geo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + return geo + }, [movingWindowNode]) + + return ( + + + + ) +} diff --git a/apps/editor/components/tools/window/window-math.ts b/apps/editor/components/tools/window/window-math.ts new file mode 100644 index 00000000..5419dfd4 --- /dev/null +++ b/apps/editor/components/tools/window/window-math.ts @@ -0,0 +1,101 @@ +import { type AnyNodeId, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core' + +/** + * Converts wall-local (X along wall, Y = height above level floor) to world XYZ. + * Wall XZ uses level-local coordinates (levels only offset in Y, not XZ). + * Pass levelYOffset (the level group's current world Y) so the cursor lands at the + * correct world height when the cursor group is at the scene root. + */ +export function wallLocalToWorld( + wallNode: WallNode, + localX: number, + localY: number, + levelYOffset = 0, +): [number, number, number] { + const wallAngle = Math.atan2( + wallNode.end[1] - wallNode.start[1], + wallNode.end[0] - wallNode.start[0], + ) + return [ + wallNode.start[0] + localX * Math.cos(wallAngle), + localY + levelYOffset, + wallNode.start[1] + localX * Math.sin(wallAngle), + ] +} + +/** + * Clamps window center position so it stays fully within wall bounds. + */ +export function clampToWall( + wallNode: WallNode, + localX: number, + localY: number, + width: number, + height: number, +): { clampedX: number; clampedY: number } { + const dx = wallNode.end[0] - wallNode.start[0] + const dz = wallNode.end[1] - wallNode.start[1] + const wallLength = Math.sqrt(dx * dx + dz * dz) + const wallHeight = wallNode.height ?? 2.5 + + const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) + const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) + return { clampedX, clampedY } +} + +/** + * Directly checks the wall's children for bounding-box overlap with a proposed window. + * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center). + * The spatial grid only tracks `item` nodes, so windows must be checked this way. + * Reads the wall's latest children from the store (not the event node) to avoid stale data. + */ +export function hasWallChildOverlap( + wallId: string, + clampedX: number, + clampedY: number, + width: number, + height: number, + ignoreId?: string, +): boolean { + const nodes = useScene.getState().nodes + const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined + if (!wallNode) return true // Block if wall not found + const halfW = width / 2 + const halfH = height / 2 + const newBottom = clampedY - halfH + const newTop = clampedY + halfH + const newLeft = clampedX - halfW + const newRight = clampedX + halfW + + for (const childId of wallNode.children) { + if (childId === ignoreId) continue + const child = nodes[childId as AnyNodeId] + if (!child) continue + + let childLeft: number, childRight: number, childBottom: number, childTop: number + + if (child.type === 'item') { + const item = child as ItemNode + if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue + const [w, h] = item.asset.dimensions + childLeft = item.position[0] - w / 2 + childRight = item.position[0] + w / 2 + childBottom = item.position[1] // items store bottom Y + childTop = item.position[1] + h + } else if (child.type === 'window') { + const win = child as WindowNode + childLeft = win.position[0] - win.width / 2 + childRight = win.position[0] + win.width / 2 + childBottom = win.position[1] - win.height / 2 // windows store center Y + childTop = win.position[1] + win.height / 2 + } else { + continue + } + + const xOverlap = newLeft < childRight && newRight > childLeft + const yOverlap = newBottom < childTop && newTop > childBottom + if (xOverlap && yOverlap) return true + } + + return false +} diff --git a/apps/editor/components/tools/window/window-tool.tsx b/apps/editor/components/tools/window/window-tool.tsx index 694d0d71..3d275456 100644 --- a/apps/editor/components/tools/window/window-tool.tsx +++ b/apps/editor/components/tools/window/window-tool.tsx @@ -1,10 +1,9 @@ import { type AnyNodeId, emitter, - type ItemNode, + sceneRegistry, useScene, type WallEvent, - type WallNode, WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -18,6 +17,7 @@ import { isValidWallSideFace, snapToHalf, } from '../item/placement-math' +import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math' // Shared edge material — reuse across renders, just toggle color const edgeMaterial = new LineBasicNodeMaterial({ @@ -27,103 +27,6 @@ const edgeMaterial = new LineBasicNodeMaterial({ depthWrite: false, }) -/** - * Converts wall-local (X along wall, Y = height) to world XYZ. - * Wall-local Y maps directly to world Y; X maps along the wall direction. - */ -function wallLocalToWorld( - wallNode: WallNode, - localX: number, - localY: number, -): [number, number, number] { - const wallAngle = Math.atan2( - wallNode.end[1] - wallNode.start[1], - wallNode.end[0] - wallNode.start[0], - ) - return [ - wallNode.start[0] + localX * Math.cos(wallAngle), - localY, - wallNode.start[1] + localX * Math.sin(wallAngle), - ] -} - -/** - * Clamps window center position so it stays fully within wall bounds. - */ -function clampToWall( - wallNode: WallNode, - localX: number, - localY: number, - width: number, - height: number, -): { clampedX: number; clampedY: number } { - const dx = wallNode.end[0] - wallNode.start[0] - const dz = wallNode.end[1] - wallNode.start[1] - const wallLength = Math.sqrt(dx * dx + dz * dz) - const wallHeight = wallNode.height ?? 2.5 - - const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) - const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY)) - return { clampedX, clampedY } -} - -/** - * Directly checks the wall's children for bounding-box overlap with a proposed window. - * Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center). - * The spatial grid only tracks `item` nodes, so windows must be checked this way. - * Reads the wall's latest children from the store (not the event node) to avoid stale data. - */ -function hasWallChildOverlap( - wallId: string, - clampedX: number, - clampedY: number, - width: number, - height: number, - ignoreId?: string, -): boolean { - const nodes = useScene.getState().nodes - const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined - if (!wallNode) return true // Block if wall not found - const halfW = width / 2 - const halfH = height / 2 - const newBottom = clampedY - halfH - const newTop = clampedY + halfH - const newLeft = clampedX - halfW - const newRight = clampedX + halfW - - for (const childId of wallNode.children) { - if (childId === ignoreId) continue - const child = nodes[childId as AnyNodeId] - if (!child) continue - - let childLeft: number, childRight: number, childBottom: number, childTop: number - - if (child.type === 'item') { - const item = child as ItemNode - if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue - const [w, h] = item.asset.dimensions - childLeft = item.position[0] - w / 2 - childRight = item.position[0] + w / 2 - childBottom = item.position[1] // items store bottom Y - childTop = item.position[1] + h - } else if (child.type === 'window') { - const win = child as WindowNode - childLeft = win.position[0] - win.width / 2 - childRight = win.position[0] + win.width / 2 - childBottom = win.position[1] - win.height / 2 // windows store center Y - childTop = win.position[1] + win.height / 2 - } else { - continue - } - - const xOverlap = newLeft < childRight && newRight > childLeft - const yOverlap = newBottom < childTop && newTop > childBottom - if (xOverlap && yOverlap) return true - } - - return false -} - /** * Window tool — places WindowNodes on walls only. * Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions. @@ -137,6 +40,10 @@ export const WindowTool: React.FC = () => { useScene.temporal.getState().pause() const getLevelId = () => useViewer.getState().selection.levelId + const getLevelYOffset = () => { + const id = getLevelId() + return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 + } const markWallDirty = (wallId: string) => { useScene.getState().dirtyNodes.add(wallId as AnyNodeId) @@ -201,7 +108,7 @@ export const WindowTool: React.FC = () => { const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id) - updateCursor(wallLocalToWorld(event.node, clampedX, clampedY), cursorRotation, valid) + updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid) event.stopPropagation() } @@ -235,7 +142,7 @@ export const WindowTool: React.FC = () => { draftRef.current?.id, ) - updateCursor(wallLocalToWorld(event.node, clampedX, clampedY), cursorRotation, valid) + updateCursor(wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset()), cursorRotation, valid) event.stopPropagation() } diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx index e9360923..655fc19d 100644 --- a/apps/editor/components/ui/panels/item-panel.tsx +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -1,8 +1,8 @@ 'use client' -import { type AnyNode, type ItemNode, useScene } from '@pascal-app/core' +import { type AnyNode, ItemNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Move, Trash2, X } from 'lucide-react' +import { Copy, Move, Trash2, X } from 'lucide-react' import Image from 'next/image' import { useCallback } from 'react' import useEditor from '@/store/use-editor' @@ -51,6 +51,24 @@ export function ItemPanel() { } }, [node, setMovingNode, setSelection]) + const handleDuplicate = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + // Create a proto node (not added to scene) as a carrier for asset/position info. + // MoveItemContent detects metadata.isNew and uses draftNode.create() so ghost rendering works correctly. + const proto = ItemNode.parse({ + position: [...node.position] as [number, number, number], + rotation: [...node.rotation] as [number, number, number], + name: node.name, + asset: node.asset, + parentId: node.parentId, + side: node.side, + metadata: { isNew: true }, + }) + setMovingNode(proto) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + const handleDelete = useCallback(() => { if (!selectedId) return sfxEmitter.emit('sfx:item-delete') @@ -193,6 +211,14 @@ export function ItemPanel() { Move + + + + + ) } diff --git a/apps/editor/package.json b/apps/editor/package.json index 4f2a6ad2..b99df7db 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -33,7 +33,7 @@ "@supabase/supabase-js": "^2.95.3", "@t3-oss/env-nextjs": "^0.13.10", "@tailwindcss/postcss": "^4.1.18", - "@types/three": "^0.182.0", + "@types/three": "^0.183.0", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", "@vercel/toolbar": "^0.2.2", diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 7ded1403..70f90a37 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -7,6 +7,7 @@ import { type LevelNode, type Space, useScene, + type WindowNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { create } from 'zustand' @@ -63,8 +64,8 @@ type EditorState = { setCatalogCategory: (category: CatalogCategory | null) => void selectedItem: AssetInput | null setSelectedItem: (item: AssetInput) => void - movingNode: ItemNode | null - setMovingNode: (node: ItemNode | null) => void + movingNode: ItemNode | WindowNode | null + setMovingNode: (node: ItemNode | WindowNode | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode diff --git a/bun.lock b/bun.lock index ae0d9712..8646c5f7 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", - "three": "^0.182.0", + "three": "^0.183.0", "three-bvh-csg": "^0.0.17", "three-mesh-bvh": "^0.9.8", "zustand": "^5.0.11", @@ -46,7 +46,7 @@ "@supabase/supabase-js": "^2.95.3", "@t3-oss/env-nextjs": "^0.13.10", "@tailwindcss/postcss": "^4.1.18", - "@types/three": "^0.182.0", + "@types/three": "^0.183.0", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", "@vercel/toolbar": "^0.2.2", @@ -105,14 +105,14 @@ "devDependencies": { "@repo/typescript-config": "*", "@types/react": "^19.2.2", - "@types/three": "^0.182.0", + "@types/three": "^0.183.0", "typescript": "5.9.2", }, "peerDependencies": { "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.182", + "three": "^0.183", }, }, "packages/db": { @@ -178,7 +178,7 @@ "devDependencies": { "@repo/typescript-config": "*", "@types/react": "^19.2.2", - "@types/three": "^0.182.0", + "@types/three": "^0.183.0", "typescript": "5.9.2", }, "peerDependencies": { @@ -186,7 +186,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.182", + "three": "^0.183", }, }, }, @@ -647,7 +647,7 @@ "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], - "@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="], + "@types/three": ["@types/three@0.183.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~1.0.1" } }, "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw=="], "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], @@ -1187,7 +1187,7 @@ "meshline": ["meshline@3.3.1", "", { "peerDependencies": { "three": ">=0.137" } }, "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ=="], - "meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="], + "meshoptimizer": ["meshoptimizer@1.0.1", "", {}, "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -1461,7 +1461,7 @@ "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], - "three": ["three@0.182.0", "", {}, "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ=="], + "three": ["three@0.183.1", "", {}, "sha512-Psv6bbd3d/M/01MT2zZ+VmD0Vj2dbWTNhfe4CuSg7w5TuW96M3NOyCVuh9SZQ05CpGmD7NEcJhZw4GVjhCYxfQ=="], "three-bvh-csg": ["three-bvh-csg@0.0.17", "", { "peerDependencies": { "three": ">=0.151.0", "three-mesh-bvh": ">=0.6.6" } }, "sha512-iEkHDF8GRfGM6593Cuw8SnF1vfENCp46gIAtRzuL4nGXGWPcR1sbTBwM9ptDONb5twqlZp5WkAKya5aBKe2qcA=="], @@ -1659,6 +1659,8 @@ "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "stats-gl/@types/three": ["@types/three@0.182.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~0.22.0" } }, "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q=="], + "stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="], "three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], @@ -1729,6 +1731,8 @@ "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "stats-gl/@types/three/meshoptimizer": ["meshoptimizer@0.22.0", "", {}, "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg=="], + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.2", "", { "dependencies": { "jackspeak": "^4.2.3" } }, "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg=="], } } diff --git a/package.json b/package.json index df29a951..386ef6dc 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "dependencies": { "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", - "three": "^0.182.0", + "three": "^0.183.0", "three-bvh-csg": "^0.0.17", "three-mesh-bvh": "^0.9.8", "zustand": "^5.0.11" diff --git a/packages/core/package.json b/packages/core/package.json index 322b5aee..85d35be2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.182" + "three": "^0.183" }, "dependencies": { "dedent": "^1.7.1", @@ -42,7 +42,7 @@ "@repo/typescript-config": "*", "@types/react": "^19.2.2", "typescript": "5.9.2", - "@types/three": "^0.182.0" + "@types/three": "^0.183.0" }, "keywords": [ "3d", diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index d3123e43..b73e65c2 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -10,6 +10,7 @@ export type { Database as SupabaseDatabase } from './types' export { type Database, db } from './drizzle' export * from './schema' + import * as dbSchema from './schema' export const schema = dbSchema export { diff --git a/packages/viewer/package.json b/packages/viewer/package.json index af258aaa..80ae189b 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -26,7 +26,7 @@ "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", - "three": "^0.182" + "three": "^0.183" }, "dependencies": { "zustand": "^5" @@ -35,7 +35,7 @@ "@repo/typescript-config": "*", "@types/react": "^19.2.2", "typescript": "5.9.2", - "@types/three": "^0.182.0" + "@types/three": "^0.183.0" }, "keywords": [ "3d", diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 283497f4..7094a4d9 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -1,6 +1,6 @@ import { type CeilingNode, useRegistry } from '@pascal-app/core' import { useRef } from 'react' -import { faceDirection, float, mix, positionWorld, smoothstep } from 'three/tsl' +import { faceDirection, float, mix, positionWorld, smoothstep, step } from 'three/tsl' import { DoubleSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { NodeRenderer } from '../node-renderer' @@ -35,7 +35,7 @@ const gridOpacity = mix(float(0.1), float(0.8), gridPattern) // faceDirection is 1.0 for front face, -1.0 for back face // Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid -ceilingMaterial.opacityNode = mix(float(1.0), gridOpacity, faceDirection.greaterThan(0.0)) +ceilingMaterial.opacityNode = mix(float(1.0), gridOpacity, step(float(0.0), float(faceDirection))) export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 697952d4..3577187e 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -20,7 +20,8 @@ import { vec4, velocity, } from 'three/tsl' -import { PostProcessing, type WebGPURenderer } from 'three/webgpu' + +import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' import useViewer from '../../store/use-viewer' // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion @@ -41,7 +42,7 @@ export const SSGI_PARAMS = { const PostProcessingPasses = () => { const { gl: renderer, scene, camera } = useThree() - const postProcessingRef = useRef(null) + const renderPipelineRef = useRef(null) useEffect(() => { if (!renderer || !scene || !camera) { @@ -151,9 +152,6 @@ const PostProcessingPasses = () => { return outlinePulse } - // Setup post-processing - const postProcessing = new PostProcessing(renderer as unknown as WebGPURenderer) - const selectedOutlinePass = generateSelectedOutlinePass() const hoverOutlinePass = generateHoverOutlinePass() @@ -165,20 +163,21 @@ const PostProcessingPasses = () => { // TRAA (Temporal Reprojection Anti-Aliasing) - applied AFTER combining everything const finalOutput = traa(compositeWithOutlines, scenePassDepth, scenePassVelocity, camera) - postProcessing.outputNode = finalOutput - postProcessingRef.current = postProcessing + const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer) + renderPipeline.outputNode = finalOutput + renderPipelineRef.current = renderPipeline return () => { - if (postProcessingRef.current) { - postProcessingRef.current.dispose() + if (renderPipelineRef.current) { + renderPipelineRef.current.dispose() } - postProcessingRef.current = null + renderPipelineRef.current = null } }, [renderer, scene, camera]) useFrame(() => { - if (postProcessingRef.current) { - postProcessingRef.current.render() + if (renderPipelineRef.current) { + renderPipelineRef.current.render() } }, 1) diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index 3151851d..dcc047a8 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -1,27 +1,88 @@ -import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' +import { type CeilingNode, type LevelNode, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { lerp } from 'three/src/math/MathUtils.js' import useViewer from '../../store/use-viewer' -const LEVEL_HEIGHT = 2.5 +const DEFAULT_LEVEL_HEIGHT = 2.5 const EXPLODED_GAP = 5 +// Cache: levelId → computed height. Invalidated by nodes reference change. +// Zustand produces a new `nodes` object on every mutation, so reference equality +// is a zero-cost way to detect stale data without any subscription overhead. +const heightCache = new Map() +let lastNodesRef: object | null = null + +function getLevelHeight( + levelId: string, + nodes: ReturnType['nodes'], +): number { + if (heightCache.has(levelId)) return heightCache.get(levelId)! + + const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined + if (!level) return DEFAULT_LEVEL_HEIGHT + + let maxTop = 0 + + for (const childId of level.children) { + const child = nodes[childId as keyof typeof nodes] + if (!child) continue + if (child.type === 'ceiling') { + // ceiling.height is the interior face Y in level-local space + const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT + if (ch > maxTop) maxTop = ch + } else if (child.type === 'wall') { + // Wall mesh is pushed up to slabElevation by WallSystem. + // mesh.position.y + wall.height gives the actual top Y in level-local space. + const meshY = sceneRegistry.nodes.get(childId as any)?.position.y ?? 0 + const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT) + if (top > maxTop) maxTop = top + } + } + + const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT + heightCache.set(levelId, height) + return height +} + export const LevelSystem = () => { useFrame((_, delta) => { + const nodes = useScene.getState().nodes + + // Clear cache when nodes reference changes (any node was mutated) + if (nodes !== lastNodesRef) { + heightCache.clear() + lastNodesRef = nodes + } + const levelMode = useViewer.getState().levelMode const selectedLevel = useViewer.getState().selection.levelId + + // Collect and sort levels by floor index so we can compute cumulative offsets. + // Level 0 → Y=0, Level 1 → Y=height(0), Level 2 → Y=height(0)+height(1), etc. + type LevelEntry = { levelId: string; index: number; obj: NonNullable> } + const entries: LevelEntry[] = [] sceneRegistry.byType.level.forEach((levelId) => { const obj = sceneRegistry.nodes.get(levelId) - if (obj) { - const level = useScene.getState().nodes[levelId as LevelNode['id']] - const targetY = - ((level as any).level || 0) * - (LEVEL_HEIGHT + (levelMode === 'exploded' ? EXPLODED_GAP: 0)) - obj.position.y = lerp(obj.position.y, targetY, delta * 3) - - obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel + const level = nodes[levelId as LevelNode['id']] + if (obj && level) { + entries.push({ levelId, index: (level as any).level ?? 0, obj }) } }) + entries.sort((a, b) => a.index - b.index) + + // Walk sorted levels, accumulating base Y offsets + let cumulativeY = 0 + for (const { levelId, index, obj } of entries) { + const level = nodes[levelId as LevelNode['id']] + const baseY = cumulativeY + const explodedExtra = levelMode === 'exploded' ? index * EXPLODED_GAP : 0 + const targetY = baseY + explodedExtra + + obj.position.y = lerp(obj.position.y, targetY, delta * 3) + obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel + + cumulativeY += getLevelHeight(levelId, nodes) + } }) return null }