draft custom door

This commit is contained in:
wass08
2026-02-25 13:28:42 +09:00
parent d9317bfba2
commit 27e4afb757
25 changed files with 1661 additions and 14 deletions
@@ -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
}
@@ -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<DoorNode | null>(null)
const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(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 (
<group ref={cursorGroupRef} visible={false}>
<lineSegments ref={edgesRef} geometry={edgesGeo} material={edgeMaterial} />
</group>
)
}
@@ -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<Group>(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<string, unknown>
: {}
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<string, unknown> | 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 (
<group ref={cursorGroupRef} visible={false}>
<lineSegments geometry={edgesGeo} material={edgeMaterial} />
</group>
)
}
@@ -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 <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} />
}
@@ -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<Phase, Partial<Record<Tool, React.FC>>> = {
slab: SlabTool,
ceiling: CeilingTool,
roof: RoofTool,
door: DoorTool,
item: ItemTool,
zone: ZoneTool,
window: WindowTool,
@@ -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
}
@@ -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' },
]
@@ -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<DoorNode>) => {
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 (
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-82 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex items-center gap-2 min-w-0">
<Image src="/icons/door.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
<h2 className="font-semibold text-foreground text-sm truncate">
{node.name || `Door (${node.width}×${node.height}m)`}
</h2>
</div>
<button
type="button"
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
onClick={handleClose}
>
<X className="h-4 w-4" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Position */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Position
</label>
<div className="grid grid-cols-1 gap-2">
<NumberInput
label="X along wall"
value={Math.round(node.position[0] * 100) / 100}
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
precision={2}
/>
</div>
<button
type="button"
className="w-full flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={handleFlip}
>
<FlipHorizontal2 className="h-3.5 w-3.5" />
Flip Side
</button>
</div>
{/* Dimensions */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Dimensions
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Width"
value={Math.round(node.width * 100) / 100}
onChange={(v) => handleUpdate({ width: v })}
min={0.5}
precision={2}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.height * 100) / 100}
onChange={(v) => handleUpdate({ height: v })}
min={1.0}
precision={2}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
{/* Frame */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Frame
</label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Thickness"
value={Math.round(node.frameThickness * 1000) / 1000}
onChange={(v) => handleUpdate({ frameThickness: v })}
min={0.01}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth"
value={Math.round(node.frameDepth * 1000) / 1000}
onChange={(v) => handleUpdate({ frameDepth: v })}
min={0.01}
precision={3}
step={0.01}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
</div>
{/* Swing */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Swing
</label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Hinges</span>
<div className="flex gap-1">
{(['left', 'right'] as const).map((side) => (
<button
key={side}
type="button"
onClick={() => handleUpdate({ hingesSide: side })}
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
node.hingesSide === side
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{side.charAt(0).toUpperCase() + side.slice(1)}
</button>
))}
</div>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Direction</span>
<div className="flex gap-1">
{(['inward', 'outward'] as const).map((dir) => (
<button
key={dir}
type="button"
onClick={() => handleUpdate({ swingDirection: dir })}
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
node.swingDirection === dir
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{dir.charAt(0).toUpperCase() + dir.slice(1)}
</button>
))}
</div>
</div>
</div>
</div>
{/* Threshold */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Threshold
</label>
<Switch
checked={node.threshold}
onCheckedChange={(checked) => handleUpdate({ threshold: checked })}
/>
</div>
{node.threshold && (
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.thresholdHeight * 1000) / 1000}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
min={0.005}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
)}
</div>
{/* Handle */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Handle
</label>
<Switch
checked={node.handle}
onCheckedChange={(checked) => handleUpdate({ handle: checked })}
/>
</div>
{node.handle && (
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Height"
value={Math.round(node.handleHeight * 100) / 100}
onChange={(v) => handleUpdate({ handleHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Side</span>
<div className="flex gap-1">
{(['left', 'right'] as const).map((side) => (
<button
key={side}
type="button"
onClick={() => handleUpdate({ handleSide: side })}
className={`flex-1 rounded border px-2 py-1 text-xs cursor-pointer transition-colors ${
node.handleSide === side
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{side.charAt(0).toUpperCase() + side.slice(1)}
</button>
))}
</div>
</div>
</div>
)}
</div>
{/* Hardware */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Hardware
</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">Door Closer</span>
<Switch
checked={node.doorCloser}
onCheckedChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-foreground">Panic Bar</span>
<Switch
checked={node.panicBar}
onCheckedChange={(checked) => handleUpdate({ panicBar: checked })}
/>
</div>
{node.panicBar && (
<div className="flex items-center gap-1.5">
<NumberInput
label="Bar height"
value={Math.round(node.panicBarHeight * 100) / 100}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
min={0.5}
max={node.height - 0.1}
precision={2}
step={0.05}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
)}
</div>
</div>
{/* Segments */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Leaf segments (top bottom)
</label>
{node.segments.map((seg, i) => (
<div key={i} className="rounded border border-border p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">Segment {i + 1}</span>
<div className="flex gap-1">
{(['panel', 'glass', 'empty'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated })
}}
className={`rounded border px-1.5 py-0.5 text-xs cursor-pointer transition-colors ${
seg.type === t
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-accent'
}`}
>
{t}
</button>
))}
</div>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Height ratio"
value={Math.round(seg.heightRatio * 100) / 100}
onChange={(v) => {
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"
/>
</div>
{seg.type === 'panel' && (
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-1.5">
<NumberInput
label="Inset"
value={Math.round(seg.panelInset * 1000) / 1000}
onChange={(v) => {
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"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
<div className="flex items-center gap-1.5">
<NumberInput
label="Depth"
value={Math.round(seg.panelDepth * 1000) / 1000}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
className="flex-1"
/>
<span className="text-muted-foreground text-xs shrink-0">m</span>
</div>
</div>
)}
</div>
))}
<div className="flex gap-2">
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
const updated = [
...node.segments,
{ type: 'panel' as const, heightRatio: 1, columnRatios: [1], dividerThickness: 0.03, panelDepth: 0.01, panelInset: 0.04 },
]
handleUpdate({ segments: updated })
}}
>
+ Add segment
</button>
{node.segments.length > 1 && (
<button
type="button"
className="flex-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent cursor-pointer"
onClick={() => {
handleUpdate({ segments: node.segments.slice(0, -1) })
}}
>
Remove last
</button>
)}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="border-t p-3">
<div className="flex gap-2">
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={handleMove}
>
<Move className="h-3.5 w-3.5" />
<span>Move</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={handleDuplicate}
>
<Copy className="h-3.5 w-3.5" />
<span>Duplicate</span>
</button>
<button
type="button"
className="flex-1 flex items-center justify-center gap-1.5 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={handleDelete}
>
<Trash2 className="h-3.5 w-3.5" />
<span>Delete</span>
</button>
</div>
</div>
</div>
)
}
@@ -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 <CeilingPanel />
case 'wall':
return <WallPanel />
case 'door':
return <DoorPanel />
case 'window':
return <WindowPanel />
}
@@ -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 (
<RenamePopover
node={node}
open={renameOpen}
onOpenChange={setRenameOpen}
defaultName={defaultName}
>
<TreeNodeWrapper
icon={<Image src="/icons/door.png" alt="" width={14} height={14} className="object-contain" />}
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={<TreeNodeActions node={node} />}
/>
</RenamePopover>
)
}
@@ -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 <RoofTreeNode node={node} depth={depth} />;
case "item":
return <ItemTreeNode node={node} depth={depth} />;
case "door":
return <DoorTreeNode node={node} depth={depth} />;
case "window":
return <WindowTreeNode node={node} depth={depth} />;
case "zone":
+5 -3
View File
@@ -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<EditorState>()((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 }),
+3 -1
View File
@@ -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<SlabNode>
export type CeilingEvent = NodeEvent<CeilingNode>
export type RoofEvent = NodeEvent<RoofNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
// 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
@@ -20,6 +20,7 @@ export const sceneRegistry = {
scan: new Set<string>(),
guide: new Set<string>(),
window: new Set<string>(),
door: new Set<string>(),
},
};
+2
View File
@@ -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'
+1
View File
@@ -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'
+67
View File
@@ -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<typeof DoorSegment>
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<typeof DoorNode>
+2
View File
@@ -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<typeof AnyNode>
@@ -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
}
@@ -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
@@ -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<Mesh>(null!)
useRegistry(node.id, 'door', ref)
const handlers = useNodeEvents(node, 'door')
return (
<mesh
ref={ref}
castShadow
receiveShadow
visible={node.visible}
position={node.position}
rotation={node.rotation}
{...handlers}
>
{/* DoorSystem replaces this geometry each time the node is dirty */}
<boxGeometry args={[0, 0, 0]} />
<meshStandardMaterial color="#d1d5db" />
</mesh>
)
}
@@ -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' && <ItemRenderer node={node} />}
{node.type === 'slab' && <SlabRenderer node={node} />}
{node.type === 'wall' && <WallRenderer node={node} />}
{node.type === 'door' && <DoorRenderer node={node} />}
{node.type === 'window' && <WindowRenderer node={node} />}
{node.type === 'zone' && <ZoneRenderer node={node} />}
{node.type === 'roof' && <RoofRenderer node={node} />}
@@ -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<ViewerProps> = ({ children, selectionManager = 'default'
<WallCutout />
{/* Core systems */}
<CeilingSystem />
<DoorSystem />
<ItemSystem />
<RoofSystem />
<SlabSystem />
@@ -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)
@@ -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