feat(nodes): roof accessories — gutters, downspouts & vents + gizmos (#355)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * fix(editor): ceiling-attached item placement from 2D floor plan The 3D viewer drives ceiling-item placement via ceiling:enter/move/click raycast events on the ceiling mesh. The floor plan has no such mesh, so ceiling-attached items (lights, fans) never transitioned out of surface: 'floor' — the draft sat at floor height while the 2D cursor moved freely, reading as a 2D/3D sync bug. Synthesise the same ceiling events from 2D plan points by hit-testing ceiling polygons on the active level, and publish the building-local cursor (not world-space) to useLiveTransforms so the floorplan registry override renders the draft under the cursor regardless of building position / rotation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(editor): chevron arrows render on SCENE_LAYER so ink-edge shader outlines them Node arrow handles and polygon-editor edge arrows were tagged for EDITOR_LAYER, which hides them from the post-processing scenePass — the ink-edge shader reads the depth/normal MRT from that pass, so the chevrons rendered flat with no outlined edges. Drop the EDITOR_LAYER tagging on both, matching the wall-height arrow which already stays on SCENE_LAYER for the same reason. Pair with depthWrite: true on the chevron materials so their silhouettes enter the depth buffer; depthTest stays off to keep the chevron drawn on top of underlying geometry. Without depthWrite, only the normal-discontinuity branch of the ink shader can detect the chevron, and the lines drop out when faces align with whatever sits behind them in screen space. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(editor): ceiling grid overlay no longer blocks selecting items under it Two issues kept the ceiling grid overlay covering the room after the user moved on from a ceiling-related action: 1. CeilingSystem treated any selected descendant of a ceiling as "reveal the grid" — so after placing a ceiling light and the new item became selected, the grid stayed on and its mesh intercepted every subsequent 3D click, re-selecting the ceiling instead of the items below. Restrict the reveal to directly-selected ceilings. 2. The ceiling top material used the opaque surface-role material, so a top-down camera lost view of everything under the ceiling the moment the overlay turned on. Swap the top material for the transparent grid-pattern material (bottom stays opaque so the in-room view still reads as a solid surface). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(core): curve- and thickness-aware wall/slab overlap detection `wallOverlapsPolygon` and `getSlabElevationForWall` previously treated a wall as an infinitely thin chord from start to end. Two failure modes fell out of that: 1. Curved walls whose chord lies outside the slab but whose centerline bows into the slab interior were missed entirely. The wall stayed at Y=0 while the slab elevation moved, and `markNodesOverlappingSlab` never re-dirtied it when the slab Y changed. 2. Perimeter walls of a room — whose centerline sits exactly on (or just outside) the slab's polygon edge — also missed detection, because pointInPolygon on the boundary is unreliable. Half the wall's body is inside the slab; it should follow the slab elevation. Switch `wallOverlapsPolygon` to a wall-shaped input (start/end + optional curveOffset + thickness), sample the centerline for curved walls, and add a ±halfThickness perpendicular test for straight walls. Threaded through `getSlabElevationForWall`, the wall system, and the `markNodesOverlappingSlab` pass. Legacy chord-only call shape preserved for callers that don't yet have a wall in hand. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(editor): show Move button for legacy-movable kinds in floating action menu `isRegistryMovable` only sees kinds wired through `capabilities.movable`, `floorplanMoveTarget`, or `affordanceTools.move`. The legacy tail of `MoveTool` (tools/item/move-tool.tsx) still handles roof, roof-segment, stair, stair-segment, building, and elevator, but the floating action menu was hiding the Move button for them because the registry check returned false. The mover worked once invoked — the entry point was missing. Add a `LEGACY_MOVABLE_KINDS` set alongside the registry check so those kinds get the Move button until they migrate onto kind-owned affordances; drop a kind from the set once it does. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nodes): in-world registry handles for roof accessories + axis-stable surface basis Adds the registry-driven chevron / tracker / rotate-gizmo handle set to skylight, solar-panel, chimney, and roof-segment so every roof-mounted kind has consistent in-world manipulation, and fixes the underlying renderer + surface-basis bugs that made those handles land on the wrong spot or behave inconsistently across mirrored slopes. Handles - `LinearResizeHandle` gains `shape: 'arrow' | 'tracker'`. `'tracker'` renders a dashed vertical leader from the surface up to a draggable cube, reusing the linear-resize drag pipeline. Roof-segment's wall-height handle adopts it. - Roof-segment: width chevrons split into two asymmetric handles (each grows its own edge, opposite stays world-fixed via `apply` recomputing `position`). - Skylight: width × 2 (asymmetric), height × 2 (asymmetric), curb-height tracker, rotate gizmo (corner, lifted off surface), and a diagonal frame-thickness chevron at the -X+Z corner. - Solar panel: same six handles operating on total array dimensions (back-solving `panelWidth` / `panelHeight` from `columns` / `rows`), plus a frame-depth chevron above the array. - Chimney: registry handle set following the same idioms. Skylight / solar-panel renderer - Collapse the previously nested `position → surfaceQuat → rotation-y → rotation-x` groups into a single registered transform group whose local `position` + composed `quaternion` carry the full pose in segment frame. The registry handles read this Object3D's local matrix (via `portal: 'grandparent'`), and a split tree exposed only the bottom group's local pose so handles landed at the segment origin on the roof floor. Surface basis (solar-panel/geometry.ts) - `surfaceQuatFromNormal` builds `right` by projecting world +X onto the surface plane instead of `up × normal`. The cross-product version flipped sign when the normal's Z component flipped (e.g. the two slopes of a gable roof), so hosted children's local +X pointed in opposite world directions across the ridge and asymmetric chevrons anchored the wrong edge. Projecting +X keeps the basis stable across mirror-image slopes. Skylight move-tool ghost - Switch from the raycast normal (`event.normal × normalMatrix`) to the analytical normal (`getAnalyticalNormal`) on every pointer move, and mirror the placement tool's transform stack: `position → yaw (roof + segment) → surfaceQuat → skylight rotation → preview`. Re-engaging Move from the floating action menu now shows the same correctly oriented ghost the first-placement tool does. * feat(nodes): split roof-segment depth chevron into asymmetric front/back arrows Brings the depth handle in line with the width handles: one chevron on each Z edge, each anchored to the opposite edge so dragging only moves its own side. `apply` recomputes `position` along the segment's local +Z arm (yaw-aware) so the anchored edge stays world-fixed. Depth also feeds the slope-frame math via `getActiveRoofHeight`, so a naïve depth change would also raise/lower the peak (constant pitch across a larger run). We hold the peak fixed by back-solving a new `pitch` for the new depth via `getPitchFromActiveRoofHeight`, clamped to the schema's pitch range — the segment grows along the deck plane without ramping up. * feat(nodes): in-world handles for dormer + window-bottom clipping check Dormer joins chimney / roof-segment with chevron handles on the selected node. Five body handles (width L/R, depth, wall-height tracker, rotate) plus four window-opening handles (width L/R, height top/bottom) — the window handles re-emit windowOffsetX / windowOffsetY in `apply` so the anchored edge stays put as the dragged edge follows the pointer. Handle visibility on roof accessories needed an editor-side assist: the host segment's mesh registers inside RoofRenderer's `<group segments-wrapper visible={false}>`, which hides anything portaled into it. Chimney's `portal: 'grandparent'` escape trips a WebGPU "Color target has no corresponding fragment stage output" pipeline error on dormer (likely an MRT interaction with the window-assembly's transparent glazing), so RoofEditSystem now flips the wrapper visible whenever ANY accessory hosted on a segment of this roof is selected — and resets each segment mesh to an empty 4-group placeholder on the transition so stale per-segment CSG from a prior edit doesn't double-render on top of the merged shell. Window clipping was using wall-top-above-slope as the exposure threshold, but the window sits in the skirt well below the eave — so a dormer whose eave barely cleared the host roof rendered a fully-buried window. `getDormerExposedFaces` now gates on window-bottom-above-slope; both the CSG cut decision and the window-assembly render path feed off the same number. The in-world window chevrons resolve the host segment via sceneApi and flip to whichever face is currently exposed, so dragging the dormer across the ridge moves the chevrons to the visible gable. Also fixes the BoxGeometry vs ExtrudeGeometry mismatch in `buildDormerFallbackGeometry` — body was indexed, roof was not, so `mergeGeometries` rejected the pair and spammed the console on every height-drag frame. Body is now `.toNonIndexed()` before the merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(viewer): glazing role uses FrontSide to avoid MRT back-face pipeline error DoubleSide on a NodeMaterial inside the MRT scene pass makes WebGPU compile a back-face shader variant that doesn't declare outputs for every MRT target — the validator rejects it and poisons the render context with "Color target has no corresponding fragment stage output". The warning was already documented on `glassMaterial` (materials.ts:77), but `createSurfaceRoleMaterial` was still forcing DoubleSide for the glazing role. Manifested on scene open as soon as a dormer was present: the dormer's window-assembly mounts the glazing material on both gable faces on the first frame, so the back-face pipeline gets compiled immediately. Glazing now resolves to FrontSide; the dormer's back gable group flips 180° so its FrontSide normals point outward (the sill no longer needs its per-face Z mirror since the group rotation handles it). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nodes): in-world handles for box-vent + ridge-vent; freeze non-active arrows during drag Adds 5 chevron handles to box-vent (width L/R, depth, height, rotate) and ridge-vent (length L/R, width, height, rotate). Box-vent's renderer now composes slope tilt + yaw onto the registered ref's quaternion (mirrors solar-panel) so handle placements use vent-mesh-local coords directly; ridge-vent's registered ref was already at the vent frame. Ridge-vent renderer now merges `useLiveNodeOverrides` so the mesh updates in-flight during a handle drag instead of freezing until commit. Box-vent / dormer / chimney already did this; ridge-vent was the only roof accessory not subscribed. `NodeArrowHandles` now tracks the active drag descriptor + a pre-drag store snapshot. Non-active arrows render against the snapshot with a node-local freeze offset that cancels the mesh's `position` drift — asymmetric resize (width / length L+R) recomputes position to anchor the opposite edge, and without the freeze every other chevron would slide along with the moving mesh center. The active arrow's freeze offset is null, so it tracks the cursor as before. Rotation drags collapse the offset to zero (position doesn't change), so non-active chevrons naturally rotate with the mesh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(nodes): gutter accessory — eave-mounted rain channel with three profiles New `gutter` node kind hosted on a roof-segment. Placement tool snaps to the eave line of the segment under the cursor (segment-local `Z = +depth/2`, `Y = wallHeight`); the back wall of the gutter then sits flush against the fascia and the trough hangs outward (+Z). Three cross-section profiles share the same outer-outline-minus-cavity extrude recipe and only differ in the outline curve: - `k-style`: ogee fascia (S-curve) — default residential look - `half-round`: semicircular trough — colonial / classical feel - `box`: rectangular u-channel — commercial / industrial Three in-world chevron handles via the registry: - length L + R (asymmetric — drag one end, the other stays world-fixed) - size (anchor='max', drops the trough downward as the cursor pulls) Wiring touches every node-kind ledger: schema (core/schema/nodes/ gutter.ts), AnyNode union, schema barrel, material targets, roof-segment hosted-accessory comment, event bus (`gutter:*`), nodes barrel + registry, plus the per-kind file set under `packages/nodes/src/gutter/` (geometry, schema re-export, parametrics, renderer, preview, tool, definition, index). V1 ships gutters only — downspouts deferred so the eave-snap + cross-section pipeline can be eyeballed before stacking the downspout-corner placement logic on top. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gutter): roof-panel entry, eave-edge snap, open-top U-channel, move tool UI surface area: - Roof inspector "Elements" section now lists existing gutters + an "Add Gutter" button. Adds `'gutter'` to the StructureTool union so setTool('gutter') typechecks. - Sidebar tree-node map gets a GutterTreeNode entry; selecting a gutter in the outline focuses it just like other roof accessories. - Floating action menu now shows a Move button on a selected gutter via the new `affordanceTools.move`. MoveGutterTool ghost-follows the cursor, eave-snaps on each frame, and commits to the new segment + side on click. Mirrors the ridge-vent move flow. Geometry fix: - Three cross-sections were authored as a closed outline + inset hole, which extrudes as a sealed box with a tunnel through it (top sealed). Real gutters need an OPEN top. Each profile now traces a single U-shape polygon around the channel material: outer wall down -> bottom -> outer wall up -> front rim -> inner wall down -> inner bottom -> inner wall up -> back rim. The interior of the U is empty space, not a hole inside a closed shape. Placement fix: - Snap now lands on the OUTER drip edge of the roof, not the wall line. Segment-local Z = sign * (depth/2 + overhang - 4 cm tuck), Y = wallHeight - overhang * tan(pitch) + 4 cm tuck. Sign of the cursor's localZ picks the near eave; back eave uses rotation = pi so the trough hangs outward in both directions. The 4 cm tuck offsets keep the gutter visually attached to the fascia rather than floating at the very tip of the overhang. Hook order fix (regression from the previous commit): - `NodeArrowHandlesForNode` had its new useState/useMemo hooks AFTER the `if (!portalObject ...) return null` guard. The registry-resolve useEffect flips portalObject from null to object one frame later, so the guard passed on render N+1 and three new hooks suddenly appeared in the hook list. Moved them above the early return. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(viewer/nodes): drop DoubleSide NodeMaterial MRT landmines across slab, vents, gutter, window DoubleSide on any NodeMaterial inside the MRT scenePass makes WebGPU compile a back-face shader variant whose fragment outputs don't cover every MRT target — the validator rejects the pipeline and poisons the render context with "Color target has no corresponding fragment stage output but writeMask is not zero", manifesting as renderPipeline_NNN invalid on scene open. The pattern was already documented at materials.ts:77 and fixed for glazing in 9400f1c5, but several roof / floor renderers still requested DoubleSide on `createSurfaceRoleMaterial` (which returns a `MeshLambertNodeMaterial`) and on user-supplied materials (which may also be NodeMaterials): - slab/geometry.ts — fired on every untextured floor; the live culprit on scene reload after the gutter renderer was switched to FrontSide. - gutter/renderer.tsx — the U-channel cross-section is traced as a single closed polygon around the material, so ExtrudeGeometry already produces outward-facing normals on every visible face; DoubleSide was speculative. - box-vent / ridge-vent renderers — DoubleSide was deliberate (to keep back faces of thin extrudes visible from below); now a known visual tradeoff. Build the geometry as a closed solid in `geometry.ts` if the underside-view becomes noticeable; do not bring DoubleSide back. - viewer/lib/materials.ts `DEFAULT_WINDOW_MATERIAL` — same fix on the fallback window material. Local `defaultMaterial` constants in box-vent / ridge-vent / gutter also lose their `side: DoubleSide` for consistency (those are `MeshStandardMaterial`, hit only when a preset ref fails to resolve, but the same landmine pattern). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(editor): Shift-snap rotate gizmos to 15° increments Holding Shift while dragging a whole-node rotation arrow now snaps the delta to π/12 (15°) steps. Scoped to `descriptor.shape === 'rotate'` so curved-stair sweep handles keep their continuous feel. * feat(nodes): stair railings track live segment-drag overrides `StairRailings` was reading each child segment from zustand only, so width/length/height drag handles (which publish to `useLiveNodeOverrides` and only flush on release) left the railing frozen at the pre-drag values until release. Subscribe to the override map and merge each child's override onto its zustand snapshot so the railing rebuilds every frame during the drag. * chore(ifc-converter): next-env routes path moves under .next/dev/types Auto-generated next-env.d.ts update from the local Next.js dev server — the routes type now lives under `.next/dev/types/routes.d.ts` rather than `.next/types/routes.d.ts`. * fix(gutter): roofType-aware eave snap — 4-way on hip/flat, low side on shed Gutter place + move tools were hard-coded to snap to ±Z eaves, working for gable / gambrel / mansard / dutch but missing: - Hip / flat: 4 eaves, not 2. Clicks on the side slopes (±X eaves) collapsed back onto ±Z, so users couldn't place a gutter on a hip's side eave at all. - Shed: only one real eave (the low side at +Z). Clicking on the high wall side used to snap to -Z, which is the rake / high end with no fascia to hang from. New shared `eave-snap.ts` module: - `resolveEaveSnap(segment, localX, localZ)` returns `{ eaveX, eaveY, eaveZ, rotation, side }`. - Hip / flat picker uses `max(|lx|/halfW, |lz|/halfD)` — same discriminator `analyticalSurfaceY` uses for hip — to pick which of the four slopes the cursor is on, then signs +/-. - ±X eaves rotate the gutter ±π/2 so its outward axis points away from the building. - Shed always returns +Z (the low side). - Gable / gambrel / mansard / dutch unchanged (±Z). Both tools collapsed their duplicated tuck constants + inlined resolver — the "keep these in sync" comment became a landmine once the resolver grew non-trivial. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gutter): end caps + corner mitre + ghost-placed parity - Ghost/move previews mirror the GutterRenderer transform chain (roof → segment → snap) and use FrontSide. Removes drift between the placement ghost and the gutter that lands on click. - New endCapLeft / endCapRight booleans (default true) slice a solid-outer plug into the extrusion at each enabled end. Inspector exposes both toggles; caps subtract from node.length so the user-set span is preserved. - corner-mitre.ts detects sibling gutters meeting within 5 cm on the same segment and returns per-end mitre angles. The end-face skew holds back walls at the inner corner while front rims extend to the outer eave intersection; cap on a mitred end is force- suppressed so the L-junction stays open. Renderer pulls siblings via useShallow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gutter): drag-snap corners, live eave Y, hangers - Length L/R handles now snap to sibling gutter endpoints within 10 cm and pull BOTH gutters' lengths to the axis intersection — the geometric eave corner — so the 5 cm corner-mitre window fires reliably. Sibling adjustment writes through sceneApi.update; the drag pipeline's history pause batches it with the main commit into one undo step. - Renderer derives eave Y live from segment.wallHeight, overhang, and pitch via the new shared computeEaveY() — instead of trusting node.position[1] from placement time. Subscribes to the segment's useLiveNodeOverrides entry too, so a wall-height drag on the segment moves the gutter on every frame (not just at commit). - Hangers: new hangerStyle (strap / none) + hangerSpacing fields. buildHangers() lays thin 25mm × 3mm × rim-width box straps across the rim at the configured spacing, inset by 5 cm from each end and skipping any cap slabs. BoxGeometry is converted to non-indexed before merge — ExtrudeGeometry isn't indexed, and mergeGeometries rejects mixed-index sets. Inspector exposes both fields in a new "Hangers" group. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gutter): downspout outlet with real CSG-drilled hole New schema fields: outletSide ('none' | 'left' | 'right'), outletInset, outletDiameter. Default 'none' so existing gutters don't sprout outlets on schema upgrade. Geometry adds a solid cylindrical stub at bore + 3 mm wall radius descending 6 cm from the trough floor, profile-aware Z midpoint (k-style 0.4·size, half-round size, box size/2), X clamped between the caps. After merging into the channel + caps + hangers, a three-bvh-csg SUBTRACTION drills a bore-wide cylinder vertically through the floor and stub — so the result is a real hole in the trough floor with a hollow tube hanging through it. Drill overshoots floor + stub by 1 cm each side to keep cut planes from coinciding with mesh faces (csg-evaluator produces degenerate output on coplanar cuts). CSG only runs when outletSide ≠ 'none' — the existing merge path is the fast path for capped-only gutters. Inspector exposes the three fields under a new 'Outlet' group. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(downspout): new node + gutter inspector list section DownspoutNode lives next to the other roof accessories. Scene-graph parent is the same roof-segment the host gutter sits on; logical attach is via `gutterId`. Schema: length (default 2.5 m), diameter (default 0.07 m / 3″ — matches gutter outlet default), material. Renderer mounts a CylinderGeometry under the same transform chain the gutter uses (segment → gutter-mesh-local → outlet); pulls `computeEaveY` from the live + drag-override segment so wallHeight / overhang / pitch changes track on the same frame as the gutter. `resolveGutterOutletPlacement` (gutter/outlet-lookup.ts) is the shared helper both the downspout renderer and the inline Add path use to compute (x, y, z, bore) in gutter-mesh-local space. Two arrow handles on a selected downspout: - length: tracker shape (dashed leader + draggable cube). Anchored to the outlet, cube at the pipe bottom — readable even when the cube ends up below ground. - diameter: symmetric `z`-axis chevron sitting at a fixed −20 cm Y below the outlet with 25 cm of outward clearance past the worst- case k-style rim, so it stays inside the gutter's camera frame instead of floating mid-pipe. Inspector UX: new optional `trailingSection` slot on ParametricDescriptor — a lazy-loaded React subsection rendered between groups and the Actions section. Gutter's slot loads a `downspouts-panel` that lists every attached downspout (button per item, click → select), and an "Add Downspout" button below that immediately creates a new one parented to the gutter's segment — matches the roof inspector's gutter list pattern. Disabled with a helper line when `outletSide === 'none'`. Multiple downspouts per gutter are allowed. `StructureTool` union picks up 'downspout' so the placement tool remains addressable (used by the legacy roof-panel button before the inspector list took over). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: pending floorplan alignment-guide work-in-progress Snapshot of in-flight alignment-guide files that have been sitting in the working tree (not authored in this session). New service + zustand store wire up the data; the floorplan layer + overlay are the visible consumers. Committed as-is to clear the tree. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): gutter multi-outlet model + downspout routing options Replace the gutter's single outletSide/outletInset/outletDiameter triple with an `outlets` array of `{ id, offset, diameter }` so one run can host several downspouts on independent drops instead of stacking on one. Each downspout links to an outlet by `outletId`. Add downspout routing/styling fields: `standoff` (gap proud of the wall), `shape` (auto/round/rect), `strapStyle`/`strapSpacing`, and `terminal` (splash/kickout/straight). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): in-world rotate & move gizmos with live-drag dimension pill Add two new handle-descriptor capabilities to the core registry — a free `translate` handle (ground-plane or wall-normal move cross) and `rotationPlane` for arc-resize (yaw vs spin-flat-against-wall) — plus `measureLabel`, which routes a resize handle's readout to a floating dimension pill instead of its inline chip, and `overrideTarget`, a cross-node redirect for handles that edit a sibling's value. node-arrow-handles implements all four, merging the in-flight drag into `useLiveNodeOverrides` so the mesh moves in real time and commits only on release. Item gains in-world rotate + move gizmos (floor items: world-Y rotate + floor-plane move; wall items: wall-normal spin + wall-face move). Add the shared MeasurementPill (H · L · T) and formatMeasurement, wired into the floating action menu (live wall/fence height drag) and the wall / fence endpoint move tools. Side handles merge live overrides so every affordance tracks the height mid-drag. Item duplicate now drag-to-places (no auto-insert) and the placement coordinator rotates in 45° steps to match the R-key step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(gutter): multi-downspout outlets with CSG drops, routing & profiles Build out the multi-outlet gutter against the new schema. Each outlet is drilled through the trough floor via CSG (profile-geometry shares the trough cross-section), and a downspout links to one outlet by id so several no longer stack on a single drop. The downspouts panel manages the outlet list; outlet-lookup resolves a downspout's mount from its gutter + outlet. Downspout gains real routing (routing.ts): offset elbows step the run back from the eave overhang to the wall (standoff escape hatch), auto/round/rect cross-section following the gutter profile, wall straps, and splash / kickout / straight terminals, with inspector-editors for the new fields. Length-snap now snaps only the dragged gutter to the geometric corner — never moving its corner-mate — so dragging one gutter can't reset another the user placed deliberately. Adds gutter floorplan (eave-line silhouette). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(floorplan): 2D footprints for roof nodes + rotate-handle angle wedge Add floorplan builders for box-vent, chimney, dormer, ridge-vent, roof, skylight, and solar-panel, and wire each into its definition, so roof-layer nodes finally draw a 2D footprint. Roof draws the merged silhouette of its child segments, and roof-segment now renders proper architectural roof linework (ridge / hip / break + shed downslope arrow) per shape instead of a bare rectangle. Add a `pivot` to the floorplan rotate affordance so the layer can sweep a live angle wedge + degree readout during a rotate drag — the 2D twin of the 3D rotate gizmo. Column / elevator / shelf / stair pass their pivot through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): select ceiling only via its corner handles, not the grid body Add `viaHandle` to NodeEvent and set it on ceiling corner-bracket clicks. The selection manager now ignores non-handle ceiling clicks without stopping propagation, so a top-down click on the revealed ceiling grid falls through to the item hosted beneath it instead of re-selecting the ceiling and swallowing the click. The corner brackets draw with depthTest off at a high render order so they stay visible and clickable through occluding geometry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(editor): highlight wall openings on a selected wall Add WallOpeningHighlights — an indigo accent frame + translucent pane drawn around each door / window opening of the selected wall, so editable children (including frameless openings with no visible geometry) are easy to locate. The accent is deliberately distinct from the white selection outline, and draws with depthTest off so it reads on top of the wall. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(solar-panel): derive surface frame live so panels re-seat on roof changes Compute the panel's Y and tilt from the parent roof-segment's finished (deck + shingle) surface every render via getRoofOuterSurfaceFrameAtPoint — the same helper skylights use — instead of reading the stored position[1]/surfaceNormal snapshot. Merging the segment's live overrides means the panel re-seats and re-tilts continuously during a wall-height / pitch drag rather than floating or burying until the value commits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(first-person): coerce imported item geometry attrs to Float32 for collider merge mergeGeometries requires every merged geometry to share the same typed-array constructor per attribute. Imported item GLBs using KHR_mesh_quantization or interleaved buffers broke the merge against Float32 wall/slab geometry, so decode each attribute into a plain non-normalized Float32 BufferAttribute before cloning into the collider world. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(editor): lower camera minimum zoom distance to 6m Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(mcp): bump @pascal-app/mcp to 0.3.0 in lockfile Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(nodes): registry-own move dispatch + hoist shared roof helpers Addresses the architecture-review findings on the gutter/downspout branch: - Move dispatch: port the bespoke roof/roof-segment/stair/stair-segment movers (MoveRoofTool) and the building mover (MoveBuildingContent) into @pascal-app/nodes (shared/move-roof-tool.tsx, building/move-tool.tsx) and declare them via `affordanceTools.move`. Delete both hardcoded dispatch lists — `LEGACY_MOVABLE_KINDS` in floating-action-menu and the roof/stair/ building arms of MoveTool. `onMove` is now purely `isRegistryMovable`. Editor internals the movers need are exported from @pascal-app/editor (adds clearRoofDuplicateMetadata); sfxEmitter.emit -> triggerSFX. elevator keeps its existing capabilities.movable path (its legacy arm is the lone remaining one, now documented). - Cross-kind imports: hoist resolveRoofSegmentHit (roof/segment-hit.ts) and the roof-surface normal math (getSurfaceY/getAnalyticalNormal/ surfaceQuatFromNormal, formerly in solar-panel/geometry.ts) into packages/nodes/src/shared/, so the 8 roof accessories + skylight/box-vent stop reaching into sibling kind folders. roof/index and solar-panel/index re-export from shared so public surfaces are unchanged. - Inspector: make ParametricInspector action `enabledIf` reactive by subscribing to its boolean result (ParamActionButton), matching the existing FieldRenderer/visibleIf pattern. Type-checked (tsc) and linted (biome) across editor + nodes. Move behaviour is preserved by construction but not yet runtime-verified in the editor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(nodes): add cupola + turbine-vent roof accessories; group-rotate gizmo; in-ring rotation readout New roof-accessory node types and editor gizmo work: - feat(nodes): cupola (roof lantern) and turbine-vent (whirlybird) node types — schema, geometry, parametrics, renderer, panel, move/placement tools, floorplan, and tests; registered in the node + core schema indexes with site-panel tree nodes. - feat(editor): group-rotate handle — a single rotation gizmo for 2+ movable nodes that spins the selection rigidly about its shared bbox center. - fix(editor): the live rotation readout (degree wedge + chip) now renders as a child of the node frame, concentric and coplanar with the guide ring, so it sits centered in the ring on pitched roofs instead of floating off to the side. Flat-ground gizmos and the group-rotate readout are unchanged. - refactor(nodes): rework box-vent / ridge-vent geometry + definitions and share roof helpers; tidy roof/box-vent panels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(nodes): eyebrow-vent kind; ridge-vent tracks roof height; vent paint + tree-node go registry-driven New eyebrow-vent roof accessory plus two follow-on cleanups: - feat(nodes): eyebrow-vent — a louvered roof vent in three styles (`scoop` swept eyebrow, `half-round` D-vent, `slant-box` hooded box). Solid louver slabs set into a framed/contained front, double-sided geometry, and adjustable style / louver-count / dimensions / slant (`backRatio`). Full registry wiring: core schema, node dir (definition/geometry/renderer/tool/ move-tool/panel/floorplan/preview/tests), event type, palette button. - fix(nodes): ridge vent derives its Y from the segment's current surface (`getSurfaceY`) instead of a stored value, so lowering a roof drops the cap onto the new ridge automatically. - refactor(nodes,editor): migrate the vent family (box / ridge / turbine / cupola / eyebrow) to `capabilities.paint` via a shared single-surface capability, removing the hardcoded `node.type` paint arms from the editor's selection-manager + material-paint (matches chimney/dormer). - refactor(editor): replace the five identical vent tree-node components with one `def.presentation`-driven `RegistryTreeNode`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
96d6e0afdb
commit
eb1f2d12c2
@@ -8,7 +8,7 @@ describe('RidgeVentNode schema', () => {
|
||||
expect(parsed.id).toMatch(/^rvent_/)
|
||||
expect(parsed.length).toBe(2.0)
|
||||
expect(parsed.width).toBe(0.3)
|
||||
expect(parsed.height).toBe(0.08)
|
||||
expect(parsed.height).toBe(0.1)
|
||||
expect(parsed.style).toBe('standard')
|
||||
expect(parsed.endCaps).toBe(true)
|
||||
expect(parsed.position).toEqual([0, 0, 0])
|
||||
|
||||
@@ -1,7 +1,148 @@
|
||||
import { type NodeDefinition, RidgeVentNode as RidgeVentNodeSchema } from '@pascal-app/core'
|
||||
import {
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
RidgeVentNode as RidgeVentNodeSchema,
|
||||
type RidgeVentNode as RidgeVentNodeType,
|
||||
} from '@pascal-app/core'
|
||||
import { buildRidgeVentFloorplan } from './floorplan'
|
||||
import { surfacePaintCapability } from '../shared/surface-paint'
|
||||
import { ridgeVentParametrics } from './parametrics'
|
||||
import { RidgeVentNode } from './schema'
|
||||
|
||||
// Edge-to-arrow-center offset, matching the box-vent / chimney cadence.
|
||||
const SIDE_HANDLE_OFFSET = 0.25
|
||||
const HEIGHT_HANDLE_OFFSET = 0.15
|
||||
// Snug to the vent corner — keeps the rotate icon close to the item.
|
||||
const ROTATE_CORNER_OFFSET = 0.1
|
||||
// Ridge vents are long but thin — minimums let users shrink without
|
||||
// collapsing the geometry past the point where the cross-section
|
||||
// degenerates. Default length is 2.0, default width 0.3, default
|
||||
// height 0.08, so these are well below the defaults.
|
||||
const MIN_LENGTH = 0.2
|
||||
const MIN_WIDTH = 0.1
|
||||
const MIN_HEIGHT = 0.02
|
||||
|
||||
// Mid-Y of the vent body in vent-mesh-local frame. The base sits at the
|
||||
// ridge line (Y=0) and the cap peaks at Y=height — so side / rotate
|
||||
// chevrons place at half-height to read as "beside the body".
|
||||
function getBodyMidY(n: RidgeVentNodeType): number {
|
||||
return Math.max(MIN_HEIGHT, n.height) / 2
|
||||
}
|
||||
|
||||
// Length arrow on ±X (the ridge direction). Asymmetric: drag one end
|
||||
// outward and the opposite end stays world-fixed by recentering
|
||||
// `position` along the vent's own +X arm in segment frame (yaw-aware
|
||||
// math, matches box-vent / chimney). The ridge vent typically straddles
|
||||
// a portion of the ridge, so dragging one end is the natural extend /
|
||||
// shorten gesture.
|
||||
function ridgeVentLengthHandle(side: 'left' | 'right'): HandleDescriptor<RidgeVentNodeType> {
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
min: MIN_LENGTH,
|
||||
currentValue: (n) => n.length,
|
||||
apply: (initial, newLength) => {
|
||||
const rotY = initial.rotation ?? 0
|
||||
const armX = Math.cos(rotY)
|
||||
const armZ = -Math.sin(rotY)
|
||||
const anchorX = initial.position[0] - sign * (initial.length / 2) * armX
|
||||
const anchorZ = initial.position[2] - sign * (initial.length / 2) * armZ
|
||||
const newCenterX = anchorX + sign * (newLength / 2) * armX
|
||||
const newCenterZ = anchorZ + sign * (newLength / 2) * armZ
|
||||
return {
|
||||
length: newLength,
|
||||
position: [newCenterX, initial.position[1], newCenterZ],
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n) => [sign * (n.length / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), 0],
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Width arrow on +Z (across the ridge). Symmetric — the vent geometry
|
||||
// straddles the ridge line (Z=0) so growing the width pushes both edges
|
||||
// outward by the same amount. A single chevron on +Z reads as "this is
|
||||
// the width dimension"; keeping it symmetric also stays inside the same
|
||||
// handle-count budget the chimney / dormer / box-vent already document.
|
||||
function ridgeVentWidthHandle(): HandleDescriptor<RidgeVentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'z',
|
||||
anchor: 'center',
|
||||
min: MIN_WIDTH,
|
||||
currentValue: (n) => n.width,
|
||||
apply: (_n, newValue) => ({ width: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, getBodyMidY(n), n.width / 2 + SIDE_HANDLE_OFFSET],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Height arrow above the cap peak. anchor='min' so the base stays
|
||||
// pinned to the ridge line (Y=0) and the peak follows the cursor. Plain
|
||||
// chevron — at default 0.08 m a dashed tracker leader would be visual
|
||||
// noise rather than a dimension cue.
|
||||
function ridgeVentHeightHandle(): HandleDescriptor<RidgeVentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_HEIGHT,
|
||||
currentValue: (n) => n.height,
|
||||
apply: (_n, newValue) => ({ height: Math.max(MIN_HEIGHT, newValue) }),
|
||||
placement: {
|
||||
position: (n) => [0, Math.max(n.height, MIN_HEIGHT) + HEIGHT_HANDLE_OFFSET, 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-vent rotation gizmo at the +X+Z corner of the body footprint.
|
||||
// Negate the cursor delta to match three.js Y-rotation handedness. The
|
||||
// registered group already centres on the vent and applies its yaw,
|
||||
// so the default rotation pivot is correct.
|
||||
function ridgeVentRotateHandle(): HandleDescriptor<RidgeVentNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
position: (n) => [
|
||||
n.length / 2 + ROTATE_CORNER_OFFSET,
|
||||
getBodyMidY(n),
|
||||
n.width / 2 + ROTATE_CORNER_OFFSET,
|
||||
],
|
||||
// Two-headed icon's natural bias is along +X; aim along the
|
||||
// +X+Z corner bisector so it sits visually flush with the
|
||||
// rotate gesture's swing direction.
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
// Guide ring centred on the vent, sized to pass through the corner icon
|
||||
// so the icon rides the ring — matches solar-panel / skylight.
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) =>
|
||||
Math.hypot(n.length / 2 + ROTATE_CORNER_OFFSET, n.width / 2 + ROTATE_CORNER_OFFSET),
|
||||
y: (n) => getBodyMidY(n),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// `portal: 'grandparent'` on every handle — see box-vent's note. The vent
|
||||
// rides the roof→segment→node frame chain, so the handle rig must too, or
|
||||
// the handles (and rotate arc) render offset from the vent.
|
||||
const ridgeVentHandles: HandleDescriptor<RidgeVentNodeType>[] = [
|
||||
ridgeVentLengthHandle('right'),
|
||||
ridgeVentLengthHandle('left'),
|
||||
ridgeVentWidthHandle(),
|
||||
ridgeVentHeightHandle(),
|
||||
ridgeVentRotateHandle(),
|
||||
].map((h): HandleDescriptor<RidgeVentNodeType> => ({ ...h, portal: 'grandparent' }))
|
||||
|
||||
/**
|
||||
* Ridge vent — a ventilation strip running along the ridge of a roof
|
||||
* segment. Parented to a `roof-segment`; position is segment-local.
|
||||
@@ -34,6 +175,8 @@ export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
// Single painted surface — registry-driven paint dispatch (see chimney).
|
||||
paint: surfacePaintCapability,
|
||||
// Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the
|
||||
// ridge — no `buildCut`, just the dirty cascade so the parent
|
||||
// roof's merged shell rebuilds when the vent moves / resizes.
|
||||
@@ -41,6 +184,8 @@ export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
|
||||
},
|
||||
|
||||
parametrics: ridgeVentParametrics,
|
||||
handles: ridgeVentHandles,
|
||||
floorplan: buildRidgeVentFloorplan,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
RidgeVentNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
// Tab pitch for the shingled style — matches SHINGLED_TAB_SIZE in
|
||||
// geometry.ts so the plan's divider spacing reads like the 3D ridge cap.
|
||||
const SHINGLED_TAB_SIZE = 0.3
|
||||
|
||||
/**
|
||||
* Floor-plan builder for a ridge vent — a ventilation strip running along
|
||||
* a roof ridge. Seen from above it's a long thin band straddling the
|
||||
* ridge crest, with a centre crest line, end caps where closed, and tab
|
||||
* dividers for the shingled style.
|
||||
*
|
||||
* Coordinate frame mirrors the 3D transform stack
|
||||
* (roof → roof-segment → vent), same as the chimney builder. `position`
|
||||
* is segment-local; the run (`length`) is along local +X and the small
|
||||
* cross-`width` straddles the ridge along local Z (centred at Z = 0).
|
||||
* `rotation` is yaw, negated for the floor plan's y-down convention.
|
||||
*/
|
||||
export function buildRidgeVentFloorplan(
|
||||
node: RidgeVentNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const segment = ctx.parent as RoofSegmentNode | null
|
||||
if (!segment || 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
|
||||
|
||||
const cosR = Math.cos(-roof.rotation)
|
||||
const sinR = Math.sin(-roof.rotation)
|
||||
const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR
|
||||
const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR
|
||||
|
||||
const segRot = -(roof.rotation + segment.rotation)
|
||||
const cosS = Math.cos(segRot)
|
||||
const sinS = Math.sin(segRot)
|
||||
const cx = segCx + node.position[0] * cosS - node.position[2] * sinS
|
||||
const cz = segCz + node.position[0] * sinS + node.position[2] * cosS
|
||||
|
||||
const rot = -(roof.rotation + segment.rotation + node.rotation)
|
||||
const cos = Math.cos(rot)
|
||||
const sin = Math.sin(rot)
|
||||
const toPlan = (lx: number, lz: number): FloorplanPoint => [
|
||||
cx + lx * cos - lz * sin,
|
||||
cz + lx * sin + lz * cos,
|
||||
]
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const isHovered = view?.hovered ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
const baseInk = '#475569'
|
||||
const stroke =
|
||||
showSelectedChrome && palette
|
||||
? palette.selectedStroke
|
||||
: isHovered && palette
|
||||
? palette.wallHoverStroke
|
||||
: baseInk
|
||||
const fill = showSelectedChrome ? '#fed7aa' : '#dbe1e8'
|
||||
const fillOpacity = showSelectedChrome ? 0.55 : 0.6
|
||||
const lineWidth = showSelectedChrome ? 0.03 : 0.02
|
||||
|
||||
const halfLen = Math.max(node.length, 0.1) / 2
|
||||
const halfW = Math.max(node.width, 0.04) / 2
|
||||
|
||||
const corners: FloorplanPoint[] = [
|
||||
toPlan(-halfLen, -halfW),
|
||||
toPlan(halfLen, -halfW),
|
||||
toPlan(halfLen, halfW),
|
||||
toPlan(-halfLen, halfW),
|
||||
]
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
// Transparent hit-target over the whole strip.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: corners,
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
// Strip fill.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: corners,
|
||||
fill,
|
||||
fillOpacity,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]
|
||||
|
||||
const seg = (
|
||||
a: readonly [number, number],
|
||||
b: readonly [number, number],
|
||||
w: number,
|
||||
opacity?: number,
|
||||
) => {
|
||||
const pa = toPlan(a[0], a[1])
|
||||
const pb = toPlan(b[0], b[1])
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: pa[0],
|
||||
y1: pa[1],
|
||||
x2: pb[0],
|
||||
y2: pb[1],
|
||||
stroke,
|
||||
strokeWidth: w,
|
||||
strokeLinecap: 'round',
|
||||
opacity,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
// Long edges along the run (always) + end caps (only when closed).
|
||||
seg([-halfLen, -halfW], [halfLen, -halfW], lineWidth)
|
||||
seg([-halfLen, halfW], [halfLen, halfW], lineWidth)
|
||||
if (node.endCaps !== false) {
|
||||
seg([-halfLen, -halfW], [-halfLen, halfW], lineWidth)
|
||||
seg([halfLen, -halfW], [halfLen, halfW], lineWidth)
|
||||
}
|
||||
|
||||
// Ridge crest line down the centre.
|
||||
seg([-halfLen, 0], [halfLen, 0], lineWidth * 0.8, 0.7)
|
||||
|
||||
// Shingled style: tab dividers across the width at the cap pitch.
|
||||
if (node.style === 'shingled') {
|
||||
const total = halfLen * 2
|
||||
const count = Math.max(2, Math.round(total / SHINGLED_TAB_SIZE))
|
||||
const step = total / count
|
||||
for (let i = 1; i < count; i++) {
|
||||
const x = -halfLen + i * step
|
||||
seg([x, -halfW], [x, halfW], lineWidth * 0.6, 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -1,512 +1,238 @@
|
||||
import type { RidgeVentNode } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
|
||||
const ARC_SEGMENTS = 8
|
||||
const SHELL_THICKNESS = 0.25
|
||||
const SHINGLED_PEAK_SEGS = 3
|
||||
const ARC_SEGS = 16
|
||||
const SHINGLED_TAB_SIZE = 0.3
|
||||
|
||||
/**
|
||||
* Pure builder for the ridge vent mesh. Three styles share a common
|
||||
* cross-section approach: extrude a 2D profile (in the Y-Z plane)
|
||||
* along the segment's X axis (ridge direction), then add optional
|
||||
* end caps.
|
||||
* Pure builder for the ridge vent mesh. Each style is a peaked **band** of
|
||||
* constant thickness `t` that drapes over the ridge like a real ridge cap:
|
||||
* a shaped top surface, a parallel underside offset down by `t`, visible
|
||||
* eave thickness faces along both edges, and end caps.
|
||||
*
|
||||
* - `standard`: smooth curved shell with offset inner surface
|
||||
* - `shingled`: angular slopes meeting at a rounded peak with tab ridges
|
||||
* - `metal`: angular bent-metal cap with drip-edge lips and a center bead
|
||||
* This is the middle ground between the two earlier extremes — the original
|
||||
* was a paper-thin shell (no perceptible thickness), then a flat-bottomed
|
||||
* solid (read as a closed box). The band keeps the V / arched cap silhouette
|
||||
* and the open underside (so it sits astride the ridge) while showing real
|
||||
* thickness at the eaves and ends.
|
||||
*
|
||||
* Pure: no React, no scene access, no store mutation.
|
||||
* - `standard`: smooth rounded arch
|
||||
* - `shingled`: angular peak with raised shingle-course ridges across the top
|
||||
* - `metal`: bent-metal cap with a wide flat seam and drip lips
|
||||
*
|
||||
* `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
|
||||
// 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 pieces: THREE.BufferGeometry[] = []
|
||||
const top =
|
||||
node.style === 'metal'
|
||||
? metalTop(halfW, h, t)
|
||||
: node.style === 'shingled'
|
||||
? shingledTop(halfW, h, t)
|
||||
: standardTop(halfW, h, t)
|
||||
|
||||
if (node.style === 'metal') {
|
||||
pieces.push(buildMetalProfile(halfLen, halfW, h))
|
||||
} else if (node.style === 'shingled') {
|
||||
pieces.push(buildShingledProfile(halfLen, halfW, h))
|
||||
} else {
|
||||
pieces.push(buildCurvedCapProfile(halfLen, halfW, h))
|
||||
}
|
||||
|
||||
if (node.endCaps) {
|
||||
const cap =
|
||||
node.style === 'metal'
|
||||
? buildMetalEndCaps(halfLen, halfW, h)
|
||||
: node.style === 'shingled'
|
||||
? buildShingledEndCaps(halfLen, halfW, h)
|
||||
: buildCurvedEndCaps(halfLen, halfW, h)
|
||||
if (cap) pieces.push(cap)
|
||||
}
|
||||
|
||||
return pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!)
|
||||
}
|
||||
|
||||
// ─── Standard curved cap ─────────────────────────────────────────────
|
||||
|
||||
function buildCurvedCapProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
const t = h * SHELL_THICKNESS
|
||||
|
||||
const outerPts: [number, number][] = []
|
||||
for (let i = 0; i <= ARC_SEGMENTS; i++) {
|
||||
const frac = i / ARC_SEGMENTS
|
||||
const angle = Math.PI * frac
|
||||
const z = -halfW + frac * (2 * halfW)
|
||||
const y = h * Math.sin(angle)
|
||||
outerPts.push([z, y])
|
||||
}
|
||||
const innerPts = offsetProfileInward(outerPts, t)
|
||||
buildBand(positions, normals, uvs, top, t, halfLen, node.endCaps)
|
||||
|
||||
for (let i = 0; i < ARC_SEGMENTS; i++) {
|
||||
const [oz0, oy0] = outerPts[i]!
|
||||
const [oz1, oy1] = outerPts[i + 1]!
|
||||
const [iz0, iy0] = innerPts[i]!
|
||||
const [iz1, iy1] = innerPts[i + 1]!
|
||||
|
||||
const dz = oz1 - oz0
|
||||
const dy = oy1 - oy0
|
||||
const fLen = Math.sqrt(dz * dz + dy * dy) || 1
|
||||
const fnz = -dy / fLen
|
||||
const fny = dz / fLen
|
||||
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, oy0, oz0],
|
||||
[halfLen, oy0, oz0],
|
||||
[halfLen, oy1, oz1],
|
||||
[-halfLen, oy1, oz1],
|
||||
[0, fny, fnz],
|
||||
)
|
||||
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy1, iz1],
|
||||
[halfLen, iy1, iz1],
|
||||
[halfLen, iy0, iz0],
|
||||
[-halfLen, iy0, iz0],
|
||||
[0, -fny, -fnz],
|
||||
)
|
||||
}
|
||||
|
||||
// Eave bottoms
|
||||
for (const idx of [0, ARC_SEGMENTS]) {
|
||||
const [oz, oy] = outerPts[idx]!
|
||||
const [iz, iy] = innerPts[idx]!
|
||||
if (idx === 0) {
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy, iz],
|
||||
[halfLen, iy, iz],
|
||||
[halfLen, oy, oz],
|
||||
[-halfLen, oy, oz],
|
||||
[0, -1, 0],
|
||||
)
|
||||
} else {
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, oy, oz],
|
||||
[halfLen, oy, oz],
|
||||
[halfLen, iy, iz],
|
||||
[-halfLen, iy, iz],
|
||||
[0, -1, 0],
|
||||
)
|
||||
}
|
||||
if (node.style === 'shingled') {
|
||||
addShingledTabs(positions, normals, uvs, halfLen, top, h)
|
||||
}
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
|
||||
function buildCurvedEndCaps(
|
||||
halfLen: number,
|
||||
halfW: number,
|
||||
h: number,
|
||||
): THREE.BufferGeometry | null {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
const t = h * SHELL_THICKNESS
|
||||
// ─── Top profiles (open polylines eave → peak → eave, in [z, y]) ─────────
|
||||
// Eaves sit at y = t so that the underside (top − t) lands on y = 0 at the
|
||||
// eaves, seating the cap on the roof while leaving a peaked void beneath.
|
||||
|
||||
const outerPts: [number, number][] = []
|
||||
for (let i = 0; i <= ARC_SEGMENTS; i++) {
|
||||
const frac = i / ARC_SEGMENTS
|
||||
const angle = Math.PI * frac
|
||||
outerPts.push([-halfW + frac * (2 * halfW), h * Math.sin(angle)])
|
||||
// Smooth rounded arch.
|
||||
function standardTop(halfW: number, h: number, t: number): [number, number][] {
|
||||
const pts: [number, number][] = []
|
||||
for (let i = 0; i <= ARC_SEGS; i++) {
|
||||
const frac = i / ARC_SEGS
|
||||
const z = -halfW + frac * 2 * halfW
|
||||
const y = t + (h - t) * Math.sin(frac * Math.PI)
|
||||
pts.push([z, y])
|
||||
}
|
||||
const innerPts = offsetProfileInward(outerPts, t)
|
||||
|
||||
for (const sign of [-1, 1] as const) {
|
||||
const x = sign * halfLen
|
||||
for (let i = 0; i < ARC_SEGMENTS; i++) {
|
||||
const a: [number, number, number] = [x, outerPts[i]![1], outerPts[i]![0]]
|
||||
const b: [number, number, number] = [x, outerPts[i + 1]![1], outerPts[i + 1]![0]]
|
||||
const c: [number, number, number] = [x, innerPts[i + 1]![1], innerPts[i + 1]![0]]
|
||||
const d: [number, number, number] = [x, innerPts[i]![1], innerPts[i]![0]]
|
||||
if (sign > 0) pushQuad(positions, normals, uvs, a, b, c, d, [sign, 0, 0])
|
||||
else pushQuad(positions, normals, uvs, d, c, b, a, [sign, 0, 0])
|
||||
}
|
||||
}
|
||||
|
||||
return positions.length === 0 ? null : buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
|
||||
// ─── Shingled profile ───────────────────────────────────────────────
|
||||
|
||||
function shingledOuterPts(halfW: number, h: number): [number, number][] {
|
||||
const peakR = halfW * 0.1
|
||||
const slopeY = (h * (halfW - peakR)) / halfW
|
||||
const pts: [number, number][] = [[-halfW, 0]]
|
||||
for (let i = 0; i <= SHINGLED_PEAK_SEGS; i++) {
|
||||
const frac = i / SHINGLED_PEAK_SEGS
|
||||
const angle = Math.PI * (1 - frac)
|
||||
pts.push([peakR * Math.cos(angle), slopeY + (h - slopeY) * Math.sin(angle)])
|
||||
}
|
||||
pts.push([halfW, 0])
|
||||
return pts
|
||||
}
|
||||
|
||||
function buildShingledProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
const t = h * SHELL_THICKNESS
|
||||
// Angular peak with a narrow flat ridge at the top.
|
||||
function shingledTop(halfW: number, h: number, t: number): [number, number][] {
|
||||
const peakHalf = halfW * 0.12
|
||||
return [
|
||||
[-halfW, t],
|
||||
[-peakHalf, h],
|
||||
[peakHalf, h],
|
||||
[halfW, t],
|
||||
]
|
||||
}
|
||||
|
||||
const outerPts = shingledOuterPts(halfW, h)
|
||||
const innerPts = offsetProfileInward(outerPts, t)
|
||||
// Bent-metal cap: steep folds up to a wide flat standing seam.
|
||||
function metalTop(halfW: number, h: number, t: number): [number, number][] {
|
||||
const seamHalf = halfW * 0.5
|
||||
const shoulderY = t + (h - t) * 0.5
|
||||
return [
|
||||
[-halfW, t],
|
||||
[-halfW * 0.82, shoulderY],
|
||||
[-seamHalf, h],
|
||||
[seamHalf, h],
|
||||
[halfW * 0.82, shoulderY],
|
||||
[halfW, t],
|
||||
]
|
||||
}
|
||||
|
||||
for (let i = 0; i < outerPts.length - 1; i++) {
|
||||
const [oz0, oy0] = outerPts[i]!
|
||||
const [oz1, oy1] = outerPts[i + 1]!
|
||||
const [iz0, iy0] = innerPts[i]!
|
||||
const [iz1, iy1] = innerPts[i + 1]!
|
||||
// ─── Band assembly ───────────────────────────────────────────────────────
|
||||
|
||||
const dz = oz1 - oz0
|
||||
const dy = oy1 - oy0
|
||||
const fLen = Math.sqrt(dz * dz + dy * dy) || 1
|
||||
const fnz = -dy / fLen
|
||||
const fny = dz / fLen
|
||||
function buildBand(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
top: [number, number][],
|
||||
t: number,
|
||||
halfLen: number,
|
||||
withCaps: boolean,
|
||||
): 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])
|
||||
|
||||
// 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, oy0, oz0],
|
||||
[halfLen, oy0, oz0],
|
||||
[halfLen, oy1, oz1],
|
||||
[-halfLen, oy1, oz1],
|
||||
[0, fny, fnz],
|
||||
[-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, iy1, iz1],
|
||||
[halfLen, iy1, iz1],
|
||||
[halfLen, iy0, iz0],
|
||||
[-halfLen, iy0, iz0],
|
||||
[0, -fny, -fnz],
|
||||
)
|
||||
}
|
||||
|
||||
// Eave bottoms
|
||||
{
|
||||
const [oz, oy] = outerPts[0]!
|
||||
const [iz, iy] = innerPts[0]!
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy, iz],
|
||||
[halfLen, iy, iz],
|
||||
[halfLen, oy, oz],
|
||||
[-halfLen, oy, oz],
|
||||
[0, -1, 0],
|
||||
)
|
||||
}
|
||||
{
|
||||
const last = outerPts.length - 1
|
||||
const [oz, oy] = outerPts[last]!
|
||||
const [iz, iy] = innerPts[last]!
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, oy, oz],
|
||||
[halfLen, oy, oz],
|
||||
[halfLen, iy, iz],
|
||||
[-halfLen, iy, iz],
|
||||
[halfLen, iy0, iz0],
|
||||
[halfLen, iy1, iz1],
|
||||
[-halfLen, iy1, iz1],
|
||||
[0, -1, 0],
|
||||
)
|
||||
}
|
||||
|
||||
// Tab divider ridges along the length
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
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]!
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x, y0, z0],
|
||||
[x, y1, z1],
|
||||
[x, iy1, iz1],
|
||||
[x, iy0, iz0],
|
||||
hint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shingled course ridges ──────────────────────────────────────────────
|
||||
// Thin raised lines running across the cap at intervals, suggesting
|
||||
// overlapping shingle courses. Sit on the top profile edges.
|
||||
|
||||
function addShingledTabs(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
halfLen: number,
|
||||
top: [number, number][],
|
||||
h: number,
|
||||
): void {
|
||||
const totalLen = halfLen * 2
|
||||
const numTabs = Math.max(2, Math.round(totalLen / SHINGLED_TAB_SIZE))
|
||||
const tabLen = totalLen / numTabs
|
||||
const ridgeH = h * 0.06
|
||||
const ridgeD = 0.006
|
||||
const ridgeD = Math.min(0.01, tabLen * 0.15)
|
||||
|
||||
for (let tab = 1; tab < numTabs; tab++) {
|
||||
const x = -halfLen + tab * tabLen
|
||||
for (let i = 0; i < outerPts.length - 1; i++) {
|
||||
const [oz0, oy0] = outerPts[i]!
|
||||
const [oz1, oy1] = outerPts[i + 1]!
|
||||
const dz = oz1 - oz0
|
||||
const dy = oy1 - oy0
|
||||
const fLen = Math.sqrt(dz * dz + dy * dy) || 1
|
||||
const fnz = -dy / fLen
|
||||
const fny = dz / fLen
|
||||
const r0y = oy0 + fny * ridgeH
|
||||
const r0z = oz0 + fnz * ridgeH
|
||||
const r1y = oy1 + fny * ridgeH
|
||||
const r1z = oz1 + fnz * ridgeH
|
||||
for (let i = 0; i < top.length - 1; i++) {
|
||||
const [z0, y0] = top[i]!
|
||||
const [z1, y1] = top[i + 1]!
|
||||
const dz = z1 - z0
|
||||
const dy = y1 - y0
|
||||
const len = Math.sqrt(dz * dz + dy * dy) || 1
|
||||
const nz = -dy / len
|
||||
const ny = dz / len
|
||||
const r0y = y0 + ny * ridgeH
|
||||
const r0z = z0 + nz * ridgeH
|
||||
const r1y = y1 + ny * ridgeH
|
||||
const r1z = z1 + nz * ridgeH
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x, r0y, r0z],
|
||||
[x, r1y, r1z],
|
||||
[x, oy1, oz1],
|
||||
[x, oy0, oz0],
|
||||
[x, y1, z1],
|
||||
[x, y0, z0],
|
||||
[1, 0, 0],
|
||||
)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[x, r0y, r0z],
|
||||
[x, r1y, r1z],
|
||||
[x - ridgeD, oy1, oz1],
|
||||
[x - ridgeD, oy0, oz0],
|
||||
[0, fny, fnz],
|
||||
[x - ridgeD, r0y, r0z],
|
||||
[x - ridgeD, r1y, r1z],
|
||||
[x - ridgeD, y1, z1],
|
||||
[x - ridgeD, y0, z0],
|
||||
[-1, 0, 0],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
|
||||
function buildShingledEndCaps(
|
||||
halfLen: number,
|
||||
halfW: number,
|
||||
h: number,
|
||||
): THREE.BufferGeometry | null {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
const t = h * SHELL_THICKNESS
|
||||
|
||||
const outerPts = shingledOuterPts(halfW, h)
|
||||
const innerPts = offsetProfileInward(outerPts, t)
|
||||
|
||||
for (const sign of [-1, 1] as const) {
|
||||
const x = sign * halfLen
|
||||
for (let i = 0; i < outerPts.length - 1; i++) {
|
||||
const a: [number, number, number] = [x, outerPts[i]![1], outerPts[i]![0]]
|
||||
const b: [number, number, number] = [x, outerPts[i + 1]![1], outerPts[i + 1]![0]]
|
||||
const c: [number, number, number] = [x, innerPts[i + 1]![1], innerPts[i + 1]![0]]
|
||||
const d: [number, number, number] = [x, innerPts[i]![1], innerPts[i]![0]]
|
||||
if (sign > 0) pushQuad(positions, normals, uvs, a, b, c, d, [sign, 0, 0])
|
||||
else pushQuad(positions, normals, uvs, d, c, b, a, [sign, 0, 0])
|
||||
}
|
||||
}
|
||||
|
||||
return positions.length === 0 ? null : buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
|
||||
// ─── Metal profile ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bent-sheet-metal ridge cap cross-section. Real metal vents read as a
|
||||
* smooth arched cap riding above two flat mounting flanges — not the
|
||||
* old angular peak + bead, which looked like a stamped novelty. Profile:
|
||||
*
|
||||
* flange ─┐ ┌─ flange
|
||||
* │ ◜╶───── rounded ridge cap ─────╶◝ │
|
||||
* │ ╱ ╲ │
|
||||
* └─╯ ╰────┘
|
||||
*
|
||||
* Built from outer points (Z, Y); the inner shell is offset inward so the
|
||||
* cap reads as a real folded-metal thickness instead of paper-thin.
|
||||
*/
|
||||
function metalProfile(halfW: number, h: number, t: number) {
|
||||
// Horizontal mounting flange that hugs the shingles on each side. Wide
|
||||
// enough to read as a real screw-down tab, not a sliver.
|
||||
const flangeW = halfW * 0.22
|
||||
// Where the arched cap takes off from the flange tip — gentle rise so
|
||||
// the corner reads as a soft fold instead of a hard kink.
|
||||
const liftH = h * 0.12
|
||||
const liftDZ = halfW * 0.04
|
||||
// Span and height of the rounded cap. Span stays narrower than the
|
||||
// overall width so the cap "rides" on the flanges rather than swallow-
|
||||
// ing them.
|
||||
const capHalfSpan = halfW * 0.7
|
||||
const capPeakY = h
|
||||
const capStartY = h * 0.45
|
||||
const capSegs = 12
|
||||
|
||||
const outer: [number, number][] = []
|
||||
// Left flange — flat horizontal tab.
|
||||
outer.push([-halfW, 0])
|
||||
outer.push([-halfW + flangeW, 0])
|
||||
// Soft fold up to the cap's starting shoulder.
|
||||
outer.push([-halfW + flangeW + liftDZ, liftH])
|
||||
outer.push([-capHalfSpan, capStartY])
|
||||
// Rounded ridge: half-sine from left shoulder over the top to right
|
||||
// shoulder. Using sin() (not cos+sin sphere math) keeps the cap's
|
||||
// tangents continuous with the slope below — no visible kinks.
|
||||
for (let i = 1; i < capSegs; i++) {
|
||||
const frac = i / capSegs
|
||||
const z = -capHalfSpan + frac * (2 * capHalfSpan)
|
||||
const y = capStartY + (capPeakY - capStartY) * Math.sin(frac * Math.PI)
|
||||
outer.push([z, y])
|
||||
}
|
||||
// Mirror down the right side.
|
||||
outer.push([capHalfSpan, capStartY])
|
||||
outer.push([halfW - flangeW - liftDZ, liftH])
|
||||
outer.push([halfW - flangeW, 0])
|
||||
outer.push([halfW, 0])
|
||||
|
||||
const inner = offsetProfileInward(outer, t)
|
||||
return { outer, inner }
|
||||
}
|
||||
|
||||
function segNormal(z0: number, y0: number, z1: number, y1: number): number[] {
|
||||
const dz = z1 - z0
|
||||
const dy = y1 - y0
|
||||
const len = Math.sqrt(dz * dz + dy * dy) || 1
|
||||
return [0, dz / len, -dy / len]
|
||||
}
|
||||
|
||||
function buildMetalProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
const t = h * SHELL_THICKNESS
|
||||
const { outer, inner } = metalProfile(halfW, h, t)
|
||||
|
||||
for (let i = 0; i < outer.length - 1; i++) {
|
||||
const [oz0, oy0] = outer[i]!
|
||||
const [oz1, oy1] = outer[i + 1]!
|
||||
const [iz0, iy0] = inner[i]!
|
||||
const [iz1, iy1] = inner[i + 1]!
|
||||
|
||||
const outerN = segNormal(oz0, oy0, oz1, oy1)
|
||||
const innerN = segNormal(iz0, iy0, iz1, iy1).map((v) => -v)
|
||||
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, oy0, oz0],
|
||||
[halfLen, oy0, oz0],
|
||||
[halfLen, oy1, oz1],
|
||||
[-halfLen, oy1, oz1],
|
||||
outerN,
|
||||
)
|
||||
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, iy1, iz1],
|
||||
[halfLen, iy1, iz1],
|
||||
[halfLen, iy0, iz0],
|
||||
[-halfLen, iy0, iz0],
|
||||
innerN,
|
||||
)
|
||||
}
|
||||
|
||||
// Eave bottoms
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, inner[0]![1], inner[0]![0]],
|
||||
[halfLen, inner[0]![1], inner[0]![0]],
|
||||
[halfLen, outer[0]![1], outer[0]![0]],
|
||||
[-halfLen, outer[0]![1], outer[0]![0]],
|
||||
[0, -1, 0],
|
||||
)
|
||||
const last = outer.length - 1
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-halfLen, outer[last]![1], outer[last]![0]],
|
||||
[halfLen, outer[last]![1], outer[last]![0]],
|
||||
[halfLen, inner[last]![1], inner[last]![0]],
|
||||
[-halfLen, inner[last]![1], inner[last]![0]],
|
||||
[0, -1, 0],
|
||||
)
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
|
||||
function buildMetalEndCaps(halfLen: number, halfW: number, h: number): THREE.BufferGeometry | null {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
const t = h * SHELL_THICKNESS
|
||||
const { outer, inner } = metalProfile(halfW, h, t)
|
||||
|
||||
for (const sign of [-1, 1] as const) {
|
||||
const x = sign * halfLen
|
||||
|
||||
for (let i = 0; i < outer.length - 1; i++) {
|
||||
const a: [number, number, number] = [x, outer[i]![1], outer[i]![0]]
|
||||
const b: [number, number, number] = [x, outer[i + 1]![1], outer[i + 1]![0]]
|
||||
const c: [number, number, number] = [x, inner[i + 1]![1], inner[i + 1]![0]]
|
||||
const d: [number, number, number] = [x, inner[i]![1], inner[i]![0]]
|
||||
if (sign > 0) pushQuad(positions, normals, uvs, a, b, c, d, [sign, 0, 0])
|
||||
else pushQuad(positions, normals, uvs, d, c, b, a, [sign, 0, 0])
|
||||
}
|
||||
}
|
||||
|
||||
return positions.length === 0 ? null : buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function offsetProfileInward(pts: [number, number][], t: number): [number, number][] {
|
||||
const result: [number, number][] = []
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const [z, y] = pts[i]!
|
||||
let dz: number
|
||||
let dy: number
|
||||
if (i === 0) {
|
||||
dz = pts[1]![0] - z
|
||||
dy = pts[1]![1] - y
|
||||
} else if (i === pts.length - 1) {
|
||||
dz = z - pts[i - 1]![0]
|
||||
dy = y - pts[i - 1]![1]
|
||||
} else {
|
||||
dz = pts[i + 1]![0] - pts[i - 1]![0]
|
||||
dy = pts[i + 1]![1] - pts[i - 1]![1]
|
||||
}
|
||||
const len = Math.sqrt(dz * dz + dy * dy) || 1
|
||||
const nz = dy / len
|
||||
const ny = -dz / len
|
||||
result.push([z + nz * t, y + ny * t])
|
||||
}
|
||||
return result
|
||||
}
|
||||
// ─── Geometry plumbing ───────────────────────────────────────────────────
|
||||
|
||||
function buildBufferGeometry(
|
||||
positions: number[],
|
||||
@@ -517,38 +243,50 @@ function buildBufferGeometry(
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||
geo.computeBoundingSphere()
|
||||
return geo
|
||||
}
|
||||
|
||||
// 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.
|
||||
function pushQuad(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
a: number[] | readonly number[],
|
||||
b: number[] | readonly number[],
|
||||
c: number[] | readonly number[],
|
||||
d: number[] | readonly number[],
|
||||
n: number[] | readonly number[],
|
||||
a: number[],
|
||||
b: number[],
|
||||
c: number[],
|
||||
d: number[],
|
||||
hint: number[],
|
||||
) {
|
||||
// Dimension-based planar UVs: U follows |b-a|, V follows |d-a|, so
|
||||
// textures tile at world scale across the ridge length, the arched
|
||||
// shell, and the end caps. Hardcoded 0..1 UVs stretched each face
|
||||
// independently — a 2m ridge tile looked the same as a 4cm lip.
|
||||
const abx = b[0]! - a[0]!
|
||||
const aby = b[1]! - a[1]!
|
||||
const abz = b[2]! - a[2]!
|
||||
const adx = d[0]! - a[0]!
|
||||
const ady = d[1]! - a[1]!
|
||||
const adz = d[2]! - a[2]!
|
||||
const u = Math.sqrt(abx * abx + aby * aby + abz * abz)
|
||||
const v = Math.sqrt(adx * adx + ady * ady + adz * adz)
|
||||
let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!)
|
||||
let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!)
|
||||
let nz = (c[0]! - a[0]!) * (b[1]! - a[1]!) - (c[1]! - a[1]!) * (b[0]! - a[0]!)
|
||||
const flip = nx * hint[0]! + ny * hint[1]! + nz * hint[2]! < 0
|
||||
if (flip) {
|
||||
nx = -nx
|
||||
ny = -ny
|
||||
nz = -nz
|
||||
}
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1
|
||||
nx /= len
|
||||
ny /= len
|
||||
nz /= len
|
||||
|
||||
// Winding is (a, c, b) + (a, d, c) so the triangle face direction
|
||||
// matches the stored normal — same fix as box-vent's pushQuad.
|
||||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
|
||||
normals.push(n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!)
|
||||
uvs.push(0, 0, u, v, u, 0)
|
||||
positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!)
|
||||
normals.push(n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!)
|
||||
uvs.push(0, 0, 0, v, u, v)
|
||||
const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!)
|
||||
const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!)
|
||||
|
||||
if (flip) {
|
||||
positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!)
|
||||
uvs.push(0, 0, u, 0, u, v)
|
||||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!)
|
||||
uvs.push(0, 0, u, v, 0, v)
|
||||
} else {
|
||||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
|
||||
uvs.push(0, 0, u, v, u, 0)
|
||||
positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!)
|
||||
uvs.push(0, 0, 0, v, u, v)
|
||||
}
|
||||
for (let i = 0; i < 6; i++) normals.push(nx, ny, nz)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import RidgeVentPreview from './preview'
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type RidgeVentNode,
|
||||
type RoofSegmentNode,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { getSurfaceY } from '../shared/roof-surface'
|
||||
import { buildRidgeVentGeometry } from './geometry'
|
||||
|
||||
// Single white fallback for every style. Paint customisation comes from
|
||||
@@ -28,7 +30,6 @@ const defaultMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 0.85,
|
||||
metalness: 0.1,
|
||||
side: THREE.DoubleSide,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -45,15 +46,26 @@ const defaultMaterial = new THREE.MeshStandardMaterial({
|
||||
* family (matte standard / shingled grey / brushed metal) before the
|
||||
* user opens the paint tray.
|
||||
*/
|
||||
const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
|
||||
const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
useRegistry(node.id, 'ridge-vent', ref)
|
||||
const handlers = useNodeEvents(node, 'ridge-vent')
|
||||
useRegistry(storeNode.id, 'ridge-vent', ref)
|
||||
const handlers = useNodeEvents(storeNode, 'ridge-vent')
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
|
||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
||||
|
||||
// Merge live drag overrides on top of the store node so handle drags
|
||||
// update the mesh in-flight without flushing to zustand on every frame.
|
||||
// Same pattern as box-vent / chimney / dormer — the override is set by
|
||||
// `NodeArrowHandles`' drag handler and cleared on commit.
|
||||
const overrides = useLiveNodeOverrides(
|
||||
(s) => s.get(storeNode.id as AnyNodeId) as Partial<RidgeVentNode> | undefined,
|
||||
)
|
||||
const node: RidgeVentNode = overrides
|
||||
? ({ ...storeNode, ...overrides } as RidgeVentNode)
|
||||
: storeNode
|
||||
|
||||
const segment = useScene((state) =>
|
||||
node.roofSegmentId
|
||||
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
@@ -67,25 +79,20 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
|
||||
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
// The preset cache returns materials with `side: FrontSide` (that's
|
||||
// what the preset payload encodes). For a thin extruded ridge cap that
|
||||
// makes the underside disappear when the camera dips below the eaves
|
||||
// — so clone the resolved material and force `DoubleSide` locally
|
||||
// without mutating the shared cache entry.
|
||||
// Paint surface: FrontSide everywhere — DoubleSide on the role
|
||||
// material's NodeMaterial poisons the MRT scene pass (see `materials.ts`
|
||||
// line 77 / glazing fix 9400f1c5). Earlier this path forced DoubleSide
|
||||
// so the underside of the thin extruded ridge cap stayed visible from
|
||||
// below; that's now a known visual tradeoff — building the cap as a
|
||||
// closed solid in `geometry.ts` is the right fix if the underside-view
|
||||
// becomes noticeable.
|
||||
const material = useMemo(() => {
|
||||
// Untextured ridge vent (and textures-off mode) takes the themed
|
||||
// 'roof' role colour. Request DoubleSide directly so the cached role
|
||||
// material is the right side — no clone/mutation of a shared material.
|
||||
if (!textures || (!node.material && !node.materialPreset)) {
|
||||
return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme)
|
||||
return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
|
||||
}
|
||||
const base = node.material
|
||||
return node.material
|
||||
? createMaterial(node.material, shading)
|
||||
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
|
||||
if (base.side === THREE.DoubleSide) return base
|
||||
const cloned = base.clone()
|
||||
cloned.side = THREE.DoubleSide
|
||||
return cloned
|
||||
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
|
||||
|
||||
if (!segment) return null
|
||||
@@ -102,10 +109,18 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
|
||||
const segPos = segment.position ?? [0, 0, 0]
|
||||
const segRotY = segment.rotation ?? 0
|
||||
|
||||
// Seat the vent on the ridge by DERIVING its Y from the segment's current
|
||||
// surface rather than the stored `position[1]`. The ridge height comes from
|
||||
// the segment's pitch (`getActiveRoofHeight`), so when the roof is lowered
|
||||
// the segment updates, this renderer re-runs, and the vent rides the ridge
|
||||
// down automatically — no stale floating cap. X/Z stay as authored (the vent
|
||||
// straddles the ridge line at localZ≈0).
|
||||
const ridgeY = getSurfaceY(node.position[0] ?? 0, node.position[2] ?? 0, segment)
|
||||
|
||||
return (
|
||||
<group position={segPos} rotation-y={segRotY}>
|
||||
<group
|
||||
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
|
||||
position={[node.position[0] ?? 0, ridgeY, node.position[2] ?? 0]}
|
||||
ref={ref}
|
||||
rotation-y={node.rotation ?? 0}
|
||||
visible={node.visible}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { ridgeVentDefinition } from './definition'
|
||||
import RidgeVentPreview from './preview'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user