Add swing angle support for door rotation
This commit is contained in:
@@ -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
|
||||
`)
|
||||
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user