From 27e4afb7572859ad1cfc2292f96b0a860c09fd79 Mon Sep 17 00:00:00 2001 From: wass08 Date: Wed, 25 Feb 2026 13:28:42 +0900 Subject: [PATCH] draft custom door --- .../editor/components/tools/door/door-math.ts | 102 ++++ .../components/tools/door/door-tool.tsx | 254 +++++++++ .../components/tools/door/move-door-tool.tsx | 354 ++++++++++++ .../components/tools/item/move-tool.tsx | 4 +- apps/editor/components/tools/tool-manager.tsx | 2 + .../components/tools/window/window-math.ts | 8 +- .../ui/action-menu/structure-tools.tsx | 2 +- .../components/ui/panels/door-panel.tsx | 512 ++++++++++++++++++ .../components/ui/panels/panel-manager.tsx | 3 + .../panels/site-panel/door-tree-node.tsx | 50 ++ .../sidebar/panels/site-panel/tree-node.tsx | 3 + apps/editor/store/use-editor.tsx | 8 +- packages/core/src/events/bus.ts | 4 +- .../hooks/scene-registry/scene-registry.ts | 1 + packages/core/src/index.ts | 2 + packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/door.ts | 67 +++ packages/core/src/schema/types.ts | 2 + .../core/src/systems/door/door-system.tsx | 247 +++++++++ .../core/src/systems/wall/wall-system.tsx | 2 +- .../renderers/door/door-renderer.tsx | 27 + .../components/renderers/node-renderer.tsx | 2 + .../viewer/src/components/viewer/index.tsx | 3 +- .../components/viewer/selection-manager.tsx | 12 +- packages/viewer/src/hooks/use-node-events.ts | 3 + 25 files changed, 1661 insertions(+), 14 deletions(-) create mode 100644 apps/editor/components/tools/door/door-math.ts create mode 100644 apps/editor/components/tools/door/door-tool.tsx create mode 100644 apps/editor/components/tools/door/move-door-tool.tsx create mode 100644 apps/editor/components/ui/panels/door-panel.tsx create mode 100644 apps/editor/components/ui/sidebar/panels/site-panel/door-tree-node.tsx create mode 100644 packages/core/src/schema/nodes/door.ts create mode 100644 packages/core/src/systems/door/door-system.tsx create mode 100644 packages/viewer/src/components/renderers/door/door-renderer.tsx diff --git a/apps/editor/components/tools/door/door-math.ts b/apps/editor/components/tools/door/door-math.ts new file mode 100644 index 00000000..17cf812c --- /dev/null +++ b/apps/editor/components/tools/door/door-math.ts @@ -0,0 +1,102 @@ +import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core' + +/** + * Converts wall-local (X along wall, Y = height above wall base) to world XYZ. + */ +export function wallLocalToWorld( + wallNode: WallNode, + localX: number, + localY: number, + levelYOffset = 0, + slabElevation = 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), + slabElevation + localY + levelYOffset, + wallNode.start[1] + localX * Math.sin(wallAngle), + ] +} + +/** + * Clamps door center X so it stays fully within wall bounds. + * Y is always height/2 — doors sit at floor level. + */ +export function clampToWall( + wallNode: WallNode, + localX: 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 clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX)) + const clampedY = height / 2 // Doors always sit at floor level + return { clampedX, clampedY } +} + +/** + * Checks if a proposed door position overlaps any existing wall children. + * Handles item, window, and door types. + */ +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 + 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] = getScaledDimensions(item) + childLeft = item.position[0] - w / 2 + childRight = item.position[0] + w / 2 + childBottom = item.position[1] + 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 + childTop = win.position[1] + win.height / 2 + } else if (child.type === 'door') { + const door = child as DoorNode + childLeft = door.position[0] - door.width / 2 + childRight = door.position[0] + door.width / 2 + childBottom = door.position[1] - door.height / 2 + childTop = door.position[1] + door.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/door/door-tool.tsx b/apps/editor/components/tools/door/door-tool.tsx new file mode 100644 index 00000000..acd275c8 --- /dev/null +++ b/apps/editor/components/tools/door/door-tool.tsx @@ -0,0 +1,254 @@ +import { + type AnyNodeId, + DoorNode, + emitter, + sceneRegistry, + spatialGridManager, + useScene, + type WallEvent, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef } from 'react' +import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { + calculateCursorRotation, + calculateItemRotation, + getSideFromNormal, + isValidWallSideFace, + snapToHalf, +} from '../item/placement-math' +import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math' + +const edgeMaterial = new LineBasicNodeMaterial({ + color: 0xef4444, + linewidth: 3, + depthTest: false, + depthWrite: false, +}) + +/** + * Door tool — places DoorNodes on walls only. + * Doors always sit at floor level (clampedY = height/2). + */ +export const DoorTool: React.FC = () => { + const draftRef = useRef(null) + const cursorGroupRef = useRef(null!) + const edgesRef = useRef(null!) + + useEffect(() => { + 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 getSlabElevation = (wallEvent: WallEvent) => + spatialGridManager.getSlabElevationForWall( + wallEvent.node.parentId ?? '', + wallEvent.node.start, + wallEvent.node.end, + ) + + const markWallDirty = (wallId: string) => { + useScene.getState().dirtyNodes.add(wallId as AnyNodeId) + } + + const destroyDraft = () => { + if (!draftRef.current) return + const wallId = draftRef.current.parentId + useScene.getState().deleteNode(draftRef.current.id) + draftRef.current = null + if (wallId) markWallDirty(wallId) + } + + 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 levelId = getLevelId() + if (!levelId) return + if (event.node.parentId !== levelId) return + + destroyDraft() + + 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 width = 0.9 + const height = 2.1 + + const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) + + const node = DoorNode.parse({ + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + wallId: event.node.id, + parentId: event.node.id, + metadata: { isTransient: true }, + }) + + useScene.getState().createNode(node, event.node.id as AnyNodeId) + draftRef.current = node + + const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id) + + updateCursor( + wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)), + cursorRotation, + valid, + ) + event.stopPropagation() + } + + const onWallMove = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + if (event.node.parentId !== getLevelId()) 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 width = draftRef.current?.width ?? 0.9 + const height = draftRef.current?.height ?? 2.1 + + const { clampedX, clampedY } = clampToWall(event.node, localX, width, height) + + if (draftRef.current) { + useScene.getState().updateNode(draftRef.current.id, { + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + parentId: event.node.id, + wallId: event.node.id, + }) + } + + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, width, height, + draftRef.current?.id, + ) + + updateCursor( + wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)), + cursorRotation, + valid, + ) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!draftRef.current) return + if (!isValidWallSideFace(event.normal)) return + if (event.node.parentId !== getLevelId()) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + + const localX = snapToHalf(event.localPosition[0]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, + draftRef.current.width, draftRef.current.height, + ) + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, + draftRef.current.width, draftRef.current.height, + draftRef.current.id, + ) + if (!valid) return + + const draft = draftRef.current + draftRef.current = null + + useScene.getState().deleteNode(draft.id) + useScene.temporal.getState().resume() + + const node = DoorNode.parse({ + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + wallId: event.node.id, + parentId: event.node.id, + width: draft.width, + height: draft.height, + frameThickness: draft.frameThickness, + frameDepth: draft.frameDepth, + threshold: draft.threshold, + thresholdHeight: draft.thresholdHeight, + hingesSide: draft.hingesSide, + swingDirection: draft.swingDirection, + segments: draft.segments, + handle: draft.handle, + handleHeight: draft.handleHeight, + handleSide: draft.handleSide, + doorCloser: draft.doorCloser, + panicBar: draft.panicBar, + panicBarHeight: draft.panicBarHeight, + }) + + useScene.getState().createNode(node, event.node.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [node.id] }) + useScene.temporal.getState().pause() + + event.stopPropagation() + } + + const onWallLeave = () => { + destroyDraft() + hideCursor() + } + + const onCancel = () => { + destroyDraft() + hideCursor() + } + + 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 () => { + destroyDraft() + hideCursor() + 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) + } + }, []) + + // Cursor geometry: door outline (default 0.9 × 2.1 × 0.07) + const boxGeo = new BoxGeometry(0.9, 2.1, 0.07) + const edgesGeo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + + return ( + + + + ) +} diff --git a/apps/editor/components/tools/door/move-door-tool.tsx b/apps/editor/components/tools/door/move-door-tool.tsx new file mode 100644 index 00000000..e8bab85d --- /dev/null +++ b/apps/editor/components/tools/door/move-door-tool.tsx @@ -0,0 +1,354 @@ +import { + type AnyNodeId, + DoorNode, + emitter, + sceneRegistry, + spatialGridManager, + useScene, + type WallEvent, +} 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 './door-math' + +const edgeMaterial = new LineBasicNodeMaterial({ + color: 0xef4444, + linewidth: 3, + depthTest: false, + depthWrite: false, +}) + +export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => { + const cursorGroupRef = useRef(null!) + + const exitMoveMode = () => { + useEditor.getState().setMovingNode(null) + } + + useEffect(() => { + useScene.temporal.getState().pause() + + const meta = (typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null) + ? movingDoorNode.metadata as Record + : {} + const isNew = !!meta.isNew + + const original = { + position: [...movingDoorNode.position] as [number, number, number], + rotation: [...movingDoorNode.rotation] as [number, number, number], + side: movingDoorNode.side, + parentId: movingDoorNode.parentId, + wallId: movingDoorNode.wallId, + metadata: movingDoorNode.metadata, + } + + if (!isNew) { + useScene.getState().updateNode(movingDoorNode.id, { + metadata: { ...meta, isTransient: true }, + }) + } + + let currentWallId: string | null = movingDoorNode.parentId + + const markWallDirty = (wallId: string | null) => { + if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId) + } + + const getLevelId = () => useViewer.getState().selection.levelId + const getLevelYOffset = () => { + const id = getLevelId() + return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0 + } + const getSlabElevation = (wallEvent: WallEvent) => + spatialGridManager.getSlabElevationForWall( + wallEvent.node.parentId ?? '', + wallEvent.node.start, + wallEvent.node.end, + ) + + 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 + if (event.node.parentId !== getLevelId()) 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 { clampedX, clampedY } = clampToWall( + event.node, localX, + movingDoorNode.width, movingDoorNode.height, + ) + + const prevWallId = currentWallId + currentWallId = event.node.id + + useScene.getState().updateNode(movingDoorNode.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, + movingDoorNode.width, movingDoorNode.height, + movingDoorNode.id, + ) + + updateCursor( + wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)), + cursorRotation, + valid, + ) + event.stopPropagation() + } + + const onWallMove = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + if (event.node.parentId !== getLevelId()) 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 { clampedX, clampedY } = clampToWall( + event.node, localX, + movingDoorNode.width, movingDoorNode.height, + ) + + useScene.getState().updateNode(movingDoorNode.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, + movingDoorNode.width, movingDoorNode.height, + movingDoorNode.id, + ) + + updateCursor( + wallLocalToWorld(event.node, clampedX, clampedY, getLevelYOffset(), getSlabElevation(event)), + cursorRotation, + valid, + ) + event.stopPropagation() + } + + const onWallClick = (event: WallEvent) => { + if (!isValidWallSideFace(event.normal)) return + if (event.node.parentId !== getLevelId()) return + + const side = getSideFromNormal(event.normal) + const itemRotation = calculateItemRotation(event.normal) + + const localX = snapToHalf(event.localPosition[0]) + const { clampedX, clampedY } = clampToWall( + event.node, localX, + movingDoorNode.width, movingDoorNode.height, + ) + + const valid = !hasWallChildOverlap( + event.node.id, clampedX, clampedY, + movingDoorNode.width, movingDoorNode.height, + movingDoorNode.id, + ) + if (!valid) return + + let placedId: string + + if (isNew) { + useScene.getState().deleteNode(movingDoorNode.id) + useScene.temporal.getState().resume() + + const node = DoorNode.parse({ + position: [clampedX, clampedY, 0], + rotation: [0, itemRotation, 0], + side, + wallId: event.node.id, + parentId: event.node.id, + width: movingDoorNode.width, + height: movingDoorNode.height, + frameThickness: movingDoorNode.frameThickness, + frameDepth: movingDoorNode.frameDepth, + threshold: movingDoorNode.threshold, + thresholdHeight: movingDoorNode.thresholdHeight, + hingesSide: movingDoorNode.hingesSide, + swingDirection: movingDoorNode.swingDirection, + segments: movingDoorNode.segments, + handle: movingDoorNode.handle, + handleHeight: movingDoorNode.handleHeight, + handleSide: movingDoorNode.handleSide, + doorCloser: movingDoorNode.doorCloser, + panicBar: movingDoorNode.panicBar, + panicBarHeight: movingDoorNode.panicBarHeight, + }) + useScene.getState().createNode(node, event.node.id as AnyNodeId) + placedId = node.id + } else { + useScene.getState().updateNode(movingDoorNode.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(movingDoorNode.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 = movingDoorNode.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 + if (currentWallId && currentWallId !== original.parentId) { + markWallDirty(currentWallId) + } + currentWallId = original.parentId + useScene.getState().updateNode(movingDoorNode.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(movingDoorNode.id) + if (currentWallId) markWallDirty(currentWallId) + } else { + useScene.getState().updateNode(movingDoorNode.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 () => { + const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined + const currentMeta = current?.metadata as Record | undefined + if (currentMeta?.isTransient) { + if (isNew) { + useScene.getState().deleteNode(movingDoorNode.id) + if (currentWallId) markWallDirty(currentWallId) + } else { + useScene.getState().updateNode(movingDoorNode.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) + } + }, [movingDoorNode]) + + const edgesGeo = useMemo(() => { + const boxGeo = new BoxGeometry( + movingDoorNode.width, + movingDoorNode.height, + movingDoorNode.frameDepth ?? 0.07, + ) + const geo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + return geo + }, [movingDoorNode]) + + return ( + + + + ) +} diff --git a/apps/editor/components/tools/item/move-tool.tsx b/apps/editor/components/tools/item/move-tool.tsx index c3cc7c38..3c9cdf55 100644 --- a/apps/editor/components/tools/item/move-tool.tsx +++ b/apps/editor/components/tools/item/move-tool.tsx @@ -1,7 +1,8 @@ -import type { ItemNode, WindowNode } from '@pascal-app/core' +import type { DoorNode, ItemNode, WindowNode } from '@pascal-app/core' import { Vector3 } from 'three' import { sfxEmitter } from '@/lib/sfx-bus' import useEditor from '@/store/use-editor' +import { MoveDoorTool } from '../door/move-door-tool' import { MoveWindowTool } from '../window/move-window-tool' import type { PlacementState } from './placement-types' import { useDraftNode } from './use-draft-node' @@ -67,6 +68,7 @@ export const MoveTool: React.FC = () => { const movingNode = useEditor((state) => state.movingNode) if (!movingNode) return null + if (movingNode.type === 'door') return if (movingNode.type === 'window') return return } diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 5de5725e..d5649386 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -4,6 +4,7 @@ import useEditor, { type Phase, type Tool } from '@/store/use-editor' import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingTool } from './ceiling/ceiling-tool' +import { DoorTool } from './door/door-tool' import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' @@ -25,6 +26,7 @@ const tools: Record>> = { slab: SlabTool, ceiling: CeilingTool, roof: RoofTool, + door: DoorTool, item: ItemTool, zone: ZoneTool, window: WindowTool, diff --git a/apps/editor/components/tools/window/window-math.ts b/apps/editor/components/tools/window/window-math.ts index 69882cad..9a3ffedc 100644 --- a/apps/editor/components/tools/window/window-math.ts +++ b/apps/editor/components/tools/window/window-math.ts @@ -1,4 +1,4 @@ -import { getScaledDimensions, type AnyNodeId, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core' +import { type AnyNodeId, type DoorNode, getScaledDimensions, type ItemNode, useScene, type WallNode, type WindowNode } from '@pascal-app/core' /** * Converts wall-local (X along wall, Y = height above wall base) to world XYZ. @@ -90,6 +90,12 @@ export function hasWallChildOverlap( 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 if (child.type === 'door') { + const door = child as DoorNode + childLeft = door.position[0] - door.width / 2 + childRight = door.position[0] + door.width / 2 + childBottom = door.position[1] - door.height / 2 // doors store center Y + childTop = door.position[1] + door.height / 2 } else { continue } diff --git a/apps/editor/components/ui/action-menu/structure-tools.tsx b/apps/editor/components/ui/action-menu/structure-tools.tsx index 7b698553..edaf8f7a 100644 --- a/apps/editor/components/ui/action-menu/structure-tools.tsx +++ b/apps/editor/components/ui/action-menu/structure-tools.tsx @@ -17,7 +17,7 @@ export const tools: ToolConfig[] = [ { id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' }, { id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' }, { id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' }, - { id: 'item', iconSrc: '/icons/door.png', label: 'Door', catalogCategory: 'door' }, + { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, ] diff --git a/apps/editor/components/ui/panels/door-panel.tsx b/apps/editor/components/ui/panels/door-panel.tsx new file mode 100644 index 00000000..4b35eb5c --- /dev/null +++ b/apps/editor/components/ui/panels/door-panel.tsx @@ -0,0 +1,512 @@ +'use client' + +import { type AnyNode, type AnyNodeId, DoorNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Copy, FlipHorizontal2, Move, Trash2, X } from 'lucide-react' +import Image from 'next/image' +import { useCallback } from 'react' +import { sfxEmitter } from '@/lib/sfx-bus' +import useEditor from '@/store/use-editor' +import { NumberInput } from '@/components/ui/primitives/number-input' +import { Switch } from '@/components/ui/primitives/switch' + +export function DoorPanel() { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const setSelection = useViewer((s) => s.setSelection) + const nodes = useScene((s) => s.nodes) + const updateNode = useScene((s) => s.updateNode) + const deleteNode = useScene((s) => s.deleteNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + + const selectedId = selectedIds[0] + const node = selectedId + ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) + : undefined + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + }, + [selectedId, updateNode], + ) + + const handleClose = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + const handleFlip = useCallback(() => { + if (!node) return + handleUpdate({ + side: node.side === 'front' ? 'back' : 'front', + rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]], + }) + }, [node, handleUpdate]) + + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + + const handleDelete = useCallback(() => { + if (!selectedId || !node) return + sfxEmitter.emit('sfx:item-delete') + deleteNode(selectedId as AnyNode['id']) + if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId) + setSelection({ selectedIds: [] }) + }, [selectedId, node, deleteNode, setSelection]) + + const handleDuplicate = useCallback(() => { + if (!node || !node.parentId) return + sfxEmitter.emit('sfx:item-pick') + useScene.temporal.getState().pause() + const duplicate = DoorNode.parse({ + position: [...node.position] as [number, number, number], + rotation: [...node.rotation] as [number, number, number], + side: node.side, + wallId: node.wallId, + parentId: node.parentId, + width: node.width, + height: node.height, + frameThickness: node.frameThickness, + frameDepth: node.frameDepth, + threshold: node.threshold, + thresholdHeight: node.thresholdHeight, + hingesSide: node.hingesSide, + swingDirection: node.swingDirection, + segments: node.segments.map(s => ({ ...s, columnRatios: [...s.columnRatios] })), + handle: node.handle, + handleHeight: node.handleHeight, + handleSide: node.handleSide, + doorCloser: node.doorCloser, + panicBar: node.panicBar, + panicBarHeight: node.panicBarHeight, + metadata: { isNew: true }, + }) + useScene.getState().createNode(duplicate, node.parentId as AnyNodeId) + setMovingNode(duplicate) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + + if (!node || node.type !== 'door' || selectedIds.length !== 1) return null + + return ( +
+ {/* Header */} +
+
+ +

+ {node.name || `Door (${node.width}×${node.height}m)`} +

+
+ +
+ + {/* Content */} +
+ + {/* Position */} +
+ +
+ handleUpdate({ position: [v, node.position[1], node.position[2]] })} + precision={2} + /> +
+ +
+ + {/* Dimensions */} +
+ +
+
+ handleUpdate({ width: v })} + min={0.5} + precision={2} + className="flex-1" + /> + m +
+
+ handleUpdate({ height: v })} + min={1.0} + precision={2} + className="flex-1" + /> + m +
+
+
+ + {/* Frame */} +
+ +
+
+ handleUpdate({ frameThickness: v })} + min={0.01} + precision={3} + step={0.01} + className="flex-1" + /> + m +
+
+ handleUpdate({ frameDepth: v })} + min={0.01} + precision={3} + step={0.01} + className="flex-1" + /> + m +
+
+
+ + {/* Swing */} +
+ +
+
+ Hinges +
+ {(['left', 'right'] as const).map((side) => ( + + ))} +
+
+
+ Direction +
+ {(['inward', 'outward'] as const).map((dir) => ( + + ))} +
+
+
+
+ + {/* Threshold */} +
+
+ + handleUpdate({ threshold: checked })} + /> +
+ {node.threshold && ( +
+ handleUpdate({ thresholdHeight: v })} + min={0.005} + precision={3} + step={0.005} + className="flex-1" + /> + m +
+ )} +
+ + {/* Handle */} +
+
+ + handleUpdate({ handle: checked })} + /> +
+ {node.handle && ( +
+
+ handleUpdate({ handleHeight: v })} + min={0.5} + max={node.height - 0.1} + precision={2} + step={0.05} + className="flex-1" + /> + m +
+
+ Side +
+ {(['left', 'right'] as const).map((side) => ( + + ))} +
+
+
+ )} +
+ + {/* Hardware */} +
+ +
+
+ Door Closer + handleUpdate({ doorCloser: checked })} + /> +
+
+ Panic Bar + handleUpdate({ panicBar: checked })} + /> +
+ {node.panicBar && ( +
+ handleUpdate({ panicBarHeight: v })} + min={0.5} + max={node.height - 0.1} + precision={2} + step={0.05} + className="flex-1" + /> + m +
+ )} +
+
+ + {/* Segments */} +
+ + {node.segments.map((seg, i) => ( +
+
+ Segment {i + 1} +
+ {(['panel', 'glass', 'empty'] as const).map((t) => ( + + ))} +
+
+
+ { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, heightRatio: Math.max(0.05, v) } : s, + ) + handleUpdate({ segments: updated }) + }} + min={0.05} + precision={2} + step={0.05} + className="flex-1" + /> +
+ {seg.type === 'panel' && ( +
+
+ { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, panelInset: v } : s, + ) + handleUpdate({ segments: updated }) + }} + min={0.005} + precision={3} + step={0.005} + className="flex-1" + /> + m +
+
+ { + const updated = node.segments.map((s, idx) => + idx === i ? { ...s, panelDepth: v } : s, + ) + handleUpdate({ segments: updated }) + }} + precision={3} + step={0.005} + className="flex-1" + /> + m +
+
+ )} +
+ ))} +
+ + {node.segments.length > 1 && ( + + )} +
+
+
+ + {/* Action Buttons */} +
+
+ + + +
+
+
+ ) +} diff --git a/apps/editor/components/ui/panels/panel-manager.tsx b/apps/editor/components/ui/panels/panel-manager.tsx index d5fa0b56..08aa3862 100644 --- a/apps/editor/components/ui/panels/panel-manager.tsx +++ b/apps/editor/components/ui/panels/panel-manager.tsx @@ -9,6 +9,7 @@ import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { SlabPanel } from './slab-panel' import { WallPanel } from './wall-panel' +import { DoorPanel } from './door-panel' import { WindowPanel } from './window-panel' export function PanelManager() { @@ -37,6 +38,8 @@ export function PanelManager() { return case 'wall': return + case 'door': + return case 'window': return } diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/door-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/door-tree-node.tsx new file mode 100644 index 00000000..5358bd43 --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/site-panel/door-tree-node.tsx @@ -0,0 +1,50 @@ +'use client' + +import { DoorNode } from "@pascal-app/core" +import { useViewer } from "@pascal-app/viewer" +import Image from "next/image" +import { useState } from "react" +import { RenamePopover } from "./rename-popover" +import { TreeNodeWrapper } from "./tree-node" +import { TreeNodeActions } from "./tree-node-actions" + +interface DoorTreeNodeProps { + node: DoorNode + depth: number +} + +export function DoorTreeNode({ node, depth }: DoorTreeNodeProps) { + const [renameOpen, setRenameOpen] = useState(false) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id)) + const isHovered = useViewer((state) => state.hoveredId === node.id) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const defaultName = `Door (${node.width}×${node.height}m)` + + return ( + + } + label={node.name || defaultName} + depth={depth} + hasChildren={false} + expanded={false} + onToggle={() => {}} + onClick={() => setSelection({ selectedIds: [node.id] })} + onDoubleClick={() => setRenameOpen(true)} + onMouseEnter={() => setHoveredId(node.id)} + onMouseLeave={() => setHoveredId(null)} + isSelected={isSelected} + isHovered={isHovered} + isVisible={node.visible !== false} + actions={} + /> + + ) +} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx index 711e5d7f..73c4b5c4 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -4,6 +4,7 @@ import { forwardRef } from "react"; import { cn } from "@/lib/utils"; import { BuildingTreeNode } from "./building-tree-node"; import { CeilingTreeNode } from "./ceiling-tree-node"; +import { DoorTreeNode } from "./door-tree-node"; import { ItemTreeNode } from "./item-tree-node"; import { LevelTreeNode } from "./level-tree-node"; import { RoofTreeNode } from "./roof-tree-node"; @@ -37,6 +38,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) { return ; case "item": return ; + case "door": + return ; case "window": return ; case "zone": diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index 70f90a37..3d0451af 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -3,6 +3,7 @@ import type { AssetInput } from '@pascal-app/core' import { type BuildingNode, + type DoorNode, type ItemNode, type LevelNode, type Space, @@ -29,6 +30,7 @@ export type StructureTool = | 'item' | 'zone' | 'window' + | 'door' // Furnish mode tools (items and decoration) export type FurnishTool = 'item' @@ -64,8 +66,8 @@ type EditorState = { setCatalogCategory: (category: CatalogCategory | null) => void selectedItem: AssetInput | null setSelectedItem: (item: AssetInput) => void - movingNode: ItemNode | WindowNode | null - setMovingNode: (node: ItemNode | WindowNode | null) => void + movingNode: ItemNode | WindowNode | DoorNode | null + setMovingNode: (node: ItemNode | WindowNode | DoorNode | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -194,7 +196,7 @@ const useEditor = create()((set, get) => ({ setCatalogCategory: (category) => set({ catalogCategory: category }), selectedItem: null, setSelectedItem: (item) => set({ selectedItem: item }), - movingNode: null, + movingNode: null as ItemNode | WindowNode | DoorNode | null, setMovingNode: (node) => set({ movingNode: node }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 7c73b92e..61250974 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,6 @@ import type { ThreeEvent } from '@react-three/fiber' import mitt from 'mitt' -import type { BuildingNode, CeilingNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema' +import type { BuildingNode, CeilingNode, DoorNode, ItemNode, LevelNode, RoofNode, SiteNode, SlabNode, WallNode, WindowNode, ZoneNode } from '../schema' import type { AnyNode } from '../schema/types' // Base event interfaces @@ -28,6 +28,7 @@ export type SlabEvent = NodeEvent export type CeilingEvent = NodeEvent export type RoofEvent = NodeEvent export type WindowEvent = NodeEvent +export type DoorEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -83,6 +84,7 @@ type EditorEvents = GridEvents & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'window', WindowEvent> & + NodeEvents<'door', DoorEvent> & CameraControlEvents & ToolEvents diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 5701b1bd..3a43873e 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -20,6 +20,7 @@ export const sceneRegistry = { scan: new Set(), guide: new Set(), window: new Set(), + door: new Set(), }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 877623d8..0f5ea252 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export type { BuildingEvent, CameraControlEvent, CeilingEvent, + DoorEvent, EventSuffix, GridEvent, ItemEvent, @@ -43,6 +44,7 @@ export * from './schema' export { default as useScene } from './store/use-scene' // Systems export { CeilingSystem } from './systems/ceiling/ceiling-system' +export { DoorSystem } from './systems/door/door-system' export { ItemSystem } from './systems/item/item-system' export { RoofSystem } from './systems/roof/roof-system' export { SlabSystem } from './systems/slab/slab-system' diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 84b4b5d0..c3de2791 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -17,6 +17,7 @@ export { RoofNode } from './nodes/roof' export { ScanNode } from './nodes/scan' export { GuideNode } from './nodes/guide' export type { AnyNodeId, AnyNodeType } from './types' +export { DoorNode, DoorSegment } from './nodes/door' export { WindowNode } from './nodes/window' // Union types export { AnyNode } from './types' diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts new file mode 100644 index 00000000..f0c065e5 --- /dev/null +++ b/packages/core/src/schema/nodes/door.ts @@ -0,0 +1,67 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const DoorSegment = z.object({ + type: z.enum(['panel', 'glass', 'empty']), + heightRatio: z.number(), + + // Each segment controls its own column split + columnRatios: z.array(z.number()).default([1]), + dividerThickness: z.number().default(0.03), + + // panel-specific + panelDepth: z.number().default(0.01), // + raised, - recessed + panelInset: z.number().default(0.04), +}) + +export type DoorSegment = z.infer + +export const DoorNode = BaseNode.extend({ + id: objectId('door'), + type: nodeType('door'), + + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + side: z.enum(['front', 'back']).optional(), + wallId: z.string().optional(), + + // Overall dimensions + width: z.number().default(0.9), + height: z.number().default(2.1), + + // Frame + frameThickness: z.number().default(0.05), + frameDepth: z.number().default(0.07), + threshold: z.boolean().default(true), + thresholdHeight: z.number().default(0.02), + + // Swing + hingesSide: z.enum(['left', 'right']).default('left'), + swingDirection: z.enum(['inward', 'outward']).default('inward'), + + // Leaf segments — stacked top to bottom, each with its own column split + segments: z.array(DoorSegment).default([ + { type: 'panel', heightRatio: 0.4, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 }, + { type: 'panel', heightRatio: 0.6, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 }, + ]), + + // Handle + handle: z.boolean().default(true), + handleHeight: z.number().default(1.05), + handleSide: z.enum(['left', 'right']).default('right'), + + // Emergency / commercial hardware + doorCloser: z.boolean().default(false), + panicBar: z.boolean().default(false), + panicBarHeight: z.number().default(1.0), + +}).describe(dedent`Door node - a parametric door placed on a wall + - position: center of the door in wall-local coordinate system (Y = height/2, always at floor) + - segments: rows stacked top to bottom, each defining its own columnRatios + - type 'empty' = flush flat fill, 'panel' = raised/recessed panel, 'glass' = glazed + - hingesSide/swingDirection: which way the door opens + - doorCloser/panicBar: commercial and emergency hardware options +`) + +export type DoorNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 6e7454c6..7d012f1f 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -8,6 +8,7 @@ import { RoofNode } from './nodes/roof' import { ScanNode } from './nodes/scan' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' +import { DoorNode } from './nodes/door' import { WallNode } from './nodes/wall' import { WindowNode } from './nodes/window' import { ZoneNode } from './nodes/zone' @@ -25,6 +26,7 @@ export const AnyNode = z.discriminatedUnion('type', [ ScanNode, GuideNode, WindowNode, + DoorNode, ]) export type AnyNode = z.infer diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx new file mode 100644 index 00000000..52548dec --- /dev/null +++ b/packages/core/src/systems/door/door-system.tsx @@ -0,0 +1,247 @@ +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' +import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import type { AnyNodeId, DoorNode } from '../../schema' +import useScene from '../../store/use-scene' + +const frameMaterial = new MeshStandardNodeMaterial({ + name: 'door-frame', + color: '#e8e8e8', + roughness: 0.6, + metalness: 0, +}) + +const leafMaterial = new MeshStandardNodeMaterial({ + name: 'door-leaf', + color: '#d0c8b8', + roughness: 0.5, + metalness: 0, +}) + +const panelMaterial = new MeshStandardNodeMaterial({ + name: 'door-panel', + color: '#c5bdb0', + roughness: 0.5, + metalness: 0, +}) + +const glassMaterial = new MeshStandardNodeMaterial({ + name: 'door-glass', + color: 'lightblue', + roughness: 0.05, + metalness: 0.1, + transparent: true, + opacity: 0.35, + side: DoubleSide, + depthWrite: false, +}) + +const thresholdMaterial = new MeshStandardNodeMaterial({ + name: 'door-threshold', + color: '#999', + roughness: 0.4, + metalness: 0.5, +}) + +const handleMaterial = new MeshStandardNodeMaterial({ + name: 'door-handle', + color: '#bbb', + roughness: 0.2, + metalness: 0.8, +}) + +const closerMaterial = new MeshStandardNodeMaterial({ + name: 'door-closer', + color: '#333', + roughness: 0.4, + metalness: 0.3, +}) + +// Invisible material for root mesh — used as selection hitbox only +const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) + +export const DoorSystem = () => { + const dirtyNodes = useScene((state) => state.dirtyNodes) + const clearDirty = useScene((state) => state.clearDirty) + + useFrame(() => { + if (dirtyNodes.size === 0) return + + const nodes = useScene.getState().nodes + + dirtyNodes.forEach((id) => { + const node = nodes[id] + if (!node || node.type !== 'door') return + + const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh + if (!mesh) return // Keep dirty until mesh mounts + + updateDoorMesh(node as DoorNode, mesh) + clearDirty(id as AnyNodeId) + + // Rebuild the parent wall so its cutout reflects the updated door geometry + if ((node as DoorNode).parentId) { + useScene.getState().dirtyNodes.add((node as DoorNode).parentId as AnyNodeId) + } + }) + }, 3) + + return null +} + +function addBox( + parent: THREE.Object3D, + material: THREE.Material, + w: number, h: number, d: number, + x: number, y: number, z: number, +) { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + m.position.set(x, y, z) + parent.add(m) +} + +function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { + // Root mesh is an invisible hitbox; all visuals live in child meshes + mesh.geometry.dispose() + mesh.geometry = new THREE.BoxGeometry(node.width, node.height, node.frameDepth) + mesh.material = hitboxMaterial + + // Sync transform from node (React may lag behind the system by a frame during drag) + mesh.position.set(node.position[0], node.position[1], node.position[2]) + mesh.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2]) + + // Dispose and remove all old visual children; preserve 'cutout' + for (const child of [...mesh.children]) { + if (child.name === 'cutout') continue + if (child instanceof THREE.Mesh) child.geometry.dispose() + mesh.remove(child) + } + + const { + width, height, frameThickness, frameDepth, threshold, thresholdHeight, + segments, handle, handleHeight, handleSide, + doorCloser, panicBar, panicBarHeight, + } = node + + // Leaf occupies the full opening (no bottom frame bar — door opens to floor) + const leafW = width - 2 * frameThickness + const leafH = height - frameThickness // only top frame + const leafDepth = 0.04 + // Leaf center is shifted down from door center by half the top frame + const leafCenterY = -frameThickness / 2 + + // ── Frame members ── + // Left post — full height + addBox(mesh, frameMaterial, frameThickness, height, frameDepth, -width / 2 + frameThickness / 2, 0, 0) + // Right post — full height + addBox(mesh, frameMaterial, frameThickness, height, frameDepth, width / 2 - frameThickness / 2, 0, 0) + // Head (top bar) — full width + addBox(mesh, frameMaterial, width, frameThickness, frameDepth, 0, height / 2 - frameThickness / 2, 0) + + // ── Threshold ── + if (threshold) { + addBox(mesh, thresholdMaterial, width, thresholdHeight, frameDepth, 0, -height / 2 + thresholdHeight / 2, 0) + } + + // ── Door leaf — full backing ── + addBox(mesh, leafMaterial, leafW, leafH, leafDepth, 0, leafCenterY, 0) + + // ── Segments (stacked top to bottom within leaf area) ── + const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0) + const leafTop = leafCenterY + leafH / 2 + + let segY = leafTop + for (const seg of segments) { + const segH = (seg.heightRatio / totalRatio) * leafH + const segCenterY = segY - segH / 2 + + const numCols = seg.columnRatios.length + const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) + const usableW = leafW - (numCols - 1) * seg.dividerThickness + const colWidths = seg.columnRatios.map(r => (r / colSum) * usableW) + + // Column x-centers + const colXCenters: number[] = [] + let cx = -leafW / 2 + for (let c = 0; c < numCols; c++) { + colXCenters.push(cx + colWidths[c]! / 2) + cx += colWidths[c]! + if (c < numCols - 1) cx += seg.dividerThickness + } + + // Column dividers within this segment + cx = -leafW / 2 + for (let c = 0; c < numCols - 1; c++) { + cx += colWidths[c]! + addBox(mesh, leafMaterial, seg.dividerThickness, segH, leafDepth + 0.001, cx + seg.dividerThickness / 2, segCenterY, 0) + cx += seg.dividerThickness + } + + // Segment content per column + for (let c = 0; c < numCols; c++) { + const colW = colWidths[c]! + const colX = colXCenters[c]! + + if (seg.type === 'glass') { + const glassDepth = Math.max(0.004, leafDepth * 0.15) + addBox(mesh, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + } else if (seg.type === 'panel') { + const panelW = colW - 2 * seg.panelInset + const panelH = segH - 2 * seg.panelInset + if (panelW > 0.01 && panelH > 0.01) { + const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) + const panelZ = leafDepth / 2 + effectiveDepth / 2 + addBox(mesh, panelMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + } + } + // 'empty' → leaf backing is already there, nothing extra + } + + segY -= segH + } + + // ── Handle ── + if (handle) { + // Convert from floor-based height to mesh-center-based Y + const handleY = handleHeight - height / 2 + // Handle grip sits on the front face (+Z) of the leaf + const faceZ = leafDepth / 2 + + // X position: handleSide refers to which side the grip is on + const handleX = handleSide === 'right' + ? leafW / 2 - 0.045 + : -leafW / 2 + 0.045 + + // Backplate + addBox(mesh, handleMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + // Grip lever + addBox(mesh, handleMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) + } + + // ── Door closer (commercial hardware at top) ── + if (doorCloser) { + const closerY = leafCenterY + leafH / 2 - 0.04 + // Body + addBox(mesh, closerMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) + // Arm (simplified as thin bar to frame side) + addBox(mesh, closerMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015) + } + + // ── Panic bar ── + if (panicBar) { + const barY = panicBarHeight - height / 2 + addBox(mesh, handleMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) + } + + // ── Cutout (for wall CSG) — always full door dimensions, 1m deep ── + let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined + if (!cutout) { + cutout = new THREE.Mesh() + cutout.name = 'cutout' + mesh.add(cutout) + } + cutout.geometry.dispose() + cutout.geometry = new THREE.BoxGeometry(node.width, node.height, 1.0) + cutout.visible = false +} diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index e741519a..cf8074b4 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -306,7 +306,7 @@ function collectCutoutBrushes( const wallMatrixInverse = wallMesh.matrixWorld.clone().invert() for (const child of childrenNodes) { - if (child.type !== 'item' && child.type !== 'window') continue + if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue const childMesh = sceneRegistry.nodes.get(child.id) if (!childMesh) continue diff --git a/packages/viewer/src/components/renderers/door/door-renderer.tsx b/packages/viewer/src/components/renderers/door/door-renderer.tsx new file mode 100644 index 00000000..d723cfd1 --- /dev/null +++ b/packages/viewer/src/components/renderers/door/door-renderer.tsx @@ -0,0 +1,27 @@ +import { useRegistry, type DoorNode } from '@pascal-app/core' +import { useRef } from 'react' +import type { Mesh } from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' + +export const DoorRenderer = ({ node }: { node: DoorNode }) => { + const ref = useRef(null!) + + useRegistry(node.id, 'door', ref) + const handlers = useNodeEvents(node, 'door') + + return ( + + {/* DoorSystem replaces this geometry each time the node is dirty */} + + + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 02fd4d9c..87e10141 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -3,6 +3,7 @@ import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' +import { DoorRenderer } from './door/door-renderer' import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' @@ -28,6 +29,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'item' && } {node.type === 'slab' && } {node.type === 'wall' && } + {node.type === 'door' && } {node.type === 'window' && } {node.type === 'zone' && } {node.type === 'roof' && } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 85f66def..acb05c90 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { CeilingSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem, WindowSystem } from '@pascal-app/core' +import { CeilingSystem, DoorSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem, WindowSystem } from '@pascal-app/core' import { Bvh } from '@react-three/drei' import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber' import * as THREE from 'three/webgpu' @@ -61,6 +61,7 @@ const Viewer: React.FC = ({ children, selectionManager = 'default' {/* Core systems */} + diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index afbfb180..5fa67574 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -29,6 +29,7 @@ type SelectableNodeType = | 'zone' | 'wall' | 'window' + | 'door' | 'item' | 'slab' | 'ceiling' @@ -86,8 +87,8 @@ const isNodeOnLevel = (node: AnyNode, levelId: string): boolean => { // Direct child of level if (node.parentId === levelId) return true - // Wall-attached items (windows/doors): check if parent wall is on the level - if (node.type === 'item' && node.parentId) { + // Wall-attached nodes (window/door/item): check if parent wall is on the level + if ((node.type === 'item' || node.type === 'window' || node.type === 'door') && node.parentId) { const parentNode = nodes[node.parentId as keyof typeof nodes] if (parentNode?.type === 'wall' && parentNode.parentId === levelId) { return true @@ -200,9 +201,9 @@ const getStrategy = (): SelectionStrategy | null => { } } - // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows) + // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors) return { - types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window'], + types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door'], handleClick: (node) => { const { selectedIds } = useViewer.getState().selection // Toggle selection - if already selected, deselect; otherwise select @@ -224,7 +225,7 @@ const getStrategy = (): SelectionStrategy | null => { } }, isValid: (node) => { - const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof', 'window'] + const validTypes = ['wall', 'item', 'slab', 'ceiling', 'roof', 'window', 'door'] if (!validTypes.includes(node.type)) return false return isNodeInZone(node, levelId, zoneId) }, @@ -277,6 +278,7 @@ export const SelectionManager = () => { 'ceiling', 'roof', 'window', + 'door', ] for (const type of allTypes) { emitter.on(`${type}:enter`, onEnter) diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 7e166726..77f9650c 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -3,6 +3,8 @@ import { type BuildingNode, type CeilingEvent, type CeilingNode, + type DoorEvent, + type DoorNode, type EventSuffix, emitter, type ItemEvent, @@ -36,6 +38,7 @@ type NodeConfig = { ceiling: { node: CeilingNode; event: CeilingEvent } roof: { node: RoofNode; event: RoofEvent } window: { node: WindowNode; event: WindowEvent } + door: { node: DoorNode; event: DoorEvent } } type NodeType = keyof NodeConfig