From bf25af6addc95ad19c27bc418ff7d5f4280bf2fb Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Wed, 1 Jul 2026 02:11:13 +0530 Subject: [PATCH] viewer: Fix Dutch roof trim artifacts (#452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * fixed conflict * Fix spiral stair openings and fence handle arrows * Implement roof trim planes and ridge vent clipping * Fix mansard roof and ridge vent placement * Fix mansard merged roof cutouts * Fix Dutch roof gable overhang * Refactor roof segment, ridge vent, and surface geometry Remove Dutch ridge axis abstraction and rework roof edit system, ridge vent clipping geometry, and roof surface placement. Co-Authored-By: Claude Opus 4.7 * Simplify Dutch roof shape * Add Dutch roof gable top geometry controls * Fix Dutch roof slope material slots * Render dutch roof tops as double-sided faces * Add auto ridge vent toggle to roof segments Track ridge vent auto-generation via an `autoRidgeVent` metadata flag so geometry changes only regenerate default vents when enabled, treating legacy segments with generated vents as auto-enabled for back-compat. Expose a panel toggle to opt in/out per segment. Co-Authored-By: Claude Opus 4.7 * Snap new walls to the floor below Feed the walls of the level directly beneath the active one into the draft snap pipeline as extra references, so a new wall can align with the floor below. They share the same local XZ origin, and the list is kept separate from the current-level walls so the measurement HUD and wall splitting only act on the active level. Co-Authored-By: Claude Opus 4.7 * Set Dutch roof shape defaults on type switch Seed the Dutch shape parameters (waist width/height/length, top rake thickness/length) with sensible defaults whenever a segment is created as or switched to Dutch, so the gablet is well-formed regardless of leftover values from the previous roof type. Co-Authored-By: Claude Opus 4.7 * Use green accent for corner and endpoint snap markers Color the corner/endpoint snap markers and the vertical cursor pillar green across the 2D floorplan beacon, the 3D alignment guide dots, and the wall snap beacon so snap targets read as a consistent accent. Co-Authored-By: Claude Opus 4.7 * Add magnetic wall snapping to the roof tool Snap roof draft corners onto wall corners, midpoints, crossings, and bodies on the active level and the floor below, reusing the wall tool's snap pipeline so the beacon and coloring match. The cursor's ground dot/ring is hidden while a wall snap is active to avoid overlapping the beacon glyph. Co-Authored-By: Claude Opus 4.7 * Update auto-generated Next.js route types path Regenerated next-env.d.ts now references ./.next/dev/types/routes.d.ts. Co-Authored-By: Claude Opus 4.7 * Show cutaway outline while dragging roof trim Slice an untrimmed segment volume generated from the live node instead of the registry mesh, whose CSG rebuild lags a few frames behind the drag and may still hold placeholder geometry — so the section outline now renders deterministically. Use LineBasicNodeMaterial so the outline draws under the WebGPU pipeline, and export generateRoofSegmentGeometry for the slice source. Co-Authored-By: Claude Opus 4.7 * Fill and clip roof trim cutaway, gate it to active drag Add a violet silhouette fill behind the cutaway outline, extend the section slicing to angled diagonal/corner trims via a generic vertical cut plane, and clip each slice to its footprint span so the infinite plane no longer sprouts stray lines across the rest of the roof. The cutaway now renders only while a trim handle is being dragged. Co-Authored-By: Claude Opus 4.7 * Separate and extend Dutch roof end slopes Pull the Dutch hip end slopes out of the watertight shingle shell into their own slab wedge so they can be reshaped independently, and extend each end slope inward up its own hip plane until the top edge meets the gablet's inner triangle. Refactor roof-segment shape geometry into a shared roof-segment-shape module. Co-Authored-By: Claude Opus 4.7 * Render roof trim cutaway as a material-only section cut Replace the triangle-mesh slicer with a CSG intersection of a thin slab against the untrimmed roof shell, so the cutaway shows red only on real material (wall + deck bands) and leaves the hollow attic empty. Add an analytic surface-edge outline, style both solid red like a SketchUp section, and make the cutaway persist whenever a segment is trimmed. Keep the merged roof shell visible during trim editing (re-trimmed live from each segment's drag override) instead of swapping in the per-segment meshes, whose abutting end-cap faces showed as stray white planes the commit never had. Extend each slab past free cut-line ends only — trimmed ends clamp to the cut line — so the red section stays inside the trim box. Re-export INTERSECTION from the viewer CSG surface for the editor. * Outline roof cutaway by fill silhouette, restyle to destructive red Derive the section-cut outline from the fill geometry's edges (EdgesGeometry) so it traces the real cut shape — wall/deck band boundaries and the hollow-attic edge — instead of just the top surface line. Drop the fill to 85% opacity and recolor both fill and outline to the app's destructive red, matching the delete/destructive UI. * Include roof accessories in trim clipping and red cutaway Roof accessories (chimney, vents, skylight, dormer, gutter, downspout, solar-panel, cupola) now slice at the trim plane like the roof shell and appear in the red section-cut while dragging a trim handle: - Export clipGeometryBySegmentTrim from the viewer as a reusable segment-local trim-clip primitive. - Add a shared useSegmentTrimClippedGeometry hook + TrimClippedMesh wrapper (nodes) that slice accessory geometry by the host segment's live trim override, so the cut tracks the drag. - Wire the clip into all 11 accessory renderers, including skylight glass panes and dormer window glass/frame/sill. - Feed every hosted accessory mesh into the editor's red cutaway, welding triangle-soup geometry (e.g. ridge vent) so CSG INTERSECTION yields a cross-section. - Register skylight in the scene-graph tree-node map so it shows in the outliner when placed on a roof. Co-Authored-By: Claude * Add smooth spline fences with editable curve handles Fences can now be drawn as one continuous Catmull-Rom/Bezier curve via an optional `path` (+ per-point `tangents`), selectable in a Straight/Curved mode toggle. Selected spline fences expose draggable control-point dots (hexagon) and symmetric tangent handles (circle) joined by a violet line, editable in both 2D plan and 3D. Side-move arrows are dropped for splines. Co-Authored-By: Claude * Fix dutch roof ridge vent handling * Fix Dutch ridge vent placement and support * Fix Dutch roof trim artifacts * Fix Dutch roof trim preview geometry * Tag roof trim overlay meshes with EDITOR_LAYER Child meshes relied on a parent group's layer, which three.js does not propagate, so the trim section/rail/plane overlays rendered on the scene layer — getting inked/SSGI-darkened and leaking into thumbnail exports. Co-Authored-By: Claude * Apply Biome cleanup * fix(core): address Dutch roof review feedback * chore: apply biome check cleanup * fix(core): relax Dutch roof surface helper input * fix * Fix biome checks and dev verification * fixes * Remove unsupported Biome noShadow override * Improve roof interactions and fence editing * Fix fence drag and ridge vent default handling * editor: drop wall-snap debug log, gate curved-fence finish hint on draft start Remove the leftover TEMP DIAGNOSTIC console.log in the wall tool's onMove hot path. Curved fences commit on a closing gesture (double-click / Enter) rather than per-click, so surface a 'Finish curve' hint in the fence HUD — but only once a point has been placed and a curve is actually in flight. The draft point count is published from SplineFenceDraft into a small ephemeral editor store (useFenceCurveDraft) that the contextual helper reads, mirroring the existing useSegmentDraftChain pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Wassim SAMAD --- apps/editor/components/build-tab.tsx | 4 +- apps/ifc-converter/next-env.d.ts | 2 +- biome.jsonc | 3 - packages/core/src/index.ts | 13 + packages/core/src/registry/handles.ts | 16 + packages/core/src/schema/index.ts | 30 +- packages/core/src/schema/nodes/fence.ts | 17 +- .../schema/nodes/ridge-vent-defaults.test.ts | 275 +++ packages/core/src/schema/nodes/ridge-vent.ts | 274 ++- .../schema/nodes/roof-segment-shape.test.ts | 229 +++ .../src/schema/nodes/roof-segment-shape.ts | 441 +++++ .../schema/nodes/roof-segment-surface.test.ts | 66 + .../schema/nodes/roof-segment-trim.test.ts | 126 ++ .../schema/nodes/roof-segment-walls.test.ts | 36 + .../src/schema/nodes/roof-segment-walls.ts | 30 +- .../core/src/schema/nodes/roof-segment.ts | 390 +++- .../core/src/store/actions/node-actions.ts | 71 + .../store/actions/ridge-vent-update.test.ts | 370 ++++ .../src/systems/elevator/elevator-service.ts | 2 +- .../src/systems/fence/fence-centerline.ts | 61 + .../src/systems/fence/fence-spline.test.ts | 176 ++ .../core/src/systems/fence/fence-spline.ts | 257 +++ .../core/src/systems/stair/stair-footprint.ts | 36 +- .../systems/stair/stair-opening-sync.test.ts | 44 + .../src/systems/stair/stair-opening-sync.ts | 22 +- packages/core/src/utils/clone-scene-graph.ts | 2 +- .../floorplan-registry-move-overlay.tsx | 14 +- .../editor-2d/floorplan-snap-beacon-layer.tsx | 8 +- .../renderers/floorplan-registry-layer.tsx | 10 + .../editor/alignment-3d-guide-layer.tsx | 12 +- .../editor/custom-camera-controls.tsx | 7 +- .../editor/fence-tangent-lines-3d.tsx | 87 + .../editor/floating-action-menu.tsx | 5 +- .../editor/floating-building-action-menu.tsx | 2 +- .../src/components/editor/floorplan-panel.tsx | 159 +- .../editor/handles/handle-arrow.tsx | 31 +- .../editor/src/components/editor/index.tsx | 2 + .../components/editor/node-arrow-handles.tsx | 7 +- .../components/editor/selection-manager.tsx | 66 +- .../editor/use-floorplan-scene-data.ts | 10 +- .../editor/wall-snap-beacon-layer.tsx | 17 +- .../systems/roof/roof-edit-system.tsx | 1764 ++++++++++++++++- .../tools/elevator/elevator-tool.tsx | 2 +- .../components/tools/fence/fence-drafting.ts | 42 + .../tools/item/use-placement-coordinator.tsx | 11 +- .../registry/move-registry-node-tool.tsx | 2 +- .../src/components/tools/roof/roof-tool.tsx | 419 +++- .../tools/select/select-candidates.ts | 2 +- .../components/tools/shared/cursor-sphere.tsx | 45 +- .../components/tools/stair/stair-defaults.ts | 1 + .../src/components/tools/stair/stair-tool.tsx | 5 +- .../src/components/tools/tool-manager.tsx | 34 + .../ui/helpers/contextual-helper-panel.tsx | 66 +- .../components/ui/helpers/helper-manager.tsx | 4 + .../components/ui/panels/panel-wrapper.tsx | 18 +- .../panels/site-panel/roof-tree-node.tsx | 6 +- .../sidebar/panels/site-panel/tree-node.tsx | 1 + .../editor/src/hooks/use-ceiling-events.ts | 2 +- packages/editor/src/hooks/use-keyboard.ts | 30 + packages/editor/src/index.tsx | 4 + packages/editor/src/lib/continuation.ts | 14 +- .../src/lib/direct-manipulation.test.ts | 13 + .../editor/src/lib/direct-manipulation.ts | 15 + packages/editor/src/lib/editor-api.ts | 12 +- .../src/lib/floorplan/apply-alignment.ts | 6 +- packages/editor/src/lib/glb-export.ts | 2 +- packages/editor/src/lib/interaction/scope.ts | 37 +- packages/editor/src/lib/level-selection.ts | 4 +- packages/editor/src/lib/roof-duplication.ts | 12 +- .../src/lib/roof-hover-outline-proxy.ts | 22 + packages/editor/src/lib/scene-clipboard.ts | 2 +- .../editor/src/lib/selection-routing.test.ts | 39 + packages/editor/src/lib/selection-routing.ts | 17 + packages/editor/src/lib/snapping-mode.test.ts | 2 +- packages/editor/src/lib/stair-duplication.ts | 8 +- packages/editor/src/lib/surface-plan-snap.ts | 12 +- packages/editor/src/lib/world-grid-snap.ts | 38 +- packages/editor/src/store/use-editor.tsx | 4 + .../editor/src/store/use-fence-curve-draft.ts | 21 + .../editor/src/store/use-interaction-scope.ts | 8 + packages/ifc-converter/src/index.ts | 4 +- packages/mcp/src/resources/constraints.ts | 2 +- .../mcp/src/tools/construction-tools.test.ts | 42 + packages/mcp/src/tools/construction-tools.ts | 2 +- packages/mcp/src/tools/find-nodes.ts | 2 +- packages/mcp/src/tools/scene-query.ts | 4 +- packages/nodes/src/box-vent/floorplan.ts | 4 +- packages/nodes/src/box-vent/renderer.tsx | 16 +- packages/nodes/src/chimney/floorplan.ts | 4 +- packages/nodes/src/chimney/panel.tsx | 2 +- packages/nodes/src/chimney/renderer.tsx | 40 +- packages/nodes/src/column/floorplan-move.ts | 58 +- packages/nodes/src/cupola/floorplan.ts | 4 +- packages/nodes/src/cupola/renderer.tsx | 16 +- packages/nodes/src/door/floorplan.ts | 2 +- packages/nodes/src/dormer/csg-geometry.ts | 18 + packages/nodes/src/dormer/floorplan.ts | 4 +- .../src/dormer/panel-position-section.tsx | 2 +- packages/nodes/src/dormer/renderer.tsx | 25 +- packages/nodes/src/dormer/window-assembly.tsx | 91 +- .../nodes/src/downspout/inspector-editors.tsx | 2 +- packages/nodes/src/downspout/renderer.tsx | 28 +- packages/nodes/src/downspout/routing.ts | 2 +- packages/nodes/src/duct-terminal/tool.tsx | 2 +- packages/nodes/src/eyebrow-vent/floorplan.ts | 4 +- packages/nodes/src/eyebrow-vent/renderer.tsx | 16 +- .../nodes/src/fence/actions/move-endpoint.ts | 2 +- packages/nodes/src/fence/definition.ts | 92 +- .../nodes/src/fence/floorplan-affordances.ts | 129 +- packages/nodes/src/fence/floorplan-move.ts | 81 +- packages/nodes/src/fence/floorplan.ts | 205 +- .../src/fence/move-control-point-tool.tsx | 129 ++ .../nodes/src/fence/move-tangent-tool.tsx | 132 ++ packages/nodes/src/fence/move-tool.tsx | 147 +- packages/nodes/src/fence/parametrics.ts | 18 +- packages/nodes/src/fence/tool.tsx | 143 +- .../nodes/src/gutter/downspouts-panel.tsx | 2 +- packages/nodes/src/gutter/floorplan.ts | 6 +- packages/nodes/src/gutter/length-snap.ts | 2 +- packages/nodes/src/gutter/renderer.tsx | 19 +- packages/nodes/src/item/paint.ts | 2 +- packages/nodes/src/liquid-line/tool.tsx | 2 +- packages/nodes/src/pipe-trap/definition.ts | 2 +- .../src/ridge-vent/__tests__/geometry.test.ts | 340 +++- packages/nodes/src/ridge-vent/definition.ts | 4 +- packages/nodes/src/ridge-vent/floorplan.ts | 4 +- packages/nodes/src/ridge-vent/geometry.ts | 651 +++++- packages/nodes/src/ridge-vent/move-tool.tsx | 22 +- packages/nodes/src/ridge-vent/renderer.tsx | 175 +- packages/nodes/src/ridge-vent/tool.tsx | 31 +- .../nodes/src/roof-segment/floorplan.test.ts | 139 ++ packages/nodes/src/roof-segment/floorplan.ts | 48 +- packages/nodes/src/roof-segment/panel.tsx | 161 +- packages/nodes/src/roof/panel.tsx | 14 +- packages/nodes/src/shared/move-roof-tool.tsx | 95 +- .../shared/opening-placement-dimensions.ts | 4 +- .../src/shared/pipe-run-translation-offset.ts | 2 +- .../nodes/src/shared/pipe-vertical-offset.ts | 6 +- packages/nodes/src/shared/ridge-snap.test.ts | 98 + packages/nodes/src/shared/ridge-snap.ts | 79 +- packages/nodes/src/shared/roof-face-host.tsx | 2 +- .../nodes/src/shared/roof-opening-host.ts | 2 +- packages/nodes/src/shared/roof-segment-hit.ts | 5 +- .../shared/roof-surface-placement-guides.ts | 44 +- .../nodes/src/shared/roof-surface.test.ts | 37 + packages/nodes/src/shared/roof-surface.ts | 408 ++-- .../src/shared/run-translation-offset.ts | 2 +- .../src/shared/use-segment-trim-clip.tsx | 202 ++ packages/nodes/src/shared/vertical-offset.ts | 6 +- packages/nodes/src/skylight/floorplan.ts | 4 +- packages/nodes/src/skylight/panel.tsx | 2 +- packages/nodes/src/skylight/renderer.tsx | 134 +- packages/nodes/src/slab/move-tool.tsx | 5 +- .../solar-panel/__tests__/geometry.test.ts | 16 +- packages/nodes/src/solar-panel/floorplan.ts | 4 +- packages/nodes/src/solar-panel/renderer.tsx | 31 +- packages/nodes/src/stair-segment/panel.tsx | 2 +- .../nodes/src/stair/floorplan-affordances.ts | 4 +- packages/nodes/src/stair/panel.tsx | 44 +- packages/nodes/src/stair/renderer.tsx | 2 +- packages/nodes/src/turbine-vent/floorplan.ts | 4 +- packages/nodes/src/turbine-vent/renderer.tsx | 33 +- packages/nodes/src/wall/floorplan-move.ts | 4 +- .../nodes/src/wall/floorplan-overrides.ts | 2 +- packages/nodes/src/wall/tool.tsx | 53 +- packages/nodes/src/window/floorplan.ts | 2 +- .../viewer/glb-walkthrough-controller.tsx | 4 - packages/viewer/src/index.ts | 3 + packages/viewer/src/lib/csg-utils.ts | 6 +- .../src/systems/ceiling/ceiling-system.tsx | 2 +- .../viewer/src/systems/door/door-system.tsx | 4 +- .../viewer/src/systems/fence/fence-system.tsx | 271 ++- .../viewer/src/systems/item/item-system.tsx | 2 +- .../viewer/src/systems/roof/roof-system.tsx | 1350 +++++++++++-- .../viewer/src/systems/slab/slab-system.tsx | 2 +- .../viewer/src/systems/stair/stair-system.tsx | 6 +- .../viewer/src/systems/wall/wall-cutout.tsx | 4 +- .../viewer/src/systems/wall/wall-system.tsx | 6 +- .../src/systems/window/window-system.tsx | 4 +- 179 files changed, 10809 insertions(+), 1467 deletions(-) create mode 100644 packages/core/src/schema/nodes/ridge-vent-defaults.test.ts create mode 100644 packages/core/src/schema/nodes/roof-segment-shape.test.ts create mode 100644 packages/core/src/schema/nodes/roof-segment-shape.ts create mode 100644 packages/core/src/schema/nodes/roof-segment-surface.test.ts create mode 100644 packages/core/src/schema/nodes/roof-segment-trim.test.ts create mode 100644 packages/core/src/store/actions/ridge-vent-update.test.ts create mode 100644 packages/core/src/systems/fence/fence-centerline.ts create mode 100644 packages/core/src/systems/fence/fence-spline.test.ts create mode 100644 packages/core/src/systems/fence/fence-spline.ts create mode 100644 packages/editor/src/components/editor/fence-tangent-lines-3d.tsx create mode 100644 packages/editor/src/lib/roof-hover-outline-proxy.ts create mode 100644 packages/editor/src/store/use-fence-curve-draft.ts create mode 100644 packages/nodes/src/fence/move-control-point-tool.tsx create mode 100644 packages/nodes/src/fence/move-tangent-tool.tsx create mode 100644 packages/nodes/src/roof-segment/floorplan.test.ts create mode 100644 packages/nodes/src/shared/ridge-snap.test.ts create mode 100644 packages/nodes/src/shared/use-segment-trim-clip.tsx diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 0ea0b355..bb476542 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -99,7 +99,6 @@ const MEP_ITEMS: MepItem[] = [ { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, - { id: 'pipe-trap', label: 'Trap', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-trap' }, ] /** @@ -158,6 +157,7 @@ const MEP_TOOL_KINDS = new Set([ ...MEP_ITEMS.map((item) => item.kind), 'duct-fitting', 'pipe-fitting', + 'pipe-trap', ]) export function BuildTab() { @@ -465,7 +465,7 @@ export function BuildTab() { aria-hidden className="size-4 object-contain" height={16} - src="/icons/dwv-pipes.png" + src="/icons/dwv-pipes.webp" width={16} /> Add Trap diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/biome.jsonc b/biome.jsonc index 9b347988..c7257872 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -81,9 +81,6 @@ "noNoninteractiveElementInteractions": "off", "useButtonType": "off" }, - "nursery": { - "noShadow": "off" - }, "security": { "noDangerouslySetInnerHtml": "info" } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc35f98f..81a90f15 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -194,6 +194,19 @@ export { resolveElevatorServiceLevelIds, resolveElevatorServiceLevels, } from './systems/elevator/elevator-service' +export { + getFenceCenterlineFrameAt, + getFenceCenterlineLength, + sampleFenceCenterline, +} from './systems/fence/fence-centerline' +export { + getFenceControlHandle, + getFenceSplineFrameAt, + getFenceSplineLength, + getTwoPointFenceCurveTangents, + isSplineFence, + sampleFenceSpline, +} from './systems/fence/fence-spline' export { type StairFootprintAABB, stairFootprintAABB } from './systems/stair/stair-footprint' export { createSurfaceOpeningPreviewController } from './systems/stair/stair-opening-preview' export { syncAutoStairOpenings } from './systems/stair/stair-opening-sync' diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index a248b54d..0b0bb5f6 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -55,6 +55,17 @@ export type EditorApi = { * fences). No-ops for kinds without endpoints. */ engageEndpointMove: (node: AnyNode, endpoint: 'start' | 'end') => void + /** + * Engage drag of a spline control point (`path[index]`). Used by spline + * fences to reshape their centerline. No-ops for kinds without a path. + */ + engageControlPointMove: (node: AnyNode, index: number) => void + /** + * Engage drag of a spline tangent handle (`path[index]`, which end). Used by + * spline fences to bend the curve through one control point. No-ops for kinds + * without tangents. + */ + engageTangentMove: (node: AnyNode, index: number, side: 'in' | 'out') => void } export type HandlePortal = 'self' | 'parent' | 'grandparent' @@ -323,6 +334,11 @@ export type TapActionHandle = { * drag, so the move tool's own preview / ticker feedback shows up. */ shape?: 'arrow' | 'corner-picker' | 'move-cross' + /** + * `shape: 'corner-picker'` only — render the disc and its outer ring as a + * circle instead of the default hexagon. + */ + round?: boolean /** * Required when `shape: 'corner-picker'` — controls the dashed leader's * vertical extent. Pure callback so the descriptor doesn't need to diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index e9d270dc..b0f0d4cd 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -98,25 +98,53 @@ export { PipeFittingNode } from './nodes/pipe-fitting' export { PipeSegmentNode } from './nodes/pipe-segment' export { PipeTrapNode } from './nodes/pipe-trap' // Nodes -export { RidgeVentNode } from './nodes/ridge-vent' +export { + createDefaultRidgeVentsForSegment, + getRidgeVentLinesForSegment, + hasAutoRidgeVentMetadata, + isAutoRidgeVentEnabled, + isDefaultRidgeVentNode, + type RidgeVentLine, + RidgeVentNode, +} from './nodes/ridge-vent' export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' export type { + DutchRoofMetrics, RoofSegmentSurfaceMaterialRole, RoofSegmentSurfaceMaterialSpec, + RoofSegmentVisibleTopBounds, SegmentSlopeFrame, } from './nodes/roof-segment' export { getActiveRoofHeight, + getDutchRoofMetrics, getEffectiveSegmentSurfaceMaterial, getPitchFromActiveRoofHeight, getRoofSegmentSurfaceY, + getRoofSegmentVisibleTopBounds, getSegmentSlopeFrame, hasSegmentMaterialOverride, + MIN_ROOF_SEGMENT_TRIM_SPAN, + normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, RoofSegmentNode, + RoofSegmentTrim, RoofType, } from './nodes/roof-segment' +export type { + DutchRoofShapeMetrics, + RoofShapeFaceVertex, + RoofShapeInsets, + RoofShapeRatios, +} from './nodes/roof-segment-shape' +export { + getDutchEndSlopeFaces, + getDutchRoofShapeMetrics, + getRoofModuleFaces, + getRoofShapeInsets, + getRoofShapeRatios, +} from './nodes/roof-segment-shape' export type { RoofSegmentWallFace, RoofWallFaceId } from './nodes/roof-segment-walls' export { clampRectToRoofWallFace, diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index 641fd3d0..e8e76874 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -17,6 +17,19 @@ export const FenceNode = BaseNode.extend({ slots: z.record(z.string(), z.string()).optional(), start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), + // Optional spline control points in level coordinate meters. When present + // (>= 2 points) the fence centerline is a smooth Catmull-Rom curve through + // these points and start/end/curveOffset no longer define the centerline. + // start/end are kept in sync with the first/last path point so consumers + // that read endpoints (handles, bbox, miter references) stay valid. Absent = + // the straight or single-arc fence defined by start/end (+ curveOffset). + path: z.array(z.tuple([z.number(), z.number()])).optional(), + // Optional per-control-point tangent handles, parallel to `path` (same + // length when present). Each entry is the OUT-handle offset vector [dx, dy] + // from its path point, in level meters; the IN handle is its mirror so the + // curve stays smooth through the point. `null` = use the automatic + // Catmull-Rom tangent for that point. Only meaningful for spline fences. + tangents: z.array(z.tuple([z.number(), z.number()]).nullable()).optional(), height: z.number().default(1.8), thickness: z.number().default(0.08), curveOffset: z.number().optional(), @@ -38,8 +51,10 @@ export const FenceNode = BaseNode.extend({ dedent` Fence node - used to represent a fence segment in the building/site level coordinate system - start/end: fence endpoints in level coordinate system + - path: optional list of [x, y] points; when set (>= 2) the centerline is a smooth spline through them + - tangents: optional per-point handle vectors (parallel to path); null entries fall back to the automatic tangent - height/thickness: overall fence dimensions in meters - - curveOffset: midpoint sagitta offset used to bend the fence into an arc + - curveOffset: midpoint sagitta offset used to bend the fence into an arc (ignored when path is set) - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model - groundClearance/edgeInset/baseStyle: fence support and inset configuration - showInfill: whether to draw intermediate posts/slats between end posts diff --git a/packages/core/src/schema/nodes/ridge-vent-defaults.test.ts b/packages/core/src/schema/nodes/ridge-vent-defaults.test.ts new file mode 100644 index 00000000..024992de --- /dev/null +++ b/packages/core/src/schema/nodes/ridge-vent-defaults.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, test } from 'bun:test' +import { + createDefaultRidgeVentsForSegment, + getRidgeVentLinesForSegment, + isAutoRidgeVentEnabled, + isDefaultRidgeVentNode, + RidgeVentNode, +} from './ridge-vent' +import { + getDutchRoofMetrics, + getRoofSegmentVisibleTopBounds, + ROOF_SHAPE_DEFAULTS, + RoofSegmentNode, +} from './roof-segment' + +describe('createDefaultRidgeVentsForSegment', () => { + test('creates one shingled default ridge vent for gable roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(1) + expect(vents[0]?.name).toBe('Ridge Vent') + expect(vents[0]?.style).toBe('shingled') + expect(vents[0]?.roofSegmentId).toBe(segment.id) + expect(isDefaultRidgeVentNode(vents[0], segment.id)).toBe(true) + }) + + test('keeps generated gable ridge vents anchored to the untrimmed ridge', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { left: 1, right: 2, front: 0, back: 0 }, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(1) + expect(vents[0]?.length).toBeCloseTo(8) + expect(vents[0]?.position[0]).toBeCloseTo(0) + }) + + test('creates top ridge plus four hip vents for rectangular hip roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'hip', + width: 8, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(5) + expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1) + expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4) + for (const vent of vents) { + expect(vent.style).toBe('shingled') + expect(vent.length).toBeGreaterThan(0.4) + expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true) + } + }) + + test('omits the collapsed top ridge on square hip roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'hip', + width: 6, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(4) + expect(vents.every((vent) => vent.name === 'Hip Ridge Vent')).toBe(true) + }) + + test('creates a top ridge plus four hip vents for width-axis Dutch roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1) + expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4) + // Width-axis Dutch ridge runs along X (constant Z = 0). + const metrics = getDutchRoofMetrics(segment) + const ridge = vents.find((vent) => vent.name === 'Ridge Vent') + const frontRightHip = vents.find( + (vent) => + vent.name === 'Hip Ridge Vent' && + (vent.position[0] ?? 0) > 0 && + (vent.position[2] ?? 0) > 0, + ) + const expectedRakeReach = Math.min( + segment.dutchGabletRake, + Math.max(0, segment.width / 2 - metrics.waistHalfX) * 0.98, + ) + expect(ridge?.position[2]).toBeCloseTo(0) + expect(ridge?.length).toBeCloseTo((metrics.waistHalfX + expectedRakeReach) * 2, 2) + expect(frontRightHip?.position[0]).toBeCloseTo((4 + 2.93) / 2, 2) + expect(frontRightHip?.position[2]).toBeCloseTo((3 + 1.5) / 2, 2) + for (const vent of vents) { + expect(vent.style).toBe('shingled') + expect(vent.length).toBeGreaterThan(0.4) + expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true) + } + }) + + test('treats legacy segments with generated ridge vents as auto-enabled', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + const nodes = Object.fromEntries(vents.map((vent) => [vent.id, vent])) + + expect( + isAutoRidgeVentEnabled( + { + id: segment.id, + children: vents.map((vent) => vent.id), + metadata: {}, + }, + nodes, + ), + ).toBe(true) + }) + + test('treats legacy preset-white ridge vents as generated defaults', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + roofType: 'gable', + width: 8, + depth: 6, + }) + const legacyVent = RidgeVentNode.parse({ + id: 'rvent_legacy' as never, + roofSegmentId: segment.id, + name: 'Ridge Vent', + style: 'shingled', + materialPreset: 'preset-white', + position: [0, 0, 0], + length: 8, + }) + + expect(isDefaultRidgeVentNode(legacyVent, segment.id)).toBe(true) + }) + + test('creates a Z-oriented ridge plus four hip lines for depth-axis Dutch roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 6, + depth: 8, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const lines = getRidgeVentLinesForSegment(segment) + const metrics = getDutchRoofMetrics(segment) + const expectedRakeReach = Math.min( + segment.dutchGabletRake, + Math.max(0, segment.depth / 2 - metrics.waistHalfZ) * 0.98, + ) + + const ridges = lines.filter((line) => line.name === 'Ridge Vent') + const hips = lines.filter((line) => line.name === 'Hip Ridge Vent') + expect(ridges).toHaveLength(1) + expect(hips).toHaveLength(4) + // Depth-axis Dutch ridge runs along Z (constant X = 0). + expect(ridges[0]?.start[0]).toBeCloseTo(0) + expect(ridges[0]?.end[0]).toBeCloseTo(0) + expect(Math.abs(ridges[0]?.start[1] ?? 0)).toBeCloseTo( + metrics.waistHalfZ + expectedRakeReach, + 2, + ) + }) + + test('keeps Dutch hip lines on the rendered arris when the roof has overhang', () => { + // Overhang + shingle thickness expand the rendered roof; the eave corners + // and the waist must share that expanded frame, otherwise the hip lines + // tilt off the arris and the vents sink into the slope. + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + overhang: 0.5, + wallThickness: 0.2, + shingleThickness: 0.05, + }) + + const lines = getRidgeVentLinesForSegment(segment) + const hips = lines.filter((line) => line.name === 'Hip Ridge Vent') + const ridge = lines.find((line) => line.name === 'Ridge Vent') + expect(hips).toHaveLength(4) + expect(ridge).toBeDefined() + + const bounds = getRoofSegmentVisibleTopBounds(segment) + const { axis, inset } = getDutchRoofMetrics(segment) + const waistLengthRatio = segment.dutchWaistLengthRatio + // width 8 >= depth 6 -> axis 'x': the ridge runs along X (waist scaled by + // waistLengthRatio), and Z is the clean hipped axis (inset exactly). + expect(axis).toBe('x') + const halfWExpanded = bounds.maxX + const halfDExpanded = bounds.maxZ + const expectedWaistX = (halfWExpanded - inset) * waistLengthRatio + const expectedWaistZ = halfDExpanded - inset + const expectedRakeReach = Math.min( + segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, + Math.max(0, halfWExpanded - expectedWaistX) * 0.98, + ) + + for (const hip of hips) { + const [ex, ez] = hip.start + const [wx, wz] = hip.end + // Eave end on an expanded-bounds corner; upper end at the rendered rake + // termination where the lower slope starts, derived from the SAME + // expanded frame (not the base-dim inner waist). + expect(Math.abs(ex)).toBeCloseTo(halfWExpanded) + expect(Math.abs(ez)).toBeCloseTo(halfDExpanded) + expect(Math.abs(wx)).toBeCloseTo(expectedWaistX + expectedRakeReach) + expect(Math.abs(wz)).toBeCloseTo(expectedWaistZ) + } + }) + + test('creates top ridge plus four upper hip vents plus four lower-slope vents for mansard roofs', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'mansard', + width: 8, + depth: 6, + }) + + const vents = createDefaultRidgeVentsForSegment(segment) + + expect(vents).toHaveLength(9) + expect(vents.filter((vent) => vent.name === 'Ridge Vent')).toHaveLength(1) + expect(vents.filter((vent) => vent.name === 'Hip Ridge Vent')).toHaveLength(4) + expect(vents.filter((vent) => vent.name === 'Slope Ridge Vent')).toHaveLength(4) + expect(vents.find((vent) => vent.name === 'Ridge Vent')?.length).toBeLessThan(segment.width) + const slopeVents = vents.filter((vent) => vent.name === 'Slope Ridge Vent') + const slopeRotations = slopeVents.map((vent) => Math.abs(vent.rotation)) + expect(slopeRotations.every((rotation) => rotation > 0.1)).toBe(true) + expect(slopeRotations.every((rotation) => Math.abs(rotation - Math.PI / 2) > 0.1)).toBe(true) + expect( + slopeVents.every( + (vent) => + Math.abs(vent.position[0]) > segment.width / 2 - 0.8 && + Math.abs(vent.position[2]) > segment.depth / 2 - 0.8, + ), + ).toBe(true) + expect( + new Set( + slopeVents.map((vent) => `${Math.sign(vent.position[0])},${Math.sign(vent.position[2])}`), + ).size, + ).toBe(4) + for (const vent of vents) { + expect(vent.style).toBe('shingled') + expect(isDefaultRidgeVentNode(vent, segment.id)).toBe(true) + } + }) +}) diff --git a/packages/core/src/schema/nodes/ridge-vent.ts b/packages/core/src/schema/nodes/ridge-vent.ts index 9a7dce48..31433d88 100644 --- a/packages/core/src/schema/nodes/ridge-vent.ts +++ b/packages/core/src/schema/nodes/ridge-vent.ts @@ -2,15 +2,50 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' +import { + getDutchRoofMetrics, + getRoofSegmentVisibleTopBounds, + ROOF_SHAPE_DEFAULTS, + type RoofSegmentNode, + type RoofSegmentTrim, +} from './roof-segment' + +const MIN_DEFAULT_RIDGE_VENT_LENGTH_M = 0.4 +const DEFAULT_RIDGE_VENT_GENERATOR = 'default-ridge-vent' +const AUTO_RIDGE_VENT_METADATA_KEY = 'autoRidgeVent' +const LEGACY_DEFAULT_RIDGE_VENT_PRESET = 'preset-white' +const UNTRIMMED_RIDGE_VENT_BOUNDS_TRIM: RoofSegmentTrim = { + left: 0, + right: 0, + front: 0, + back: 0, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, +} + +export type RidgeVentLine = { + name: string + start: [number, number] + end: [number, number] +} export const RidgeVentNode = BaseNode.extend({ id: objectId('rvent'), type: nodeType('ridge-vent'), material: MaterialSchema.optional(), - // See note on box-vent: default to white so the paint inspector - // reflects the current visual state instead of "no material". - materialPreset: z.string().default('preset-white'), + // Unpainted ridge vents inherit the roof top material in the renderer. + materialPreset: z.string().optional(), roofSegmentId: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), @@ -37,3 +72,236 @@ export const RidgeVentNode = BaseNode.extend({ ) export type RidgeVentNode = z.infer + +export function getRidgeVentLinesForSegment(segment: RoofSegmentNode): RidgeVentLine[] { + const bounds = getRoofSegmentVisibleTopBounds({ + ...segment, + trim: UNTRIMMED_RIDGE_VENT_BOUNDS_TRIM, + }) + const { width, depth, minX, maxX, minZ, maxZ } = bounds + if (segment.roofType === 'flat' || segment.roofType === 'shed') return [] + + const halfW = width / 2 + const halfD = depth / 2 + const ridgeZVisible = minZ <= 0 && maxZ >= 0 + const ridgeXVisible = minX <= 0 && maxX >= 0 + + if (segment.roofType === 'mansard') { + const inset = Math.min( + Math.min(width, depth) * + (segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio), + Math.max(0, Math.min(width, depth) / 2 - 0.01), + ) + const shoulderMinX = minX + inset + const shoulderMaxX = maxX - inset + const shoulderMinZ = minZ + inset + const shoulderMaxZ = maxZ - inset + const topW = Math.max(0, shoulderMaxX - shoulderMinX) + const topD = Math.max(0, shoulderMaxZ - shoulderMinZ) + const lowerSlopeLines: RidgeVentLine[] = [ + { + name: 'Slope Ridge Vent', + start: [shoulderMinX, shoulderMaxZ], + end: [minX, maxZ], + }, + { + name: 'Slope Ridge Vent', + start: [shoulderMaxX, shoulderMaxZ], + end: [maxX, maxZ], + }, + { + name: 'Slope Ridge Vent', + start: [shoulderMaxX, shoulderMinZ], + end: [maxX, minZ], + }, + { + name: 'Slope Ridge Vent', + start: [shoulderMinX, shoulderMinZ], + end: [minX, minZ], + }, + ] + + if (topW >= topD) { + const leftRidge: [number, number] = [shoulderMinX + topD / 2, 0] + const rightRidge: [number, number] = [shoulderMaxX - topD / 2, 0] + return [ + ...(ridgeZVisible ? [{ name: 'Ridge Vent', start: leftRidge, end: rightRidge }] : []), + { name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMaxZ], end: leftRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMinZ], end: leftRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMaxZ], end: rightRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMinZ], end: rightRidge }, + ...lowerSlopeLines, + ] + } + + const frontRidge: [number, number] = [0, shoulderMaxZ - topW / 2] + const backRidge: [number, number] = [0, shoulderMinZ + topW / 2] + return [ + ...(ridgeXVisible ? [{ name: 'Ridge Vent', start: frontRidge, end: backRidge }] : []), + { name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMaxZ], end: frontRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMaxZ], end: frontRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMinX, shoulderMinZ], end: backRidge }, + { name: 'Hip Ridge Vent', start: [shoulderMaxX, shoulderMinZ], end: backRidge }, + ...lowerSlopeLines, + ] + } + + if (segment.roofType === 'dutch') { + const { axis, inset } = getDutchRoofMetrics(segment) + // The rendered hip arris and waist come from the EXPANDED (overhang + + // shingle) rectangle — the same dims the eave corners below use — while + // the inset is base-derived (matches getDutchRoofMetrics / the brush + // builder). Deriving the waist from `getDutchRoofMetrics`' own base-dim + // half-spans would tilt the hip lines off the arris on any roof with + // overhang, sinking the vents into the slope. Mirror the mansard branch: + // one coordinate system (expanded bounds) for both eave and waist. + const waistLengthRatio = + segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio + const waistHalfX = + axis === 'x' ? Math.max(0, (halfW - inset) * waistLengthRatio) : Math.max(0, halfW - inset) + const waistHalfZ = + axis === 'x' ? Math.max(0, halfD - inset) : Math.max(0, (halfD - inset) * waistLengthRatio) + if (!(waistHalfX > 0.001 && waistHalfZ > 0.001)) return [] + + // Dutch lower hip vents should terminate where the lower slope actually + // meets the gablet rake, not at the inner waist line of the upper gable + // triangle. Mirror the rendered roof shell's "outer waist" cap with the + // same expanded-bounds frame we use for the eave corners above. + const rake = segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake + const rakeReach = + axis === 'x' + ? Math.min(Math.max(0, rake), Math.max(0, halfW - waistHalfX) * 0.98) + : Math.min(Math.max(0, rake), Math.max(0, halfD - waistHalfZ) * 0.98) + const rakeEndHalfX = axis === 'x' ? waistHalfX + rakeReach : waistHalfX + const rakeEndHalfZ = axis === 'x' ? waistHalfZ : waistHalfZ + rakeReach + + const mainRidgeVisible = axis === 'x' ? ridgeZVisible : ridgeXVisible + const ridgeStart: [number, number] = axis === 'x' ? [-rakeEndHalfX, 0] : [0, rakeEndHalfZ] + const ridgeEnd: [number, number] = axis === 'x' ? [rakeEndHalfX, 0] : [0, -rakeEndHalfZ] + return [ + ...(mainRidgeVisible ? [{ name: 'Ridge Vent', start: ridgeStart, end: ridgeEnd }] : []), + { name: 'Hip Ridge Vent', start: [minX, maxZ], end: [-rakeEndHalfX, rakeEndHalfZ] }, + { name: 'Hip Ridge Vent', start: [maxX, maxZ], end: [rakeEndHalfX, rakeEndHalfZ] }, + { name: 'Hip Ridge Vent', start: [maxX, minZ], end: [rakeEndHalfX, -rakeEndHalfZ] }, + { name: 'Hip Ridge Vent', start: [minX, minZ], end: [-rakeEndHalfX, -rakeEndHalfZ] }, + ] + } + + if (segment.roofType !== 'hip') { + if (!ridgeZVisible) return [] + return [ + { + name: 'Ridge Vent', + start: [minX, 0], + end: [maxX, 0], + }, + ] + } + + if (width >= depth) { + const leftRidge: [number, number] = [minX + halfD, 0] + const rightRidge: [number, number] = [maxX - halfD, 0] + return [ + ...(ridgeZVisible ? [{ name: 'Ridge Vent', start: leftRidge, end: rightRidge }] : []), + { name: 'Hip Ridge Vent', start: [minX, maxZ], end: leftRidge }, + { name: 'Hip Ridge Vent', start: [minX, minZ], end: leftRidge }, + { name: 'Hip Ridge Vent', start: [maxX, maxZ], end: rightRidge }, + { name: 'Hip Ridge Vent', start: [maxX, minZ], end: rightRidge }, + ] + } + + const frontRidge: [number, number] = [0, maxZ - halfW] + const backRidge: [number, number] = [0, minZ + halfW] + return [ + ...(ridgeXVisible ? [{ name: 'Ridge Vent', start: frontRidge, end: backRidge }] : []), + { name: 'Hip Ridge Vent', start: [minX, maxZ], end: frontRidge }, + { name: 'Hip Ridge Vent', start: [maxX, maxZ], end: frontRidge }, + { name: 'Hip Ridge Vent', start: [minX, minZ], end: backRidge }, + { name: 'Hip Ridge Vent', start: [maxX, minZ], end: backRidge }, + ] +} + +function getLineYaw(start: [number, number], end: [number, number]): number { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + return Math.atan2(-dz, dx) +} + +export function createDefaultRidgeVentsForSegment(segment: RoofSegmentNode): RidgeVentNode[] { + return getRidgeVentLinesForSegment(segment) + .map((line) => { + const length = Math.hypot(line.end[0] - line.start[0], line.end[1] - line.start[1]) + if (length < MIN_DEFAULT_RIDGE_VENT_LENGTH_M) return null + + return RidgeVentNode.parse({ + name: line.name, + roofSegmentId: segment.id, + position: [(line.start[0] + line.end[0]) / 2, 0, (line.start[1] + line.end[1]) / 2], + rotation: getLineYaw(line.start, line.end), + length, + style: 'shingled', + metadata: { generatedBy: DEFAULT_RIDGE_VENT_GENERATOR }, + }) + }) + .filter((vent): vent is RidgeVentNode => vent !== null) +} + +export function isDefaultRidgeVentNode( + node: unknown, + roofSegmentId?: RoofSegmentNode['id'], +): node is RidgeVentNode { + const parsed = RidgeVentNode.safeParse(node) + if (!parsed.success) return false + const vent = parsed.data + if (roofSegmentId && vent.roofSegmentId !== roofSegmentId) return false + const metadata = vent.metadata + if ( + typeof metadata === 'object' && + metadata !== null && + (metadata as Record).generatedBy === DEFAULT_RIDGE_VENT_GENERATOR + ) { + return true + } + + const hasDefaultName = + vent.name === 'Ridge Vent' || + vent.name === 'Hip Ridge Vent' || + vent.name === 'Shoulder Ridge Vent' || + vent.name === 'Slope Ridge Vent' + return ( + hasDefaultName && + vent.style === 'shingled' && + vent.material === undefined && + vent.materialPreset === LEGACY_DEFAULT_RIDGE_VENT_PRESET + ) +} + +function metadataRecord(metadata: unknown): Record { + if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) { + return metadata as Record + } + return {} +} + +export function hasAutoRidgeVentMetadata( + segment: Pick, +): segment is Pick & { + metadata: Record & { autoRidgeVent: boolean } +} { + return typeof metadataRecord(segment.metadata)[AUTO_RIDGE_VENT_METADATA_KEY] === 'boolean' +} + +export function isAutoRidgeVentEnabled( + segment: Pick, + nodes?: Record, +): boolean { + const metadataValue = metadataRecord(segment.metadata)[AUTO_RIDGE_VENT_METADATA_KEY] + if (typeof metadataValue === 'boolean') { + return metadataValue + } + + if (!nodes) return false + return (segment.children ?? []).some((childId) => + isDefaultRidgeVentNode(nodes[childId], segment.id), + ) +} diff --git a/packages/core/src/schema/nodes/roof-segment-shape.test.ts b/packages/core/src/schema/nodes/roof-segment-shape.test.ts new file mode 100644 index 00000000..64ff58a1 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-shape.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from 'bun:test' +import { + getDutchEndSlopeFaces, + getDutchRoofShapeMetrics, + getRoofModuleFaces, + getRoofShapeRatios, +} from './roof-segment-shape' + +describe('roof segment shape', () => { + test('dutch shell is built as one complete non-duplicated face set', () => { + const wh = 3 + const rh = 2 + const faces = getRoofModuleFaces({ + type: 'dutch', + w: 8, + d: 6, + wh, + rh, + baseY: 0, + insets: { dutchI: 1.5 }, + baseW: 8, + baseD: 6, + tanTheta: 1, + shapeRatios: getRoofShapeRatios({ + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + dutchGabletRake: 0.25, + }), + }) + const peakY = wh + rh + const peakFaces = faces.filter((face) => + face.some((vertex) => Math.abs(vertex.y - peakY) < 1e-6), + ) + const signatures = new Set( + faces.map((face) => + face + .map((vertex) => `${vertex.x.toFixed(3)},${vertex.y.toFixed(3)},${vertex.z.toFixed(3)}`) + .sort() + .join('|'), + ), + ) + + expect(faces).toHaveLength(13) + expect(peakFaces).toHaveLength(4) + expect(signatures.size).toBe(faces.length) + + const lowerRightHip = faces.find((face) => + face.some( + (vertex) => + Math.abs(vertex.x - 2.75) < 1e-6 && + Math.abs(vertex.y - 4) < 1e-6 && + Math.abs(vertex.z - 1.5) < 1e-6, + ), + ) + const gableTriangle = faces.find( + (face) => + face.length === 3 && + face.some((vertex) => vertex.x === 2.5 && vertex.y === 5) && + face.some((vertex) => vertex.x === 2.5 && vertex.z === 1.5), + ) + + expect(lowerRightHip).toBeDefined() + expect(gableTriangle?.some((vertex) => vertex.x === 2.75)).toBe(false) + }) + + test('dutch depth-axis shell rotates the upper gable correctly', () => { + const faces = getRoofModuleFaces({ + type: 'dutch', + w: 6, + d: 8, + wh: 3, + rh: 2, + baseY: 0, + insets: { dutchI: 1.5 }, + baseW: 6, + baseD: 8, + tanTheta: 1, + shapeRatios: getRoofShapeRatios({ + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + dutchGabletRake: 0.25, + }), + }) + + const peakFaces = faces.filter((face) => face.some((vertex) => vertex.y === 5)) + const ridgeOnlyOnZAxis = peakFaces + .flat() + .filter((vertex) => vertex.y === 5) + .every((vertex) => vertex.x === 0) + + expect(peakFaces).toHaveLength(4) + expect(ridgeOnlyOnZAxis).toBe(true) + }) + + test('dutch end hip slope extends inward until it meets the gable triangle', () => { + const ratios = getRoofShapeRatios({ + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + dutchGabletRake: 0.75, + }) + const metrics = getDutchRoofShapeMetrics({ + w: 8, + d: 6, + wh: 3, + rh: 2, + dutchI: 1.5, + baseW: 8, + baseD: 6, + shapeRatios: ratios, + }) + const faces = getRoofModuleFaces({ + type: 'dutch', + w: 8, + d: 6, + wh: 3, + rh: 2, + baseY: 0, + insets: { dutchI: 1.5 }, + baseW: 8, + baseD: 6, + tanTheta: 1, + shapeRatios: ratios, + dutchTopRakeThickness: 0.21, + }) + + expect(metrics?.innerWaistHalfX).toBe(2.5) + expect(metrics?.outerWaistHalfX).toBe(3.25) + + // The end slope now clips against the rake's lower inner edge and is + // reprojected back onto the end-slope plane, so the shorter top edge stays + // planar instead of twisting. + const hipTopFace = faces.find( + (face) => + face.some( + (vertex) => + Math.abs(vertex.x - 4) < 1e-6 && + Math.abs(vertex.y - 3) < 1e-6 && + Math.abs(vertex.z - 3) < 1e-6, + ) && + face.some( + (vertex) => + Math.abs(vertex.x - 3.0325) < 1e-6 && + Math.abs(vertex.y - 4.29) < 1e-6 && + Math.abs(vertex.z - 0.75) < 1e-6, + ) && + face.some( + (vertex) => + Math.abs(vertex.x - 3.0325) < 1e-6 && + Math.abs(vertex.y - 4.29) < 1e-6 && + Math.abs(vertex.z + 0.75) < 1e-6, + ), + ) + const gableTriangle = faces.find( + (face) => + face.length === 3 && + face.some((vertex) => vertex.x === 2.5 && vertex.y === 5) && + face.some((vertex) => vertex.x === 2.5 && vertex.z === 1.5), + ) + + expect(hipTopFace).toBeDefined() + expect(gableTriangle?.some((vertex) => vertex.x === 3.25)).toBe(false) + }) + + test('excludeDutchEndSlopes pulls the end slopes out into getDutchEndSlopeFaces', () => { + const ratios = getRoofShapeRatios({ + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + dutchGabletRake: 0.75, + }) + const args = { + type: 'dutch' as const, + w: 8, + d: 6, + wh: 3, + rh: 2, + baseY: 0, + insets: { dutchI: 1.5 }, + baseW: 8, + baseD: 6, + tanTheta: 1, + shapeRatios: ratios, + dutchTopRakeThickness: 0.21, + } + + const full = getRoofModuleFaces(args) + const shell = getRoofModuleFaces({ ...args, excludeDutchEndSlopes: true }) + const endSlopes = getDutchEndSlopeFaces({ + w: 8, + d: 6, + wh: 3, + rh: 2, + insets: { dutchI: 1.5 }, + baseW: 8, + baseD: 6, + shapeRatios: ratios, + dutchTopRakeThickness: 0.21, + }) + + // The two end slopes leave the shell and reappear in the standalone set. + expect(shell).toHaveLength(full.length - 2) + expect(endSlopes).toHaveLength(2) + + // The standalone end slopes are 6-point polygons whose shorter top edge is + // clipped to the rake's lower inner edge and then kept on the end-slope + // plane. + const endIsEndSlope = endSlopes.every( + (face) => + face.length === 6 && + face.some((vertex) => Math.abs(Math.abs(vertex.x) - 3.25) < 1e-6) && + face.some((vertex) => Math.abs(Math.abs(vertex.x) - 3.0325) < 1e-6) && + face.some((vertex) => Math.abs(Math.abs(vertex.x) - 4) < 1e-6), + ) + expect(endIsEndSlope).toBe(true) + + // The removed faces are exactly the end slopes — the shell keeps every + // other face the full module produced. + const signature = (face: { x: number; y: number; z: number }[]) => + face + .map((vertex) => `${vertex.x.toFixed(3)},${vertex.y.toFixed(3)},${vertex.z.toFixed(3)}`) + .join('|') + const shellSigs = new Set(shell.map(signature)) + const removed = full.filter((face) => !shellSigs.has(signature(face))) + expect(removed).toHaveLength(2) + }) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-shape.ts b/packages/core/src/schema/nodes/roof-segment-shape.ts new file mode 100644 index 00000000..ac1d5787 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-shape.ts @@ -0,0 +1,441 @@ +import { ROOF_SHAPE_DEFAULTS, type RoofType } from './roof-segment' + +export type RoofShapeFaceVertex = { + x: number + y: number + z: number +} + +export type RoofShapeInsets = { + iF?: number + iB?: number + iL?: number + iR?: number + dutchI?: number +} + +export type RoofShapeRatios = { + gambrelLowerWidthRatio: number + mansardSteepWidthRatio: number + dutchHipWidthRatio: number + dutchHipHeightRatio: number + dutchWaistLengthRatio: number + dutchGabletRake: number +} + +export function getRoofShapeRatios(input: { + gambrelLowerWidthRatio?: number + mansardSteepWidthRatio?: number + dutchHipWidthRatio?: number + dutchHipHeightRatio?: number + dutchWaistLengthRatio?: number + dutchGabletRake?: number +}): RoofShapeRatios { + return { + gambrelLowerWidthRatio: + input.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, + mansardSteepWidthRatio: + input.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio, + dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + dutchWaistLengthRatio: input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + dutchGabletRake: input.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, + } +} + +export type DutchRoofShapeMetrics = { + axis: 'width' | 'depth' + inset: number + middleHeight: number + peakHeight: number + rakeReach: number + innerWaistHalfX: number + innerWaistHalfZ: number + outerWaistHalfX: number + outerWaistHalfZ: number +} + +export function getDutchRoofShapeMetrics(input: { + w: number + d: number + wh: number + rh: number + dutchI?: number + baseW: number + baseD: number + shapeRatios: RoofShapeRatios +}): DutchRoofShapeMetrics | null { + const fallbackInset = Math.min(input.baseW, input.baseD) * input.shapeRatios.dutchHipWidthRatio + const maxI = Math.max(0, Math.min(input.w, input.d) / 2 - 0.005) + const inset = Math.min(Math.max(0, input.dutchI ?? fallbackInset), maxI) + const peakHeight = input.wh + Math.max(0.001, input.rh) + const middleHeight = input.wh + input.rh * input.shapeRatios.dutchHipHeightRatio + if (!(inset > 0.001) || !(peakHeight > middleHeight + 0.001)) return null + + if (input.w >= input.d) { + const innerWaistHalfX = Math.max( + 0, + (input.w / 2 - inset) * input.shapeRatios.dutchWaistLengthRatio, + ) + const innerWaistHalfZ = Math.max(0, input.d / 2 - inset) + if (!(innerWaistHalfX > 0.001 && innerWaistHalfZ > 0.001)) return null + + const rakeReach = Math.min( + Math.max(0, input.shapeRatios.dutchGabletRake), + Math.max(0, input.w / 2 - innerWaistHalfX) * 0.98, + ) + + return { + axis: 'width', + inset, + middleHeight, + peakHeight, + rakeReach, + innerWaistHalfX, + innerWaistHalfZ, + outerWaistHalfX: innerWaistHalfX + rakeReach, + outerWaistHalfZ: innerWaistHalfZ, + } + } + + const innerWaistHalfX = Math.max(0, input.w / 2 - inset) + const innerWaistHalfZ = Math.max( + 0, + (input.d / 2 - inset) * input.shapeRatios.dutchWaistLengthRatio, + ) + if (!(innerWaistHalfX > 0.001 && innerWaistHalfZ > 0.001)) return null + + const rakeReach = Math.min( + Math.max(0, input.shapeRatios.dutchGabletRake), + Math.max(0, input.d / 2 - innerWaistHalfZ) * 0.98, + ) + + return { + axis: 'depth', + inset, + middleHeight, + peakHeight, + rakeReach, + innerWaistHalfX, + innerWaistHalfZ, + outerWaistHalfX: innerWaistHalfX, + outerWaistHalfZ: innerWaistHalfZ + rakeReach, + } +} + +export function getRoofShapeInsets(input: { + roofType: RoofType + width: number + depth: number + wh: number + baseY: number + isVoid: boolean + brushW: number + brushD: number + tanTheta: number + shingleThickness: number + dutchHipWidthRatio: number +}): RoofShapeInsets { + let inset = (input.wh - input.baseY) * input.tanTheta + const maxSafeInset = Math.min(input.brushW, input.brushD) / 2 - 0.005 + if (inset > maxSafeInset) inset = maxSafeInset + + let iF = 0 + let iB = 0 + let iL = 0 + let iR = 0 + if (input.roofType === 'hip' || input.roofType === 'mansard' || input.roofType === 'dutch') { + iF = inset + iB = inset + iL = inset + iR = inset + } else if (input.roofType === 'gable' || input.roofType === 'gambrel') { + iF = inset + iB = inset + } else if (input.roofType === 'shed') { + iF = inset + } + + let dutchI = Math.min(input.width, input.depth) * input.dutchHipWidthRatio + if (input.isVoid) dutchI += input.shingleThickness + return { iF, iB, iL, iR, dutchI } +} + +export function getDutchEndSlopeFaces(input: { + w: number + d: number + wh: number + rh: number + insets: RoofShapeInsets + baseW: number + baseD: number + shapeRatios: RoofShapeRatios + dutchTopRakeThickness?: number +}): RoofShapeFaceVertex[][] { + const dutch = getDutchRoofShapeMetrics({ + w: input.w, + d: input.d, + wh: input.wh, + rh: input.rh, + dutchI: input.insets.dutchI, + baseW: input.baseW, + baseD: input.baseD, + shapeRatios: input.shapeRatios, + }) + if (!dutch) return [] + + const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z }) + const e1 = v(-input.w / 2, input.wh, input.d / 2) + const e2 = v(input.w / 2, input.wh, input.d / 2) + const e3 = v(input.w / 2, input.wh, -input.d / 2) + const e4 = v(-input.w / 2, input.wh, -input.d / 2) + + // The hip end slope is constructed only up to the outer waist (rake) line. + // Extend its top edge inward along the same hip rulings until it reaches the + // gablet's inner triangle (the inner waist line), continuing each + // eave→outer-waist ridge line so the face stays planar and the pitch is + // unchanged — the edge climbs past middleHeight and meets the gablet face. + const lerp = (a: RoofShapeFaceVertex, b: RoofShapeFaceVertex, t: number): RoofShapeFaceVertex => + v(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t) + const lowerAlongY = (point: RoofShapeFaceVertex): RoofShapeFaceVertex => + v(point.x, point.y - Math.max(0, input.dutchTopRakeThickness ?? 0), point.z) + + if (dutch.axis === 'width') { + const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const r1 = v(-dutch.innerWaistHalfX, input.wh + input.rh, 0) + const r2 = v(dutch.innerWaistHalfX, input.wh + input.rh, 0) + const projectWidthAxisPointToEndSlope = ( + point: RoofShapeFaceVertex, + side: 1 | -1, + ): RoofShapeFaceVertex => { + const denomY = dutch.middleHeight - input.wh + const tPlane = Math.abs(denomY) > 1e-9 ? (point.y - input.wh) / denomY : 0 + const x = side * (input.w / 2 + (dutch.outerWaistHalfX - input.w / 2) * tPlane) + return v(x, point.y, point.z) + } + const top2 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m2, r2, 0.5)), 1) + const top3 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m3, r2, 0.5)), 1) + const top1 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m1, r1, 0.5)), -1) + const top4 = projectWidthAxisPointToEndSlope(lowerAlongY(lerp(m4, r1, 0.5)), -1) + return [ + [e2, e3, o3, top3, top2, o2], + [e4, e1, o1, top1, top4, o4], + ] + } + + const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const r1 = v(0, input.wh + input.rh, dutch.innerWaistHalfZ) + const r2 = v(0, input.wh + input.rh, -dutch.innerWaistHalfZ) + const projectDepthAxisPointToEndSlope = ( + point: RoofShapeFaceVertex, + side: 1 | -1, + ): RoofShapeFaceVertex => { + const denomY = dutch.middleHeight - input.wh + const tPlane = Math.abs(denomY) > 1e-9 ? (point.y - input.wh) / denomY : 0 + const z = side * (input.d / 2 + (dutch.outerWaistHalfZ - input.d / 2) * tPlane) + return v(point.x, point.y, z) + } + const top2 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m2, r1, 0.5)), 1) + const top1 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m1, r1, 0.5)), 1) + const top4 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m4, r2, 0.5)), -1) + const top3 = projectDepthAxisPointToEndSlope(lowerAlongY(lerp(m3, r2, 0.5)), -1) + return [ + [e1, e2, o2, top2, top1, o1], + [e3, e4, o4, top4, top3, o3], + ] +} + +export function getRoofModuleFaces(input: { + type: RoofType + w: number + d: number + wh: number + rh: number + baseY: number + insets: RoofShapeInsets + baseW: number + baseD: number + tanTheta: number + shapeRatios: RoofShapeRatios + excludeDutchEndSlopes?: boolean + dutchTopRakeThickness?: number +}): RoofShapeFaceVertex[][] { + const v = (x: number, y: number, z: number): RoofShapeFaceVertex => ({ x, y, z }) + const { iF = 0, iB = 0, iL = 0, iR = 0 } = input.insets + + const b1 = v(-input.w / 2 + iL, input.baseY, input.d / 2 - iF) + const b2 = v(input.w / 2 - iR, input.baseY, input.d / 2 - iF) + const b3 = v(input.w / 2 - iR, input.baseY, -input.d / 2 + iB) + const b4 = v(-input.w / 2 + iL, input.baseY, -input.d / 2 + iB) + const bottom = [b4, b3, b2, b1] + + const e1 = v(-input.w / 2, input.wh, input.d / 2) + const e2 = v(input.w / 2, input.wh, input.d / 2) + const e3 = v(input.w / 2, input.wh, -input.d / 2) + const e4 = v(-input.w / 2, input.wh, -input.d / 2) + + const faces: RoofShapeFaceVertex[][] = [] + faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom) + + const h = input.wh + Math.max(0.001, input.rh) + + if (input.type === 'flat' || input.rh === 0) { + faces.push([e1, e2, e3, e4]) + } else if (input.type === 'gable') { + const r1 = v(-input.w / 2, h, 0) + const r2 = v(input.w / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else if (input.type === 'hip') { + if (Math.abs(input.w - input.d) < 0.01) { + const r = v(0, h, 0) + faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r]) + } else if (input.w >= input.d) { + const r1 = v(-input.w / 2 + input.d / 2, h, 0) + const r2 = v(input.w / 2 - input.d / 2, h, 0) + faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) + } else { + const r1 = v(0, h, input.d / 2 - input.w / 2) + const r2 = v(0, h, -input.d / 2 + input.w / 2) + faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) + } + } else if (input.type === 'shed') { + const t1 = v(-input.w / 2, h, -input.d / 2) + const t2 = v(input.w / 2, h, -input.d / 2) + faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1]) + } else if (input.type === 'gambrel') { + const mz = (input.baseD / 2) * input.shapeRatios.gambrelLowerWidthRatio + const dist = input.d / 2 - mz + const mh = input.wh + dist * (input.tanTheta || 0) + + const m1 = v(-input.w / 2, mh, mz) + const m2 = v(input.w / 2, mh, mz) + const m3 = v(input.w / 2, mh, -mz) + const m4 = v(-input.w / 2, mh, -mz) + const r1 = v(-input.w / 2, h, 0) + const r2 = v(input.w / 2, h, 0) + faces.push( + [e4, e1, m1, r1, m4], + [e2, e3, m3, r2, m2], + [e1, e2, m2, m1], + [m1, m2, r2, r1], + [e3, e4, m4, m3], + [m3, m4, r1, r2], + ) + } else if (input.type === 'mansard') { + const i = Math.min(input.baseW, input.baseD) * input.shapeRatios.mansardSteepWidthRatio + const mh = input.wh + i * (input.tanTheta || 0) + + const m1 = v(-input.w / 2 + i, mh, input.d / 2 - i) + const m2 = v(input.w / 2 - i, mh, input.d / 2 - i) + const m3 = v(input.w / 2 - i, mh, -input.d / 2 + i) + const m4 = v(-input.w / 2 + i, mh, -input.d / 2 + i) + const topW = input.w - i * 2 + const topD = input.d - i * 2 + + faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4]) + + if (Math.abs(topW - topD) < 0.01) { + const r = v(0, h, 0) + faces.push([m4, m1, r], [m1, m2, r], [m2, m3, r], [m3, m4, r]) + } else if (topW >= topD) { + const r1 = v(-topW / 2 + topD / 2, h, 0) + const r2 = v(topW / 2 - topD / 2, h, 0) + faces.push([m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2]) + } else { + const r1 = v(0, h, topD / 2 - topW / 2) + const r2 = v(0, h, -topD / 2 + topW / 2) + faces.push([m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2]) + } + } else if (input.type === 'dutch') { + const dutch = getDutchRoofShapeMetrics({ + w: input.w, + d: input.d, + wh: input.wh, + rh: input.rh, + dutchI: input.insets.dutchI, + baseW: input.baseW, + baseD: input.baseD, + shapeRatios: input.shapeRatios, + }) + if (!dutch) return faces + + const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + + if (dutch.axis === 'width') { + const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const r1 = v(-dutch.innerWaistHalfX, h, 0) + const r2 = v(dutch.innerWaistHalfX, h, 0) + const endSlopes = input.excludeDutchEndSlopes + ? [] + : getDutchEndSlopeFaces({ + w: input.w, + d: input.d, + wh: input.wh, + rh: input.rh, + insets: input.insets, + baseW: input.baseW, + baseD: input.baseD, + shapeRatios: input.shapeRatios, + dutchTopRakeThickness: input.dutchTopRakeThickness, + }) + faces.push([e1, e2, o2, m2, m1, o1], [e3, e4, o4, m4, m3, o3]) + if (endSlopes.length === 2) { + faces.push(...endSlopes) + } else if (!input.excludeDutchEndSlopes) { + faces.push([e2, e3, o3, o2], [e4, e1, o1, o4]) + } + faces.push([m1, m2, r2, r1], [m3, m4, r1, r2]) + faces.push([m4, m1, r1], [m2, m3, r2]) + } else { + const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const r1 = v(0, h, dutch.innerWaistHalfZ) + const r2 = v(0, h, -dutch.innerWaistHalfZ) + const endSlopes = input.excludeDutchEndSlopes + ? [] + : getDutchEndSlopeFaces({ + w: input.w, + d: input.d, + wh: input.wh, + rh: input.rh, + insets: input.insets, + baseW: input.baseW, + baseD: input.baseD, + shapeRatios: input.shapeRatios, + dutchTopRakeThickness: input.dutchTopRakeThickness, + }) + faces.push([e2, e3, o3, m3, m2, o2], [e4, e1, o1, m1, m4, o4]) + if (endSlopes.length === 2) { + faces.push(...endSlopes) + } else if (!input.excludeDutchEndSlopes) { + faces.push([e1, e2, o2, o1], [e3, e4, o4, o3]) + } + faces.push([m2, m3, r2, r1], [m4, m1, r1, r2]) + faces.push([m1, m2, r1], [m3, m4, r2]) + } + } + + return faces +} diff --git a/packages/core/src/schema/nodes/roof-segment-surface.test.ts b/packages/core/src/schema/nodes/roof-segment-surface.test.ts new file mode 100644 index 00000000..17ba53af --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-surface.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import { getRoofSegmentSurfaceY, ROOF_SHAPE_DEFAULTS, RoofSegmentNode } from './roof-segment' + +describe('getRoofSegmentSurfaceY', () => { + test('keeps the Dutch width-axis rake on the upper gable slope', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 3, + pitch: 40, + }) + const activeRh = getRoofSegmentSurfaceY(segment, 0, 0) - segment.wallHeight + const upperRise = activeRh * (1 - segment.dutchHipHeightRatio) + const waistHalfZ = + segment.depth / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio + const availableRake = Math.max( + 0, + segment.width / 2 - + (segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) * + segment.dutchWaistLengthRatio, + ) + const rakeReach = Math.min(segment.dutchGabletRake, availableRake * 0.98) + const localX = + (segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) * + segment.dutchWaistLengthRatio + + rakeReach * 0.5 + const localZ = waistHalfZ * 0.5 + + const expected = segment.wallHeight + activeRh - localZ * (upperRise / waistHalfZ) + + expect(localX).toBeGreaterThan( + (segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) * + segment.dutchWaistLengthRatio, + ) + expect(getRoofSegmentSurfaceY(segment, localX, localZ)).toBeCloseTo(expected, 6) + }) + + test('keeps the Dutch depth-axis rake on the upper gable slope', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 6, + depth: 8, + wallHeight: 3, + pitch: 40, + }) + const activeRh = getRoofSegmentSurfaceY(segment, 0, 0) - segment.wallHeight + const upperRise = activeRh * (1 - segment.dutchHipHeightRatio) + const waistHalfX = + segment.width / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio + const innerWaistHalfZ = + (segment.depth / 2 - Math.min(segment.width, segment.depth) * segment.dutchHipWidthRatio) * + segment.dutchWaistLengthRatio + const rakeReach = Math.min( + segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, + Math.max(0, segment.depth / 2 - innerWaistHalfZ) * 0.98, + ) + const localX = waistHalfX * 0.5 + const localZ = innerWaistHalfZ + rakeReach * 0.5 + + const expected = segment.wallHeight + activeRh - localX * (upperRise / waistHalfX) + + expect(localZ).toBeGreaterThan(innerWaistHalfZ) + expect(getRoofSegmentSurfaceY(segment, localX, localZ)).toBeCloseTo(expected, 6) + }) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-trim.test.ts b/packages/core/src/schema/nodes/roof-segment-trim.test.ts new file mode 100644 index 00000000..d35de190 --- /dev/null +++ b/packages/core/src/schema/nodes/roof-segment-trim.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from 'bun:test' +import { + getRoofSegmentVisibleTopBounds, + normalizeRoofSegmentTrim, + RoofSegmentNode, +} from './roof-segment' + +describe('roof segment trim', () => { + test('defaults legacy segments to no trim', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + }) + + expect(segment.trim).toEqual({ + left: 0, + right: 0, + front: 0, + back: 0, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, + }) + }) + + test('normalizes impossible side totals without inverting the footprint', () => { + const trim = normalizeRoofSegmentTrim({ + width: 4, + depth: 3, + trim: { left: 3, right: 3, front: 2, back: 2 }, + }) + + expect(trim.left + trim.right).toBeCloseTo(3.9) + expect(trim.front + trim.back).toBeCloseTo(2.9) + }) + + test('visible top bounds respect asymmetric trims', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { left: 1, right: 2, front: 0, back: 0 }, + }) + + const bounds = getRoofSegmentVisibleTopBounds(segment) + + expect(bounds.minX).toBeCloseTo(-3) + expect(bounds.maxX).toBeCloseTo(2) + expect(bounds.width).toBeCloseTo(5) + }) + + test('visible top bounds stay finite for legacy partial segments', () => { + const bounds = getRoofSegmentVisibleTopBounds({ + id: 'rseg_legacy', + type: 'roof-segment', + roofType: 'gable', + trim: { left: 1 }, + } as unknown as Parameters[0]) + + expect(Number.isFinite(bounds.minX)).toBe(true) + expect(Number.isFinite(bounds.maxX)).toBe(true) + expect(Number.isFinite(bounds.minZ)).toBe(true) + expect(Number.isFinite(bounds.maxZ)).toBe(true) + expect(Number.isFinite(bounds.width)).toBe(true) + expect(Number.isFinite(bounds.depth)).toBe(true) + }) + + test('diagonal trims can shorten the ridge span', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { left: 0, right: 0, front: 0, back: 0, frontLeft: 4, backRight: 4 }, + }) + + const bounds = getRoofSegmentVisibleTopBounds(segment) + + expect(bounds.minX).toBeCloseTo(-3) + expect(bounds.maxX).toBeCloseTo(3) + expect(bounds.width).toBeCloseTo(6) + }) + + test('diagonal trims support independent width and depth endpoints', () => { + const segment = RoofSegmentNode.parse({ + id: 'rseg_test', + type: 'roof-segment', + width: 8, + depth: 6, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { + frontLeftX: 5, + frontLeftZ: 2, + backRightX: 2, + backRightZ: 5, + }, + }) + + const trim = normalizeRoofSegmentTrim(segment) + const bounds = getRoofSegmentVisibleTopBounds(segment) + + expect(trim.frontLeft).toBeCloseTo(2) + expect(trim.frontLeftX).toBeCloseTo(5) + expect(trim.frontLeftZ).toBeCloseTo(2) + expect(bounds.maxZ).toBeCloseTo(2.6) + expect(bounds.maxX).toBeCloseTo(3.2) + }) +}) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.test.ts b/packages/core/src/schema/nodes/roof-segment-walls.test.ts index 7e4d7478..61c087f5 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.test.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import { RoofSegmentNode } from './roof-segment' import { getRoofSegmentWallFace, + getRoofSegmentWallFaces, getRoofWallFaceFrame, roofFacePointToSegment, segmentPointToRoofWallFace, @@ -75,4 +76,39 @@ describe('roof wall face frames', () => { expect(getRoofWallFaceFrame(seg, faceId).yaw).toBe(getRoofSegmentWallFace(seg, faceId).yaw) } }) + + test('dutch width-axis roofs expose hostable gable-end wall profiles on the short ends', () => { + const faces = getRoofSegmentWallFaces( + segment({ + roofType: 'dutch', + width: 8, + depth: 6, + dutchHipWidthRatio: 0.25, + dutchWaistLengthRatio: 1, + }), + ) + + const left = faces.find((face) => face.id === 'left') + const right = faces.find((face) => face.id === 'right') + + expect(left?.profile.length).toBeGreaterThan(5) + expect(right?.profile.length).toBeGreaterThan(5) + expect(left?.profile[3]?.[1]).toBeCloseTo(left?.profile[2]?.[1] ?? 0) + }) + + test('dutch long-side faces stay rectangular while only the gable ends rise above the eave', () => { + const faces = getRoofSegmentWallFaces( + segment({ + roofType: 'dutch', + width: 8, + depth: 6, + }), + ) + + const front = faces.find((face) => face.id === 'front') + const back = faces.find((face) => face.id === 'back') + + expect(front?.profile).toHaveLength(4) + expect(back?.profile).toHaveLength(4) + }) }) diff --git a/packages/core/src/schema/nodes/roof-segment-walls.ts b/packages/core/src/schema/nodes/roof-segment-walls.ts index 7d783592..d6fa9c5b 100644 --- a/packages/core/src/schema/nodes/roof-segment-walls.ts +++ b/packages/core/src/schema/nodes/roof-segment-walls.ts @@ -1,5 +1,5 @@ import type { RoofSegmentNode } from './roof-segment' -import { getSegmentSlopeFrame } from './roof-segment' +import { getDutchRoofMetrics, getSegmentSlopeFrame } from './roof-segment' /** * Wall-face math for roof segments — the vertical surfaces a wall-mounted @@ -53,6 +53,7 @@ type SegmentWallInputs = Pick< | 'mansardSteepHeightRatio' | 'dutchHipWidthRatio' | 'dutchHipHeightRatio' + | 'dutchWaistLengthRatio' > > @@ -173,8 +174,31 @@ function buildFaceProfile( [0, peakY], ] } - // hip / mansard / dutch slope on every side (dutch gablets are - // recessed from the wall plane), so only the base rect is placeable. + case 'dutch': { + const metrics = getDutchRoofMetrics(node) + const isDutchGableFace = + (metrics.axis === 'x' && isEnd) || + (metrics.axis === 'z' && (id === 'front' || id === 'back')) + if (!isDutchGableFace) return rectProfile(length, eaveY) + + const shoulderInset = + metrics.axis === 'x' ? metrics.shoulderInsetAlongDepth : metrics.shoulderInsetAlongWidth + if (!(shoulderInset > 0.001)) return rectProfile(length, eaveY) + + const shoulderLo = Math.max(0, shoulderInset) + const shoulderHi = Math.min(length, length - shoulderInset) + if (!(shoulderHi - shoulderLo > 0.02)) return rectProfile(length, eaveY) + + return [ + [0, 0], + [length, 0], + [length, eaveY], + [shoulderHi, eaveY], + [length / 2, peakY], + [shoulderLo, eaveY], + [0, eaveY], + ] + } default: return rectProfile(length, eaveY) } diff --git a/packages/core/src/schema/nodes/roof-segment.ts b/packages/core/src/schema/nodes/roof-segment.ts index 201f5ed0..2f13fdc2 100644 --- a/packages/core/src/schema/nodes/roof-segment.ts +++ b/packages/core/src/schema/nodes/roof-segment.ts @@ -8,6 +8,50 @@ export const RoofType = z.enum(['hip', 'gable', 'shed', 'gambrel', 'dutch', 'man export type RoofType = z.infer +export const MIN_ROOF_SEGMENT_TRIM_SPAN = 0.1 +const DEFAULT_ROOF_SEGMENT_WIDTH = 8 +const DEFAULT_ROOF_SEGMENT_DEPTH = 6 + +export const RoofSegmentTrim = z + .object({ + left: z.number().min(0).default(0), + right: z.number().min(0).default(0), + front: z.number().min(0).default(0), + back: z.number().min(0).default(0), + frontLeft: z.number().min(0).default(0), + frontRight: z.number().min(0).default(0), + backLeft: z.number().min(0).default(0), + backRight: z.number().min(0).default(0), + frontLeftX: z.number().min(0).default(0), + frontLeftZ: z.number().min(0).default(0), + frontRightX: z.number().min(0).default(0), + frontRightZ: z.number().min(0).default(0), + backLeftX: z.number().min(0).default(0), + backLeftZ: z.number().min(0).default(0), + backRightX: z.number().min(0).default(0), + backRightZ: z.number().min(0).default(0), + }) + .default({ + left: 0, + right: 0, + front: 0, + back: 0, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, + }) + +export type RoofSegmentTrim = z.infer + // Default shape ratios. Tuning these used to require editing the geometry // code in two places; they are now schema fields with these defaults. export const ROOF_SHAPE_DEFAULTS = { @@ -23,6 +67,17 @@ export const ROOF_SHAPE_DEFAULTS = { dutchHipWidthRatio: 0.25, /** Dutch: hip face rises this fraction of the way to the peak. */ dutchHipHeightRatio: 0.5, + /** Dutch: gable waist span along the ridge axis, as a fraction of the max span. */ + dutchWaistLengthRatio: 0.98, + /** + * Dutch: how far the gablet's barge board extends outward past the gablet + * end-wall, along the ridge axis, in metres. 0 disables the rake. The board + * lies in the gablet's slope planes (coplanar with the main Dutch slopes) + * and overhangs the lower hip skirt; the gablet end-wall itself stays put. + */ + dutchGabletRake: 0.48, + /** Dutch: thickness of the top gable rake slab. */ + dutchTopRakeThickness: 0.21, } as const export const RoofSegmentNode = BaseNode.extend({ @@ -51,6 +106,10 @@ export const RoofSegmentNode = BaseNode.extend({ // Footprint dimensions width: z.number().default(8), depth: z.number().default(6), + // Segment-local distances trimmed from each footprint side. The trim + // boundary is projected vertically through the roof volume, so the + // resulting edge follows the actual sloped roof surfaces. + trim: RoofSegmentTrim, // Wall height beneath the roof wallHeight: z.number().default(0.5), // Roof pitch in degrees — angle of the primary slope face. @@ -96,6 +155,17 @@ export const RoofSegmentNode = BaseNode.extend({ .min(0.1) .max(0.9) .default(ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio), + dutchWaistLengthRatio: z + .number() + .min(0.1) + .max(1) + .default(ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio), + dutchGabletRake: z.number().min(0).max(3).default(ROOF_SHAPE_DEFAULTS.dutchGabletRake), + dutchTopRakeThickness: z + .number() + .min(0.01) + .max(0.5) + .default(ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness), // Hosted accessories — chimney, dormer, skylight, box-vent, // ridge-vent, solar-panel, gutter. Each accessory's `parentId` points back // here; the segment renderer mounts them recursively via @@ -112,6 +182,7 @@ export const RoofSegmentNode = BaseNode.extend({ Multiple segments can be combined to form complex roof shapes. - roofType: hip, gable, shed, gambrel, dutch, mansard, flat - width/depth: footprint dimensions + - trim: segment-local side cut distances - wallHeight: height of walls below the roof - pitch: roof slope in degrees (angle of the primary slope face) - wallThickness/deckThickness: structural thicknesses @@ -120,11 +191,131 @@ export const RoofSegmentNode = BaseNode.extend({ - gambrelLowerWidthRatio / gambrelLowerHeightRatio: kink position on gambrel roofs - mansardSteepWidthRatio / mansardSteepHeightRatio: waist position on mansard roofs - dutchHipWidthRatio / dutchHipHeightRatio: hip-to-gable split on dutch roofs + - dutchWaistLengthRatio: gable waist span along the ridge axis + - dutchGabletRake: gablet barge-board overhang past the gablet end-wall (m, 0 = none) + - dutchTopRakeThickness: thickness of the top gable rake slab `, ) export type RoofSegmentNode = z.infer +function finiteNonNegative(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} + +function finitePositive(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback +} + +function normalizeTrimAxis(start: unknown, end: unknown, span: number): readonly [number, number] { + const maxTotal = Math.max(0, finiteNonNegative(span) - MIN_ROOF_SEGMENT_TRIM_SPAN) + let a = Math.min(finiteNonNegative(start), maxTotal) + let b = Math.min(finiteNonNegative(end), maxTotal) + const total = a + b + + if (total > maxTotal && total > 0) { + const scale = maxTotal / total + a *= scale + b *= scale + } + + return [a, b] as const +} + +export function normalizeRoofSegmentTrim( + node: Pick & { trim?: Partial }, +): RoofSegmentTrim { + const trim = node.trim ?? {} + const [left, right] = normalizeTrimAxis(trim.left, trim.right, node.width) + const [back, front] = normalizeTrimAxis(trim.back, trim.front, node.depth) + const maxWidthDiagonal = Math.max(0, finiteNonNegative(node.width) - left - right) + const maxDepthDiagonal = Math.max(0, finiteNonNegative(node.depth) - front - back) + const maxWidthPair = Math.max(0, maxWidthDiagonal - MIN_ROOF_SEGMENT_TRIM_SPAN) + const maxDepthPair = Math.max(0, maxDepthDiagonal - MIN_ROOF_SEGMENT_TRIM_SPAN) + let [frontLeftX, frontLeftZ] = normalizeCornerAxisTrim( + trim.frontLeft, + trim.frontLeftX, + trim.frontLeftZ, + maxWidthPair, + maxDepthPair, + ) + let [frontRightX, frontRightZ] = normalizeCornerAxisTrim( + trim.frontRight, + trim.frontRightX, + trim.frontRightZ, + maxWidthPair, + maxDepthPair, + ) + let [backLeftX, backLeftZ] = normalizeCornerAxisTrim( + trim.backLeft, + trim.backLeftX, + trim.backLeftZ, + maxWidthPair, + maxDepthPair, + ) + let [backRightX, backRightZ] = normalizeCornerAxisTrim( + trim.backRight, + trim.backRightX, + trim.backRightZ, + maxWidthPair, + maxDepthPair, + ) + + for (let i = 0; i < 3; i += 1) { + ;[frontLeftX, frontRightX] = normalizeTrimPair(frontLeftX, frontRightX, maxWidthPair) + ;[backLeftX, backRightX] = normalizeTrimPair(backLeftX, backRightX, maxWidthPair) + ;[frontLeftZ, backLeftZ] = normalizeTrimPair(frontLeftZ, backLeftZ, maxDepthPair) + ;[frontRightZ, backRightZ] = normalizeTrimPair(frontRightZ, backRightZ, maxDepthPair) + } + + const frontLeft = Math.min(frontLeftX, frontLeftZ) + const frontRight = Math.min(frontRightX, frontRightZ) + const backLeft = Math.min(backLeftX, backLeftZ) + const backRight = Math.min(backRightX, backRightZ) + + return { + left, + right, + front, + back, + frontLeft, + frontRight, + backLeft, + backRight, + frontLeftX, + frontLeftZ, + frontRightX, + frontRightZ, + backLeftX, + backLeftZ, + backRightX, + backRightZ, + } +} + +function normalizeTrimPair(a: number, b: number, maxTotal: number): [number, number] { + const total = a + b + if (total <= maxTotal || total <= 0) return [a, b] + const scale = maxTotal / total + return [a * scale, b * scale] +} + +function normalizeCornerAxisTrim( + scalar: unknown, + axisX: unknown, + axisZ: unknown, + maxX: number, + maxZ: number, +): [number, number] { + const x = finiteNonNegative(axisX) + const z = finiteNonNegative(axisZ) + const fallback = finiteNonNegative(scalar) + if (x > 0 || z > 0) { + return [Math.min(x, maxX), Math.min(z, maxZ)] + } + return [Math.min(fallback, maxX), Math.min(fallback, maxZ)] +} + // ---------------------------------------------------------------------------- // Pitch ↔ roof-peak height // @@ -144,6 +335,7 @@ type ShapeRatios = { mansardSteepHeightRatio: number dutchHipWidthRatio: number dutchHipHeightRatio: number + dutchWaistLengthRatio: number } type PitchInputs = { @@ -152,9 +344,44 @@ type PitchInputs = { depth: number } & Partial +export type DutchRoofMetrics = { + axis: 'x' | 'z' + inset: number + waistHalfX: number + waistHalfZ: number + ridgeStart: readonly [number, number] + ridgeEnd: readonly [number, number] + shoulderInsetAlongDepth: number + shoulderInsetAlongWidth: number +} + +function getDutchUpperShellBounds( + node: Pick & + Partial< + Pick + >, +) { + const metrics = getDutchRoofMetrics(node) + const width = finitePositive(node.width, DEFAULT_ROOF_SEGMENT_WIDTH) + const depth = finitePositive(node.depth, DEFAULT_ROOF_SEGMENT_DEPTH) + const rake = node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake + const rakeReach = + metrics.axis === 'x' + ? Math.min(Math.max(0, rake), Math.max(0, width / 2 - metrics.waistHalfX) * 0.98) + : Math.min(Math.max(0, rake), Math.max(0, depth / 2 - metrics.waistHalfZ) * 0.98) + + return { + ...metrics, + upperHalfX: metrics.axis === 'x' ? metrics.waistHalfX + rakeReach : metrics.waistHalfX, + upperHalfZ: metrics.axis === 'x' ? metrics.waistHalfZ : metrics.waistHalfZ + rakeReach, + } +} + function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios { return { ...input, + width: finitePositive(input.width, DEFAULT_ROOF_SEGMENT_WIDTH), + depth: finitePositive(input.depth, DEFAULT_ROOF_SEGMENT_DEPTH), gambrelLowerWidthRatio: input.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, gambrelLowerHeightRatio: @@ -165,6 +392,46 @@ function withRatioDefaults(input: PitchInputs): PitchInputs & ShapeRatios { input.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio, dutchHipWidthRatio: input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, dutchHipHeightRatio: input.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + dutchWaistLengthRatio: input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + } +} + +export function getDutchRoofMetrics( + input: Pick & + Partial>, +): DutchRoofMetrics { + const width = finitePositive(input.width, DEFAULT_ROOF_SEGMENT_WIDTH) + const depth = finitePositive(input.depth, DEFAULT_ROOF_SEGMENT_DEPTH) + const inset = + Math.min(width, depth) * (input.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio) + const waistLengthRatio = input.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio + + if (width >= depth) { + const waistHalfX = Math.max(0, (width / 2 - inset) * waistLengthRatio) + const waistHalfZ = Math.max(0, depth / 2 - inset) + return { + axis: 'x', + inset, + waistHalfX, + waistHalfZ, + ridgeStart: [-waistHalfX, 0], + ridgeEnd: [waistHalfX, 0], + shoulderInsetAlongDepth: Math.max(0, depth / 2 - waistHalfZ), + shoulderInsetAlongWidth: Math.max(0, width / 2 - waistHalfX), + } + } + + const waistHalfX = Math.max(0, width / 2 - inset) + const waistHalfZ = Math.max(0, (depth / 2 - inset) * waistLengthRatio) + return { + axis: 'z', + inset, + waistHalfX, + waistHalfZ, + ridgeStart: [0, waistHalfZ], + ridgeEnd: [0, -waistHalfZ], + shoulderInsetAlongDepth: Math.max(0, depth / 2 - waistHalfZ), + shoulderInsetAlongWidth: Math.max(0, width / 2 - waistHalfX), } } @@ -252,6 +519,86 @@ export function getActiveRoofHeight(node: Parameters 0) { + frontExt += shingleOverhang + } + + let minX = trim.left > 0 ? -width / 2 + trim.left : -width / 2 - xExt + let maxX = trim.right > 0 ? width / 2 - trim.right : width / 2 + xExt + let minZ = trim.back > 0 ? -depth / 2 + trim.back : -depth / 2 - backExt + let maxZ = trim.front > 0 ? depth / 2 - trim.front : depth / 2 + frontExt + + if (trim.frontLeftX > 0 && trim.frontLeftZ > 0 && maxZ - trim.frontLeftZ < 0) { + minX = Math.max(minX, minX + (trim.frontLeftX * (trim.frontLeftZ - maxZ)) / trim.frontLeftZ) + } + if (trim.backLeftX > 0 && trim.backLeftZ > 0 && minZ + trim.backLeftZ > 0) { + minX = Math.max(minX, minX + (trim.backLeftX * (trim.backLeftZ + minZ)) / trim.backLeftZ) + } + if (trim.frontRightX > 0 && trim.frontRightZ > 0 && maxZ - trim.frontRightZ < 0) { + maxX = Math.min(maxX, maxX - (trim.frontRightX * (trim.frontRightZ - maxZ)) / trim.frontRightZ) + } + if (trim.backRightX > 0 && trim.backRightZ > 0 && minZ + trim.backRightZ > 0) { + maxX = Math.min(maxX, maxX - (trim.backRightX * (trim.backRightZ + minZ)) / trim.backRightZ) + } + + if (trim.frontLeftX > 0 && trim.frontLeftZ > 0 && minX + trim.frontLeftX > 0) { + maxZ = Math.min(maxZ, maxZ - (trim.frontLeftZ * (trim.frontLeftX + minX)) / trim.frontLeftX) + } + if (trim.frontRightX > 0 && trim.frontRightZ > 0 && maxX - trim.frontRightX < 0) { + maxZ = Math.min(maxZ, maxZ - (trim.frontRightZ * (trim.frontRightX - maxX)) / trim.frontRightX) + } + if (trim.backLeftX > 0 && trim.backLeftZ > 0 && minX + trim.backLeftX > 0) { + minZ = Math.max(minZ, minZ + (trim.backLeftZ * (trim.backLeftX + minX)) / trim.backLeftX) + } + if (trim.backRightX > 0 && trim.backRightZ > 0 && maxX - trim.backRightX < 0) { + minZ = Math.max(minZ, minZ + (trim.backRightZ * (trim.backRightX - maxX)) / trim.backRightX) + } + + return { + minX, + maxX, + minZ, + maxZ, + width: Math.max(0.01, maxX - minX), + depth: Math.max(0.01, maxZ - minZ), + } +} + /** Segment-local surface height used by roof accessory placement and hit disambiguation. */ export function getRoofSegmentSurfaceY( node: Pick & @@ -259,20 +606,49 @@ export function getRoofSegmentSurfaceY( localX: number, localZ: number, ): number { - const activeRh = getActiveRoofHeight(node) + const slopeFrame = getSegmentSlopeFrame(node) + const activeRh = slopeFrame.activeRh const peakY = node.wallHeight + activeRh if (activeRh === 0) return node.wallHeight - if ( - node.roofType === 'gable' || - node.roofType === 'gambrel' || - node.roofType === 'mansard' || - node.roofType === 'dutch' - ) { + if (node.roofType === 'gable' || node.roofType === 'gambrel' || node.roofType === 'mansard') { const t = node.depth > 0 ? Math.abs(localZ) / (node.depth / 2) : 0 return peakY - t * activeRh } + if (node.roofType === 'dutch') { + const hipHeightRatio = node.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio + const metrics = getDutchUpperShellBounds(node) + const lowerRise = activeRh * hipHeightRatio + if (metrics.axis === 'x') { + const waistHalfZ = Math.max(0.0001, metrics.waistHalfZ) + if (Math.abs(localX) <= metrics.upperHalfX && Math.abs(localZ) <= waistHalfZ) { + const upperRise = activeRh * (1 - hipHeightRatio) + const upperTan = upperRise / waistHalfZ + return peakY - Math.abs(localZ) * upperTan + } + + const xProgressDenom = Math.max(0.0001, node.width / 2 - metrics.waistHalfX) + const zProgressDenom = Math.max(0.0001, node.depth / 2 - waistHalfZ) + const xProgress = Math.max(0, Math.abs(localX) - metrics.waistHalfX) / xProgressDenom + const zProgress = Math.max(0, Math.abs(localZ) - waistHalfZ) / zProgressDenom + return node.wallHeight + lowerRise * (1 - Math.min(1, Math.max(xProgress, zProgress))) + } + + const waistHalfX = Math.max(0.0001, metrics.waistHalfX) + if (Math.abs(localX) <= waistHalfX && Math.abs(localZ) <= metrics.upperHalfZ) { + const upperRise = activeRh * (1 - hipHeightRatio) + const upperRun = waistHalfX + const upperTan = upperRise / upperRun + return peakY - Math.abs(localX) * upperTan + } + const xProgressDenom = Math.max(0.0001, node.width / 2 - waistHalfX) + const zProgressDenom = Math.max(0.0001, node.depth / 2 - metrics.waistHalfZ) + const xProgress = Math.max(0, Math.abs(localX) - waistHalfX) / xProgressDenom + const zProgress = Math.max(0, Math.abs(localZ) - metrics.waistHalfZ) / zProgressDenom + return node.wallHeight + lowerRise * (1 - Math.min(1, Math.max(xProgress, zProgress))) + } + if (node.roofType === 'shed') { const t = (localZ + node.depth / 2) / (node.depth || 1) return peakY - t * activeRh diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 94997dba..bc7b524a 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -3,8 +3,12 @@ import { type AnyNode, type AnyNodeId, AnyNode as AnyNodeSchema, + createDefaultRidgeVentsForSegment, getEffectiveWallSurfaceMaterial, getWallSurfaceMaterialSignature, + isAutoRidgeVentEnabled, + isDefaultRidgeVentNode, + type RoofSegmentNode, type WallNode, } from '../../schema' import type { CollectionId } from '../../schema/collections' @@ -24,6 +28,21 @@ type WallMergePlan = { attachmentUpdates: WallAttachmentUpdate[] } +const DEFAULT_RIDGE_VENT_REFRESH_FIELDS = new Set([ + 'roofType', + 'width', + 'depth', + 'pitch', + 'overhang', + 'wallThickness', + 'shingleThickness', + 'gambrelLowerWidthRatio', + 'mansardSteepWidthRatio', + 'dutchHipWidthRatio', + 'dutchWaistLengthRatio', + 'dutchGabletRake', +]) + type ZodCheckLike = { _zod?: { def?: { @@ -496,6 +515,44 @@ function parseUpdatedNode(currentNode: AnyNode, data: Partial): AnyNode return { ...currentNode, ...(sanitized.value as Partial) } as AnyNode } +function shouldRefreshDefaultRidgeVents(data: Partial) { + return Object.keys(data).some((key) => DEFAULT_RIDGE_VENT_REFRESH_FIELDS.has(key)) +} + +function refreshDefaultRidgeVentsForSegment( + nextNodes: Record, + segment: RoofSegmentNode, +): AnyNodeId[] { + const childIds = Array.isArray(segment.children) ? (segment.children as AnyNodeId[]) : [] + if (!isAutoRidgeVentEnabled(segment, nextNodes)) return [] + + const defaultIds = childIds.filter((childId) => + isDefaultRidgeVentNode(nextNodes[childId], segment.id), + ) + const defaultIdSet = new Set(defaultIds) + for (const id of defaultIds) { + delete nextNodes[id] + } + + const nextVents = createDefaultRidgeVentsForSegment(segment) + for (const vent of nextVents) { + nextNodes[vent.id as AnyNodeId] = { + ...vent, + parentId: segment.id, + } as AnyNode + } + + nextNodes[segment.id as AnyNodeId] = { + ...segment, + children: [ + ...childIds.filter((childId) => !defaultIdSet.has(childId)), + ...nextVents.map((vent) => vent.id as AnyNodeId), + ], + } as AnyNode + + return nextVents.map((vent) => vent.id as AnyNodeId) +} + // Track pending RAF for updateNodesAction to prevent multiple queued callbacks let pendingRafId: number | null = null let pendingUpdates: Set = new Set() @@ -808,6 +865,11 @@ export const applyNodeChangesAction = ( } nextNodes[id] = updatedNode + if (updatedNode.type === 'roof-segment' && shouldRefreshDefaultRidgeVents(data)) { + for (const ventId of refreshDefaultRidgeVentsForSegment(nextNodes, updatedNode)) { + nodesToMarkDirty.add(ventId) + } + } nodesToMarkDirty.add(id) } @@ -905,6 +967,7 @@ export const updateNodesAction = ( ) => { if (get().readOnly) return const parentsToUpdate = new Set() + const extraNodesToUpdate = new Set() set((state) => { const nextNodes = { ...state.nodes } @@ -952,6 +1015,11 @@ export const updateNodesAction = ( // Apply the update nextNodes[id] = updatedNode + if (updatedNode.type === 'roof-segment' && shouldRefreshDefaultRidgeVents(data)) { + for (const ventId of refreshDefaultRidgeVentsForSegment(nextNodes, updatedNode)) { + extraNodesToUpdate.add(ventId) + } + } } return { nodes: nextNodes } @@ -964,6 +1032,9 @@ export const updateNodesAction = ( for (const pId of parentsToUpdate) { pendingUpdates.add(pId) } + for (const id of extraNodesToUpdate) { + pendingUpdates.add(id) + } if (pendingRafId !== null) { cancelAnimationFrame(pendingRafId) diff --git a/packages/core/src/store/actions/ridge-vent-update.test.ts b/packages/core/src/store/actions/ridge-vent-update.test.ts new file mode 100644 index 00000000..19bfac4b --- /dev/null +++ b/packages/core/src/store/actions/ridge-vent-update.test.ts @@ -0,0 +1,370 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { createDefaultRidgeVentsForSegment, RidgeVentNode } from '../../schema/nodes/ridge-vent' +import { RoofNode } from '../../schema/nodes/roof' +import { RoofSegmentNode } from '../../schema/nodes/roof-segment' +import type { AnyNode, AnyNodeId } from '../../schema/types' +import useScene from '../use-scene' + +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +describe('roof segment default ridge vents', () => { + beforeEach(() => { + useScene.setState({ + nodes: {}, + rootNodeIds: [], + dirtyNodes: new Set(), + collections: {}, + materials: {}, + readOnly: false, + }) + }) + + test('regenerates default ridge vents when the host ridge geometry changes', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 6, + }) + const defaults = createDefaultRidgeVentsForSegment(segment) + const custom = RidgeVentNode.parse({ + id: 'rvent_custom' as never, + parentId: segment.id, + roofSegmentId: segment.id, + name: 'Custom Ridge Vent', + position: [0, 0.2, 0], + length: 1.25, + materialPreset: 'preset-custom', + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: [...defaults.map((vent) => vent.id), custom.id], + } as AnyNode, + ...Object.fromEntries( + defaults.map((vent) => [ + vent.id, + { ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode, + ]), + ), + [custom.id]: custom as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + const oldDefaultIds = defaults.map((vent) => vent.id) + useScene.getState().updateNode(segment.id as AnyNodeId, { width: 12 } as Partial) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + const nextChildren = nextSegment?.children ?? [] + const nextDefaultIds = nextChildren.filter((id) => id !== custom.id) + + expect(nextChildren).toContain(custom.id) + expect(useScene.getState().nodes[custom.id as AnyNodeId]).toMatchObject({ + length: 1.25, + materialPreset: 'preset-custom', + }) + for (const oldId of oldDefaultIds) { + expect(useScene.getState().nodes[oldId as AnyNodeId]).toBeUndefined() + } + expect(nextDefaultIds).toHaveLength(defaults.length) + expect( + nextDefaultIds.some((id) => { + const node = useScene.getState().nodes[id as AnyNodeId] + return node?.type === 'ridge-vent' && node.length > (defaults[0]?.length ?? 0) + }), + ).toBe(true) + }) + + test('does not regenerate default ridge vents when only trim changes', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 6, + }) + const defaults = createDefaultRidgeVentsForSegment(segment) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: defaults.map((vent) => vent.id), + } as AnyNode, + ...Object.fromEntries( + defaults.map((vent) => [ + vent.id, + { ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode, + ]), + ), + } as Record, + [roof.id as AnyNodeId], + ) + + const originalDefaultId = defaults[0]?.id as AnyNodeId + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + trim: { ...segment.trim, left: 2, frontLeftX: 2, frontLeftZ: 3 }, + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + + expect(nextSegment?.children).toEqual(defaults.map((vent) => vent.id)) + expect(useScene.getState().nodes[originalDefaultId]).toMatchObject({ + id: originalDefaultId, + length: defaults[0]?.length, + position: defaults[0]?.position, + }) + }) + + test('creates default ridge vents after a geometry change when auto ridge vent is enabled', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'flat', + width: 8, + depth: 6, + metadata: { autoRidgeVent: true }, + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: segment as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + roofType: 'gable', + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + + expect(nextSegment?.children).toHaveLength(1) + expect(useScene.getState().nodes[nextSegment?.children[0] as AnyNodeId]).toMatchObject({ + type: 'ridge-vent', + roofSegmentId: segment.id, + }) + }) + + test('regenerates default ridge vents when Dutch auto-vent fields change', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'dutch', + width: 8, + depth: 6, + metadata: { autoRidgeVent: true }, + }) + const defaults = createDefaultRidgeVentsForSegment(segment) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: defaults.map((vent) => vent.id), + } as AnyNode, + ...Object.fromEntries( + defaults.map((vent) => [ + vent.id, + { ...vent, parentId: segment.id, roofSegmentId: segment.id } as AnyNode, + ]), + ), + } as Record, + [roof.id as AnyNodeId], + ) + + const originalDefaultIds = defaults.map((vent) => vent.id) + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + pitch: 52, + dutchWaistLengthRatio: 0.72, + dutchGabletRake: 0.9, + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + const nextChildren = nextSegment?.children ?? [] + + expect(nextChildren).toHaveLength(defaults.length) + expect( + nextChildren.some((id) => + originalDefaultIds.includes(id as (typeof originalDefaultIds)[number]), + ), + ).toBe(false) + for (const oldId of originalDefaultIds) { + expect(useScene.getState().nodes[oldId as AnyNodeId]).toBeUndefined() + } + }) + + test('refresh replaces legacy default vents that still use preset-white', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 6, + metadata: { autoRidgeVent: true }, + }) + const legacyDefault = RidgeVentNode.parse({ + id: 'rvent_legacy' as never, + parentId: segment.id, + roofSegmentId: segment.id, + name: 'Ridge Vent', + style: 'shingled', + materialPreset: 'preset-white', + position: [0, 0, 0], + length: 8, + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: [legacyDefault.id], + } as AnyNode, + [legacyDefault.id]: legacyDefault as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + pitch: 52, + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + const nextChildren = nextSegment?.children ?? [] + + expect(nextChildren).toHaveLength(1) + expect(nextChildren[0]).not.toBe(legacyDefault.id) + expect(useScene.getState().nodes[legacyDefault.id as AnyNodeId]).toBeUndefined() + }) + + test('refresh preserves user-created default-looking ridge vents without legacy preset metadata', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'gable', + width: 8, + depth: 6, + metadata: { autoRidgeVent: true }, + }) + const userVent = RidgeVentNode.parse({ + id: 'rvent_user' as never, + parentId: segment.id, + roofSegmentId: segment.id, + name: 'Ridge Vent', + style: 'shingled', + position: [0, 0, 0], + length: 8, + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: { + ...segment, + children: [userVent.id], + } as AnyNode, + [userVent.id]: userVent as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + pitch: 52, + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + const nextChildren = nextSegment?.children ?? [] + + expect(nextChildren).toContain(userVent.id) + expect(useScene.getState().nodes[userVent.id as AnyNodeId]).toMatchObject({ + id: userVent.id, + roofSegmentId: segment.id, + name: 'Ridge Vent', + style: 'shingled', + length: 8, + }) + }) + + test('does not create default ridge vents after a geometry change when auto ridge vent is disabled', () => { + const roof = RoofNode.parse({ id: 'roof_test' as never, children: [] }) + const segment = RoofSegmentNode.parse({ + id: 'rseg_test' as never, + parentId: roof.id, + roofType: 'flat', + width: 8, + depth: 6, + metadata: { autoRidgeVent: false }, + }) + + useScene.getState().setScene( + { + [roof.id]: { ...roof, children: [segment.id] } as AnyNode, + [segment.id]: segment as AnyNode, + } as Record, + [roof.id as AnyNodeId], + ) + + useScene.getState().updateNode( + segment.id as AnyNodeId, + { + roofType: 'gable', + } as Partial, + ) + + const nextSegment = useScene.getState().nodes[segment.id as AnyNodeId] as + | RoofSegmentNode + | undefined + + expect(nextSegment?.children ?? []).toHaveLength(0) + }) +}) diff --git a/packages/core/src/systems/elevator/elevator-service.ts b/packages/core/src/systems/elevator/elevator-service.ts index 5239434c..736ffe1d 100644 --- a/packages/core/src/systems/elevator/elevator-service.ts +++ b/packages/core/src/systems/elevator/elevator-service.ts @@ -83,7 +83,7 @@ export function resolveElevatorServiceLevels( export function getElevatorLevelHeight(levelId: string, nodes: Record): number { const level = nodes[levelId as AnyNodeId] as LevelNode | undefined - if (!level || level.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT + if (level?.type !== 'level') return DEFAULT_ELEVATOR_LEVEL_HEIGHT let maxTop = 0 diff --git a/packages/core/src/systems/fence/fence-centerline.ts b/packages/core/src/systems/fence/fence-centerline.ts new file mode 100644 index 00000000..0cfc402c --- /dev/null +++ b/packages/core/src/systems/fence/fence-centerline.ts @@ -0,0 +1,61 @@ +import type { FenceNode } from '../../schema' +import { getWallCurveFrameAt, getWallCurveLength, sampleWallCenterline } from '../wall/wall-curve' +import type { Point2D } from '../wall/wall-mitering' +import { + getFenceSplineFrameAt, + getFenceSplineLength, + isSplineFence, + sampleFenceSpline, +} from './fence-spline' + +/** + * Unified fence centerline accessors. A fence is either: + * - a spline fence (`path` of >= 2 control points) → smooth Catmull-Rom, or + * - a straight / single-arc fence (`start`/`end` + optional `curveOffset`). + * + * These wrappers branch on `isSplineFence` and return the SAME shapes the wall + * arc helpers return, so every consumer (3D geometry, 2D floor-plan, length, + * handles) can sample the centerline without caring which kind it is. Wall arc + * math in `wall-curve.ts` is untouched — walls never carry a `path`. + */ + +const DEFAULT_SAMPLE_SEGMENTS = 96 + +type CurveFrame = { + point: Point2D + tangent: Point2D + normal: Point2D +} + +export function getFenceCenterlineFrameAt(fence: FenceNode, t: number): CurveFrame { + if (isSplineFence(fence) && fence.path) { + return getFenceSplineFrameAt(fence.path, t, fence.tangents) + } + return getWallCurveFrameAt(fence, t) +} + +export function sampleFenceCenterline( + fence: FenceNode, + segments = DEFAULT_SAMPLE_SEGMENTS, +): Point2D[] { + if (isSplineFence(fence) && fence.path) { + // Spread the requested sample budget across the spans so a long path still + // reads smoothly without exploding the point count. + const spanCount = Math.max(1, fence.path.length - 1) + const perSpan = Math.max(2, Math.ceil(segments / spanCount)) + return sampleFenceSpline(fence.path, fence.tangents, perSpan) + } + return sampleWallCenterline(fence, segments) +} + +export function getFenceCenterlineLength( + fence: FenceNode, + segments = DEFAULT_SAMPLE_SEGMENTS, +): number { + if (isSplineFence(fence) && fence.path) { + const spanCount = Math.max(1, fence.path.length - 1) + const perSpan = Math.max(2, Math.ceil(segments / spanCount)) + return getFenceSplineLength(fence.path, fence.tangents, perSpan) + } + return getWallCurveLength(fence, segments) +} diff --git a/packages/core/src/systems/fence/fence-spline.test.ts b/packages/core/src/systems/fence/fence-spline.test.ts new file mode 100644 index 00000000..9f4e5009 --- /dev/null +++ b/packages/core/src/systems/fence/fence-spline.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from 'bun:test' +import { + getFenceControlHandle, + getFenceSplineFrameAt, + getFenceSplineLength, + getTwoPointFenceCurveTangents, + isSplineFence, + sampleFenceSpline, +} from './fence-spline' + +describe('isSplineFence', () => { + test('false without a path or with < 2 points', () => { + expect(isSplineFence({ path: undefined })).toBe(false) + expect(isSplineFence({ path: [[0, 0]] })).toBe(false) + }) + + test('true with >= 2 points', () => { + expect( + isSplineFence({ + path: [ + [0, 0], + [1, 0], + ], + }), + ).toBe(true) + }) +}) + +describe('sampleFenceSpline', () => { + test('honors the control points as on-curve anchors', () => { + const path: Array<[number, number]> = [ + [0, 0], + [2, 2], + [4, 0], + ] + const sampled = sampleFenceSpline(path, undefined, 8) + // First and last sample equal the path endpoints exactly. + expect(sampled[0]).toEqual({ x: 0, y: 0 }) + expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 }) + // The interior control point is interpolated: some sample lands on (2, 2). + const hitsMiddle = sampled.some((p) => Math.hypot(p.x - 2, p.y - 2) < 1e-6) + expect(hitsMiddle).toBe(true) + }) + + test('two-point path with no tangents is a straight segment', () => { + expect( + sampleFenceSpline( + [ + [0, 0], + [3, 0], + ], + undefined, + 8, + ), + ).toEqual([ + { x: 0, y: 0 }, + { x: 3, y: 0 }, + ]) + }) + + test('a stored tangent bends an otherwise straight two-point span', () => { + const path: Array<[number, number]> = [ + [0, 0], + [4, 0], + ] + // Pull the first point's handle up — the span should bow off the X axis. + const sampled = sampleFenceSpline(path, [[0, 2], null], 12) + expect(sampled[0]).toEqual({ x: 0, y: 0 }) + expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 }) + const maxY = Math.max(...sampled.map((p) => Math.abs(p.y))) + expect(maxY).toBeGreaterThan(0.1) + }) + + test('generated two-point curve tangents create a gentle arc', () => { + const path: Array<[number, number]> = [ + [0, 0], + [4, 0], + ] + const sampled = sampleFenceSpline(path, getTwoPointFenceCurveTangents(path), 16) + expect(sampled[0]).toEqual({ x: 0, y: 0 }) + expect(sampled[sampled.length - 1]).toEqual({ x: 4, y: 0 }) + const maxY = Math.max(...sampled.map((p) => p.y)) + expect(maxY).toBeGreaterThan(0.4) + }) + + test('produces a smooth (no-cusp) curve on uneven spacing', () => { + const path: Array<[number, number]> = [ + [0, 0], + [1, 0.2], + [5, 0.3], + [6, 0], + ] + const sampled = sampleFenceSpline(path, undefined, 16) + let maxTurn = 0 + for (let i = 2; i < sampled.length; i += 1) { + const a = sampled[i - 2]! + const b = sampled[i - 1]! + const c = sampled[i]! + const t1 = Math.atan2(b.y - a.y, b.x - a.x) + const t2 = Math.atan2(c.y - b.y, c.x - b.x) + let d = Math.abs(t2 - t1) + if (d > Math.PI) d = 2 * Math.PI - d + maxTurn = Math.max(maxTurn, d) + } + expect(maxTurn).toBeLessThan(Math.PI / 2) + }) +}) + +describe('getFenceControlHandle', () => { + test('returns the stored tangent when present', () => { + expect( + getFenceControlHandle( + [ + [0, 0], + [4, 0], + ], + [[1, 2], null], + 0, + ), + ).toEqual({ x: 1, y: 2 }) + }) + + test('falls back to the automatic distance-aware tangent', () => { + const path: Array<[number, number]> = [ + [0, 0], + [3, 0], + [6, 0], + ] + expect(getFenceControlHandle(path, undefined, 1)).toEqual({ x: 1, y: 0 }) + }) +}) + +describe('getFenceSplineFrameAt', () => { + const path: Array<[number, number]> = [ + [0, 0], + [2, 0], + [4, 0], + ] + + test('t=0 / t=1 land on the endpoints', () => { + expect(getFenceSplineFrameAt(path, 0).point).toEqual({ x: 0, y: 0 }) + expect(getFenceSplineFrameAt(path, 1).point).toEqual({ x: 4, y: 0 }) + }) + + test('returns a unit tangent and perpendicular normal', () => { + const frame = getFenceSplineFrameAt(path, 0.5) + expect(Math.hypot(frame.tangent.x, frame.tangent.y)).toBeCloseTo(1, 5) + const dot = frame.tangent.x * frame.normal.x + frame.tangent.y * frame.normal.y + expect(dot).toBeCloseTo(0, 5) + }) +}) + +describe('getFenceSplineLength', () => { + test('roughly matches the straight distance for a straight path', () => { + expect( + getFenceSplineLength( + [ + [0, 0], + [3, 4], + ], + undefined, + 8, + ), + ).toBeCloseTo(5, 5) + }) + + test('a curved path is longer than its endpoint chord', () => { + const path: Array<[number, number]> = [ + [0, 0], + [2, 2], + [4, 0], + ] + const chord = Math.hypot(4, 0) + expect(getFenceSplineLength(path, undefined, 16)).toBeGreaterThan(chord) + }) +}) diff --git a/packages/core/src/systems/fence/fence-spline.ts b/packages/core/src/systems/fence/fence-spline.ts new file mode 100644 index 00000000..4d55cf74 --- /dev/null +++ b/packages/core/src/systems/fence/fence-spline.ts @@ -0,0 +1,257 @@ +import type { FenceNode } from '../../schema' +import type { Point2D } from '../wall/wall-mitering' + +/** + * Pure 2D spline sampling for fences whose centerline is defined by a `path` + * of control points (the "flying path" curved fence). + * + * Each control point carries an OUT-handle offset vector. When the user has + * not adjusted it, the handle defaults to a distance-aware Catmull-Rom-style + * tangent: direction comes from neighbouring points, while length is capped by + * the shorter adjacent span. When the user drags a tangent handle (stored in + * `tangents[i]`), that point's handle becomes the stored vector and the IN + * handle is its mirror, so the curve stays smooth (C1) through the point but + * bends to taste. Each span is then a cubic Bézier between consecutive points + * using their handles. + * + * Lives in `@pascal-app/core` and imports NO Three.js — the same `CurveFrame` + * shape that `wall-curve.ts` returns (point / tangent / normal) is produced + * here so the spline branch is a drop-in for the arc branch in every consumer. + */ + +const EPSILON = 1e-6 +const DEFAULT_SEGMENTS_PER_SPAN = 32 +const TWO_POINT_CURVE_SAGITTA_RATIO = 0.18 +const TWO_POINT_CURVE_MIN_SAGITTA = 0.18 +const TWO_POINT_CURVE_MAX_SAGITTA = 1.2 + +type FenceSplineLike = Pick +type TangentList = ReadonlyArray | undefined + +export function isSplineFence(fence: FenceSplineLike): boolean { + return Array.isArray(fence.path) && fence.path.length >= 2 +} + +type CurveFrame = { + point: Point2D + tangent: Point2D + normal: Point2D +} + +function toPoints(path: ReadonlyArray): Point2D[] { + return path.map(([x, y]) => ({ x, y })) +} + +function distance(a: Point2D, b: Point2D): number { + return Math.hypot(b.x - a.x, b.y - a.y) +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)) +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} + +/** + * OUT-handle offset vector for control point `index` — the stored tangent if + * the user has adjusted it, otherwise the automatic distance-aware tangent + * (endpoints duplicate the neighbour so the ends stay tangent to their single + * span). The IN handle is the negation of this. + * + * Exported so the editing UI can draw the tangent line / handle dots at the + * right place even before the user has dragged them. + */ +export function getFenceControlHandle( + path: ReadonlyArray, + tangents: TangentList, + index: number, +): Point2D { + const stored = tangents?.[index] + if (stored) { + return { x: stored[0], y: stored[1] } + } + const prev = path[index - 1] ?? path[index]! + const next = path[index + 1] ?? path[index]! + const prevDistance = distance( + { x: path[index]![0], y: path[index]![1] }, + { + x: prev[0], + y: prev[1], + }, + ) + const nextDistance = distance( + { x: path[index]![0], y: path[index]![1] }, + { + x: next[0], + y: next[1], + }, + ) + const handleLength = Math.min(prevDistance || nextDistance, nextDistance || prevDistance) / 3 + const vx = next[0] - prev[0] + const vy = next[1] - prev[1] + const len = Math.hypot(vx, vy) + if (len < EPSILON || handleLength < EPSILON) { + return { x: 0, y: 0 } + } + + return { + x: (vx / len) * handleLength, + y: (vy / len) * handleLength, + } +} + +export function getTwoPointFenceCurveTangents( + path: ReadonlyArray, +): Array<[number, number] | null> | undefined { + if (path.length !== 2) return undefined + const start = path[0]! + const end = path[1]! + const dx = end[0] - start[0] + const dy = end[1] - start[1] + const chordLength = Math.hypot(dx, dy) + if (chordLength < EPSILON) return undefined + + const tangentX = dx / 3 + const tangentY = dy / 3 + const normalX = -dy / chordLength + const normalY = dx / chordLength + const sagitta = clamp( + chordLength * TWO_POINT_CURVE_SAGITTA_RATIO, + TWO_POINT_CURVE_MIN_SAGITTA, + TWO_POINT_CURVE_MAX_SAGITTA, + ) + const bendX = normalX * sagitta * (4 / 3) + const bendY = normalY * sagitta * (4 / 3) + + return [ + [tangentX + bendX, tangentY + bendY], + [tangentX - bendX, tangentY - bendY], + ] +} + +function cubicBezier(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, u: number): Point2D { + const mu = 1 - u + const a = mu * mu * mu + const b = 3 * mu * mu * u + const c = 3 * mu * u * u + const d = u * u * u + return { + x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, + y: a * p0.y + b * p1.y + c * p2.y + d * p3.y, + } +} + +function hasAnyTangent(tangents: TangentList): boolean { + return Array.isArray(tangents) && tangents.some((t) => t != null) +} + +/** + * Sample the spline centerline into a polyline. Control points are honored as + * on-curve anchors; `segmentsPerSpan` controls smoothness between them. + * Returns `(segmentsPerSpan * spanCount) + 1` points, first == path[0], + * last == path[-1]. + */ +export function sampleFenceSpline( + path: ReadonlyArray, + tangents?: TangentList, + segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN, +): Point2D[] { + const pts = toPoints(path) + if (pts.length === 0) return [] + if (pts.length === 1) return [pts[0]!] + // Two points with no adjusted tangents is a straight segment. + if (pts.length === 2 && !hasAnyTangent(tangents)) return [pts[0]!, pts[1]!] + + const steps = Math.max(1, Math.floor(segmentsPerSpan)) + const result: Point2D[] = [pts[0]!] + + for (let i = 0; i < pts.length - 1; i += 1) { + const p1 = pts[i]! + const p2 = pts[i + 1]! + const outHandle = getFenceControlHandle(path, tangents, i) + const nextHandle = getFenceControlHandle(path, tangents, i + 1) + // Bézier controls: leave p1 along its OUT handle, arrive at p2 along its + // IN handle (= negated OUT handle). + const c1: Point2D = { x: p1.x + outHandle.x, y: p1.y + outHandle.y } + const c2: Point2D = { x: p2.x - nextHandle.x, y: p2.y - nextHandle.y } + + for (let s = 1; s <= steps; s += 1) { + result.push(cubicBezier(p1, c1, c2, p2, s / steps)) + } + } + + return result +} + +function frameFromPolyline(points: Point2D[], t: number): CurveFrame { + if (points.length === 0) { + return { + point: { x: 0, y: 0 }, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + if (points.length === 1) { + return { + point: points[0]!, + tangent: { x: 1, y: 0 }, + normal: { x: 0, y: 1 }, + } + } + + const clamped = clamp01(t) + const lastIndex = points.length - 1 + const scaled = clamped * lastIndex + const lower = Math.min(lastIndex - 1, Math.floor(scaled)) + const upper = lower + 1 + const localU = scaled - lower + + const a = points[lower]! + const b = points[upper]! + const point = { + x: a.x + (b.x - a.x) * localU, + y: a.y + (b.y - a.y) * localU, + } + + const dx = b.x - a.x + const dy = b.y - a.y + const len = Math.hypot(dx, dy) + const tangent = len < EPSILON ? { x: 1, y: 0 } : { x: dx / len, y: dy / len } + + return { + point, + tangent, + normal: { x: -tangent.y, y: tangent.x }, + } +} + +/** + * Frame (point + tangent + normal) at parameter `t` in [0, 1] along the spline + * centerline. Same return shape as `getWallCurveFrameAt` so it is a drop-in for + * the arc branch. `t` is uniform over the sampled polyline (arc length is not + * reparameterised — adequate for marching posts / rails and far cheaper). + */ +export function getFenceSplineFrameAt( + path: ReadonlyArray, + t: number, + tangents?: TangentList, + segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN, +): CurveFrame { + return frameFromPolyline(sampleFenceSpline(path, tangents, segmentsPerSpan), t) +} + +/** Total polyline length of the sampled spline centerline. */ +export function getFenceSplineLength( + path: ReadonlyArray, + tangents?: TangentList, + segmentsPerSpan = DEFAULT_SEGMENTS_PER_SPAN, +): number { + const points = sampleFenceSpline(path, tangents, segmentsPerSpan) + let total = 0 + for (let i = 1; i < points.length; i += 1) { + total += distance(points[i - 1]!, points[i]!) + } + return total +} diff --git a/packages/core/src/systems/stair/stair-footprint.ts b/packages/core/src/systems/stair/stair-footprint.ts index 9a6e88ad..cf41533d 100644 --- a/packages/core/src/systems/stair/stair-footprint.ts +++ b/packages/core/src/systems/stair/stair-footprint.ts @@ -149,6 +149,19 @@ function straightStairAABB( const ARC_SAMPLES = 48 +function getSpiralLandingSweep(stair: StairNode, sweepAngle: number) { + if ((stair.topLandingMode ?? 'none') !== 'integrated') return 0 + + const innerRadius = Math.max(0.05, stair.innerRadius ?? 0.9) + const width = Math.max(stair.width ?? 1, 0.4) + const landingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max(width * 0.9, 0.8)) + + return ( + Math.min(Math.PI * 0.75, landingDepth / Math.max(innerRadius + width / 2, 0.1)) * + Math.sign(sweepAngle || 1) + ) +} + /** Bounding box of a curved / spiral stair's annular sector (plus the * integrated spiral top landing when present). */ function arcStairAABB(stair: StairNode): StairFootprintAABB | null { @@ -158,7 +171,8 @@ function arcStairAABB(stair: StairNode): StairFootprintAABB | null { const width = Math.max(stair.width ?? 1, 0.4) const outerRadius = innerRadius + width - let sweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2) + const rawSweep = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2) + let sweep = rawSweep // A full revolution would make the arc degenerate; clamp just under 2π the // same way the floor-plan emitter does so the sampled box stays correct. if (Math.abs(sweep) >= Math.PI * 2) sweep = Math.sign(sweep || 1) * (Math.PI * 2 - 0.001) @@ -175,17 +189,17 @@ function arcStairAABB(stair: StairNode): StairFootprintAABB | null { extendByLocal(box, stair, cos * outerRadius, sin * outerRadius) } - // Integrated spiral top landing — a rectangle hung off the outer rim. + // Integrated spiral top landing renders as an angular extension of the + // annular stair body, not as a rectangular box outside the outer rim. if (isSpiral && stair.topLandingMode === 'integrated') { - const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1) - const halfWidth = width / 2 - for (const [cornerX, cornerZ] of [ - [outerRadius, -halfWidth], - [outerRadius + depth, -halfWidth], - [outerRadius + depth, halfWidth], - [outerRadius, halfWidth], - ] as const) { - extendByLocal(box, stair, cornerX, cornerZ) + const landingSweep = getSpiralLandingSweep(stair, rawSweep) + const landingSteps = Math.max(1, Math.ceil(Math.abs(landingSweep) / (Math.PI / 24))) + for (let step = 0; step <= landingSteps; step += 1) { + const angle = rawSweep / 2 + (landingSweep * step) / landingSteps + const cos = Math.cos(angle) + const sin = Math.sin(angle) + extendByLocal(box, stair, cos * innerRadius, sin * innerRadius) + extendByLocal(box, stair, cos * outerRadius, sin * outerRadius) } } diff --git a/packages/core/src/systems/stair/stair-opening-sync.test.ts b/packages/core/src/systems/stair/stair-opening-sync.test.ts index 2ba5169c..9068c029 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.test.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -508,4 +508,48 @@ describe('syncAutoStairOpenings', () => { expect(landingUpdate?.data.holes).toEqual([manualOpening]) expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'manual' }]) }) + + test('does not add a separate rectangular hole for an integrated spiral top landing', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const landingSlab = SlabNode.parse({ + name: 'Landing Slab', + parentId: upper.id, + polygon: [ + [-4, -4], + [4, -4], + [4, 4], + [-4, 4], + ], + }) + const stair = StairNode.parse({ + id: 'stair_spiral_landing', + name: 'Spiral Landing Stair', + parentId: ground.id, + position: [0, 0, 0], + rotation: Math.PI / 2, + stairType: 'spiral', + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + innerRadius: 0.35, + width: 1.2, + sweepAngle: Math.PI * 1.6, + topLandingMode: 'integrated', + topLandingDepth: 1.1, + }) + const nodes = Object.fromEntries( + [building, ground, upper, landingSlab, stair].map((node) => [node.id, node]), + ) as Record + + const updates = syncAutoStairOpenings(nodes) + const landingUpdate = updates.find((update) => update.id === landingSlab.id) + const holes = landingUpdate?.data.holes ?? [] + const rectangularHoles = holes.filter((hole) => hole.length === 4) + + expect(holes).toHaveLength(1) + expect(rectangularHoles).toHaveLength(0) + expect(landingUpdate?.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + }) }) diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index def28ce6..36b1c2ff 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -454,22 +454,6 @@ function getSpiralOpeningPolygon(stair: StairNode, offset: number = 0): Point2D[ }) } -function getSpiralLandingPolygon(stair: StairNode, offset: number = 0): Point2D[] { - const width = Math.max(stair.width ?? 1, 0.4) - const outerRadius = Math.max(0.05, (stair.innerRadius ?? 0.9) + width) - const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1) - const halfWidth = width / 2 - - const localPoints: Point2D[] = [ - [outerRadius - offset, -halfWidth - offset], - [outerRadius + depth + offset, -halfWidth - offset], - [outerRadius + depth + offset, halfWidth + offset], - [outerRadius - offset, halfWidth + offset], - ] - - return localPoints.map(([x, z]) => toWorldPlanPoint(stair, x, z)) -} - function getStraightOpeningPolygonsForSurface( stair: StairNode, nodes: Record, @@ -575,11 +559,7 @@ function getStairOpeningPolygons( if (stair.stairType === 'spiral') { const offset = Math.max(openingOffset - STAIR_SLAB_OPENING_TIGHTENING, 0) - const polygons = [getSpiralOpeningPolygon(stair, offset)] - if (stair.topLandingMode === 'integrated') { - polygons.push(getSpiralLandingPolygon(stair, offset)) - } - return polygons + return [getSpiralOpeningPolygon(stair, offset)] } if (typeof targetElevation === 'number') { diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index a69e91b5..30aa6293 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -155,7 +155,7 @@ export function cloneLevelSubtree( levelId: AnyNodeId, ): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map } { const levelNode = nodes[levelId] - if (!levelNode || levelNode.type !== 'level') { + if (levelNode?.type !== 'level') { throw new Error(`Node "${levelId}" is not a level`) } diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index c3f4b459..ad3dbb23 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -10,7 +10,6 @@ import { type FloorplanMoveTargetSession, nodeRegistry, pauseSceneHistory, - resolveAlignment, resumeSceneHistory, useLiveNodeOverrides, useLiveTransforms, @@ -22,6 +21,7 @@ import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement' import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' import { sfxEmitter } from '../../lib/sfx-bus' +import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' import { useMovingNode } from '../../store/use-interaction-scope' @@ -455,11 +455,13 @@ export function FloorplanRegistryMoveOverlay() { // point by the cursor delta and commit the translated `path` instead. // The reference origin is the path centre so the SVG `translate` delta // matches the geometry's actual location (which isn't at [0,0,0]). + // Only 3D `[x, y, z]` polyline kinds (duct / pipe / lineset) are handled + // here. A spline fence also carries a `path`, but it is 2D (`[x, y]`) and + // moves through its own `floorplanMoveTarget`, so exclude shorter tuples. + const rawPath = (movingNode as { path?: unknown }).path const originalPath = - 'path' in movingNode && Array.isArray((movingNode as { path?: unknown }).path) - ? (movingNode as { path: [number, number, number][] }).path.map( - (p) => [...p] as [number, number, number], - ) + Array.isArray(rawPath) && Array.isArray(rawPath[0]) && rawPath[0].length >= 3 + ? (rawPath as [number, number, number][]).map((p) => [...p] as [number, number, number]) : null const originalPosition: [number, number, number] = originalPath ? (() => { @@ -574,7 +576,7 @@ export function FloorplanRegistryMoveOverlay() { // store, which the 2D FloorplanAlignmentGuideLayer renders // inside the rotated scene . The 3D pipeline uses a // separate store, so frames stay isolated per surface. - const result = resolveAlignment({ + const result = resolveAlignmentForFloorplanView({ moving: movingAnchors, candidates: candidateAnchors, threshold: ALIGNMENT_THRESHOLD_M, diff --git a/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx index 85fd3bb9..0a943e2f 100644 --- a/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-snap-beacon-layer.tsx @@ -14,12 +14,14 @@ import { useFloorplanRender } from './floorplan-render-context' * endpoint (corner) → square midpoint → triangle * intersection → ✕ cross wall body (edge) → circle * - * Indigo to match the 3D beacon and stay distinct from the red Figma alignment - * guides. Sizes are pixel-budgeted via `unitsPerPixel` so the marker stays a + * Indigo to match the 3D beacon, except the corner (endpoint) square which is + * green to match the alignment guides. Sizes are pixel-budgeted via + * `unitsPerPixel` so the marker stays a * constant size on screen at any zoom. Mounted inside the `data-floorplan-scene` * group so coordinates are world meters (XZ) 1:1, like the alignment guides. */ const COLOR = '#6366f1' // indigo-500 — matches the 3D beacon +const ENDPOINT_COLOR = '#22c55e' // green-500 — corner (endpoint) snap accent export const FloorplanSnapBeaconLayer = memo(function FloorplanSnapBeaconLayer() { const point = useWallSnapIndicator((s) => s.point) @@ -54,7 +56,7 @@ function SnapMarker({ z: number }) { if (kind === 'endpoint') { - return + return } if (kind === 'midpoint') { const t = m * 1.3 diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 12b34276..cb7a949f 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -44,9 +44,11 @@ import { createEditorApi } from '../../../lib/editor-api' import { type ActiveInteractionScope, boundaryReshapeScope, + controlPointReshapeScope, curveReshapeScope, endpointReshapeScope, holeEditScope, + tangentReshapeScope, } from '../../../lib/interaction/scope' import { sfxEmitter } from '../../../lib/sfx-bus' import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap' @@ -151,6 +153,14 @@ function affordanceReshapeScope( if (affordance.includes('curve')) { return curveReshapeScope(nodeId) } + if (affordance.includes('control-point')) { + const index = (payload as { index?: number } | undefined)?.index ?? 0 + return controlPointReshapeScope(nodeId, index) + } + if (affordance.includes('tangent')) { + const target = payload as { index?: number; side?: 'in' | 'out' } | undefined + return tangentReshapeScope(nodeId, target?.index ?? 0, target?.side ?? 'out') + } if (affordance.includes('endpoint')) { const endpoint = (payload as { endpoint?: 'start' | 'end' } | undefined)?.endpoint ?? 'end' return endpointReshapeScope(nodeId, endpoint) diff --git a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx index 4333b622..fb6b6506 100644 --- a/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx +++ b/packages/editor/src/components/editor/alignment-3d-guide-layer.tsx @@ -1,7 +1,6 @@ 'use client' import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core' -import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' @@ -9,6 +8,7 @@ import { memo, useMemo, useRef } from 'react' import { BoxGeometry, CircleGeometry, type Group, Vector3 } from 'three' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' +import useAlignmentGuides from '../../store/use-alignment-guides' import { formatMeasurement } from './measurement-pill' /** @@ -49,6 +49,14 @@ const guideMaterial = new MeshBasicNodeMaterial({ toneMapped: false, transparent: true, }) +const DOT_COLOR = 0x22_c5_5e // green-500 — matches the wall-snap marker +const dotMaterial = new MeshBasicNodeMaterial({ + color: DOT_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, +}) const DASH_GEOMETRY = new BoxGeometry(1, 1, 1) const DOT_GEOMETRY = new CircleGeometry(1, 24) @@ -150,7 +158,7 @@ function Dot({ position }: { position: Vec3 }) { { return useEditor.subscribe((state) => { const pose = state.navigationSyncPose - if ( - !pose || - pose.source !== '2d' || - pose.revision === lastApplied2dNavigationRevision.current - ) - return + if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return const control = controls.current if (!control) return diff --git a/packages/editor/src/components/editor/fence-tangent-lines-3d.tsx b/packages/editor/src/components/editor/fence-tangent-lines-3d.tsx new file mode 100644 index 00000000..44c9edf1 --- /dev/null +++ b/packages/editor/src/components/editor/fence-tangent-lines-3d.tsx @@ -0,0 +1,87 @@ +'use client' + +import { + type AnyNodeId, + type FenceNode, + getFenceControlHandle, + isSplineFence, + sceneRegistry, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { createPortal, useFrame } from '@react-three/fiber' +import { useMemo, useState } from 'react' +import { BufferGeometry, type Object3D, Vector3 } from 'three' +import { EDITOR_LAYER } from '../../lib/constants' + +/** + * Straight connecting line through each spline-fence control point joining its + * two tangent handle ends (the classic pen-tool look). The handle *dots* are + * registry tap-handles (see `fence/definition.ts`); this overlay only draws + * the line between them, in both views' 3D scene. + * + * Must match the handle placement's arm scale so the line ends land exactly on + * the dots. Portals into the fence's own Object3D so it inherits the same world + * transform as the geometry (path coords are node-local plan meters), and reads + * the live override so the line tracks an in-flight tangent / point drag. + */ + +// Keep in sync with TANGENT_HANDLE_ARM_SCALE in fence/definition.ts. +const TANGENT_HANDLE_ARM_SCALE = 3 +const LINE_LIFT_Y = 0.02 + +export function FenceTangentLines3D() { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + + const rawNode = useScene((s) => (selectedId ? s.nodes[selectedId as AnyNodeId] : null)) + const liveOverride = useLiveNodeOverrides((s) => + selectedId ? s.overrides.get(selectedId as AnyNodeId) : undefined, + ) + const fence = useMemo(() => { + if (rawNode?.type !== 'fence') return null + const merged = liveOverride ? ({ ...rawNode, ...liveOverride } as FenceNode) : rawNode + return isSplineFence(merged) ? merged : null + }, [rawNode, liveOverride]) + + const [object, setObject] = useState<{ id: AnyNodeId; object: Object3D } | null>(null) + const selectedObject = selectedId && object?.id === selectedId ? object.object : null + + useFrame(() => { + if (!selectedId || selectedObject) return + const next = sceneRegistry.nodes.get(selectedId) + if (next) setObject({ id: selectedId as AnyNodeId, object: next }) + }) + + const geometry = useMemo(() => { + if (!fence?.path) return null + const positions: Vector3[] = [] + for (let i = 0; i < fence.path.length; i += 1) { + const point = fence.path[i]! + const handle = getFenceControlHandle(fence.path, fence.tangents, i) + const ax = handle.x * TANGENT_HANDLE_ARM_SCALE + const az = handle.y * TANGENT_HANDLE_ARM_SCALE + // One disjoint segment per point — `lineSegments` pairs vertices, so the + // in/out ends connect through the point without joining across points. + positions.push(new Vector3(point[0] - ax, LINE_LIFT_Y, point[1] - az)) + positions.push(new Vector3(point[0] + ax, LINE_LIFT_Y, point[1] + az)) + } + return new BufferGeometry().setFromPoints(positions) + }, [fence]) + + if (!(fence && selectedObject && geometry)) return null + + return createPortal( + + + , + selectedObject, + ) +} diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 6c93d7b5..b0ab3795 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -16,8 +16,10 @@ import { getWallCurveLength, getWallThickness, ItemNode, + isCurvedWall, isRegistryMovable, isRegistrySelectable, + isSplineFence, nodeRegistry, RoofSegmentNode, type SlabNode, @@ -691,7 +693,8 @@ export function FloatingActionMenu() { onFind={node && canFindNode ? handleFind : undefined} onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined} onCurve={ - node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall) + (node?.type === 'fence' && !isSplineFence(node) && !isCurvedWall(node)) || + (node?.type === 'wall' && canCurveSelectedWall) ? handleCurve : undefined } diff --git a/packages/editor/src/components/editor/floating-building-action-menu.tsx b/packages/editor/src/components/editor/floating-building-action-menu.tsx index 3f935b59..f5e9cc8e 100644 --- a/packages/editor/src/components/editor/floating-building-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-building-action-menu.tsx @@ -38,7 +38,7 @@ export function FloatingBuildingActionMenu() { // Read lazily at click time — no need to subscribe to nodes for a // one-shot action. const node = useScene.getState().nodes[buildingId] - if (!node || node.type !== 'building') return + if (node?.type !== 'building') return sfxEmitter.emit('sfx:item-pick') setMovingNode(node as BuildingNode) setSelection({ buildingId: null }) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index e562d403..39d5c0f0 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -54,7 +54,7 @@ import { ZoneNode as ZoneNodeSchema, type ZoneNode as ZoneNodeType, } from '@pascal-app/core' -import { useAlignmentGuides, useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor' +import { useSegmentDraftChain, useWallSnapIndicator } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' import { Command, Ruler } from 'lucide-react' import { @@ -91,6 +91,7 @@ import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import { cn } from '../../lib/utils' import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap' +import useAlignmentGuides from '../../store/use-alignment-guides' import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor' import useEditor, { isAngleSnapActive, @@ -5105,13 +5106,13 @@ export function FloorplanPanel({ const hasAmbientBuildingLevel = useScene((state) => { if (levelId || !ambientBuildingId) return false const building = state.nodes[ambientBuildingId] - if (!building || building.type !== 'building') return false + if (building?.type !== 'building') return false return building.children.some((cid) => state.nodes[cid]?.type === 'level') }) const elevators = useScene( useShallow((state) => { const building = currentBuildingId ? state.nodes[currentBuildingId] : null - if (!building || building.type !== 'building') { + if (building?.type !== 'building') { return [] as ElevatorNode[] } @@ -5889,6 +5890,7 @@ export function FloorplanPanel({ const isOpeningMoveActive = movingOpeningType !== null const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive const isFenceBuildActive = phase === 'structure' && mode === 'build' && tool === 'fence' + const fenceContinuation = useEditor((state) => state.continuationByContext.fence) const isRoofBuildActive = phase === 'structure' && mode === 'build' && tool === 'roof' const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair' const isStairMoveActive = movingNode?.type === 'stair' @@ -5945,18 +5947,16 @@ export function FloorplanPanel({ isRegistryToolBuildActive const floorplanOpeningLocalY = useMemo(() => { if (movingNode?.type === 'door' || movingNode?.type === 'window') { - return shiftPressed ? movingNode.position[1] : snapToHalf(movingNode.position[1]) + return snapToHalf(movingNode.position[1]) } if (isWindowBuildActive) { // Floorplan is top-down, so new windows need an explicit wall-local height. - return shiftPressed - ? FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y - : snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y) + return snapToHalf(FLOORPLAN_DEFAULT_WINDOW_LOCAL_Y) } return 0 - }, [isWindowBuildActive, movingNode, shiftPressed]) + }, [isWindowBuildActive, movingNode]) // Float the faithful door/window symbol at the cursor while it isn't over a // wall (the off-wall placement ghost), by publishing a transient opening on a // synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer` @@ -6488,7 +6488,7 @@ export function FloorplanPanel({ // hidden; a no-op while closed (the sync early-returns). syncFloorplanViewportToNavigationPose(pose) } - }, [isFloorplanOpen, syncFloorplanViewportToNavigationPose]) + }, [syncFloorplanViewportToNavigationPose]) useEffect(() => { return useEditor.subscribe((state) => { @@ -7154,7 +7154,7 @@ export function FloorplanPanel({ setCursorPoint(planPoint) return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial }, - [], + [setCursorPoint], ) const handleElevatorResizePointerDown = useCallback( @@ -7227,7 +7227,13 @@ export function FloorplanPanel({ setElevatorResizeDragState(null) setCursorPoint(null) }, - [elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize, updateNode], + [ + elevatorResizeDragState, + getPlanPointFromClientPoint, + previewElevatorResize, + updateNode, + setCursorPoint, + ], ) useEffect(() => { @@ -7504,15 +7510,15 @@ export function FloorplanPanel({ setWallChainFirstVertex(null) setDraftEnd(null) useSegmentDraftChain.getState().clear('wall') - }, []) + }, [setDraftEnd]) const clearFencePlacementDraft = useCallback(() => { setFenceDraftStart(null) setFenceDraftEnd(null) - }, []) + }, [setFenceDraftEnd]) const clearRoofPlacementDraft = useCallback(() => { setRoofDraftStart(null) setRoofDraftEnd(null) - }, []) + }, [setRoofDraftEnd]) const clearCeilingPlacementDraft = useCallback(() => { setCeilingDraftPoints([]) }, []) @@ -7585,6 +7591,7 @@ export function FloorplanPanel({ clearWallEndpointDrag, clearWallPlacementDraft, clearZonePlacementDraft, + setCursorPoint, ]) useEffect(() => { @@ -8020,14 +8027,13 @@ export function FloorplanPanel({ return } - // Wall endpoint move: grid snap only. Shift bypasses all snap. - const bypassSnap = shiftPressed || event.shiftKey + // Wall endpoint move: snapping follows the active mode; there is no + // held-key bypass. const snapResult = snapWallDraftPointDetailed({ point: planPoint, walls, ignoreWallIds: [dragState.wallId], - bypassSnap, - magnetic: !bypassSnap && isMagneticSnapActive(), + magnetic: isMagneticSnapActive(), }) const snappedPoint = snapResult.point // Magnetic beacon at the endpoint when it locked onto existing geometry. @@ -8068,7 +8074,6 @@ export function FloorplanPanel({ ) if ( - !bypassSnap && !( previousDraft && pointsEqual(previousDraft.start, nextDraft.start) && @@ -8097,18 +8102,12 @@ export function FloorplanPanel({ } const chord = getWallChordFrame(wall) - const bypassSnap = shiftPressed || event.shiftKey - const snappedPoint: WallPlanPoint = bypassSnap - ? planPoint - : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] const rawCurveOffset = -( (snappedPoint[0] - chord.midpoint.x) * chord.normal.x + (snappedPoint[1] - chord.midpoint.y) * chord.normal.y ) - const nextCurveOffset = normalizeWallCurveOffset( - wall, - bypassSnap ? rawCurveOffset : snapToHalf(rawCurveOffset), - ) + const nextCurveOffset = normalizeWallCurveOffset(wall, snapToHalf(rawCurveOffset)) if (curveDragState.currentCurveOffset === nextCurveOffset) { return @@ -8117,9 +8116,7 @@ export function FloorplanPanel({ curveDragState.currentCurveOffset = nextCurveOffset setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset }) setCursorPoint(snappedPoint) - if (!bypassSnap) { - sfxEmitter.emit('sfx:grid-snap') - } + sfxEmitter.emit('sfx:grid-snap') } const commitGuideInteraction = (event: PointerEvent) => { @@ -8314,6 +8311,7 @@ export function FloorplanPanel({ updateNode, wallById, walls, + setCursorPoint, ]) useEffect(() => { @@ -8348,10 +8346,7 @@ export function FloorplanPanel({ return } - const bypassSnap = shiftPressed || event.shiftKey - const snappedPoint: WallPlanPoint = bypassSnap - ? planPoint - : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] + const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])] setCursorPoint(snappedPoint) const currentDraft = siteBoundaryDraftRef.current @@ -8364,9 +8359,7 @@ export function FloorplanPanel({ return } - if (!bypassSnap) { - sfxEmitter.emit('sfx:grid-snap') - } + sfxEmitter.emit('sfx:grid-snap') const nextPolygon = [...currentDraft.polygon] nextPolygon[dragState.vertexIndex] = snappedPoint @@ -8441,11 +8434,11 @@ export function FloorplanPanel({ exitSiteEditingToSelect, getPlanPointFromClientPoint, setSiteBoundaryLivePreview, - shiftPressed, site, siteBoundaryWorldPolygon, siteVertexDragState, updateNode, + setCursorPoint, ]) useEffect(() => { @@ -8504,7 +8497,14 @@ export function FloorplanPanel({ event.currentTarget.setPointerCapture(event.pointerId) }, - [fittedViewport, floorplanSceneRotationDeg, floorplanUserRotationDeg, viewport], + [ + fittedViewport, + floorplanSceneRotationDeg, + floorplanUserRotationDeg, + viewport, + setFloorplanCursorPosition, + setCursorPoint, + ], ) const handlePointerDown = useCallback( @@ -8570,7 +8570,7 @@ export function FloorplanPanel({ } const wallNode = useScene.getState().nodes[wallId as AnyNodeId] - if (!wallNode || wallNode.type !== 'wall') { + if (wallNode?.type !== 'wall') { return } @@ -8643,7 +8643,7 @@ export function FloorplanPanel({ const emitFloorplanCeilingLeave = useCallback((ceilingId: string | null) => { if (!ceilingId) return const ceilingNode = useScene.getState().nodes[ceilingId as AnyNodeId] - if (!ceilingNode || ceilingNode.type !== 'ceiling') return + if (ceilingNode?.type !== 'ceiling') return emitter.emit('ceiling:leave', { node: ceilingNode, @@ -8834,8 +8834,7 @@ export function FloorplanPanel({ // chip on the right): `grid` quantizes via `snapToHalf` (whose step is // 0 — i.e. off — in any non-grid mode), `angles` locks to 15° rays from // the previous vertex, `lines` pulls onto wall corners / alignment - // guides, `off` is free. No Shift hold-to-bypass; Alt forces (skips - // alignment). + // guides, `off` is free. Alignment follows the magnetic snap mode. const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive() const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, @@ -8846,7 +8845,6 @@ export function FloorplanPanel({ rawPoint: planPoint, fallbackPoint, levelId, - altKey: event.altKey, align: !angleSnap, }).point @@ -8861,9 +8859,9 @@ export function FloorplanPanel({ // Roof is placed as a footprint (no directional draw → polygon context: // grid / lines / off, no angle lock). Mode-driven, matching the chip: // `grid` quantizes via `getSnappedFloorplanPoint` (step 0 in non-grid - // modes), `lines` pulls onto alignment, `off` is free. Alt forces. + // modes), `lines` pulls onto alignment, `off` is free. const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { - bypass: event.altKey || !isMagneticSnapActive(), + bypass: !isMagneticSnapActive(), }) emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => @@ -8930,7 +8928,7 @@ export function FloorplanPanel({ // Mode-driven (matches the chip): `grid` quantizes (`snapToHalf`'s step // is 0 in non-grid modes), `angles` locks 15° rays from the previous // vertex, `lines` snaps onto wall corners / alignment guides, `off` is - // free. No Shift bypass; Alt forces (skips alignment). + // free. Alignment follows the magnetic snap mode. const angleSnap = activePolygonDraftPoints.length > 0 && isAngleSnapActive() const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, @@ -8943,14 +8941,13 @@ export function FloorplanPanel({ rawPoint: planPoint, fallbackPoint, levelId, - altKey: event.altKey, align: !angleSnap, }).point } else if (angleSnap) { useAlignmentGuides.getState().clear() } else { snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { - bypass: event.altKey || !isMagneticSnapActive(), + bypass: !isMagneticSnapActive(), }) } @@ -9025,9 +9022,8 @@ export function FloorplanPanel({ // builder needs a wall for `ctx.parent`, so we publish the opening on // a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to // `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it - // through the real `def.floorplan` builder. Shift bypasses grid snap. - const snappedPoint = - shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) + // through the real `def.floorplan` builder. + const snappedPoint = getSnappedFloorplanPoint(planPoint) showOpeningGhost(snappedPoint) } return @@ -9039,8 +9035,7 @@ export function FloorplanPanel({ // routing through `grid:move`, which would otherwise be processed // by the floor strategy and drop the item to floor height. if (isCeilingItemPlacementActive) { - const bypassSnap = shiftPressed || event.shiftKey - const snappedPoint = bypassSnap ? planPoint : getSnappedFloorplanPoint(planPoint) + const snappedPoint = getSnappedFloorplanPoint(planPoint) setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, ) @@ -9060,7 +9055,7 @@ export function FloorplanPanel({ // this exclusion the catch-all would emit `grid:move` and re-drive the // 3D MoveDoorTool's free-follow, fighting the overlay again. if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) { - const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) + const snappedPoint = getSnappedFloorplanPoint(planPoint) emitFloorplanGridEvent('move', snappedPoint, event) setCursorPoint((previousPoint) => previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, @@ -9172,12 +9167,15 @@ export function FloorplanPanel({ roofDraftStart, elevatorResizeDragState, siteVertexDragState, - shiftPressed, surfaceSize.height, surfaceSize.width, viewBox.height, viewBox.width, walls, + setCursorPoint, + setDraftEnd, + setRoofDraftEnd, + setFenceDraftEnd, ], ) @@ -9206,7 +9204,7 @@ export function FloorplanPanel({ setSlabDraftPoints((currentPoints) => [...currentPoints, point]) setCursorPoint(point) }, - [clearDraft, createSlabOnCurrentLevel, slabDraftPoints], + [clearDraft, createSlabOnCurrentLevel, slabDraftPoints, setCursorPoint], ) const handleSlabPlacementConfirm = useCallback( (point?: WallPlanPoint) => { @@ -9256,7 +9254,7 @@ export function FloorplanPanel({ setCeilingDraftPoints((currentPoints) => [...currentPoints, point]) setCursorPoint(point) }, - [ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel], + [ceilingDraftPoints, clearCeilingPlacementDraft, createCeilingOnCurrentLevel, setCursorPoint], ) const handleCeilingPlacementConfirm = useCallback( (point?: WallPlanPoint) => { @@ -9306,7 +9304,7 @@ export function FloorplanPanel({ setZoneDraftPoints((currentPoints) => [...currentPoints, point]) setCursorPoint(point) }, - [clearDraft, createZoneOnCurrentLevel, zoneDraftPoints], + [clearDraft, createZoneOnCurrentLevel, zoneDraftPoints, setCursorPoint], ) const handleZonePlacementConfirm = useCallback( (point?: WallPlanPoint) => { @@ -9388,7 +9386,7 @@ export function FloorplanPanel({ setDraftEnd(nextStart) setCursorPoint(nextStart) }, - [clearWallPlacementDraft, draftStart, wallChainFirstVertex], + [clearWallPlacementDraft, draftStart, wallChainFirstVertex, setDraftEnd, setCursorPoint], ) const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({ ceilingPolygons: displayCeilingPolygons, @@ -9604,6 +9602,7 @@ export function FloorplanPanel({ unit, visibleZonePolygons, emitFloorplanGridEvent, + setCursorPoint, ], ) const handleSvgClick = useCallback( @@ -9642,7 +9641,6 @@ export function FloorplanPanel({ rawPoint: planPoint, fallbackPoint, levelId, - altKey: event.altKey, align: !angleSnap, }).point emitFloorplanGridEvent('double-click', snappedPoint, event) @@ -9657,7 +9655,6 @@ export function FloorplanPanel({ rawPoint: planPoint, fallbackPoint, levelId, - altKey: event.altKey, align: !angleSnap, }).point // Slab is registry-driven: forward the double-click so the 3D tool @@ -10121,7 +10118,7 @@ export function FloorplanPanel({ }) setCursorPoint(toWallPlanPoint(vertexPoint)) }, - [displaySitePolygon, setSiteBoundaryLivePreview], + [displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint], ) const handleSiteVertexDoubleClick = useCallback( ( @@ -10205,7 +10202,7 @@ export function FloorplanPanel({ }) setCursorPoint(insertedPoint) }, - [displaySitePolygon, setSiteBoundaryLivePreview], + [displaySitePolygon, setSiteBoundaryLivePreview, setCursorPoint], ) const handlePointerLeave = useCallback(() => { @@ -10224,7 +10221,7 @@ export function FloorplanPanel({ emitFloorplanWallLeave(hoveredWallIdRef.current) hoveredWallIdRef.current = null } - }, [emitFloorplanWallLeave, siteVertexDragState]) + }, [emitFloorplanWallLeave, siteVertexDragState, setCursorPoint]) // Lightweight flag that mirrors the conditions under which // FloorplanCursorIndicatorOverlay renders — used to gate cursor-position @@ -10274,6 +10271,7 @@ export function FloorplanPanel({ isSpacePanPressed, elevatorResizeDragState, siteVertexDragState, + setFloorplanCursorPosition, ], ) @@ -10281,7 +10279,7 @@ export function FloorplanPanel({ setFloorplanCursorPosition(null) setHoveredGuideCorner(null) handlePointerLeave() - }, [handlePointerLeave]) + }, [handlePointerLeave, setFloorplanCursorPosition]) const handleMarqueePointerDown = useCallback( (event: ReactPointerEvent) => { @@ -10317,7 +10315,12 @@ export function FloorplanPanel({ event.currentTarget.setPointerCapture(event.pointerId) }, - [getPlanPointFromClientPoint, syncPreviewSelectedIds], + [ + getPlanPointFromClientPoint, + syncPreviewSelectedIds, + setFloorplanCursorPosition, + setCursorPoint, + ], ) const handleMarqueePointerMove = useCallback( @@ -10370,7 +10373,13 @@ export function FloorplanPanel({ // marquee overlay leaf, never this panel. useFloorplanMarquee.getState().setCurrent(snappedPoint) }, - [getFloorplanSelectionIdsInBounds, getPlanPointFromClientPoint, syncPreviewSelectedIds], + [ + getFloorplanSelectionIdsInBounds, + getPlanPointFromClientPoint, + syncPreviewSelectedIds, + setFloorplanCursorPosition, + setCursorPoint, + ], ) const handleMarqueePointerUp = useCallback( @@ -10441,7 +10450,7 @@ export function FloorplanPanel({ syncPreviewSelectedIds([]) setCursorPoint(null) }, - [syncPreviewSelectedIds], + [syncPreviewSelectedIds, setFloorplanCursorPosition, setCursorPoint], ) useEffect(() => { @@ -10456,7 +10465,13 @@ export function FloorplanPanel({ } setFloorplanCursorPosition(null) - }, [isMarqueeSelectionToolActive, mode, syncPreviewSelectedIds]) + }, [ + isMarqueeSelectionToolActive, + mode, + syncPreviewSelectedIds, + setFloorplanCursorPosition, + setCursorPoint, + ]) useEffect(() => { if (mode !== 'delete') { @@ -10554,7 +10569,7 @@ export function FloorplanPanel({ } const buildingNode = sceneNodes[nextBuildingId] - if (!buildingNode || buildingNode.type !== 'building') { + if (buildingNode?.type !== 'building') { return null } @@ -10793,7 +10808,7 @@ export function FloorplanPanel({ )} - {(!levelNode || levelNode.type !== 'level') && !hasAmbientBuildingLevel ? ( + {levelNode?.type !== 'level' && !hasAmbientBuildingLevel ? (
Switch to a building level to view and edit the floorplan.
diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index 43cb3607..08b98277 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -99,6 +99,8 @@ export type HandleArrowProps = { onPointerLeave?: PointerHandler // Extrude the slimmer wall-handle chevron profile (chevron shape only). thin?: boolean + // Render the corner-picker disc as a smooth circle instead of a hexagon. + round?: boolean } function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape { @@ -336,7 +338,11 @@ export function createEndpointHitAreaGeometry(radius: number) { return geometry } -function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) { +// Hexagon (6 segments) by default; a smooth circle (32 segments) when `round`. +const CORNER_DISC_SEGMENTS = 6 +const CORNER_DISC_ROUND_SEGMENTS = 32 + +function createHandleArrowGeometry(shape: HandleArrowShape, thin = false, round = false) { if (shape === 'chevron') return createArrowHandleGeometry(thin) if (shape === 'cross') return createMoveCrossHandleGeometry() if (shape === 'curved-arrow') return createRotateArrowHandleGeometry() @@ -345,17 +351,23 @@ function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) { geometry.computeBoundingSphere() return geometry } - const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6) + const geometry = new CircleGeometry( + CORNER_HEX_RADIUS, + round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS, + ) geometry.computeBoundingSphere() return geometry } -function createHandleArrowHitGeometry(shape: HandleArrowShape) { +function createHandleArrowHitGeometry(shape: HandleArrowShape, round = false) { if (shape === 'chevron') return createArrowHitAreaGeometry() if (shape === 'cross') return createMoveCrossHitAreaGeometry() if (shape === 'curved-arrow') return createRotateArrowHitAreaGeometry() if (shape === 'tracker') return createTrackerHitAreaGeometry() - const geometry = new CircleGeometry(CORNER_HEX_RADIUS, 6) + const geometry = new CircleGeometry( + CORNER_HEX_RADIUS, + round ? CORNER_DISC_ROUND_SEGMENTS : CORNER_DISC_SEGMENTS, + ) geometry.computeBoundingSphere() return geometry } @@ -482,10 +494,17 @@ export function HandleArrow({ onPointerEnter, onPointerLeave, thin = false, + round = false, }: HandleArrowProps) { const visualShape = normalizeHandleArrowShape(shape, cursor) - const geometry = useMemo(() => createHandleArrowGeometry(visualShape, thin), [visualShape, thin]) - const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape]) + const geometry = useMemo( + () => createHandleArrowGeometry(visualShape, thin, round), + [visualShape, thin, round], + ) + const hitGeometry = useMemo( + () => createHandleArrowHitGeometry(visualShape, round), + [visualShape, round], + ) const indicatorMaterial = useHandleArrowMaterial(visualShape) const hitMaterial = useInvisibleHitAreaMaterial() const rootRef = useRef(null) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 508094e3..55fb152b 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -58,6 +58,7 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar' import { CustomCameraControls } from './custom-camera-controls' import { EditorLayoutV2 } from './editor-layout-v2' import { ExportManager } from './export-manager' +import { FenceTangentLines3D } from './fence-tangent-lines-3d' import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls' import { FloatingActionMenu } from './floating-action-menu' import { FloatingBuildingActionMenu } from './floating-building-action-menu' @@ -725,6 +726,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!noEditing && } {!noEditing && } {!noEditing && } + {!noEditing && } {!noEditing && } {!noEditing && } {!isFirstPersonMode && } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index f4d5cbe8..c5cc5466 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -1295,6 +1295,7 @@ function TapActionArrow({ const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0 const shape = descriptor.shape ?? 'arrow' const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize') + const round = descriptor.round ?? false const onActivate = useHandleDrag({ kind: 'tap', @@ -1315,6 +1316,7 @@ function TapActionArrow({ onHoverChange={setIsHovered} onPointerDown={onActivate} position={position} + round={round} /> ) } @@ -1420,6 +1422,7 @@ function CornerPickerShape({ hover, onHoverChange, onPointerDown, + round = false, }: { position: readonly [number, number, number] height: number @@ -1428,6 +1431,7 @@ function CornerPickerShape({ hover: boolean onHoverChange: (hovered: boolean) => void onPointerDown: (event: ThreeEvent) => void + round?: boolean }) { const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height]) useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry]) @@ -1505,10 +1509,11 @@ function CornerPickerShape({ onHoverChange={onHoverChange} onPointerDown={onPointerDown} placement={{ position: [0, 0, 0], baseScale }} + round={round} shape="corner-picker" /> - + diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 728c214e..d2320480 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -57,11 +57,13 @@ import { resolvePaintScopeTargets, slotDisplayLabel, } from '../../lib/paint-scope' +import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy' import { resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, type SelectionModifierKeys, selectionModifiersFromEvent, + shouldPreserveSelectedRoofHostTarget, } from '../../lib/selection-routing' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' @@ -300,18 +302,21 @@ function resolveRoofSegmentSelectionTarget(event: NodeEvent): RoofSegmentNode | return bestSegment?.node ?? firstSegment } -function isInActiveRoofContext( - segment: RoofSegmentNode, - selectedIds: readonly string[], - nodes: Record, -): boolean { - if (!segment.parentId) return false - if (selectedIds.includes(segment.id) || selectedIds.includes(segment.parentId)) return true +function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode { + if (event.node.type === 'roof') { + if ( + shouldPreserveSelectedRoofHostTarget({ + node: event.node, + selectedIds: useViewer.getState().selection.selectedIds, + armedRoofId: useEditor.getState().roofHostDragArmedId, + }) + ) { + return event.node + } + return resolveRoofSegmentSelectionTarget(event) ?? event.node + } - return selectedIds.some((selectedId) => { - const selectedNode = nodes[selectedId] - return selectedNode?.type === 'roof-segment' && selectedNode.parentId === segment.parentId - }) + return event.node } function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup { @@ -902,7 +907,7 @@ export const SelectionManager = () => { : node.parentId ? useScene.getState().nodes[node.parentId as AnyNodeId] : null - if (!roofNode || roofNode.type !== 'roof') return null + if (roofNode?.type !== 'roof') return null const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent) const compatible = role !== null && paintEnabled @@ -1428,7 +1433,7 @@ export const SelectionManager = () => { const activeScope = useInteractionScope.getState().scope if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return - const node = event.node + const node = resolveSelectModeNodeTarget(event) // A ceiling is selectable only through its corner handles, never via // the `ceiling-grid` body mesh. When the grid is revealed (ceiling @@ -1481,18 +1486,6 @@ export const SelectionManager = () => { }, 50) let nodeToSelect = node - if (node.type === 'roof-segment' && node.parentId) { - const nodes = useScene.getState().nodes - const parentNode = nodes[node.parentId as AnyNodeId] - const selectedIds = useViewer.getState().selection.selectedIds - if ( - parentNode && - parentNode.type === 'roof' && - !isInActiveRoofContext(node, selectedIds, nodes) - ) { - nodeToSelect = parentNode - } - } if (node.type === 'stair-segment' && node.parentId) { const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] if (parentNode && parentNode.type === 'stair') { @@ -1670,7 +1663,7 @@ export const SelectionManager = () => { // surface move tools keep tracking — but the select-hover outline must // stay put, so don't repaint under the cursor mid-drag. if (useViewer.getState().inputDragging) return - const node = event.node + const node = resolveSelectModeNodeTarget(event) const currentPhase = useEditor.getState().phase // Ignore site/building if we are already inside a building @@ -1698,17 +1691,14 @@ export const SelectionManager = () => { const onLeave = (event: NodeEvent) => { if (useViewer.getState().inputDragging) return - const nodeId = event?.node?.id + const nodeId = resolveSelectModeNodeTarget(event)?.id if (nodeId && useViewer.getState().hoveredId === nodeId) { useViewer.setState({ hoveredId: null }) } } const onDoubleClick = (event: NodeEvent) => { - let node = event.node - if (node.type === 'roof') { - node = resolveRoofSegmentSelectionTarget(event) ?? node - } + let node = resolveSelectModeNodeTarget(event) const currentPhase = useEditor.getState().phase @@ -1893,6 +1883,8 @@ export const SelectionManager = () => { const SelectionStateSync = () => { const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget) + const roofHostDragArmedId = useEditor((s) => s.roofHostDragArmedId) + const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId) const singleSelectedId = useViewer((s) => s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null, ) @@ -1925,6 +1917,12 @@ const SelectionStateSync = () => { }) }, []) + useEffect(() => { + if (!roofHostDragArmedId) return + if (singleSelectedId === roofHostDragArmedId) return + setRoofHostDragArmedId(null) + }, [roofHostDragArmedId, setRoofHostDragArmedId, singleSelectedId]) + useEffect(() => { if (!selectedMaterialTarget) return @@ -2152,7 +2150,11 @@ const EditorOutlinerSync = () => { if (!nodes[hoveredId as AnyNodeId]) { useViewer.setState({ hoveredId: null }) } else { - const obj = sceneRegistry.nodes.get(hoveredId) + const hoveredNode = nodes[hoveredId as AnyNodeId] + const obj = + hoveredNode?.type === 'roof-segment' + ? (getHoveredRoofSegmentOutlineProxy(hoveredId) ?? sceneRegistry.nodes.get(hoveredId)) + : sceneRegistry.nodes.get(hoveredId) if (obj?.parent) outliner.hoveredObjects.push(obj) } } diff --git a/packages/editor/src/components/editor/use-floorplan-scene-data.ts b/packages/editor/src/components/editor/use-floorplan-scene-data.ts index 6f8bd7e1..912f6c42 100644 --- a/packages/editor/src/components/editor/use-floorplan-scene-data.ts +++ b/packages/editor/src/components/editor/use-floorplan-scene-data.ts @@ -36,7 +36,7 @@ function useLevelChildren( } const levelNode = state.nodes[levelId] - if (!levelNode || levelNode.type !== 'level') { + if (levelNode?.type !== 'level') { return [] as TNode[] } @@ -105,7 +105,7 @@ export function useFloorplanSceneData({ } const buildingNode = state.nodes[currentBuildingId] - if (!buildingNode || buildingNode.type !== 'building') { + if (buildingNode?.type !== 'building') { return [] as LevelNode[] } @@ -133,7 +133,7 @@ export function useFloorplanSceneData({ } const nextLevelNode = state.nodes[levelId] - if (!nextLevelNode || nextLevelNode.type !== 'level') { + if (nextLevelNode?.type !== 'level') { return [] as RoofNode[] } @@ -149,7 +149,7 @@ export function useFloorplanSceneData({ } const nextLevelNode = state.nodes[levelId] - if (!nextLevelNode || nextLevelNode.type !== 'level') { + if (nextLevelNode?.type !== 'level') { return [] as OpeningNode[] } @@ -171,7 +171,7 @@ export function useFloorplanSceneData({ } const nextLevelNode = state.nodes[levelId] - if (!nextLevelNode || nextLevelNode.type !== 'level') { + if (nextLevelNode?.type !== 'level') { return [] as AnyNode[] } diff --git a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx index 9d3c2ed0..e3fa0855 100644 --- a/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx +++ b/packages/editor/src/components/editor/wall-snap-beacon-layer.tsx @@ -33,8 +33,8 @@ import { EDITOR_LAYER } from '../../lib/constants' * intersection → ✕ cross wall body (edge) → circle * * Subscribes to the shared `useWallSnapIndicator` store (published by the wall - * draft + endpoint-move tools). Shares the indigo accent of - * `Alignment3DGuideLayer` for visual consistency. + * draft + endpoint-move tools). The vertical mouse pillar and the corner + * (endpoint) square are green; the other floor glyphs are indigo. * * The point carries only XZ (building-local plan coords); like the alignment * guides it's lifted to the active level's building-local Y each frame so it @@ -43,6 +43,7 @@ import { EDITOR_LAYER } from '../../lib/constants' */ const BEACON_COLOR = 0x81_8c_f8 // indigo-400 — matches the alignment guide accent +const MARKER_GREEN = 0x22_c5_5e // green-500 — vertical mouse marker + corner (endpoint) glyph const BEACON_HEIGHT = 2.5 // world-meter height of the pillar const BEACON_RADIUS = 0.018 // world-meter radius of the pillar const MARKER = 0.13 // world-meter base size of the floor glyph @@ -66,6 +67,14 @@ const beaconMaterial = new MeshBasicNodeMaterial({ transparent: true, opacity: 0.9, }) +const greenMarkerMaterial = new MeshBasicNodeMaterial({ + color: MARKER_GREEN, + depthTest: false, + depthWrite: false, + toneMapped: false, + transparent: true, + opacity: 0.9, +}) const wallTopHighlightMaterial = new MeshBasicNodeMaterial({ color: BEACON_COLOR, depthTest: false, @@ -121,7 +130,7 @@ export const WallSnapBeaconLayer = memo(function WallSnapBeaconLayer() { @@ -235,7 +244,7 @@ function SnapMarker({ kind, x, z }: { kind: WallSnapKind; x: number; z: number } +type DiagonalTrimAxisKey = DiagonalTrimAxisSide + +const TRIM_PLANE_COLOR = '#93c5fd' +const TRIM_PLANE_OPACITY = 0.18 +const TRIM_PLANE_HOVER_OPACITY = 0.32 +const TRIM_RAIL_COLOR = '#2563eb' +const TRIM_RAIL_HOVER_COLOR = '#4f46e5' +const TRIM_CAP_COLOR = TRIM_RAIL_COLOR +const TRIM_CAP_HOVER_COLOR = TRIM_RAIL_HOVER_COLOR +const TRIM_ADD_COLOR = TRIM_RAIL_COLOR +const TRIM_ADD_HOVER_COLOR = TRIM_RAIL_HOVER_COLOR +const TRIM_PLANE_RENDER_ORDER = 1001 +const TRIM_RAIL_RENDER_ORDER = 1003 +const TRIM_HANDLE_BASE_SCALE = 0.65 +const TRIM_RAIL_SURFACE_OFFSET = 0 +const TRIM_RAIL_HIT_HEIGHT = 0.18 +const TRIM_RAIL_HIT_DEPTH = 0.16 +const TRIM_CAP_HIT_SIZE = 0.22 +const TRIM_LIVE_REBUILD_INTERVAL_MS = 80 + +const TRIM_UNIT_PLANE_GEOMETRY = new THREE.PlaneGeometry(1, 1) +const TRIM_UNIT_RAIL_GEOMETRY = new THREE.BoxGeometry(1, 1, 1) +const TRIM_UNIT_RAIL_CAP_GEOMETRY = new THREE.SphereGeometry(0.5, 16, 8) +const TRIM_UNIT_ADD_GEOMETRY = new THREE.OctahedronGeometry(0.5, 0) + +const trimPlaneMaterial = new MeshBasicNodeMaterial({ + color: TRIM_PLANE_COLOR, + depthTest: false, + depthWrite: false, + opacity: TRIM_PLANE_OPACITY, + side: THREE.DoubleSide, + transparent: true, +}) +const trimPlaneHoverMaterial = new MeshBasicNodeMaterial({ + color: TRIM_PLANE_COLOR, + depthTest: false, + depthWrite: false, + opacity: TRIM_PLANE_HOVER_OPACITY, + side: THREE.DoubleSide, + transparent: true, +}) +const trimRailMaterial = new MeshBasicNodeMaterial({ + color: TRIM_RAIL_COLOR, + depthTest: false, + depthWrite: false, +}) +const trimRailHoverMaterial = new MeshBasicNodeMaterial({ + color: TRIM_RAIL_HOVER_COLOR, + depthTest: false, + depthWrite: false, +}) +const trimCapMaterial = new MeshBasicNodeMaterial({ + color: TRIM_CAP_COLOR, + depthTest: false, + depthWrite: false, + opacity: 1, + transparent: false, +}) +const trimCapHoverMaterial = new MeshBasicNodeMaterial({ + color: TRIM_CAP_HOVER_COLOR, + depthTest: false, + depthWrite: false, + opacity: 1, + transparent: false, +}) +const trimAddMaterial = new MeshBasicNodeMaterial({ + color: TRIM_ADD_COLOR, + depthTest: false, + depthWrite: false, +}) +const trimAddHoverMaterial = new MeshBasicNodeMaterial({ + color: TRIM_ADD_HOVER_COLOR, + depthTest: false, + depthWrite: false, +}) +const trimDiagonalPreviewRailMaterial = new MeshBasicNodeMaterial({ + color: TRIM_RAIL_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.42, + transparent: true, +}) + +// ─── Section-cut (cutaway) feedback ────────────────────────────────── +// The cross-section the trim removes: a thin slab at each cut line is +// intersected with the untrimmed roof shell AND every hosted accessory +// (chimney, vents, skylight, dormer, …), so the fill shows real material only +// (wall + deck bands + any accessory the cut passes through) and leaves the +// hollow attic empty. The outline is the edge silhouette of that fill. A +// translucent red fill + darker red outline read together as a SketchUp-style +// section cut of what the trim removes. +// Matches the app's destructive red (`--destructive`, oklch(0.577 0.245 27.325) +// ≈ #dc2626 / red-600 — the delete/destructive button color). Three's +// MeshBasicNodeMaterial color doesn't parse oklch() strings, so use the sRGB hex +// equivalent; the outline is a darker shade of the same hue. +const SECTION_FILL_COLOR = '#dc2626' +const SECTION_OUTLINE_COLOR = '#991b1b' +const SECTION_FILL_RENDER_ORDER = 1000 +const SECTION_OUTLINE_RENDER_ORDER = 1002 +// Build the cut line just inside the kept material so the section plane never +// sits coplanar with the mesh's own cut face. +const SECTION_PLANE_INSET = 0.004 + +// A vertical cut plane defined by a horizontal line through the XZ ground +// plane. `origin` is a point on the line, `dir` is the unit in-plane +// horizontal direction (in XZ), and `normal` is the unit XZ normal. Vertices +// are projected to (u = dir·xz, v = world Y) for slicing and lifted back via +// `origin + dir`. This handles both the axis-aligned sides (left/right cut on +// x, front/back on z) and the angled diagonal/corner cuts. +type SectionPlaneSpec = { + origin: THREE.Vector2 // point on the cut line, (x, z) + dir: THREE.Vector2 // unit in-plane horizontal direction, (x, z) + normal: THREE.Vector2 // unit normal in the XZ plane, (x, z) + // The cut plane is infinite, but the visible cut only spans the footprint + // edge between the two endpoints. We clip slice segments to this u-range + // (u = projection along `dir`) so the silhouette doesn't sprout lines where + // the infinite plane grazes the rest of the roof. + uMin: number + uMax: number + // How far the slab extends past each end of the cut line. A FREE (untrimmed) + // end has eave/overhang material beyond the footprint edge, so we extend to + // capture it; a TRIMMED end has none, so we clamp to 0 — otherwise the slab + // grabs phantom material from the untrimmed shell and the red section pokes + // out past the trim box. `dir` points A→B, so uMin is always endpoint A and + // uMax endpoint B; `extendMin` applies at A, `extendMax` at B. + extendMin: number + extendMax: number +} + +// Build a section plane from two XZ points on the cut line. `inset` shifts the +// plane along its normal toward the supplied "inside" point so the slice sits +// just inside kept material instead of coplanar with the mesh's own cut face. +// `extendA` / `extendB` set the slab overrun past endpoint A / B (0 at trimmed +// ends, a small overhang allowance at free ends). +function makeSectionPlane( + ax: number, + az: number, + bx: number, + bz: number, + inset = 0, + insidePoint?: readonly [number, number], + extendA = 0, + extendB = 0, +): SectionPlaneSpec { + const dir = new THREE.Vector2(bx - ax, bz - az) + if (dir.lengthSq() < 1e-12) dir.set(1, 0) + dir.normalize() + const normal = new THREE.Vector2(dir.y, -dir.x) + const origin = new THREE.Vector2(ax, az) + if (inset !== 0 && insidePoint) { + const toInsideX = insidePoint[0] - ax + const toInsideZ = insidePoint[1] - az + const sign = normal.x * toInsideX + normal.y * toInsideZ >= 0 ? 1 : -1 + origin.x += normal.x * inset * sign + origin.y += normal.y * inset * sign + } + // dir = (B-A).normalized, so dir·B - dir·A = |B-A| > 0 → uB is always uMax. + const uA = dir.x * ax + dir.y * az + const uB = dir.x * bx + dir.y * bz + return { origin, dir, normal, uMin: uA, uMax: uB, extendMin: extendA, extendMax: extendB } +} + +// Lift a 2D plane-frame point (u = projection along dir, v = world Y) back to +// segment-local 3D using the plane's origin and direction. +function liftSectionPoint(plane: SectionPlaneSpec, u: number, v: number): [number, number, number] { + const uOrigin = plane.dir.x * plane.origin.x + plane.dir.y * plane.origin.y + const t = u - uOrigin + return [plane.origin.x + plane.dir.x * t, v, plane.origin.y + plane.dir.y * t] +} + +const sectionFillMaterial = new MeshBasicNodeMaterial({ + color: SECTION_FILL_COLOR, + depthTest: false, + depthWrite: false, + opacity: 0.85, + side: THREE.DoubleSide, + transparent: true, +}) + +const sectionOutlineMaterial = new LineBasicNodeMaterial({ + color: SECTION_OUTLINE_COLOR, + depthTest: false, + depthWrite: false, + linewidth: 2, +}) + +const hoverOutlineProxyMaterial = new THREE.MeshBasicMaterial({ + colorWrite: false, + depthWrite: false, + side: THREE.DoubleSide, + transparent: true, + opacity: 0, +}) + +// Half-thickness of the slab brush intersected with the roof shell. The wafer +// must be thin enough to read as a flat cut face but thick enough that CSG +// produces a stable, non-degenerate solid. +const SECTION_SLAB_HALF_THICKNESS = 0.006 + +// Builds a thin oriented slab brush straddling one cut line, spanning the full +// segment height. Intersecting it with the roof shell yields exactly the +// material the cut passes through (roof slab + wall bands), leaving the hollow +// attic empty — a true section cut. +function buildSectionSlabBrush(plane: SectionPlaneSpec, vMin: number, vMax: number): Brush | null { + const span = plane.uMax - plane.uMin + const height = vMax - vMin + if (!(span > 1e-4 && height > 1e-4)) return null + + // Extend past each end only as far as that end allows: a free edge gets an + // overhang allowance so eave/shingle material is captured; a trimmed end gets + // 0 so the slab stops at the cut line and the section never pokes past the + // trim box. The box is centred on the extended span's midpoint. + const uLo = plane.uMin - plane.extendMin + const uHi = plane.uMax + plane.extendMax + const length = uHi - uLo + const geometry = new THREE.BoxGeometry(length, height, SECTION_SLAB_HALF_THICKNESS * 2) + const yaw = Math.atan2(-plane.dir.y, plane.dir.x) + geometry.rotateY(yaw) + const midU = (uLo + uHi) / 2 + const [cx, , cz] = liftSectionPoint(plane, midU, 0) + geometry.translate(cx, (vMin + vMax) / 2, cz) + + const brush = new Brush(geometry) + prepareBrushForCSG(brush) + return brush +} + +// Builds the combined fill + outline geometries (segment-local 3D) for all +// active trim planes. The fill is the CSG intersection of the untrimmed roof +// shell with a thin slab at each cut line (material only — attic stays hollow). +// The outline is the edge silhouette of that same fill (via EdgesGeometry), so +// it traces the real cut shape — wall/deck band boundaries and the hollow-attic +// edge — instead of just the top surface line. Both in segment-local space, to +// be mounted under the segment-world-matrix group. +function buildSectionGeometries( + segment: RoofSegmentNode, + planes: SectionPlaneSpec[], + accessoryGeometries: THREE.BufferGeometry[] = [], +): { fill: THREE.BufferGeometry; outline: THREE.BufferGeometry } | null { + if (planes.length === 0) return null + + // Untrimmed shell — the source we intersect slabs against. Dutch rake boards + // are decorative overhang geometry; slicing them makes the red preview sprout + // tall phantom triangles, so the section fill uses the stable roof shell. + const sectionSourceSegment: RoofSegmentNode = + segment.roofType === 'dutch' + ? { ...segment, trim: ZERO_TRIM, dutchGabletRake: 0 } + : { ...segment, trim: ZERO_TRIM } + const shellGeometry = generateRoofSegmentGeometry(sectionSourceSegment) + const shell = new Brush(shellGeometry) + prepareBrushForCSG(shell) + + const slopeFrame = getSegmentSlopeFrame(segment) + // Span the full material height — wall bands (base→eave) plus the deck/shingle + // wedge above — so the cut face fills completely. The earlier red bars that + // poked below the box were horizontal overshoot (the untrimmed slab dragging + // wall material past a perpendicular cut), now fixed by the per-end slab + // extension clamp; clipping the wall band off here only left gaps. + const vMin = -0.05 + const vMax = + segment.wallHeight + + slopeFrame.activeRh + + segment.deckThickness + + segment.shingleThickness + + 0.5 + + const fillPositions: number[] = [] + const outlinePositions: number[] = [] + // Section cut only needs material presence, not per-face materials — disable + // group bookkeeping on the shared evaluator for this pass so mismatched slab / + // shell material slots can't misalign group indices and crash. + const prevUseGroups = csgEvaluator.useGroups + const prevAttributes = csgEvaluator.attributes + csgEvaluator.useGroups = false + csgEvaluator.attributes = ['position'] + + // Intersect every active section slab with a source solid (the roof shell or + // an accessory mesh), appending the resulting cross-section to the shared + // fill + outline buffers. The slab sits just inside the kept material, so the + // intersection yields the material face the cut exposes. + const sliceSourceBySlabs = (source: Brush) => { + for (const plane of planes) { + const slab = buildSectionSlabBrush(plane, vMin, vMax) + if (!slab) continue + try { + const result = csgEvaluator.evaluate(source, slab, INTERSECTION) as Brush + const geo = result.geometry as THREE.BufferGeometry + const pos = geo.getAttribute('position') as THREE.BufferAttribute | undefined + if (pos && pos.count > 0) { + const index = geo.getIndex() + const count = index ? index.count : pos.count + for (let i = 0; i < count; i++) { + const vi = index ? index.getX(i) : i + fillPositions.push(pos.getX(vi), pos.getY(vi), pos.getZ(vi)) + } + // Trace the silhouette of the cut face itself. EdgesGeometry emits a + // boundary/crease line list; the thin wafer's flat cut caps give a + // crisp outline of the actual material shape. + const edges = new THREE.EdgesGeometry(geo, 1) + const ep = edges.getAttribute('position') as THREE.BufferAttribute | undefined + if (ep) { + for (let i = 0; i < ep.count; i++) { + outlinePositions.push(ep.getX(i), ep.getY(i), ep.getZ(i)) + } + } + edges.dispose() + } + geo.dispose() + } catch (e) { + console.error('Roof section-cut CSG failed:', e) + } finally { + slab.geometry.dispose() + } + } + } + + // Brushes built from accessory meshes; disposed after the slice pass. + const accessoryBrushes: Brush[] = [] + try { + sliceSourceBySlabs(shell) + // Accessories that fall in the trimmed region contribute their own + // cross-section to the same red fill (chimney, vents, skylight, dormer, …). + // Each geometry arrives already in segment-local space. + for (const accGeo of accessoryGeometries) { + try { + // Weld coincident verts so the brush is a valid indexed solid. Most + // accessories are clean THREE primitives, but some (e.g. the ridge + // vent) are hand-wound non-indexed triangle soup; three-bvh-csg's + // INTERSECTION classifies inside/outside off a welded, indexed mesh and + // silently yields nothing for raw soup — the same `mergeVertices` step + // the viewer's own accessory CSG runs before any boolean op. + const welded = mergeVertices(accGeo, 1e-4) + const accBrush = new Brush(welded) + prepareBrushForCSG(accBrush) + accessoryBrushes.push(accBrush) + sliceSourceBySlabs(accBrush) + } catch (e) { + console.error('Roof section-cut accessory CSG failed:', e) + } + } + } finally { + csgEvaluator.useGroups = prevUseGroups + csgEvaluator.attributes = prevAttributes + shellGeometry.dispose() + for (const b of accessoryBrushes) b.geometry.dispose() + } + + if (fillPositions.length === 0) return null + + const fill = new THREE.BufferGeometry() + fill.setAttribute('position', new THREE.Float32BufferAttribute(fillPositions, 3)) + const outline = new THREE.BufferGeometry() + outline.setAttribute('position', new THREE.Float32BufferAttribute(outlinePositions, 3)) + return { fill, outline } +} + +// Zeroed trim — the section fill intersects the FULL (untrimmed) roof shell at +// the cut line, so we regenerate the shell with no trim and slab-intersect it. +const ZERO_TRIM: RoofSegmentTrim = { + left: 0, + right: 0, + front: 0, + back: 0, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, +} + +type TrimVisibleBounds = ReturnType + +function getTrimVisibleTopBounds(segment: RoofSegmentNode): TrimVisibleBounds { + const bounds = getRoofSegmentVisibleTopBounds(segment) + if (segment.roofType !== 'dutch') return bounds + + const trim = normalizeRoofSegmentTrim(segment) + const metrics = getDutchRoofMetrics(segment) + const requestedRake = Math.max(0, segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake) + const rakeReach = Math.min( + requestedRake, + (metrics.axis === 'x' ? metrics.shoulderInsetAlongWidth : metrics.shoulderInsetAlongDepth) * + 0.98, + ) + if (!(rakeReach > 0.001)) return bounds + + const next = { ...bounds } + if (metrics.axis === 'x') { + if (!(trim.left > 0)) next.minX -= rakeReach + if (!(trim.right > 0)) next.maxX += rakeReach + } else { + if (!(trim.back > 0)) next.minZ -= rakeReach + if (!(trim.front > 0)) next.maxZ += rakeReach + } + + next.width = Math.max(0.01, next.maxX - next.minX) + next.depth = Math.max(0.01, next.maxZ - next.minZ) + return next +} + +// Shape fields that affect the segment's 3D volume. Trim is excluded — the cut +// lines arrive via `planes`, whose endpoints already encode the trim — so the +// memo recomputes when either the roof shape or any trim changes, and the shell +// regeneration only reruns when the actual roof shape changes. +function segmentShapeKey(segment: RoofSegmentNode): string { + return JSON.stringify([ + segment.roofType, + segment.width, + segment.depth, + segment.wallHeight, + segment.pitch, + segment.wallThickness, + segment.deckThickness, + segment.overhang, + segment.shingleThickness, + segment.gambrelLowerWidthRatio, + segment.gambrelLowerHeightRatio, + segment.mansardSteepWidthRatio, + segment.mansardSteepHeightRatio, + segment.dutchHipWidthRatio, + segment.dutchHipHeightRatio, + segment.dutchWaistLengthRatio, + segment.dutchGabletRake, + segment.dutchTopRakeThickness, + ]) +} + +// Collect every roof-accessory mesh hosted on `segment` as a segment-local +// geometry, so the section-cut pass can intersect each with the cut slabs and +// show its cross-section in the red fill. Registry-driven — an accessory is any +// child kind declaring the `roofAccessory` capability — so no kind is named +// here. The meshes come straight from `sceneRegistry` (already live: each +// renderer re-clips against the live trim), so the geometry reflects the +// in-flight drag. Returned geometries are fresh clones the caller owns + +// disposes. +const _segWorldInverse = new THREE.Matrix4() +const _accWorld = new THREE.Matrix4() +function collectAccessorySectionGeometries(segment: RoofSegmentNode): THREE.BufferGeometry[] { + const childIds = segment.children + if (!childIds || childIds.length === 0) return [] + + const segSource = sceneRegistry.nodes.get(segment.id) + if (!segSource) return [] + segSource.updateWorldMatrix(true, false) + _segWorldInverse.copy(segSource.matrixWorld).invert() + + const nodes = useScene.getState().nodes + const out: THREE.BufferGeometry[] = [] + for (const childId of childIds) { + const childNode = nodes[childId as AnyNodeId] + if (!childNode) continue + if (!nodeRegistry.get(childNode.type)?.capabilities?.roofAccessory) continue + const obj = sceneRegistry.nodes.get(childId) + if (!obj) continue + obj.updateWorldMatrix(true, true) + obj.traverse((child) => { + const mesh = child as THREE.Mesh + if (!mesh.isMesh || !mesh.geometry) return + const posAttr = mesh.geometry.getAttribute('position') as THREE.BufferAttribute | undefined + if (!posAttr || posAttr.count === 0) return + // mesh-world → segment-local: segmentWorld⁻¹ · meshWorld. + _accWorld.multiplyMatrices(_segWorldInverse, mesh.matrixWorld) + const geo = mesh.geometry.clone() + geo.applyMatrix4(_accWorld) + out.push(geo) + }) + } + return out +} + +// A change key over the hosted accessories' kinds + world transforms, so the +// section-cut memo recomputes when an accessory moves, is added/removed, or its +// host pose shifts (mirrors how `planes` keys the trim cut). Geometry-shape +// changes are caught by the renderer re-clipping (new mesh world matrix on +// resize is not guaranteed, but trim drag changes `planes` every tick, which +// already forces the recompute during the gesture we care about). +function accessorySectionKey(segment: RoofSegmentNode): string { + const childIds = segment.children + if (!childIds || childIds.length === 0) return 'none' + const nodes = useScene.getState().nodes + const parts: string[] = [] + for (const childId of childIds) { + const childNode = nodes[childId as AnyNodeId] + if (!childNode) continue + if (!nodeRegistry.get(childNode.type)?.capabilities?.roofAccessory) continue + const obj = sceneRegistry.nodes.get(childId) + if (!obj) continue + parts.push(`${childId}:${obj.matrixWorld.elements.map((n) => n.toFixed(3)).join(',')}`) + } + return parts.join('|') +} + +// Renders the cutaway section cut (material fill + cut-edge outline) for the +// active trim planes, in segment-local space (mounted under the +// segment-world-matrix group). The fill is the CSG intersection of the +// untrimmed roof shell (and every hosted accessory) with a thin slab at each +// cut line, so only real material is shown and the hollow attic stays empty; +// the outline is the analytic surface edge. +function SectionCut({ segment, planes }: { segment: RoofSegmentNode; planes: SectionPlaneSpec[] }) { + const shapeKey = segmentShapeKey(segment) + const accessoryKey = accessorySectionKey(segment) + + const geometries = useMemo(() => { + if (planes.length === 0) return null + const accessoryGeometries = collectAccessorySectionGeometries(segment) + const result = buildSectionGeometries(segment, planes, accessoryGeometries) + for (const g of accessoryGeometries) g.dispose() + return result + // Recompute when the roof shape (shapeKey), the cut lines (planes), or the + // hosted accessories (accessoryKey) change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [segment, planes.length, planes]) + + useEffect(() => { + return () => { + geometries?.fill.dispose() + geometries?.outline.dispose() + } + }, [geometries]) + + if (!geometries) return null + + return ( + + null} + renderOrder={SECTION_FILL_RENDER_ORDER} + /> + + + ) +} + +function HoveredRoofSegmentOutlineProxy() { + const hoveredId = useViewer((s) => s.hoveredId) + const segment = useScene((s) => { + if (!hoveredId) return null + const node = s.nodes[hoveredId as AnyNodeId] + return node?.type === 'roof-segment' ? (node as RoofSegmentNode) : null + }) + const nodes = useScene((s) => s.nodes) + const ref = useRef(null) + + const geometry = useMemo(() => { + if (!segment) return null + return generateRoofSegmentGeometry(segment, nodes) + }, [nodes, segment]) + const source = segment ? (sceneRegistry.nodes.get(segment.id) ?? null) : null + const roofRoot = + segment?.parentId && nodes[segment.parentId as AnyNodeId]?.type === 'roof' + ? (sceneRegistry.nodes.get(segment.parentId) ?? null) + : null + + useEffect(() => { + return () => { + geometry?.dispose() + } + }, [geometry]) + + useFrame(() => { + const mesh = ref.current + if (!(mesh && source && roofRoot)) return + source.updateWorldMatrix(true, false) + roofRoot.updateWorldMatrix(true, false) + mesh.matrix.copy(roofRoot.matrixWorld).invert().multiply(source.matrixWorld) + mesh.matrixAutoUpdate = false + }) + + if (!(source && geometry && roofRoot && segment)) return null + + return createPortal( + null} + ref={ref} + />, + roofRoot, + ) +} + +const _dragNdc = new THREE.Vector2() +const _dragRaycaster = new THREE.Raycaster() +const _dragPlaneHit = new THREE.Vector3() +const _dragLocalPoint = new THREE.Vector3() +const _dragInverseMatrix = new THREE.Matrix4() +const _trimHitInverseMatrix = new THREE.Matrix4() +const _trimHitRay = new THREE.Ray() +const _trimHitBox = new THREE.Box3() +const _trimHitPoint = new THREE.Vector3() + +function makeExpandedTrimRaycast( + visualScale: readonly [number, number, number], + hitScale: readonly [number, number, number], +) { + const halfX = Math.max(0.5, hitScale[0] / Math.max(visualScale[0], 1e-6) / 2) + const halfY = Math.max(0.5, hitScale[1] / Math.max(visualScale[1], 1e-6) / 2) + const halfZ = Math.max(0.5, hitScale[2] / Math.max(visualScale[2], 1e-6) / 2) + return function expandedTrimRaycast( + this: THREE.Mesh, + raycaster: THREE.Raycaster, + intersects: THREE.Intersection[], + ) { + _trimHitInverseMatrix.copy(this.matrixWorld).invert() + _trimHitRay.copy(raycaster.ray).applyMatrix4(_trimHitInverseMatrix) + _trimHitBox.min.set(-halfX, -halfY, -halfZ) + _trimHitBox.max.set(halfX, halfY, halfZ) + const localHit = _trimHitRay.intersectBox(_trimHitBox, _trimHitPoint) + if (!localHit) return + const point = localHit.clone().applyMatrix4(this.matrixWorld) + const distance = raycaster.ray.origin.distanceTo(point) + if (distance < raycaster.near || distance > raycaster.far) return + intersects.push({ distance, point, object: this }) + } +} + +function trimEquals(a: RoofSegmentTrim, b: RoofSegmentTrim): boolean { + return ( + a.left === b.left && + a.right === b.right && + a.front === b.front && + a.back === b.back && + a.frontLeft === b.frontLeft && + a.frontRight === b.frontRight && + a.backLeft === b.backLeft && + a.backRight === b.backRight && + a.frontLeftX === b.frontLeftX && + a.frontLeftZ === b.frontLeftZ && + a.frontRightX === b.frontRightX && + a.frontRightZ === b.frontRightZ && + a.backLeftX === b.backLeftX && + a.backLeftZ === b.backLeftZ && + a.backRightX === b.backRightX && + a.backRightZ === b.backRightZ + ) +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function isDiagonalTrimSide(side: RoofTrimSide): side is DiagonalTrimSide { + return ( + side === 'frontLeft' || side === 'frontRight' || side === 'backLeft' || side === 'backRight' + ) +} + +function getDiagonalAxisKeys(side: DiagonalTrimSide): [DiagonalTrimAxisKey, DiagonalTrimAxisKey] { + switch (side) { + case 'frontLeft': + return ['frontLeftX', 'frontLeftZ'] + case 'frontRight': + return ['frontRightX', 'frontRightZ'] + case 'backLeft': + return ['backLeftX', 'backLeftZ'] + case 'backRight': + return ['backRightX', 'backRightZ'] + } +} + +function getDiagonalResetCorner(side: RoofTrimSide): DiagonalTrimSide | null { + if (isDiagonalTrimSide(side)) return side + if (side.endsWith('X') || side.endsWith('Z')) { + return getDiagonalAxisCorner(side as DiagonalTrimAxisSide) + } + return null +} + +function getDiagonalAxisCorner(side: DiagonalTrimAxisSide): DiagonalTrimSide { + if (side === 'frontLeftX' || side === 'frontLeftZ') return 'frontLeft' + if (side === 'frontRightX' || side === 'frontRightZ') return 'frontRight' + if (side === 'backLeftX' || side === 'backLeftZ') return 'backLeft' + return 'backRight' +} + +function getOppositeDiagonalAxis(side: DiagonalTrimAxisKey): DiagonalTrimAxisKey { + switch (side) { + case 'frontLeftX': + return 'frontRightX' + case 'frontRightX': + return 'frontLeftX' + case 'backLeftX': + return 'backRightX' + case 'backRightX': + return 'backLeftX' + case 'frontLeftZ': + return 'backLeftZ' + case 'backLeftZ': + return 'frontLeftZ' + case 'frontRightZ': + return 'backRightZ' + case 'backRightZ': + return 'frontRightZ' + } +} + +function getMaxDiagonalAxisTrim( + segment: RoofSegmentNode, + trim: RoofSegmentTrim, + axis: DiagonalTrimAxisKey, +): number { + const keptWidth = Math.max(0, segment.width - trim.left - trim.right) + const keptDepth = Math.max(0, segment.depth - trim.front - trim.back) + const opposite = trim[getOppositeDiagonalAxis(axis)] + const span = axis.endsWith('X') ? keptWidth : keptDepth + return Math.max(0, span - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite) +} + +function getStarterDiagonalTrim(segment: RoofSegmentNode, trim: RoofSegmentTrim): number { + const keptWidth = Math.max(0, segment.width - trim.left - trim.right) + const keptDepth = Math.max(0, segment.depth - trim.front - trim.back) + const maxDiagonalTrim = Math.max(0, Math.min(keptWidth, keptDepth) - MIN_ROOF_SEGMENT_TRIM_SPAN) + return Math.min(maxDiagonalTrim, Math.max(0.75, maxDiagonalTrim * 0.2)) +} + +function patchTrimSide( + segment: RoofSegmentNode, + baseTrim: RoofSegmentTrim, + side: RoofTrimSide, + rawValue: number, +): RoofSegmentTrim { + const next = { ...baseTrim } + if (side === 'left' || side === 'right') { + const opposite = side === 'left' ? baseTrim.right : baseTrim.left + const max = Math.max(0, segment.width - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite) + next[side] = clamp(rawValue, 0, max) + } else { + if (isDiagonalTrimSide(side)) { + const [xAxis, zAxis] = getDiagonalAxisKeys(side) + next[xAxis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, xAxis)) + next[zAxis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, zAxis)) + next[side] = Math.min(next[xAxis], next[zAxis]) + return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next }) + } + + if (side.endsWith('X') || side.endsWith('Z')) { + const axis = side as DiagonalTrimAxisKey + const corner = getDiagonalAxisCorner(axis) + const [xAxis, zAxis] = getDiagonalAxisKeys(corner) + const otherAxis = axis === xAxis ? zAxis : xAxis + const starter = getStarterDiagonalTrim(segment, baseTrim) + next[axis] = clamp(rawValue, 0, getMaxDiagonalAxisTrim(segment, baseTrim, axis)) + if (next[otherAxis] <= 0 && next[corner] <= 0) { + next[otherAxis] = Math.min(starter, getMaxDiagonalAxisTrim(segment, baseTrim, otherAxis)) + } + next[corner] = Math.min(next[xAxis], next[zAxis]) + return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next }) + } + + const opposite = side === 'front' ? baseTrim.back : baseTrim.front + const max = Math.max(0, segment.depth - MIN_ROOF_SEGMENT_TRIM_SPAN - opposite) + next[side] = clamp(rawValue, 0, max) + } + + return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next }) +} + +function patchTrimSideByDelta( + segment: RoofSegmentNode, + baseTrim: RoofSegmentTrim, + side: RoofTrimSide, + delta: number, +): RoofSegmentTrim { + if (isDiagonalTrimSide(side)) { + const [xAxis, zAxis] = getDiagonalAxisKeys(side) + const next = { ...baseTrim } + next[xAxis] = clamp( + baseTrim[xAxis] + delta, + 0, + getMaxDiagonalAxisTrim(segment, baseTrim, xAxis), + ) + next[zAxis] = clamp( + baseTrim[zAxis] + delta, + 0, + getMaxDiagonalAxisTrim(segment, baseTrim, zAxis), + ) + next[side] = Math.min(next[xAxis], next[zAxis]) + return normalizeRoofSegmentTrim({ width: segment.width, depth: segment.depth, trim: next }) + } + + const baseValue = baseTrim[side] + return patchTrimSide(segment, baseTrim, side, baseValue + delta) +} + +function getTrimValueFromLocalPoint( + segment: RoofSegmentNode, + baseTrim: RoofSegmentTrim, + side: RoofTrimSide, + localPoint: THREE.Vector3, +): number { + const leftX = -segment.width / 2 + baseTrim.left + const rightX = segment.width / 2 - baseTrim.right + const frontZ = segment.depth / 2 - baseTrim.front + const backZ = -segment.depth / 2 + baseTrim.back + + switch (side) { + case 'left': + return localPoint.x + segment.width / 2 + case 'right': + return segment.width / 2 - localPoint.x + case 'front': + return segment.depth / 2 - localPoint.z + case 'back': + return localPoint.z + segment.depth / 2 + case 'frontLeft': + return localPoint.x - leftX + (frontZ - localPoint.z) + case 'frontRight': + return rightX - localPoint.x + (frontZ - localPoint.z) + case 'backLeft': + return localPoint.x - leftX + (localPoint.z - backZ) + case 'backRight': + return rightX - localPoint.x + (localPoint.z - backZ) + case 'frontLeftX': + return localPoint.x - leftX + case 'frontLeftZ': + return frontZ - localPoint.z + case 'frontRightX': + return rightX - localPoint.x + case 'frontRightZ': + return frontZ - localPoint.z + case 'backLeftX': + return localPoint.x - leftX + case 'backLeftZ': + return localPoint.z - backZ + case 'backRightX': + return rightX - localPoint.x + case 'backRightZ': + return localPoint.z - backZ + } +} + +function getTrimLabel(side: RoofTrimSide): string { + switch (side) { + case 'left': + return 'trim left' + case 'right': + return 'trim right' + case 'front': + return 'trim front' + case 'back': + return 'trim back' + case 'frontLeft': + return 'trim front left diagonal' + case 'frontRight': + return 'trim front right diagonal' + case 'backLeft': + return 'trim back left diagonal' + case 'backRight': + return 'trim back right diagonal' + case 'frontLeftX': + return 'trim front left diagonal width' + case 'frontLeftZ': + return 'trim front left diagonal depth' + case 'frontRightX': + return 'trim front right diagonal width' + case 'frontRightZ': + return 'trim front right diagonal depth' + case 'backLeftX': + return 'trim back left diagonal width' + case 'backLeftZ': + return 'trim back left diagonal depth' + case 'backRightX': + return 'trim back right diagonal width' + case 'backRightZ': + return 'trim back right diagonal depth' + } +} + +function getTrimCursor(side: RoofTrimSide): string { + switch (side) { + case 'left': + case 'right': + return 'ew-resize' + case 'front': + case 'back': + return 'ns-resize' + case 'frontLeft': + case 'backRight': + return 'nwse-resize' + case 'frontRight': + case 'backLeft': + return 'nesw-resize' + case 'frontLeftX': + case 'frontRightX': + case 'backLeftX': + case 'backRightX': + return 'ew-resize' + case 'frontLeftZ': + case 'frontRightZ': + case 'backLeftZ': + case 'backRightZ': + return 'ns-resize' + } +} + +function shouldShowTrimPlanes(metadata: unknown): boolean { + return ( + typeof metadata === 'object' && + metadata !== null && + !Array.isArray(metadata) && + (metadata as Record).showTrimPlanes === true + ) +} + +function commitSegmentTrim(segment: RoofSegmentNode, trim: RoofSegmentTrim) { + const scene = useScene.getState() + scene.applyNodeChanges({ + update: [{ id: segment.id as AnyNodeId, data: { trim } as Partial }], + }) +} + +function RoofTrimHandles() { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const selectedId = selectedIds.length === 1 ? (selectedIds[0] as AnyNodeId) : null + const segment = useScene((s) => { + if (!selectedId) return null + const node = s.nodes[selectedId] + return node?.type === 'roof-segment' ? (node as RoofSegmentNode) : null + }) + const readOnly = useScene((s) => s.readOnly) + const liveOverrideKey = useLiveNodeOverrides((s) => + segment ? JSON.stringify(s.overrides.get(segment.id) ?? null) : null, + ) + const [hoveredSide, setHoveredSide] = useState(null) + const [draggingSide, setDraggingSide] = useState(null) + const groupRef = useRef(null) + const dragCleanupRef = useRef<(() => void) | null>(null) + const { camera, gl } = useThree() + const zoom = camera instanceof THREE.OrthographicCamera ? 1 / camera.zoom : 1 + const handleBaseScale = zoom * TRIM_HANDLE_BASE_SCALE + + useEffect(() => () => dragCleanupRef.current?.(), []) + + const liveSegment = useMemo(() => { + if (!segment) return null + void liveOverrideKey + return getEffectiveNode(segment) + }, [segment, liveOverrideKey]) + + useFrame(() => { + if (!liveSegment || !groupRef.current) return + const source = sceneRegistry.nodes.get(liveSegment.id) + if (!source) return + source.updateWorldMatrix(true, false) + groupRef.current.matrix.copy(source.matrixWorld) + groupRef.current.matrixAutoUpdate = false + }) + + const showTrimPlanes = shouldShowTrimPlanes(liveSegment?.metadata) + + if (readOnly || !segment || !liveSegment || !showTrimPlanes) return null + + const trim = normalizeRoofSegmentTrim(liveSegment) + const activeRh = getActiveRoofHeight(liveSegment) + const handleY = Math.max(0.45, liveSegment.wallHeight + activeRh + 0.45) + const keptWidth = Math.max(0.01, liveSegment.width - trim.left - trim.right) + const keptDepth = Math.max(0.01, liveSegment.depth - trim.front - trim.back) + const leftX = -liveSegment.width / 2 + trim.left + const rightX = liveSegment.width / 2 - trim.right + const frontZ = liveSegment.depth / 2 - trim.front + const backZ = -liveSegment.depth / 2 + trim.back + const visibleBounds = getTrimVisibleTopBounds({ + ...liveSegment, + trim: { + ...trim, + frontLeft: 0, + frontRight: 0, + backLeft: 0, + backRight: 0, + frontLeftX: 0, + frontLeftZ: 0, + frontRightX: 0, + frontRightZ: 0, + backLeftX: 0, + backLeftZ: 0, + backRightX: 0, + backRightZ: 0, + }, + }) + const visibleCenterX = (visibleBounds.minX + visibleBounds.maxX) / 2 + const visibleCenterZ = (visibleBounds.minZ + visibleBounds.maxZ) / 2 + const visibleWidth = Math.max(0.01, visibleBounds.maxX - visibleBounds.minX) + const visibleDepth = Math.max(0.01, visibleBounds.maxZ - visibleBounds.minZ) + const visualLeftX = trim.left > 0 ? leftX : visibleBounds.minX + const visualRightX = trim.right > 0 ? rightX : visibleBounds.maxX + const visualFrontZ = trim.front > 0 ? frontZ : visibleBounds.maxZ + const visualBackZ = trim.back > 0 ? backZ : visibleBounds.minZ + const maxDiagonalTrim = Math.max(0, Math.min(keptWidth, keptDepth) - MIN_ROOF_SEGMENT_TRIM_SPAN) + + const pointOnTrimLineAtX = ( + start: readonly [number, number], + end: readonly [number, number], + x: number, + ): [number, number] => { + const dx = end[0] - start[0] + if (Math.abs(dx) < 1e-6) return [x, start[1]] + const t = (x - start[0]) / dx + return [x, start[1] + (end[1] - start[1]) * t] + } + + const pointOnTrimLineAtZ = ( + start: readonly [number, number], + end: readonly [number, number], + z: number, + ): [number, number] => { + const dz = end[1] - start[1] + if (Math.abs(dz) < 1e-6) return [start[0], z] + const t = (z - start[1]) / dz + return [start[0] + (end[0] - start[0]) * t, z] + } + + const getDiagonalRailLine = ( + side: DiagonalTrimSide, + start: readonly [number, number], + end: readonly [number, number], + ): [[number, number], [number, number]] => { + switch (side) { + case 'frontLeft': + return [ + pointOnTrimLineAtZ(start, end, visualFrontZ), + pointOnTrimLineAtX(start, end, visualLeftX), + ] + case 'frontRight': + return [ + pointOnTrimLineAtX(start, end, visualRightX), + pointOnTrimLineAtZ(start, end, visualFrontZ), + ] + case 'backLeft': + return [ + pointOnTrimLineAtX(start, end, visualLeftX), + pointOnTrimLineAtZ(start, end, visualBackZ), + ] + case 'backRight': + return [ + pointOnTrimLineAtZ(start, end, visualBackZ), + pointOnTrimLineAtX(start, end, visualRightX), + ] + } + } + + // Cross-section planes the active trim cuts expose. Slice the live roof + // mesh just inside the kept material (the cut sits coplanar with the mesh's + // own face otherwise) so the cutaway shows real construction layers. + const sectionPlanes: SectionPlaneSpec[] = [] + // Inside reference: the kept-region center, on the kept side of every cut so + // the inset always shifts the slice into solid material. + const insideRef: readonly [number, number] = [0, 0] + if (trim.left > 0) { + sectionPlanes.push( + makeSectionPlane(leftX, visualBackZ, leftX, visualFrontZ, SECTION_PLANE_INSET, insideRef), + ) + } + if (trim.right > 0) { + sectionPlanes.push( + makeSectionPlane(rightX, visualBackZ, rightX, visualFrontZ, SECTION_PLANE_INSET, insideRef), + ) + } + if (trim.front > 0) { + sectionPlanes.push( + makeSectionPlane(visualLeftX, frontZ, visualRightX, frontZ, SECTION_PLANE_INSET, insideRef), + ) + } + if (trim.back > 0) { + sectionPlanes.push( + makeSectionPlane(visualLeftX, backZ, visualRightX, backZ, SECTION_PLANE_INSET, insideRef), + ) + } + + // Diagonal/corner cuts run at an angle, so they need a generic vertical + // plane through the corner cut line. The endpoints match the rail line + // geometry in renderDiagonalTrimPlane (start/end before the visual-bounds + // extension; only direction matters for slicing). + const diagonalCutLine = (side: DiagonalTrimSide): [[number, number], [number, number]] | null => { + const [xKey, zKey] = getDiagonalAxisKeys(side) + const dx = trim[xKey] + const dz = trim[zKey] + if (!(dx > 0 && dz > 0)) return null + switch (side) { + case 'frontLeft': + return [ + [leftX + dx, frontZ], + [leftX, frontZ - dz], + ] + case 'frontRight': + return [ + [rightX, frontZ - dz], + [rightX - dx, frontZ], + ] + case 'backLeft': + return [ + [leftX, backZ + dz], + [leftX + dx, backZ], + ] + case 'backRight': + return [ + [rightX - dx, backZ], + [rightX, backZ + dz], + ] + } + } + for (const side of ['frontLeft', 'frontRight', 'backLeft', 'backRight'] as const) { + const line = diagonalCutLine(side) + if (!line) continue + const [s, e] = line + const [railStart, railEnd] = getDiagonalRailLine(side, s, e) + sectionPlanes.push( + makeSectionPlane( + railStart[0], + railStart[1], + railEnd[0], + railEnd[1], + SECTION_PLANE_INSET, + insideRef, + ), + ) + } + + const resetDiagonalTrim = (side: RoofTrimSide, event: ThreeEvent) => { + event.stopPropagation() + const corner = getDiagonalResetCorner(side) + if (!corner) return + + const baseSegment = getEffectiveNode(segment) + const baseTrim = normalizeRoofSegmentTrim(baseSegment) + const next = { ...baseTrim } + const [xAxis, zAxis] = getDiagonalAxisKeys(corner) + next[corner] = 0 + next[xAxis] = 0 + next[zAxis] = 0 + const normalized = normalizeRoofSegmentTrim({ + width: baseSegment.width, + depth: baseSegment.depth, + trim: next, + }) + useLiveNodeOverrides.getState().clear(segment.id as AnyNodeId) + if (!trimEquals(normalized, baseTrim)) { + commitSegmentTrim(baseSegment, normalized) + } + useScene.getState().markDirty(segment.id as AnyNodeId) + } + + const startDrag = (side: RoofTrimSide, event: ThreeEvent) => { + event.stopPropagation() + const source = sceneRegistry.nodes.get(segment.id) + if (!source) return + + source.updateWorldMatrix(true, false) + const startMatrix = source.matrixWorld.clone() + _dragInverseMatrix.copy(startMatrix).invert() + const dragPlanePoint = new THREE.Vector3(0, handleY, 0).applyMatrix4(startMatrix) + const dragPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -dragPlanePoint.y) + const baseSegment = getEffectiveNode(segment) + const baseTrim = normalizeRoofSegmentTrim(baseSegment) + const segmentId = segment.id as AnyNodeId + let pendingTrim = baseTrim + let lastDirtyMarkAt = 0 + let pendingDirtyTimeout: number | null = null + + const clearPendingDirtyTimeout = () => { + if (pendingDirtyTimeout === null) return + window.clearTimeout(pendingDirtyTimeout) + pendingDirtyTimeout = null + } + + const flushDirtyMark = () => { + clearPendingDirtyTimeout() + lastDirtyMarkAt = performance.now() + useScene.getState().markDirty(segmentId) + } + + const scheduleDirtyMark = () => { + const now = performance.now() + if (now - lastDirtyMarkAt >= TRIM_LIVE_REBUILD_INTERVAL_MS) { + flushDirtyMark() + return + } + if (pendingDirtyTimeout !== null) return + pendingDirtyTimeout = window.setTimeout( + () => { + flushDirtyMark() + }, + Math.max(0, TRIM_LIVE_REBUILD_INTERVAL_MS - (now - lastDirtyMarkAt)), + ) + } + + const getPointerTrimValue = (clientX: number, clientY: number): number | null => { + const rect = gl.domElement.getBoundingClientRect() + _dragNdc.set( + ((clientX - rect.left) / rect.width) * 2 - 1, + -(((clientY - rect.top) / rect.height) * 2 - 1), + ) + _dragRaycaster.setFromCamera(_dragNdc, camera) + if (!_dragRaycaster.ray.intersectPlane(dragPlane, _dragPlaneHit)) return null + _dragLocalPoint.copy(_dragPlaneHit).applyMatrix4(_dragInverseMatrix) + return getTrimValueFromLocalPoint(baseSegment, baseTrim, side, _dragLocalPoint) + } + + const initialPointerValue = getPointerTrimValue(event.clientX, event.clientY) + if (initialPointerValue === null) return + + document.body.style.cursor = getTrimCursor(side) + setDraggingSide(side) + useInteractionScope + .getState() + .begin({ kind: 'handle-drag', nodeId: segmentId, handle: getTrimLabel(side) }) + useViewer.getState().setInputDragging(true) + useScene.temporal.getState().pause() + + const updateFromPointer = (clientX: number, clientY: number) => { + const pointerValue = getPointerTrimValue(clientX, clientY) + if (pointerValue === null) return + pendingTrim = patchTrimSideByDelta( + baseSegment, + baseTrim, + side, + pointerValue - initialPointerValue, + ) + useLiveNodeOverrides.getState().set(segmentId, { trim: pendingTrim }) + // Coalesce live merged-shell rebuilds during trim drag. The override + // still updates every pointer move for local trim affordances, but the + // full roof CSG only refreshes at a capped cadence instead of at raw + // pointer-event frequency. + scheduleDirtyMark() + } + + updateFromPointer(event.clientX, event.clientY) + + const cleanup = () => { + clearPendingDirtyTimeout() + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) + if ( + document.body.style.cursor === 'ew-resize' || + document.body.style.cursor === 'ns-resize' || + document.body.style.cursor === 'nwse-resize' || + document.body.style.cursor === 'nesw-resize' || + document.body.style.cursor === 'move' + ) { + document.body.style.cursor = '' + } + useScene.temporal.getState().resume() + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'handle-drag' && scope.nodeId === segmentId) + useViewer.getState().setInputDragging(false) + setDraggingSide(null) + dragCleanupRef.current = null + } + + const onMove = (moveEvent: PointerEvent) => { + updateFromPointer(moveEvent.clientX, moveEvent.clientY) + } + + const onUp = () => { + swallowNextClick() + if (!trimEquals(pendingTrim, baseTrim)) { + commitSegmentTrim(baseSegment, pendingTrim) + } + useLiveNodeOverrides.getState().clear(segmentId) + flushDirtyMark() + cleanup() + } + + const onCancel = () => { + useLiveNodeOverrides.getState().clear(segmentId) + flushDirtyMark() + cleanup() + } + + dragCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } + + const renderTrimPlane = ( + side: RoofTrimSide, + position: [number, number, number], + args: [number, number], + rotation: [number, number, number] = [0, 0, 0], + handles: readonly { side: RoofTrimSide; offsetX: number }[] = [{ side, offsetX: 0 }], + showPlane = true, + ) => { + const [planeWidth, planeHeight] = args + const isHovered = handles.some((handle) => handle.side === hoveredSide) + const railY = planeHeight / 2 + const railVisualHeight = Math.max(0.012, handleBaseScale * 0.022) + const railVisualDepth = Math.max(0.01, handleBaseScale * 0.018) + const railVisualLength = planeWidth + railVisualDepth * 2 + const capSize = Math.max(0.045, handleBaseScale * 0.085) + const primaryHandle = handles[0] ?? { side, offsetX: 0 } + const endpointHandles = handles.slice(1) + + const renderRailHitTarget = ( + handle: { side: RoofTrimSide; offsetX: number }, + scale: [number, number, number], + visual: 'rail' | 'cap', + ) => { + const hovered = hoveredSide === handle.side + const visualScale: [number, number, number] = + visual === 'rail' ? scale : [capSize, capSize, capSize] + const hitScale: [number, number, number] = + visual === 'rail' + ? [scale[0], TRIM_RAIL_HIT_HEIGHT, TRIM_RAIL_HIT_DEPTH] + : [TRIM_CAP_HIT_SIZE, TRIM_CAP_HIT_SIZE, TRIM_CAP_HIT_SIZE] + const resetCorner = getDiagonalResetCorner(handle.side) + return ( + + resetDiagonalTrim(handle.side, event) : undefined + } + onPointerDown={(event) => startDrag(handle.side, event)} + onPointerEnter={(event) => { + event.stopPropagation() + setHoveredSide(handle.side) + document.body.style.cursor = getTrimCursor(handle.side) + }} + onPointerLeave={(event) => { + event.stopPropagation() + if (!dragCleanupRef.current) { + setHoveredSide((current) => (current === handle.side ? null : current)) + document.body.style.cursor = '' + } + }} + renderOrder={TRIM_RAIL_RENDER_ORDER} + scale={visualScale} + /> + + ) + } + + return ( + + {showPlane ? ( + null} + renderOrder={TRIM_PLANE_RENDER_ORDER} + scale={[planeWidth, planeHeight, 1]} + /> + ) : null} + + {renderRailHitTarget( + primaryHandle, + [railVisualLength, railVisualHeight, railVisualDepth], + 'rail', + )} + {endpointHandles.map((handle) => + renderRailHitTarget(handle, [capSize, capSize, capSize], 'cap'), + )} + + ) + } + + const renderDiagonalAddHandle = (side: DiagonalTrimSide) => { + if (maxDiagonalTrim <= 0) return null + + let position: [number, number, number] + let xDir = 1 + let zDir = 1 + switch (side) { + case 'frontLeft': + position = [leftX, handleY, frontZ] + xDir = 1 + zDir = -1 + break + case 'frontRight': + position = [rightX, handleY, frontZ] + xDir = -1 + zDir = -1 + break + case 'backLeft': + position = [leftX, handleY, backZ] + xDir = 1 + zDir = 1 + break + case 'backRight': + position = [rightX, handleY, backZ] + xDir = -1 + zDir = 1 + break + default: + return null + } + + const hovered = hoveredSide === side + const addSize = Math.max(0.055, handleBaseScale * 0.1) + const addVisualScale: [number, number, number] = [addSize, addSize, addSize] + const addHitScale: [number, number, number] = [ + TRIM_CAP_HIT_SIZE, + TRIM_CAP_HIT_SIZE, + TRIM_CAP_HIT_SIZE, + ] + const bracketLength = Math.min(0.55, Math.max(0.28, maxDiagonalTrim * 0.22)) + const bracketHeight = Math.max(0.012, handleBaseScale * 0.022) + const bracketDepth = Math.max(0.01, handleBaseScale * 0.018) + const bracketArmLength = bracketLength + bracketDepth + const bracketVisualScale: [number, number, number] = [ + bracketArmLength, + bracketHeight, + bracketDepth, + ] + const bracketHitScale: [number, number, number] = [ + bracketArmLength, + TRIM_RAIL_HIT_HEIGHT, + TRIM_RAIL_HIT_DEPTH, + ] + const previewAmount = getStarterDiagonalTrim(liveSegment, trim) + + let previewStart: [number, number] + let previewEnd: [number, number] + switch (side) { + case 'frontLeft': + previewStart = [leftX + previewAmount, frontZ] + previewEnd = [leftX, frontZ - previewAmount] + break + case 'frontRight': + previewStart = [rightX, frontZ - previewAmount] + previewEnd = [rightX - previewAmount, frontZ] + break + case 'backLeft': + previewStart = [leftX, backZ + previewAmount] + previewEnd = [leftX + previewAmount, backZ] + break + case 'backRight': + previewStart = [rightX - previewAmount, backZ] + previewEnd = [rightX, backZ + previewAmount] + break + } + + const [previewRailStart, previewRailEnd] = getDiagonalRailLine(side, previewStart, previewEnd) + const previewDx = previewRailEnd[0] - previewRailStart[0] + const previewDz = previewRailEnd[1] - previewRailStart[1] + const previewWidth = Math.hypot(previewDx, previewDz) + const previewYaw = Math.atan2(-previewDz, previewDx) + const handlePointerEnter = (event: ThreeEvent) => { + event.stopPropagation() + setHoveredSide(side) + document.body.style.cursor = getTrimCursor(side) + } + const handlePointerLeave = (event: ThreeEvent) => { + event.stopPropagation() + if (!dragCleanupRef.current) { + setHoveredSide((current) => (current === side ? null : current)) + document.body.style.cursor = '' + } + } + + return ( + + {hovered && previewWidth > 0 ? ( + + null} + renderOrder={TRIM_RAIL_RENDER_ORDER} + scale={[previewWidth + bracketDepth * 2, bracketHeight, bracketDepth]} + /> + + ) : null} + + resetDiagonalTrim(side, event)} + onPointerDown={(event) => startDrag(side, event)} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} + position={[position[0] + (xDir * bracketArmLength) / 2, position[1], position[2]]} + raycast={makeExpandedTrimRaycast(bracketVisualScale, bracketHitScale)} + renderOrder={TRIM_RAIL_RENDER_ORDER} + scale={bracketVisualScale} + /> + resetDiagonalTrim(side, event)} + onPointerDown={(event) => startDrag(side, event)} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} + position={[position[0], position[1], position[2] + (zDir * bracketArmLength) / 2]} + raycast={makeExpandedTrimRaycast(bracketVisualScale, bracketHitScale)} + renderOrder={TRIM_RAIL_RENDER_ORDER} + rotation={[0, zDir > 0 ? -Math.PI / 2 : Math.PI / 2, 0]} + scale={bracketVisualScale} + /> + resetDiagonalTrim(side, event)} + onPointerDown={(event) => startDrag(side, event)} + onPointerEnter={handlePointerEnter} + onPointerLeave={handlePointerLeave} + position={position} + raycast={makeExpandedTrimRaycast(addVisualScale, addHitScale)} + renderOrder={TRIM_RAIL_RENDER_ORDER} + scale={addVisualScale} + /> + + ) + } + + const renderDiagonalTrimPlane = (side: DiagonalTrimSide, xAmount: number, zAmount: number) => { + if (maxDiagonalTrim <= 0) { + return null + } + + if (!(xAmount > 0 && zAmount > 0)) { + return renderDiagonalAddHandle(side) + } + + const displayX = xAmount + const displayZ = zAmount + if (!(displayX > 0 && displayZ > 0)) return null + + let start: [number, number] + let end: [number, number] + let xOffset = 0 + let zOffset = 0 + const [xSide, zSide] = getDiagonalAxisKeys(side) + switch (side) { + case 'frontLeft': + start = [leftX + displayX, frontZ] + end = [leftX, frontZ - displayZ] + xOffset = -1 + zOffset = 1 + break + case 'frontRight': + start = [rightX, frontZ - displayZ] + end = [rightX - displayX, frontZ] + zOffset = -1 + xOffset = 1 + break + case 'backLeft': + start = [leftX, backZ + displayZ] + end = [leftX + displayX, backZ] + zOffset = -1 + xOffset = 1 + break + case 'backRight': + start = [rightX - displayX, backZ] + end = [rightX, backZ + displayZ] + xOffset = -1 + zOffset = 1 + break + default: + return null + } + + const [railStart, railEnd] = getDiagonalRailLine(side, start, end) + const dx = railEnd[0] - railStart[0] + const dz = railEnd[1] - railStart[1] + const width = Math.hypot(dx, dz) + const yaw = Math.atan2(-dz, dx) + return renderTrimPlane( + side, + [(railStart[0] + railEnd[0]) / 2, handleY / 2, (railStart[1] + railEnd[1]) / 2], + [width, handleY], + [0, yaw, 0], + [ + { side, offsetX: 0 }, + { side: xSide, offsetX: (width / 2) * xOffset }, + { side: zSide, offsetX: (width / 2) * zOffset }, + ], + false, + ) + } + + return ( + + {sectionPlanes.length > 0 ? ( + + ) : null} + {renderTrimPlane( + 'left', + [visualLeftX, handleY / 2, visibleCenterZ], + [visibleDepth, handleY], + [0, Math.PI / 2, 0], + )} + {renderTrimPlane( + 'right', + [visualRightX, handleY / 2, visibleCenterZ], + [visibleDepth, handleY], + [0, Math.PI / 2, 0], + )} + {renderTrimPlane( + 'front', + [visibleCenterX, handleY / 2, visualFrontZ], + [visibleWidth, handleY], + )} + {renderTrimPlane('back', [visibleCenterX, handleY / 2, visualBackZ], [visibleWidth, handleY])} + {renderDiagonalTrimPlane('frontLeft', trim.frontLeftX, trim.frontLeftZ)} + {renderDiagonalTrimPlane('frontRight', trim.frontRightX, trim.frontRightZ)} + {renderDiagonalTrimPlane('backLeft', trim.backLeftX, trim.backLeftZ)} + {renderDiagonalTrimPlane('backRight', trim.backRightX, trim.backRightZ)} + + ) +} + /** * Imperatively toggles the Three.js visibility of roof objects based on the * editor selection — without causing React re-renders in RoofRenderer. * * Full edit-mode (segment selected): - * - merged-roof mesh is hidden - * - segments-wrapper group is shown (individual segments visible for editing) - * - all children are marked dirty so RoofSystem rebuilds their geometry + * - merged-roof mesh stays VISIBLE — it rebuilds live from each segment's + * trim override, so the edited cutaway matches the clean commit instead of + * exposing the per-segment meshes' abutting end-cap faces + * - segments-wrapper group stays hidden (handles render from RoofTrimHandles) + * - all children are marked dirty so RoofSystem rebuilds the merged shell * * Accessory-reveal mode (a dormer/chimney/etc. hosted on a segment is selected): * - merged-roof mesh stays visible (we don't want the appearance to jump) @@ -55,7 +1766,9 @@ function makeEmptySegmentGeometry(): THREE.BufferGeometry { */ export const RoofEditSystem = () => { const selectedIds = useViewer((s) => s.selection.selectedIds) + const movingNode = useMovingNode() const prevActiveRoofIds = useRef(new Set()) + const prevMovingRoofIds = useRef(new Set()) const prevRevealRoofIds = useRef(new Set()) useEffect(() => { @@ -68,6 +1781,10 @@ export const RoofEditSystem = () => { // reveal the wrapper so handle portals into the segment mesh become // visible. Merged stays on. const revealRoofIds = new Set() + // Roofs whose selected segment is currently being moved in 3D. During this + // transient state we reveal the wrapper so the moving segment mesh is + // visible and hide the merged roof to avoid the duplicate shell fighting it. + const movingRoofIds = new Set() for (const id of selectedIds) { const node = nodes[id as AnyNodeId] @@ -87,11 +1804,17 @@ export const RoofEditSystem = () => { } } + if (movingNode?.type === 'roof-segment' && movingNode.parentId) { + movingRoofIds.add(movingNode.parentId) + } + // Union of roofs that need ANY state change this tick. const roofIdsToUpdate = new Set([ ...activeRoofIds, + ...movingRoofIds, ...revealRoofIds, ...prevActiveRoofIds.current, + ...prevMovingRoofIds.current, ...prevRevealRoofIds.current, ]) @@ -102,19 +1825,28 @@ export const RoofEditSystem = () => { const mergedMesh = group.getObjectByName('merged-roof') const segmentsWrapper = group.getObjectByName('segments-wrapper') const isActive = activeRoofIds.has(roofId) + const isMoving = movingRoofIds.has(roofId) const isReveal = revealRoofIds.has(roofId) - if (mergedMesh) mergedMesh.visible = !isActive - if (segmentsWrapper) segmentsWrapper.visible = isActive || isReveal + // Keep the clean merged shell visible during trim editing too (not just + // when deselected). The merged shell rebuilds live from each segment's + // trim override (RoofSystem reads getEffectiveNode), so the dragged + // cutaway matches the commit. Showing the individual per-segment meshes + // instead would expose their abutting end-cap faces (the white planes the + // merged union removes) — exactly what the commit doesn't show. + if (mergedMesh) mergedMesh.visible = !isMoving + if (segmentsWrapper) segmentsWrapper.visible = isReveal || isMoving const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined if (roofNode?.children?.length) { const wasActive = prevActiveRoofIds.current.has(roofId) + const wasMoving = prevMovingRoofIds.current.has(roofId) const wasReveal = prevRevealRoofIds.current.has(roofId) - if (isActive !== wasActive) { + if (isActive !== wasActive || isMoving !== wasMoving) { // Entering / exiting full edit mode: rebuild segment / merged - // geometries. Accessory-reveal doesn't need this — segments - // keep their placeholder; only their visibility flips. + // geometries. Segment-move reveal uses the same rebuild so any + // wrapper mesh previously stripped to an empty placeholder is + // restored before the drag begins. const { markDirty } = useScene.getState() for (const childId of roofNode.children) { markDirty(childId as AnyNodeId) @@ -139,8 +1871,14 @@ export const RoofEditSystem = () => { } prevActiveRoofIds.current = activeRoofIds + prevMovingRoofIds.current = movingRoofIds prevRevealRoofIds.current = revealRoofIds - }, [selectedIds]) + }, [movingNode, selectedIds]) - return null + return ( + <> + + + + ) } diff --git a/packages/editor/src/components/tools/elevator/elevator-tool.tsx b/packages/editor/src/components/tools/elevator/elevator-tool.tsx index 7410edb0..95fd2db0 100644 --- a/packages/editor/src/components/tools/elevator/elevator-tool.tsx +++ b/packages/editor/src/components/tools/elevator/elevator-tool.tsx @@ -9,11 +9,11 @@ import { resolveAlignment, useScene, } from '@pascal-app/core' -import { useAlignmentGuides } from '@pascal-app/editor' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support' import { sfxEmitter } from '../../../lib/sfx-bus' +import useAlignmentGuides from '../../../store/use-alignment-guides' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' import usePlacementPreview from '../../../store/use-placement-preview' import { CursorSphere } from '../shared/cursor-sphere' diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index f7dc1f92..0d76ef27 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -1,6 +1,7 @@ import { DEFAULT_ANGLE_STEP, FenceNode, + getTwoPointFenceCurveTangents, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, @@ -214,3 +215,44 @@ export function createFenceOnCurrentLevel( return fence } + +/** + * Commit a smooth spline fence from a list of drawn control points. The + * centerline becomes a Catmull-Rom curve through `path`; `start`/`end` are + * pinned to the first/last point so endpoint handles, bbox, and miter + * references stay valid. Requires >= 2 points spanning a usable distance. + */ +export function createSplineFenceOnCurrentLevel( + path: FencePlanPoint[], + tangents = getTwoPointFenceCurveTangents(path), +): FenceNode | null { + const currentLevelId = useViewer.getState().selection.levelId + const { createNode, nodes } = useScene.getState() + + if (!currentLevelId || path.length < 2) { + return null + } + const start = path[0]! + const end = path[path.length - 1]! + // A degenerate single-point-ish path (all clicks on one spot) is rejected + // the same way a too-short straight segment is. + if (!isSegmentLongEnough(start, end) && path.length < 3) { + return null + } + + const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length + const defaults = useEditor.getState().toolDefaults.fence ?? {} + const fence = FenceNode.parse({ + ...defaults, + name: `Fence ${fenceCount + 1}`, + start, + end, + path, + tangents, + }) + + createNode(fence, currentLevelId) + sfxEmitter.emit('sfx:structure-build') + + return fence +} diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 1737edbe..b88b188d 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -20,7 +20,6 @@ import { type WallEvent, type WallNode, } from '@pascal-app/core' -import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' @@ -47,7 +46,11 @@ import { import { EDITOR_LAYER } from '../../../lib/constants' import { formatLinearMeasurement } from '../../../lib/measurements' import { sfxEmitter } from '../../../lib/sfx-bus' -import { resolveAlignmentForActiveBuilding } from '../../../lib/world-grid-snap' +import { + projectAlignmentGuidesWorldToActiveBuildingLocal, + resolveAlignmentForActiveBuilding, +} from '../../../lib/world-grid-snap' +import useAlignmentGuides from '../../../store/use-alignment-guides' import useEditor, { isMagneticSnapActive } from '../../../store/use-editor' import useFacingPose from '../../../store/use-facing-pose' import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview' @@ -856,7 +859,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea alignX = ar.snap.dx alignZ = ar.snap.dz } - useAlignmentGuides.getState().set(ar.guides) + useAlignmentGuides + .getState() + .set(projectAlignmentGuidesWorldToActiveBuildingLocal(ar.guides)) } else { useAlignmentGuides.getState().clear() } diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index b8e1108f..67edda5d 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -23,7 +23,6 @@ import { useLiveTransforms, useScene, } from '@pascal-app/core' -import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' @@ -32,6 +31,7 @@ import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { sfxEmitter } from '../../../lib/sfx-bus' import { resolveSnapFlags } from '../../../lib/snapping-mode' +import useAlignmentGuides from '../../../store/use-alignment-guides' import useEditor, { getActiveSnappingMode, isMagneticSnapActive } from '../../../store/use-editor' import useFacingPose from '../../../store/use-facing-pose' import { swallowNextClick } from '../../editor/node-arrow-handles' diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/editor/src/components/tools/roof/roof-tool.tsx index 5cd769e3..f812dc1c 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/roof-tool.tsx @@ -1,4 +1,5 @@ import { + type AlignmentAnchor, type AnyNode, type AnyNodeId, collectAlignmentAnchors, @@ -7,33 +8,93 @@ import { type LevelNode, RoofNode, RoofSegmentNode, + resolveBuildingForLevel, sceneRegistry, - snapScalar, useScene, + type WallNode, + wallSegmentAnchors, } from '@pascal-app/core' -import { useAlignmentGuides } from '@pascal-app/editor' +import { clearSurfacePlanSnapFeedback, resolveSurfacePlanPointSnap } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' -import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three' +import { + BufferGeometry, + DoubleSide, + Float32BufferAttribute, + type Group, + type Line, + Vector3, +} from 'three' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' -import { - resolveAlignmentForActiveBuilding, - snapWorldXZForActiveBuilding, -} from '../../../lib/world-grid-snap' +import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_PITCH_DEG = 40 const GRID_OFFSET = 0.02 -/** Figma-style alignment-snap threshold (meters), matching the move tools. */ -const ALIGNMENT_THRESHOLD_M = 0.08 -function snapToActiveGrid(value: number): number { - return snapScalar(value, useEditor.getState().gridSnapStep) +// Walls that are direct children of a level. +function getLevelWalls( + levelId: string | null, + nodes: Readonly>, +): WallNode[] { + if (!levelId) return [] + const levelNode = nodes[levelId] + if (levelNode?.type !== 'level') return [] + return (levelNode as LevelNode).children + .map((childId) => nodes[childId]) + .filter((node): node is WallNode => node?.type === 'wall') +} + +// Walls on the level directly beneath the active one. Levels share the same +// local XZ origin (they only differ in world Y), so these walls live in the +// identical coordinate frame and feed straight into both the alignment pool +// and the magnetic wall-snap pipeline — letting a roof drawn on the upper +// floor snap onto the wall corners of the floor below. +function getBelowLevelWalls( + currentLevelId: string | null, + nodes: Readonly>, +): WallNode[] { + if (!currentLevelId) return [] + const currentLevel = nodes[currentLevelId] + if (currentLevel?.type !== 'level') return [] + const buildingId = resolveBuildingForLevel(currentLevel.id, nodes) + if (!buildingId) return [] + const building = nodes[buildingId] + if (building?.type !== 'building') return [] + const currentIndex = (currentLevel as LevelNode).level + const belowLevel = (building.children ?? []) + .map((childId) => nodes[childId]) + .filter((node): node is LevelNode => node?.type === 'level' && node.level < currentIndex) + .sort((a, b) => b.level - a.level)[0] + return getLevelWalls(belowLevel?.id ?? null, nodes) +} + +// Current-level + floor-below walls — the magnetic snap targets the roof draft +// locks onto (corners, midpoints, crossings, wall bodies), matching the wall +// tool. Same coordinate frame, so no transform is needed. +function getRoofSnapWalls( + currentLevelId: string | null, + nodes: Readonly>, +): WallNode[] { + return [...getLevelWalls(currentLevelId, nodes), ...getBelowLevelWalls(currentLevelId, nodes)] +} + +// Current-level alignment anchors plus the floor-below wall corners. +function collectRoofAlignmentAnchors( + nodes: Readonly>, + currentLevelId: string | null, +): AlignmentAnchor[] { + return [ + ...collectAlignmentAnchors(nodes, '', currentLevelId), + ...getBelowLevelWalls(currentLevelId, nodes).flatMap((wall) => + wallSegmentAnchors(wall.id, wall.start, wall.end, wall.thickness), + ), + ] } /** @@ -148,14 +209,168 @@ type PreviewState = { levelY: number } +function buildRoofGhostGeometry( + width: number, + depth: number, + wallHeight: number, + pitchDeg: number, +) { + const safeWidth = Math.max(width, 0.1) + const safeDepth = Math.max(depth, 0.1) + const halfWidth = safeWidth / 2 + const halfDepth = safeDepth / 2 + const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth + + const vertices = [ + // Front slope + -halfWidth, + wallHeight, + -halfDepth, + halfWidth, + wallHeight, + -halfDepth, + halfWidth, + ridgeHeight, + 0, + + -halfWidth, + wallHeight, + -halfDepth, + halfWidth, + ridgeHeight, + 0, + -halfWidth, + ridgeHeight, + 0, + + // Back slope + -halfWidth, + ridgeHeight, + 0, + halfWidth, + ridgeHeight, + 0, + halfWidth, + wallHeight, + halfDepth, + + -halfWidth, + ridgeHeight, + 0, + halfWidth, + wallHeight, + halfDepth, + -halfWidth, + wallHeight, + halfDepth, + + // Left gable + -halfWidth, + wallHeight, + -halfDepth, + -halfWidth, + ridgeHeight, + 0, + -halfWidth, + wallHeight, + halfDepth, + + // Right gable + halfWidth, + wallHeight, + -halfDepth, + halfWidth, + wallHeight, + halfDepth, + halfWidth, + ridgeHeight, + 0, + ] + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)) + geometry.computeVertexNormals() + return geometry +} + +function buildRoofGhostEdges(width: number, depth: number, wallHeight: number, pitchDeg: number) { + const safeWidth = Math.max(width, 0.1) + const safeDepth = Math.max(depth, 0.1) + const halfWidth = safeWidth / 2 + const halfDepth = safeDepth / 2 + const ridgeHeight = wallHeight + Math.tan((pitchDeg * Math.PI) / 180) * halfDepth + + const vertices = [ + // Base rectangle + -halfWidth, + wallHeight, + -halfDepth, + halfWidth, + wallHeight, + -halfDepth, + halfWidth, + wallHeight, + -halfDepth, + halfWidth, + wallHeight, + halfDepth, + halfWidth, + wallHeight, + halfDepth, + -halfWidth, + wallHeight, + halfDepth, + -halfWidth, + wallHeight, + halfDepth, + -halfWidth, + wallHeight, + -halfDepth, + + // Ridge + gable edges + -halfWidth, + ridgeHeight, + 0, + halfWidth, + ridgeHeight, + 0, + -halfWidth, + wallHeight, + -halfDepth, + -halfWidth, + ridgeHeight, + 0, + -halfWidth, + ridgeHeight, + 0, + -halfWidth, + wallHeight, + halfDepth, + halfWidth, + wallHeight, + -halfDepth, + halfWidth, + ridgeHeight, + 0, + halfWidth, + ridgeHeight, + 0, + halfWidth, + wallHeight, + halfDepth, + ] + + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)) + return geometry +} + export const RoofTool: React.FC = () => { const cursorRef = useRef(null) const outlineRef = useRef(null!) const currentLevelId = useViewer((state) => state.selection.levelId) const selectedIds = useViewer((state) => state.selection.selectedIds) const setSelection = useViewer((state) => state.setSelection) - const setTool = useEditor((state) => state.setTool) - const setMode = useEditor((state) => state.setMode) const selectedIdsRef = useRef(selectedIds) useEffect(() => { @@ -179,44 +394,38 @@ export const RoofTool: React.FC = () => { outlineRef.current.geometry = new BufferGeometry() - // Alignment candidates — anchors of every alignable object; refreshed - // after each roof commits. Both corners of the rectangle align. - let alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) - // Snap the drafted corner onto another object's nearest real anchor and - // publish the guide. The probe is the RAW cursor, NOT the 0.5m-grid-snapped - // point: resolving against the grid point would only ever catch anchors - // that happen to sit on a grid line, so off-grid items (furniture, angled - // walls) would never surface a guide. The matched axis locks exactly to the - // candidate's coordinate; the other axis keeps its grid snap. Alignment runs - // only when the magnetic (lines) snapping mode is active. - const alignPoint = ( - gridX: number, - gridZ: number, - rawX: number, - rawZ: number, - bypass: boolean, - ): [number, number] => { - if (bypass || alignmentCandidates.length === 0) { - useAlignmentGuides.getState().clear() - return [gridX, gridZ] - } - const ar = resolveAlignmentForActiveBuilding({ - moving: [{ nodeId: '__roof-draft__', kind: 'corner', x: rawX, z: rawZ }], + // Alignment candidates — anchors of every alignable object on the active + // level plus the wall corners of the floor directly below, so a roof drawn + // on the upper floor aligns to the walls beneath it. Refreshed after each + // roof commits. Both corners of the rectangle align. + let alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId) + + // Resolve a grid:move/click into the drafted corner via the shared surface + // snap pipeline: magnetic lock onto wall corners / midpoints / crossings / + // bodies on the active level + floor below (raising the green beacon), + // falling back to alignment guides, then to the world-grid snap. The same + // path the slab/ceiling tools use, so the beacon and coloring match. The + // pipeline reads the snapping mode itself (Shift bypass, magnetic on/off), + // so this tool never inspects the flags. `levelId` is intentionally omitted + // so the explicit floor-below `walls` aren't filtered back out. + const resolveDraftPoint = (event: GridEvent): [number, number] => { + const rawPoint: [number, number] = [event.localPosition[0], event.localPosition[2]] + const gridFallback: [number, number] = isGridSnapActive() + ? snapWorldXZForActiveBuilding( + event.position[0], + event.position[2], + useEditor.getState().gridSnapStep, + ).local + : rawPoint + const nodes = useScene.getState().nodes + return resolveSurfacePlanPointSnap({ + rawPoint, + fallbackPoint: gridFallback, + walls: getRoofSnapWalls(currentLevelId, nodes), candidates: alignmentCandidates, - threshold: ALIGNMENT_THRESHOLD_M, - }) - if (ar.guides.length === 0) { - useAlignmentGuides.getState().clear() - return [gridX, gridZ] - } - useAlignmentGuides.getState().set(ar.guides) - let x = gridX - let z = gridZ - for (const guide of ar.guides) { - if (guide.axis === 'x') x = guide.coord - else z = guide.coord - } - return [x, z] + movingId: '__roof-draft__', + highlightWalls: true, + }).point } const updateOutline = ( @@ -241,24 +450,7 @@ export const RoofTool: React.FC = () => { const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return - // World-grid snap projected into building-local; rotated buildings - // used to drag every roof corner off the visible grid. Snapping follows - // the global mode (grid quantize / lines alignment); Off keeps the raw - // cursor. Shift cycles the mode centrally — this tool never reads it. - const snapped: [number, number] = isGridSnapActive() - ? snapWorldXZForActiveBuilding( - event.position[0], - event.position[2], - useEditor.getState().gridSnapStep, - ).local - : [event.localPosition[0], event.localPosition[2]] - const [gridX, gridZ] = alignPoint( - snapped[0], - snapped[1], - event.localPosition[0], - event.localPosition[2], - !isMagneticSnapActive(), - ) + const [gridX, gridZ] = resolveDraftPoint(event) const y = event.localPosition[1] const cursorPosition: [number, number, number] = [gridX, y, gridZ] @@ -291,23 +483,7 @@ export const RoofTool: React.FC = () => { const onGridClick = (event: GridEvent) => { if (!currentLevelId) return - // World-grid snap projected into building-local; rotated buildings - // used to drag every roof corner off the visible grid. Snapping follows - // the global mode; Off keeps the raw cursor. - const snapped: [number, number] = isGridSnapActive() - ? snapWorldXZForActiveBuilding( - event.position[0], - event.position[2], - useEditor.getState().gridSnapStep, - ).local - : [event.localPosition[0], event.localPosition[2]] - const [gridX, gridZ] = alignPoint( - snapped[0], - snapped[1], - event.localPosition[0], - event.localPosition[2], - !isMagneticSnapActive(), - ) + const [gridX, gridZ] = resolveDraftPoint(event) const y = event.localPosition[1] if (corner1Ref.current) { @@ -322,8 +498,8 @@ export const RoofTool: React.FC = () => { corner1Ref.current = null outlineRef.current.visible = false - alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '', currentLevelId) - useAlignmentGuides.getState().clear() + alignmentCandidates = collectRoofAlignmentAnchors(useScene.getState().nodes, currentLevelId) + clearSurfacePlanSnapFeedback() } else { corner1Ref.current = [gridX, y, gridZ] sfxEmitter.emit('sfx:structure-build-start') @@ -341,7 +517,7 @@ export const RoofTool: React.FC = () => { outlineRef.current.visible = false setPreview((prev) => ({ ...prev, corner1: null })) } - useAlignmentGuides.getState().clear() + clearSurfacePlanSnapFeedback() } emitter.on('grid:move', onGridMove) @@ -352,7 +528,7 @@ export const RoofTool: React.FC = () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) emitter.off('tool:cancel', onCancel) - useAlignmentGuides.getState().clear() + clearSurfacePlanSnapFeedback() corner1Ref.current = null } @@ -369,6 +545,34 @@ export const RoofTool: React.FC = () => { return { length, width, centerX, centerZ } }, [corner1, cursorPosition]) + const roofGhostGeometry = useMemo(() => { + if (!previewDimensions) return null + return buildRoofGhostGeometry( + previewDimensions.length, + previewDimensions.width, + DEFAULT_WALL_HEIGHT, + DEFAULT_PITCH_DEG, + ) + }, [previewDimensions]) + + const roofGhostEdges = useMemo(() => { + if (!previewDimensions) return null + return buildRoofGhostEdges( + previewDimensions.length, + previewDimensions.width, + DEFAULT_WALL_HEIGHT, + DEFAULT_PITCH_DEG, + ) + }, [previewDimensions]) + + useEffect( + () => () => { + roofGhostGeometry?.dispose() + roofGhostEdges?.dispose() + }, + [roofGhostEdges, roofGhostGeometry], + ) + return ( @@ -402,21 +606,34 @@ export const RoofTool: React.FC = () => { )} {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && ( - - - - + {roofGhostGeometry && ( + + + + )} + {roofGhostEdges && ( + + + + )} + )} ) diff --git a/packages/editor/src/components/tools/select/select-candidates.ts b/packages/editor/src/components/tools/select/select-candidates.ts index 2f91b80c..62396af7 100644 --- a/packages/editor/src/components/tools/select/select-candidates.ts +++ b/packages/editor/src/components/tools/select/select-candidates.ts @@ -50,7 +50,7 @@ export function collectSelectableCandidateIds(): string[] { if (!levelId) return [] const levelNode = nodes[levelId as AnyNodeId] as LevelNode | undefined - if (!levelNode || levelNode.type !== 'level') return [] + if (levelNode?.type !== 'level') return [] if (phase === 'structure' && structureLayer === 'zones') { for (const childId of levelNode.children) { diff --git a/packages/editor/src/components/tools/shared/cursor-sphere.tsx b/packages/editor/src/components/tools/shared/cursor-sphere.tsx index d403842a..2fb10094 100644 --- a/packages/editor/src/components/tools/shared/cursor-sphere.tsx +++ b/packages/editor/src/components/tools/shared/cursor-sphere.tsx @@ -6,6 +6,7 @@ import { furnishTools } from '../../../components/ui/action-menu/furnish-tools' import { tools } from '../../../components/ui/action-menu/structure-tools' import { EDITOR_LAYER } from '../../../lib/constants' import useEditor from '../../../store/use-editor' +import useWallSnapIndicator from '../../../store/use-wall-snap-indicator' interface CursorSphereProps extends Omit { color?: string @@ -40,6 +41,10 @@ export const CursorSphere = forwardRef(function Cursor const mode = useEditor((s) => s.mode) const catalogCategory = useEditor((s) => s.catalogCategory) const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) + // While a wall snap is active the beacon layer already marks the point + // (green square at a corner); hide the cursor's ground dot/ring so it + // doesn't sit on top of that glyph. + const isSnapping = useWallSnapIndicator((s) => s.point !== null) // Find the icon for the current tool let activeToolConfig = null @@ -58,33 +63,35 @@ export const CursorSphere = forwardRef(function Cursor {/* Flat marker on the ground. The bright center dot moves to the tip of the line in `dotAtTip` mode (the placement point hangs above the floor), leaving a faint ring here so the plan position stays read. */} - - {/* Center dot — at the ground unless the placement point is elevated */} - {!dotAtTip && ( + {!isSnapping && ( + + {/* Center dot — at the ground unless the placement point is elevated */} + {!dotAtTip && ( + + + + + )} + + {/* Outer ring / glow */} - + - )} - - {/* Outer ring / glow */} - - - - - + + )} {/* Vertical line */} {height > 0 && ( diff --git a/packages/editor/src/components/tools/stair/stair-defaults.ts b/packages/editor/src/components/tools/stair/stair-defaults.ts index 572d9d9e..a81fa0d0 100644 --- a/packages/editor/src/components/tools/stair/stair-defaults.ts +++ b/packages/editor/src/components/tools/stair/stair-defaults.ts @@ -6,6 +6,7 @@ export const DEFAULT_STAIR_STEP_COUNT = 10 export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const export const DEFAULT_STAIR_FILL_TO_FLOOR = true export const DEFAULT_STAIR_THICKNESS = 0.25 +export const DEFAULT_STAIR_OPENING_OFFSET = 0 export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9 export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2 export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180 diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 3daf31cd..cb54f526 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -14,7 +14,6 @@ import { syncAutoStairOpenings, useScene, } from '@pascal-app/core' -import { useAlignmentGuides } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' @@ -23,6 +22,7 @@ import { resolveStairDestinationLevel, resolveStairPlacementLevelId, } from '../../../lib/stair-levels' +import useAlignmentGuides from '../../../store/use-alignment-guides' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' import useFacingPose from '../../../store/use-facing-pose' import { CursorSphere } from '../shared/cursor-sphere' @@ -38,6 +38,7 @@ import { DEFAULT_STAIR_FILL_TO_FLOOR, DEFAULT_STAIR_HEIGHT, DEFAULT_STAIR_LENGTH, + DEFAULT_STAIR_OPENING_OFFSET, DEFAULT_STAIR_RAILING_HEIGHT, DEFAULT_STAIR_RAILING_MODE, DEFAULT_STAIR_STEP_COUNT, @@ -140,7 +141,7 @@ function createDefaultStairNode({ fromLevelId: levelId, toLevelId: nextLevelId, slabOpeningMode: 'destination', - openingOffset: 0.08, + openingOffset: DEFAULT_STAIR_OPENING_OFFSET, width: DEFAULT_STAIR_WIDTH, totalRise: DEFAULT_STAIR_HEIGHT, stepCount: DEFAULT_STAIR_STEP_COUNT, diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 8c3832b7..26a6d606 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -12,11 +12,13 @@ import { useViewer } from '@pascal-app/viewer' import { type ComponentType, lazy, Suspense, useMemo } from 'react' import useEditor, { type Phase, type Tool } from '../../store/use-editor' import { + useControlPointReshape, useEditingHole, useEndpointReshape, useIsCurveReshape, useMovingNode, useReshapingNode, + useTangentReshape, } from '../../store/use-interaction-scope' import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer' import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer' @@ -68,6 +70,8 @@ export const ToolManager: React.FC = () => { const movingNode = useMovingNode() const movingNodeOrigin = useEditor((state) => state.movingNodeOrigin) const endpointReshape = useEndpointReshape() + const controlPointReshape = useControlPointReshape() + const tangentReshape = useTangentReshape() const isCurveReshape = useIsCurveReshape() const reshapingNode = useReshapingNode() // The endpoint affordance tool's `target` is kind-specific @@ -81,6 +85,18 @@ export const ToolManager: React.FC = () => { ? { fence: reshapingNode as FenceNode, endpoint: endpointReshape.endpoint } : { wall: reshapingNode as WallNode, endpoint: endpointReshape.endpoint } }, [endpointReshape, reshapingNode]) + const controlPointTarget = useMemo(() => { + if (!(controlPointReshape && reshapingNode?.type === 'fence')) return null + return { fence: reshapingNode as FenceNode, index: controlPointReshape.index } + }, [controlPointReshape, reshapingNode]) + const tangentTarget = useMemo(() => { + if (!(tangentReshape && reshapingNode?.type === 'fence')) return null + return { + fence: reshapingNode as FenceNode, + index: tangentReshape.index, + side: tangentReshape.side, + } + }, [reshapingNode, tangentReshape]) const editingHole = useEditingHole() const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedIds = useViewer((state) => state.selection.selectedIds) @@ -271,6 +287,24 @@ export const ToolManager: React.FC = () => { ) : null })()} + {controlPointTarget && + (() => { + const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-control-point') + return RegistryAffordance ? ( + + + + ) : null + })()} + {tangentTarget && + (() => { + const RegistryAffordance = getRegistryAffordanceTool('fence', 'move-tangent') + return RegistryAffordance ? ( + + + + ) : null + })()} {showMover && movingNode.type !== 'building' && ( void @@ -82,7 +85,9 @@ function ChipRow({ ) if (!onClick) { - return
{body}
+ return ( +
{body}
+ ) } const button = ( @@ -91,6 +96,7 @@ function ChipRow({ className={cn( ROW_CLASS, 'pointer-events-auto cursor-pointer items-center rounded-md text-left transition-colors hover:bg-muted/60', + disabled && 'opacity-45 saturate-0', )} onClick={onClick} type="button" @@ -181,6 +187,59 @@ function ContinuationChip({ context }: { context: ContinuationContext }) { ) } +function FenceContinuationChips() { + const mode = useEditor((s) => s.getContinuation('fence')) + const setContinuation = useEditor((s) => s.setContinuation) + const curveStarted = useFenceCurveDraft((s) => s.pointCount > 0) + + const isCurved = mode === 'curved' + const straightMode = isCurved ? 'continuous' : mode + const straightLabel = straightMode === 'single' ? 'Straight: Single' : 'Straight: Continuous' + const straightIcon = straightMode === 'single' ? 'lucide:minus' : 'lucide:waypoints' + const typeLabel = isCurved ? 'Type: Curved' : 'Type: Straight' + const typeIcon = isCurved ? 'lucide:spline' : 'lucide:minus' + + return ( + <> + setContinuation('fence', isCurved ? 'continuous' : 'curved')} + shortcut="T" + tooltip="Fence type — click or press T to switch between straight and curved" + /> + setContinuation('fence', straightMode === 'single' ? 'continuous' : 'single') + } + shortcut="C" + tooltip={ + isCurved + ? 'Straight continuation is unavailable while curved fence type is active' + : 'Straight fence continuation — click or press C to toggle' + } + /> + {/* Curved fences are committed by a closing gesture rather than per-click, + so the finish keys aren't discoverable on their own — surface them, but + only once the user has placed a point and a curve is actually in flight. */} + {isCurved && curveStarted ? ( + + ) : null} + + ) +} + const PAINT_SCOPE_ICONS: Record = { single: 'lucide:square', object: 'lucide:box', @@ -260,7 +319,10 @@ export function ContextualHelperPanel({ return (
{snapContext ? : null} - {continuationContext ? : null} + {continuationContext === 'fence' ? : null} + {continuationContext && continuationContext !== 'fence' ? ( + + ) : null} {showPaintScope ? : null} {hints.map((hint) => (
(null) // The whole panel is collapsed to just its header by default; the chevron - // expands it to reveal the inspector body. - const [collapsed, setCollapsed] = useState(true) + // expands it to reveal the inspector body. Keep the desktop value shared + // across inspector swaps (roof ↔ segment, etc.) so navigating between + // related panels preserves whether the user left the inspector open. + const [collapsed, setCollapsedState] = useState(desktopInspectorCollapsed) + + const setCollapsed = useCallback( + (next: boolean | ((previous: boolean) => boolean)) => { + setCollapsedState((previous) => { + const resolved = typeof next === 'function' ? next(previous) : next + desktopInspectorCollapsed = resolved + return resolved + }) + }, + [], + ) // Drag-to-reposition from the header. `offset` is a translation applied on // top of the default `top-20 right-4` anchor; null until first dragged. diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx index eb23ca4d..02795c58 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx @@ -28,6 +28,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({ const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) + const setRoofHostDragArmedId = useEditor((state) => state.setRoofHostDragArmedId) const { drag, dropTarget } = useTreeNodeDrag() const segments = useScene( @@ -57,8 +58,11 @@ export const RoofTreeNode = memo(function RoofTreeNode({ if (!handled && useEditor.getState().phase === 'furnish') { useEditor.getState().setPhase('structure') } + if (!e.metaKey && !e.ctrlKey && !e.shiftKey) { + setRoofHostDragArmedId(nodeId) + } }, - [nodeId, setSelection], + [nodeId, setRoofHostDragArmedId, setSelection], ) const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 78657feb..7c46a116 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -157,6 +157,7 @@ const treeNodeByType: Record< 'turbine-vent': RegistryTreeNode, cupola: RegistryTreeNode, 'eyebrow-vent': RegistryTreeNode, + skylight: RegistryTreeNode, roof: RoofTreeNode, stair: StairTreeNode, door: DoorTreeNode, diff --git a/packages/editor/src/hooks/use-ceiling-events.ts b/packages/editor/src/hooks/use-ceiling-events.ts index 27105f8b..730db810 100644 --- a/packages/editor/src/hooks/use-ceiling-events.ts +++ b/packages/editor/src/hooks/use-ceiling-events.ts @@ -86,7 +86,7 @@ export function useCeilingEvents() { for (const id of ceilingIds) { const node = nodes[id as AnyNodeId] as CeilingNode | undefined - if (!node || node.type !== 'ceiling' || node.polygon.length < 3) continue + if (node?.type !== 'ceiling' || node.polygon.length < 3) continue if (resolveLevelId(node, nodes) !== activeLevelId) continue const mesh = sceneRegistry.nodes.get(id) if (!mesh) continue diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 47aee91a..e5b60275 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -92,6 +92,26 @@ export const useKeyboard = ({ return } + if ( + (e.key === 't' || e.key === 'T') && + !e.repeat && + !e.metaKey && + !e.ctrlKey && + !e.shiftKey && + !e.altKey + ) { + const context = getActiveContinuationContext() + if (context === 'fence') { + e.preventDefault() + const current = useEditor.getState().getContinuation('fence') + useEditor + .getState() + .setContinuation('fence', current === 'curved' ? 'continuous' : 'curved') + sfxEmitter.emit('sfx:grid-snap') + return + } + } + if ( (e.key === 'c' || e.key === 'C') && !e.repeat && @@ -103,6 +123,16 @@ export const useKeyboard = ({ const context = getActiveContinuationContext() if (context) { e.preventDefault() + if (context === 'fence') { + const current = useEditor.getState().getContinuation('fence') + if (current !== 'curved') { + useEditor + .getState() + .setContinuation('fence', current === 'single' ? 'continuous' : 'single') + sfxEmitter.emit('sfx:grid-snap') + } + return + } useEditor.getState().cycleContinuation(context) sfxEmitter.emit('sfx:grid-snap') return diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 4be6aba7..f4d4717b 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -80,6 +80,7 @@ export { // shared/segment-angle.ts) once every Stage D port is in. export { createFenceOnCurrentLevel, + createSplineFenceOnCurrentLevel, type FencePlanPoint, snapFenceDraftPoint, } from './components/tools/fence/fence-drafting' @@ -353,7 +354,9 @@ export { export { cn } from './lib/utils' export { getActiveBuildingPose, + projectAlignmentGuidesWorldToActiveBuildingLocal, resolveAlignmentForActiveBuilding, + resolveAlignmentForFloorplanView, snapBuildingLocalToWorldGrid, snapWorldXZForActiveBuilding, } from './lib/world-grid-snap' @@ -377,6 +380,7 @@ export { isMagneticSnapActive, } from './store/use-editor' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' +export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' export { default as useInteractionScope, getEditingHole, diff --git a/packages/editor/src/lib/continuation.ts b/packages/editor/src/lib/continuation.ts index 1a34edbe..e4899bc6 100644 --- a/packages/editor/src/lib/continuation.ts +++ b/packages/editor/src/lib/continuation.ts @@ -17,10 +17,18 @@ export const CONTINUATION_PROFILES: Record< icons: { room: 'lucide:square', single: 'lucide:minus' }, }, fence: { - options: ['continuous', 'single'], + options: ['single', 'continuous', 'curved'], default: 'continuous', - labels: { continuous: 'Continuous', single: 'Single fence' }, - icons: { continuous: 'lucide:waypoints', single: 'lucide:minus' }, + labels: { + continuous: 'Continuous', + single: 'Single fence', + curved: 'Curved fence', + }, + icons: { + continuous: 'lucide:waypoints', + single: 'lucide:minus', + curved: 'lucide:spline', + }, }, point: { options: ['once', 'repeat'], diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 61297d99..188cb84b 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -71,6 +71,19 @@ describe('canDirectMoveNode', () => { expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) }) + test('rejects MEP kinds that own move through bespoke selection affordances', () => { + for (const kind of [ + 'duct-segment', + 'duct-fitting', + 'pipe-segment', + 'pipe-fitting', + 'lineset', + 'liquid-line', + ]) { + expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) + } + }) + test('accepts kinds with a bespoke move tool', () => { const kind = 'direct-move-bespoke-tool-test' registerTestDefinition(kind, { diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index db2649a7..3f5139a2 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -34,7 +34,22 @@ export function canDirectRotateNode(node: AnyNode): boolean { ) } +const BESPOKE_SELECTION_MOVE_KINDS = new Set([ + 'duct-segment', + 'duct-fitting', + 'pipe-segment', + 'pipe-fitting', + 'lineset', + 'liquid-line', +]) + export function canDirectMoveNode(node: AnyNode): boolean { + // These MEP kinds own move through bespoke selection rigs (latch cubes, + // directional arrows, grid-driven previews). Sending body drags/clicks + // through the generic direct-move handoff conflicts with that path and can + // leave the editor appearing frozen while their mover waits for the wrong + // gesture stream. + if (BESPOKE_SELECTION_MOVE_KINDS.has(node.type)) return false // 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that // mounts in 3D — distinct from `isRegistryMovable`, which also accepts // floorplan-only movers (zone) for the 2D plan. diff --git a/packages/editor/src/lib/editor-api.ts b/packages/editor/src/lib/editor-api.ts index c18d2903..1c73ddc9 100644 --- a/packages/editor/src/lib/editor-api.ts +++ b/packages/editor/src/lib/editor-api.ts @@ -1,7 +1,11 @@ import type { AnyNode, EditorApi } from '@pascal-app/core' import useEditor from '../store/use-editor' import useInteractionScope from '../store/use-interaction-scope' -import { endpointReshapeScope } from './interaction/scope' +import { + controlPointReshapeScope, + endpointReshapeScope, + tangentReshapeScope, +} from './interaction/scope' /** * Concrete {@link EditorApi} backed by `useEditor` + the interaction scope. @@ -36,5 +40,11 @@ export function createEditorApi(): EditorApi { // endpoint-draggable kind needs no entry here. useInteractionScope.getState().begin(endpointReshapeScope(node.id, endpoint)) }, + engageControlPointMove(node: AnyNode, index: number) { + useInteractionScope.getState().begin(controlPointReshapeScope(node.id, index)) + }, + engageTangentMove(node: AnyNode, index: number, side: 'in' | 'out') { + useInteractionScope.getState().begin(tangentReshapeScope(node.id, index, side)) + }, } } diff --git a/packages/editor/src/lib/floorplan/apply-alignment.ts b/packages/editor/src/lib/floorplan/apply-alignment.ts index 6c8349ab..50fa215d 100644 --- a/packages/editor/src/lib/floorplan/apply-alignment.ts +++ b/packages/editor/src/lib/floorplan/apply-alignment.ts @@ -2,10 +2,10 @@ import { type AlignmentAnchor, type AlignmentGuide, collectAlignmentAnchors, - resolveAlignment, useScene, } from '@pascal-app/core' -import { useAlignmentGuides } from '@pascal-app/editor' +import useAlignmentGuides from '../../store/use-alignment-guides' +import { resolveAlignmentForFloorplanView } from '../world-grid-snap' /** * Fixed Figma-style alignment threshold (meters) for floor-plan placement / @@ -49,7 +49,7 @@ export function applyFloorplanAlignment( return { point: [point[0], point[1]], snapped: false, guides: [] } } - const result = resolveAlignment({ + const result = resolveAlignmentForFloorplanView({ moving: movingAnchors, candidates, threshold: opts?.threshold ?? FLOORPLAN_ALIGNMENT_THRESHOLD_M, diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 31594338..4d58f1c5 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -570,7 +570,7 @@ function bakeSwingDoorClip( doorObject.traverse((object) => { const marker = object.userData.pascalSwingLeaf as SwingLeafMarker | undefined - if (!marker || marker.axis !== 'y') return + if (marker?.axis !== 'y') return object.rotation.y = 0 const closed = object.quaternion.clone() diff --git a/packages/editor/src/lib/interaction/scope.ts b/packages/editor/src/lib/interaction/scope.ts index d4b972fd..796fdcea 100644 --- a/packages/editor/src/lib/interaction/scope.ts +++ b/packages/editor/src/lib/interaction/scope.ts @@ -18,7 +18,7 @@ export type InteractionView = '2d' | '3d' // node, one in-flight reshape. Grouping them as sub-states of `reshaping` // (rather than four sibling scopes) keeps the union small while still making // "curving and hole-editing at once" unrepresentable. -export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' +export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' | 'control-point' | 'tangent' export type InteractionScope = | { kind: 'idle' } @@ -49,6 +49,8 @@ export type InteractionScope = reshape: ReshapeKind holeIndex?: number endpoint?: 'start' | 'end' + index?: number + side?: 'in' | 'out' } // Marquee selection drag. | { kind: 'box-select' } @@ -151,6 +153,27 @@ export function endpointReshapeInfo( : null } +export function controlPointReshapeInfo( + scope: InteractionScope, +): { nodeId: string; index: number } | null { + return scope.kind === 'reshaping' && + scope.reshape === 'control-point' && + scope.index !== undefined + ? { nodeId: scope.nodeId, index: scope.index } + : null +} + +export function tangentReshapeInfo( + scope: InteractionScope, +): { nodeId: string; index: number; side: 'in' | 'out' } | null { + return scope.kind === 'reshaping' && + scope.reshape === 'tangent' && + scope.index !== undefined && + scope.side !== undefined + ? { nodeId: scope.nodeId, index: scope.index, side: scope.side } + : null +} + // The id of the node being reshaped (any reshape kind), for the scene lookup // that recovers the full node payload a few consumers still need. export function reshapingNodeId(scope: InteractionScope): string | null { @@ -169,6 +192,18 @@ export function endpointReshapeScope( return { kind: 'reshaping', nodeId, reshape: 'endpoint', endpoint } } +export function controlPointReshapeScope(nodeId: string, index: number): ActiveInteractionScope { + return { kind: 'reshaping', nodeId, reshape: 'control-point', index } +} + +export function tangentReshapeScope( + nodeId: string, + index: number, + side: 'in' | 'out', +): ActiveInteractionScope { + return { kind: 'reshaping', nodeId, reshape: 'tangent', index, side } +} + // Dragging a polygon vertex/edge (slab / ceiling boundary). Drives the snapping // HUD (no-angle 'polygon' set) and keeps the idle select hints off-screen. export function boundaryReshapeScope(nodeId: string): ActiveInteractionScope { diff --git a/packages/editor/src/lib/level-selection.ts b/packages/editor/src/lib/level-selection.ts index aed583a8..94cdc585 100644 --- a/packages/editor/src/lib/level-selection.ts +++ b/packages/editor/src/lib/level-selection.ts @@ -5,10 +5,10 @@ import { useViewer } from '@pascal-app/viewer' function getAdjacentLevelIdForDeletion(levelId: AnyNodeId): LevelNode['id'] | null { const { nodes } = useScene.getState() const level = nodes[levelId] - if (!level || level.type !== 'level' || !level.parentId) return null + if (level?.type !== 'level' || !level.parentId) return null const building = nodes[level.parentId as AnyNodeId] - if (!building || building.type !== 'building') return null + if (building?.type !== 'building') return null const siblingLevelIds = (building as BuildingNode).children.filter( (childId): childId is LevelNode['id'] => nodes[childId as AnyNodeId]?.type === 'level', diff --git a/packages/editor/src/lib/roof-duplication.ts b/packages/editor/src/lib/roof-duplication.ts index d46998ce..379b556f 100644 --- a/packages/editor/src/lib/roof-duplication.ts +++ b/packages/editor/src/lib/roof-duplication.ts @@ -80,7 +80,7 @@ export function duplicateRoofSubtree( const scene = useScene.getState() const sourceRoof = scene.nodes[sourceRoofId] - if (!sourceRoof || sourceRoof.type !== 'roof') { + if (sourceRoof?.type !== 'roof') { throw new Error(`Node "${sourceRoofId}" is not a roof`) } @@ -105,7 +105,7 @@ export function duplicateRoofSubtree( const segmentClones: RoofSegmentNode[] = [] for (const childId of sourceRoof.children ?? []) { const childNode = scene.nodes[childId as AnyNodeId] - if (!childNode || childNode.type !== 'roof-segment') { + if (childNode?.type !== 'roof-segment') { continue } @@ -125,7 +125,7 @@ export function duplicateRoofSubtree( const nextScene = useScene.getState() const createdRoof = nextScene.nodes[roofClone.id as AnyNodeId] - if (!createdRoof || createdRoof.type !== 'roof') { + if (createdRoof?.type !== 'roof') { throw new Error(`Duplicated roof "${roofClone.id}" was not created`) } @@ -151,7 +151,7 @@ export function duplicateRoofSubtree( const invalidSegment = segmentIds.find((segmentId) => { const segment = nextScene.nodes[segmentId as AnyNodeId] - return !segment || segment.type !== 'roof-segment' || segment.parentId !== createdRoof.id + return segment?.type !== 'roof-segment' || segment.parentId !== createdRoof.id }) if (invalidSegment) { throw new Error( @@ -179,7 +179,7 @@ export function clearRoofDuplicateMetadata( ) { const scene = useScene.getState() const roofNode = scene.nodes[roofId] - if (!roofNode || roofNode.type !== 'roof') { + if (roofNode?.type !== 'roof') { return } @@ -198,7 +198,7 @@ export function clearRoofDuplicateMetadata( for (const childId of roofNode.children ?? []) { const childNode = scene.nodes[childId as AnyNodeId] - if (!childNode || childNode.type !== 'roof-segment') { + if (childNode?.type !== 'roof-segment') { continue } diff --git a/packages/editor/src/lib/roof-hover-outline-proxy.ts b/packages/editor/src/lib/roof-hover-outline-proxy.ts new file mode 100644 index 00000000..fcb4a169 --- /dev/null +++ b/packages/editor/src/lib/roof-hover-outline-proxy.ts @@ -0,0 +1,22 @@ +import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' +import type * as THREE from 'three' + +export const HOVERED_ROOF_SEGMENT_OUTLINE_PROXY_NAME = '__roof-hover-outline-proxy__' + +function hoveredRoofSegmentOutlineProxyName(segmentId: string) { + return `${HOVERED_ROOF_SEGMENT_OUTLINE_PROXY_NAME}:${segmentId}` +} + +export function getHoveredRoofSegmentOutlineProxy(segmentId: string): THREE.Object3D | null { + const segment = useScene.getState().nodes[segmentId as AnyNodeId] + if (!(segment?.type === 'roof-segment' && segment.parentId)) return null + return ( + sceneRegistry.nodes + .get(segment.parentId) + ?.getObjectByName(hoveredRoofSegmentOutlineProxyName(segmentId)) ?? null + ) +} + +export function getHoveredRoofSegmentOutlineProxyName(segmentId: string): string { + return hoveredRoofSegmentOutlineProxyName(segmentId) +} diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index 4cbbef3a..b40f96ac 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -114,7 +114,7 @@ function getNextLevelId(level: LevelNode, nodes: Record) { if (!parentId) return null const building = nodes[parentId] - if (!building || building.type !== 'building') return null + if (building?.type !== 'building') return null const siblingLevels = building.children .map((childId) => nodes[childId as AnyNodeId]) diff --git a/packages/editor/src/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts index fb1e4c7d..2e7f65d8 100644 --- a/packages/editor/src/lib/selection-routing.test.ts +++ b/packages/editor/src/lib/selection-routing.test.ts @@ -4,6 +4,7 @@ import { resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, selectionModifiersFromEvent, + shouldPreserveSelectedRoofHostTarget, } from './selection-routing' describe('resolveSelectedIdsForNodeClick', () => { @@ -77,3 +78,41 @@ describe('resolveNodeSelectionTarget', () => { }) }) }) + +describe('shouldPreserveSelectedRoofHostTarget', () => { + test('keeps the roof host target while that roof is the sole armed selection', () => { + const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode + + expect( + shouldPreserveSelectedRoofHostTarget({ + node, + selectedIds: ['roof_1'], + armedRoofId: 'roof_1', + }), + ).toBe(true) + }) + + test('falls back to segment targeting when the roof host is not armed', () => { + const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode + + expect( + shouldPreserveSelectedRoofHostTarget({ + node, + selectedIds: ['roof_1'], + armedRoofId: null, + }), + ).toBe(false) + }) + + test('falls back to segment targeting when the roof is no longer the sole selection', () => { + const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode + + expect( + shouldPreserveSelectedRoofHostTarget({ + node, + selectedIds: ['roof_1', 'wall_1'], + armedRoofId: 'roof_1', + }), + ).toBe(false) + }) +}) diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts index 9d978da1..207f7266 100644 --- a/packages/editor/src/lib/selection-routing.ts +++ b/packages/editor/src/lib/selection-routing.ts @@ -66,6 +66,23 @@ export function resolveSelectedIdsForNodeClick({ return [nodeId] } +export function shouldPreserveSelectedRoofHostTarget({ + node, + selectedIds, + armedRoofId, +}: { + node: AnyNode + selectedIds: readonly string[] + armedRoofId: string | null +}): boolean { + return ( + node.type === 'roof' && + armedRoofId === node.id && + selectedIds.length === 1 && + selectedIds[0] === node.id + ) +} + export function resolveNodeSelectionTarget(node: AnyNode): NodeSelectionTarget | null { if (node.type === 'building') { return { phase: 'site' } diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index 7ca26fb3..09017871 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -119,7 +119,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => { // Roof / stair / elevator are placed as footprints, not directional draws → // declared `snapDraftDirectional: false`, so their draft context drops the // angle-lock mode. Directional structural kinds (no flag) stay `wall`. - const draftDirectionalOf = (t: string) => (t === 'roof' ? false : true) + const draftDirectionalOf = (t: string) => t !== 'roof' const draftCtx = (tool: string) => snapContextOf({ scope: { kind: 'idle' }, mode: 'build', tool, profileOf, draftDirectionalOf }) expect(draftCtx('roof')).toBe('polygon') diff --git a/packages/editor/src/lib/stair-duplication.ts b/packages/editor/src/lib/stair-duplication.ts index e27626d0..99efe3c1 100644 --- a/packages/editor/src/lib/stair-duplication.ts +++ b/packages/editor/src/lib/stair-duplication.ts @@ -37,7 +37,7 @@ function stripDuplicateFlags(metadata: unknown) { function moveStairWhenRegistered(stairId: StairNode['id'], attempt = 0) { const latestStair = useScene.getState().nodes[stairId as AnyNodeId] - if (!latestStair || latestStair.type !== 'stair') { + if (latestStair?.type !== 'stair') { return } @@ -64,7 +64,7 @@ export function duplicateStairSubtree( const scene = useScene.getState() const sourceStair = scene.nodes[sourceStairId] - if (!sourceStair || sourceStair.type !== 'stair') { + if (sourceStair?.type !== 'stair') { throw new Error(`Node "${sourceStairId}" is not a stair`) } @@ -89,7 +89,7 @@ export function duplicateStairSubtree( const segmentClones: StairSegmentNode[] = [] for (const childId of sourceStair.children ?? []) { const childNode = scene.nodes[childId as AnyNodeId] - if (!childNode || childNode.type !== 'stair-segment') { + if (childNode?.type !== 'stair-segment') { continue } @@ -108,7 +108,7 @@ export function duplicateStairSubtree( ]) const createdStair = useScene.getState().nodes[stairClone.id as AnyNodeId] - if (!createdStair || createdStair.type !== 'stair') { + if (createdStair?.type !== 'stair') { throw new Error(`Duplicated stair "${stairClone.id}" was not created`) } diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts index 0851bca7..71e162c1 100644 --- a/packages/editor/src/lib/surface-plan-snap.ts +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -6,7 +6,6 @@ import { getWallCurveFrameAt, getWallCurveLength, isCurvedWall, - resolveAlignment, resolveLevelId, useScene, type WallNode, @@ -21,6 +20,7 @@ import { import useAlignmentGuides from '../store/use-alignment-guides' import { isMagneticSnapActive } from '../store/use-editor' import useWallSnapIndicator from '../store/use-wall-snap-indicator' +import { resolveAlignmentForFloorplanView } from './world-grid-snap' const SURFACE_SNAP_MOVING_ID = '__surface_snap__' export const SURFACE_ALIGNMENT_THRESHOLD_M = 0.08 @@ -172,12 +172,6 @@ export function clearSurfacePlanSnapFeedback() { } export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): SurfacePlanSnapResult { - if (input.shiftKey) { - useWallSnapIndicator.getState().clear() - useAlignmentGuides.getState().clear() - return { point: input.rawPoint, wallSnap: null, guides: [], wallIds: [] } - } - const nodes = input.nodes ?? useScene.getState().nodes const walls = getLevelWalls(nodes, input.levelId, input.walls) const fallbackPoint = input.fallbackPoint @@ -213,7 +207,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac // only when magnetic snap is on — `grid`/`angles`/`off` keep the grid/raw // `fallbackPoint` instead of being pulled onto an alignment axis. const basePoint = fallbackPoint ?? wallSnap.point - if (input.align === false || input.altKey || !magnetic) { + if (input.align === false || !magnetic) { useAlignmentGuides.getState().clear() return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } } @@ -228,7 +222,7 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } } - const alignment = resolveAlignment({ + const alignment = resolveAlignmentForFloorplanView({ moving: [{ nodeId: movingId, kind: 'corner', x: basePoint[0], z: basePoint[1] }], candidates, threshold: input.threshold ?? SURFACE_ALIGNMENT_THRESHOLD_M, diff --git a/packages/editor/src/lib/world-grid-snap.ts b/packages/editor/src/lib/world-grid-snap.ts index fc6984a3..acda5a95 100644 --- a/packages/editor/src/lib/world-grid-snap.ts +++ b/packages/editor/src/lib/world-grid-snap.ts @@ -10,6 +10,7 @@ */ import { type AlignmentAnchor, + type AlignmentGuide, type AnyNodeId, type BuildingPose, type ResolveAlignmentInBuildingResult, @@ -54,7 +55,7 @@ export function getActiveBuildingPose(): BuildingPose | null { } if (!buildingId) buildingId = sel.buildingId ?? null const building = buildingId ? nodes[buildingId] : null - if (!building || building.type !== 'building') return null + if (building?.type !== 'building') return null const live = useLiveTransforms.getState().transforms.get(buildingId as string) return { position: live?.position ?? building.position, @@ -80,6 +81,37 @@ export function resolveAlignmentForActiveBuilding(args: { return resolveAlignmentInBuildingWorld({ ...args, pose: getActiveBuildingPose() }) } +function worldXZToPoseLocal(x: number, z: number, pose: BuildingPose | null): [number, number] { + if (!pose) return [x, z] + const cos = Math.cos(pose.rotationY) + const sin = Math.sin(pose.rotationY) + const dx = x - pose.position[0] + const dz = z - pose.position[2] + return [dx * cos - dz * sin, dx * sin + dz * cos] +} + +/** + * Project WORLD-frame alignment guides into the active building's LOCAL frame. + * + * The 3D alignment layer is still mounted inside the building-local tool group, + * so tools that resolve alignment on the world axes (item placement, slab move) + * need their guides converted before publishing to `useAlignmentGuides`. + */ +export function projectAlignmentGuidesWorldToActiveBuildingLocal( + guides: readonly AlignmentGuide[], +): AlignmentGuide[] { + const pose = getActiveBuildingPose() + return guides.map((guide) => { + const [fromX, fromZ] = worldXZToPoseLocal(guide.from.x, guide.from.z, pose) + const [toX, toZ] = worldXZToPoseLocal(guide.to.x, guide.to.z, pose) + return { + ...guide, + from: { x: fromX, z: fromZ }, + to: { x: toX, z: toZ }, + } + }) +} + /** * Baseline rotation the floor-plan view applies on top of the building * rotation. Mirrors `FLOORPLAN_VIEW_ROTATION_DEG = 90` in floorplan-panel.tsx — @@ -159,7 +191,7 @@ export function snapWorldXZForActiveBuilding( ): { world: [number, number]; local: [number, number] } { const buildingId = useViewer.getState().selection.buildingId const building = buildingId ? useScene.getState().nodes[buildingId] : null - if (!building || building.type !== 'building') { + if (building?.type !== 'building') { if (step <= 0) return { world: [worldX, worldZ], local: [worldX, worldZ] } const sx = Math.round(worldX / step) * step const sz = Math.round(worldZ / step) * step @@ -186,7 +218,7 @@ export function snapBuildingLocalToWorldGrid( ): [number, number] { const buildingId = useViewer.getState().selection.buildingId const building = buildingId ? useScene.getState().nodes[buildingId] : null - if (!building || building.type !== 'building') { + if (building?.type !== 'building') { if (step <= 0) return [local[0], local[1]] return [Math.round(local[0] / step) * step, Math.round(local[1] / step) * step] } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 87cdbbe6..9d73d96c 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -239,6 +239,8 @@ type EditorState = { */ placementDragMode: boolean setPlacementDragMode: (dragMode: boolean) => void + roofHostDragArmedId: AnyNodeId | null + setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void setMovingNode: ( node: | ItemNode @@ -870,6 +872,8 @@ const useEditor = create()( setSelectedItem: (item) => set({ selectedItem: item }), placementDragMode: false, setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }), + roofHostDragArmedId: null, + setRoofHostDragArmedId: (nodeId) => set({ roofHostDragArmedId: nodeId }), // The node being placed/moved now lives inside the interaction scope // (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter // remains the single entry point: it drives the scope and still touches diff --git a/packages/editor/src/store/use-fence-curve-draft.ts b/packages/editor/src/store/use-fence-curve-draft.ts new file mode 100644 index 00000000..9b76a63b --- /dev/null +++ b/packages/editor/src/store/use-fence-curve-draft.ts @@ -0,0 +1,21 @@ +// Ephemeral store: how many points the in-progress curved-fence draft has +// placed. Written by the 3D spline draft tool (`@pascal-app/nodes` +// fence/tool.tsx) and read by the contextual helper so the "finish curve" hint +// only surfaces once the user has actually started drawing. Reset on commit, +// cancel, and unmount — never persisted, never in undo history. + +import { create } from 'zustand' + +type FenceCurveDraftState = { + pointCount: number + setPointCount(count: number): void + reset(): void +} + +const useFenceCurveDraft = create((set) => ({ + pointCount: 0, + setPointCount: (count) => set({ pointCount: count }), + reset: () => set({ pointCount: 0 }), +})) + +export default useFenceCurveDraft diff --git a/packages/editor/src/store/use-interaction-scope.ts b/packages/editor/src/store/use-interaction-scope.ts index 95061624..5af76c44 100644 --- a/packages/editor/src/store/use-interaction-scope.ts +++ b/packages/editor/src/store/use-interaction-scope.ts @@ -6,6 +6,7 @@ import { create } from 'zustand' import { useShallow } from 'zustand/react/shallow' import { type ActiveInteractionScope, + controlPointReshapeInfo, editingHoleInfo, endpointReshapeInfo, handleDragInfo, @@ -14,6 +15,7 @@ import { isCurveReshape, movingNodeOf, reshapingNodeId, + tangentReshapeInfo, } from '../lib/interaction/scope' // The authoritative interaction state machine. A single owner holds exactly one @@ -87,6 +89,12 @@ export const useIsCurveReshape = (): boolean => useInteractionScope((s) => isCur export const useEndpointReshape = (): { nodeId: string; endpoint: 'start' | 'end' } | null => useInteractionScope(useShallow((s) => endpointReshapeInfo(s.scope))) +export const useControlPointReshape = (): { nodeId: string; index: number } | null => + useInteractionScope(useShallow((s) => controlPointReshapeInfo(s.scope))) + +export const useTangentReshape = (): { nodeId: string; index: number; side: 'in' | 'out' } | null => + useInteractionScope(useShallow((s) => tangentReshapeInfo(s.scope))) + // The node currently being reshaped (curve / endpoint / hole), looked up live // from the scene by the scope's `nodeId`. During a reshape the scene node holds // the same data the legacy `curvingWall` / `movingWallEndpoint.wall` carried, so diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts index 75d29fbc..ad3d5a14 100644 --- a/packages/ifc-converter/src/index.ts +++ b/packages/ifc-converter/src/index.ts @@ -1111,7 +1111,7 @@ export async function convertIfcToPascal( const wallNodeId = expressIdToNodeId.get(wallExpressID) if (!wallNodeId) continue const wallNode = nodes[wallNodeId] as WallNode - if (!wallNode || wallNode.type !== 'wall') continue + if (wallNode?.type !== 'wall') continue const wallDx = wallNode.end[0] - wallNode.start[0] const wallDy = wallNode.end[1] - wallNode.start[1] @@ -1288,7 +1288,7 @@ export async function convertIfcToPascal( const wallInfos: WallInfo[] = [] for (const [wallExpressId, wallNodeId] of expressIdToNodeId) { const node = nodes[wallNodeId] - if (!node || node.type !== 'wall') continue + if (node?.type !== 'wall') continue const w = node as WallNode const length = Math.hypot(w.end[0] - w.start[0], w.end[1] - w.start[1]) if (length < 1e-6) continue diff --git a/packages/mcp/src/resources/constraints.ts b/packages/mcp/src/resources/constraints.ts index c4047c93..4a90140f 100644 --- a/packages/mcp/src/resources/constraints.ts +++ b/packages/mcp/src/resources/constraints.ts @@ -41,7 +41,7 @@ function buildPayload( levelId: string, ): ConstraintsPayload | ConstraintsError { const level = bridge.getNode(levelId as never) - if (!level || level.type !== 'level') { + if (level?.type !== 'level') { return { error: 'level_not_found', levelId, diff --git a/packages/mcp/src/tools/construction-tools.test.ts b/packages/mcp/src/tools/construction-tools.test.ts index e4dac3bf..014a48ff 100644 --- a/packages/mcp/src/tools/construction-tools.test.ts +++ b/packages/mcp/src/tools/construction-tools.test.ts @@ -121,6 +121,48 @@ describe('construction tools', () => { expect(bridge.validateScene().valid).toBe(true) }) + test('create_stair_between_levels defaults opening offset to zero', async () => { + const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! + const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! + const upper = LevelNode.parse({ name: 'Second Floor', level: 1, metadata: { height: 2.8 } }) + bridge.createNode(upper, building.id) + + for (const level of [ground, upper]) { + const result = await client.callTool({ + name: 'create_story_shell', + arguments: { + levelId: level.id, + footprint: [ + [-4, -3], + [4, -3], + [4, 3], + [-4, 3], + ], + wallHeight: 2.8, + }, + }) + expect(result.isError).toBeFalsy() + } + + const result = await client.callTool({ + name: 'create_stair_between_levels', + arguments: { + fromLevelId: ground.id, + toLevelId: upper.id, + position: [0, 0, -1], + width: 1, + runLength: 3, + totalRise: 2.8, + }, + }) + expect(result.isError).toBeFalsy() + const parsed = JSON.parse((result.content as Array<{ type: string; text: string }>)[0]!.text) + const stair = bridge.getNode(parsed.stairId) + + expect(stair?.type).toBe('stair') + if (stair?.type === 'stair') expect(stair.openingOffset).toBe(0) + }) + test('verify_scene flags suspicious multi-story wall heights', async () => { const building = Object.values(bridge.getNodes()).find((n) => n.type === 'building')! const ground = Object.values(bridge.getNodes()).find((n) => n.type === 'level')! diff --git a/packages/mcp/src/tools/construction-tools.ts b/packages/mcp/src/tools/construction-tools.ts index 11f85ae8..1488e57f 100644 --- a/packages/mcp/src/tools/construction-tools.ts +++ b/packages/mcp/src/tools/construction-tools.ts @@ -85,7 +85,7 @@ export const createStairBetweenLevelsInput = { createSourceCeilingOpening: z.boolean().default(true), openingWidth: z.number().positive().optional(), openingLength: z.number().positive().optional(), - openingOffset: z.number().min(0).default(0.15), + openingOffset: z.number().min(0).default(0), openingCenter: Vec2Schema.optional(), openingRotation: z.number().optional(), materialPreset: z.string().optional(), diff --git a/packages/mcp/src/tools/find-nodes.ts b/packages/mcp/src/tools/find-nodes.ts index 5c0d84b9..4abe80bb 100644 --- a/packages/mcp/src/tools/find-nodes.ts +++ b/packages/mcp/src/tools/find-nodes.ts @@ -100,7 +100,7 @@ export function registerFindNodes(server: McpServer, bridge: SceneOperations): v // Zone-polygon filter: point-in-polygon on a representative 2D point. if (zoneId) { const zone = bridge.getNode(zoneId as AnyNodeId) - if (!zone || zone.type !== 'zone') { + if (zone?.type !== 'zone') { // Unknown zoneId → return empty list rather than throw; matches // typical "filter" semantics. results = [] diff --git a/packages/mcp/src/tools/scene-query.ts b/packages/mcp/src/tools/scene-query.ts index 26b6a0ab..22f7cc2d 100644 --- a/packages/mcp/src/tools/scene-query.ts +++ b/packages/mcp/src/tools/scene-query.ts @@ -396,7 +396,7 @@ function parentListsChild(parent: AnyNode, childId: string): boolean { function levelSummary(bridge: SceneOperations, levelId: AnyNodeId) { const level = bridge.getNode(levelId) - if (!level || level.type !== 'level') { + if (level?.type !== 'level') { throw new Error(`Level not found: ${levelId}`) } const nodes = nodesOnLevel(bridge, levelId) @@ -680,7 +680,7 @@ export function registerVerifyScene(server: McpServer, bridge: SceneOperations): for (const node of Object.values(bridge.getNodes())) { if (node.type === 'door' || node.type === 'window') { const parent = node.parentId ? bridge.getNode(node.parentId as AnyNodeId) : null - if (!parent || parent.type !== 'wall') { + if (parent?.type !== 'wall') { issues.push(`${node.type} ${node.id} is not parented to a wall`) continue } diff --git a/packages/nodes/src/box-vent/floorplan.ts b/packages/nodes/src/box-vent/floorplan.ts index 36c572bc..9bc55578 100644 --- a/packages/nodes/src/box-vent/floorplan.ts +++ b/packages/nodes/src/box-vent/floorplan.ts @@ -25,10 +25,10 @@ export function buildBoxVentFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null const cosR = Math.cos(-roof.rotation) const sinR = Math.sin(-roof.rotation) diff --git a/packages/nodes/src/box-vent/renderer.tsx b/packages/nodes/src/box-vent/renderer.tsx index 26f01e84..f72d71a2 100644 --- a/packages/nodes/src/box-vent/renderer.tsx +++ b/packages/nodes/src/box-vent/renderer.tsx @@ -19,6 +19,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildBoxVentGeometry } from './geometry' const defaultMaterial = new THREE.MeshStandardMaterial({ @@ -134,6 +135,19 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat) }, [surfaceQuat, node.rotation, yAxis]) + // Map vent-local geometry into the host segment's local frame (the frame the + // trim cut prisms live in) — same pose the inner mesh group is mounted with. + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0), + composedQuat, + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[1], node.position[2], composedQuat], + ) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment) + if (!segment) return null // `node.position` is segment-local (the placement + move tools resolve @@ -158,7 +172,7 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => { > { [geo, trimmedBody], ) + // Map chimney-local geometry into the host segment's local frame (where the + // trim cut prisms live) — same pose the inner mesh group is mounted with + // (node.position x/z, y=0 — chimneys anchor to the surface, not position[1]). + // Every chimney part (body, cap, flues, cricket, bands) shares this pose, so + // each is clipped by the segment trim independently. The body chains AFTER + // the through-roof self-trim, so both CSG passes compose. + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, 0, node.position[2] ?? 0), + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), node.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[2], node.rotation], + ) + const clippedBody = useSegmentTrimClippedGeometry( + trimmedBody ?? geo?.body ?? null, + segment, + localToSegment, + ) + const clippedCap = useSegmentTrimClippedGeometry(geo?.cap ?? null, segment, localToSegment) + const clippedFlues = useSegmentTrimClippedGeometry(geo?.flues ?? null, segment, localToSegment) + const clippedCricket = useSegmentTrimClippedGeometry( + geo?.cricket ?? null, + segment, + localToSegment, + ) + const clippedBands = useSegmentTrimClippedGeometry(geo?.bands ?? null, segment, localToSegment) + // Per-instance fallback materials. Were previously module-scoped // singletons shared across every chimney — a paint-mode or debug // system that mutates `surfaceMaterial` would have flipped the look @@ -238,7 +268,7 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => { > { {geo.cap && ( { {geo.flues && ( { {geo.cricket && ( { {geo.bands && ( = ({ nod const session: FloorplanMoveTargetSession = { affectedIds: [columnId], - apply({ planPoint, modifiers }) { + apply({ planPoint }) { const snap = (value: number) => { - if (modifiers.shiftKey) return value + if (!isGridSnapActive()) return value const step = useEditor.getState().gridSnapStep return Math.round(value / step) * step } const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint - // Figma-style alignment layered on the grid snap (Alt bypasses alignment; Shift all snap). + // Figma-style alignment layered on the active snap mode. const { point: snapped } = applyFloorplanAlignment( gridSnapped, movingFootprintAnchors( @@ -73,26 +63,36 @@ export const columnFloorplanMoveTarget: FloorplanMoveTarget = ({ nod rotationY, ), candidates, - { bypass: modifiers.altKey || modifiers.shiftKey }, + { bypass: !isMagneticSnapActive() }, ) const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]] lastPosition = next const snapKey = `${snapped[0]},${snapped[1]}` - if (!modifiers.shiftKey && snapKey !== lastSnapKey) { + if (snapKey !== lastSnapKey) { triggerSFX('sfx:grid-snap') lastSnapKey = snapKey } - // Single source of truth — write the absolute position straight to the - // scene (history paused by the overlay). 2D SVG and 3D group transform - // both follow `node.position` reactively, so they can't diverge. - useScene.getState().updateNodes([{ id: columnId, data: { position: next } }]) + const visualPosition = getFloorStackPreviewPosition({ + node, + position: next, + rotation: rotationY, + levelId: node.parentId ?? null, + }) + sceneRegistry.nodes.get(columnId)?.position.set(...visualPosition) + useLiveTransforms.getState().set(columnId, { + position: next, + rotation: rotationY, + }) }, canCommit() { const live = useScene.getState().nodes[columnId] as ColumnNode | undefined - if (!live || live.type !== 'column') return false + if (live?.type !== 'column') return false return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2]) }, + commit() { + useScene.getState().updateNodes([{ id: columnId, data: { position: lastPosition } }]) + }, } return session } diff --git a/packages/nodes/src/cupola/floorplan.ts b/packages/nodes/src/cupola/floorplan.ts index fa6b5cb6..4c0d4941 100644 --- a/packages/nodes/src/cupola/floorplan.ts +++ b/packages/nodes/src/cupola/floorplan.ts @@ -19,10 +19,10 @@ export function buildCupolaFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null const cosR = Math.cos(-roof.rotation) const sinR = Math.sin(-roof.rotation) diff --git a/packages/nodes/src/cupola/renderer.tsx b/packages/nodes/src/cupola/renderer.tsx index 357c6956..71251ae7 100644 --- a/packages/nodes/src/cupola/renderer.tsx +++ b/packages/nodes/src/cupola/renderer.tsx @@ -19,6 +19,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildCupolaGeometry } from './geometry' const defaultMaterial = new THREE.MeshStandardMaterial({ @@ -81,6 +82,19 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat) }, [surfaceQuat, node.rotation, yAxis]) + // Map cupola-local geometry into the host segment's local frame (where the + // trim cut prisms live) — same pose the inner mesh group is mounted with. + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0), + composedQuat, + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[1], node.position[2], composedQuat], + ) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment) + if (!segment) return null const segPos = segment.position ?? [0, 0, 0] @@ -96,7 +110,7 @@ const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => { > { useEffect(() => () => geometry?.dispose(), [geometry]) + // Map dormer-local geometry into the host segment's local frame (where the + // trim cut prisms live) — same pose the inner mesh group is mounted with. + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0), + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), node.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[1], node.position[2], node.rotation], + ) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment) + if (!(segment && geometry)) return null // Dormers are mounted inside `RoofRenderer`'s `roof-elements` group @@ -171,8 +185,15 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => { ref={ref} rotation-y={node.rotation ?? 0} > - + { // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. const skirtWin = useMemo( @@ -140,39 +146,58 @@ const DormerWindowAssembly = ({ // FrontSide points outward (-Z in segment frame). With the rotation, // the sill always extrudes along the group's local +Z, so its position // no longer needs to flip per-face. - const renderFace = (zPos: number, yRot: number, keyPrefix: string) => ( - - {winGeo.glassPanes.map((pane, i) => ( - - ))} - {winGeo.frameBars.map((bar, i) => ( - - ))} - {sillGeo && ( - - )} - - ) + const renderFace = (zPos: number, yRot: number, keyPrefix: string) => { + // Compose this face group's transform onto the dormer→segment matrix, so + // each window part can be clipped by the trim in segment-local space. + const faceToSegment = new THREE.Matrix4() + .copy(dormerToSegment) + .multiply( + new THREE.Matrix4().compose( + new THREE.Vector3(winX, winY, zPos), + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), yRot), + new THREE.Vector3(1, 1, 1), + ), + ) + return ( + + {winGeo.glassPanes.map((pane, i) => ( + + ))} + {winGeo.frameBars.map((bar, i) => ( + + ))} + {sillGeo && ( + + )} + + ) + } return ( <> diff --git a/packages/nodes/src/downspout/inspector-editors.tsx b/packages/nodes/src/downspout/inspector-editors.tsx index 69fdf06b..fa5c06ac 100644 --- a/packages/nodes/src/downspout/inspector-editors.tsx +++ b/packages/nodes/src/downspout/inspector-editors.tsx @@ -41,7 +41,7 @@ export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) { : undefined, ) - if (!gutter || gutter.type !== 'gutter') return null + if (gutter?.type !== 'gutter') return null const storeOutlets = gutter.outlets ?? [] const effectiveOutlets = (override?.outlets as GutterOutlet[] | undefined) ?? storeOutlets const outlet = effectiveOutlets.find((o) => o.id === node.outletId) diff --git a/packages/nodes/src/downspout/renderer.tsx b/packages/nodes/src/downspout/renderer.tsx index 8f071dda..a86604e4 100644 --- a/packages/nodes/src/downspout/renderer.tsx +++ b/packages/nodes/src/downspout/renderer.tsx @@ -21,6 +21,7 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { computeEaveY } from '../gutter/eave-snap' import { resolveGutterOutletById } from '../gutter/outlet-lookup' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildDownspoutGeometry } from './geometry' import { computeDownspoutRouting } from './routing' @@ -143,6 +144,31 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + // Map downspout-local geometry into the host segment's local frame (where the + // trim cut prisms live). Recompose the same outlet pose the inner mesh group + // is mounted with (gutter offset + rotation → outlet → eave Y). Computed + // before the early returns so the hook order stays stable. + const localToSegment = useMemo(() => { + if (!effectiveGutter || !effectiveSegment) return new THREE.Matrix4() + const outlet = resolveGutterOutletById(effectiveGutter, node.outletId) + if (!outlet) return new THREE.Matrix4() + const liveEaveY = computeEaveY(effectiveSegment) + const gutterRotY = effectiveGutter.rotation ?? 0 + const gutterX = effectiveGutter.position[0] ?? 0 + const gutterZ = effectiveGutter.position[2] ?? 0 + const cos = Math.cos(gutterRotY) + const sin = Math.sin(gutterRotY) + const outletSegX = gutterX + (outlet.x * cos + outlet.z * sin) + const outletSegZ = gutterZ + (-outlet.x * sin + outlet.z * cos) + const outletSegY = liveEaveY + outlet.y + return new THREE.Matrix4().compose( + new THREE.Vector3(outletSegX, outletSegY, outletSegZ), + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), gutterRotY), + new THREE.Vector3(1, 1, 1), + ) + }, [effectiveGutter, effectiveSegment, node.outletId]) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment) + if (!effectiveGutter || !effectiveSegment) return null const outlet = resolveGutterOutletById(effectiveGutter, node.outletId) if (!outlet) return null @@ -177,7 +203,7 @@ const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => { > (node.gutterId as AnyNodeId) - if (!gutter || gutter.type !== 'gutter') return null + if (gutter?.type !== 'gutter') return null const segment = gutter.roofSegmentId ? sceneApi.get(gutter.roofSegmentId as AnyNodeId) : undefined diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index 45903b3a..4ada9816 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -225,7 +225,7 @@ const DuctTerminalTool = () => { const nodes = useScene.getState().nodes let best: { hit: Vector3; height: number } | null = null for (const node of Object.values(nodes)) { - if (!node || node.type !== 'ceiling') continue + if (node?.type !== 'ceiling') continue if (resolveLevelId(node, nodes) !== activeLevelId) continue const ceiling = node as { height?: number diff --git a/packages/nodes/src/eyebrow-vent/floorplan.ts b/packages/nodes/src/eyebrow-vent/floorplan.ts index 9acd4b87..d550d87b 100644 --- a/packages/nodes/src/eyebrow-vent/floorplan.ts +++ b/packages/nodes/src/eyebrow-vent/floorplan.ts @@ -19,10 +19,10 @@ export function buildEyebrowVentFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null const cosR = Math.cos(-roof.rotation) const sinR = Math.sin(-roof.rotation) diff --git a/packages/nodes/src/eyebrow-vent/renderer.tsx b/packages/nodes/src/eyebrow-vent/renderer.tsx index eb9bf093..cf019e6c 100644 --- a/packages/nodes/src/eyebrow-vent/renderer.tsx +++ b/packages/nodes/src/eyebrow-vent/renderer.tsx @@ -19,6 +19,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildEyebrowVentGeometry } from './geometry' const defaultMaterial = new THREE.MeshStandardMaterial({ @@ -83,6 +84,19 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat) }, [surfaceQuat, node.rotation, yAxis]) + // Map vent-local geometry into the host segment's local frame (where the trim + // cut prisms live) — same pose the inner mesh group is mounted with. + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0), + composedQuat, + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[1], node.position[2], composedQuat], + ) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment) + if (!segment) return null const segPos = segment.position ?? [0, 0, 0] @@ -98,7 +112,7 @@ const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => > [] = [ - fenceSideMoveHandle('front'), - fenceSideMoveHandle('back'), - fenceHeightHandle(), - fenceCornerPicker('start'), - fenceCornerPicker('end'), -] +const TANGENT_HANDLE_ARM_SCALE = 3 + +function fenceControlPointPicker(index: number): HandleDescriptor { + return { + kind: 'tap-action', + shape: 'corner-picker', + cursor: 'move', + nodeHeight: (n) => n.height ?? 1.8, + onActivate: (node, _scene, editor) => editor.engageControlPointMove(node, index), + placement: { + position: (n) => { + const point = n.path?.[index] ?? n.start + return [point[0], 0, point[1]] + }, + }, + } +} + +function fenceTangentPicker(index: number, side: 'in' | 'out'): HandleDescriptor { + const sign = side === 'out' ? 1 : -1 + return { + kind: 'tap-action', + shape: 'corner-picker', + round: true, + cursor: 'move', + nodeHeight: (n) => (n.height ?? 1.8) * 0.6, + onActivate: (node, _scene, editor) => editor.engageTangentMove(node, index, side), + placement: { + position: (n) => { + const point = n.path?.[index] ?? n.start + if (!n.path) return [point[0], 0, point[1]] + const handle = getFenceControlHandle(n.path, n.tangents, index) + return [ + point[0] + sign * handle.x * TANGENT_HANDLE_ARM_SCALE, + 0, + point[1] + sign * handle.y * TANGENT_HANDLE_ARM_SCALE, + ] + }, + }, + } +} + +const fenceHandles = (node: FenceNodeType): HandleDescriptor[] => { + if (isSplineFence(node) && node.path) { + return [ + fenceHeightHandle(), + ...node.path.flatMap((_, index) => [ + fenceControlPointPicker(index), + fenceTangentPicker(index, 'out'), + fenceTangentPicker(index, 'in'), + ]), + ] + } + + return [ + fenceSideMoveHandle('front'), + fenceSideMoveHandle('back'), + fenceHeightHandle(), + fenceCornerPicker('start'), + fenceCornerPicker('end'), + ] +} /** * Fence — Phase 5 batch kind. Stage B complete: `def.geometry` drives @@ -209,6 +277,8 @@ export const fenceDefinition: NodeDefinition = { // pointer-up). floorplanAffordances: { 'move-endpoint': fenceMoveEndpointAffordance, + 'move-control-point': fenceControlPointAffordance, + 'move-tangent': fenceTangentAffordance, curve: fenceCurveAffordance, }, // Body move on the fence is driven by the two `move-arrow` chevrons @@ -225,6 +295,8 @@ export const fenceDefinition: NodeDefinition = { affordanceTools: { curve: () => import('./curve-tool'), 'move-endpoint': () => import('./move-endpoint-tool'), + 'move-control-point': () => import('./move-control-point-tool'), + 'move-tangent': () => import('./move-tangent-tool'), move: () => import('./move-tool'), }, diff --git a/packages/nodes/src/fence/floorplan-affordances.ts b/packages/nodes/src/fence/floorplan-affordances.ts index da4487be..49bfa747 100644 --- a/packages/nodes/src/fence/floorplan-affordances.ts +++ b/packages/nodes/src/fence/floorplan-affordances.ts @@ -16,6 +16,7 @@ import { type FencePlanPoint, getSegmentGridStep, isAngleSnapActive, + isGridSnapActive, isMagneticSnapActive, isSegmentLongEnough, snapBuildingLocalToWorldGrid, @@ -43,6 +44,13 @@ import { const LINKED_FENCE_ENDPOINT_EPSILON = 0.025 type FenceEndpointPayload = { fenceId: AnyNodeId; endpoint: 'start' | 'end' } +type FenceControlPointPayload = { fenceId: AnyNodeId; index: number } +type FenceTangentPayload = { fenceId: AnyNodeId; index: number; side: 'in' | 'out' } + +// Must match the floorplan builder's TANGENT_HANDLE_ARM_SCALE: the on-screen +// arm is this many times the raw tangent vector, so dividing the dragged +// offset back out recovers the stored tangent. +const TANGENT_HANDLE_ARM_SCALE = 3 function pointsNearlyEqual(a: FencePlanPoint, b: FencePlanPoint): boolean { return ( @@ -88,7 +96,7 @@ function collectLinkedFences( /** * Fence curve sagitta drag — 1:1 mirror of `wallCurveAffordance`. Drag * projects the pointer onto the chord normal to compute `curveOffset`, - * snaps to grid (Shift bypasses), clamps to `getMaxWallCurveOffset`, + * snaps to grid when that mode is active, clamps to `getMaxWallCurveOffset`, * normalizes via `normalizeWallCurveOffset`. Same single-undo dance — the * dispatcher handles snapshot / pause / resume around `apply`. Lives in * the same file as the endpoint affordance to keep the two fence @@ -105,17 +113,16 @@ export const fenceCurveAffordance: FloorplanAffordance = { return { affectedIds: [node.id], apply({ planPoint, modifiers }) { - const snapStep = getSegmentGridStep() - const x = modifiers.shiftKey ? planPoint[0] : snapScalarToGrid(planPoint[0], snapStep) - const y = modifiers.shiftKey ? planPoint[1] : snapScalarToGrid(planPoint[1], snapStep) + const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0 + const x = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0] + const y = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1] const offsetFromMidpoint = -( (x - chord.midpoint.x) * chord.normal.x + (y - chord.midpoint.y) * chord.normal.y ) - const snappedOffset = modifiers.shiftKey - ? offsetFromMidpoint - : snapScalarToGrid(offsetFromMidpoint, snapStep) + const snappedOffset = + snapStep > 0 ? snapScalarToGrid(offsetFromMidpoint, snapStep) : offsetFromMidpoint const nextCurveOffset = normalizeWallCurveOffset( node, Math.max(-maxOffset, Math.min(maxOffset, snappedOffset)), @@ -136,6 +143,114 @@ export const fenceCurveAffordance: FloorplanAffordance = { }, } +/** + * Spline control-point drag — reshapes one point of the fence `path`. Grid + * snap follows the active mode; start/end stay pinned to the path ends so endpoint- + * dependent code stays valid. Publishes a live override per tick, commits the + * final path as one tracked change. No linked-fence cascade: a spline's shape + * is self-contained. + */ +export const fenceControlPointAffordance: FloorplanAffordance = { + start({ node, payload }): FloorplanAffordanceSession { + const { index } = payload as FenceControlPointPayload + const fenceId = node.id as AnyNodeId + const originalPath: FencePlanPoint[] = (node.path ?? []).map((p) => [p[0], p[1]]) + let lastPath = originalPath + + const buildPatch = (point: FencePlanPoint): Record => { + const nextPath = originalPath.map((p, i): FencePlanPoint => (i === index ? point : p)) + lastPath = nextPath + const patch: Record = { path: nextPath } + if (index === 0) patch.start = point + if (index === nextPath.length - 1) patch.end = point + return patch + } + + return { + affectedIds: [fenceId], + apply({ planPoint, modifiers }) { + const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0 + const x = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0] + const y = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1] + useLiveNodeOverrides.getState().set(fenceId, buildPatch([x, y])) + useScene.getState().markDirty(fenceId) + }, + canCommit() { + return lastPath.length >= 2 + }, + commit() { + const data: Partial = { path: lastPath } + data.start = lastPath[0] + data.end = lastPath[lastPath.length - 1] + useScene.getState().updateNodes([{ id: fenceId, data }]) + useLiveNodeOverrides.getState().clear(fenceId) + }, + } + }, +} + +/** + * Spline tangent-handle drag — bends the curve through one control point. The + * dragged end (in / out) gives the OUT-handle vector (negated for the IN end); + * the IN handle is always the mirror so the curve stays smooth (symmetric). + * The visual arm is `TANGENT_HANDLE_ARM_SCALE`× the stored vector, so we divide + * that factor out before storing. Writes `tangents[index]`, padding the array + * to the path length with nulls so untouched points keep their auto tangent. + */ +export const fenceTangentAffordance: FloorplanAffordance = { + start({ node, payload }): FloorplanAffordanceSession { + const { index, side } = payload as FenceTangentPayload + const fenceId = node.id as AnyNodeId + const path = node.path ?? [] + const anchor = path[index] ?? node.start + let lastTangents: Array<[number, number] | null> = (node.tangents ?? []).map((t) => + t ? [t[0], t[1]] : null, + ) + + const buildTangents = (vec: [number, number]): Array<[number, number] | null> => { + const next: Array<[number, number] | null> = Array.from( + { length: path.length }, + (_, i) => lastTangents[i] ?? null, + ) + next[index] = vec + lastTangents = next + return next + } + + return { + affectedIds: [fenceId], + apply({ planPoint, modifiers }) { + const snapStep = isGridSnapActive() ? getSegmentGridStep() : 0 + const px = snapStep > 0 ? snapScalarToGrid(planPoint[0], snapStep) : planPoint[0] + const py = snapStep > 0 ? snapScalarToGrid(planPoint[1], snapStep) : planPoint[1] + // Arm vector from the anchor to the dragged handle, in plan meters. + let armX = px - anchor[0] + let armY = py - anchor[1] + // The IN end is the mirror, so its drag describes the negated OUT vector. + if (side === 'in') { + armX = -armX + armY = -armY + } + const vec: [number, number] = [ + armX / TANGENT_HANDLE_ARM_SCALE, + armY / TANGENT_HANDLE_ARM_SCALE, + ] + useLiveNodeOverrides.getState().set(fenceId, { tangents: buildTangents(vec) }) + useScene.getState().markDirty(fenceId) + }, + canCommit() { + return true + }, + commit() { + useScene + .getState() + .updateNodes([{ id: fenceId, data: { tangents: lastTangents } as Partial }]) + useLiveNodeOverrides.getState().clear(fenceId) + }, + } + }, +} + export const fenceMoveEndpointAffordance: FloorplanAffordance = { start({ node, payload, nodes }): FloorplanAffordanceSession { const { endpoint } = payload as FenceEndpointPayload diff --git a/packages/nodes/src/fence/floorplan-move.ts b/packages/nodes/src/fence/floorplan-move.ts index b3ae4105..5a3db015 100644 --- a/packages/nodes/src/fence/floorplan-move.ts +++ b/packages/nodes/src/fence/floorplan-move.ts @@ -6,7 +6,12 @@ import { useLiveNodeOverrides, useScene, } from '@pascal-app/core' -import { getSegmentGridStep, isSegmentLongEnough, snapPointToGrid } from '@pascal-app/editor' +import { + getSegmentGridStep, + isGridSnapActive, + isSegmentLongEnough, + snapPointToGrid, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' type PlanPoint = [number, number] @@ -15,7 +20,7 @@ function pointsEqual(a: PlanPoint, b: PlanPoint): boolean { return a[0] === b[0] && a[1] === b[1] } -type LinkedFenceSnapshot = { id: AnyNodeId; start: PlanPoint; end: PlanPoint } +type LinkedFenceSnapshot = { id: AnyNodeId; start: PlanPoint; end: PlanPoint; path?: PlanPoint[] } function getLinkedFenceSnapshots(args: { fenceId: AnyNodeId @@ -40,12 +45,21 @@ function getLinkedFenceSnapshots(args: { id: fence.id as AnyNodeId, start: [fence.start[0], fence.start[1]], end: [fence.end[0], fence.end[1]], + path: fence.path?.map((point) => [point[0], point[1]]), }) } } return snapshots } +function translatePath( + path: PlanPoint[] | undefined, + dx: number, + dz: number, +): PlanPoint[] | undefined { + return path?.map((point) => [point[0] + dx, point[1] + dz]) +} + /** * 2D floor-plan body move for fence. Mirrors `wallFloorplanMoveTarget` * but without bridge-wall planning: fence corners cascade through @@ -76,23 +90,32 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node let lastDelta: PlanPoint = [0, 0] let lastNextStart: PlanPoint = originalStart let lastNextEnd: PlanPoint = originalEnd + let lastNextPath: PlanPoint[] | undefined = node.path?.map((point) => [point[0], point[1]]) const projectLinked = ( snapshot: LinkedFenceSnapshot, nextStart: PlanPoint, nextEnd: PlanPoint, - ): { start: PlanPoint; end: PlanPoint } => ({ - start: pointsEqual(snapshot.start, originalStart) + dx: number, + dz: number, + ): { start: PlanPoint; end: PlanPoint; path?: PlanPoint[] } => { + const start = pointsEqual(snapshot.start, originalStart) ? nextStart : pointsEqual(snapshot.start, originalEnd) ? nextEnd - : snapshot.start, - end: pointsEqual(snapshot.end, originalStart) + : snapshot.start + const end = pointsEqual(snapshot.end, originalStart) ? nextStart : pointsEqual(snapshot.end, originalEnd) ? nextEnd - : snapshot.end, - }) + : snapshot.end + + return { + start, + end, + path: translatePath(snapshot.path, dx, dz), + } + } const session: FloorplanMoveTargetSession = { affectedIds: [fenceId, ...linkedOriginals.map((l) => l.id)], @@ -104,10 +127,8 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node } const rawDx = planPoint[0] - rawAnchor[0] const rawDz = planPoint[1] - rawAnchor[1] - const step = getSegmentGridStep() - const nextStart: PlanPoint = modifiers.shiftKey - ? [originalStart[0] + rawDx, originalStart[1] + rawDz] - : snapPointToGrid([originalStart[0] + rawDx, originalStart[1] + rawDz], step) + const step = isGridSnapActive() ? getSegmentGridStep() : 0 + const nextStart = snapPointToGrid([originalStart[0] + rawDx, originalStart[1] + rawDz], step) const dx = nextStart[0] - originalStart[0] const dz = nextStart[1] - originalStart[1] if (dx === lastDelta[0] && dz === lastDelta[1]) return @@ -115,17 +136,29 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node const nextEnd: PlanPoint = [originalEnd[0] + dx, originalEnd[1] + dz] lastNextStart = nextStart lastNextEnd = nextEnd + lastNextPath = translatePath( + node.path?.map((point) => [point[0], point[1]]), + dx, + dz, + ) const linkedUpdates = modifiers.altKey ? [] - : linkedOriginals.map((l) => ({ id: l.id, ...projectLinked(l, nextStart, nextEnd) })) + : linkedOriginals.map((l) => ({ + id: l.id, + ...projectLinked(l, nextStart, nextEnd, dx, dz), + })) useLiveNodeOverrides .getState() .setMany([ - [fenceId, { start: nextStart, end: nextEnd }], + [fenceId, { start: nextStart, end: nextEnd, path: lastNextPath }], ...linkedUpdates.map( - (u) => [u.id, { start: u.start, end: u.end }] as [string, Record], + (u) => + [u.id, { start: u.start, end: u.end, path: u.path }] as [ + string, + Record, + ], ), ]) const sceneState = useScene.getState() @@ -152,20 +185,22 @@ export const fenceFloorplanMoveTarget: FloorplanMoveTarget = ({ node data: { start: lastNextStart, end: lastNextEnd, + path: lastNextPath, metadata: { ...originalMetadata, isNew: false }, } as Partial, } - : { id: fenceId, data: { start: lastNextStart, end: lastNextEnd } } + : { id: fenceId, data: { start: lastNextStart, end: lastNextEnd, path: lastNextPath } } const linkedUpdates = linkedOriginals.map((l) => ({ id: l.id, - ...projectLinked(l, lastNextStart, lastNextEnd), + ...projectLinked(l, lastNextStart, lastNextEnd, lastDelta[0], lastDelta[1]), })) - useScene - .getState() - .updateNodes([ - fenceUpdate, - ...linkedUpdates.map((u) => ({ id: u.id, data: { start: u.start, end: u.end } })), - ]) + useScene.getState().updateNodes([ + fenceUpdate, + ...linkedUpdates.map((u) => ({ + id: u.id, + data: { start: u.start, end: u.end, path: u.path }, + })), + ]) const overrides = useLiveNodeOverrides.getState() overrides.clear(fenceId) for (const l of linkedOriginals) overrides.clear(l.id) diff --git a/packages/nodes/src/fence/floorplan.ts b/packages/nodes/src/fence/floorplan.ts index 17297f8f..4c6f23d4 100644 --- a/packages/nodes/src/fence/floorplan.ts +++ b/packages/nodes/src/fence/floorplan.ts @@ -1,11 +1,13 @@ import { type FloorplanGeometry, type GeometryContext, - getWallCurveFrameAt, - getWallCurveLength, + getFenceCenterlineFrameAt, + getFenceCenterlineLength, + getFenceControlHandle, getWallMidpointHandlePoint, isCurvedWall, - sampleWallCenterline, + isSplineFence, + sampleFenceCenterline, } from '@pascal-app/core' import type { FenceNode } from './schema' @@ -32,14 +34,22 @@ import type { FenceNode } from './schema' * in the legacy panel and is fence-specific, so it lives with the kind. */ +// The tangent handle arm is drawn this many times longer than the raw curve +// handle vector so it's easy to grab on screen even at the default (small) +// tangent. The `move-tangent` affordance divides this factor back out so the +// stored tangent matches the visual arm length. Must stay in sync with the +// 3D tool's arm scale. +const TANGENT_HANDLE_ARM_SCALE = 3 + function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)) } function getFloorplanFenceLength(fence: FenceNode): number { - return isCurvedWall(fence) - ? getWallCurveLength(fence) - : Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) + if (isSplineFence(fence) || isCurvedWall(fence)) { + return getFenceCenterlineLength(fence) + } + return Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) } /** @@ -217,14 +227,15 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo const isActive = isSelected || isHighlighted const showInteractiveChrome = isActive || isHovered - // Centerline path — sampled for curved fences so the underlay / + // Centerline path — sampled for spline / arc fences so the underlay / // accent / glow all trace the same shape. - const centerlinePoints = isCurvedWall(node) - ? sampleWallCenterline(node, 24) - : [ - { x: node.start[0], y: node.start[1] }, - { x: node.end[0], y: node.end[1] }, - ] + const centerlinePoints = + isSplineFence(node) || isCurvedWall(node) + ? sampleFenceCenterline(node, 24) + : [ + { x: node.start[0], y: node.start[1] }, + { x: node.end[0], y: node.end[1] }, + ] const pathD = buildCenterlinePathD(centerlinePoints) // Stroke shifts: selected wins; hover (not selected) → `wallHoverStroke` @@ -259,7 +270,7 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo // still sees end posts (matches legacy). const markerTs = getFloorplanFenceMarkerTs(node) const markerFrames = markerTs.map((t) => { - const frame = getWallCurveFrameAt(node, t) + const frame = getFenceCenterlineFrameAt(node, t) return { point: frame.point, angle: Math.atan2(frame.tangent.y, frame.tangent.x), @@ -329,36 +340,113 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo ) } - // 5. Hit-line for click detection. - children.push({ - kind: 'hit-line', - x1: node.start[0], - y1: node.start[1], - x2: node.end[0], - y2: node.end[1], - strokeWidthPx: 18, - cursor: 'pointer', - }) + // 5. Hit-line(s) for click detection. A straight/arc fence uses a single + // chord-spanning line; a spline fence emits one short hit-line per sampled + // span so the clickable region follows the curve instead of cutting the + // chord (there's no curved `hit-path` primitive yet). + if (isSplineFence(node)) { + for (let i = 1; i < centerlinePoints.length; i += 1) { + const a = centerlinePoints[i - 1]! + const b = centerlinePoints[i]! + children.push({ + kind: 'hit-line', + x1: a.x, + y1: a.y, + x2: b.x, + y2: b.y, + strokeWidthPx: 18, + cursor: 'pointer', + }) + } + } else { + children.push({ + kind: 'hit-line', + x1: node.start[0], + y1: node.start[1], + x2: node.end[0], + y2: node.end[1], + strokeWidthPx: 18, + cursor: 'pointer', + }) + } // 6. Endpoint handles + side move-arrows + curve handle + length when // selected. Mirrors the wall builder so fences gain the same set of // in-plan affordances (drag endpoints, drag body via either side // arrow, drag the midpoint sagitta to curve). if (isSelected) { - children.push({ - kind: 'endpoint-handle', - point: [node.start[0], node.start[1]], - state: 'idle', - affordance: 'move-endpoint', - payload: { fenceId: node.id, endpoint: 'start' as const }, - }) - children.push({ - kind: 'endpoint-handle', - point: [node.end[0], node.end[1]], - state: 'idle', - affordance: 'move-endpoint', - payload: { fenceId: node.id, endpoint: 'end' as const }, - }) + if (isSplineFence(node) && node.path) { + // Spline fence: per control point draw (a) the symmetric tangent line + // through the point with a small handle dot on each end — dragging an + // end bends the curve on both sides via `move-tangent` — and (b) the + // larger control-point dot itself, which moves the point. + for (let i = 0; i < node.path.length; i += 1) { + const point = node.path[i]! + const handle = getFenceControlHandle(node.path, node.tangents, i) + // Scale the on-screen handle arm so even the default (small) tangent + // is grabbable; the affordance divides this back out on apply. + const armX = handle.x * TANGENT_HANDLE_ARM_SCALE + const armY = handle.y * TANGENT_HANDLE_ARM_SCALE + const out: [number, number] = [point[0] + armX, point[1] + armY] + const inn: [number, number] = [point[0] - armX, point[1] - armY] + + // Connecting line (the "tangent" through the point). Violet to match + // the 3D tangent line + the handle dots. + children.push({ + kind: 'line', + x1: inn[0], + y1: inn[1], + x2: out[0], + y2: out[1], + stroke: '#8381ed', + strokeWidth: 1.25, + strokeOpacity: 0.85, + vectorEffect: 'non-scaling-stroke', + }) + // Handle dot on each end. Both drive the same `move-tangent` + // affordance; `side` tells it which end is being dragged so the + // stored OUT vector gets the correct sign. + children.push({ + kind: 'endpoint-handle', + point: out, + state: 'idle', + variant: 'curve', + affordance: 'move-tangent', + payload: { fenceId: node.id, index: i, side: 'out' as const }, + }) + children.push({ + kind: 'endpoint-handle', + point: inn, + state: 'idle', + variant: 'curve', + affordance: 'move-tangent', + payload: { fenceId: node.id, index: i, side: 'in' as const }, + }) + // The control-point dot last so it sits on top of the tangent line. + children.push({ + kind: 'endpoint-handle', + point: [point[0], point[1]], + state: 'idle', + affordance: 'move-control-point', + payload: { fenceId: node.id, index: i }, + }) + } + } else { + children.push({ + kind: 'endpoint-handle', + point: [node.start[0], node.start[1]], + state: 'idle', + affordance: 'move-endpoint', + payload: { fenceId: node.id, endpoint: 'start' as const }, + }) + children.push({ + kind: 'endpoint-handle', + point: [node.end[0], node.end[1]], + state: 'idle', + affordance: 'move-endpoint', + payload: { fenceId: node.id, endpoint: 'end' as const }, + }) + } // Two perpendicular `move-arrow` chevrons at the fence midpoint. // No `affordance` → the registry layer routes pointer-down through @@ -371,7 +459,8 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo const dz = node.end[1] - node.start[1] const lineLength = Math.hypot(dx, dz) if (lineLength > 1e-6) { - const frame = isCurvedWall(node) ? getWallCurveFrameAt(node, 0.5) : null + const frame = + isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null const midX = frame ? frame.point.x : (node.start[0] + node.end[0]) / 2 const midZ = frame ? frame.point.y : (node.start[1] + node.end[1]) / 2 const nx = frame ? frame.normal.x : -dz / lineLength @@ -393,29 +482,35 @@ export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): Floo // Curve sagitta handle — teal dot at the visual midpoint that drives // `curveOffset`. Routes through `fenceCurveAffordance`. Fences host // no children, so there's no equivalent of wall's curve-blocking - // check to gate this. - const curveHandle = getWallMidpointHandlePoint(node) - children.push({ - kind: 'endpoint-handle', - point: [curveHandle.x, curveHandle.y], - state: 'idle', - variant: 'curve', - affordance: 'curve', - payload: { fenceId: node.id }, - }) + // check to gate this. Suppressed for spline fences: the single sagitta + // is meaningless against a multi-point curve. + if (!isSplineFence(node)) { + const curveHandle = getWallMidpointHandlePoint(node) + children.push({ + kind: 'endpoint-handle', + point: [curveHandle.x, curveHandle.y], + state: 'idle', + variant: 'curve', + affordance: 'curve', + payload: { fenceId: node.id }, + }) + } - const length = getWallCurveLength(node) + const length = getFloorplanFenceLength(node) if (length >= 0.1) { - const midX = (node.start[0] + node.end[0]) / 2 - const midZ = (node.start[1] + node.end[1]) / 2 - const dx = node.end[0] - node.start[0] - const dz = node.end[1] - node.start[1] + const labelFrame = + isSplineFence(node) || isCurvedWall(node) ? getFenceCenterlineFrameAt(node, 0.5) : null + const midX = labelFrame ? labelFrame.point.x : (node.start[0] + node.end[0]) / 2 + const midZ = labelFrame ? labelFrame.point.y : (node.start[1] + node.end[1]) / 2 + const angle = labelFrame + ? Math.atan2(labelFrame.tangent.y, labelFrame.tangent.x) + : Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) children.push({ kind: 'dimension-label', cx: midX, cy: midZ, text: `${Number.parseFloat(length.toFixed(2))}m`, - angle: Math.atan2(dz, dx), + angle, }) } } diff --git a/packages/nodes/src/fence/move-control-point-tool.tsx b/packages/nodes/src/fence/move-control-point-tool.tsx new file mode 100644 index 00000000..0656d9b5 --- /dev/null +++ b/packages/nodes/src/fence/move-control-point-tool.tsx @@ -0,0 +1,129 @@ +'use client' + +import { + type AnyNodeId, + emitter, + type FenceNode, + type GridEvent, + pauseSceneHistory, + resumeSceneHistory, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { + CursorSphere, + getSegmentGridStep, + isGridSnapActive, + markToolCancelConsumed, + snapScalarToGrid, + triggerSFX, + useInteractionScope, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useState } from 'react' + +export const MoveFenceControlPointTool: React.FC<{ + target: { fence: FenceNode; index: number } +}> = ({ target }) => { + const fenceId = target.fence.id as AnyNodeId + const index = target.index + const originalPath = target.fence.path ?? [] + const originalPoint = originalPath[index] ?? target.fence.start + + const [cursor, setCursor] = useState<[number, number, number]>([ + originalPoint[0], + 0, + originalPoint[1], + ]) + + useEffect(() => { + pauseSceneHistory(useScene) + let committed = false + let lastPoint: [number, number] = [originalPoint[0], originalPoint[1]] + + const buildPatch = (point: [number, number]): Partial => { + const nextPath = originalPath.map((pathPoint, pathIndex) => + pathIndex === index ? point : pathPoint, + ) + const patch: Partial = { path: nextPath } + if (index === 0) patch.start = point + if (index === nextPath.length - 1) patch.end = point + return patch + } + + const previewPath = (point: [number, number]) => { + useLiveNodeOverrides.getState().set(fenceId, buildPatch(point)) + useScene.getState().markDirty(fenceId) + } + + const restore = () => { + useLiveNodeOverrides.getState().clear(fenceId) + useScene.getState().markDirty(fenceId) + } + + const exit = (didCommit: boolean) => { + if (didCommit) triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [fenceId] }) + useInteractionScope + .getState() + .endIf( + (scope) => + scope.kind === 'reshaping' && + scope.reshape === 'control-point' && + scope.nodeId === fenceId && + scope.index === index, + ) + } + + const onGridMove = (event: GridEvent) => { + const step = isGridSnapActive() ? getSegmentGridStep() : 0 + const x = step > 0 ? snapScalarToGrid(event.localPosition[0], step) : event.localPosition[0] + const z = step > 0 ? snapScalarToGrid(event.localPosition[2], step) : event.localPosition[2] + if (x !== lastPoint[0] || z !== lastPoint[1]) { + if (step > 0) triggerSFX('sfx:grid-snap') + lastPoint = [x, z] + setCursor([x, 0, z]) + previewPath([x, z]) + } + } + + const onGridClick = (event: GridEvent) => { + committed = true + resumeSceneHistory(useScene) + useScene.getState().updateNode(fenceId, buildPatch(lastPoint)) + useLiveNodeOverrides.getState().clear(fenceId) + useScene.getState().markDirty(fenceId) + exit(true) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + restore() + resumeSceneHistory(useScene) + markToolCancelConsumed() + exit(false) + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + if (!committed) { + restore() + resumeSceneHistory(useScene) + } + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + } + }, [fenceId, index, originalPath, originalPoint, originalPoint[0], originalPoint[1]]) + + return ( + + + + ) +} + +export default MoveFenceControlPointTool diff --git a/packages/nodes/src/fence/move-tangent-tool.tsx b/packages/nodes/src/fence/move-tangent-tool.tsx new file mode 100644 index 00000000..6109dfbe --- /dev/null +++ b/packages/nodes/src/fence/move-tangent-tool.tsx @@ -0,0 +1,132 @@ +'use client' + +import { + type AnyNodeId, + emitter, + type FenceNode, + type GridEvent, + pauseSceneHistory, + resumeSceneHistory, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { + CursorSphere, + getSegmentGridStep, + isGridSnapActive, + markToolCancelConsumed, + snapScalarToGrid, + triggerSFX, + useInteractionScope, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useState } from 'react' + +const TANGENT_HANDLE_ARM_SCALE = 3 + +export const MoveFenceTangentTool: React.FC<{ + target: { fence: FenceNode; index: number; side: 'in' | 'out' } +}> = ({ target }) => { + const fenceId = target.fence.id as AnyNodeId + const { index, side } = target + const anchor = target.fence.path?.[index] ?? target.fence.start + + const [cursor, setCursor] = useState<[number, number, number]>([anchor[0], 0, anchor[1]]) + + useEffect(() => { + pauseSceneHistory(useScene) + let committed = false + const originalTangents: Array<[number, number] | null> = (target.fence.tangents ?? []).map( + (t) => (t ? [t[0], t[1]] : null), + ) + let lastTangents = originalTangents + + const writeTangent = (vector: [number, number]) => { + const pathLength = target.fence.path?.length ?? originalTangents.length + const next: Array<[number, number] | null> = Array.from( + { length: pathLength }, + (_, tangentIndex) => lastTangents[tangentIndex] ?? null, + ) + next[index] = vector + lastTangents = next + useLiveNodeOverrides.getState().set(fenceId, { tangents: next }) + useScene.getState().markDirty(fenceId) + } + + const restore = () => { + useLiveNodeOverrides.getState().clear(fenceId) + useScene.getState().markDirty(fenceId) + lastTangents = originalTangents + } + + const exit = (didCommit: boolean) => { + if (didCommit) triggerSFX('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [fenceId] }) + useInteractionScope + .getState() + .endIf( + (scope) => + scope.kind === 'reshaping' && + scope.reshape === 'tangent' && + scope.nodeId === fenceId && + scope.index === index && + scope.side === side, + ) + } + + const onGridMove = (event: GridEvent) => { + const step = isGridSnapActive() ? getSegmentGridStep() : 0 + const px = step > 0 ? snapScalarToGrid(event.localPosition[0], step) : event.localPosition[0] + const pz = step > 0 ? snapScalarToGrid(event.localPosition[2], step) : event.localPosition[2] + setCursor([px, 0, pz]) + let armX = px - anchor[0] + let armZ = pz - anchor[1] + if (side === 'in') { + armX = -armX + armZ = -armZ + } + writeTangent([armX / TANGENT_HANDLE_ARM_SCALE, armZ / TANGENT_HANDLE_ARM_SCALE]) + } + + const onGridClick = (event: GridEvent) => { + committed = true + const finalTangents = lastTangents + resumeSceneHistory(useScene) + useScene.getState().updateNode(fenceId, { tangents: finalTangents }) + useLiveNodeOverrides.getState().clear(fenceId) + useScene.getState().markDirty(fenceId) + lastTangents = finalTangents + exit(true) + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + restore() + resumeSceneHistory(useScene) + markToolCancelConsumed() + exit(false) + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + if (!committed) { + restore() + resumeSceneHistory(useScene) + } + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + } + }, [anchor[0], anchor[1], fenceId, index, side, target.fence]) + + return ( + + + + ) +} + +export default MoveFenceTangentTool diff --git a/packages/nodes/src/fence/move-tool.tsx b/packages/nodes/src/fence/move-tool.tsx index 09165504..fcc1f279 100644 --- a/packages/nodes/src/fence/move-tool.tsx +++ b/packages/nodes/src/fence/move-tool.tsx @@ -7,7 +7,10 @@ import { type FenceNode, type GridEvent, getPerpendicularWallMoveAxis, + isCurvedWall, + isSplineFence, type LevelNode, + useLiveNodeOverrides, useScene, type WallMoveAxis, type WallNode, @@ -15,6 +18,8 @@ import { import { CursorSphere, consumePlacementDragRelease, + getSegmentGridStep, + isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, snapFenceDraftPoint, @@ -27,10 +32,12 @@ import { useCallback, useEffect, useRef, useState } from 'react' /** * Phase 5 Stage D — fence whole-move tool. * - * Live-drag pattern: translate the fence via direct scene updates while - * temporal history is paused. On commit we restore the original, resume - * history, apply the final position (single undo step), then re-pause. - * `constrainWallMoveDeltaToAxis` keeps moves axis-aligned. + * Live-drag pattern: preview data-driven reshapes through live node + * overrides. On commit we write the final position once for a single undo + * step. + * Straight fences use `constrainWallMoveDeltaToAxis` to keep side-moves + * perpendicular; curved fences translate freely because their path shape + * already carries direction. * * Wired via `def.affordanceTools.move`. The editor's `MoveTool` * dispatcher picks this up before its legacy chain. @@ -43,6 +50,7 @@ type LinkedFenceSnapshot = { id: FenceNode['id'] start: [number, number] end: [number, number] + path?: [number, number][] } function getLinkedFenceSnapshots(args: { @@ -70,11 +78,32 @@ function getLinkedFenceSnapshots(args: { id: node.id, start: [...node.start] as [number, number], end: [...node.end] as [number, number], + path: node.path?.map((point) => [...point] as [number, number]), }) } return snapshots } +function translatePath( + path: [number, number][] | undefined, + deltaX: number, + deltaZ: number, +): [number, number][] | undefined { + return path?.map((point) => [point[0] + deltaX, point[1] + deltaZ]) +} + +function projectLinkedPath( + path: [number, number][] | undefined, + start: [number, number], + end: [number, number], +): [number, number][] | undefined { + if (!path || path.length === 0) return path + const nextPath = path.map((point) => [...point] as [number, number]) + nextPath[0] = start + nextPath[nextPath.length - 1] = end + return nextPath +} + function getLinkedFenceUpdates( linkedFences: LinkedFenceSnapshot[], originalStart: [number, number], @@ -82,19 +111,25 @@ function getLinkedFenceUpdates( nextStart: [number, number], nextEnd: [number, number], ) { - return linkedFences.map((fence) => ({ - id: fence.id, - start: samePoint(fence.start, originalStart) + return linkedFences.map((fence) => { + const start = samePoint(fence.start, originalStart) ? nextStart : samePoint(fence.start, originalEnd) ? nextEnd - : fence.start, - end: samePoint(fence.end, originalStart) + : fence.start + const end = samePoint(fence.end, originalStart) ? nextStart : samePoint(fence.end, originalEnd) ? nextEnd - : fence.end, - })) + : fence.end + + return { + id: fence.id, + start, + end, + path: projectLinkedPath(fence.path, start, end), + } + }) } export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { @@ -102,6 +137,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const previousGridPosRef = useRef<[number, number] | null>(null) const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) + const originalPathRef = useRef(node.path?.map((point) => [...point] as [number, number])) const meta = typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) ? (node.metadata as Record) @@ -119,9 +155,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { }), ) const dragAnchorRef = useRef<[number, number] | null>(null) - const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) + const previewRef = useRef<{ + start: [number, number] + end: [number, number] + path?: [number, number][] + } | null>(null) + const canMoveFreely = isSplineFence(node) || isCurvedWall(node) const moveAxisRef = useRef( - getPerpendicularWallMoveAxis(node.start, node.end), + canMoveFreely ? null : getPerpendicularWallMoveAxis(node.start, node.end), ) const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { @@ -155,12 +196,38 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { let wasCommitted = false const applyNodePreview = ( - updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>, + updates: Array<{ + id: FenceNode['id'] + start: [number, number] + end: [number, number] + path?: [number, number][] + }>, + ) => { + useLiveNodeOverrides + .getState() + .setMany( + updates.map((entry) => [ + entry.id as AnyNodeId, + { start: entry.start, end: entry.end, path: entry.path }, + ]), + ) + for (const entry of updates) { + useScene.getState().markDirty(entry.id as AnyNodeId) + } + } + + const applyCommittedUpdates = ( + updates: Array<{ + id: FenceNode['id'] + start: [number, number] + end: [number, number] + path?: [number, number][] + }>, ) => { useScene.getState().updateNodes( updates.map((entry) => ({ id: entry.id as AnyNodeId, - data: { start: entry.start, end: entry.end }, + data: { start: entry.start, end: entry.end, path: entry.path }, })), ) for (const entry of updates) { @@ -169,19 +236,27 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { } const restoreOriginal = () => { - applyNodePreview([ - { id: fenceId, start: originalStart, end: originalEnd }, - ...linkedOriginalsRef.current, - ]) + const overrides = useLiveNodeOverrides.getState() + overrides.clear(fenceId) + for (const linkedFence of linkedOriginalsRef.current) { + overrides.clear(linkedFence.id) + } + useScene.getState().markDirty(fenceId) + for (const linkedFence of linkedOriginalsRef.current) { + useScene.getState().markDirty(linkedFence.id as AnyNodeId) + } } const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { - previewRef.current = { start: nextStart, end: nextEnd } + const deltaX = nextStart[0] - originalStart[0] + const deltaZ = nextStart[1] - originalStart[1] + const nextPath = translatePath(originalPathRef.current, deltaX, deltaZ) + previewRef.current = { start: nextStart, end: nextEnd, path: nextPath } const centerX = (nextStart[0] + nextEnd[0]) / 2 const centerZ = (nextStart[1] + nextEnd[1]) / 2 setCursorLocalPos([centerX, 0, centerZ]) const previewUpdates = [ - { id: fenceId, start: nextStart, end: nextEnd }, + { id: fenceId, start: nextStart, end: nextEnd, path: nextPath }, ...getLinkedFenceUpdates( linkedOriginalsRef.current, originalStart, @@ -195,18 +270,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { - const bypassSnap = event.nativeEvent?.shiftKey === true + const gridSnapActive = isGridSnapActive() + const magneticSnapActive = isMagneticSnapActive() const [localX, localZ] = snapFenceDraftPoint({ point: [event.localPosition[0], event.localPosition[2]], walls: levelWalls, fences: levelFences, ignoreFenceIds: [fenceId], - bypassSnap, - magnetic: !bypassSnap && isMagneticSnapActive(), + magnetic: magneticSnapActive, + step: gridSnapActive ? getSegmentGridStep() : 0, }) if ( - !bypassSnap && + (gridSnapActive || magneticSnapActive) && previousGridPosRef.current && (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) ) { @@ -217,11 +293,11 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { const anchor = dragAnchorRef.current ?? [localX, localZ] dragAnchorRef.current = anchor - const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis( - localX - anchor[0], - localZ - anchor[1], - moveAxisRef.current, - ) + const rawDeltaX = localX - anchor[0] + const rawDeltaZ = localZ - anchor[1] + const [deltaX, deltaZ] = canMoveFreely + ? [rawDeltaX, rawDeltaZ] + : constrainWallMoveDeltaToAxis(rawDeltaX, rawDeltaZ, moveAxisRef.current) const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ] const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ] @@ -245,13 +321,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { return } - // Restore original baseline while paused so the next resume+update - // registers as a single tracked change (undo reverts to original). - restoreOriginal() - useScene.temporal.getState().resume() - applyNodePreview([ - { id: fenceId, start: preview.start, end: preview.end }, + applyCommittedUpdates([ + { id: fenceId, start: preview.start, end: preview.end, path: preview.path }, ...getLinkedFenceUpdates( linkedOriginalsRef.current, originalStart, @@ -260,6 +332,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { preview.end, ), ]) + restoreOriginal() useScene.temporal.getState().pause() triggerSFX('sfx:item-place') @@ -316,7 +389,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { emitter.off('tool:cancel', onCancel) window.removeEventListener('pointerup', onPlacementDragPointerUp) } - }, [exitMoveMode, node]) + }, [exitMoveMode, node, canMoveFreely]) return ( diff --git a/packages/nodes/src/fence/parametrics.ts b/packages/nodes/src/fence/parametrics.ts index 59f3bcd2..8d5f67a3 100644 --- a/packages/nodes/src/fence/parametrics.ts +++ b/packages/nodes/src/fence/parametrics.ts @@ -1,4 +1,4 @@ -import type { ParametricDescriptor } from '@pascal-app/core' +import { isSplineFence, type ParametricDescriptor } from '@pascal-app/core' import { FenceCurveEditor, FenceLengthEditor } from './inspector-editors' import type { FenceNode } from './schema' @@ -37,8 +37,20 @@ export const fenceParametrics: ParametricDescriptor = { { label: 'Dimensions', fields: [ - { key: 'length', kind: 'custom', component: FenceLengthEditor }, - { key: 'curve', kind: 'custom', component: FenceCurveEditor }, + // Length / Curve drive start/end + the single sagitta — meaningless + // for a multi-point spline fence, so hide them when `path` is set. + { + key: 'length', + kind: 'custom', + component: FenceLengthEditor, + visibleIf: (n) => !isSplineFence(n), + }, + { + key: 'curve', + kind: 'custom', + component: FenceCurveEditor, + visibleIf: (n) => !isSplineFence(n), + }, { key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 4, step: 0.05 }, { key: 'thickness', kind: 'number', unit: 'm', min: 0.03, max: 0.5, step: 0.005 }, ], diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 1909bf4c..ac348b9d 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -6,10 +6,12 @@ import { emitter, type FenceNode, type GridEvent, + getTwoPointFenceCurveTangents, getWallMiterBoundaryPoints, type LevelNode, type Point2D, resolveAlignment, + sampleFenceSpline, useScene, type WallMiterData, type WallNode, @@ -17,6 +19,7 @@ import { import { CursorSphere, createFenceOnCurrentLevel, + createSplineFenceOnCurrentLevel, EDITOR_LAYER, type FencePlanPoint, formatAngleRadians, @@ -24,14 +27,18 @@ import { getAngleArcToSegmentReference, getAngleToSegmentReference, getSegmentAngleReferenceAtPoint, + getSegmentGridStep, isAngleSnapActive, + isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, type SegmentAngleReference, snapFenceDraftPoint, + snapScalarToGrid, triggerSFX, useAlignmentGuides, useEditor, + useFenceCurveDraft, useSegmentDraftChain, } from '@pascal-app/editor' import { getSceneTheme, useViewer } from '@pascal-app/viewer' @@ -418,7 +425,7 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } { const { nodes } = useScene.getState() if (!currentLevelId) return { walls: [], fences: [] } const levelNode = nodes[currentLevelId] - if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] } + if (levelNode?.type !== 'level') return { walls: [], fences: [] } const children = (levelNode as LevelNode).children.map((childId) => nodes[childId]) return { walls: children.filter((n): n is WallNode => n?.type === 'wall'), @@ -427,6 +434,14 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } { } export const FenceTool: React.FC = () => { + const fenceMode = useEditor((s) => s.continuationByContext.fence) + if (fenceMode === 'curved') { + return + } + return +} + +const StraightFenceTool: React.FC = () => { const unit = useViewer((state) => state.unit) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') // A placed preset seeds `toolDefaults.fence` before the tool mounts, so @@ -693,6 +708,132 @@ export const FenceTool: React.FC = () => { ) } +const SPLINE_PREVIEW_COLOR = '#8381ed' +const SPLINE_PREVIEW_SEGMENTS = 40 + +const SplineFenceDraft: React.FC = () => { + const previewHeight = + typeof useEditor.getState().toolDefaults.fence?.height === 'number' + ? (useEditor.getState().toolDefaults.fence?.height as number) + : FENCE_PREVIEW_HEIGHT + const [draftPoints, setDraftPoints] = useState([]) + const [cursor, setCursor] = useState(null) + const draftRef = useRef(draftPoints) + + draftRef.current = draftPoints + + // Mirror the draft length into the HUD store so the "finish curve" hint only + // shows once drafting has started; always clear it when the tool unmounts. + useEffect(() => { + useFenceCurveDraft.getState().setPointCount(draftPoints.length) + }, [draftPoints]) + useEffect(() => () => useFenceCurveDraft.getState().reset(), []) + + useEffect(() => () => useEditor.getState().setToolDefaults('fence', null), []) + + useEffect(() => { + const snapPoint = (local: FencePlanPoint): FencePlanPoint => { + const step = isGridSnapActive() ? getSegmentGridStep() : 0 + if (step <= 0) return local + return [snapScalarToGrid(local[0], step), snapScalarToGrid(local[1], step)] + } + + const commit = () => { + const points = draftRef.current + if (points.length >= 2) { + const created = createSplineFenceOnCurrentLevel(points) + if (created) { + triggerSFX('sfx:item-place') + // Once the new curve fence is selected for direct editing, leave + // placement mode so the toolbar matches the active interaction. + useViewer.getState().setSelection({ selectedIds: [created.id] }) + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + } + } + setDraftPoints([]) + setCursor(null) + } + + const onMove = (event: GridEvent) => { + setCursor(snapPoint([event.localPosition[0], event.localPosition[2]])) + } + + const onClick = (event: GridEvent) => { + if (event.nativeEvent.detail >= 2) { + commit() + return + } + const point = snapPoint([event.localPosition[0], event.localPosition[2]]) + triggerSFX('sfx:grid-snap') + setDraftPoints((prev) => [...prev, point]) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') commit() + } + const onCancel = () => { + if (draftRef.current.length === 0) return + markToolCancelConsumed() + setDraftPoints((prev) => prev.slice(0, -1)) + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + } + }, []) + + const previewPoints = cursor ? [...draftPoints, cursor] : draftPoints + const curveGeometry = useMemo(() => { + if (previewPoints.length < 2) return null + const sampled = sampleFenceSpline( + previewPoints, + getTwoPointFenceCurveTangents(previewPoints), + SPLINE_PREVIEW_SEGMENTS, + ) + return new BufferGeometry().setFromPoints( + sampled.map((point) => new Vector3(point.x, previewHeight, point.y)), + ) + }, [previewHeight, previewPoints]) + + return ( + + {cursor && } + {draftPoints.map((point, index) => ( + + + + + ))} + {curveGeometry && ( + // @ts-expect-error - R3F accepts Three line primitives here. + + + + )} + + ) +} + function DraftAngleArc({ arc, color }: { arc: DraftAngleLabel['arc']; color: string }) { const geometry = useMemo(() => { const segmentCount = Math.max( diff --git a/packages/nodes/src/gutter/downspouts-panel.tsx b/packages/nodes/src/gutter/downspouts-panel.tsx index 06cbafd0..640312f6 100644 --- a/packages/nodes/src/gutter/downspouts-panel.tsx +++ b/packages/nodes/src/gutter/downspouts-panel.tsx @@ -76,7 +76,7 @@ export default function DownspoutsPanel() { }), ) - if (!gutter || gutter.type !== 'gutter') return null + if (gutter?.type !== 'gutter') return null const handleSelectDownspout = (id: AnyNodeId) => { setSelection({ selectedIds: [id] }) diff --git a/packages/nodes/src/gutter/floorplan.ts b/packages/nodes/src/gutter/floorplan.ts index e292a906..d032def7 100644 --- a/packages/nodes/src/gutter/floorplan.ts +++ b/packages/nodes/src/gutter/floorplan.ts @@ -31,10 +31,10 @@ export function buildGutterFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null // Compose roof → segment → gutter in plan coords. Each rotation is // negated so SVG's y-down CW matches Three.js' top-down CCW — the same @@ -111,7 +111,7 @@ export function buildGutterFloorplan( const mitreSiblings: GutterWithSegment[] = [] for (const segId of roof.children ?? []) { const sib = ctx.resolve(segId as AnyNodeId) as RoofSegmentNode | undefined - if (!sib || sib.type !== 'roof-segment') continue + if (sib?.type !== 'roof-segment') continue for (const gid of sib.children ?? []) { const g = ctx.resolve(gid as AnyNodeId) as GutterNode | undefined if (g && g.type === 'gutter' && g.id !== node.id) { diff --git a/packages/nodes/src/gutter/length-snap.ts b/packages/nodes/src/gutter/length-snap.ts index 95177a8d..b3c381bb 100644 --- a/packages/nodes/src/gutter/length-snap.ts +++ b/packages/nodes/src/gutter/length-snap.ts @@ -104,7 +104,7 @@ export function snapLengthToCorner( const roofChildren = (roof as { children?: readonly string[] } | undefined)?.children for (const sid of roofChildren ?? []) { const s = sceneApi.get(sid as AnyNodeId) - if (!s || s.type !== 'roof-segment') continue + if (s?.type !== 'roof-segment') continue const tf = segmentTransform(s) for (const gid of s.children ?? []) { const g = sceneApi.get(gid as AnyNodeId) diff --git a/packages/nodes/src/gutter/renderer.tsx b/packages/nodes/src/gutter/renderer.tsx index 5a3862d6..b9adcde2 100644 --- a/packages/nodes/src/gutter/renderer.tsx +++ b/packages/nodes/src/gutter/renderer.tsx @@ -19,6 +19,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner-mitre' import { computeSharedEaveY } from './eave-align' import { computeEaveY } from './eave-snap' @@ -200,6 +201,22 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + // Map gutter-local geometry into the host segment's local frame (where the + // trim cut prisms live) — same pose the inner mesh group is mounted with + // (position [x, liveEaveY, z] + yaw). Computed before the early return so the + // hook order stays stable. + const liveEaveYForClip = sharedEaveY ?? (effectiveSegment ? computeEaveY(effectiveSegment) : 0) + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, liveEaveYForClip, node.position[2] ?? 0), + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), node.rotation ?? 0), + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[2], node.rotation, liveEaveYForClip], + ) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment) + if (!segment || !effectiveSegment) return null // `node.position` is segment-local — the placement tool resolves the @@ -232,7 +249,7 @@ const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => { > { if (state.readOnly) return state const currentNode = state.nodes[nodeId] - if (!currentNode || currentNode.type !== 'item') return state + if (currentNode?.type !== 'item') return state return { materials: { ...state.materials, [sceneMaterial.id as SceneMaterialId]: sceneMaterial }, nodes: { diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx index c20fd6ed..25c2c20c 100644 --- a/packages/nodes/src/liquid-line/tool.tsx +++ b/packages/nodes/src/liquid-line/tool.tsx @@ -226,7 +226,7 @@ function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null const scene = useScene.getState() const linesets: LinesetNode[] = [] for (const n of Object.values(scene.nodes)) { - if (!n || n.type !== 'lineset') continue + if (n?.type !== 'lineset') continue if ((n.parentId as AnyNodeId | null) !== levelId) continue const ls = n as LinesetNode if (ls.path.length >= 2) linesets.push(ls) diff --git a/packages/nodes/src/pipe-trap/definition.ts b/packages/nodes/src/pipe-trap/definition.ts index 490bd394..a2d051ec 100644 --- a/packages/nodes/src/pipe-trap/definition.ts +++ b/packages/nodes/src/pipe-trap/definition.ts @@ -59,7 +59,7 @@ export const pipeTrapDefinition: NodeDefinition = { presentation: { label: 'Trap', description: 'DWV P-trap — water seal on the waste line. The trap arm runs to the vent.', - icon: { kind: 'iconify', name: 'lucide:spline' }, + icon: { kind: 'url', src: '/icons/dwv-pipes.webp' }, paletteSection: 'structure', paletteOrder: 98, }, diff --git a/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts b/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts index 4c0ec951..3a2e9925 100644 --- a/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts +++ b/packages/nodes/src/ridge-vent/__tests__/geometry.test.ts @@ -1,7 +1,65 @@ import { describe, expect, test } from 'bun:test' +import { getDutchRoofMetrics, getRidgeVentLinesForSegment, RoofSegmentNode } from '@pascal-app/core' +import type * as THREE from 'three' +import { getRoofTopSurfaceY } from '../../shared/roof-surface' import { buildRidgeVentGeometry } from '../geometry' import { RidgeVentNode } from '../schema' +function minYAtLocalPoint(geo: THREE.BufferGeometry, targetX: number, targetZ: number): number { + const pos = geo.getAttribute('position').array as Float32Array + let minY = Infinity + for (let i = 0; i < pos.length; i += 3) { + if (Math.abs(pos[i]! - targetX) <= 1e-5 && Math.abs(pos[i + 2]! - targetZ) <= 1e-5) { + minY = Math.min(minY, pos[i + 1]!) + } + } + return minY +} + +function xBounds(geo: THREE.BufferGeometry): { minX: number; maxX: number } { + const pos = geo.getAttribute('position').array as Float32Array + let minX = Infinity + let maxX = -Infinity + for (let i = 0; i < pos.length; i += 3) { + minX = Math.min(minX, pos[i]!) + maxX = Math.max(maxX, pos[i]!) + } + return { minX, maxX } +} + +function maxAbsZNearX(geo: THREE.BufferGeometry, targetX: number, tolerance = 0.03): number { + const pos = geo.getAttribute('position').array as Float32Array + let maxAbsZ = 0 + for (let i = 0; i < pos.length; i += 3) { + if (Math.abs(pos[i]! - targetX) <= tolerance) { + maxAbsZ = Math.max(maxAbsZ, Math.abs(pos[i + 2]!)) + } + } + return maxAbsZ +} + +function expectFinitePositions(geo: THREE.BufferGeometry): void { + const pos = geo.getAttribute('position').array as Float32Array + for (let i = 0; i < pos.length; i++) { + expect(Number.isFinite(pos[i])).toBe(true) + } +} + +function rotatedSurfaceYAt( + segment: RoofSegmentNode, + centerX: number, + centerZ: number, + rotation: number, + localX: number, + localZ: number, +): number { + return getRoofTopSurfaceY( + centerX + localX * Math.cos(rotation) + localZ * Math.sin(rotation), + centerZ - localX * Math.sin(rotation) + localZ * Math.cos(rotation), + segment, + ) +} + describe('buildRidgeVentGeometry', () => { test('returns geometry with matching position / normal / uv counts', () => { const geo = buildRidgeVentGeometry(RidgeVentNode.parse({})) @@ -38,14 +96,282 @@ describe('buildRidgeVentGeometry', () => { test('length scales the X bounds proportionally', () => { const geo = buildRidgeVentGeometry(RidgeVentNode.parse({ length: 4, endCaps: false })) - const pos = geo.getAttribute('position').array as Float32Array - let maxX = -Infinity - let minX = Infinity - for (let i = 0; i < pos.length; i += 3) { - if (pos[i]! > maxX) maxX = pos[i]! - if (pos[i]! < minX) minX = pos[i]! - } + const { minX, maxX } = xBounds(geo) expect(maxX).toBeCloseTo(2) expect(minX).toBeCloseTo(-2) }) + + test('legacy partial ridge vents never produce NaN positions', () => { + const geo = buildRidgeVentGeometry({ + id: 'rvent_legacy', + type: 'ridge-vent', + } as unknown as Parameters[0]) + + expect(geo.getAttribute('position').count).toBeGreaterThan(0) + expectFinitePositions(geo) + }) + + test('clips rendered length to host segment trim without mutating the stored length', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { left: 2, right: 1 }, + }) + const vent = RidgeVentNode.parse({ + length: 8, + position: [0, 0, 0], + rotation: 0, + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const { minX, maxX } = xBounds(geo) + + expect(vent.length).toBe(8) + expect(minX).toBeCloseTo(-2) + expect(maxX).toBeCloseTo(3) + }) + + test('clips rendered ridge vents against diagonal trim planes', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + trim: { frontLeftX: 2, frontLeftZ: 4 }, + }) + const vent = RidgeVentNode.parse({ + length: 8, + width: 0.3, + position: [0, 0, 0], + rotation: 0, + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const { minX, maxX } = xBounds(geo) + + expect(vent.length).toBe(8) + expect(vent.position[0]).toBe(0) + expect(minX).toBeCloseTo(-3.575, 3) + expect(maxX).toBeCloseTo(4) + }) + + test('seats the underside onto rendered roof top faces when a segment is provided', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'gable', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const vent = RidgeVentNode.parse({ + width: 0.4, + height: 0.12, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const halfWidth = vent.width / 2 + const ridgeY = getRoofTopSurfaceY(0, 0, segment) + + expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo( + getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY, + ) + expect(minYAtLocalPoint(geo, -halfLength, halfWidth)).toBeCloseTo( + getRoofTopSurfaceY(-halfLength, halfWidth, segment) - ridgeY, + ) + }) + + test('seats the underside using the vent rotation for diagonal hip caps', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'hip', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const rotation = Math.PI / 4 + const centerX = -2.5 + const centerZ = 1.5 + const vent = RidgeVentNode.parse({ + position: [centerX, 0, centerZ], + rotation, + width: 0.4, + height: 0.12, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const startHalfWidth = maxAbsZNearX(geo, -halfLength, 1e-5) + const ridgeY = rotatedSurfaceYAt(segment, centerX, centerZ, rotation, 0, 0) + + expect(startHalfWidth).toBeGreaterThan(0) + expect(minYAtLocalPoint(geo, -halfLength, -startHalfWidth)).toBeCloseTo( + rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, -startHalfWidth) - ridgeY, + ) + expect(minYAtLocalPoint(geo, -halfLength, startHalfWidth)).toBeCloseTo( + rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, startHalfWidth) - ridgeY, + ) + }) + + test('seats diagonal hip cap ends at different roof heights along the slope', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'hip', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const rotation = Math.PI / 4 + const centerX = -2.5 + const centerZ = 1.5 + const vent = RidgeVentNode.parse({ + position: [centerX, 0, centerZ], + rotation, + length: Math.SQRT2, + width: 0.4, + height: 0.12, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const startHalfWidth = maxAbsZNearX(geo, -halfLength, 1e-5) + const endHalfWidth = maxAbsZNearX(geo, halfLength, 1e-5) + const ridgeY = rotatedSurfaceYAt(segment, centerX, centerZ, rotation, 0, 0) + const leftEndY = + rotatedSurfaceYAt(segment, centerX, centerZ, rotation, -halfLength, -startHalfWidth) - ridgeY + const rightEndY = + rotatedSurfaceYAt(segment, centerX, centerZ, rotation, halfLength, -endHalfWidth) - ridgeY + + expect(startHalfWidth).toBeGreaterThan(0) + expect(endHalfWidth).toBeGreaterThan(0) + expect(leftEndY).not.toBeCloseTo(rightEndY) + expect(minYAtLocalPoint(geo, -halfLength, -startHalfWidth)).toBeCloseTo(leftEndY) + expect(minYAtLocalPoint(geo, halfLength, -endHalfWidth)).toBeCloseTo(rightEndY) + }) + + test('seats the dutch top ridge with the same sloped cap profile as other ridge vents', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const vent = RidgeVentNode.parse({ + name: 'Ridge Vent', + position: [0, 0, 0], + rotation: 0, + length: 5, + width: 0.3, + height: 0.1, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const halfWidth = vent.width / 2 + const ridgeY = getRoofTopSurfaceY(0, 0, segment) + const rawRoofDrop = getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY + + expect(rawRoofDrop).toBeLessThan(-0.05) + expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(rawRoofDrop) + }) + + test('keeps an extended Dutch top ridge level through the rake span instead of drooping onto the hip', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + }) + const metrics = getDutchRoofMetrics(segment) + const rakeReach = Math.min( + segment.dutchGabletRake, + Math.max(0, segment.width / 2 - metrics.waistHalfX) * 0.98, + ) + const vent = RidgeVentNode.parse({ + name: 'Ridge Vent', + position: [0, 0, 0], + rotation: 0, + length: (metrics.waistHalfX + rakeReach) * 2, + width: 0.3, + height: 0.1, + style: 'shingled', + endCaps: false, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const halfLength = vent.length / 2 + const halfWidth = vent.width / 2 + const ridgeY = getRoofTopSurfaceY(0, 0, segment) + const rawRoofDrop = getRoofTopSurfaceY(-halfLength, -halfWidth, segment) - ridgeY + const supportedDrop = getRoofTopSurfaceY(-metrics.waistHalfX, -halfWidth, segment) - ridgeY + + expect(rawRoofDrop).toBeLessThan(supportedDrop - 0.05) + expect(minYAtLocalPoint(geo, -halfLength, -halfWidth)).toBeCloseTo(supportedDrop) + expect(minYAtLocalPoint(geo, halfLength, -halfWidth)).toBeCloseTo(supportedDrop) + }) + + test('tapers Dutch hip ridge vent ends to a small capped nose without shortening the support-line length', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 0.5, + pitch: 45, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + const hipLine = getRidgeVentLinesForSegment(segment).find( + (line) => line.name === 'Hip Ridge Vent' && line.start[0] < 0 && line.start[1] < 0, + ) + expect(hipLine).toBeDefined() + const line = hipLine! + const rotation = Math.atan2(-(line.end[1] - line.start[1]), line.end[0] - line.start[0]) + const vent = RidgeVentNode.parse({ + name: 'Hip Ridge Vent', + position: [(line.start[0] + line.end[0]) / 2, 0, (line.start[1] + line.end[1]) / 2], + rotation, + length: Math.hypot(line.end[0] - line.start[0], line.end[1] - line.start[1]), + width: 0.3, + height: 0.1, + style: 'shingled', + endCaps: true, + }) + + const geo = buildRidgeVentGeometry(vent, segment) + const bounds = xBounds(geo) + const fullHalfLength = vent.length / 2 + const startWidth = maxAbsZNearX(geo, bounds.minX) + const endWidth = maxAbsZNearX(geo, bounds.maxX) + const bodyWidth = maxAbsZNearX(geo, 0, 0.08) + + expect(bounds.minX).toBeLessThanOrEqual(-fullHalfLength + 0.02) + expect(bounds.maxX).toBeGreaterThan(fullHalfLength - 0.15) + expect(startWidth).toBeGreaterThan(0.03) + expect(startWidth).toBeLessThan(0.09) + expect(endWidth).toBeGreaterThan(0.1) + expect(bodyWidth).toBeGreaterThan(startWidth + 0.015) + }) }) diff --git a/packages/nodes/src/ridge-vent/definition.ts b/packages/nodes/src/ridge-vent/definition.ts index 9d11c863..949b187f 100644 --- a/packages/nodes/src/ridge-vent/definition.ts +++ b/packages/nodes/src/ridge-vent/definition.ts @@ -152,8 +152,8 @@ const ridgeVentHandles: HandleDescriptor[] = [ * geometry builder shared with the placement preview + future tests, * no animation or per-frame system. * - * The placement tool snaps to the ridge (segment-local Z=0) wherever - * the cursor lands on a segment. + * The placement tool snaps to the nearest ridge/break line wherever the + * cursor lands on a segment. */ export const ridgeVentDefinition: NodeDefinition = { kind: 'ridge-vent', diff --git a/packages/nodes/src/ridge-vent/floorplan.ts b/packages/nodes/src/ridge-vent/floorplan.ts index 793db221..3261b7fd 100644 --- a/packages/nodes/src/ridge-vent/floorplan.ts +++ b/packages/nodes/src/ridge-vent/floorplan.ts @@ -29,10 +29,10 @@ export function buildRidgeVentFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null const cosR = Math.cos(-roof.rotation) const sinR = Math.sin(-roof.rotation) diff --git a/packages/nodes/src/ridge-vent/geometry.ts b/packages/nodes/src/ridge-vent/geometry.ts index b97eb6bd..8984a5ea 100644 --- a/packages/nodes/src/ridge-vent/geometry.ts +++ b/packages/nodes/src/ridge-vent/geometry.ts @@ -1,8 +1,46 @@ -import type { RidgeVentNode } from '@pascal-app/core' +import { + getDutchRoofMetrics, + getRidgeVentLinesForSegment, + normalizeRoofSegmentTrim, + type RidgeVentNode, + type RoofSegmentNode, +} from '@pascal-app/core' import * as THREE from 'three' +import { getRoofTopSurfaceY } from '../shared/roof-surface' const ARC_SEGS = 16 const SHINGLED_TAB_SIZE = 0.3 +const DEFAULT_RIDGE_VENT_LENGTH = 2 +const DEFAULT_RIDGE_VENT_WIDTH = 0.3 +const DEFAULT_RIDGE_VENT_HEIGHT = 0.1 +type ProfilePoint = [z: number, capY: number] +type RidgeVentGeometryVertex = { + x: number + y: number + z: number + nx: number + ny: number + nz: number + u: number + v: number +} +type SegmentTrimClipPlane = { + signedDistance: (segmentX: number, segmentZ: number) => number +} + +type RidgeVentSupportLine = { + startX: number + endX: number + name: string + taperAtStart: boolean + taperAtEnd: boolean +} +type RidgeVentEndTaper = { + taperAtStart: boolean + taperAtEnd: boolean + taperLength: number + tipHalfWidth: number +} /** * Pure builder for the ridge vent mesh. Each style is a peaked **band** of @@ -22,32 +60,136 @@ const SHINGLED_TAB_SIZE = 0.3 * * `endCaps` closes both ends. Pure: no React, no scene access, no mutation. */ -export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometry { - const halfLen = node.length / 2 - const halfW = node.width / 2 - const h = node.height +export function buildRidgeVentGeometry( + node: RidgeVentNode, + segment?: RoofSegmentNode, +): THREE.BufferGeometry { + const length = finitePositive(node.length, DEFAULT_RIDGE_VENT_LENGTH) + const width = finitePositive(node.width, DEFAULT_RIDGE_VENT_WIDTH) + const h = finitePositive(node.height, DEFAULT_RIDGE_VENT_HEIGHT) + const halfLen = length / 2 + const halfW = width / 2 // Band thickness. Generous enough to read as a solid cap; the eave faces // are `t` tall, which is the depth the user actually sees from the side. const t = Math.max(0.02, h * 0.4) + const centerX = finiteNumber(node.position?.[0], 0) + const centerZ = finiteNumber(node.position?.[2], 0) + const rotationY = finiteNumber(node.rotation, 0) + const sinR = Math.sin(rotationY) + const cosR = Math.cos(rotationY) + const dutchTopRidgeSupport = getDutchTopRidgeSupport(segment, centerX, centerZ, rotationY) + const surfaceYAt = (x: number, z: number) => { + if (!segment) return 0 + let sampleX = centerX + x * cosR + z * sinR + let sampleZ = centerZ - x * sinR + z * cosR + if (dutchTopRidgeSupport) { + if (dutchTopRidgeSupport.axis === 'x') { + sampleX = clamp( + sampleX, + -dutchTopRidgeSupport.innerHalfSpan, + dutchTopRidgeSupport.innerHalfSpan, + ) + } else { + sampleZ = clamp( + sampleZ, + -dutchTopRidgeSupport.innerHalfSpan, + dutchTopRidgeSupport.innerHalfSpan, + ) + } + } + return getRoofTopSurfaceY(sampleX, sampleZ, segment) + } + const ridgeY = surfaceYAt(0, 0) + const seatYAt = (x: number, z: number) => (segment ? surfaceYAt(x, z) - ridgeY : 0) + const top = node.style === 'metal' ? metalTop(halfW, h, t) : node.style === 'shingled' ? shingledTop(halfW, h, t) : standardTop(halfW, h, t) + const supportLine = segment ? getSupportLineForVent(segment, node) : null + const endTaper = getRidgeVentEndTaper(supportLine, width, h) const positions: number[] = [] const normals: number[] = [] const uvs: number[] = [] - buildBand(positions, normals, uvs, top, t, halfLen, node.endCaps) + buildBand(positions, normals, uvs, top, seatYAt, -halfLen, halfLen, node.endCaps, endTaper) if (node.style === 'shingled') { - addShingledTabs(positions, normals, uvs, halfLen, top, h) + addShingledTabs(positions, normals, uvs, -halfLen, halfLen, top, h, seatYAt, endTaper) } - return buildBufferGeometry(positions, normals, uvs) + const geometry = buildBufferGeometry(positions, normals, uvs) + if (!segment) return geometry + + const clipped = clipRidgeVentGeometryToSegmentTrim(geometry, node, segment) + if (clipped !== geometry) geometry.dispose() + return clipped +} + +function finiteNumber(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function finitePositive(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +function getRidgeVentEndTaper( + supportLine: RidgeVentSupportLine | null, + width: number, + height: number, +): RidgeVentEndTaper | null { + if ( + !supportLine || + (supportLine.name !== 'Hip Ridge Vent' && supportLine.name !== 'Slope Ridge Vent') + ) { + return null + } + + const lineLength = supportLine.endX - supportLine.startX + const taperLength = Math.min( + Math.max(0.07, width * 0.45, height * 0.9), + Math.max(0, lineLength / 2 - 0.02), + ) + if (!(taperLength > 0.001)) return null + + return { + taperAtStart: supportLine.taperAtStart, + taperAtEnd: supportLine.taperAtEnd, + taperLength, + tipHalfWidth: Math.min(width * 0.34, Math.max(0.04, width * 0.26)), + } +} + +function getDutchTopRidgeSupport( + segment: RoofSegmentNode | undefined, + centerX: number, + centerZ: number, + rotationY: number, +): { axis: 'x' | 'z'; innerHalfSpan: number } | null { + if (segment?.roofType !== 'dutch') return null + + const metrics = getDutchRoofMetrics(segment) + const onWidthAxisTopRidge = + metrics.axis === 'x' && Math.abs(centerZ) <= 1e-4 && Math.abs(Math.sin(rotationY)) <= 1e-4 + const onDepthAxisTopRidge = + metrics.axis === 'z' && Math.abs(centerX) <= 1e-4 && Math.abs(Math.cos(rotationY)) <= 1e-4 + + if (onWidthAxisTopRidge) { + return { axis: 'x', innerHalfSpan: metrics.waistHalfX } + } + if (onDepthAxisTopRidge) { + return { axis: 'z', innerHalfSpan: metrics.waistHalfZ } + } + return null } // ─── Top profiles (open polylines eave → peak → eave, in [z, y]) ───────── @@ -55,8 +197,8 @@ export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometr // eaves, seating the cap on the roof while leaving a peaked void beneath. // Smooth rounded arch. -function standardTop(halfW: number, h: number, t: number): [number, number][] { - const pts: [number, number][] = [] +function standardTop(halfW: number, h: number, t: number): ProfilePoint[] { + const pts: ProfilePoint[] = [] for (let i = 0; i <= ARC_SEGS; i++) { const frac = i / ARC_SEGS const z = -halfW + frac * 2 * halfW @@ -67,7 +209,7 @@ function standardTop(halfW: number, h: number, t: number): [number, number][] { } // Angular peak with a narrow flat ridge at the top. -function shingledTop(halfW: number, h: number, t: number): [number, number][] { +function shingledTop(halfW: number, h: number, t: number): ProfilePoint[] { const peakHalf = halfW * 0.12 return [ [-halfW, t], @@ -78,7 +220,7 @@ function shingledTop(halfW: number, h: number, t: number): [number, number][] { } // Bent-metal cap: steep folds up to a wide flat standing seam. -function metalTop(halfW: number, h: number, t: number): [number, number][] { +function metalTop(halfW: number, h: number, t: number): ProfilePoint[] { const seamHalf = halfW * 0.5 const shoulderY = t + (h - t) * 0.5 return [ @@ -97,78 +239,97 @@ function buildBand( positions: number[], normals: number[], uvs: number[], - top: [number, number][], - t: number, - halfLen: number, + top: ProfilePoint[], + seatYAt: (x: number, z: number) => number, + startX: number, + endX: number, withCaps: boolean, + endTaper: RidgeVentEndTaper | null = null, ): void { const n = top.length - // Underside: the same profile dropped straight down by `t` (eaves → y 0). - const inner: [number, number][] = top.map(([z, y]) => [z, y - t]) + const halfWidth = getProfileHalfWidth(top) + const stations = getRidgeVentSweepStations(startX, endX, endTaper) + const scaledZAt = (x: number, z: number): number => + z * getProfileScaleAtX(x, startX, endX, halfWidth, endTaper) + const seatAt = (x: number, z: number): number => seatYAt(x, scaledZAt(x, z)) + const topAt = (x: number, z: number, capY: number): number => seatAt(x, z) + capY // Top surface + underside, swept along the ridge length. - for (let i = 0; i < n - 1; i++) { - const [z0, y0] = top[i]! - const [z1, y1] = top[i + 1]! - pushQuad( - positions, - normals, - uvs, - [-halfLen, y0, z0], - [halfLen, y0, z0], - [halfLen, y1, z1], - [-halfLen, y1, z1], - [0, 1, 0], - ) - const [iz0, iy0] = inner[i]! - const [iz1, iy1] = inner[i + 1]! - pushQuad( - positions, - normals, - uvs, - [-halfLen, iy0, iz0], - [halfLen, iy0, iz0], - [halfLen, iy1, iz1], - [-halfLen, iy1, iz1], - [0, -1, 0], - ) + for (let station = 0; station < stations.length - 1; station += 1) { + const x0 = stations[station]! + const x1 = stations[station + 1]! + for (let i = 0; i < n - 1; i++) { + const [z0, capY0] = top[i]! + const [z1, capY1] = top[i + 1]! + const x0z0 = scaledZAt(x0, z0) + const x1z0 = scaledZAt(x1, z0) + const x1z1 = scaledZAt(x1, z1) + const x0z1 = scaledZAt(x0, z1) + pushQuad( + positions, + normals, + uvs, + [x0, topAt(x0, z0, capY0), x0z0], + [x1, topAt(x1, z0, capY0), x1z0], + [x1, topAt(x1, z1, capY1), x1z1], + [x0, topAt(x0, z1, capY1), x0z1], + [0, 1, 0], + ) + pushQuad( + positions, + normals, + uvs, + [x0, seatAt(x0, z0), x0z0], + [x1, seatAt(x1, z0), x1z0], + [x1, seatAt(x1, z1), x1z1], + [x0, seatAt(x0, z1), x0z1], + [0, -1, 0], + ) + } } // Eave thickness faces (the visible depth along each long edge). - for (const idx of [0, n - 1]) { - const [z, yTop] = top[idx]! - const [, yInner] = inner[idx]! - const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1] - pushQuad( - positions, - normals, - uvs, - [-halfLen, yInner, z], - [halfLen, yInner, z], - [halfLen, yTop, z], - [-halfLen, yTop, z], - hint, - ) + for (let station = 0; station < stations.length - 1; station += 1) { + const x0 = stations[station]! + const x1 = stations[station + 1]! + for (const idx of [0, n - 1]) { + const [z, capY] = top[idx]! + const x0z = scaledZAt(x0, z) + const x1z = scaledZAt(x1, z) + const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1] + pushQuad( + positions, + normals, + uvs, + [x0, seatAt(x0, z), x0z], + [x1, seatAt(x1, z), x1z], + [x1, topAt(x1, z, capY), x1z], + [x0, topAt(x0, z, capY), x0z], + hint, + ) + } } // End caps: the band's cross-section ring at each end. if (withCaps) { - for (const sign of [-1, 1] as const) { - const x = sign * halfLen + for (const [x, sign] of [ + [startX, -1], + [endX, 1], + ] as const) { const hint: [number, number, number] = [sign, 0, 0] for (let i = 0; i < n - 1; i++) { - const [z0, y0] = top[i]! - const [z1, y1] = top[i + 1]! - const [iz0, iy0] = inner[i]! - const [iz1, iy1] = inner[i + 1]! + const [z0, capY0] = top[i]! + const [z1, capY1] = top[i + 1]! + const scaledZ0 = scaledZAt(x, z0) + const scaledZ1 = scaledZAt(x, z1) pushQuad( positions, normals, uvs, - [x, y0, z0], - [x, y1, z1], - [x, iy1, iz1], - [x, iy0, iz0], + [x, topAt(x, z0, capY0), scaledZ0], + [x, topAt(x, z1, capY1), scaledZ1], + [x, seatAt(x, z1), scaledZ1], + [x, seatAt(x, z0), scaledZ0], hint, ) } @@ -176,6 +337,45 @@ function buildBand( } } +function getProfileHalfWidth(top: ProfilePoint[]): number { + return top.reduce((halfWidth, [z]) => Math.max(halfWidth, Math.abs(z)), 0) +} + +function getRidgeVentSweepStations( + startX: number, + endX: number, + endTaper: RidgeVentEndTaper | null, +): number[] { + const stations = [startX, endX] + if (endTaper?.taperAtStart) stations.push(startX + endTaper.taperLength) + if (endTaper?.taperAtEnd) stations.push(endX - endTaper.taperLength) + return stations + .filter((x) => x >= startX && x <= endX) + .sort((a, b) => a - b) + .filter((x, index, sorted) => index === 0 || Math.abs(x - sorted[index - 1]!) > 1e-5) +} + +function getProfileScaleAtX( + x: number, + startX: number, + endX: number, + halfWidth: number, + endTaper: RidgeVentEndTaper | null, +): number { + if (!endTaper || !(halfWidth > 0.0001)) return 1 + + const tipScale = clamp(endTaper.tipHalfWidth / halfWidth, 0, 1) + if (endTaper.taperAtStart && x <= startX + endTaper.taperLength) { + const progress = clamp((x - startX) / endTaper.taperLength, 0, 1) + return lerp(tipScale, 1, progress) + } + if (endTaper.taperAtEnd && x >= endX - endTaper.taperLength) { + const progress = clamp((endX - x) / endTaper.taperLength, 0, 1) + return lerp(tipScale, 1, progress) + } + return 1 +} + // ─── Shingled course ridges ────────────────────────────────────────────── // Thin raised lines running across the cap at intervals, suggesting // overlapping shingle courses. Sit on the top profile edges. @@ -184,21 +384,27 @@ function addShingledTabs( positions: number[], normals: number[], uvs: number[], - halfLen: number, - top: [number, number][], + startX: number, + endX: number, + top: ProfilePoint[], h: number, + seatYAt: (x: number, z: number) => number, + endTaper: RidgeVentEndTaper | null = null, ): void { - const totalLen = halfLen * 2 + const totalLen = endX - startX const numTabs = Math.max(2, Math.round(totalLen / SHINGLED_TAB_SIZE)) const tabLen = totalLen / numTabs const ridgeH = h * 0.06 const ridgeD = Math.min(0.01, tabLen * 0.15) for (let tab = 1; tab < numTabs; tab++) { - const x = -halfLen + tab * tabLen + const x = startX + tab * tabLen + if (isInsideEndTaper(x, startX, endX, endTaper)) continue for (let i = 0; i < top.length - 1; i++) { - const [z0, y0] = top[i]! - const [z1, y1] = top[i + 1]! + const [z0, capY0] = top[i]! + const [z1, capY1] = top[i + 1]! + const y0 = seatYAt(x, z0) + capY0 + const y1 = seatYAt(x, z1) + capY1 const dz = z1 - z0 const dy = y1 - y0 const len = Math.sqrt(dz * dz + dy * dy) || 1 @@ -208,6 +414,11 @@ function addShingledTabs( const r0z = z0 + nz * ridgeH const r1y = y1 + ny * ridgeH const r1z = z1 + nz * ridgeH + const backX = x - ridgeD + const by0 = seatYAt(backX, z0) + capY0 + const by1 = seatYAt(backX, z1) + capY1 + const br0y = by0 + ny * ridgeH + const br1y = by1 + ny * ridgeH pushQuad( positions, normals, @@ -222,16 +433,29 @@ function addShingledTabs( positions, normals, uvs, - [x - ridgeD, r0y, r0z], - [x - ridgeD, r1y, r1z], - [x - ridgeD, y1, z1], - [x - ridgeD, y0, z0], + [backX, br0y, r0z], + [backX, br1y, r1z], + [backX, by1, z1], + [backX, by0, z0], [-1, 0, 0], ) } } } +function isInsideEndTaper( + x: number, + startX: number, + endX: number, + endTaper: RidgeVentEndTaper | null, +): boolean { + if (!endTaper) return false + return ( + (endTaper.taperAtStart && x <= startX + endTaper.taperLength) || + (endTaper.taperAtEnd && x >= endX - endTaper.taperLength) + ) +} + // ─── Geometry plumbing ─────────────────────────────────────────────────── function buildBufferGeometry( @@ -240,6 +464,13 @@ function buildBufferGeometry( uvs: number[], ): THREE.BufferGeometry { const geo = new THREE.BufferGeometry() + if (positions.length === 0) { + geo.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + geo.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3)) + geo.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2)) + geo.computeBoundingSphere() + return geo + } geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) @@ -247,6 +478,274 @@ function buildBufferGeometry( return geo } +function clipRidgeVentGeometryToSegmentTrim( + geometry: THREE.BufferGeometry, + node: RidgeVentNode, + segment: RoofSegmentNode, +): THREE.BufferGeometry { + const planes = getSegmentTrimClipPlanes(segment) + if (planes.length === 0) return geometry + + const position = geometry.getAttribute('position') + const normal = geometry.getAttribute('normal') + const uv = geometry.getAttribute('uv') + if (!position || !normal || !uv) return geometry + + const positions: number[] = [] + const normals: number[] = [] + const uvs: number[] = [] + + for (let i = 0; i < position.count; i += 3) { + let polygon: RidgeVentGeometryVertex[] = [ + readGeometryVertex(position, normal, uv, i), + readGeometryVertex(position, normal, uv, i + 1), + readGeometryVertex(position, normal, uv, i + 2), + ] + + for (const plane of planes) { + polygon = clipPolygonToSegmentTrimPlane(polygon, plane, node) + if (polygon.length < 3) break + } + + if (polygon.length < 3) continue + for (let j = 1; j < polygon.length - 1; j += 1) { + pushGeometryVertex(positions, normals, uvs, polygon[0]!) + pushGeometryVertex(positions, normals, uvs, polygon[j]!) + pushGeometryVertex(positions, normals, uvs, polygon[j + 1]!) + } + } + + return buildBufferGeometry(positions, normals, uvs) +} + +function getSupportLineForVent( + segment: RoofSegmentNode, + node: RidgeVentNode, +): RidgeVentSupportLine | null { + const lines = getRidgeVentLinesForSegment(segment) + if (lines.length === 0) return null + + const centerX = finiteNumber(node.position?.[0], 0) + const centerZ = finiteNumber(node.position?.[2], 0) + const rotationY = finiteNumber(node.rotation, 0) + const dirX = Math.cos(rotationY) + const dirZ = -Math.sin(rotationY) + + let best: RidgeVentSupportLine | null = null + let bestScore = Number.POSITIVE_INFINITY + + for (const line of lines) { + const [sx, sz] = line.start + const [ex, ez] = line.end + const lineDx = ex - sx + const lineDz = ez - sz + const lineLength = Math.hypot(lineDx, lineDz) + if (!(lineLength > 1e-4)) continue + + const unitX = lineDx / lineLength + const unitZ = lineDz / lineLength + const yawPenalty = 1 - Math.abs(unitX * dirX + unitZ * dirZ) + const centerOffsetX = centerX - sx + const centerOffsetZ = centerZ - sz + const t = Math.max(0, Math.min(lineLength, centerOffsetX * unitX + centerOffsetZ * unitZ)) + const nearestX = sx + unitX * t + const nearestZ = sz + unitZ * t + const distanceSq = (centerX - nearestX) ** 2 + (centerZ - nearestZ) ** 2 + const score = distanceSq + yawPenalty * 6 + + if (score < bestScore) { + bestScore = score + const startLocalX = (sx - centerX) * dirX + (sz - centerZ) * dirZ + const endLocalX = (ex - centerX) * dirX + (ez - centerZ) * dirZ + const startRadiusSq = sx * sx + sz * sz + const endRadiusSq = ex * ex + ez * ez + const outerIsStart = startRadiusSq > endRadiusSq + const minIsStart = startLocalX <= endLocalX + best = { + startX: Math.min(startLocalX, endLocalX), + endX: Math.max(startLocalX, endLocalX), + name: line.name, + taperAtStart: minIsStart ? outerIsStart : !outerIsStart, + taperAtEnd: minIsStart ? !outerIsStart : outerIsStart, + } + } + } + + return best +} + +function readGeometryVertex( + position: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + normal: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + uv: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + index: number, +): RidgeVentGeometryVertex { + return { + x: position.getX(index), + y: position.getY(index), + z: position.getZ(index), + nx: normal.getX(index), + ny: normal.getY(index), + nz: normal.getZ(index), + u: uv.getX(index), + v: uv.getY(index), + } +} + +function pushGeometryVertex( + positions: number[], + normals: number[], + uvs: number[], + vertex: RidgeVentGeometryVertex, +) { + positions.push(vertex.x, vertex.y, vertex.z) + normals.push(vertex.nx, vertex.ny, vertex.nz) + uvs.push(vertex.u, vertex.v) +} + +function clipPolygonToSegmentTrimPlane( + polygon: RidgeVentGeometryVertex[], + plane: SegmentTrimClipPlane, + node: RidgeVentNode, +): RidgeVentGeometryVertex[] { + const next: RidgeVentGeometryVertex[] = [] + let previous = polygon[polygon.length - 1]! + let previousDistance = getTrimClipDistance(previous, plane, node) + let previousInside = previousDistance <= 1e-6 + + for (const current of polygon) { + const currentDistance = getTrimClipDistance(current, plane, node) + const currentInside = currentDistance <= 1e-6 + + if (currentInside) { + if (!previousInside) { + next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance)) + } + next.push(current) + } else if (previousInside) { + next.push(interpolateGeometryVertex(previous, current, previousDistance, currentDistance)) + } + + previous = current + previousDistance = currentDistance + previousInside = currentInside + } + + return next +} + +function getTrimClipDistance( + vertex: RidgeVentGeometryVertex, + plane: SegmentTrimClipPlane, + node: RidgeVentNode, +): number { + const centerX = finiteNumber(node.position?.[0], 0) + const centerZ = finiteNumber(node.position?.[2], 0) + const rotationY = finiteNumber(node.rotation, 0) + const segmentX = centerX + vertex.x * Math.cos(rotationY) + vertex.z * Math.sin(rotationY) + const segmentZ = centerZ - vertex.x * Math.sin(rotationY) + vertex.z * Math.cos(rotationY) + return plane.signedDistance(segmentX, segmentZ) +} + +function interpolateGeometryVertex( + a: RidgeVentGeometryVertex, + b: RidgeVentGeometryVertex, + distanceA: number, + distanceB: number, +): RidgeVentGeometryVertex { + const t = distanceA / (distanceA - distanceB || 1) + return { + x: lerp(a.x, b.x, t), + y: lerp(a.y, b.y, t), + z: lerp(a.z, b.z, t), + nx: lerp(a.nx, b.nx, t), + ny: lerp(a.ny, b.ny, t), + nz: lerp(a.nz, b.nz, t), + u: lerp(a.u, b.u, t), + v: lerp(a.v, b.v, t), + } +} + +function lerp(a: number, b: number, t: number): number { + return a + (b - a) * t +} + +function getSegmentTrimClipPlanes(segment: RoofSegmentNode): SegmentTrimClipPlane[] { + const trim = normalizeRoofSegmentTrim(segment) + const planes: SegmentTrimClipPlane[] = [] + const leftX = -segment.width / 2 + trim.left + const rightX = segment.width / 2 - trim.right + const frontZ = segment.depth / 2 - trim.front + const backZ = -segment.depth / 2 + trim.back + + if (trim.left > 0) planes.push({ signedDistance: (x) => leftX - x }) + if (trim.right > 0) planes.push({ signedDistance: (x) => x - rightX }) + if (trim.front > 0) planes.push({ signedDistance: (_x, z) => z - frontZ }) + if (trim.back > 0) planes.push({ signedDistance: (_x, z) => backZ - z }) + + const diagonalPlane = ( + lineA: readonly [number, number], + lineB: readonly [number, number], + outsidePoint: readonly [number, number], + ): SegmentTrimClipPlane | null => { + const dx = lineB[0] - lineA[0] + const dz = lineB[1] - lineA[1] + const length = Math.hypot(dx, dz) + if (!(length > 0)) return null + let nx = -dz / length + let nz = dx / length + const midX = (lineA[0] + lineB[0]) / 2 + const midZ = (lineA[1] + lineB[1]) / 2 + if (nx * (outsidePoint[0] - midX) + nz * (outsidePoint[1] - midZ) < 0) { + nx *= -1 + nz *= -1 + } + return { + signedDistance: (x, z) => nx * (x - midX) + nz * (z - midZ), + } + } + + const pushDiagonalPlane = ( + lineA: readonly [number, number], + lineB: readonly [number, number], + outsidePoint: readonly [number, number], + ) => { + const plane = diagonalPlane(lineA, lineB, outsidePoint) + if (plane) planes.push(plane) + } + + if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) { + pushDiagonalPlane( + [leftX + trim.frontLeftX, frontZ], + [leftX, frontZ - trim.frontLeftZ], + [leftX - 1, frontZ + 1], + ) + } + if (trim.frontRightX > 0 && trim.frontRightZ > 0) { + pushDiagonalPlane( + [rightX, frontZ - trim.frontRightZ], + [rightX - trim.frontRightX, frontZ], + [rightX + 1, frontZ + 1], + ) + } + if (trim.backLeftX > 0 && trim.backLeftZ > 0) { + pushDiagonalPlane( + [leftX, backZ + trim.backLeftZ], + [leftX + trim.backLeftX, backZ], + [leftX - 1, backZ - 1], + ) + } + if (trim.backRightX > 0 && trim.backRightZ > 0) { + pushDiagonalPlane( + [rightX - trim.backRightX, backZ], + [rightX, backZ + trim.backRightZ], + [rightX + 1, backZ - 1], + ) + } + + return planes +} + // Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so // the shared flat normal points toward `hint`. UVs are dimension-based so // painted presets tile at world scale across the ridge length and the cap. diff --git a/packages/nodes/src/ridge-vent/move-tool.tsx b/packages/nodes/src/ridge-vent/move-tool.tsx index 6ca2c49e..70508f5e 100644 --- a/packages/nodes/src/ridge-vent/move-tool.tsx +++ b/packages/nodes/src/ridge-vent/move-tool.tsx @@ -23,7 +23,8 @@ import { roofSegmentLocalToBuildingLocal, snapRelativeRoofDragTarget, } from '../shared/relative-roof-drag' -import { getSurfaceY } from '../shared/roof-surface' +import { resolveRidgeSnap } from '../shared/ridge-snap' +import { getRoofTopSurfaceY } from '../shared/roof-surface' import { clearRoofSurfacePlacementGuides, publishRoofSurfaceNodePlacementGuides, @@ -32,7 +33,8 @@ import RidgeVentPreview from './preview' type RidgeVentDragTarget = Pick & { localY: number - localZ: 0 + localZ: number + rotation: number } /** @@ -85,11 +87,15 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { const rawTarget = roofDrag.resolve(event) if (!rawTarget) return null const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true) + const snap = resolveRidgeSnap(target.segment, target.localX, target.localZ) + if (!snap) return null + const yOffset = original.position[1] ?? 0 return { segment: target.segment, - localX: target.localX, - localY: getSurfaceY(target.localX, 0, target.segment), - localZ: 0, + localX: snap.localX, + localY: getRoofTopSurfaceY(snap.localX, snap.localZ, target.segment) + yOffset, + localZ: snap.localZ, + rotation: snap.rotation, } } @@ -111,7 +117,7 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { lastSnap = [sx, sz] } - setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0)) + setPreviewYaw((event.node.rotation ?? 0) + (target.segment.rotation ?? 0) + target.rotation) setPreviewPos( roofSegmentLocalToBuildingLocal(target.segment.id, [ target.localX, @@ -158,8 +164,8 @@ export default function MoveRidgeVentTool({ node }: { node: RidgeVentNode }) { st.updateNode(node.id as AnyNodeId, { roofSegmentId: targetSegmentId, parentId: targetSegmentId, - position: [target.localX, target.localY, target.localZ], - rotation: original.rotation, + position: [target.localX, original.position[1] ?? 0, target.localZ], + rotation: target.rotation, visible: true, metadata: {}, }) diff --git a/packages/nodes/src/ridge-vent/renderer.tsx b/packages/nodes/src/ridge-vent/renderer.tsx index 15386ed9..63591703 100644 --- a/packages/nodes/src/ridge-vent/renderer.tsx +++ b/packages/nodes/src/ridge-vent/renderer.tsx @@ -2,7 +2,10 @@ import { type AnyNodeId, + getEffectiveRoofSurfaceMaterial, + getEffectiveSegmentSurfaceMaterial, type RidgeVentNode, + type RoofNode, type RoofSegmentNode, useLiveNodeOverrides, useRegistry, @@ -13,25 +16,54 @@ import { createMaterial, createMaterialFromPresetRef, createSurfaceRoleMaterial, + getRoofMaterialArray, useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { RIDGE_LIFT, resolveRidgeSnap } from '../shared/ridge-snap' -import { getSurfaceY } from '../shared/roof-surface' +import { getRoofTopSurfaceY } from '../shared/roof-surface' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildRidgeVentGeometry } from './geometry' -// Single white fallback for every style. Paint customisation comes from -// `node.material` / `node.materialPreset` (default: `preset-white`); the -// fallback only fires for legacy nodes that pre-date the schema default -// and shouldn't punish them with style-specific grey/metal that diverges -// from the "default white" the inspector advertises. -const defaultMaterial = new THREE.MeshStandardMaterial({ - color: 0xff_ff_ff, - roughness: 0.85, - metalness: 0.1, -}) +function ridgeVentSegmentGeometryKey(segment: RoofSegmentNode | undefined): string { + if (!segment) return 'none' + const trim = segment.trim + return [ + segment.roofType, + segment.width, + segment.depth, + segment.wallHeight, + segment.pitch, + segment.wallThickness, + segment.deckThickness, + segment.overhang, + segment.shingleThickness, + segment.gambrelLowerWidthRatio, + segment.gambrelLowerHeightRatio, + segment.mansardSteepWidthRatio, + segment.mansardSteepHeightRatio, + segment.dutchHipWidthRatio, + segment.dutchHipHeightRatio, + trim.left, + trim.right, + trim.front, + trim.back, + trim.frontLeft, + trim.frontRight, + trim.backLeft, + trim.backRight, + trim.frontLeftX, + trim.frontLeftZ, + trim.frontRightX, + trim.frontRightZ, + trim.backLeftX, + trim.backLeftZ, + trim.backRightX, + trim.backRightZ, + ].join('|') +} /** * Ridge vent renderer. Sits along the ridge of a roof-segment — no @@ -66,6 +98,7 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { const node: RidgeVentNode = overrides ? ({ ...storeNode, ...overrides } as RidgeVentNode) : storeNode + const nodePosition = node.position ?? [0, 0, 0] const segmentStore = useScene((state) => node.roofSegmentId @@ -87,11 +120,44 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { ? ({ ...segmentStore, ...segmentOverrides } as RoofSegmentNode) : segmentStore : undefined + const parentRoof = useScene((state) => + segmentStore?.parentId + ? (state.nodes[segmentStore.parentId as AnyNodeId] as RoofNode | undefined) + : undefined, + ) + const segmentGeometryKey = ridgeVentSegmentGeometryKey(segment) + const rotationY = node.rotation ?? 0 + const snap = useMemo( + () => + segment && Math.abs(rotationY) < 1e-5 + ? resolveRidgeSnap(segment, nodePosition[0] ?? 0, nodePosition[2] ?? 0) + : null, + [segment, rotationY, nodePosition[0], nodePosition[2]], + ) + const ridgeX = snap ? snap.localX : (nodePosition[0] ?? 0) + const ridgeZ = snap ? snap.localZ : (nodePosition[2] ?? 0) + const effectiveNode = useMemo( + () => ({ + ...node, + position: [ridgeX, nodePosition[1] ?? 0, ridgeZ], + }), + [node, ridgeX, ridgeZ, nodePosition[1]], + ) - // biome-ignore lint/correctness/useExhaustiveDependencies: deps deliberately list the build inputs; depending on the whole object would rebuild on unrelated field changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: `segmentGeometryKey` captures the segment fields that affect the generated mesh; depending on the whole segment would rebuild on unrelated node changes. const geometry = useMemo( - () => buildRidgeVentGeometry(node), - [node.length, node.width, node.height, node.style, node.endCaps], + () => buildRidgeVentGeometry(effectiveNode, segment), + [ + effectiveNode.length, + effectiveNode.width, + effectiveNode.height, + effectiveNode.style, + effectiveNode.endCaps, + effectiveNode.rotation, + effectiveNode.position[0], + effectiveNode.position[2], + segmentGeometryKey, + ], ) useEffect(() => () => geometry.dispose(), [geometry]) @@ -104,13 +170,62 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { // closed solid in `geometry.ts` is the right fix if the underside-view // becomes noticeable. const material = useMemo(() => { - if (!textures || (!node.material && !node.materialPreset)) { - return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + const createDefaultTopMaterial = () => { + const parentSpec = parentRoof ? getEffectiveRoofSurfaceMaterial(parentRoof, 'top') : undefined + const spec = segment ? getEffectiveSegmentSurfaceMaterial(segment, 'top', parentSpec) : null + + if (typeof spec?.materialPreset === 'string') { + const resolved = createMaterialFromPresetRef(spec.materialPreset, shading) + if (resolved) return resolved + } + if (spec?.material !== undefined) { + return createMaterial(spec.material, shading) + } + + const roofMaterials = parentRoof + ? getRoofMaterialArray(parentRoof, shading, textures, colorPreset, sceneTheme) + : null + return ( + roofMaterials?.[3] ?? + createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme) + ) } - return node.material - ? createMaterial(node.material, shading) - : (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial) - }, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset]) + + if (node.material) { + return createMaterial(node.material, shading) + } + if (node.materialPreset) { + return createMaterialFromPresetRef(node.materialPreset, shading) ?? createDefaultTopMaterial() + } + return createDefaultTopMaterial() + }, [ + textures, + colorPreset, + sceneTheme, + shading, + node.material, + node.materialPreset, + segment, + parentRoof, + ]) + + // Map vent-local geometry into the host segment's local frame (where the trim + // cut prisms live). Recompose the same pose the inner mesh group is mounted + // with — ridge snap for X/Z, slope-locked base Y + offset, yaw — so the clip + // matches the rendered placement. Computed before the early return so the + // hook order stays stable. + const localToSegment = useMemo(() => { + if (!segment) return new THREE.Matrix4() + const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment) + const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0)) + const ridgeY = baseY + RIDGE_LIFT + yOffset + return new THREE.Matrix4().compose( + new THREE.Vector3(ridgeX, ridgeY, ridgeZ), + new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), rotationY), + new THREE.Vector3(1, 1, 1), + ) + }, [segment, ridgeX, ridgeZ, rotationY, nodePosition[1]]) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, segment, localToSegment) if (!segment) return null @@ -126,33 +241,29 @@ const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => { const segPos = segment.position ?? [0, 0, 0] const segRotY = segment.rotation ?? 0 - // Lock the BASE position to the ridge so the vent always starts on the - // slope top; treat `position[1]` and `position[2]` as user-tunable OFFSETS - // off that base (Y above ridge lift, Z away from ridge centerline). So + // Lock the BASE position to the rendered roof skin so the vent always starts + // on the roof structure; treat `position[1]` and `position[2]` as user-tunable OFFSETS + // off that base (Y above the surface, Z away from ridge centerline). So // after placement the inspector's Y / Z sliders nudge the vent off the // locked ridge without losing the slope-tracking base. X is the position // along the ridge — the snap re-clamps it to the segment's ridge span. - const snap = resolveRidgeSnap(segment, node.position[0] ?? 0, 0) - const ridgeX = snap ? snap.localX : (node.position[0] ?? 0) - const baseZ = snap ? snap.localZ : 0 - const baseY = getSurfaceY(ridgeX, baseZ, segment) + RIDGE_LIFT + const baseY = getRoofTopSurfaceY(ridgeX, ridgeZ, segment) // Clamp legacy stored Y (absolute peak height from earlier versions) so the // vent doesn't fly off when the field was an absolute Y instead of offset. - const yOffset = Math.max(-2, Math.min(2, node.position[1] ?? 0)) - const ridgeY = baseY + yOffset - const ridgeZ = baseZ + (node.position[2] ?? 0) + const yOffset = Math.max(-2, Math.min(2, nodePosition[1] ?? 0)) + const ridgeY = baseY + RIDGE_LIFT + yOffset return ( { const activeBuildingId = useViewer((s) => s.selection.buildingId) @@ -72,9 +71,9 @@ const RidgeVentTool = () => { ) if (!hit) return - // Project the cursor onto the segment's ridge line (clamped to the - // segment's ridge span). The preview then moves ALONG the ridge as the - // cursor moves — never off it. Flat segments have no ridge: hide. + // Project the cursor onto the nearest segment ridge/break line. + // The preview then moves along that line as the cursor moves — never + // off it. Flat segments have no ridge: hide. const snap = resolveRidgeSnap(hit.segment, hit.localX, hit.localZ) if (!snap) { setPreviewPos(null) @@ -84,7 +83,11 @@ const RidgeVentTool = () => { const segObj = sceneRegistry.nodes.get(hit.segment.id) let ridgeWorld: [number, number, number] if (segObj) { - const ridgeLocal = new THREE.Vector3(snap.localX, hit.localY, snap.localZ) + const ridgeLocal = new THREE.Vector3( + snap.localX, + getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment), + snap.localZ, + ) segObj.updateWorldMatrix(true, false) ridgeLocal.applyMatrix4(segObj.matrixWorld) ridgeWorld = [ridgeLocal.x, ridgeLocal.y, ridgeLocal.z] @@ -100,12 +103,16 @@ const RidgeVentTool = () => { lastSnapRef.current = [sx, sz] } - setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0)) + setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0) + snap.rotation) setPreviewPos(worldToBuildingLocal(ridgeWorld[0], ridgeWorld[1], ridgeWorld[2])) publishRoofSurfacePlacementGuides({ roof: event.node as RoofNode, segment: hit.segment, - center: [snap.localX, hit.localY, snap.localZ], + center: [ + snap.localX, + getRoofTopSurfaceY(snap.localX, snap.localZ, hit.segment), + snap.localZ, + ], footprint: roofSurfaceFootprintFromNode(previewNode), mode: 'linear-edge', }) @@ -129,7 +136,7 @@ const RidgeVentTool = () => { name: 'Ridge Vent', roofSegmentId: hit.segment.id, position: [snap.localX, 0, snap.localZ], - rotation: 0, + rotation: snap.rotation, }) state.createNode(vent, hit.segment.id as AnyNodeId) state.dirtyNodes.add(hit.segment.id as AnyNodeId) diff --git a/packages/nodes/src/roof-segment/floorplan.test.ts b/packages/nodes/src/roof-segment/floorplan.test.ts new file mode 100644 index 00000000..6b0ab302 --- /dev/null +++ b/packages/nodes/src/roof-segment/floorplan.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from 'bun:test' +import type { RoofSegmentNode } from '@pascal-app/core' +import { getRoofSegmentPlanLinework } from './floorplan' + +function dutchSegment(overrides: Partial = {}): RoofSegmentNode { + return { + object: 'node', + id: 'rseg_test', + type: 'roof-segment', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + roofType: 'dutch', + width: 8, + depth: 6, + wallHeight: 2.5, + pitch: 40, + wallThickness: 0.1, + deckThickness: 0.1, + overhang: 0.3, + shingleThickness: 0.05, + gambrelLowerWidthRatio: 0.5, + gambrelLowerHeightRatio: 0.6, + mansardSteepWidthRatio: 0.15, + mansardSteepHeightRatio: 0.7, + dutchHipWidthRatio: 0.25, + dutchHipHeightRatio: 0.5, + dutchWaistLengthRatio: 1, + children: [], + ...overrides, + } as RoofSegmentNode +} + +describe('getRoofSegmentPlanLinework', () => { + test('draws a dutch width-axis upper ridge plus waist linework', () => { + const linework = getRoofSegmentPlanLinework(dutchSegment()) + + expect(linework.ridges).toEqual([ + [ + [-2.5, 0], + [2.5, 0], + ], + ]) + expect(linework.breaks).toEqual([ + [ + [-2.5, 1.5], + [2.5, 1.5], + ], + [ + [2.5, 1.5], + [2.5, -1.5], + ], + [ + [2.5, -1.5], + [-2.5, -1.5], + ], + [ + [-2.5, -1.5], + [-2.5, 1.5], + ], + ]) + expect(linework.hips).toContainEqual([ + [-4, 3], + [-2.5, 1.5], + ]) + expect(linework.hips).toContainEqual([ + [4, 3], + [2.5, 1.5], + ]) + }) + + test('draws a dutch depth-axis upper ridge when the depth exceeds the width', () => { + const linework = getRoofSegmentPlanLinework(dutchSegment({ width: 6, depth: 8 })) + + expect(linework.ridges).toEqual([ + [ + [0, 2.5], + [0, -2.5], + ], + ]) + expect(linework.breaks).toEqual([ + [ + [-1.5, 2.5], + [1.5, 2.5], + ], + [ + [1.5, 2.5], + [1.5, -2.5], + ], + [ + [1.5, -2.5], + [-1.5, -2.5], + ], + [ + [-1.5, -2.5], + [-1.5, 2.5], + ], + ]) + expect(linework.hips).toContainEqual([ + [-3, 4], + [-1.5, 2.5], + ]) + expect(linework.hips).toContainEqual([ + [3, -4], + [1.5, -2.5], + ]) + }) + + test('shortens dutch waist length along the ridge axis', () => { + const linework = getRoofSegmentPlanLinework(dutchSegment({ dutchWaistLengthRatio: 0.5 })) + + expect(linework.ridges).toEqual([ + [ + [-1.25, 0], + [1.25, 0], + ], + ]) + expect(linework.breaks).toEqual([ + [ + [-1.25, 1.5], + [1.25, 1.5], + ], + [ + [1.25, 1.5], + [1.25, -1.5], + ], + [ + [1.25, -1.5], + [-1.25, -1.5], + ], + [ + [-1.25, -1.5], + [-1.25, 1.5], + ], + ]) + }) +}) diff --git a/packages/nodes/src/roof-segment/floorplan.ts b/packages/nodes/src/roof-segment/floorplan.ts index e91bce9c..fa8b93c1 100644 --- a/packages/nodes/src/roof-segment/floorplan.ts +++ b/packages/nodes/src/roof-segment/floorplan.ts @@ -1,9 +1,10 @@ -import type { - FloorplanGeometry, - FloorplanPoint, - GeometryContext, - RoofNode, - RoofSegmentNode, +import { + type FloorplanGeometry, + type FloorplanPoint, + type GeometryContext, + getDutchRoofMetrics, + type RoofNode, + type RoofSegmentNode, } from '@pascal-app/core' /** @@ -23,7 +24,7 @@ export function buildRoofSegmentFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const roof = ctx.parent as RoofNode | null - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null // Segment center in world coords. Floor-plan plots at `-rotation` so // SVG's CW-with-y-down `rotate` direction ends up matching Three.js @@ -285,30 +286,19 @@ export function getRoofSegmentPlanLinework(node: RoofSegmentNode): { break } case 'dutch': { - // Hipped lower skirt (eave corners → waist corners) + the gablet - // fold, then a gable-style ridge on top of the waist. - const i = Math.min(node.width, node.depth) * node.dutchHipWidthRatio - if (hw - i > 0.02 && hd - i > 0.02) { - const w1: PlanPt = [-hw + i, hd - i] - const w2: PlanPt = [hw - i, hd - i] - const w3: PlanPt = [hw - i, -hd + i] - const w4: PlanPt = [-hw + i, -hd + i] - hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) - breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) - if (node.width >= node.depth) { - const r1: PlanPt = [-hw + i, 0] - const r2: PlanPt = [hw - i, 0] - ridges.push([r1, r2]) - hips.push([w1, r1], [w4, r1], [w2, r2], [w3, r2]) - } else { - const r1: PlanPt = [0, hd - i] - const r2: PlanPt = [0, -hd + i] - ridges.push([r1, r2]) - hips.push([w1, r1], [w2, r1], [w3, r2], [w4, r2]) - } - } else { + const metrics = getDutchRoofMetrics(node) + if (!(metrics.waistHalfX > 0.02 && metrics.waistHalfZ > 0.02)) { pushHip() + break } + + const w1: PlanPt = [-metrics.waistHalfX, metrics.waistHalfZ] + const w2: PlanPt = [metrics.waistHalfX, metrics.waistHalfZ] + const w3: PlanPt = [metrics.waistHalfX, -metrics.waistHalfZ] + const w4: PlanPt = [-metrics.waistHalfX, -metrics.waistHalfZ] + hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4]) + breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1]) + ridges.push([metrics.ridgeStart, metrics.ridgeEnd]) break } } diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index 907f55fb..ed87ec21 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -3,6 +3,10 @@ import { type AnyNode, type AnyNodeId, + createDefaultRidgeVentsForSegment, + isAutoRidgeVentEnabled, + isDefaultRidgeVentNode, + ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, type RoofType, @@ -15,6 +19,7 @@ import { PanelWrapper, SegmentedControl, SliderControl, + ToggleControl, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -26,10 +31,10 @@ const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [ { label: 'Hip', value: 'hip' }, { label: 'Gable', value: 'gable' }, { label: 'Shed', value: 'shed' }, - { label: 'Flat', value: 'flat' }, ] const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [ + { label: 'Flat', value: 'flat' }, { label: 'Gambrel', value: 'gambrel' }, { label: 'Dutch', value: 'dutch' }, { label: 'Mansard', value: 'mansard' }, @@ -44,15 +49,34 @@ const PITCH_PRESETS: { label: string; deg: number }[] = [ { label: '12/12', deg: 45 }, ] +function shouldShowTrimPlanes(metadata: unknown): boolean { + return metadataRecord(metadata).showTrimPlanes === true +} + +function metadataRecord(metadata: unknown): Record { + if (typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata)) { + return metadata as Record + } + return {} +} + export default function RoofSegmentPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const setRoofHostDragArmedId = useEditor((s) => s.setRoofHostDragArmedId) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined, ) + const autoRidgeVentEnabled = useScene((s) => { + const current = selectedId + ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) + : undefined + if (current?.type !== 'roof-segment') return false + return isAutoRidgeVentEnabled(current, s.nodes) + }) const handleUpdate = useCallback( (updates: Partial) => { @@ -62,15 +86,37 @@ export default function RoofSegmentPanel() { [selectedId, updateNode], ) + const handleRoofTypeChange = useCallback( + (roofType: RoofType) => { + // Switching to Dutch resets the shape parameters to their defaults so the + // gablet is well-formed regardless of the leftover values from the + // previous roof type. + handleUpdate( + roofType === 'dutch' + ? { + roofType, + dutchHipWidthRatio: ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + dutchHipHeightRatio: ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + dutchWaistLengthRatio: ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + dutchGabletRake: ROOF_SHAPE_DEFAULTS.dutchGabletRake, + dutchTopRakeThickness: ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness, + } + : { roofType }, + ) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) const handleBack = useCallback(() => { if (node?.parentId) { + setRoofHostDragArmedId(node.parentId as AnyNodeId) setSelection({ selectedIds: [node.parentId] }) } - }, [node?.parentId, setSelection]) + }, [node?.parentId, setRoofHostDragArmedId, setSelection]) const handleDuplicate = useCallback(() => { if (!node?.parentId) return @@ -117,8 +163,52 @@ export default function RoofSegmentPanel() { } }, [selectedId, node, setSelection]) + const handleAutoRidgeVentToggle = useCallback( + (checked: boolean) => { + if (!selectedId) return + const scene = useScene.getState() + const current = scene.nodes[selectedId as AnyNodeId] as RoofSegmentNode | undefined + if (current?.type !== 'roof-segment') return + + scene.updateNode(selectedId as AnyNodeId, { + metadata: { ...metadataRecord(current.metadata), autoRidgeVent: checked }, + }) + + const latest = useScene.getState().nodes[selectedId as AnyNodeId] as + | RoofSegmentNode + | undefined + if (latest?.type !== 'roof-segment') return + + const defaultVentIds = (latest.children ?? []).filter((childId) => + isDefaultRidgeVentNode(useScene.getState().nodes[childId as AnyNodeId], latest.id), + ) as AnyNodeId[] + + if (!checked) { + if (defaultVentIds.length > 0) { + useScene.getState().deleteNodes(defaultVentIds) + } + return + } + + if (defaultVentIds.length > 0) return + + const ridgeVents = createDefaultRidgeVentsForSegment(latest) + if (ridgeVents.length === 0) return + + scene.createNodes( + ridgeVents.map((ridgeVent) => ({ + node: ridgeVent, + parentId: latest.id as AnyNodeId, + })), + ) + }, + [selectedId], + ) + if (!(node && node.type === 'roof-segment' && selectedId)) return null + const showTrimPlanes = shouldShowTrimPlanes(node.metadata) + return ( handleUpdate({ roofType: v })} + onChange={(v) => handleRoofTypeChange(v)} options={ROOF_TYPE_OPTIONS} value={node.roofType} /> handleUpdate({ roofType: v })} + onChange={(v) => handleRoofTypeChange(v)} options={ROOF_TYPE_OPTIONS_2} value={node.roofType} /> + + + handleUpdate({ + metadata: { ...metadataRecord(node.metadata), showTrimPlanes: checked }, + }) + } + /> + {node.roofType !== 'shed' && node.roofType !== 'flat' && ( + + )} + + handleUpdate({ dutchHipWidthRatio: v })} @@ -261,7 +370,7 @@ export default function RoofSegmentPanel() { value={Math.round(node.dutchHipWidthRatio * 100) / 100} /> handleUpdate({ dutchHipHeightRatio: v })} @@ -270,6 +379,46 @@ export default function RoofSegmentPanel() { unit="" value={Math.round(node.dutchHipHeightRatio * 100) / 100} /> + handleUpdate({ dutchWaistLengthRatio: v })} + precision={2} + step={0.01} + unit="" + value={ + Math.round( + (node.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio) * 100, + ) / 100 + } + /> + handleUpdate({ dutchTopRakeThickness: v })} + precision={2} + step={0.01} + unit="m" + value={ + Math.round( + (node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness) * 100, + ) / 100 + } + /> + handleUpdate({ dutchGabletRake: v })} + precision={2} + step={0.01} + unit="m" + value={ + Math.round((node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake) * 100) / 100 + } + /> )} diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index 6424af86..6b4b6821 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type BoxVentNode, type ChimneyNode, + createDefaultRidgeVentsForSegment, type DormerNode, type GutterNode, type RidgeVentNode, @@ -37,7 +38,7 @@ export default function RoofPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) - const createNode = useScene((s) => s.createNode) + const createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => @@ -176,8 +177,15 @@ export default function RoofPanel() { roofType: 'gable', position: [2, 0, 2], }) - createNode(segment, node.id as AnyNodeId) - }, [node, createNode]) + const ridgeVents = createDefaultRidgeVentsForSegment(segment) + createNodes([ + { node: segment, parentId: node.id as AnyNodeId }, + ...ridgeVents.map((ridgeVent) => ({ + node: ridgeVent, + parentId: segment.id as AnyNodeId, + })), + ]) + }, [node, createNodes]) const handleSelectSegment = useCallback( (segmentId: string) => { diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index ce863c22..d76a1e9e 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -13,6 +13,7 @@ import { type StairNode, type StairSegmentNode, sceneRegistry, + useLiveNodeOverrides, useLiveTransforms, useScene, type WallNode, @@ -40,6 +41,38 @@ import * as THREE from 'three' /** Figma-style alignment-snap threshold (meters), matching the other tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 +function disableRaycastDuringDrag(root: THREE.Object3D | undefined): () => void { + if (!root) return () => {} + + const originals: Array<[THREE.Object3D, THREE.Object3D['raycast']]> = [] + root.traverse((child) => { + originals.push([child, child.raycast]) + child.raycast = () => {} + }) + + return () => { + for (const [child, raycast] of originals) { + child.raycast = raycast + } + } +} + +function resolvePreviewRotationY( + node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode, + localRotation: number, +): number { + if ((node.type === 'roof-segment' || node.type === 'stair-segment') && node.parentId) { + const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] + const parentRotation = + parentNode && 'rotation' in parentNode && typeof parentNode.rotation === 'number' + ? parentNode.rotation + : 0 + return parentRotation + localRotation + } + + return localRotation +} + export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode }> = ({ node: movingNode }) => { @@ -59,7 +92,9 @@ export const MoveRoofTool: React.FC<{ const previousGridPosRef = useRef<[number, number] | null>(null) const dragAnchorRef = useRef<[number, number] | null>(null) - const [previewRotation, setPreviewRotation] = useState(movingNode.rotation as number) + const [previewRotation, setPreviewRotation] = useState(() => + resolvePreviewRotationY(movingNode, movingNode.rotation as number), + ) const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => { const obj = sceneRegistry.nodes.get(movingNode.id) if (obj) { @@ -124,24 +159,19 @@ export const MoveRoofTool: React.FC<{ movingNode.position[1], movingNode.position[2], ] + const movingObject = sceneRegistry.nodes.get(movingNode.id) + const restoreRaycasts = disableRaycastDuringDrag(movingObject) - // For roof-segment moves: the selection was cleared before entering move mode, - // so isSelected=false on the parent roof, hiding individual segment meshes and - // showing only the merged mesh. We directly flip Three.js visibility so the - // user sees the individual segment tracking the cursor. - let segmentWrapperGroup: THREE.Object3D | null = null - let mergedRoofMesh: THREE.Object3D | null = null - if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') { - const segmentMesh = sceneRegistry.nodes.get(movingNode.id) - if (segmentMesh?.parent) { - // segmentMesh.parent = wrapper in Roof/StairRenderer - // segmentMesh.parent.parent = the registered roof/stair group - segmentWrapperGroup = segmentMesh.parent - const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof' - mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null - segmentWrapperGroup.visible = true - if (mergedRoofMesh) mergedRoofMesh.visible = false - } + const syncHostedPreview = ( + patch: Pick, + ) => { + if (movingNode.type !== 'roof-segment' && movingNode.type !== 'stair-segment') return + useLiveNodeOverrides.getState().set(movingNode.id, patch as Record) + } + + const clearHostedPreview = () => { + if (movingNode.type !== 'roof-segment' && movingNode.type !== 'stair-segment') return + useLiveNodeOverrides.getState().clear(movingNode.id) } const resolveLevelId = () => { @@ -360,6 +390,10 @@ export const MoveRoofTool: React.FC<{ position: lastLocalPosition, rotation: pendingRotation, }) + syncHostedPreview({ + position: lastLocalPosition, + rotation: pendingRotation, + }) } const onGridClick = (event: GridEvent) => { @@ -393,6 +427,7 @@ export const MoveRoofTool: React.FC<{ triggerSFX('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [committedId] }) + clearHostedPreview() useLiveTransforms.getState().clear(movingNode.id) useEditor.getState().setMovingNodeOrigin('3d') exitMoveMode() @@ -406,6 +441,7 @@ export const MoveRoofTool: React.FC<{ const onCancel = () => { wasCancelled = true + clearHostedPreview() useLiveTransforms.getState().clear(movingNode.id) useAlignmentGuides.getState().clear() if (isNew) { @@ -436,7 +472,7 @@ export const MoveRoofTool: React.FC<{ triggerSFX('sfx:item-rotate') pendingRotation += rotationDelta - setPreviewRotation(pendingRotation) + setPreviewRotation(resolvePreviewRotationY(movingNode, pendingRotation)) // Directly update the Three.js mesh — no store update during drag const mesh = sceneRegistry.nodes.get(movingNode.id) @@ -457,6 +493,10 @@ export const MoveRoofTool: React.FC<{ rotation: pendingRotation, }) } + syncHostedPreview({ + position: lastLocalPosition, + rotation: pendingRotation, + }) } } @@ -467,11 +507,10 @@ export const MoveRoofTool: React.FC<{ window.addEventListener('pointerup', onPlacementDragPointerUp) return () => { - // Restore segment wrapper visibility (React will re-sync on next render) - if (segmentWrapperGroup) segmentWrapperGroup.visible = false - if (mergedRoofMesh) mergedRoofMesh.visible = true + restoreRaycasts() // Clear ephemeral live transform + any alignment guides + clearHostedPreview() useLiveTransforms.getState().clear(movingNode.id) useAlignmentGuides.getState().clear() @@ -500,10 +539,14 @@ export const MoveRoofTool: React.FC<{ } }, [movingNode, exitMoveMode, isFreshPlacement, revealFreshPlacement, useAbsoluteCursorPlacement]) - // Green footprint box during whole-stair / whole-roof moves. Skipped for - // segments — their cursor lives in parent-local space and the bounding box - // would render in the wrong frame. - const showBoundingBox = movingNode.type === 'stair' || movingNode.type === 'roof' + // Show the same green drag box for both top-level roofs/stairs and their + // segments. Segment cursor positions are converted into the tool's + // building-local frame above, so the box can now ride the cursor correctly. + const showBoundingBox = + movingNode.type === 'stair' || + movingNode.type === 'roof' || + movingNode.type === 'roof-segment' || + movingNode.type === 'stair-segment' return ( diff --git a/packages/nodes/src/shared/opening-placement-dimensions.ts b/packages/nodes/src/shared/opening-placement-dimensions.ts index 42d375fb..08fedd62 100644 --- a/packages/nodes/src/shared/opening-placement-dimensions.ts +++ b/packages/nodes/src/shared/opening-placement-dimensions.ts @@ -33,7 +33,7 @@ export function buildOpeningPlacementDimensions( ctx: GeometryContext, ): FloorplanGeometry[] { const wall = ctx.parent as WallNode | null - if (!wall || wall.type !== 'wall') return [] + if (wall?.type !== 'wall') return [] if (isCurvedWall(wall)) return [] const [x1, z1] = wall.start @@ -161,7 +161,7 @@ function computeOutwardNormal( let count = 0 for (const childId of levelChildren) { const child = ctx.resolve(childId) as AnyNode | undefined - if (!child || child.type !== 'wall') continue + if (child?.type !== 'wall') continue const w = child as WallNode sumX += w.start[0] + w.end[0] sumZ += w.start[1] + w.end[1] diff --git a/packages/nodes/src/shared/pipe-run-translation-offset.ts b/packages/nodes/src/shared/pipe-run-translation-offset.ts index c9ad7d66..d93c454a 100644 --- a/packages/nodes/src/shared/pipe-run-translation-offset.ts +++ b/packages/nodes/src/shared/pipe-run-translation-offset.ts @@ -158,7 +158,7 @@ export function planPipeRunTranslationOffsets(args: { } const partner = nodesById[conn.nodeId] - if (!partner || partner.type !== 'pipe-fitting') return null + if (partner?.type !== 'pipe-fitting') return null const elbow = { ...(partner as PipeFittingNode), ...pipeElbowProfilePatch(profile), diff --git a/packages/nodes/src/shared/pipe-vertical-offset.ts b/packages/nodes/src/shared/pipe-vertical-offset.ts index cbadc7e8..b198359c 100644 --- a/packages/nodes/src/shared/pipe-vertical-offset.ts +++ b/packages/nodes/src/shared/pipe-vertical-offset.ts @@ -568,7 +568,7 @@ function directRiserCollapseAction(args: { ) if (!farPort) return null const lower = nodesById[farPort.nodeId] - if (!lower || lower.type !== 'pipe-fitting') return null + if (lower?.type !== 'pipe-fitting') return null const lowerElbow = { ...(lower as PipeFittingNode), ...profilePatch } as PipeFittingNode if (lowerElbow.fittingType !== 'elbow') return null @@ -665,7 +665,7 @@ function fittingRiserAction(args: { continue } const lower = nodesById[farPort.nodeId] - if (!lower || lower.type !== 'pipe-fitting') { + if (lower?.type !== 'pipe-fitting') { if (stretch === 'stretch') return { status: 'stretch' } continue } @@ -806,7 +806,7 @@ function planVerticalOffsetsAtDy( // shows a red preview rather than dragging the network up to absorb it). if (conn.kind !== 'run') { const partner = nodesById[conn.nodeId] - if (!partner || partner.type !== 'pipe-fitting') return { status: 'invalid' } + if (partner?.type !== 'pipe-fitting') return { status: 'invalid' } const elbow = partner as PipeFittingNode const profilePatch = pipeElbowProfilePatch(profile) const profiledElbow = { ...elbow, ...profilePatch } as PipeFittingNode diff --git a/packages/nodes/src/shared/ridge-snap.test.ts b/packages/nodes/src/shared/ridge-snap.test.ts new file mode 100644 index 00000000..c4eaa93c --- /dev/null +++ b/packages/nodes/src/shared/ridge-snap.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test' +import { RoofSegmentNode } from '@pascal-app/core' +import { resolveRidgeSnap } from './ridge-snap' + +describe('resolveRidgeSnap', () => { + test('snaps Dutch width-axis center clicks to the shortened top ridge', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + pitch: 40, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const center = resolveRidgeSnap(segment, 0, 0) + + expect(center?.localX).toBeCloseTo(0) + expect(center?.localZ).toBeCloseTo(0) + expect(center?.rotation).toBeCloseTo(0) + }) + + test('snaps Dutch depth-axis center clicks to the shortened top ridge', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 6, + depth: 8, + pitch: 40, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const center = resolveRidgeSnap(segment, 0, 0) + + expect(center?.localX).toBeCloseTo(0) + expect(center?.localZ).toBeCloseTo(0) + expect(center?.rotation).toBeCloseTo(Math.PI / 2) + }) + + test('snaps Dutch shoulder clicks onto the extended lower hip seam up to the rake end', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'dutch', + width: 8, + depth: 6, + pitch: 40, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const snap = resolveRidgeSnap(segment, 3.8, 2.8) + + expect(snap).not.toBeNull() + expect(snap?.localX).toBeCloseTo(3.84, 2) + expect(snap?.localZ).toBeCloseTo(2.77, 2) + expect(Math.abs(snap?.rotation ?? 0)).toBeGreaterThan(0.1) + expect(Math.abs(Math.abs(snap?.rotation ?? 0) - Math.PI / 2)).toBeGreaterThan(0.1) + }) + + test('snaps mansard center clicks to the upper top ridge', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'mansard', + width: 8, + depth: 6, + pitch: 40, + }) + + const center = resolveRidgeSnap(segment, 0, 0) + + expect(center?.localX).toBeCloseTo(0) + expect(center?.localZ).toBe(0) + expect(center?.rotation).toBeCloseTo(0) + }) + + test('snaps mansard lower-slope clicks to the nearest lower-slope vent line', () => { + const segment = RoofSegmentNode.parse({ + roofType: 'mansard', + width: 8, + depth: 6, + pitch: 40, + overhang: 0, + wallThickness: 0, + shingleThickness: 0, + }) + + const frontRight = resolveRidgeSnap(segment, 3.5, 2.5) + const frontLeft = resolveRidgeSnap(segment, -3.5, 2.5) + + expect(frontRight?.localX).toBeGreaterThan(0) + expect(frontRight?.localZ).toBeGreaterThan(0) + expect(frontLeft?.localX).toBeLessThan(0) + expect(frontLeft?.localZ).toBeGreaterThan(0) + expect(Math.abs(frontRight?.rotation ?? 0)).toBeGreaterThan(0.1) + expect(Math.abs(frontRight?.rotation ?? 0)).toBeLessThan(Math.PI / 2 - 0.1) + }) +}) diff --git a/packages/nodes/src/shared/ridge-snap.ts b/packages/nodes/src/shared/ridge-snap.ts index 8a2af476..bb2f5697 100644 --- a/packages/nodes/src/shared/ridge-snap.ts +++ b/packages/nodes/src/shared/ridge-snap.ts @@ -1,48 +1,87 @@ -import type { RoofSegmentNode } from '@pascal-app/core' +import { + getRidgeVentLinesForSegment, + getRoofSegmentVisibleTopBounds, + type RoofSegmentNode, +} from '@pascal-app/core' /** * Shared ridge-line snap math for ridge-vent placement + move tools. * - * Ridge vents must sit centered on the segment's ridge — off-ridge the - * cap's far half dips into the higher part of the slope ("goes inside" - * the roof). So the placement tools clamp the cursor onto the ridge: - * closest-point projection along the segment's local X axis, with the X - * span clipped to where a real ridge actually exists for that roof type. + * Ridge vents must sit centered on a roof break line — off-line the cap's + * far half dips into the higher part of the slope ("goes inside" the roof). + * So the placement tools clamp the cursor onto the nearest generated ridge + * line, preserving the line's yaw for hip / lower-slope runs. * - * Per roof type (the segment's ridge runs along the segment's local X): - * - gable / gambrel / dutch / mansard: ridge spans the full width. + * Per roof type: + * - gable / gambrel: ridge spans the full width. + * - mansard: top ridge, upper hip runs, plus lower-slope runs on all + * four steep lower faces. + * - dutch: top ridge between the gablet waists plus four hip runs down + * to the eave corners (the gablet ends are vertical walls, not ridges). * - hip: ridge is shortened by the hipped ends — spans width − depth. * A square hip (width ≤ depth) collapses to a single apex point. * - shed: no true ridge — snap to the high eave (z = -depth/2). * - flat: no ridge at all → return null. */ -// Standard lift above the analytical slope surface so the cap reads as -// sitting on the shingle course rather than clipping into it. Shared -// with the renderer so live ridge-Y derivation matches placement. -export const RIDGE_LIFT = 0.12 +// Ridge vents seat directly onto the analytical roof surface; any visible +// thickness belongs in the vent geometry itself, not in a renderer lift. +export const RIDGE_LIFT = 0.09 export type RidgeSnap = { /** Segment-local X of the snapped ridge position. */ localX: number - /** Segment-local Z of the snapped ridge position (0 for peaked roofs). */ + /** Segment-local Z of the snapped ridge position. */ localZ: number + /** Segment-local yaw matching the snapped ridge line. */ + rotation: number } export function resolveRidgeSnap( segment: RoofSegmentNode, cursorLocalX: number, - _cursorLocalZ: number, + cursorLocalZ: number, ): RidgeSnap | null { const roofType = segment.roofType ?? 'gable' if (roofType === 'flat') return null - const halfW = (segment.width ?? 0) / 2 - const halfD = (segment.depth ?? 0) / 2 + const lines = + roofType === 'shed' + ? getShedHighEaveLine(segment) + : getRidgeVentLinesForSegment(segment).map(({ start, end }) => ({ start, end })) + if (lines.length === 0) return null - const ridgeZ = roofType === 'shed' ? -halfD : 0 - const ridgeHalfLength = roofType === 'hip' ? Math.max(0, halfW - halfD) : halfW - const localX = Math.max(-ridgeHalfLength, Math.min(ridgeHalfLength, cursorLocalX)) + let best: RidgeSnap | null = null + let bestDistanceSq = Number.POSITIVE_INFINITY - return { localX, localZ: ridgeZ } + for (const line of lines) { + const [sx, sz] = line.start + const [ex, ez] = line.end + const dx = ex - sx + const dz = ez - sz + const lengthSq = dx * dx + dz * dz + const t = + lengthSq <= 1e-8 + ? 0 + : Math.max(0, Math.min(1, ((cursorLocalX - sx) * dx + (cursorLocalZ - sz) * dz) / lengthSq)) + const localX = sx + dx * t + const localZ = sz + dz * t + const distanceSq = (cursorLocalX - localX) ** 2 + (cursorLocalZ - localZ) ** 2 + + if (distanceSq < bestDistanceSq) { + bestDistanceSq = distanceSq + best = { + localX, + localZ, + rotation: Math.atan2(-dz, dx), + } + } + } + + return best +} + +function getShedHighEaveLine(segment: RoofSegmentNode) { + const { minX, maxX, minZ } = getRoofSegmentVisibleTopBounds(segment) + return [{ start: [minX, minZ] as [number, number], end: [maxX, minZ] as [number, number] }] } diff --git a/packages/nodes/src/shared/roof-face-host.tsx b/packages/nodes/src/shared/roof-face-host.tsx index 1083b015..3a0e1027 100644 --- a/packages/nodes/src/shared/roof-face-host.tsx +++ b/packages/nodes/src/shared/roof-face-host.tsx @@ -40,7 +40,7 @@ export function RoofFaceHostFrame({ [storeSegment, liveOverride], ) - if (!segment || segment.type !== 'roof-segment' || !roofFace) return null + if (segment?.type !== 'roof-segment' || !roofFace) return null const frame = getRoofWallFaceFrame(segment, roofFace) return ( diff --git a/packages/nodes/src/shared/roof-opening-host.ts b/packages/nodes/src/shared/roof-opening-host.ts index ad37ad7d..ffea7855 100644 --- a/packages/nodes/src/shared/roof-opening-host.ts +++ b/packages/nodes/src/shared/roof-opening-host.ts @@ -33,7 +33,7 @@ type SceneReader = { get: (id: AnyNodeId) => unknown } function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) { if (!(node.roofSegmentId && node.roofFace)) return null const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) } } diff --git a/packages/nodes/src/shared/roof-segment-hit.ts b/packages/nodes/src/shared/roof-segment-hit.ts index 262c847c..b5f9e749 100644 --- a/packages/nodes/src/shared/roof-segment-hit.ts +++ b/packages/nodes/src/shared/roof-segment-hit.ts @@ -9,6 +9,7 @@ import { import * as THREE from 'three' const worldPoint = new THREE.Vector3() +const localPoint = new THREE.Vector3() export type RoofSegmentHit = { segment: RoofSegmentNode @@ -59,7 +60,7 @@ export function resolveRoofSegmentHit( const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) continue segObj.updateWorldMatrix(true, false) - const local = segObj.worldToLocal(worldPoint.clone()) + const local = segObj.worldToLocal(localPoint.copy(worldPoint)) if (!firstSegment) firstSegment = { seg, segObj } @@ -81,7 +82,7 @@ export function resolveRoofSegmentHit( if (best) return best.hit if (firstSegment) { - const local = firstSegment.segObj.worldToLocal(worldPoint.clone()) + const local = firstSegment.segObj.worldToLocal(localPoint.copy(worldPoint)) return { segment: firstSegment.seg, localX: local.x, diff --git a/packages/nodes/src/shared/roof-surface-placement-guides.ts b/packages/nodes/src/shared/roof-surface-placement-guides.ts index 1834fdc0..12e9c877 100644 --- a/packages/nodes/src/shared/roof-surface-placement-guides.ts +++ b/packages/nodes/src/shared/roof-surface-placement-guides.ts @@ -29,6 +29,11 @@ const EQUAL_SPACING_THRESHOLD_M = 0.03 const tmp = new THREE.Vector3() const tmpA = new THREE.Vector3() const tmpB = new THREE.Vector3() +const ROOF_SURFACE_FOOTPRINT_CACHE_MAX = 160 +const roofSurfaceFootprintCache = new Map< + string, + Pick | null +>() export type RoofSurfaceGuideMode = 'side-center' | 'linear-edge' @@ -80,7 +85,7 @@ export function roofSurfaceFootprintFromNode( options?: { segment?: RoofSegmentNode }, ): RoofSurfaceGuideFootprint { const n = node as Record - const geometryBounds = geometryFootprintForNode(n, options?.segment) + const geometryBounds = cachedGeometryFootprintForNode(n, options?.segment) if (geometryBounds) { return { ...geometryBounds, @@ -127,6 +132,43 @@ export function roofSurfaceFootprintFromNode( } } +function cachedGeometryFootprintForNode( + node: Record, + segment: RoofSegmentNode | undefined, +): Pick | null { + const key = geometryFootprintCacheKey(node, segment) + if (roofSurfaceFootprintCache.has(key)) { + const cached = roofSurfaceFootprintCache.get(key) + return cached ? { ...cached } : null + } + + const footprint = geometryFootprintForNode(node, segment) + roofSurfaceFootprintCache.set(key, footprint ? { ...footprint } : null) + if (roofSurfaceFootprintCache.size > ROOF_SURFACE_FOOTPRINT_CACHE_MAX) { + const oldestKey = roofSurfaceFootprintCache.keys().next().value + if (oldestKey) roofSurfaceFootprintCache.delete(oldestKey) + } + return footprint +} + +function geometryFootprintCacheKey( + node: Record, + segment: RoofSegmentNode | undefined, +): string { + const type = typeof node.type === 'string' ? node.type : 'unknown' + const segmentKey = type === 'chimney' && segment ? `|segment:${stableCacheKey(segment)}` : '' + return `${type}|node:${stableCacheKey(node)}${segmentKey}` +} + +function stableCacheKey(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map((item) => stableCacheKey(item)).join(',')}]` + const entries = Object.entries(value as Record) + .filter(([, entryValue]) => typeof entryValue !== 'function' && entryValue !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableCacheKey(entryValue)}`).join(',')}}` +} + function geometryFootprintForNode( node: Record, segment: RoofSegmentNode | undefined, diff --git a/packages/nodes/src/shared/roof-surface.test.ts b/packages/nodes/src/shared/roof-surface.test.ts index 83cd0b33..b0dd4bf0 100644 --- a/packages/nodes/src/shared/roof-surface.test.ts +++ b/packages/nodes/src/shared/roof-surface.test.ts @@ -40,6 +40,17 @@ describe('getDownSlopeYaw', () => { test('flat segment has no down-slope direction (yaw 0)', () => { expect(getDownSlopeYaw(0, 0, fixtureSegment({ roofType: 'flat' }))).toBe(0) }) + test('dutch width-axis shoulder falls toward the side eaves', () => { + expect(getDownSlopeYaw(3.8, 0, fixtureSegment({ roofType: 'dutch' }))).toBeCloseTo(Math.PI / 2) + }) + test('dutch width-axis front skirt yaws toward the front eave', () => { + expect(getDownSlopeYaw(0, 1, fixtureSegment({ roofType: 'dutch' }))).toBeCloseTo(0) + }) + test('dutch depth-axis top gable falls toward the side eaves', () => { + expect( + getDownSlopeYaw(1, 0, fixtureSegment({ roofType: 'dutch', width: 6, depth: 8 })), + ).toBeCloseTo(Math.PI / 2) + }) }) describe('getRoofSurfaceFaceBoundsAt', () => { @@ -63,4 +74,30 @@ describe('getRoofSurfaceFaceBoundsAt', () => { expect(ridgeInterval?.[0]).toBeGreaterThan(-2) expect(ridgeInterval?.[1]).toBeLessThan(2) }) + + test('mansard top surface rises to a center ridge instead of staying flat', () => { + const segment = fixtureSegment({ + roofType: 'mansard', + mansardSteepWidthRatio: 0.15, + mansardSteepHeightRatio: 0.7, + }) + + const center = getRoofSurfaceFaceBoundsAt(segment, 0, 0).surfaceYAt(0, 0) + const offRidge = getRoofSurfaceFaceBoundsAt(segment, 0, 0.5).surfaceYAt(0, 0.5) + + expect(center).toBeGreaterThan(offRidge) + }) + + test('dutch top surface rises from the waist to the center ridge', () => { + const segment = fixtureSegment({ + roofType: 'dutch', + dutchHipWidthRatio: 0.2, + dutchHipHeightRatio: 0.6, + }) + + const center = getRoofSurfaceFaceBoundsAt(segment, 0, 0).surfaceYAt(0, 0) + const waist = getRoofSurfaceFaceBoundsAt(segment, 0, 1.2).surfaceYAt(0, 1.2) + + expect(center).toBeGreaterThan(waist) + }) }) diff --git a/packages/nodes/src/shared/roof-surface.ts b/packages/nodes/src/shared/roof-surface.ts index 033b293f..aa636956 100644 --- a/packages/nodes/src/shared/roof-surface.ts +++ b/packages/nodes/src/shared/roof-surface.ts @@ -1,9 +1,11 @@ import { + getRoofModuleFaces, getRoofSegmentSurfaceY, + getRoofShapeInsets, + getRoofShapeRatios, getSegmentSlopeFrame, ROOF_SHAPE_DEFAULTS, type RoofSegmentNode, - type RoofType, } from '@pascal-app/core' import * as THREE from 'three' @@ -17,6 +19,10 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe return getRoofSegmentSurfaceY(seg, lx, lz) } +export function getRoofTopSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number { + return getRoofSurfaceFaceBoundsAt(seg, lx, lz).surfaceYAt(lx, lz) +} + export type RoofSurfacePoint2D = [number, number] export type RoofSurfaceFaceBounds = { @@ -36,9 +42,7 @@ export function getRoofSurfaceFaceBoundsAt( lz: number, ): RoofSurfaceFaceBounds { const faces = getRoofSurfaceFaces(segment) - const face = - faces.find((candidate) => pointInPolygon([lx, lz], candidate.polygon)) ?? - nearestFaceToPoint(faces, [lx, lz]) + const face = topmostFaceAtPoint(faces, lx, lz) ?? nearestFaceToPoint(faces, [lx, lz]) const { polygon } = face const xs = polygon.map((point) => point[0]) @@ -61,23 +65,52 @@ type RoofSurfaceFace = { vertices: FaceVertex[] } type FaceVertex = { x: number; y: number; z: number } -type FaceInsets = { - iF?: number - iB?: number - iL?: number - iR?: number - dutchI?: number -} -type FaceShapeRatios = { - gambrelLowerWidthRatio: number - mansardSteepWidthRatio: number - dutchHipWidthRatio: number -} const SHINGLE_SURFACE_EPSILON = 0.02 const FACE_TOLERANCE = 1e-6 +const ROOF_SURFACE_FACE_CACHE_MAX = 128 +const roofSurfaceFaceCache = new Map() +const _downSlopeYawNormal = new THREE.Vector3() +const _surfaceQuatRight = new THREE.Vector3() +const _surfaceQuatForward = new THREE.Vector3() +const _surfaceQuatMatrix = new THREE.Matrix4() function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { + const key = roofSurfaceFaceCacheKey(segment) + const cached = roofSurfaceFaceCache.get(key) + if (cached) return cached + + const faces = buildRoofSurfaceFaces(segment) + roofSurfaceFaceCache.set(key, faces) + if (roofSurfaceFaceCache.size > ROOF_SURFACE_FACE_CACHE_MAX) { + const oldestKey = roofSurfaceFaceCache.keys().next().value + if (oldestKey) roofSurfaceFaceCache.delete(oldestKey) + } + return faces +} + +function roofSurfaceFaceCacheKey(segment: RoofSegmentNode): string { + return [ + segment.roofType, + segment.width, + segment.depth, + segment.wallHeight, + segment.wallThickness, + segment.deckThickness, + segment.overhang, + segment.shingleThickness, + segment.pitch, + segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, + segment.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio, + segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio, + segment.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio, + segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, + segment.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio, + segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + ].join('|') +} + +function buildRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment) @@ -122,39 +155,43 @@ function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { const dropTop = Math.min(1, maxDrop * 0.4) const topBaseY = shinBotWh - dropTop - const insetsTop = getRoofFaceInsets( + const dutchHipWidthRatio = segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio + const insetsTop = getRoofShapeInsets({ roofType, width, depth, - shinTopWh, - topBaseY, - false, - shinTopW, - shinTopD, + wh: shinTopWh, + baseY: topBaseY, + isVoid: false, + brushW: shinTopW, + brushD: shinTopD, tanTheta, shingleThickness, - ) - const shapeRatios = { - gambrelLowerWidthRatio: - segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio, - mansardSteepWidthRatio: - segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio, - dutchHipWidthRatio: segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio, - } + dutchHipWidthRatio, + }) + const shapeRatios = getRoofShapeRatios({ + gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio, + mansardSteepWidthRatio: segment.mansardSteepWidthRatio, + dutchHipWidthRatio, + dutchHipHeightRatio: segment.dutchHipHeightRatio, + dutchWaistLengthRatio: segment.dutchWaistLengthRatio, + dutchGabletRake: segment.dutchGabletRake, + }) - return getRoofModuleFaces( - roofType, - shinTopW, - shinTopD, - shinTopWh, - shinTopRh, - topBaseY, - insetsTop, - width, - depth, + return getRoofModuleFaces({ + type: roofType, + w: shinTopW, + d: shinTopD, + wh: shinTopWh, + rh: shinTopRh, + baseY: topBaseY, + insets: insetsTop, + baseW: width, + baseD: depth, tanTheta, shapeRatios, - ) + dutchTopRakeThickness: segment.dutchTopRakeThickness, + }) .filter((face) => faceNormalY(face) > SHINGLE_SURFACE_EPSILON) .map((face) => { const vertices = face.map((point) => ({ ...point, z: point.z + transZ })) @@ -166,199 +203,23 @@ function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] { .filter((face) => face.polygon.length >= 3) } -function getRoofFaceInsets( - roofType: RoofType, - width: number, - depth: number, - wh: number, - baseY: number, - isVoid: boolean, - brushW: number, - brushD: number, - tanTheta: number, - shingleThickness: number, -): FaceInsets { - let inset = (wh - baseY) * tanTheta - const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005 - if (inset > maxSafeInset) inset = maxSafeInset +function topmostFaceAtPoint( + faces: RoofSurfaceFace[], + lx: number, + lz: number, +): RoofSurfaceFace | null { + let best: RoofSurfaceFace | null = null + let bestY = Number.NEGATIVE_INFINITY - let iF = 0 - let iB = 0 - let iL = 0 - let iR = 0 - if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') { - iF = inset - iB = inset - iL = inset - iR = inset - } else if (roofType === 'gable' || roofType === 'gambrel') { - iF = inset - iB = inset - } else if (roofType === 'shed') { - iF = inset + for (const face of faces) { + if (!pointInPolygon([lx, lz], face.polygon)) continue + const y = surfaceYOnFace(face.vertices, lx, lz) + if (y === null || y <= bestY) continue + best = face + bestY = y } - let dutchI = Math.min(width, depth) * 0.25 - if (isVoid) dutchI += shingleThickness - return { iF, iB, iL, iR, dutchI } -} - -function getRoofModuleFaces( - type: RoofType, - w: number, - d: number, - wh: number, - rh: number, - baseY: number, - insets: FaceInsets, - baseW: number, - baseD: number, - tanTheta: number, - shapeRatios: FaceShapeRatios, -): FaceVertex[][] { - const v = (x: number, y: number, z: number): FaceVertex => ({ x, y, z }) - const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets - - const b1 = v(-w / 2 + iL, baseY, d / 2 - iF) - const b2 = v(w / 2 - iR, baseY, d / 2 - iF) - const b3 = v(w / 2 - iR, baseY, -d / 2 + iB) - const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB) - const bottom = [b4, b3, b2, b1] - - const e1 = v(-w / 2, wh, d / 2) - const e2 = v(w / 2, wh, d / 2) - const e3 = v(w / 2, wh, -d / 2) - const e4 = v(-w / 2, wh, -d / 2) - - const faces: FaceVertex[][] = [] - faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom) - - const h = wh + Math.max(0.001, rh) - - if (type === 'flat' || rh === 0) { - faces.push([e1, e2, e3, e4]) - } else if (type === 'gable') { - const r1 = v(-w / 2, h, 0) - const r2 = v(w / 2, h, 0) - faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) - } else if (type === 'hip') { - if (Math.abs(w - d) < 0.01) { - const r = v(0, h, 0) - faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r]) - } else if (w >= d) { - const r1 = v(-w / 2 + d / 2, h, 0) - const r2 = v(w / 2 - d / 2, h, 0) - faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) - } else { - const r1 = v(0, h, d / 2 - w / 2) - const r2 = v(0, h, -d / 2 + w / 2) - faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) - } - } else if (type === 'shed') { - const t1 = v(-w / 2, h, -d / 2) - const t2 = v(w / 2, h, -d / 2) - faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1]) - } else if (type === 'gambrel') { - const mz = (baseD / 2) * shapeRatios.gambrelLowerWidthRatio - const dist = d / 2 - mz - const mh = wh + dist * (tanTheta || 0) - - const m1 = v(-w / 2, mh, mz) - const m2 = v(w / 2, mh, mz) - const m3 = v(w / 2, mh, -mz) - const m4 = v(-w / 2, mh, -mz) - const r1 = v(-w / 2, h, 0) - const r2 = v(w / 2, h, 0) - faces.push( - [e4, e1, m1, r1, m4], - [e2, e3, m3, r2, m2], - [e1, e2, m2, m1], - [m1, m2, r2, r1], - [e3, e4, m4, m3], - [m3, m4, r1, r2], - ) - } else if (type === 'mansard') { - const i = Math.min(baseW, baseD) * shapeRatios.mansardSteepWidthRatio - const mh = wh + i * (tanTheta || 0) - - const m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2) - const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2) - const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2) - const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2) - if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) { - if (w >= d) { - const r1 = v(-w / 2 + d / 2, h, 0) - const r2 = v(w / 2 - d / 2, h, 0) - faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) - } else { - const r1 = v(0, h, d / 2 - w / 2) - const r2 = v(0, h, -d / 2 + w / 2) - faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) - } - } else { - faces.push( - [t1, t2, t3, t4], - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m1, m2, t2, t1], - [m2, m3, t3, t2], - [m3, m4, t4, t3], - [m4, m1, t1, t4], - ) - } - } else if (type === 'dutch') { - const i = - insets.dutchI !== undefined - ? insets.dutchI - : Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio - const mh = wh + i * (tanTheta || 0) - - if (w >= d) { - const m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const r1 = v(-w / 2 + i, h, 0) - const r2 = v(w / 2 - i, h, 0) - - faces.push( - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m4, m1, r1], - [m2, m3, r2], - [m1, m2, r2, r1], - [m3, m4, r1, r2], - ) - } else { - const m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const r1 = v(0, h, d / 2 - i) - const r2 = v(0, h, -d / 2 + i) - - faces.push( - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m1, m2, r1], - [m3, m4, r2], - [m2, m3, r2, r1], - [m4, m1, r1, r2], - ) - } - } - - return faces + return best } function faceNormalY(face: FaceVertex[]): number { @@ -527,15 +388,20 @@ function lineInterval( // down-slope direction (cos θ horizontal + −sin θ vertical). Crossing // them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz), // equivalently (dx · tan θ, 1, dz · tan θ) un-normalised. -function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 { - return new THREE.Vector3(dx * tan, 1, dz * tan).normalize() +function buildSlopeNormal(dx: number, dz: number, tan: number, out: THREE.Vector3): THREE.Vector3 { + return out.set(dx * tan, 1, dz * tan).normalize() } -export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 { +export function getAnalyticalNormal( + lx: number, + lz: number, + seg: RoofSegmentNode, + out = new THREE.Vector3(), +): THREE.Vector3 { const { roofType, depth, width } = seg const slope = getSegmentSlopeFrame(seg) if (slope.activeRh === 0 || slope.tanTheta === 0) { - return new THREE.Vector3(0, 1, 0) + return out.set(0, 1, 0) } const primaryTan = slope.tanTheta const halfW = width / 2 @@ -557,15 +423,15 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode const upperRise = slope.activeRh * (1 - lowerHeightRatio) const upperRun = mz const upperTan = upperRun > 0 ? upperRise / upperRun : 0 - return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan) + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan, out) } } - return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out) } // Single slope falling toward +Z (ridge at -Z, eave at +Z). if (roofType === 'shed') { - return buildSlopeNormal(0, 1, primaryTan) + return buildSlopeNormal(0, 1, primaryTan, out) } // 4-sided slopes: the dominant axis chooses which face the point sits @@ -574,10 +440,9 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode // ends and gable sides — both share the same primaryTan from the // slope frame, so directional dispatch is enough. if (roofType === 'hip') { - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) + const onZ = (halfD > 0 ? Math.abs(lz) / halfD : 0) >= (halfW > 0 ? Math.abs(lx) / halfW : 0) + if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan, out) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan, out) } if (roofType === 'mansard') { @@ -597,21 +462,49 @@ export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode const topRun = Math.max(0, Math.min(halfW, halfD) - inset) tan = topRun > 0 ? topRise / topRun : 0 } - if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan) + if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan, out) + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan, out) } if (roofType === 'dutch') { - // Hip on the short-axis ends, gable on the long-axis sides. Both - // share the primary pitch on their primary (eave-band) face, so the - // approximation collapses to "pick the dominant axis." - const fx = halfW > 0 ? Math.abs(lx) / halfW : 0 - const fz = halfD > 0 ? Math.abs(lz) / halfD : 0 - if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan) - return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan) + const inset = + Math.min(width, depth) * (seg.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio) + const heightRatio = seg.dutchHipHeightRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio + const lengthRatio = seg.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio + const lowerRise = slope.activeRh * heightRatio + + if (width >= depth) { + const waistHalfX = Math.max(0, (halfW - inset) * lengthRatio) + const waistHalfZ = Math.max(0, halfD - inset) + if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) { + const topRise = slope.activeRh * (1 - heightRatio) + const topTan = waistHalfZ > 0 ? topRise / waistHalfZ : 0 + return buildSlopeNormal(0, lz >= 0 ? 1 : -1, topTan, out) + } + + const xRun = Math.max(0.0001, halfW - waistHalfX) + const zRun = Math.max(0.0001, halfD - waistHalfZ) + const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0 + const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0 + return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize() + } + + const waistHalfX = Math.max(0, halfW - inset) + const waistHalfZ = Math.max(0, (halfD - inset) * lengthRatio) + if (Math.abs(lx) <= waistHalfX && Math.abs(lz) <= waistHalfZ) { + const topRise = slope.activeRh * (1 - heightRatio) + const topTan = waistHalfX > 0 ? topRise / waistHalfX : 0 + return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, topTan, out) + } + + const xRun = Math.max(0.0001, halfW - waistHalfX) + const zRun = Math.max(0.0001, halfD - waistHalfZ) + const xTan = Math.abs(lx) > waistHalfX ? lowerRise / xRun : 0 + const zTan = Math.abs(lz) > waistHalfZ ? lowerRise / zRun : 0 + return out.set((lx >= 0 ? 1 : -1) * xTan, 1, (lz >= 0 ? 1 : -1) * zTan).normalize() } - return new THREE.Vector3(0, 1, 0) + return out.set(0, 1, 0) } // ─── Quaternion helper ─────────────────────────────────────────────── @@ -628,8 +521,7 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni // depending on which slope they sit on, and registry chevrons end up // anchored to the wrong edge. Projecting +X keeps the basis stable // across slope-flips that share the same X axis. - const wx = new THREE.Vector3(1, 0, 0) - const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal))) + const right = _surfaceQuatRight.set(1, 0, 0).addScaledVector(normal, -normal.x) if (right.lengthSq() < 1e-6) { // Degenerate: normal is parallel to ±X. Fall back to +Z so the basis // is still well-defined; this is the wall-like edge case (vertical @@ -638,9 +530,9 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni } else { right.normalize() } - const forward = new THREE.Vector3().crossVectors(right, normal).normalize() - const m = new THREE.Matrix4().makeBasis(right, normal, forward) - return out.setFromRotationMatrix(m) + _surfaceQuatForward.crossVectors(right, normal).normalize() + _surfaceQuatMatrix.makeBasis(right, normal, _surfaceQuatForward) + return out.setFromRotationMatrix(_surfaceQuatMatrix) } // Yaw (about the surface normal, composed AFTER `surfaceQuatFromNormal`) @@ -650,7 +542,7 @@ export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaterni // → 0, −Z → π, +X → +π/2, −X → −π/2. Kept next to `surfaceQuatFromNormal` // so the two stay in lockstep — the formula is only valid for its basis. export function getDownSlopeYaw(lx: number, lz: number, seg: RoofSegmentNode): number { - const n = getAnalyticalNormal(lx, lz, seg) + const n = getAnalyticalNormal(lx, lz, seg, _downSlopeYawNormal) if (n.x === 0 && n.z === 0) return 0 return Math.atan2(n.x * n.y, n.z) } diff --git a/packages/nodes/src/shared/run-translation-offset.ts b/packages/nodes/src/shared/run-translation-offset.ts index 44bf540f..d327da0b 100644 --- a/packages/nodes/src/shared/run-translation-offset.ts +++ b/packages/nodes/src/shared/run-translation-offset.ts @@ -166,7 +166,7 @@ export function planRunTranslationOffsets(args: { } const partner = nodesById[conn.nodeId] - if (!partner || partner.type !== 'duct-fitting') return null + if (partner?.type !== 'duct-fitting') return null const elbow = { ...(partner as DuctFittingNode), ...elbowProfilePatch(profile), diff --git a/packages/nodes/src/shared/use-segment-trim-clip.tsx b/packages/nodes/src/shared/use-segment-trim-clip.tsx new file mode 100644 index 00000000..471ba979 --- /dev/null +++ b/packages/nodes/src/shared/use-segment-trim-clip.tsx @@ -0,0 +1,202 @@ +import { + type AnyNodeId, + normalizeRoofSegmentTrim, + type RoofSegmentNode, + type RoofSegmentTrim, + useLiveNodeOverrides, +} from '@pascal-app/core' +import { clipGeometryBySegmentTrim } from '@pascal-app/viewer' +import { useEffect, useMemo } from 'react' +import * as THREE from 'three' +import { Matrix4 } from 'three' + +// Does this segment remove any material? Mirrors the viewer's internal +// `hasSegmentTrim` (not exported) — the clip is a no-op otherwise, so the +// common (untrimmed) case skips all CSG work. +function segmentHasTrim(segment: RoofSegmentNode): boolean { + const t = normalizeRoofSegmentTrim(segment) + return ( + t.left > 0 || + t.right > 0 || + t.front > 0 || + t.back > 0 || + t.frontLeftX > 0 || + t.frontLeftZ > 0 || + t.frontRightX > 0 || + t.frontRightZ > 0 || + t.backLeftX > 0 || + t.backLeftZ > 0 || + t.backRightX > 0 || + t.backRightZ > 0 + ) +} + +// A stable key for the segment's trim + footprint, so the memo only re-clips +// when the cut shape actually changes (not on unrelated segment edits like +// material). +function segmentTrimKey(segment: RoofSegmentNode | undefined): string { + if (!segment) return 'none' + const t = normalizeRoofSegmentTrim(segment) + return JSON.stringify([ + segment.width, + segment.depth, + t.left, + t.right, + t.front, + t.back, + t.frontLeftX, + t.frontLeftZ, + t.frontRightX, + t.frontRightZ, + t.backLeftX, + t.backLeftZ, + t.backRightX, + t.backRightZ, + ]) +} + +/** + * Clip a roof accessory's geometry by its host segment's trim, so the part of + * the accessory standing in a trimmed-away region is sliced off exactly like + * the roof shell. + * + * `geometry` is in accessory-local space; `localToSegment` maps it into the + * segment-local frame the trim cut prisms live in (compose the same + * `node.position` + inner-group quaternion the renderer mounts the mesh with). + * The function bakes that transform, runs `clipGeometryBySegmentTrim`, then + * strips the transform back off so the returned geometry is still + * accessory-local and drops straight into the renderer's existing mesh. + * + * Returns the input geometry untouched when the segment is missing or has no + * trim — zero cost for the overwhelmingly common case. The derived (clipped) + * geometry is owned by the hook and disposed on change / unmount; the input + * geometry is never consumed (we clip a clone), so the caller keeps owning it. + */ +export function useSegmentTrimClippedGeometry( + geometry: THREE.BufferGeometry | null, + segment: RoofSegmentNode | undefined, + localToSegment: THREE.Matrix4, +): THREE.BufferGeometry | null { + // Subscribe to the segment's live trim override so the accessory re-slices + // in lockstep with the trim handle drag (the editor publishes the in-flight + // trim to `useLiveNodeOverrides`; the store only updates on commit). Without + // this the accessory would only re-clip once the drag is released. + const liveTrim = useLiveNodeOverrides((s) => + segment ? (s.get(segment.id as AnyNodeId)?.trim as RoofSegmentTrim | undefined) : undefined, + ) + const effectiveSegment = useMemo( + () => (segment && liveTrim ? { ...segment, trim: liveTrim } : segment), + [segment, liveTrim], + ) + + const trimKey = segmentTrimKey(effectiveSegment) + // Matrix identity isn't stable across renders; key on its elements. + const matrixKey = localToSegment.elements.join(',') + + const clipped = useMemo(() => { + if (!geometry || !effectiveSegment || !segmentHasTrim(effectiveSegment)) return null + const baked = geometry.clone() + baked.applyMatrix4(localToSegment) + const result = clipGeometryBySegmentTrim(baked, effectiveSegment) + if (!result) return null + // `clipGeometryBySegmentTrim` returns the same object when there's no trim + // (already guarded above) or a fresh clone otherwise; either way it's our + // `baked` clone or its descendant, safe to mutate + own. + const inverse = new Matrix4().copy(localToSegment).invert() + result.applyMatrix4(inverse) + result.computeVertexNormals() + // CSG can stamp the freshly-exposed cut faces with the cutter's material + // slot, which may exceed the accessory's material array (multi-slot kinds: + // dormer = 5 slots, solar-panel / skylight = 2). Clamp every group back into + // the original geometry's slot range so the renderer never indexes past its + // `material` array (mismatch crashes the draw, as the empty-segment + // placeholder guard documents). + const maxSlot = geometry.groups.reduce((m, g) => Math.max(m, g.materialIndex ?? 0), 0) + for (const g of result.groups) { + if ((g.materialIndex ?? 0) > maxSlot) g.materialIndex = maxSlot + } + return result + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [geometry, localToSegment, effectiveSegment]) + + useEffect(() => { + return () => { + clipped?.dispose() + } + }, [clipped]) + + // No trim (or no input) → render the original geometry unchanged. + return clipped ?? geometry +} + +const _trimMeshLocal = new THREE.Matrix4() +const _trimMeshPos = new THREE.Vector3() +const _trimMeshQuat = new THREE.Quaternion() +const _trimMeshEuler = new THREE.Euler() +const _trimMeshScale = new THREE.Vector3(1, 1, 1) + +/** + * A `` whose geometry is sliced by the host roof segment's trim, for + * accessory sub-parts that live deeper than the registered group (skylight + * glass panes, dormer window glass / frame / sill). Pass the matrix that maps + * the part's own parent frame into the segment-local frame (`parentToSegment`) + * plus the part's local `position` / `rotation`; the component composes the + * full mesh→segment transform, clips, and renders the result at the same local + * pose. When the segment has no trim the original geometry renders unchanged. + * + * `geometry` is owned by the caller (built once and reused); the clipped + * derivative is owned by the hook and disposed on change / unmount. + */ +export function TrimClippedMesh({ + geometry, + segment, + parentToSegment, + position = [0, 0, 0], + rotation = [0, 0, 0], + material, + name, + castShadow, + receiveShadow, +}: { + geometry: THREE.BufferGeometry + segment: RoofSegmentNode | undefined + parentToSegment: THREE.Matrix4 + position?: [number, number, number] + rotation?: [number, number, number] + material: THREE.Material | THREE.Material[] + name?: string + castShadow?: boolean + receiveShadow?: boolean +}) { + // mesh→segment = parentToSegment · T(position) · R(rotation). Keyed in the + // hook on the matrix elements, so a moving/animated pane re-clips correctly. + const localToSegment = useMemo(() => { + _trimMeshEuler.set(rotation[0], rotation[1], rotation[2]) + _trimMeshQuat.setFromEuler(_trimMeshEuler) + _trimMeshPos.set(position[0], position[1], position[2]) + _trimMeshLocal.compose(_trimMeshPos, _trimMeshQuat, _trimMeshScale) + return new Matrix4().multiplyMatrices(parentToSegment, _trimMeshLocal) + }, [ + parentToSegment, + position[0], + position[1], + position[2], + rotation[0], + rotation[1], + rotation[2], + ]) + + const clipped = useSegmentTrimClippedGeometry(geometry, segment, localToSegment) + + return ( + + ) +} diff --git a/packages/nodes/src/shared/vertical-offset.ts b/packages/nodes/src/shared/vertical-offset.ts index 54dd96bf..2ea4aa3d 100644 --- a/packages/nodes/src/shared/vertical-offset.ts +++ b/packages/nodes/src/shared/vertical-offset.ts @@ -590,7 +590,7 @@ function directRiserCollapseAction(args: { ) if (!farPort) return null const lower = nodesById[farPort.nodeId] - if (!lower || lower.type !== 'duct-fitting') return null + if (lower?.type !== 'duct-fitting') return null const lowerElbow = { ...(lower as DuctFittingNode), ...profilePatch } as DuctFittingNode if (lowerElbow.fittingType !== 'elbow') return null @@ -687,7 +687,7 @@ function fittingRiserAction(args: { continue } const lower = nodesById[farPort.nodeId] - if (!lower || lower.type !== 'duct-fitting') { + if (lower?.type !== 'duct-fitting') { if (stretch === 'stretch') return { status: 'stretch' } continue } @@ -828,7 +828,7 @@ function planVerticalOffsetsAtDy( // shows a red preview rather than dragging the network up to absorb it). if (conn.kind !== 'run') { const partner = nodesById[conn.nodeId] - if (!partner || partner.type !== 'duct-fitting') return { status: 'invalid' } + if (partner?.type !== 'duct-fitting') return { status: 'invalid' } const elbow = partner as DuctFittingNode const profilePatch = elbowProfilePatch(profile) const profiledElbow = { ...elbow, ...profilePatch } as DuctFittingNode diff --git a/packages/nodes/src/skylight/floorplan.ts b/packages/nodes/src/skylight/floorplan.ts index 2c4bf4cc..0a2be712 100644 --- a/packages/nodes/src/skylight/floorplan.ts +++ b/packages/nodes/src/skylight/floorplan.ts @@ -26,10 +26,10 @@ export function buildSkylightFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null const cosR = Math.cos(-roof.rotation) const sinR = Math.sin(-roof.rotation) diff --git a/packages/nodes/src/skylight/panel.tsx b/packages/nodes/src/skylight/panel.tsx index 2eaba0cb..1b54bba8 100644 --- a/packages/nodes/src/skylight/panel.tsx +++ b/packages/nodes/src/skylight/panel.tsx @@ -142,7 +142,7 @@ export default function SkylightPanel() { const state = useScene.getState() const worldPt = new Vector3(wx, 0, wz) for (const candidate of Object.values(state.nodes)) { - if (!candidate || candidate.type !== 'roof-segment') continue + if (candidate?.type !== 'roof-segment') continue const seg = candidate as RoofSegmentNode const segObj = sceneRegistry.nodes.get(seg.id) if (!segObj) continue diff --git a/packages/nodes/src/skylight/renderer.tsx b/packages/nodes/src/skylight/renderer.tsx index f4038470..f6fe5f7f 100644 --- a/packages/nodes/src/skylight/renderer.tsx +++ b/packages/nodes/src/skylight/renderer.tsx @@ -23,6 +23,7 @@ import { import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { surfaceQuatFromNormal } from '../shared/roof-surface' +import { TrimClippedMesh, useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildFrameGeometry } from './frame-csg' import { buildLanternGlassGeometry, clamp01, paneSize } from './geometry' @@ -99,6 +100,8 @@ function GlassPane({ position = [0, 0, 0], rotation, width, + segment, + parentToSegment, }: { glassThickness: number material: THREE.Material | THREE.Material[] @@ -107,11 +110,42 @@ function GlassPane({ position?: [number, number, number] rotation?: [number, number, number] width: number + // Trim-clip context: when provided, the glass pane is sliced by the host + // segment's trim (so the glazing matches the roof cutaway). Absent in + // contexts with no host segment frame. + segment?: RoofSegmentNode + parentToSegment?: THREE.Matrix4 }) { + const geometry = useMemo( + () => new THREE.BoxGeometry(paneSize(width), paneSize(glassThickness), paneSize(paneDepth)), + [width, glassThickness, paneDepth], + ) + useEffect(() => () => geometry.dispose(), [geometry]) + + if (segment && parentToSegment) { + return ( + + ) + } + return ( - - - + ) } @@ -181,11 +215,15 @@ function LanternGlass({ frameMaterial, glassMaterial, node, + segment, + parentToSegment, }: { curbHeight: number frameMaterial: THREE.Material | THREE.Material[] glassMaterial: THREE.Material | THREE.Material[] node: SkylightNode + segment?: RoofSegmentNode + parentToSegment?: THREE.Matrix4 }) { const preset = SKYLIGHT_TYPE_PRESETS.lantern const width = node.width - 0.01 @@ -228,9 +266,27 @@ function LanternGlass({ } }, [geometry]) + const glassParentToSegment = + segment && parentToSegment + ? new THREE.Matrix4() + .copy(parentToSegment) + .multiply(new THREE.Matrix4().makeTranslation(0, curbHeight, 0)) + : undefined + return ( - + {segment && glassParentToSegment ? ( + + ) : ( + + )} {baseCorners.map((corner, index) => ( { const hasCurb = node.curb ?? false const curbH = hasCurb ? Math.max(0, node.curbHeight ?? 0.1) : 0 + // Map skylight-local geometry into the host segment's local frame (where the + // trim cut prisms live) — the same pose the inner registered group is mounted + // with (position [x, surfaceY, z] + surfaceQuat·yaw). Only the structural + // frame is clipped; the thin glass panes live inside animated sub-components + // (sliding / hinged) with their own internal transforms and ride with the + // frame. Computed before the early return so the hook order stays stable. + const localToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, surfaceFrame.point.y, node.position[2] ?? 0), + composedQuat, + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[2], surfaceFrame.point.y, composedQuat], + ) + const clippedFrame = useSegmentTrimClippedGeometry(frameGeo, segment, localToSegment) + if (!segment || !frameGeo) return null const surfaceY = surfaceFrame.point.y @@ -707,7 +823,7 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { > { frameMaterial={frameMaterial} glassMaterial={glassMaterial} node={node} + parentToSegment={localToSegment} + segment={segment} /> )} {activeType === 'sliding' && ( @@ -728,6 +846,8 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { glassThickness={glassThickness} node={node} openAmount={openAmount} + parentToSegment={localToSegment} + segment={segment} /> )} {activeType === 'opening' && ( @@ -739,6 +859,8 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { hasMotorHousing={node.motorHousing ?? false} node={node} openAmount={openAmount} + parentToSegment={localToSegment} + segment={segment} /> )} {(activeType === 'flat' || activeType === 'walk-on') && ( @@ -746,7 +868,9 @@ const SkylightRenderer = ({ node: storeNode }: { node: SkylightNode }) => { glassThickness={glassThickness} material={glassMaterial} paneDepth={node.height + 0.004} + parentToSegment={localToSegment} position={[0, curbH + glassThickness / 2, 0]} + segment={segment} width={node.width + 0.004} /> )} diff --git a/packages/nodes/src/slab/move-tool.tsx b/packages/nodes/src/slab/move-tool.tsx index db9594e6..42e8afb6 100644 --- a/packages/nodes/src/slab/move-tool.tsx +++ b/packages/nodes/src/slab/move-tool.tsx @@ -20,6 +20,7 @@ import { getSegmentGridStep, isMagneticSnapActive, markToolCancelConsumed, + projectAlignmentGuidesWorldToActiveBuildingLocal, resolveAlignmentForActiveBuilding, snapBuildingLocalToWorldGrid, snapFenceDraftPoint, @@ -205,7 +206,9 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => { deltaX += result.snap.dx deltaZ += result.snap.dz } - useAlignmentGuides.getState().set(result.guides) + useAlignmentGuides + .getState() + .set(projectAlignmentGuidesWorldToActiveBuildingLocal(result.guides)) } else { useAlignmentGuides.getState().clear() } diff --git a/packages/nodes/src/solar-panel/__tests__/geometry.test.ts b/packages/nodes/src/solar-panel/__tests__/geometry.test.ts index deb6328b..f617c50a 100644 --- a/packages/nodes/src/solar-panel/__tests__/geometry.test.ts +++ b/packages/nodes/src/solar-panel/__tests__/geometry.test.ts @@ -135,9 +135,7 @@ describe('getAnalyticalNormal', () => { expect(top.x).toBeGreaterThan(0) expect(top.x).toBeLessThan(steep.x) }) - // Regression: dutch previously fell through to gable code, ignoring - // the X axis. Hip ends rendered with the wrong tilt direction. - test('dutch +x hip end tilts toward +x (w>=d)', () => { + test('dutch width-axis shoulder tilts toward +x when outside the waist span', () => { const seg = fixtureSegment({ roofType: 'dutch', width: 8, depth: 6 }) const n = getAnalyticalNormal(seg.width / 2 - 0.01, 0, seg) expect(n.x).toBeGreaterThan(0) @@ -151,6 +149,18 @@ describe('getAnalyticalNormal', () => { expect(Math.abs(n.x)).toBeLessThan(1e-6) expect(n.y).toBeGreaterThan(0) }) + test('dutch top gable face keeps the same fall direction near the ridge', () => { + const seg = fixtureSegment({ + roofType: 'dutch', + width: 8, + depth: 6, + dutchHipWidthRatio: 0.2, + dutchHipHeightRatio: 0.6, + }) + const upper = getAnalyticalNormal(0, 0.01, seg) + expect(upper.z).toBeGreaterThan(0) + expect(Math.abs(upper.x)).toBeLessThan(1e-6) + }) }) describe('computeAutoFit', () => { diff --git a/packages/nodes/src/solar-panel/floorplan.ts b/packages/nodes/src/solar-panel/floorplan.ts index dd305cf9..ac640d99 100644 --- a/packages/nodes/src/solar-panel/floorplan.ts +++ b/packages/nodes/src/solar-panel/floorplan.ts @@ -31,10 +31,10 @@ export function buildSolarPanelFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const segment = ctx.parent as RoofSegmentNode | null - if (!segment || segment.type !== 'roof-segment') return null + if (segment?.type !== 'roof-segment') return null const roofId = segment.parentId as AnyNodeId | null const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined - if (!roof || roof.type !== 'roof') return null + if (roof?.type !== 'roof') return null // Compose roof → segment → panel in plan coords. Each rotation is // negated so SVG's y-down CW matches Three.js' top-down CCW. diff --git a/packages/nodes/src/solar-panel/renderer.tsx b/packages/nodes/src/solar-panel/renderer.tsx index 997c29b0..f9211299 100644 --- a/packages/nodes/src/solar-panel/renderer.tsx +++ b/packages/nodes/src/solar-panel/renderer.tsx @@ -21,6 +21,7 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { MeshStandardNodeMaterial } from 'three/webgpu' import { surfaceQuatFromNormal } from '../shared/roof-surface' +import { useSegmentTrimClippedGeometry } from '../shared/use-segment-trim-clip' import { buildSolarPanelGeometry, getDefaultPanelMaterial } from './geometry' // Module-scope scratch vectors and quaternions for composing the panel's @@ -156,6 +157,34 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { [surfaceFrame.normal], ) + // Map panel-local geometry into the host segment's local frame (where the + // trim cut prisms live). Recompose the same pose the inner mesh group is + // mounted with (position [x, surfaceY, z] + surfaceQuat·yaw·tilt) so the clip + // matches the rendered placement exactly. Computed before the early return so + // the hook order stays stable. + const localToSegment = useMemo(() => { + const surfaceY = surfaceFrame.point.y + const tiltRad = node.mountingType === 'tilted' ? (node.tiltAngle * Math.PI) / 180 : 0 + const quat = new THREE.Quaternion() + .copy(surfaceQuat) + .multiply(new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0)) + .multiply(new THREE.Quaternion().setFromAxisAngle(xAxis, tiltRad)) + return new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, surfaceY, node.position[2] ?? 0), + quat, + new THREE.Vector3(1, 1, 1), + ) + }, [ + surfaceFrame.point.y, + surfaceQuat, + node.mountingType, + node.tiltAngle, + node.rotation, + node.position[0], + node.position[2], + ]) + const clippedGeometry = useSegmentTrimClippedGeometry(geometry, effectiveSegment, localToSegment) + if (!effectiveSegment || !geometry) return null const surfaceY = surfaceFrame.point.y @@ -190,7 +219,7 @@ const SolarPanelRenderer = ({ node: storeNode }: { node: SolarPanelNode }) => { > { if (!node?.parentId) return true const parent = s.nodes[node.parentId as AnyNodeId] - if (!parent || parent.type !== 'stair') return true + if (parent?.type !== 'stair') return true const children = (parent as any).children ?? [] return children[0] === node.id }) diff --git a/packages/nodes/src/stair/floorplan-affordances.ts b/packages/nodes/src/stair/floorplan-affordances.ts index f4dbe9de..90545532 100644 --- a/packages/nodes/src/stair/floorplan-affordances.ts +++ b/packages/nodes/src/stair/floorplan-affordances.ts @@ -57,7 +57,7 @@ export const segmentWidthAffordance: FloorplanAffordance = { const { segmentId, side, axisX } = payload as SegmentWidthPayload const segmentNodeId = segmentId as AnyNodeId const segment = nodes[segmentNodeId] as StairSegmentNode | undefined - if (!segment || segment.type !== 'stair-segment') return noopSession() + if (segment?.type !== 'stair-segment') return noopSession() const initialWidth = segment.width const sign = side === 'right' ? 1 : -1 @@ -96,7 +96,7 @@ export const segmentLengthAffordance: FloorplanAffordance = { const { segmentId, axisZ } = payload as SegmentLengthPayload const segmentNodeId = segmentId as AnyNodeId const segment = nodes[segmentNodeId] as StairSegmentNode | undefined - if (!segment || segment.type !== 'stair-segment') return noopSession() + if (segment?.type !== 'stair-segment') return noopSession() const initialLength = segment.length const az = axisZ[0] diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 13d6e6f2..00297893 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -309,6 +309,33 @@ export default function StairPanel() { value={Math.round((node.openingOffset ?? 0) * 100) / 100} /> ) : null} + + {node.stairType === 'spiral' && ( + <> +
+
+ Landing +
+ handleUpdate({ topLandingMode: value })} + options={TOP_LANDING_MODE_OPTIONS} + value={node.topLandingMode ?? 'none'} + /> +
+ {(node.topLandingMode ?? 'none') === 'integrated' && ( + handleUpdate({ topLandingDepth: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100} + /> + )} + + )}
@@ -415,23 +442,6 @@ export default function StairPanel() { /> {node.stairType === 'spiral' && ( <> - handleUpdate({ topLandingMode: value })} - options={TOP_LANDING_MODE_OPTIONS} - value={node.topLandingMode ?? 'none'} - /> - {(node.topLandingMode ?? 'none') === 'integrated' && ( - handleUpdate({ topLandingDepth: value })} - precision={2} - step={0.05} - unit="m" - value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100} - /> - )} return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat) }, [surfaceQuat, node.rotation, yAxis]) + const neckHForClip = Math.max( + 0.02, + Math.min(node.neckHeight ?? 0.09, Math.max(0.12, node.height) * 0.5), + ) + // Map vent-local geometry into the host segment's local frame (where the trim + // cut prisms live). The base sits at the inner group's pose; the head is + // nested one level deeper at [0, neckH, 0], so its clip matrix composes that + // offset. (When the head is spinning — opt-in, default paused — the cut edge + // rotates with it, which is acceptable for the animation.) + const baseLocalToSegment = useMemo( + () => + new THREE.Matrix4().compose( + new THREE.Vector3(node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0), + composedQuat, + new THREE.Vector3(1, 1, 1), + ), + [node.position[0], node.position[1], node.position[2], composedQuat], + ) + const headLocalToSegment = useMemo( + () => + new THREE.Matrix4() + .copy(baseLocalToSegment) + .multiply(new THREE.Matrix4().makeTranslation(0, neckHForClip, 0)), + [baseLocalToSegment, neckHForClip], + ) + const clippedBase = useSegmentTrimClippedGeometry(baseGeometry, segment, baseLocalToSegment) + const clippedHead = useSegmentTrimClippedGeometry(headGeometry, segment, headLocalToSegment) + if (!segment) return null // Replicate the parent segment's roof-local transform — see the long @@ -133,7 +162,7 @@ const TurbineVentRenderer = ({ node: storeNode }: { node: TurbineVentNode }) => > = ({ node }) canCommit() { const live = useScene.getState().nodes[wallId] as WallNode | undefined - if (!live || live.type !== 'wall') return false + if (live?.type !== 'wall') return false const [dx, dz] = lastDelta return dx !== 0 || dz !== 0 }, @@ -244,7 +244,7 @@ export const wallFloorplanMoveTarget: FloorplanMoveTarget = ({ node }) commit() { const sceneState = useScene.getState() const liveWall = sceneState.nodes[wallId] as WallNode | undefined - if (!liveWall || liveWall.type !== 'wall') { + if (liveWall?.type !== 'wall') { // Bail without leaving stale overrides behind. const overrides = useLiveNodeOverrides.getState() overrides.clear(wallId) diff --git a/packages/nodes/src/wall/floorplan-overrides.ts b/packages/nodes/src/wall/floorplan-overrides.ts index 72c6804b..d069d152 100644 --- a/packages/nodes/src/wall/floorplan-overrides.ts +++ b/packages/nodes/src/wall/floorplan-overrides.ts @@ -25,7 +25,7 @@ export function wallFloorplanSiblingOverrides(args: { let out: Record | null = null for (const [id, override] of liveOverrides) { const existing = nodes[id as AnyNodeId] - if (!existing || existing.type !== 'wall') continue + if (existing?.type !== 'wall') continue if (Object.keys(override).length === 0) continue if (!out) out = { ...nodes } out[id as AnyNodeId] = { ...existing, ...override } as AnyNode diff --git a/packages/nodes/src/wall/tool.tsx b/packages/nodes/src/wall/tool.tsx index 3f57359e..f7537dae 100644 --- a/packages/nodes/src/wall/tool.tsx +++ b/packages/nodes/src/wall/tool.tsx @@ -1,4 +1,5 @@ import { + type AnyNode, calculateLevelMiters, collectAlignmentAnchors, emitter, @@ -7,6 +8,7 @@ import { type LevelNode, type Point2D, resolveAlignment, + resolveBuildingForLevel, useScene, type WallMiterData, type WallNode, @@ -464,17 +466,45 @@ function updateWallPreview( mesh.geometry = geometry } -function getCurrentLevelWalls(): WallNode[] { - const currentLevelId = useViewer.getState().selection.levelId - const { nodes } = useScene.getState() - if (!currentLevelId) return [] - const levelNode = nodes[currentLevelId] - if (!levelNode || levelNode.type !== 'level') return [] +function getLevelWalls(levelId: string | null, nodes: Record): WallNode[] { + if (!levelId) return [] + const levelNode = nodes[levelId] + if (levelNode?.type !== 'level') return [] return (levelNode as LevelNode).children .map((childId) => nodes[childId]) .filter((node): node is WallNode => node?.type === 'wall') } +function getCurrentLevelWalls(): WallNode[] { + const currentLevelId = useViewer.getState().selection.levelId + const { nodes } = useScene.getState() + return getLevelWalls(currentLevelId ?? null, nodes) +} + +// Walls on the level directly beneath the active one. Levels share the same +// local XZ origin (they only differ in world Y), so these walls live in the +// identical coordinate frame and can be fed straight into the snap pipeline — +// letting the user draw a new wall aligned with the floor below. They are +// snap references only; `createWallOnCurrentLevel` re-derives its own +// current-level wall list, so the floor below is never split or mutated. +function getBelowLevelWalls(): WallNode[] { + const currentLevelId = useViewer.getState().selection.levelId + const { nodes } = useScene.getState() + if (!currentLevelId) return [] + const currentLevel = nodes[currentLevelId] + if (currentLevel?.type !== 'level') return [] + const buildingId = resolveBuildingForLevel(currentLevelId, nodes) + if (!buildingId) return [] + const building = nodes[buildingId] + if (building?.type !== 'building') return [] + const currentIndex = (currentLevel as LevelNode).level + const belowLevel = (building.children ?? []) + .map((childId) => nodes[childId]) + .filter((node): node is LevelNode => node?.type === 'level' && node.level < currentIndex) + .sort((a, b) => b.level - a.level)[0] + return getLevelWalls(belowLevel?.id ?? null, nodes) +} + export const WallTool: React.FC = () => { const unit = useViewer((state) => state.unit) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') @@ -558,6 +588,10 @@ export const WallTool: React.FC = () => { if (!(cursorRef.current && wallPreviewRef.current)) return const walls = getCurrentLevelWalls() + // Add walls on the floor below as extra snap references so the new wall + // can align with the level beneath it. Kept separate from `walls` so the + // measurement HUD only reports against the active level. + const snapWalls = [...walls, ...getBelowLevelWalls()] const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] // Snapping is governed entirely by the snapping mode (grid / lines / // angles / off). `'off'` is the bypass — there is no Shift hold-to-bypass. @@ -566,7 +600,7 @@ export const WallTool: React.FC = () => { const bypassAlign = !isMagneticSnapActive() const snapResult = snapWallDraftPointDetailed({ point: localPoint, - walls, + walls: snapWalls, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, angleSnap: angleLocked, magnetic: isMagneticSnapActive(), @@ -641,6 +675,7 @@ export const WallTool: React.FC = () => { } const walls = getCurrentLevelWalls() + const snapWalls = [...walls, ...getBelowLevelWalls()] const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]] // Alignment guides follow the snapping mode (lines = magnetic on), not Alt. @@ -650,7 +685,7 @@ export const WallTool: React.FC = () => { const snappedStart = alignPoint( snapWallDraftPointDetailed({ point: localClick, - walls, + walls: snapWalls, magnetic: isMagneticSnapActive(), }).point, { bypass: bypassAlign }, @@ -679,7 +714,7 @@ export const WallTool: React.FC = () => { const snappedEnd = alignPoint( snapWallDraftPointDetailed({ point: localClick, - walls, + walls: snapWalls, start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined, angleSnap: angleLocked, magnetic: isMagneticSnapActive(), diff --git a/packages/nodes/src/window/floorplan.ts b/packages/nodes/src/window/floorplan.ts index 7468d2bf..cb37e717 100644 --- a/packages/nodes/src/window/floorplan.ts +++ b/packages/nodes/src/window/floorplan.ts @@ -25,7 +25,7 @@ export function buildWindowFloorplan( ctx: GeometryContext, ): FloorplanGeometry | null { const wall = ctx.parent as WallNode | null - if (!wall || wall.type !== 'wall') return null + if (wall?.type !== 'wall') return null const [x1, z1] = wall.start const [x2, z2] = wall.end diff --git a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx index 286cdea2..56d52163 100644 --- a/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx +++ b/packages/viewer/src/components/viewer/glb-walkthrough-controller.tsx @@ -175,11 +175,8 @@ function buildGlbColliderWorld(scene: Object3D): GlbColliderWorld | null { merged?.dispose() return null } - // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype ;(merged as any).computeBoundsTree = computeBoundsTree - // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype ;(merged as any).disposeBoundsTree = disposeBoundsTree - // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension ;(merged as any).computeBoundsTree({ maxLeafSize: 12, strategy: 0 }) merged.computeBoundingBox() @@ -199,7 +196,6 @@ function buildGlbColliderWorld(scene: Object3D): GlbColliderWorld | null { mesh, minY: merged.boundingBox?.min.y ?? 0, dispose: () => { - // biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension ;(merged as any).disposeBoundsTree?.() merged.dispose() }, diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 761e4ce5..5dc8e3b2 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -53,6 +53,7 @@ export { csgEvaluator, csgGeometry, csgMaterials, + INTERSECTION, prepareBrushForCSG, SUBTRACTION, } from './lib/csg-utils' @@ -141,6 +142,8 @@ export { getRoofMaterialArray } from './systems/roof/roof-materials' // read these through the public surface. No kind-specific helpers // belong here — those live in `@pascal-app/nodes//`. export { + clipGeometryBySegmentTrim, + generateRoofSegmentGeometry, getRoofOuterSurfaceFrameAtPoint, getRoofSegmentBrushes, mapRoofGroupMaterialIndex, diff --git a/packages/viewer/src/lib/csg-utils.ts b/packages/viewer/src/lib/csg-utils.ts index 982b3a02..a59e900e 100644 --- a/packages/viewer/src/lib/csg-utils.ts +++ b/packages/viewer/src/lib/csg-utils.ts @@ -111,6 +111,6 @@ export function prepareBrushForCSG(brush: Brush) { brush.updateMatrixWorld() } -// Re-export Brush + SUBTRACTION + ADDITION so kinds don't need a direct -// `three-bvh-csg` dependency. -export { ADDITION, Brush, SUBTRACTION } from 'three-bvh-csg' +// Re-export Brush + SUBTRACTION + ADDITION + INTERSECTION so kinds don't need a +// direct `three-bvh-csg` dependency. +export { ADDITION, Brush, INTERSECTION, SUBTRACTION } from 'three-bvh-csg' diff --git a/packages/viewer/src/systems/ceiling/ceiling-system.tsx b/packages/viewer/src/systems/ceiling/ceiling-system.tsx index 3b5fd63c..b1fc6623 100644 --- a/packages/viewer/src/systems/ceiling/ceiling-system.tsx +++ b/packages/viewer/src/systems/ceiling/ceiling-system.tsx @@ -35,7 +35,7 @@ export const CeilingSystem = () => { // Process dirty ceilings dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'ceiling') return + if (node?.type !== 'ceiling') return const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (mesh) { diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index 186243f8..28a0e403 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -139,7 +139,7 @@ export const DoorSystem = () => { dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'door') return + if (node?.type !== 'door') return dirtyDoorIds.push(id as AnyNodeId) }) @@ -161,7 +161,7 @@ export const DoorSystem = () => { } const node = nodes[id] - if (!node || node.type !== 'door') continue + if (node?.type !== 'door') continue const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (!mesh) continue // Keep dirty until mesh mounts diff --git a/packages/viewer/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx index f60ca60e..180076b6 100644 --- a/packages/viewer/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -1,8 +1,8 @@ import { type AnyNodeId, type FenceNode, - getWallCurveFrameAt, - getWallCurveLength, + getFenceCenterlineFrameAt, + getFenceCenterlineLength, sceneRegistry, useScene, } from '@pascal-app/core' @@ -11,6 +11,7 @@ import * as THREE from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' type FencePart = { + geometry?: THREE.BufferGeometry position: [number, number, number] rotationY?: number scale: [number, number, number] @@ -20,8 +21,12 @@ type FencePart = { } const MIN_CURVE_SEGMENT_LENGTH = 0.18 +const HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH = 0.2 function createFencePartGeometry(part: FencePart) { + if (part.geometry) { + return part.geometry + } const geometry = part.shape === 'pyramid' ? new THREE.ConeGeometry(0.5, 1, 4, 1, false, Math.PI / 4) @@ -36,65 +41,164 @@ function createFencePartGeometry(part: FencePart) { } function getFencePointAt(fence: FenceNode, t: number) { - const frame = getWallCurveFrameAt(fence, t) + const frame = getFenceCenterlineFrameAt(fence, t) return { point: frame.point, tangentAngle: Math.atan2(frame.tangent.y, frame.tangent.x), } } -function createStraightFenceSpanPart( - start: [number, number], - end: [number, number], - centerY: number, - height: number, - depth: number, -): FencePart | null { - const dx = end[0] - start[0] - const dz = end[1] - start[1] - const length = Math.hypot(dx, dz) - if (length <= 1e-4) { - return null - } - - return { - position: [(start[0] + end[0]) / 2, centerY, (start[1] + end[1]) / 2], - rotationY: -Math.atan2(dz, dx), - scale: [length, height, depth], - } -} - -function createFenceCurveSpanParts( +function createFenceCurveBlockPart( fence: FenceNode, startT: number, endT: number, centerY: number, height: number, depth: number, -): FencePart[] { - const parts: FencePart[] = [] - const frameCount = Math.max( - 1, - Math.ceil( - (getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH, - ), - ) +): FencePart | null { + if (endT - startT <= 1e-5) return null + const halfHeight = height / 2 + const halfDepth = depth / 2 + const centerlineLength = getFenceCenterlineLength(fence) + const startDistance = startT * centerlineLength + const endDistance = endT * centerlineLength + const bottomY = centerY - halfHeight + const topY = centerY + halfHeight + const corners: Array<[number, number, number]> = [] - let previous = getFencePointAt(fence, startT) - for (let index = 1; index <= frameCount; index += 1) { - const t = startT + (endT - startT) * (index / frameCount) - const current = getFencePointAt(fence, t) - const segment = createStraightFenceSpanPart( - [previous.point.x, previous.point.y], - [current.point.x, current.point.y], + for (const t of [startT, endT]) { + const frame = getFencePointAt(fence, t) + const normalX = -Math.sin(frame.tangentAngle) + const normalZ = Math.cos(frame.tangentAngle) + + const outerX = frame.point.x + normalX * halfDepth + const outerZ = frame.point.y + normalZ * halfDepth + const innerX = frame.point.x - normalX * halfDepth + const innerZ = frame.point.y - normalZ * halfDepth + + corners.push( + [outerX, bottomY, outerZ], + [innerX, bottomY, innerZ], + [outerX, topY, outerZ], + [innerX, topY, innerZ], + ) + } + + const positions: number[] = [] + const uvs: number[] = [] + const pushVertex = (index: number, uv: [number, number]) => { + positions.push(...corners[index]!) + uvs.push(...uv) + } + + const pushQuad = ( + a: number, + b: number, + c: number, + d: number, + uvA: [number, number], + uvB: [number, number], + uvC: [number, number], + uvD: [number, number], + ) => { + pushVertex(a, uvA) + pushVertex(b, uvB) + pushVertex(c, uvC) + pushVertex(a, uvA) + pushVertex(c, uvC) + pushVertex(d, uvD) + } + + const topOuterV = topY + const topInnerV = topY + depth + const innerTopV = topInnerV + const innerBottomV = topInnerV + height + const bottomInnerV = bottomY - depth + + pushQuad( + 0, + 4, + 6, + 2, + [startDistance, bottomY], + [endDistance, bottomY], + [endDistance, topY], + [startDistance, topY], + ) + pushQuad( + 1, + 3, + 7, + 5, + [startDistance, innerBottomV], + [startDistance, innerTopV], + [endDistance, innerTopV], + [endDistance, innerBottomV], + ) + pushQuad( + 2, + 6, + 7, + 3, + [startDistance, topOuterV], + [endDistance, topOuterV], + [endDistance, topInnerV], + [startDistance, topInnerV], + ) + pushQuad( + 0, + 1, + 5, + 4, + [startDistance, bottomY], + [startDistance, bottomInnerV], + [endDistance, bottomInnerV], + [endDistance, bottomY], + ) + pushQuad(0, 2, 3, 1, [0, bottomY], [0, topY], [depth, innerTopV], [depth, innerBottomV]) + pushQuad(4, 5, 7, 6, [0, bottomY], [depth, innerBottomV], [depth, innerTopV], [0, topY]) + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute(new Float32Array(positions), 3), + ) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(uvs), 2)) + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(uvs), 2)) + geometry.computeVertexNormals() + + return { + geometry, + position: [0, 0, 0], + scale: [1, 1, 1], + } +} + +function createFenceCurveBlockParts( + fence: FenceNode, + startT: number, + endT: number, + centerY: number, + height: number, + depth: number, + maxSegmentLength = MIN_CURVE_SEGMENT_LENGTH, +): FencePart[] { + const length = getFenceCenterlineLength(fence) * Math.max(1e-4, endT - startT) + const segmentCount = Math.max(1, Math.ceil(length / Math.max(1e-4, maxSegmentLength))) + const parts: FencePart[] = [] + + for (let index = 0; index < segmentCount; index += 1) { + const segmentStartT = startT + (endT - startT) * (index / segmentCount) + const segmentEndT = startT + (endT - startT) * ((index + 1) / segmentCount) + const part = createFenceCurveBlockPart( + fence, + segmentStartT, + segmentEndT, centerY, height, depth, ) - if (segment) { - parts.push(segment) - } - previous = current + if (part) parts.push(part) } return parts @@ -176,7 +280,7 @@ function createHorizontalFenceParts(fence: FenceNode): FenceSlotParts { const base: FencePart[] = [] const rail: FencePart[] = [] - const length = Math.max(getWallCurveLength(fence), 0.01) + const length = Math.max(getFenceCenterlineLength(fence), 0.01) const panelDepth = Math.max(fence.thickness, 0.03) const clearance = Math.max(fence.groundClearance, 0) const isFloating = fence.baseStyle === 'floating' @@ -191,17 +295,25 @@ function createHorizontalFenceParts(fence: FenceNode): FenceSlotParts { const postWidth = Math.max(fence.postSize * 1.4, 0.04) const postDepth = postWidth const boardDepth = Math.min(panelDepth, postDepth - 0.012) + // Stop the horizontal boards / base / rail at the inner faces of the + // end posts. Letting curved spans run all the way to t=0/1 makes them + // overlap the terminal post mesh and creates the broken seam/notch seen + // at curve ends. + const edgeInset = Math.max(fence.edgeInset ?? 0.015, postWidth * 0.5) + const startInsetT = Math.min(0.499, edgeInset / length) + const endInsetT = Math.max(0.501, 1 - edgeInset / length) // Grounded fences get a kickboard along the bottom; floating ones don't. if (!isFloating) { base.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, - 0, - 1, + startInsetT, + endInsetT, baseY + baseHeight / 2, baseHeight, postDepth * 0.92, + HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH, ), ) } @@ -216,13 +328,14 @@ function createHorizontalFenceParts(fence: FenceNode): FenceSlotParts { // No reveal → one flush panel, so the stacked-board edge seams don't // read as faint lines where the user asked for a smooth surface. infill.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, - 0, - 1, + startInsetT, + endInsetT, infillBottom + verticalHeight / 2, verticalHeight, boardDepth, + HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH, ), ) } else { @@ -230,20 +343,31 @@ function createHorizontalFenceParts(fence: FenceNode): FenceSlotParts { const slabHeight = Math.max((verticalHeight - reveal * (boardCount - 1)) / boardCount, 0.02) for (let index = 0; index < boardCount; index += 1) { const centerY = infillBottom + slabHeight / 2 + index * (slabHeight + reveal) - infill.push(...createFenceCurveSpanParts(fence, 0, 1, centerY, slabHeight, boardDepth)) + infill.push( + ...createFenceCurveBlockParts( + fence, + startInsetT, + endInsetT, + centerY, + slabHeight, + boardDepth, + HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH, + ), + ) } } } // Top rail caps the boards. rail.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, - 0, - 1, + startInsetT, + endInsetT, baseY + baseHeight + verticalHeight + topRailHeight / 2, topRailHeight, Math.max(postDepth * 0.78, 0.02), + HORIZONTAL_FENCE_CURVE_SEGMENT_LENGTH, ), ) @@ -287,7 +411,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { const infill: FencePart[] = [] const base: FencePart[] = [] const rail: FencePart[] = [] - const length = Math.max(getWallCurveLength(fence), 0.01) + const length = Math.max(getFenceCenterlineLength(fence), 0.01) const panelDepth = Math.max(fence.thickness, 0.03) const clearance = Math.max(fence.groundClearance, 0) const styleDefaults = getStyleDefaults(fence.style) @@ -306,7 +430,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { if (!isFloating) { base.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, 0, 1, @@ -315,8 +439,9 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { panelDepth * 1.05, ), ) + base.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, 0, 1, @@ -332,7 +457,6 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { for (let index = 0; index < count; index += 1) { const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1)) - const frame = getFencePointAt(fence, t) const isEdgePost = index === 0 || index === count - 1 const fullHeightPost = !showInfill || (isFloating && isEdgePost) const postHeight = fullHeightPost @@ -342,17 +466,24 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { // End posts are the structural `posts` slot; the intermediate verticals are // the `infill` slats (only present when showInfill adds them). - // Depth is 0.001 m shy of the accent rail's `panelDepth * 0.35` so the two - // never share a coplanar face where they cross (kills the rail z-fighting). - ;(isEdgePost ? posts : infill).push({ - position: [frame.point.x, postY, frame.point.y], - rotationY: -frame.tangentAngle, - scale: [postWidth, postHeight, Math.max(panelDepth * 0.35 - 0.001, 0.011)], - }) + const slatHalfT = Math.max(0.0005, postWidth / (2 * length)) + const slatStartT = Math.max(0, t - slatHalfT) + const slatEndT = Math.min(1, t + slatHalfT) + const slat = createFenceCurveBlockPart( + fence, + slatStartT, + slatEndT, + postY, + postHeight, + Math.max(panelDepth * 0.35 - 0.001, 0.011), + ) + if (slat) { + ;(isEdgePost ? posts : infill).push(slat) + } } rail.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, 0, 1, @@ -364,7 +495,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts { if (isFloating) { rail.push( - ...createFenceCurveSpanParts( + ...createFenceCurveBlockParts( fence, 0, 1, @@ -421,7 +552,7 @@ export function generateFenceGeometry(fence: FenceNode) { function updateFenceGeometry(fenceId: FenceNode['id']) { const node = useScene.getState().nodes[fenceId] - if (!node || node.type !== 'fence') return + if (node?.type !== 'fence') return const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Mesh | undefined if (!mesh) return @@ -443,7 +574,7 @@ export const FenceSystem = () => { const nodes = useScene.getState().nodes dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'fence') return + if (node?.type !== 'fence') return updateFenceGeometry(id as FenceNode['id']) clearDirty(id as AnyNodeId) }) diff --git a/packages/viewer/src/systems/item/item-system.tsx b/packages/viewer/src/systems/item/item-system.tsx index 25cb03b4..0458437a 100644 --- a/packages/viewer/src/systems/item/item-system.tsx +++ b/packages/viewer/src/systems/item/item-system.tsx @@ -29,7 +29,7 @@ export const ItemSystem = () => { dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'item') return + if (node?.type !== 'item') return const item = node as ItemNode const mesh = sceneRegistry.nodes.get(id) as THREE.Object3D diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 637af8bc..3a65665a 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -1,10 +1,17 @@ import { type AnyNode, type AnyNodeId, + getDutchEndSlopeFaces, + getDutchRoofShapeMetrics, getEffectiveNode, + getRoofModuleFaces, + getRoofShapeInsets, + getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, nodeRegistry, + normalizeRoofSegmentTrim, + ROOF_SHAPE_DEFAULTS, type RoofNode, type RoofSegmentNode, type RoofType, @@ -14,7 +21,7 @@ import { } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import * as THREE from 'three' -import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' @@ -143,6 +150,10 @@ export const RoofSystem = () => { if (rootNodeIds.length === 0) { pendingRoofUpdates.clear() warnedMergedRoofNaNIds.clear() + for (const cached of mergedRoofSegmentGeometryCache.values()) { + disposeCachedMergedRoofSegmentGeometrySet(cached) + } + mergedRoofSegmentGeometryCache.clear() return } @@ -156,25 +167,19 @@ export const RoofSystem = () => { const node = nodes[id] if (!node) return - // Roof accessories (chimney, skylight, solar-panel, dormer, - // ridge-vent, box-vent — anything declaring - // `capabilities.roofAccessory` on its NodeDefinition) cascade - // their dirty mark to the host segment's parent roof so the - // merged shell re-CSGs with the new cut. Without this, moving / - // resizing an accessory leaves the merged roof showing the - // previous cut shape (stale CSG) once the user exits segment - // edit mode. Registry-driven so the viewer stays kind-agnostic. + // Cutting roof accessories cascade their dirty mark to the host + // segment's parent roof so the merged shell re-CSGs with the new + // cut. Non-cutting accessories (vents, panels, gutters, etc.) sit on + // top of the shell and should not force a full roof merge. const def = nodeRegistry.get(node.type) // Kinds with `dirtyHandledByOwnSystem` (door / window) reach the roof // through their own geometry system's parentId cascade instead — // their dirty marks belong to that system, not to this loop. - if ( - def?.capabilities?.roofAccessory && - !def.capabilities.roofAccessory.dirtyHandledByOwnSystem - ) { + const roofAccessory = def?.capabilities?.roofAccessory + if (roofAccessory && !roofAccessory.dirtyHandledByOwnSystem) { const segId = (node as { roofSegmentId?: string }).roofSegmentId const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined - if (seg?.parentId) { + if (roofAccessory.buildCut && seg?.parentId) { pendingRoofUpdates.add(seg.parentId as AnyNodeId) } clearDirty(id as AnyNodeId) @@ -265,7 +270,7 @@ export const RoofSystem = () => { if (roofsProcessed >= MAX_ROOFS_PER_FRAME) break const node = nodes[id] - if (!node || node.type !== 'roof') { + if (node?.type !== 'roof') { pendingRoofUpdates.delete(id) continue } @@ -319,7 +324,7 @@ function updateRoofSegmentGeometry( * Registry-driven so the viewer never names a kind. */ function subtractAccessoryCuts( - brushes: { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush }, + brushes: RoofSegmentBrushSet, segment: RoofSegmentNode, nodes: Record, ) { @@ -392,6 +397,80 @@ function subtractAccessoryCuts( brushes.wallBrush = workingWall } +function getMergedRoofSegmentBrushes( + roofNode: RoofNode, + segment: RoofSegmentNode, + nodes: Record, +): RoofSegmentBrushSet | null { + const segmentId = segment.id as AnyNodeId + const cacheKey = getMergedRoofSegmentCacheKey(roofNode, segment, nodes) + const cached = mergedRoofSegmentGeometryCache.get(segmentId) + if (cached?.key === cacheKey) { + return cloneCachedMergedRoofSegmentBrushes(cached) + } + + const brushes = withSegmentUvMatrix( + composeSegmentWorldMatrix( + roofNode.position, + roofNode.rotation ?? 0, + segment.position, + segment.rotation ?? 0, + ), + () => getRoofSegmentBrushes(segment), + ) + if (!brushes) { + disposeCachedMergedRoofSegmentGeometrySet(cached) + mergedRoofSegmentGeometryCache.delete(segmentId) + return null + } + + subtractAccessoryCuts(brushes, segment, nodes) + + _matrix.compose( + _position.set(segment.position[0], segment.position[1], segment.position[2]), + _quaternion.setFromAxisAngle(_yAxis, segment.rotation), + _scale, + ) + + const applyTransform = (brush: Brush) => { + csgGeometry(brush).applyMatrix4(_matrix) + brush.updateMatrixWorld() + } + + applyTransform(brushes.shinSlab) + applyTransform(brushes.deckSlab) + applyTransform(brushes.wallBrush) + applyTransform(brushes.innerBrush) + brushes.rakeBoards?.applyMatrix4(_matrix) + + const nextCached: CachedMergedRoofSegmentGeometrySet = { + key: cacheKey, + deckSlab: { + geometry: csgGeometry(brushes.deckSlab).clone(), + materials: csgMaterials(brushes.deckSlab), + }, + shinSlab: { + geometry: csgGeometry(brushes.shinSlab).clone(), + materials: csgMaterials(brushes.shinSlab), + }, + wallBrush: { + geometry: csgGeometry(brushes.wallBrush).clone(), + materials: csgMaterials(brushes.wallBrush), + }, + innerBrush: { + geometry: csgGeometry(brushes.innerBrush).clone(), + materials: csgMaterials(brushes.innerBrush), + }, + rakeBoards: brushes.rakeBoards?.clone() ?? null, + } + disposeCachedMergedRoofSegmentGeometrySet(cached) + mergedRoofSegmentGeometryCache.set(segmentId, nextCached) + + const cloned = cloneCachedMergedRoofSegmentBrushes(nextCached) + disposeRoofSegmentBrushSet(brushes) + return cloned +} + function updateMergedRoofGeometry( roofNode: RoofNode, group: THREE.Group, @@ -427,37 +506,15 @@ function updateMergedRoofGeometry( let totalDeckSlab: Brush | null = null let totalWall: Brush | null = null let totalInner: Brush | null = null + const rakeBoardGeometries: THREE.BufferGeometry[] = [] for (const child of children) { - const brushes = withSegmentUvMatrix( - composeSegmentWorldMatrix( - roofNode.position, - roofNode.rotation ?? 0, - child.position, - child.rotation ?? 0, - ), - () => getRoofSegmentBrushes(child), - ) + const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes) if (!brushes) continue - - subtractAccessoryCuts(brushes, child, nodes) - - _matrix.compose( - _position.set(child.position[0], child.position[1], child.position[2]), - _quaternion.setFromAxisAngle(_yAxis, child.rotation), - _scale, - ) - - const applyTransform = (brush: Brush) => { - csgGeometry(brush).applyMatrix4(_matrix) - brush.updateMatrixWorld() + if (brushes.rakeBoards) { + rakeBoardGeometries.push(brushes.rakeBoards) } - applyTransform(brushes.shinSlab) - applyTransform(brushes.deckSlab) - applyTransform(brushes.wallBrush) - applyTransform(brushes.innerBrush) - if (totalShinSlab) { const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush totalShinSlab.geometry.dispose() @@ -501,33 +558,31 @@ function updateMergedRoofGeometry( if (totalShinSlab && totalDeckSlab && totalWall && totalInner) { try { - const finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION) - prepareBrushForCSG(finalShinTrimmed) - const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION) - prepareBrushForCSG(finalDeckTrimmed) const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION) prepareBrushForCSG(finalWallTrimmed) - const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION) + const shinDeck = csgEvaluator.evaluate(totalShinSlab, totalDeckSlab, ADDITION) prepareBrushForCSG(shinDeck) const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION) prepareBrushForCSG(combined) const resultGeo = csgGeometry(combined) - if (geometryHasNaNPositions(resultGeo)) { + if (geometryHasInvalidAttributes(resultGeo)) { if (!warnedMergedRoofNaNIds.has(roofNode.id)) { - console.warn('[RoofSystem] Skipping merged roof geometry with NaN positions', roofNode.id) + console.warn( + '[RoofSystem] Skipping merged roof geometry with invalid attributes', + roofNode.id, + ) warnedMergedRoofNaNIds.add(roofNode.id) } resultGeo.dispose() - finalShinTrimmed.geometry.dispose() - finalDeckTrimmed.geometry.dispose() finalWallTrimmed.geometry.dispose() shinDeck.geometry.dispose() totalShinSlab.geometry.dispose() totalDeckSlab.geometry.dispose() totalWall.geometry.dispose() totalInner.geometry.dispose() + for (const geometry of rakeBoardGeometries) geometry.dispose() return } @@ -544,13 +599,21 @@ function updateMergedRoofGeometry( g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) } - resultGeo.computeVertexNormals() - ensureRenderableGeometryAttributes(resultGeo) + let finalGeo = resultGeo + if (rakeBoardGeometries.length > 0) { + const merged = mergeGeometriesPreservingGroups([finalGeo, ...rakeBoardGeometries]) + if (merged) { + finalGeo.dispose() + finalGeo = merged + } + } + for (const geometry of rakeBoardGeometries) geometry.dispose() + + finalGeo.computeVertexNormals() + ensureRenderableGeometryAttributes(finalGeo) mergedMesh.geometry.dispose() - mergedMesh.geometry = resultGeo + mergedMesh.geometry = finalGeo - finalShinTrimmed.geometry.dispose() - finalDeckTrimmed.geometry.dispose() finalWallTrimmed.geometry.dispose() shinDeck.geometry.dispose() } catch (e) { @@ -561,15 +624,39 @@ function updateMergedRoofGeometry( totalDeckSlab.geometry.dispose() totalWall.geometry.dispose() totalInner.geometry.dispose() + for (const geometry of rakeBoardGeometries) geometry.dispose() } } -function geometryHasNaNPositions(geometry: THREE.BufferGeometry) { +function geometryHasInvalidAttributes(geometry: THREE.BufferGeometry) { const position = geometry.getAttribute('position') - if (!position) return false + if (!(position && position.count > 0)) return true - for (let i = 0; i < position.array.length; i++) { - if (Number.isNaN(position.array[i])) return true + for (const name of ['position', 'normal', 'uv', 'uv2']) { + const attribute = geometry.getAttribute(name) + if (!attribute) continue + for (let i = 0; i < attribute.array.length; i++) { + if (!Number.isFinite(attribute.array[i])) return true + } + } + + const index = geometry.getIndex() + if (!index || index.count === 0) return true + for (let i = 0; i < index.count; i++) { + const value = index.getX(i) + if (!Number.isInteger(value) || value < 0 || value >= position.count) return true + } + + for (const group of geometry.groups) { + if ( + !Number.isInteger(group.start) || + !Number.isInteger(group.count) || + group.start < 0 || + group.count <= 0 || + group.start + group.count > index.count + ) { + return true + } } return false @@ -599,6 +686,102 @@ const dummyMats = roofCsgDummyMats export const ROOF_MATERIAL_SLOT_COUNT = 4 +type RoofSegmentBrushSet = { + deckSlab: Brush + shinSlab: Brush + wallBrush: Brush + innerBrush: Brush + rakeBoards: THREE.BufferGeometry | null +} + +type CachedMergedRoofSegmentGeometrySet = { + key: string + deckSlab: CachedMergedRoofSegmentBrush + shinSlab: CachedMergedRoofSegmentBrush + wallBrush: CachedMergedRoofSegmentBrush + innerBrush: CachedMergedRoofSegmentBrush + rakeBoards: THREE.BufferGeometry | null +} + +type CachedMergedRoofSegmentBrush = { + geometry: THREE.BufferGeometry + materials: THREE.Material[] +} + +const mergedRoofSegmentGeometryCache = new Map() + +function disposeCachedMergedRoofSegmentGeometrySet( + cached: CachedMergedRoofSegmentGeometrySet | undefined, +) { + if (!cached) return + cached.deckSlab.geometry.dispose() + cached.shinSlab.geometry.dispose() + cached.wallBrush.geometry.dispose() + cached.innerBrush.geometry.dispose() + cached.rakeBoards?.dispose() +} + +function disposeRoofSegmentBrushSet(brushes: RoofSegmentBrushSet) { + brushes.deckSlab.geometry.dispose() + brushes.shinSlab.geometry.dispose() + brushes.wallBrush.geometry.dispose() + brushes.innerBrush.geometry.dispose() + brushes.rakeBoards?.dispose() +} + +function cloneCachedBrush(cached: CachedMergedRoofSegmentBrush): Brush { + const brush = new Brush(cached.geometry.clone(), cached.materials) + prepareBrushForCSG(brush) + return brush +} + +function cloneCachedMergedRoofSegmentBrushes( + cached: CachedMergedRoofSegmentGeometrySet, +): RoofSegmentBrushSet { + return { + deckSlab: cloneCachedBrush(cached.deckSlab), + shinSlab: cloneCachedBrush(cached.shinSlab), + wallBrush: cloneCachedBrush(cached.wallBrush), + innerBrush: cloneCachedBrush(cached.innerBrush), + rakeBoards: cached.rakeBoards?.clone() ?? null, + } +} + +function getMergedRoofAccessoryCachePayload( + segment: RoofSegmentNode, + nodes: Record, +): unknown[] { + const payload: unknown[] = [] + for (const childElemId of segment.children ?? []) { + const storedChild = nodes[childElemId as AnyNodeId] + if (!storedChild) continue + const childElem = getEffectiveNode(storedChild) + const meta = + typeof childElem.metadata === 'object' && childElem.metadata !== null + ? (childElem.metadata as Record) + : undefined + if (meta?.isTransient) continue + + const childDef = nodeRegistry.get(childElem.type) + if (!childDef?.capabilities?.roofAccessory?.buildCut) continue + payload.push(childElem) + } + return payload +} + +function getMergedRoofSegmentCacheKey( + roofNode: RoofNode, + segment: RoofSegmentNode, + nodes: Record, +): string { + return JSON.stringify({ + roofPosition: roofNode.position ?? [0, 0, 0], + roofRotation: roofNode.rotation ?? 0, + segment, + accessories: getMergedRoofAccessoryCachePayload(segment, nodes), + }) +} + export function mapRoofGroupMaterialIndex( groupMaterialIndex: number | undefined, csgMaterials: THREE.Material[], @@ -635,17 +818,336 @@ function normalizeRoofMaterialIndex(materialIndex: number | undefined): number { return normalized } +function remapDutchRakeBoardMaterials(geometry: THREE.BufferGeometry) { + const position = geometry.getAttribute('position') + if (!position) return + + const index = geometry.getIndex() + const triangleCount = (index?.count ?? position.count) / 3 + if (!Number.isFinite(triangleCount) || triangleCount <= 0) return + + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + const ab = new THREE.Vector3() + const ac = new THREE.Vector3() + const normal = new THREE.Vector3() + const triangleMaterials = new Array(triangleCount).fill(DUTCH_RAKE_SIDE_MATERIAL_INDEX) + + for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) { + const offset = triangleIndex * 3 + const ia = index ? index.getX(offset) : offset + const ib = index ? index.getX(offset + 1) : offset + 1 + const ic = index ? index.getX(offset + 2) : offset + 2 + + a.fromBufferAttribute(position, ia) + b.fromBufferAttribute(position, ib) + c.fromBufferAttribute(position, ic) + ab.subVectors(b, a) + ac.subVectors(c, a) + normal.crossVectors(ab, ac).normalize() + + triangleMaterials[triangleIndex] = + Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON + ? DUTCH_RAKE_TOP_MATERIAL_INDEX + : DUTCH_RAKE_SIDE_MATERIAL_INDEX + } + + geometry.clearGroups() + let currentMaterial = triangleMaterials[0] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX + let groupStart = 0 + + for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) { + const materialIndex = triangleMaterials[triangleIndex] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX + if (materialIndex === currentMaterial) continue + + geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) + groupStart = triangleIndex + currentMaterial = materialIndex + } + + geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) +} + const SHINGLE_SURFACE_EPSILON = 0.02 const RAKE_FACE_NORMAL_EPSILON = 0.3 const RAKE_FACE_ALIGNMENT_EPSILON = 0.35 +const TRIM_CUT_EPSILON = 0.002 +const DUTCH_RAKE_SIDE_MATERIAL_INDEX = 1 +const DUTCH_RAKE_TOP_MATERIAL_INDEX = 3 +const DUTCH_RAKE_SLOPE_SEAT_OFFSET = 0.0002 + +function pushDoubleSidedFace(targetFaces: THREE.Vector3[][], face: THREE.Vector3[]) { + targetFaces.push(face) + targetFaces.push(face.map((point) => point.clone()).reverse()) +} + +function hasSegmentTrim(node: RoofSegmentNode): boolean { + const trim = normalizeRoofSegmentTrim(node) + return ( + trim.left > 0 || + trim.right > 0 || + trim.front > 0 || + trim.back > 0 || + trim.frontLeft > 0 || + trim.frontRight > 0 || + trim.backLeft > 0 || + trim.backRight > 0 || + trim.frontLeftX > 0 || + trim.frontLeftZ > 0 || + trim.frontRightX > 0 || + trim.frontRightZ > 0 || + trim.backLeftX > 0 || + trim.backLeftZ > 0 || + trim.backRightX > 0 || + trim.backRightZ > 0 + ) +} + +function buildTrimCutBrush( + minX: number, + maxX: number, + minZ: number, + maxZ: number, + minY: number, + maxY: number, +): Brush | null { + const width = maxX - minX + const height = maxY - minY + const depth = maxZ - minZ + if (!(width > 0 && height > 0 && depth > 0)) return null + + const geometry = new THREE.BoxGeometry(width, height, depth) + geometry.translate((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) + ensureRenderableGeometryAttributes(geometry) + computeGeometryBoundsTree(geometry) + + const cut = new Brush(geometry, dummyMats) + cut.updateMatrixWorld() + return cut +} + +type TrimPlanPoint = readonly [number, number] + +function buildDiagonalTrimCutBrush( + bounds: { + minX: number + maxX: number + minZ: number + maxZ: number + minY: number + maxY: number + }, + lineA: TrimPlanPoint, + lineB: TrimPlanPoint, + outsidePoint: TrimPlanPoint, +): Brush | null { + const dx = lineB[0] - lineA[0] + const dz = lineB[1] - lineA[1] + const lineLength = Math.hypot(dx, dz) + const height = bounds.maxY - bounds.minY + if (!(lineLength > 0 && height > 0)) return null + + const ux = dx / lineLength + const uz = dz / lineLength + let nx = -uz + let nz = ux + const midX = (lineA[0] + lineB[0]) / 2 + const midZ = (lineA[1] + lineB[1]) / 2 + const toOutsideX = outsidePoint[0] - midX + const toOutsideZ = outsidePoint[1] - midZ + let normalFlipped = false + if (nx * toOutsideX + nz * toOutsideZ < 0) { + nx *= -1 + nz *= -1 + normalFlipped = true + } + + const boundsDiagonal = Math.hypot(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ) + const cutterLength = Math.max(lineLength, boundsDiagonal) * 2 + const cutterDepth = boundsDiagonal * 2 + const centerX = midX + nx * (cutterDepth / 2 - TRIM_CUT_EPSILON) + const centerZ = midZ + nz * (cutterDepth / 2 - TRIM_CUT_EPSILON) + const yaw = Math.atan2(-uz, ux) + (normalFlipped ? Math.PI : 0) + + const geometry = new THREE.BoxGeometry(cutterLength, height, cutterDepth) + geometry.rotateY(yaw) + geometry.translate(centerX, (bounds.minY + bounds.maxY) / 2, centerZ) + ensureRenderableGeometryAttributes(geometry) + computeGeometryBoundsTree(geometry) + + const cut = new Brush(geometry, dummyMats) + cut.updateMatrixWorld() + return cut +} + +function subtractCutFromSegmentBrushes(brushes: RoofSegmentBrushSet, cut: Brush) { + for (const key of ['shinSlab', 'deckSlab', 'wallBrush', 'innerBrush'] as const) { + const next = csgEvaluator.evaluate(brushes[key], cut, SUBTRACTION) as Brush + brushes[key].geometry.dispose() + prepareBrushForCSG(next) + brushes[key] = next + } +} + +function buildSegmentTrimCutBrushes(segment: RoofSegmentNode): Brush[] { + const trim = normalizeRoofSegmentTrim(segment) + if ( + trim.left === 0 && + trim.right === 0 && + trim.front === 0 && + trim.back === 0 && + trim.frontLeft === 0 && + trim.frontRight === 0 && + trim.backLeft === 0 && + trim.backRight === 0 && + trim.frontLeftX === 0 && + trim.frontLeftZ === 0 && + trim.frontRightX === 0 && + trim.frontRightZ === 0 && + trim.backLeftX === 0 && + trim.backLeftZ === 0 && + trim.backRightX === 0 && + trim.backRightZ === 0 + ) { + return [] + } + + const { activeRh } = getSegmentSlopeFrame(segment) + const extra = + segment.wallThickness + segment.overhang + segment.deckThickness + segment.shingleThickness + 2 + const minX = -segment.width / 2 - extra + const maxX = segment.width / 2 + extra + const minZ = -segment.depth / 2 - extra + const maxZ = segment.depth / 2 + extra + const minY = -2 + const maxY = segment.wallHeight + activeRh + segment.deckThickness + segment.shingleThickness + 2 + + const cuts: Brush[] = [] + + if (trim.left > 0) { + const planeX = -segment.width / 2 + trim.left + const cut = buildTrimCutBrush(minX, planeX + TRIM_CUT_EPSILON, minZ, maxZ, minY, maxY) + if (cut) cuts.push(cut) + } + if (trim.right > 0) { + const planeX = segment.width / 2 - trim.right + const cut = buildTrimCutBrush(planeX - TRIM_CUT_EPSILON, maxX, minZ, maxZ, minY, maxY) + if (cut) cuts.push(cut) + } + if (trim.front > 0) { + const planeZ = segment.depth / 2 - trim.front + const cut = buildTrimCutBrush(minX, maxX, planeZ - TRIM_CUT_EPSILON, maxZ, minY, maxY) + if (cut) cuts.push(cut) + } + if (trim.back > 0) { + const planeZ = -segment.depth / 2 + trim.back + const cut = buildTrimCutBrush(minX, maxX, minZ, planeZ + TRIM_CUT_EPSILON, minY, maxY) + if (cut) cuts.push(cut) + } + + const leftX = -segment.width / 2 + trim.left + const rightX = segment.width / 2 - trim.right + const frontZ = segment.depth / 2 - trim.front + const backZ = -segment.depth / 2 + trim.back + + if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) { + const cut = buildDiagonalTrimCutBrush( + { minX, maxX, minZ, maxZ, minY, maxY }, + [leftX + trim.frontLeftX + TRIM_CUT_EPSILON, frontZ], + [leftX, frontZ - trim.frontLeftZ - TRIM_CUT_EPSILON], + [minX, maxZ], + ) + if (cut) cuts.push(cut) + } + if (trim.frontRightX > 0 && trim.frontRightZ > 0) { + const cut = buildDiagonalTrimCutBrush( + { minX, maxX, minZ, maxZ, minY, maxY }, + [rightX, frontZ - trim.frontRightZ - TRIM_CUT_EPSILON], + [rightX - trim.frontRightX - TRIM_CUT_EPSILON, frontZ], + [maxX, maxZ], + ) + if (cut) cuts.push(cut) + } + if (trim.backLeftX > 0 && trim.backLeftZ > 0) { + const cut = buildDiagonalTrimCutBrush( + { minX, maxX, minZ, maxZ, minY, maxY }, + [leftX, backZ + trim.backLeftZ + TRIM_CUT_EPSILON], + [leftX + trim.backLeftX + TRIM_CUT_EPSILON, backZ], + [minX, minZ], + ) + if (cut) cuts.push(cut) + } + if (trim.backRightX > 0 && trim.backRightZ > 0) { + const cut = buildDiagonalTrimCutBrush( + { minX, maxX, minZ, maxZ, minY, maxY }, + [rightX - trim.backRightX - TRIM_CUT_EPSILON, backZ], + [rightX, backZ + trim.backRightZ + TRIM_CUT_EPSILON], + [maxX, minZ], + ) + if (cut) cuts.push(cut) + } + + return cuts +} + +function subtractSegmentTrimCuts(brushes: RoofSegmentBrushSet, segment: RoofSegmentNode) { + const cuts = buildSegmentTrimCutBrushes(segment) + for (const cut of cuts) { + try { + subtractCutFromSegmentBrushes(brushes, cut) + } catch (e) { + console.error('Roof trim CSG failed:', e) + } finally { + cut.geometry.dispose() + } + } +} + +/** + * Subtract a segment's trim cuts from an arbitrary segment-local geometry, + * returning the clipped result. The input geometry is consumed (disposed) on + * each successful CSG pass — callers that need to keep the original must pass a + * clone. Returns the input untouched when the segment has no trim. Used + * internally for rake-board / end-slope attachments and exported so roof + * accessories (chimney, vents, skylight, …) can clip their own meshes by the + * same trim, in the same segment-local frame. + */ +export function clipGeometryBySegmentTrim( + geometry: THREE.BufferGeometry | null, + segment: RoofSegmentNode, +): THREE.BufferGeometry | null { + if (!geometry) return null + + const cuts = buildSegmentTrimCutBrushes(segment) + if (cuts.length === 0) return geometry + + let currentGeometry = geometry + for (const cut of cuts) { + try { + const brush = new Brush(currentGeometry, dummyMats) + prepareBrushForCSG(brush) + const next = csgEvaluator.evaluate(brush, cut, SUBTRACTION) as Brush + const trimmed = csgGeometry(next).clone() + currentGeometry.dispose() + next.geometry.dispose() + ensureRenderableGeometryAttributes(trimmed) + currentGeometry = trimmed + } catch (e) { + console.error('Roof trim CSG failed for attachment geometry:', e) + } finally { + cut.geometry.dispose() + } + } + + return currentGeometry +} /** * Generate complete hollow-shell geometry for a roof segment. * Ports the prototype's CSG approach using three-bvh-csg. */ -export function getRoofSegmentBrushes( - node: RoofSegmentNode, -): { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush } | null { +export function getRoofSegmentBrushes(node: RoofSegmentNode): RoofSegmentBrushSet | null { const { roofType, width, @@ -658,14 +1160,20 @@ export function getRoofSegmentBrushes( } = node const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node) - const shapeRatios: ShapeWidthRatios = { + const shapeRatios = getRoofShapeRatios({ gambrelLowerWidthRatio: node.gambrelLowerWidthRatio, mansardSteepWidthRatio: node.mansardSteepWidthRatio, dutchHipWidthRatio: node.dutchHipWidthRatio, - } + dutchHipHeightRatio: node.dutchHipHeightRatio, + dutchWaistLengthRatio: node.dutchWaistLengthRatio, + dutchGabletRake: node.dutchGabletRake, + }) const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness - const baseI = Math.min(width, depth) * 0.25 + // Gablet inset must track dutchHipWidthRatio so the 3D waist matches both + // the 2D floorplan and the slope frame (which derives activeRh from the + // same ratio). A hardcoded 0.25 desyncs the gablet from the parameter. + const baseI = Math.min(width, depth) * node.dutchHipWidthRatio const getVol = ( wExt: number, @@ -693,19 +1201,20 @@ export function getRoofSegmentBrushes( structuralI += deckThickness } - const faces = getModuleFaces( - roofType, - wV, - dV, - whV, - rhV, - safeBaseY, - { dutchI: structuralI }, - width, - depth, + const faces = getRoofModuleFaces({ + type: roofType, + w: wV, + d: dV, + wh: whV, + rh: rhV, + baseY: safeBaseY, + insets: { dutchI: structuralI }, + baseW: width, + baseD: depth, tanTheta, shapeRatios, - ) + dutchTopRakeThickness: node.dutchTopRakeThickness, + }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) return createGeometryFromFaces(faces, matIndex) } @@ -762,73 +1271,86 @@ export function getRoofSegmentBrushes( const topBaseY = shinBotWh - dropTop const botBaseY = shinBotWh - dropBot - const getInsets = (wh: number, bY: number, isVoid: boolean, brushW: number, brushD: number) => { - let inset = (wh - bY) * tanTheta - const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005 - if (inset > maxSafeInset) { - inset = maxSafeInset - } + const insetsBot = getRoofShapeInsets({ + roofType, + width, + depth, + wh: shinBotWh, + baseY: botBaseY, + isVoid: true, + brushW: shinBotW, + brushD: shinBotD, + tanTheta, + shingleThickness, + dutchHipWidthRatio: node.dutchHipWidthRatio, + }) + const insetsTop = getRoofShapeInsets({ + roofType, + width, + depth, + wh: shinTopWh, + baseY: topBaseY, + isVoid: false, + brushW: shinTopW, + brushD: shinTopD, + tanTheta, + shingleThickness, + dutchHipWidthRatio: node.dutchHipWidthRatio, + }) - let iF = 0, - iB = 0, - iL = 0, - iR = 0 - if (['hip', 'mansard', 'dutch'].includes(roofType)) { - iF = inset - iB = inset - iL = inset - iR = inset - } else if (['gable', 'gambrel'].includes(roofType)) { - iF = inset - iB = inset - } else if (roofType === 'shed') { - iF = inset - } + const botFaces = getRoofModuleFaces({ + type: roofType, + w: shinBotW, + d: shinBotD, + wh: shinBotWh, + rh: shinBotRh, + baseY: botBaseY, + insets: insetsBot, + baseW: width, + baseD: depth, + tanTheta, + shapeRatios, + dutchTopRakeThickness: node.dutchTopRakeThickness, + }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) + const topFaces = getRoofModuleFaces({ + type: roofType, + w: shinTopW, + d: shinTopD, + wh: shinTopWh, + rh: shinTopRh, + baseY: topBaseY, + insets: insetsTop, + baseW: width, + baseD: depth, + tanTheta, + shapeRatios, + dutchTopRakeThickness: node.dutchTopRakeThickness, + }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z))) - let structuralI = baseI - if (isVoid) { - structuralI += shingleThickness - } - return { iF, iB, iL, iR, dutchI: structuralI } + let rakeBoards: THREE.BufferGeometry | null = null + if (roofType === 'dutch' && insetsTop.dutchI !== undefined) { + rakeBoards = buildDutchRakeBoards( + shinTopW, + shinTopD, + shinTopWh, + shinTopRh, + insetsTop.dutchI, + shapeRatios, + node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, + node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness, + ) } - const insetsBot = getInsets(shinBotWh, botBaseY, true, shinBotW, shinBotD) - const insetsTop = getInsets(shinTopWh, topBaseY, false, shinTopW, shinTopD) - - const botFaces = getModuleFaces( - roofType, - shinBotW, - shinBotD, - shinBotWh, - shinBotRh, - botBaseY, - insetsBot, - width, - depth, - tanTheta, - shapeRatios, + const shinBotGeo = createGeometryFromFaces(botFaces, (normal) => + normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, ) - const topFaces = getModuleFaces( - roofType, - shinTopW, - shinTopD, - shinTopWh, - shinTopRh, - topBaseY, - insetsTop, - width, - depth, - tanTheta, - shapeRatios, - ) - - const shinBotGeo = createGeometryFromFaces(botFaces, 1) const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1, ) if (transZ !== 0) { shinTopGeo.translate(0, 0, transZ) + rakeBoards?.translate(0, 0, transZ) } const toBrush = (geo: THREE.BufferGeometry): Brush | null => { @@ -839,6 +1361,7 @@ export function getRoofSegmentBrushes( geo.groups = geo.groups.filter((g) => g.count > 0) if (geo.groups.length === 0) return null ensureRenderableGeometryAttributes(geo) + if (geometryHasInvalidAttributes(geo)) return null computeGeometryBoundsTree(geo) const brush = new Brush(geo, dummyMats) brush.updateMatrixWorld() @@ -893,7 +1416,26 @@ export function getRoofSegmentBrushes( shinTopBrush.geometry.dispose() shinBotBrush.geometry.dispose() - return { deckSlab, shinSlab, wallBrush, innerBrush } + const brushes = { + deckSlab, + shinSlab, + wallBrush, + innerBrush, + rakeBoards, + } + if (hasSegmentTrim(node)) { + subtractSegmentTrimCuts(brushes, node) + brushes.rakeBoards = clipGeometryBySegmentTrim(brushes.rakeBoards, node) + // The clip is a CSG subtraction: rake faces can come back as + // `slot + 4n` because the cutter contributes its own material array. + // Preserve top roof-material faces (slot 3) and force only cutter / + // side faces back to the rake side material. + if (brushes.rakeBoards) { + remapDutchRakeBoardMaterials(brushes.rakeBoards) + } + } + + return brushes } catch (e) { console.error('CSG prep failed:', e) } @@ -905,6 +1447,7 @@ export function getRoofSegmentBrushes( if (shinBotBrush) shinBotBrush.geometry.dispose() if (wallBrush) wallBrush.geometry.dispose() if (innerBrush) innerBrush.geometry.dispose() + rakeBoards?.dispose() return null } @@ -938,7 +1481,7 @@ export function generateRoofSegmentGeometry( subtractAccessoryCuts(brushes, node, nodes) } - const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes + const { deckSlab, shinSlab, wallBrush, innerBrush, rakeBoards } = brushes let resultGeo = new THREE.BufferGeometry() try { @@ -950,6 +1493,10 @@ export function generateRoofSegmentGeometry( prepareBrushForCSG(combined) resultGeo = csgGeometry(combined) + if (geometryHasInvalidAttributes(resultGeo)) { + resultGeo.dispose() + resultGeo = csgGeometry(wallBrush).clone() + } const resultMaterials = csgMaterials(combined) @@ -982,6 +1529,15 @@ export function generateRoofSegmentGeometry( wallBrush.geometry.dispose() innerBrush.geometry.dispose() + if (rakeBoards) { + const merged = mergeGeometriesPreservingGroups([resultGeo, rakeBoards]) + rakeBoards.dispose() + if (merged) { + resultGeo.dispose() + resultGeo = merged + } + } + resultGeo.computeVertexNormals() ensureRenderableGeometryAttributes(resultGeo) return resultGeo @@ -999,6 +1555,174 @@ type Insets = { dutchI?: number } +type RawGeometryGroup = { + start: number + count: number + materialIndex: number +} + +function createGeometryFromRawAttributes( + positions: number[], + normals: number[], + uvs: number[], + groups: RawGeometryGroup[], +): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + geometry.setIndex(Array.from({ length: positions.length / 3 }, (_, i) => i)) + for (const group of groups) { + geometry.addGroup(group.start, group.count, group.materialIndex) + } + ensureRenderableGeometryAttributes(geometry) + return geometry +} + +function mergeGeometriesPreservingGroups( + geometries: THREE.BufferGeometry[], +): THREE.BufferGeometry | null { + if (geometries.length === 0) return null + + const merged = mergeGeometries(geometries, false) + if (!merged) return null + + merged.clearGroups() + + let indexStart = 0 + for (const geometry of geometries) { + if (geometry.groups.length > 0) { + for (const group of geometry.groups) { + merged.addGroup(indexStart + group.start, group.count, group.materialIndex ?? 0) + } + } else { + const count = geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0 + if (count > 0) { + merged.addGroup(indexStart, count, 0) + } + } + + indexStart += geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0 + } + + return merged +} + +function collectGeometryPlanes(geometry: THREE.BufferGeometry): THREE.Plane[] { + const source = geometry.index ? geometry.toNonIndexed() : geometry + const position = source.getAttribute('position') as THREE.BufferAttribute | undefined + const planes: THREE.Plane[] = [] + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + if (!position) return planes + for (let i = 0; i + 2 < position.count; i += 3) { + a.fromBufferAttribute(position, i) + b.fromBufferAttribute(position, i + 1) + c.fromBufferAttribute(position, i + 2) + const plane = new THREE.Plane().setFromCoplanarPoints(a, b, c) + if (Number.isFinite(plane.normal.x) && plane.normal.lengthSq() > 1e-10) { + planes.push(plane.normalize()) + } + } + if (source !== geometry) source.dispose() + return planes +} + +function splitShellByFacePlanes( + geometry: THREE.BufferGeometry, + planes: THREE.Plane[], + materialIndex: number, +): THREE.BufferGeometry { + if (planes.length === 0) return geometry + + const source = geometry.index ? geometry.toNonIndexed() : geometry.clone() + const position = source.getAttribute('position') as THREE.BufferAttribute | undefined + const normal = source.getAttribute('normal') as THREE.BufferAttribute | undefined + const uv = source.getAttribute('uv') as THREE.BufferAttribute | undefined + if (!position || !normal || !uv) { + source.dispose() + return geometry + } + + const groups = + source.groups.length > 0 ? source.groups : [{ start: 0, count: position.count, materialIndex }] + const keptPositions: number[] = [] + const keptNormals: number[] = [] + const keptUvs: number[] = [] + const keptGroups: RawGeometryGroup[] = [] + let keptVertexCount = 0 + const point = new THREE.Vector3() + const triNormal = new THREE.Vector3() + + const pushTriangle = ( + targetPositions: number[], + targetNormals: number[], + targetUvs: number[], + targetGroups: RawGeometryGroup[], + startVertex: number, + groupMaterialIndex: number, + vertexOffset: number, + ) => { + if ( + targetGroups.length === 0 || + targetGroups[targetGroups.length - 1]!.materialIndex !== groupMaterialIndex + ) { + targetGroups.push({ start: startVertex, count: 0, materialIndex: groupMaterialIndex }) + } + const targetGroup = targetGroups[targetGroups.length - 1]! + for (let k = 0; k < 3; k += 1) { + const vi = vertexOffset + k + targetPositions.push(position.getX(vi), position.getY(vi), position.getZ(vi)) + targetNormals.push(normal.getX(vi), normal.getY(vi), normal.getZ(vi)) + targetUvs.push(uv.getX(vi), uv.getY(vi)) + } + targetGroup.count += 3 + } + + const isPlaneMatch = (vertexOffset: number) => { + if ( + materialIndex >= 0 && + triNormal.fromBufferAttribute(normal, vertexOffset).y <= SHINGLE_SURFACE_EPSILON + ) { + return false + } + triNormal.fromBufferAttribute(normal, vertexOffset).normalize() + return planes.some((plane) => { + if (Math.abs(triNormal.dot(plane.normal)) < 0.999) return false + for (let k = 0; k < 3; k += 1) { + point.fromBufferAttribute(position, vertexOffset + k) + if (Math.abs(plane.distanceToPoint(point)) > 1e-3) return false + } + return true + }) + } + + for (const group of groups) { + const groupStart = Math.max(0, group.start) + const groupEnd = Math.min(position.count, group.start + group.count) + for (let i = groupStart; i + 2 < groupEnd; i += 3) { + if (group.materialIndex === materialIndex && isPlaneMatch(i)) { + continue + } + pushTriangle( + keptPositions, + keptNormals, + keptUvs, + keptGroups, + keptVertexCount, + group.materialIndex ?? 0, + i, + ) + keptVertexCount += 3 + } + } + + source.dispose() + geometry.dispose() + return createGeometryFromRawAttributes(keptPositions, keptNormals, keptUvs, keptGroups) +} + export function remapRoofShellFaces(geometry: THREE.BufferGeometry, node: RoofSegmentNode) { const position = geometry.getAttribute('position') const index = geometry.getIndex() @@ -1044,12 +1768,14 @@ export function remapRoofShellFaces(geometry: THREE.BufferGeometry, node: RoofSe .add(c) .multiplyScalar(1 / 3) - if (normal.y > SHINGLE_SURFACE_EPSILON) { + if (node.roofType === 'dutch' && Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON) { + materialIndex = 3 + } else if (normal.y > SHINGLE_SURFACE_EPSILON) { materialIndex = 3 } else if (isRakeFace(node, geometry, centroid, normal)) { - materialIndex = 0 - } else { materialIndex = 1 + } else { + materialIndex = 0 } } @@ -1106,7 +1832,6 @@ function isRakeFace( function getRakeAxis(node: RoofSegmentNode): 'x' | 'z' | null { if (node.roofType === 'gable' || node.roofType === 'gambrel') return 'x' - if (node.roofType === 'dutch') return node.width >= node.depth ? 'x' : 'z' return null } @@ -1114,6 +1839,9 @@ type ShapeWidthRatios = { gambrelLowerWidthRatio: number mansardSteepWidthRatio: number dutchHipWidthRatio: number + dutchHipHeightRatio: number + dutchWaistLengthRatio: number + dutchGabletRake: number } /** @@ -1136,6 +1864,7 @@ function getModuleFaces( baseD: number, tanTheta: number, shapeRatios: ShapeWidthRatios, + dutchTopRakeThickness?: number, ): THREE.Vector3[][] { const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z) const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets @@ -1206,82 +1935,270 @@ function getModuleFaces( const m2 = v(w / 2 - i, mh, d / 2 - i) const m3 = v(w / 2 - i, mh, -d / 2 + i) const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2) - const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2) - const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2) - const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2) - if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) { - if (w >= d) { - const r1 = v(-w / 2 + d / 2, h, 0) - const r2 = v(w / 2 - d / 2, h, 0) - faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]) - } else { - const r1 = v(0, h, d / 2 - w / 2) - const r2 = v(0, h, -d / 2 + w / 2) - faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]) - } + const topW = w - i * 2 + const topD = d - i * 2 + + faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4]) + + if (Math.abs(topW - topD) < 0.01) { + const r = v(0, h, 0) + faces.push([m4, m1, r], [m1, m2, r], [m2, m3, r], [m3, m4, r]) + } else if (topW >= topD) { + const r1 = v(-topW / 2 + topD / 2, h, 0) + const r2 = v(topW / 2 - topD / 2, h, 0) + faces.push([m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2]) } else { - faces.push( - [t1, t2, t3, t4], - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m1, m2, t2, t1], - [m2, m3, t3, t2], - [m3, m4, t4, t3], - [m4, m1, t1, t4], - ) + const r1 = v(0, h, topD / 2 - topW / 2) + const r2 = v(0, h, -topD / 2 + topW / 2) + faces.push([m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2]) } } else if (type === 'dutch') { - const i = - insets.dutchI !== undefined - ? insets.dutchI - : Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio - const mh = wh + i * (tanTheta || 0) + const dutch = getDutchRoofShapeMetrics({ + w, + d, + wh, + rh, + dutchI: insets.dutchI, + baseW, + baseD, + shapeRatios, + }) + if (!dutch) return faces - if (w >= d) { - const m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const r1 = v(-w / 2 + i, h, 0) - const r2 = v(w / 2 - i, h, 0) - - faces.push( - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m4, m1, r1], - [m2, m3, r2], - [m1, m2, r2, r1], - [m3, m4, r1, r2], - ) + if (dutch.axis === 'width') { + const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const r1 = v(-dutch.innerWaistHalfX, h, 0) + const r2 = v(dutch.innerWaistHalfX, h, 0) + const endSlopes = getDutchEndSlopeFaces({ + w, + d, + wh, + rh, + insets, + baseW, + baseD, + shapeRatios, + dutchTopRakeThickness, + }).map((face) => face.map((point) => v(point.x, point.y, point.z))) + faces.push([e1, e2, o2, m2, m1, o1], [e3, e4, o4, m4, m3, o3]) + if (endSlopes.length === 2) { + faces.push(...endSlopes) + } else { + faces.push([e2, e3, o3, o2], [e4, e1, o1, o4]) + } + faces.push([m1, m2, r2, r1], [m3, m4, r1, r2]) + faces.push([m4, m1, r1], [m2, m3, r2]) } else { - const m1 = v(-w / 2 + i, mh, d / 2 - i) - const m2 = v(w / 2 - i, mh, d / 2 - i) - const m3 = v(w / 2 - i, mh, -d / 2 + i) - const m4 = v(-w / 2 + i, mh, -d / 2 + i) - const r1 = v(0, h, d / 2 - i) - const r2 = v(0, h, -d / 2 + i) - - faces.push( - [e1, e2, m2, m1], - [e2, e3, m3, m2], - [e3, e4, m4, m3], - [e4, e1, m1, m4], - [m1, m2, r1], - [m3, m4, r2], - [m2, m3, r2, r1], - [m4, m1, r1, r2], - ) + const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ) + const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ) + const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ) + const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ) + const r1 = v(0, h, dutch.innerWaistHalfZ) + const r2 = v(0, h, -dutch.innerWaistHalfZ) + const endSlopes = getDutchEndSlopeFaces({ + w, + d, + wh, + rh, + insets, + baseW, + baseD, + shapeRatios, + dutchTopRakeThickness, + }).map((face) => face.map((point) => v(point.x, point.y, point.z))) + faces.push([e2, e3, o3, m3, m2, o2], [e4, e1, o1, m1, m4, o4]) + if (endSlopes.length === 2) { + faces.push(...endSlopes) + } else { + faces.push([e1, e2, o2, o1], [e3, e4, o4, o3]) + } + faces.push([m2, m3, r2, r1], [m4, m1, r1, r2]) + faces.push([m1, m2, r1], [m3, m4, r2]) } } return faces } +function addDutchRakeBoard( + apex: THREE.Vector3, + base: THREE.Vector3, + outward: THREE.Vector3, + reach: number, + thickness: number, + topFaces: THREE.Vector3[][], + sideFaces: THREE.Vector3[][], +) { + if (!(reach > 0.001) || !(thickness > 0.0001)) return + + const apexOuter = apex.clone().addScaledVector(outward, reach) + const baseOuter = base.clone().addScaledVector(outward, reach) + const topPoly = [apex.clone(), base.clone(), baseOuter, apexOuter] + + const normal = new THREE.Vector3() + .crossVectors( + new THREE.Vector3().subVectors(topPoly[1]!, topPoly[0]!), + new THREE.Vector3().subVectors(topPoly[2]!, topPoly[0]!), + ) + .normalize() + if (normal.y < 0) { + topPoly.reverse() + normal.multiplyScalar(-1) + } + + const top = topPoly.map((point) => + point.clone().addScaledVector(normal, DUTCH_RAKE_SLOPE_SEAT_OFFSET), + ) + const bottom = top.map((point) => new THREE.Vector3(point.x, point.y - thickness, point.z)) + pushDoubleSidedFace(topFaces, top) + pushDoubleSidedFace(sideFaces, bottom.slice().reverse()) + for (let i = 0; i < top.length; i += 1) { + const next = (i + 1) % top.length + pushDoubleSidedFace(sideFaces, [ + top[i]!.clone(), + top[next]!.clone(), + bottom[next]!.clone(), + bottom[i]!.clone(), + ]) + } +} + +function buildDutchRakeBoards( + W: number, + D: number, + wh: number, + rh: number, + i: number, + shapeRatios: ShapeWidthRatios, + rake: number, + thickness: number, +): THREE.BufferGeometry | null { + if (!(rake > 0.001) || !(thickness > 0.0001) || !(i > 0.001) || !(rh > 0.001)) { + return null + } + + const dutch = getDutchRoofShapeMetrics({ + w: W, + d: D, + wh, + rh, + dutchI: i, + baseW: W, + baseD: D, + shapeRatios: { + ...shapeRatios, + dutchGabletRake: rake, + }, + }) + if (!dutch || !(dutch.rakeReach > 0.001)) return null + + const v = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z) + const topFaces: THREE.Vector3[][] = [] + const sideFaces: THREE.Vector3[][] = [] + + if (dutch.axis === 'width') { + addDutchRakeBoard( + v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), + v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), + v(-1, 0, 0), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), + v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), + v(-1, 0, 0), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(dutch.innerWaistHalfX, dutch.peakHeight, 0), + v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), + v(1, 0, 0), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(dutch.innerWaistHalfX, dutch.peakHeight, 0), + v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), + v(1, 0, 0), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + } else { + addDutchRakeBoard( + v(0, dutch.peakHeight, dutch.innerWaistHalfZ), + v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), + v(0, 0, 1), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(0, dutch.peakHeight, dutch.innerWaistHalfZ), + v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), + v(0, 0, 1), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), + v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), + v(0, 0, -1), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + addDutchRakeBoard( + v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), + v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), + v(0, 0, -1), + dutch.rakeReach, + thickness, + topFaces, + sideFaces, + ) + } + + const geometries: THREE.BufferGeometry[] = [] + if (topFaces.length > 0) { + geometries.push(createGeometryFromFaces(topFaces, DUTCH_RAKE_TOP_MATERIAL_INDEX)) + } + if (sideFaces.length > 0) { + geometries.push(createGeometryFromFaces(sideFaces, DUTCH_RAKE_SIDE_MATERIAL_INDEX)) + } + + if (geometries.length === 0) return null + if (geometries.length === 1) return geometries[0]! + + const merged = mergeGeometriesPreservingGroups(geometries) + for (const geometry of geometries) geometry.dispose() + return merged +} + /** * Converts an array of face polygons into a BufferGeometry. * Each face is triangulated via fan triangulation. @@ -1289,6 +2206,9 @@ function getModuleFaces( function createGeometryFromFaces( faces: THREE.Vector3[][], matRule: number | ((normal: THREE.Vector3) => number) | null = null, + options?: { + treatBidirectionalSlopeFacesAsSlope?: boolean + }, ): THREE.BufferGeometry { const positions: number[] = [] const normals: number[] = [] @@ -1306,15 +2226,21 @@ function createGeometryFromFaces( const vA = new THREE.Vector3().subVectors(p1, p0) const vB = new THREE.Vector3().subVectors(p2, p0) const normal = new THREE.Vector3().crossVectors(vA, vB).normalize() + if (normal.lengthSq() < 1e-12) continue let slopeAlignedDown: THREE.Vector3 | null = null let slopeAlignedAcross: THREE.Vector3 | null = null let slopeAlignedVOrigin = 0 - if (normal.y > SHINGLE_SURFACE_EPSILON) { - _uvDownSlope.copy(_uvWorldDown).projectOnPlane(normal) + const slopeUvNormal = + options?.treatBidirectionalSlopeFacesAsSlope && normal.y < 0 + ? normal.clone().multiplyScalar(-1) + : normal + + if (Math.abs(slopeUvNormal.y) > SHINGLE_SURFACE_EPSILON) { + _uvDownSlope.copy(_uvWorldDown).projectOnPlane(slopeUvNormal) if (_uvDownSlope.lengthSq() > 1e-8) { _uvDownSlope.normalize() - _uvAcrossSlope.crossVectors(_uvDownSlope, normal).normalize() + _uvAcrossSlope.crossVectors(_uvDownSlope, slopeUvNormal).normalize() let highestPoint = face[0]! for (const candidate of face) { @@ -1504,6 +2430,9 @@ export function getRoofOuterSurfaceFrameAtPoint( const topBaseY = 0 const baseI = Math.min(width, depth) * 0.25 + // Dutch gablet waist tracks dutchHipWidthRatio (see getRoofSegmentBrushes); + // the generic baseI above still drives the bottom-rect insets for other types. + const dutchBaseI = Math.min(width, depth) * segment.dutchHipWidthRatio const getInsets = ( _wh: number, _baseY: number, @@ -1534,7 +2463,7 @@ export function getRoofOuterSurfaceFrameAtPoint( iF = inset } - let structuralI = baseI + let structuralI = dutchBaseI if (isVoid) { structuralI += shingleThickness } @@ -1547,6 +2476,10 @@ export function getRoofOuterSurfaceFrameAtPoint( gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio, mansardSteepWidthRatio: segment.mansardSteepWidthRatio, dutchHipWidthRatio: segment.dutchHipWidthRatio, + dutchHipHeightRatio: segment.dutchHipHeightRatio, + dutchWaistLengthRatio: + segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio, + dutchGabletRake: segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, } const topFaces = getModuleFaces( roofType, @@ -1560,6 +2493,7 @@ export function getRoofOuterSurfaceFrameAtPoint( depth, tanTheta, shapeRatios, + segment.dutchTopRakeThickness, ) const topGeo = createGeometryFromFaces(topFaces, (normal) => diff --git a/packages/viewer/src/systems/slab/slab-system.tsx b/packages/viewer/src/systems/slab/slab-system.tsx index 78addcf3..61b28063 100644 --- a/packages/viewer/src/systems/slab/slab-system.tsx +++ b/packages/viewer/src/systems/slab/slab-system.tsx @@ -48,7 +48,7 @@ export const SlabSystem = () => { // Process dirty slabs dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'slab') return + if (node?.type !== 'slab') return const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (mesh) { diff --git a/packages/viewer/src/systems/stair/stair-system.tsx b/packages/viewer/src/systems/stair/stair-system.tsx index 19bd62a2..7581b2c0 100644 --- a/packages/viewer/src/systems/stair/stair-system.tsx +++ b/packages/viewer/src/systems/stair/stair-system.tsx @@ -88,7 +88,7 @@ export const StairSystem = () => { // --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) --- for (const stairId of parentsNeedingSegmentSync) { const baseStairNode = nodes[stairId] - if (!baseStairNode || baseStairNode.type !== 'stair') continue + if (baseStairNode?.type !== 'stair') continue // Merge any in-flight drag override (e.g. parent-stair rotate handle) // so slab-elevation spatial queries match where the segments are // actually being rendered. Without this, dragging the rotate gizmo @@ -110,7 +110,7 @@ export const StairSystem = () => { if (stairsProcessed >= MAX_STAIRS_PER_FRAME) break const node = nodes[id] - if (!node || node.type !== 'stair') { + if (node?.type !== 'stair') { pendingStairUpdates.delete(id) continue } @@ -1075,7 +1075,7 @@ function computeAbsoluteHeight(node: StairSegmentNode): number { if (!node.parentId) return 0 const parent = nodes[node.parentId as AnyNodeId] - if (!parent || parent.type !== 'stair') return 0 + if (parent?.type !== 'stair') return 0 const stair = parent as StairNode const segments = (stair.children ?? []) diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 4fa7779e..52d23d47 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -103,7 +103,7 @@ export const WallCutout = () => { const wallMesh = sceneRegistry.nodes.get(wallId) if (!wallMesh) return const wallNode = useScene.getState().nodes[wallId as WallNode['id']] - if (!wallNode || wallNode.type !== 'wall') return + if (wallNode?.type !== 'wall') return const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const isDeleteHighlighted = deleteHoveredWallId === wallId @@ -155,7 +155,7 @@ export const WallCutout = () => { const wallMesh = sceneRegistry.nodes.get(wallId) as Mesh | undefined if (!wallMesh) return const wallNode = useScene.getState().nodes[wallId as AnyNodeId] as WallNode | undefined - if (!wallNode || wallNode.type !== 'wall') return + if (wallNode?.type !== 'wall') return const mats = getMaterialsForWall( wallNode, useViewer.getState().shading, diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 8ebe59be..824dc21b 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -366,7 +366,7 @@ export const WallSystem = () => { if (hasDirty) { dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'wall') return + if (node?.type !== 'wall') return const levelId = node.parentId if (!levelId) return @@ -515,7 +515,7 @@ function getLevelWalls(levelId: string): WallNode[] { const { nodes } = useScene.getState() const level = nodes[levelId as AnyNodeId] - if (!level || level.type !== 'level') return [] + if (level?.type !== 'level') return [] const walls: WallNode[] = [] for (const childId of level.children) { @@ -536,7 +536,7 @@ function getLevelWalls(levelId: string): WallNode[] { function updateWallGeometry(wallId: string, miterData: WallMiterData) { const nodes = useScene.getState().nodes const sceneNode = nodes[wallId as WallNode['id']] - if (!sceneNode || sceneNode.type !== 'wall') return + if (sceneNode?.type !== 'wall') return const node = getEffectiveWall(sceneNode as WallNode) const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh diff --git a/packages/viewer/src/systems/window/window-system.tsx b/packages/viewer/src/systems/window/window-system.tsx index 618971b9..32f8c676 100644 --- a/packages/viewer/src/systems/window/window-system.tsx +++ b/packages/viewer/src/systems/window/window-system.tsx @@ -116,7 +116,7 @@ export const WindowSystem = () => { dirtyNodes.forEach((id) => { const node = nodes[id] - if (!node || node.type !== 'window') return + if (node?.type !== 'window') return dirtyWindowIds.push(id as AnyNodeId) }) @@ -138,7 +138,7 @@ export const WindowSystem = () => { } const node = nodes[id] - if (!node || node.type !== 'window') continue + if (node?.type !== 'window') continue const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh if (!mesh) continue // Keep dirty until mesh mounts