draft custom door
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user