From 915393d649f0ab09094f14a941a7108084f6fd6f Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 1 May 2026 10:18:36 +0530 Subject: [PATCH] Add swing angle support for door rotation --- packages/core/src/schema/nodes/door.ts | 7 +- .../core/src/systems/door/door-system.tsx | 64 ++++++++++++------- .../src/components/editor/floorplan-panel.tsx | 36 +++++++++-- packages/editor/src/hooks/use-keyboard.ts | 19 +++++- 4 files changed, 95 insertions(+), 31 deletions(-) diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 519ac6a4..f898216b 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -41,6 +41,11 @@ export const DoorNode = BaseNode.extend({ // Swing hingesSide: z.enum(['left', 'right']).default('left'), swingDirection: z.enum(['inward', 'outward']).default('inward'), + swingAngle: z + .number() + .min(0) + .max(Math.PI / 2) + .default(0), // Leaf segments — stacked top to bottom, each with its own column split segments: z.array(DoorSegment).default([ @@ -78,7 +83,7 @@ export const DoorNode = BaseNode.extend({ - 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' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed - - hingesSide/swingDirection: which way the door opens + - hingesSide/swingDirection/swingAngle: which way the door opens and how far it is currently open - doorCloser/panicBar: commercial and emergency hardware options `) diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx index b9b5c9e9..508c9242 100644 --- a/packages/core/src/systems/door/door-system.tsx +++ b/packages/core/src/systems/door/door-system.tsx @@ -52,6 +52,12 @@ function addBox( parent.add(m) } +function disposeObject(object: THREE.Object3D) { + object.traverse((child) => { + if (child instanceof THREE.Mesh) child.geometry.dispose() + }) +} + function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -65,7 +71,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // 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() + disposeObject(child) mesh.remove(child) } @@ -85,8 +91,11 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { panicBarHeight, contentPadding, hingesSide, + swingDirection, + swingAngle = 0, } = node const hasLeafContent = segments.some((seg) => seg.type !== 'empty') + const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle)) // Leaf occupies the full opening (no bottom frame bar — door opens to floor) const leafW = width - 2 * frameThickness @@ -94,6 +103,23 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const leafDepth = 0.04 // Leaf center is shifted down from door center by half the top frame const leafCenterY = -frameThickness / 2 + const hingeX = hingesSide === 'right' ? leafW / 2 : -leafW / 2 + const swingDirectionSign = swingDirection === 'inward' ? 1 : -1 + const hingeDirectionSign = hingesSide === 'right' ? 1 : -1 + const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign + const leafGroup = new THREE.Group() + leafGroup.position.set(hingeX, 0, 0) + leafGroup.rotation.y = leafSwingRotation + mesh.add(leafGroup) + const addLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) // ── Frame members ── // Left post — full height @@ -149,16 +175,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const cpY = contentPadding[1] if (hasLeafContent && cpY > 0) { // Top strip - addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) + addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) // Bottom strip - addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) + addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) } if (hasLeafContent && cpX > 0) { const innerH = leafH - 2 * cpY // Left strip - addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) + addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) // Right strip - addBox(mesh, baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0) + addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0) } // Content area inside padding @@ -193,8 +219,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { cx = -contentW / 2 for (let c = 0; c < numCols - 1; c++) { cx += colWidths[c]! - addBox( - mesh, + addLeafBox( baseMaterial, seg.dividerThickness, segH, @@ -215,17 +240,17 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { if (seg.type === 'glass') { // Glass only — no opaque backing so it's truly transparent const glassDepth = Math.max(0.004, leafDepth * 0.15) - addBox(mesh, glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) } else if (seg.type === 'panel') { // Opaque leaf backing for this column - addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) // Raised panel detail 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, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) } } else { // 'empty' leaves the opening unfilled @@ -246,33 +271,24 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045 // Backplate - addBox(mesh, baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) // Grip lever - addBox(mesh, baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) + addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) } // ── Door closer (commercial hardware at top) ── if (hasLeafContent && doorCloser) { const closerY = leafCenterY + leafH / 2 - 0.04 // Body - addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) + addLeafBox(baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) // Arm (simplified as thin bar to frame side) - addBox( - mesh, - baseMaterial, - 0.14, - 0.015, - 0.015, - leafW / 4, - closerY + 0.025, - leafDepth / 2 + 0.015, - ) + addLeafBox(baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015) } // ── Panic bar ── if (hasLeafContent && panicBar) { const barY = panicBarHeight - height / 2 - addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) + addLeafBox(baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) } // ── Hinges (3 knuckle-style hinges on the hinge side) ── diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 1c71b30b..53374ae3 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -2841,6 +2841,21 @@ function getOpeningCenterLine(polygon: Point2D[]) { } } +function isOpeningPlanFlipped(rotation: [number, number, number]) { + const normalized = + ((((rotation[1] % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) + 1e-6) % (Math.PI * 2) + + return normalized > Math.PI / 2 && normalized < (Math.PI * 3) / 2 +} + +function getFlippedHingesSide(hingesSide: DoorNode['hingesSide']) { + return hingesSide === 'left' ? 'right' : 'left' +} + +function getFlippedSwingDirection(swingDirection: DoorNode['swingDirection']) { + return swingDirection === 'inward' ? 'outward' : 'inward' +} + function normalizeGridCoordinate(value: number): number { return Number(value.toFixed(GRID_COORDINATE_PRECISION)) } @@ -4301,8 +4316,14 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ const px = -ny const py = nx - const hingesSide = opening.hingesSide ?? 'left' - const swingDirection = opening.swingDirection ?? 'inward' + const isPlanFlipped = isOpeningPlanFlipped(opening.rotation) + const baseHingesSide = opening.hingesSide ?? 'left' + const baseSwingDirection = opening.swingDirection ?? 'inward' + const hingesSide = isPlanFlipped ? getFlippedHingesSide(baseHingesSide) : baseHingesSide + const swingDirection = isPlanFlipped + ? getFlippedSwingDirection(baseSwingDirection) + : baseSwingDirection + const swingAngle = Math.max(0, Math.min(Math.PI / 2, opening.swingAngle ?? 0)) const width = opening.width const sweepFlag = hingesSide === 'left' @@ -4345,9 +4366,16 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ ny * hingeTangentSign * (doorCubeSize / 2), } const swingRadius = Math.hypot(arcEnd.x - leafStart.x, arcEnd.y - leafStart.y) + const closedLeafVector = { + x: arcEnd.x - leafStart.x, + y: arcEnd.y - leafStart.y, + } + const openAngle = swingAngle * swingSign * hingeTangentSign + const openCos = Math.cos(openAngle) + const openSin = Math.sin(openAngle) const leafEnd = { - x: leafStart.x + px * swingSign * swingRadius, - y: leafStart.y + py * swingSign * swingRadius, + x: leafStart.x + closedLeafVector.x * openCos - closedLeafVector.y * openSin, + y: leafStart.y + closedLeafVector.x * openSin + closedLeafVector.y * openCos, } const doorBackgroundPoints = [ { diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 328116ad..a87d40ce 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -5,6 +5,8 @@ import { runRedo, runUndo } from '../lib/history' import { sfxEmitter } from '../lib/sfx-bus' import useEditor from '../store/use-editor' +const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 + // Tools call this in their onCancel handler when they have an active mid-action to cancel, // so that the global Escape handler knows not to also switch to select mode. let _toolCancelConsumed = false @@ -145,10 +147,19 @@ export const useKeyboard = ({ } } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) { // Rotate selected node clockwise if it supports rotation (items, roofs, etc.) + // Doors use R to toggle their leaf open/closed around the hinge. const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] - if (node && 'rotation' in node) { + if (node?.type === 'door') { + e.preventDefault() + const currentSwingAngle = node.swingAngle ?? 0 + useScene.getState().updateNode(node.id, { + swingAngle: + currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + }) + sfxEmitter.emit('sfx:item-rotate') + } else if (node && 'rotation' in node) { e.preventDefault() const ROTATION_STEP = Math.PI / 4 @@ -168,7 +179,11 @@ export const useKeyboard = ({ const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] - if (node && 'rotation' in node) { + if (node?.type === 'door') { + e.preventDefault() + useScene.getState().updateNode(node.id, { swingAngle: 0 }) + sfxEmitter.emit('sfx:item-rotate') + } else if (node && 'rotation' in node) { e.preventDefault() const ROTATION_STEP = Math.PI / 4