Add swing angle support for door rotation

This commit is contained in:
sudhir
2026-05-01 10:18:36 +05:30
parent 3df3d12f46
commit 915393d649
4 changed files with 95 additions and 31 deletions
+6 -1
View File
@@ -41,6 +41,11 @@ export const DoorNode = BaseNode.extend({
// Swing // Swing
hingesSide: z.enum(['left', 'right']).default('left'), hingesSide: z.enum(['left', 'right']).default('left'),
swingDirection: z.enum(['inward', 'outward']).default('inward'), 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 // Leaf segments — stacked top to bottom, each with its own column split
segments: z.array(DoorSegment).default([ 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) - 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 - 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 - 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 - doorCloser/panicBar: commercial and emergency hardware options
`) `)
+40 -24
View File
@@ -52,6 +52,12 @@ function addBox(
parent.add(m) 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) { function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
// Root mesh is an invisible hitbox; all visuals live in child meshes // Root mesh is an invisible hitbox; all visuals live in child meshes
mesh.geometry.dispose() mesh.geometry.dispose()
@@ -65,7 +71,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
// Dispose and remove all old visual children; preserve 'cutout' // Dispose and remove all old visual children; preserve 'cutout'
for (const child of [...mesh.children]) { for (const child of [...mesh.children]) {
if (child.name === 'cutout') continue if (child.name === 'cutout') continue
if (child instanceof THREE.Mesh) child.geometry.dispose() disposeObject(child)
mesh.remove(child) mesh.remove(child)
} }
@@ -85,8 +91,11 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
panicBarHeight, panicBarHeight,
contentPadding, contentPadding,
hingesSide, hingesSide,
swingDirection,
swingAngle = 0,
} = node } = node
const hasLeafContent = segments.some((seg) => seg.type !== 'empty') 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) // Leaf occupies the full opening (no bottom frame bar — door opens to floor)
const leafW = width - 2 * frameThickness const leafW = width - 2 * frameThickness
@@ -94,6 +103,23 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
const leafDepth = 0.04 const leafDepth = 0.04
// Leaf center is shifted down from door center by half the top frame // Leaf center is shifted down from door center by half the top frame
const leafCenterY = -frameThickness / 2 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 ── // ── Frame members ──
// Left post — full height // Left post — full height
@@ -149,16 +175,16 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
const cpY = contentPadding[1] const cpY = contentPadding[1]
if (hasLeafContent && cpY > 0) { if (hasLeafContent && cpY > 0) {
// Top strip // 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 // 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) { if (hasLeafContent && cpX > 0) {
const innerH = leafH - 2 * cpY const innerH = leafH - 2 * cpY
// Left strip // 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 // 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 // Content area inside padding
@@ -193,8 +219,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
cx = -contentW / 2 cx = -contentW / 2
for (let c = 0; c < numCols - 1; c++) { for (let c = 0; c < numCols - 1; c++) {
cx += colWidths[c]! cx += colWidths[c]!
addBox( addLeafBox(
mesh,
baseMaterial, baseMaterial,
seg.dividerThickness, seg.dividerThickness,
segH, segH,
@@ -215,17 +240,17 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
if (seg.type === 'glass') { if (seg.type === 'glass') {
// Glass only — no opaque backing so it's truly transparent // Glass only — no opaque backing so it's truly transparent
const glassDepth = Math.max(0.004, leafDepth * 0.15) 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') { } else if (seg.type === 'panel') {
// Opaque leaf backing for this column // 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 // Raised panel detail
const panelW = colW - 2 * seg.panelInset const panelW = colW - 2 * seg.panelInset
const panelH = segH - 2 * seg.panelInset const panelH = segH - 2 * seg.panelInset
if (panelW > 0.01 && panelH > 0.01) { if (panelW > 0.01 && panelH > 0.01) {
const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth)
const panelZ = leafDepth / 2 + effectiveDepth / 2 const panelZ = leafDepth / 2 + effectiveDepth / 2
addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ)
} }
} else { } else {
// 'empty' leaves the opening unfilled // '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 const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045
// Backplate // 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 // 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) ── // ── Door closer (commercial hardware at top) ──
if (hasLeafContent && doorCloser) { if (hasLeafContent && doorCloser) {
const closerY = leafCenterY + leafH / 2 - 0.04 const closerY = leafCenterY + leafH / 2 - 0.04
// Body // 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) // Arm (simplified as thin bar to frame side)
addBox( addLeafBox(baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015)
mesh,
baseMaterial,
0.14,
0.015,
0.015,
leafW / 4,
closerY + 0.025,
leafDepth / 2 + 0.015,
)
} }
// ── Panic bar ── // ── Panic bar ──
if (hasLeafContent && panicBar) { if (hasLeafContent && panicBar) {
const barY = panicBarHeight - height / 2 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) ── // ── Hinges (3 knuckle-style hinges on the hinge side) ──
@@ -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 { function normalizeGridCoordinate(value: number): number {
return Number(value.toFixed(GRID_COORDINATE_PRECISION)) return Number(value.toFixed(GRID_COORDINATE_PRECISION))
} }
@@ -4301,8 +4316,14 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
const px = -ny const px = -ny
const py = nx const py = nx
const hingesSide = opening.hingesSide ?? 'left' const isPlanFlipped = isOpeningPlanFlipped(opening.rotation)
const swingDirection = opening.swingDirection ?? 'inward' 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 width = opening.width
const sweepFlag = const sweepFlag =
hingesSide === 'left' hingesSide === 'left'
@@ -4345,9 +4366,16 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
ny * hingeTangentSign * (doorCubeSize / 2), ny * hingeTangentSign * (doorCubeSize / 2),
} }
const swingRadius = Math.hypot(arcEnd.x - leafStart.x, arcEnd.y - leafStart.y) 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 = { const leafEnd = {
x: leafStart.x + px * swingSign * swingRadius, x: leafStart.x + closedLeafVector.x * openCos - closedLeafVector.y * openSin,
y: leafStart.y + py * swingSign * swingRadius, y: leafStart.y + closedLeafVector.x * openSin + closedLeafVector.y * openCos,
} }
const doorBackgroundPoints = [ const doorBackgroundPoints = [
{ {
+17 -2
View File
@@ -5,6 +5,8 @@ import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor' 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, // 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. // so that the global Escape handler knows not to also switch to select mode.
let _toolCancelConsumed = false let _toolCancelConsumed = false
@@ -145,10 +147,19 @@ export const useKeyboard = ({
} }
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) { } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.) // 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[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) { if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!] 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() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 const ROTATION_STEP = Math.PI / 4
@@ -168,7 +179,11 @@ export const useKeyboard = ({
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) { if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!] 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() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 const ROTATION_STEP = Math.PI / 4