Files
editor/packages/nodes/src/door/floorplan.ts
T
ab76686b8b
CI / quality (push) Has been cancelled
mcp-ci / ci (push) Has been cancelled
editor: improve floorplan modes and annotations (#549)
* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* fix(editor): anchor floorplan cursor to snapped point

* fix(nodes): preview floorplan edits through live overrides

* feat(editor): add context-aware floorplan modes

* fix(nodes): render crisp wall selection hatching

* fix(editor): cap floorplan handles at extreme zoom

* fix(editor): keep zone labels upright after rotation

* refactor(editor): make referenced annotations registry-driven

* refactor(nodes): colocate contextual dimension builders

* fix(editor): use mode-driven angle snapping

* fix(nodes): use mode-driven move snapping

* chore(editor): update react scan tooling

* refactor(floorplan): streamline construction documentation

* refactor(floorplan): remove wall assembly roadmap

* fix(floorplan): migrate retired scene data

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-27 18:57:59 -04:00

842 lines
33 KiB
TypeScript

import type {
DoorNode,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
WallNode,
} from '@pascal-app/core'
import {
readFloorplanContext,
readFloorplanGeometryMetadata,
withFloorplanGeometryMetadata,
} from '@pascal-app/editor'
import {
buildOpeningMarkAnnotation,
type OpeningFloorplanLevelData,
} from '../shared/opening-documentation'
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
/**
* Stage C floor-plan builder for door. 1:1 visual port of the legacy
* floorplan-panel door rendering:
*
* 1. The door footprint rectangle in the wall cutout (themed
* accent stroke when selected).
* 2. The door swing arc — a fixed quarter-circle from the hinge to
* the door's fully-open (90°) position, oriented by `hingesSide`
* and `swingDirection`. The angle is intentionally constant so the
* plan symbol stays static regardless of the door's live open-close
* state. Renders as a wedge of low-opacity fill so the swept area
* reads at a glance.
* 3. The door leaf — a thick line from the hinge to the open
* position, terminating at the arc end.
*
* Double / french doors render two mirrored half-width leaves hinged at
* the opposite outer ends, each with its own dashed arc, meeting
* perpendicular at the centre — the standard double-door plan sign.
*
* Folding / bifold doors render a static zigzag accordion of panels
* across the opening (porting the 3D folding geometry's panel layout),
* with no swing arc.
* 4. Center line through the cutout (matches the legacy's
* `getOpeningCenterLine` segment for visual continuity).
*
* Requires `ctx.parent` to be a wall (door.parentId is the wall it's
* mounted on). Returns null when the parent isn't a wall (orphaned
* doors during placement etc.).
*
* Swing leaves include hinge / strike indicators and panic hardware
* when present. Rounded and arched heads are shown as dashed overhead
* lines because their geometry sits above the horizontal plan cut.
*/
export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null {
const wall = ctx.parent as WallNode | null
if (wall?.type !== 'wall') return null
const [x1, z1] = wall.start
const [x2, z2] = wall.end
const dx = x2 - x1
const dz = z2 - z1
const length = Math.sqrt(dx * dx + dz * dz)
if (length < 1e-9) return null
const dirX = dx / length
const dirZ = dz / length
// Perpendicular unit normal (rotate 90° CCW).
const perpX = -dirZ
const perpZ = dirX
const distance = node.position[0]
const width = node.width
const depth = wall.thickness ?? 0.1
const cx = x1 + dirX * distance
const cz = z1 + dirZ * distance
const halfWidth = width / 2
const halfDepth = depth / 2
const isPlanFlipped = isOpeningPlanFlipped(node.rotation)
const baseHingesSide = node.hingesSide ?? 'left'
const baseSwingDirection = node.swingDirection ?? 'inward'
const hingesSide = isPlanFlipped ? (baseHingesSide === 'left' ? 'right' : 'left') : baseHingesSide
const swingDirection = isPlanFlipped
? baseSwingDirection === 'inward'
? 'outward'
: 'inward'
: baseSwingDirection
// The floor-plan door symbol is the standard architectural sign: the
// leaf drawn at a fixed 90° open with its quarter-circle swing arc.
// It deliberately ignores `node.swingAngle` / the live open-close
// animation — the plan view documents how the door is hung, not how
// far it currently happens to be open, so it must stay static.
const swingAngle = Math.PI / 2
// Footprint rectangle in the cutout.
const points: readonly FloorplanPoint[] = [
[cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth],
[cx + dirX * halfWidth + perpX * halfDepth, cz + dirZ * halfWidth + perpZ * halfDepth],
[cx + dirX * halfWidth - perpX * halfDepth, cz + dirZ * halfWidth - perpZ * halfDepth],
[cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth],
]
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const showSelectedChrome = isSelected || isHighlighted
// Match the legacy floor-plan door render: unselected is a quiet
// grey accent so the door reads as a hole in the wall, selected is
// a full orange treatment (body + outline) so the user can see at
// a glance which door is targeted by the inspector / move handle.
const accentColor = showSelectedChrome ? '#f97316' : 'rgba(100, 116, 139, 0.82)'
const accentMuted = accentColor
const fillColor = showSelectedChrome ? '#fed7aa' : '#ffffff'
const children: FloorplanGeometry[] = [
// Background — the cutout is filled white so the swing arc sits on
// a clean canvas (the wall hatch shows through otherwise).
{
kind: 'polygon',
points,
fill: fillColor,
stroke: accentMuted,
strokeWidth: showSelectedChrome ? 2 : 1.25,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
},
]
if (node.openingShape !== 'rectangle') {
const overheadRise =
Math.min(width, node.openingShape === 'arch' ? node.archHeight : node.cornerRadius) * 0.2
const overheadSide = swingDirection === 'inward' ? 1 : -1
const startX = cx - dirX * halfWidth
const startZ = cz - dirZ * halfWidth
const endX = cx + dirX * halfWidth
const endZ = cz + dirZ * halfWidth
children.push({
kind: 'path',
d: `M ${startX} ${startZ} Q ${cx + perpX * overheadRise * overheadSide} ${cz + perpZ * overheadRise * overheadSide} ${endX} ${endZ}`,
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeOpacity: 0.8,
strokeDasharray: '4 3',
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
}
// Swing geometry. A leaf is drawn as a wedge fill + dashed swing arc +
// solid leaf line. `drawSwingLeaf` emits one leaf given its hinge, the
// closed-leaf vector (hinge → strike, whose length is the swing
// radius) and a signed swing angle. Single doors draw one leaf; double
// / french doors draw two mirrored half-width leaves meeting in the
// middle.
const swingSign = swingDirection === 'inward' ? 1 : -1
const drawSwingLeaf = (
hX: number,
hZ: number,
closedX: number,
closedZ: number,
signedAngle: number,
) => {
const radius = Math.sqrt(closedX * closedX + closedZ * closedZ)
if (radius < 1e-3) return
// Rotate the closed leaf vector around the hinge to the open tip.
const cos = Math.cos(signedAngle)
const sin = Math.sin(signedAngle)
const tipX = hX + (closedX * cos - closedZ * sin)
const tipZ = hZ + (closedX * sin + closedZ * cos)
const closedTipX = hX + closedX
const closedTipZ = hZ + closedZ
// Swing arc — from closed tip to open tip via an arc centered at the
// hinge. SVG `A`: rx ry rotation large-arc-flag sweep-flag x y. The
// sweep flag flips with the signed angle direction.
const sweepFlag = signedAngle >= 0 ? 1 : 0
const arcPath = `M ${closedTipX} ${closedTipZ} A ${radius} ${radius} 0 0 ${sweepFlag} ${tipX} ${tipZ}`
// Swept wedge fill (light, low opacity) — reads as the open zone.
children.push({
kind: 'path',
d: `M ${hX} ${hZ} L ${closedTipX} ${closedTipZ} ${arcPath.replace(/^M [^A]+/, '').trim()} Z`,
fill: accentColor,
fillOpacity: showSelectedChrome ? 0.08 : 0.05,
stroke: 'none',
})
// The arc itself, dashed to match the standard architectural plan
// symbol, where the door's swing path is drawn as a broken arc.
children.push({
kind: 'path',
d: arcPath,
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.6 : 1.1,
strokeOpacity: 0.85,
// Dash is in screen pixels because of `non-scaling-stroke` (same
// reason strokeWidth is a small pixel value, not metres).
strokeDasharray: '5 4',
vectorEffect: 'non-scaling-stroke',
strokeLinecap: 'round',
})
// The door leaf — line from hinge to the open tip.
children.push({
kind: 'line',
x1: hX,
y1: hZ,
x2: tipX,
y2: tipZ,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2.4 : 1.7,
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
const markerSize = Math.min(0.05, Math.max(0.025, radius * 0.05))
const markerHalf = markerSize / 2
const hardwareMarkers: Array<[number, number]> = [
[hX, hZ],
[closedTipX, closedTipZ],
]
for (const [markerX, markerZ] of hardwareMarkers) {
children.push({
kind: 'rect',
x: markerX - markerHalf,
y: markerZ - markerHalf,
width: markerSize,
height: markerSize,
fill: fillColor,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
vectorEffect: 'non-scaling-stroke',
})
}
if (node.panicBar) {
const leafX = (tipX - hX) / radius
const leafZ = (tipZ - hZ) / radius
const barCenterX = hX + leafX * radius * 0.7
const barCenterZ = hZ + leafZ * radius * 0.7
const barHalfLength = Math.min(0.12, Math.max(0.06, depth * 0.75))
const barX = -leafZ * barHalfLength
const barZ = leafX * barHalfLength
children.push({
kind: 'line',
x1: barCenterX - barX,
y1: barCenterZ - barZ,
x2: barCenterX + barX,
y2: barCenterZ + barZ,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2.6 : 2.2,
strokeLinecap: 'square',
vectorEffect: 'non-scaling-stroke',
})
}
}
const isDoubleLeaf = node.doorType === 'double' || node.doorType === 'french'
const isFolding = node.doorType === 'folding'
const isSliding = node.doorType === 'sliding'
const isPocket = node.doorType === 'pocket'
const isBarn = node.doorType === 'barn'
const isGarageSectional = node.doorType === 'garage-sectional'
const isGarageRollup = node.doorType === 'garage-rollup'
const isGarageTiltup = node.doorType === 'garage-tiltup'
// Swing doors get the dashed swing arc; garage and any other types
// fall through to just the opening footprint (the earlier behaviour).
const isSwingDoor = node.doorType === 'hinged' || isDoubleLeaf
// A frameless wall opening — drawn as a bare gap, no leaf / arc / panel
// (mirrors the 3D system, which renders only the cutout for openings).
const isOpening = node.openingKind === 'opening'
if (isOpening) {
// Open doorway — a frameless gap in the wall. No leaf, arc, or panel;
// the cleared footprint above is the whole symbol.
} else if (isFolding && width > 1e-3) {
// Folding / bifold door: a static accordion of panels drawn as a
// zigzag that folds toward the hinge side, occupying ~70% of the
// opening (a real folding door stacks to one side, so it never
// spans the full width). Mirrors the 3D folding geometry's
// alternating-fold panels (`panelCount = leafCount === 2 ? 2 : 4`).
// The fold span / depth are fixed so the plan symbol stays static
// regardless of the live open state.
const FOLD_SPAN_RATIO = 0.8
const panelCount = node.leafCount === 2 ? 2 : 4
const panelLen = (width * FOLD_SPAN_RATIO) / panelCount
const peakDepth = panelLen * 0.7
// Anchor the accordion's base line on the room-facing wall face (the
// edge / corner of the opening box) rather than the wall centreline,
// so the panels start from the box corner instead of its middle.
const baseOffX = perpX * halfDepth * swingSign
const baseOffZ = perpZ * halfDepth * swingSign
// Start at the hinge edge and step toward the opposite jamb.
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
const startX = cx - dirX * halfWidth * hingeTangentSign + baseOffX
const startZ = cz - dirZ * halfWidth * hingeTangentSign + baseOffZ
const stepX = dirX * panelLen * hingeTangentSign
const stepZ = dirZ * panelLen * hingeTangentSign
const peakX = perpX * peakDepth * swingSign
const peakZ = perpZ * peakDepth * swingSign
let d = ''
for (let i = 0; i <= panelCount; i++) {
const alongX = startX + stepX * i
const alongZ = startZ + stepZ * i
const isPeak = i % 2 === 1
const px = alongX + (isPeak ? peakX : 0)
const pz = alongZ + (isPeak ? peakZ : 0)
d += `${i === 0 ? 'M' : 'L'} ${px} ${pz} `
}
children.push({
kind: 'path',
d: d.trim(),
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2.4 : 1.7,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
} else if (isSliding && width > 1e-3) {
// Sliding (bypass) door: two panels on parallel tracks that slide
// past each other. Each spans a bit over half the opening and they
// overlap in the centre, drawn at slightly different depths (one
// toward each wall face). A slide arrow shows the direction. Static
// regardless of the live open state.
const slideSign = node.slideDirection === 'right' ? 1 : -1
const panelHalfThick = Math.min(depth * 0.22, 0.03)
const panelHalfLen = width * 0.275
const panelRect = (centerAlong: number, perpSign: number): [number, number][] => {
const rcx = cx + dirX * centerAlong + perpX * panelHalfThick * perpSign
const rcz = cz + dirZ * centerAlong + perpZ * panelHalfThick * perpSign
const aX = dirX * panelHalfLen
const aZ = dirZ * panelHalfLen
const tX = perpX * panelHalfThick
const tZ = perpZ * panelHalfThick
return [
[rcx - aX + tX, rcz - aZ + tZ],
[rcx + aX + tX, rcz + aZ + tZ],
[rcx + aX - tX, rcz + aZ - tZ],
[rcx - aX - tX, rcz - aZ - tZ],
]
}
const pushPanel = (points: [number, number][]) =>
children.push({
kind: 'polygon',
points,
fill: fillColor,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2 : 1.4,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
// Left panel toward one face, right panel toward the other; they
// overlap across the centre.
pushPanel(panelRect(-halfWidth + panelHalfLen, 1))
pushPanel(panelRect(halfWidth - panelHalfLen, -1))
// Slide-direction arrow, centred and pushed clear of the wall face
// so it doesn't overlap the wall or panels.
const arrowPerp = -(halfDepth + Math.max(panelHalfThick * 4, halfWidth * 0.22))
const arX = cx + perpX * arrowPerp
const arZ = cz + perpZ * arrowPerp
const tipX = arX + dirX * halfWidth * 0.5 * slideSign
const tipZ = arZ + dirZ * halfWidth * 0.5 * slideSign
const tailX = arX - dirX * halfWidth * 0.5 * slideSign
const tailZ = arZ - dirZ * halfWidth * 0.5 * slideSign
const headLen = halfWidth * 0.18
const headSpread = headLen * 0.6
const backX = tipX - dirX * headLen * slideSign
const backZ = tipZ - dirZ * headLen * slideSign
children.push({
kind: 'path',
d:
`M ${tailX} ${tailZ} L ${tipX} ${tipZ} ` +
`M ${backX + perpX * headSpread} ${backZ + perpZ * headSpread} L ${tipX} ${tipZ} ` +
`L ${backX - perpX * headSpread} ${backZ - perpZ * headSpread}`,
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeOpacity: 0.85,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
} else if (isPocket && width > 1e-3) {
// Pocket door: the leaf slides into a cavity inside the wall. Shown
// open — the leaf is tucked into the wall on the `slideDirection`
// side, and the carved pocket slot is sized to match the leaf (no
// empty cavity sticking out past it). The opening itself reads as a
// clear doorway. Static regardless of the live open state.
const slideSign = node.slideDirection === 'right' ? 1 : -1
const leafHalfThick = Math.min(depth * 0.2, 0.025)
const rectPoints = (
centerAlong: number,
halfLen: number,
halfThick: number,
): [number, number][] => {
const rcx = cx + dirX * centerAlong
const rcz = cz + dirZ * centerAlong
const aX = dirX * halfLen
const aZ = dirZ * halfLen
const tX = perpX * halfThick
const tZ = perpZ * halfThick
return [
[rcx - aX + tX, rcz - aZ + tZ],
[rcx + aX + tX, rcz + aZ + tZ],
[rcx + aX - tX, rcz + aZ - tZ],
[rcx - aX - tX, rcz - aZ - tZ],
]
}
// Show the door ~60% closed: the leaf covers 60% of the opening and
// has slid 40% of its width into the pocket. The pocket side of the
// wall stays solid (no carve) so the in-wall part of the leaf reads
// as an outline over it, matching the standard plan symbol.
const CLOSE_FRACTION = 0.6
const openOffset = (1 - CLOSE_FRACTION) * width
const leafCenter = slideSign * openOffset
// The leaf is a thin white rectangle with an outline — white-filled
// along its whole length, so the part sliding into the wall carves
// its shape out in white rather than showing the solid wall behind.
children.push({
kind: 'polygon',
points: rectPoints(leafCenter, halfWidth, leafHalfThick),
fill: '#ffffff',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2 : 1.4,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
} else if (isBarn && width > 1e-3) {
// Barn / surface-sliding door: the leaf rides a surface-mounted
// track in front of the wall. Shown as a solid panel parked over the
// wall on the slide side, a dashed ghost of its closed position over
// the opening, and a slide-direction arrow. `slideDirection` picks
// the side. Static regardless of the live open state.
const slideSign = node.slideDirection === 'right' ? 1 : -1
const panelHalfThick = Math.min(depth * 0.3, 0.04)
// Sit the panels clearly in front of the wall face with a gap, so
// they read as surface-mounted rather than embedded in the wall.
const gap = Math.max(panelHalfThick * 1.6, halfDepth * 0.6)
const faceOffset = halfDepth + gap + panelHalfThick
const frontRect = (centerAlong: number, halfLen: number): [number, number][] => {
const fcx = cx + perpX * faceOffset + dirX * centerAlong
const fcz = cz + perpZ * faceOffset + dirZ * centerAlong
const aX = dirX * halfLen
const aZ = dirZ * halfLen
const tX = perpX * panelHalfThick
const tZ = perpZ * panelHalfThick
return [
[fcx - aX + tX, fcz - aZ + tZ],
[fcx + aX + tX, fcz + aZ + tZ],
[fcx + aX - tX, fcz + aZ - tZ],
[fcx - aX - tX, fcz - aZ - tZ],
]
}
// Dashed ghost of the closed position across the opening.
children.push({
kind: 'polygon',
points: frontRect(0, halfWidth),
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeDasharray: '5 4',
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
// Solid panel parked over the wall on the slide side.
children.push({
kind: 'polygon',
points: frontRect(slideSign * width, halfWidth),
fill: accentColor,
fillOpacity: showSelectedChrome ? 0.25 : 0.18,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2 : 1.4,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
// Slide-direction arrow, pushed out beyond the panels so it clears
// them instead of sitting on top.
const arrowOffset = faceOffset + panelHalfThick + gap
const arX = cx + perpX * arrowOffset
const arZ = cz + perpZ * arrowOffset
const tipX = arX + dirX * halfWidth * 0.8 * slideSign
const tipZ = arZ + dirZ * halfWidth * 0.8 * slideSign
const tailX = arX - dirX * halfWidth * 0.8 * slideSign
const tailZ = arZ - dirZ * halfWidth * 0.8 * slideSign
const headLen = halfWidth * 0.18
const headSpread = headLen * 0.6
const backX = tipX - dirX * headLen * slideSign
const backZ = tipZ - dirZ * headLen * slideSign
children.push({
kind: 'path',
d:
`M ${tailX} ${tailZ} L ${tipX} ${tipZ} ` +
`M ${backX + perpX * headSpread} ${backZ + perpZ * headSpread} L ${tipX} ${tipZ} ` +
`L ${backX - perpX * headSpread} ${backZ - perpZ * headSpread}`,
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeOpacity: 0.85,
strokeLinecap: 'round',
strokeLinejoin: 'round',
vectorEffect: 'non-scaling-stroke',
})
} else if (isGarageSectional && width > 1e-3) {
// Garage sectional door: an overhead door that rolls up on side
// tracks. Drawn as the closed leaf across the opening (just inside
// the interior face), two tracks running into the garage, and a
// dashed ghost of the door parked at the inner end of the tracks.
// `swingDirection` picks the interior side. Static.
// Mechanism (tracks / coil) sits on the interior side — matching the
// 3D garage builders, which place it on the door-local -z side.
const interiorSign = -swingSign
const panelHalfThick = Math.min(depth * 0.22, 0.03)
const trackLen = Math.max(width * 0.55, 0.6)
const aX = dirX * halfWidth
const aZ = dirZ * halfWidth
const tX = perpX * panelHalfThick
const tZ = perpZ * panelHalfThick
const leafRect = (perpDist: number): [number, number][] => {
const rcx = cx + perpX * perpDist
const rcz = cz + perpZ * perpDist
return [
[rcx - aX + tX, rcz - aZ + tZ],
[rcx + aX + tX, rcz + aZ + tZ],
[rcx + aX - tX, rcz + aZ - tZ],
[rcx - aX - tX, rcz - aZ - tZ],
]
}
const faceDist = interiorSign * halfDepth
const innerDist = interiorSign * (halfDepth + trackLen)
// Two side tracks running into the garage interior.
for (const edgeSign of [-1, 1]) {
const ex = cx + dirX * halfWidth * edgeSign
const ez = cz + dirZ * halfWidth * edgeSign
children.push({
kind: 'line',
x1: ex + perpX * faceDist,
y1: ez + perpZ * faceDist,
x2: ex + perpX * innerDist,
y2: ez + perpZ * innerDist,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeOpacity: 0.85,
strokeDasharray: '5 4',
vectorEffect: 'non-scaling-stroke',
strokeLinecap: 'round',
})
}
// Dashed ghost of the door parked at the inner end of the tracks.
children.push({
kind: 'polygon',
points: leafRect(innerDist - interiorSign * panelHalfThick),
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeDasharray: '5 4',
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
// Closed leaf, just inside the interior wall face.
children.push({
kind: 'polygon',
points: leafRect(interiorSign * (halfDepth + panelHalfThick)),
fill: fillColor,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2 : 1.4,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
} else if (isGarageRollup && width > 1e-3) {
// Roll-up garage door: the curtain coils into a barrel just inside
// the opening (rather than running back on tracks like a sectional).
// Drawn as the closed leaf across the opening, the coil barrel — a
// capsule parallel to the wall — and a small coil hint at its centre.
// `swingDirection` picks the interior side. Static.
// Mechanism (tracks / coil) sits on the interior side — matching the
// 3D garage builders, which place it on the door-local -z side.
const interiorSign = -swingSign
const panelHalfThick = Math.min(depth * 0.22, 0.03)
const aX = dirX * halfWidth
const aZ = dirZ * halfWidth
const tX = perpX * panelHalfThick
const tZ = perpZ * panelHalfThick
// Closed leaf, just inside the interior wall face.
const leafPerp = interiorSign * (halfDepth + panelHalfThick)
const lcx = cx + perpX * leafPerp
const lcz = cz + perpZ * leafPerp
children.push({
kind: 'polygon',
points: [
[lcx - aX + tX, lcz - aZ + tZ],
[lcx + aX + tX, lcz + aZ + tZ],
[lcx + aX - tX, lcz + aZ - tZ],
[lcx - aX - tX, lcz - aZ - tZ],
],
fill: fillColor,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2 : 1.4,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
// Coil barrel — a capsule (stadium) parallel to the wall, just inside
// the leaf. Built as a polygon so it's robust to wall orientation.
const drumRadius = Math.min(Math.max(width * 0.12, 0.08), halfWidth * 0.5, 0.16)
const drumPerp = interiorSign * (halfDepth + 2 * panelHalfThick + drumRadius)
const dcx = cx + perpX * drumPerp
const dcz = cz + perpZ * drumPerp
const capL = Math.max(halfWidth - drumRadius, 0)
const SAMPLES = 8
const capsule: [number, number][] = []
const c2x = dcx + dirX * capL
const c2z = dcz + dirZ * capL
for (let i = 0; i <= SAMPLES; i++) {
const th = Math.PI / 2 - (Math.PI * i) / SAMPLES
capsule.push([
c2x + drumRadius * (Math.cos(th) * dirX + Math.sin(th) * perpX),
c2z + drumRadius * (Math.cos(th) * dirZ + Math.sin(th) * perpZ),
])
}
const c1x = dcx - dirX * capL
const c1z = dcz - dirZ * capL
for (let i = 0; i <= SAMPLES; i++) {
const ph = -Math.PI / 2 - (Math.PI * i) / SAMPLES
capsule.push([
c1x + drumRadius * (Math.cos(ph) * dirX + Math.sin(ph) * perpX),
c1z + drumRadius * (Math.cos(ph) * dirZ + Math.sin(ph) * perpZ),
])
}
children.push({
kind: 'polygon',
points: capsule,
fill: fillColor,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.6 : 1.1,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
// Coil hint — a small circle at the barrel centre.
const innerR = drumRadius * 0.45
const coil: [number, number][] = []
for (let i = 0; i <= 12; i++) {
const a = (Math.PI * 2 * i) / 12
coil.push([
dcx + innerR * (Math.cos(a) * dirX + Math.sin(a) * perpX),
dcz + innerR * (Math.cos(a) * dirZ + Math.sin(a) * perpZ),
])
}
children.push({
kind: 'polygon',
points: coil,
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeOpacity: 0.85,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
} else if (isGarageTiltup && width > 1e-3) {
// Tilt-up (up-and-over) garage door: one rigid panel that pivots at
// the top and swings up to park overhead inside the garage. Drawn as
// the closed leaf across the opening, a dashed panel parked into the
// interior, and a dashed curved swing path between them.
// `swingDirection` is ignored; like the other garage builders the
// mechanism is on the door-local -z (interior) side. Static.
const interiorSign = -swingSign
const panelHalfThick = Math.min(depth * 0.22, 0.03)
const projDepth = Math.max(width * 0.5, 0.7)
const aX = dirX * halfWidth
const aZ = dirZ * halfWidth
const tX = perpX * panelHalfThick
const tZ = perpZ * panelHalfThick
const leafRect = (perpDist: number): [number, number][] => {
const rcx = cx + perpX * perpDist
const rcz = cz + perpZ * perpDist
return [
[rcx - aX + tX, rcz - aZ + tZ],
[rcx + aX + tX, rcz + aZ + tZ],
[rcx + aX - tX, rcz + aZ - tZ],
[rcx - aX - tX, rcz - aZ - tZ],
]
}
const closedPerp = interiorSign * (halfDepth + panelHalfThick)
const parkedPerp = interiorSign * (halfDepth + projDepth)
// Dashed parked panel, projected overhead into the interior.
children.push({
kind: 'polygon',
points: leafRect(parkedPerp),
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeDasharray: '5 4',
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
// Dashed curved swing path from the closed leaf to the parked panel.
const startX = cx + perpX * closedPerp
const startZ = cz + perpZ * closedPerp
const endX = cx + perpX * parkedPerp
const endZ = cz + perpZ * parkedPerp
const midPerp = interiorSign * (halfDepth + projDepth * 0.5)
const ctrlX = cx + perpX * midPerp + dirX * projDepth * 0.5
const ctrlZ = cz + perpZ * midPerp + dirZ * projDepth * 0.5
children.push({
kind: 'path',
d: `M ${startX} ${startZ} Q ${ctrlX} ${ctrlZ} ${endX} ${endZ}`,
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeOpacity: 0.85,
strokeDasharray: '5 4',
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
// Closed leaf, just inside the interior wall face.
children.push({
kind: 'polygon',
points: leafRect(closedPerp),
fill: fillColor,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2 : 1.4,
vectorEffect: 'non-scaling-stroke',
strokeLinejoin: 'round',
})
} else if (isSwingDoor && swingAngle > 1e-3 && width > 1e-3) {
if (isDoubleLeaf) {
// Two half-width leaves hinged at the opposite outer ends, each
// swinging toward the centre. `hingesSide` is irrelevant for a
// symmetric double door; only `swingDirection` chooses the side.
const halfLeafX = dirX * halfWidth
const halfLeafZ = dirZ * halfWidth
// Leaf at the start edge: closed leaf points toward the centre.
drawSwingLeaf(cx - halfLeafX, cz - halfLeafZ, halfLeafX, halfLeafZ, swingAngle * swingSign)
// Leaf at the end edge: closed leaf points the other way, swung
// with the opposite sign so both meet perpendicular at the centre.
drawSwingLeaf(cx + halfLeafX, cz + halfLeafZ, -halfLeafX, -halfLeafZ, -swingAngle * swingSign)
} else {
// Single leaf hinged at one end, strike at the opposite end.
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
drawSwingLeaf(
cx - dirX * halfWidth * hingeTangentSign,
cz - dirZ * halfWidth * hingeTangentSign,
dirX * width * hingeTangentSign,
dirZ * width * hingeTangentSign,
swingAngle * swingSign * hingeTangentSign,
)
}
}
// Move handle — orange dot at the door center. Only visible when
// selected. Pointer-down on this triggers `setMovingNode(door)`
// → `FloorplanRegistryMoveOverlay` → `def.floorplanMoveTarget`.
if (isSelected) {
children.push({
kind: 'move-handle',
point: [cx, cz],
})
// Width-resize arrows at each side of the door (along the wall
// direction). Pointer-down on either routes through the door's
// `resize-width` affordance — anchored at the opposite edge, clamped
// to wall bounds. Mirrors the 3D `DoorSideArrow` width drag.
const startEdgeX = cx - dirX * halfWidth
const startEdgeZ = cz - dirZ * halfWidth
const endEdgeX = cx + dirX * halfWidth
const endEdgeZ = cz + dirZ * halfWidth
children.push({
kind: 'move-arrow',
point: [startEdgeX, startEdgeZ],
angle: Math.atan2(-dirZ, -dirX),
affordance: 'resize-width',
payload: { side: 'start' },
})
children.push({
kind: 'move-arrow',
point: [endEdgeX, endEdgeZ],
angle: Math.atan2(dirZ, dirX),
affordance: 'resize-width',
payload: { side: 'end' },
})
}
// Placement-measurement dimensions — distances to adjacent openings
// (or wall ends) on each side. Only visible while actively moving
// (the user clicked Move or grabbed the orange dot).
if (view?.moving && readFloorplanContext(ctx).automaticDimensions) {
for (const dim of buildOpeningPlacementDimensions(node, ctx)) {
children.push(dim)
}
}
const markAnnotation = buildOpeningMarkAnnotation(
node,
wall,
ctx.levelData as OpeningFloorplanLevelData | undefined,
{
preferredSide: swingSign === 1 ? -1 : 1,
stroke: showSelectedChrome ? '#f97316' : '#334155',
},
)
if (markAnnotation) children.push(markAnnotation)
return { kind: 'group', children: children.map(markDoorPlanObstacle) }
}
function markDoorPlanObstacle(geometry: FloorplanGeometry): FloorplanGeometry {
if (geometry.kind === 'group') {
const isOpeningMark = readFloorplanGeometryMetadata(geometry).annotationRole === 'opening-mark'
return isOpeningMark
? geometry
: { ...geometry, children: geometry.children.map(markDoorPlanObstacle) }
}
if (
geometry.kind === 'path' ||
geometry.kind === 'polygon' ||
geometry.kind === 'polyline' ||
geometry.kind === 'rect' ||
geometry.kind === 'circle' ||
geometry.kind === 'line'
) {
return withFloorplanGeometryMetadata(geometry, { annotationObstacle: 'bounds' })
}
return geometry
}
/**
* The opening's wall-normal orientation is encoded in the door's Y
* rotation. When the door faces "inward" along an angle in [π/2, 3π/2],
* the rendering needs the hinge side + swing direction flipped to
* keep the visual swing on the correct side of the wall.
*
* Mirrors `isOpeningPlanFlipped` in `floorplan-panel.tsx`.
*/
function isOpeningPlanFlipped(rotation: readonly [number, number, number]): boolean {
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
}