arch: enforce layer boundaries — ceiling dispatch, store relocation, shared helper (#382)
* 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 * editor: per-door-type floor-plan symbols Render a distinct, static plan symbol for each door type in the registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`), independent of the door's live open/close animation: - single / hinged: fixed 90° swing with a dashed quarter-circle arc - double / french: two mirrored half-width leaves + dashed arcs - folding / bifold: static zigzag accordion (~80% span) on the wall face - sliding: bypass — two overlapping panels on parallel tracks + arrow - pocket: thin white leaf, ~60% closed, sliding into the solid wall - barn: surface-mounted panel parked over the wall, dashed closed-ghost + slide arrow The swing arc is dashed in screen-pixel units (the renderer uses non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection / slideDirection as appropriate. Also includes pre-existing working-tree changes unrelated to the door symbols: group move/rotate transform and box-select tweaks, and a regenerated ifc-converter next-env.d.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix recessed ceiling fixtures and draw safety * feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D) Snap the wall draft / endpoint-move point onto existing wall geometry — corners, midpoints, wall–wall intersections, and along-wall edges — and show a beacon at the snap point whose glyph encodes what it caught (square = corner, triangle = midpoint, ✕ = intersection, circle = edge). - Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested). - Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon and a 2D SVG glyph beacon, both indigo to match the alignment guides. - Gated by a new persisted "Magnetic snap" toggle in the Display menu (useEditor); honored by draw + commit + endpoint-move in both views. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * editor: garage and open-doorway floor-plan symbols Extend the per-door-type plan symbols in the registry floor-plan builder (packages/nodes/src/door/floorplan.ts): - open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel (mirrors the 3D system, which renders only the cutout for openings) - garage sectional: closed leaf + side tracks into the garage + dashed parked ghost at the inner end - garage roll-up: closed leaf + coil barrel (capsule) with a coil hint - garage tilt-up: closed leaf + dashed parked panel + dashed curved up-and-over swing path - gate the swing arc to actual swing doors (hinged/double/french) so other types fall back to the plain footprint Garage mechanisms sit on the interior (door-local -z) side to match the 3D garage builders, independent of swingDirection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * arch: enforce layer boundaries — registry dispatch, store relocation, shared helper Three architectural fixes to bring the branch into full compliance: 1. **ceiling-system kind check → CeilingCutCapability** Replace `child.type === 'item'` branch in `ceiling-system` with registry dispatch. Add `CeilingCutCapability` type to `packages/core` registry types, implement `buildCeilingHole` on `itemDefinition`, and rewrite `collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through `nodeRegistry` — viewer never again inspects a node's kind directly. 2. **useAlignmentGuides + useWallSnapIndicator → packages/editor** These stores are editor-only UI (snap beacons, alignment guides). Move them from `packages/core/src/store/` to `packages/editor/src/store/`, re-export from `packages/editor`, and update all 34 consumer files across `packages/editor` and `packages/nodes` to import from `@pascal-app/editor`. 3. **findLevelAncestorId extracted to core** `item-light-system` had a private `resolveNodeLevelId` that duplicated level-ancestor traversal logic. Extract it as `findLevelAncestorId` in `packages/core` (spatial-grid-sync), export it, and replace the local copy. All four packages typecheck cleanly (zero errors). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: fix lint, untrack .claude/launch.json Run bun check --write to clear 8 Biome errors (formatting + import order + one unused import). Untrack .claude/launch.json and add it plus .claude/settings.local.json to .gitignore so local IDE/agent configs stop landing in commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7796837081
commit
ce6f999310
@@ -13,12 +13,22 @@ import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dim
|
||||
*
|
||||
* 1. The door footprint rectangle in the wall cutout (themed
|
||||
* accent stroke when selected).
|
||||
* 2. The door swing arc — a quarter-circle from the hinge to the
|
||||
* door's open position, modulated by `swingAngle`, `hingesSide`,
|
||||
* and `swingDirection`. Renders as a wedge of low-opacity fill so
|
||||
* the swept area reads at a glance.
|
||||
* 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).
|
||||
*
|
||||
@@ -65,7 +75,12 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
? 'outward'
|
||||
: 'inward'
|
||||
: baseSwingDirection
|
||||
const swingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0))
|
||||
// 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[] = [
|
||||
@@ -103,53 +118,49 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
},
|
||||
]
|
||||
|
||||
// Swing geometry. The hinge sits at one end of the door along the
|
||||
// wall direction; the strike sits at the opposite end. The leaf
|
||||
// rotates around the hinge by `swingAngle` toward the inward /
|
||||
// outward side of the wall.
|
||||
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
|
||||
// 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 hingeX = cx - dirX * halfWidth * hingeTangentSign
|
||||
const hingeZ = cz - dirZ * halfWidth * hingeTangentSign
|
||||
// Closed leaf vector points from hinge to strike (along the wall).
|
||||
const closedLeafX = dirX * width * hingeTangentSign
|
||||
const closedLeafZ = dirZ * width * hingeTangentSign
|
||||
|
||||
if (swingAngle > 1e-3 && width > 1e-3) {
|
||||
// Rotate the closed leaf vector by `swingAngle * swingSign *
|
||||
// hingeTangentSign` around the hinge to get the open leaf tip.
|
||||
const angle = swingAngle * swingSign * hingeTangentSign
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
const openLeafX = closedLeafX * cos - closedLeafZ * sin
|
||||
const openLeafZ = closedLeafX * sin + closedLeafZ * cos
|
||||
const tipX = hingeX + openLeafX
|
||||
const tipZ = hingeZ + openLeafZ
|
||||
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
|
||||
|
||||
// Closed leaf tip — where the leaf would land if fully closed.
|
||||
const closedTipX = hingeX + closedLeafX
|
||||
const closedTipZ = hingeZ + closedLeafZ
|
||||
// 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 — a path from closed tip to open tip via an arc
|
||||
// centered at the hinge. SVG's A command takes rx ry rotation
|
||||
// large-arc-flag sweep-flag x y. Sweep flag flips based on the
|
||||
// signed angle direction.
|
||||
const sweepFlag = angle >= 0 ? 1 : 0
|
||||
const arcPath = `M ${closedTipX} ${closedTipZ} A ${width} ${width} 0 0 ${sweepFlag} ${tipX} ${tipZ}`
|
||||
// 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) — gives the door a
|
||||
// visible "this is the open zone" treatment.
|
||||
// Swept wedge fill (light, low opacity) — reads as the open zone.
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: `M ${hingeX} ${hingeZ} L ${closedTipX} ${closedTipZ} ${arcPath
|
||||
.replace(/^M [^A]+/, '')
|
||||
.trim()} Z`,
|
||||
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, stroked.
|
||||
// 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,
|
||||
@@ -157,6 +168,9 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
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',
|
||||
})
|
||||
@@ -164,8 +178,8 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
// The door leaf — line from hinge to the open tip.
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: hingeX,
|
||||
y1: hingeZ,
|
||||
x1: hX,
|
||||
y1: hZ,
|
||||
x2: tipX,
|
||||
y2: tipZ,
|
||||
stroke: accentColor,
|
||||
@@ -175,6 +189,497 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
})
|
||||
}
|
||||
|
||||
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`.
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
isValidWallSideFace,
|
||||
stripPlacementMetadataFlags,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useAlignmentGuides,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
} from '@pascal-app/core'
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
triggerSFX,
|
||||
useAlignmentGuides,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
Reference in New Issue
Block a user