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
@@ -0,0 +1,264 @@
|
||||
import type { GutterNode, RoofSegmentNode } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Auto-mitre detector for two gutters meeting at a roof corner.
|
||||
*
|
||||
* When two gutters' endpoints land within `CORNER_EPSILON` of each
|
||||
* other in plan (ROOF-local X/Z — see `planDistSq`), the renderer
|
||||
* treats them as a single L-junction and skews each end so the back
|
||||
* walls meet at the inner corner while the front rims extend outward to
|
||||
* a clean mitre.
|
||||
*
|
||||
* Why "back wall stays at the corner": the gutter mounts against the
|
||||
* fascia (gutter-local +X is the length, +Z is outward over the eave).
|
||||
* Two perpendicular fascias meet at the eave corner — that's the
|
||||
* fixed point. The rims hang in space past the building, so they're
|
||||
* the parts that need to extend to actually touch each other.
|
||||
*
|
||||
* For 90° (typical hip / rectangular plan) corners the mitre is 45°
|
||||
* each side; arbitrary angles use the standard mitre formula
|
||||
* `(π − interior) / 2`. Aligned gutters (interior ≈ π) → mitre 0 → no
|
||||
* displacement, no cap suppression — they read as a straight run.
|
||||
*
|
||||
* Cross-segment: endpoints + length axes are lifted into the shared
|
||||
* ROOF frame (each gutter's segment position + Y-rotation applied), so
|
||||
* gutters on DIFFERENT roof segments (L-shaped plans, additions) mitre
|
||||
* at the corner where their segments meet — not just gutters sharing a
|
||||
* segment. Same-segment corners fall out of the same path (both gutters
|
||||
* carry the same segment transform). The skew assumes a convex (outer)
|
||||
* corner; a concave inner corner mitres approximately.
|
||||
*/
|
||||
|
||||
export type GutterMitres = {
|
||||
/**
|
||||
* SIGNED mitre angle (radians) at the gutter's −X end; 0 = no mitre.
|
||||
* Positive = CONVEX (outer) corner — the front rim EXTENDS past the
|
||||
* end to reach the outer eave intersection. Negative = CONCAVE (inner)
|
||||
* corner — the rim RETRACTS to the inner intersection. The geometry
|
||||
* builder feeds the value straight through `Math.tan`, so the sign
|
||||
* flips the skew direction; `=== 0` still means "no mitre / keep cap".
|
||||
*/
|
||||
left: number
|
||||
/** Signed mitre angle (radians) at the gutter's +X end; see `left`. */
|
||||
right: number
|
||||
}
|
||||
|
||||
export const NO_MITRES: GutterMitres = { left: 0, right: 0 }
|
||||
|
||||
// Match the length-snap's 10 cm catch radius (`length-snap.ts`): any two
|
||||
// endpoints close enough for the corner snap to bind are close enough to
|
||||
// read as "they meant to meet". The corner snap pulls them to the exact
|
||||
// eave intersection (≈ 0 cm), so this is mostly slack for eyeballed /
|
||||
// move-tool corners that never went through the length handle.
|
||||
const CORNER_EPSILON = 0.1
|
||||
export const CORNER_EPSILON_SQ = CORNER_EPSILON * CORNER_EPSILON
|
||||
|
||||
// Mitres beyond this are unphysical (an acute outer corner past 30°
|
||||
// interior angle isn't a building corner, it's a CSG artefact). Capping
|
||||
// keeps a misplaced gutter from producing a runaway skew that swallows
|
||||
// the rest of the trough.
|
||||
const MAX_MITRE = (75 * Math.PI) / 180
|
||||
|
||||
// Cross product below this magnitude counts as parallel length-axes — a
|
||||
// straight collinear run, no corner. Matches `length-snap.ts`.
|
||||
const AXIS_PARALLEL_EPSILON = 1e-3
|
||||
|
||||
/** A sibling gutter paired with the segment it sits on, for the frame lift. */
|
||||
export type GutterWithSegment = {
|
||||
gutter: GutterNode
|
||||
segment: Pick<RoofSegmentNode, 'position' | 'rotation'>
|
||||
}
|
||||
|
||||
export type Endpoint = {
|
||||
pos: readonly [number, number, number]
|
||||
/** Length-axis direction in segment frame, pointing from this end toward the other end. */
|
||||
awayDir: readonly [number, number]
|
||||
/** Outward normal (gutter-local +Z, "away from the building") in this frame. */
|
||||
outDir: readonly [number, number]
|
||||
}
|
||||
|
||||
function gutterEndpoints(g: GutterNode): { plus: Endpoint; minus: Endpoint } {
|
||||
const [px, py, pz] = g.position
|
||||
const r = g.rotation ?? 0
|
||||
// Gutter-local +X (length axis) rotated by `r` around Y. THREE's
|
||||
// rotation-y convention: local (1, 0, 0) → (cos r, 0, −sin r).
|
||||
const dirX = Math.cos(r)
|
||||
const dirZ = -Math.sin(r)
|
||||
// Gutter-local +Z (outward) rotated by `r`: (0, 0, 1) → (sin r, 0, cos r).
|
||||
const outX = Math.sin(r)
|
||||
const outZ = Math.cos(r)
|
||||
const half = g.length / 2
|
||||
return {
|
||||
plus: {
|
||||
pos: [px + dirX * half, py, pz + dirZ * half],
|
||||
// From the +X endpoint, the rest of the gutter extends back
|
||||
// toward the −X end — so "away from this end" is −dir.
|
||||
awayDir: [-dirX, -dirZ],
|
||||
outDir: [outX, outZ],
|
||||
},
|
||||
minus: {
|
||||
pos: [px - dirX * half, py, pz - dirZ * half],
|
||||
awayDir: [dirX, dirZ],
|
||||
outDir: [outX, outZ],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gutter endpoints lifted from segment-local into the shared ROOF frame
|
||||
* by applying the host segment's position + Y-rotation. Two gutters on
|
||||
* different segments can then be compared in one frame. THREE's
|
||||
* rotation-y convention: a point/dir (x, z) rotates to
|
||||
* (x·cos + z·sin, −x·sin + z·cos).
|
||||
*/
|
||||
export function gutterEndpointsInFrame(
|
||||
g: GutterNode,
|
||||
segment: Pick<RoofSegmentNode, 'position' | 'rotation'>,
|
||||
): { plus: Endpoint; minus: Endpoint } {
|
||||
const local = gutterEndpoints(g)
|
||||
const sx = segment.position?.[0] ?? 0
|
||||
const sz = segment.position?.[2] ?? 0
|
||||
const sr = segment.rotation ?? 0
|
||||
const c = Math.cos(sr)
|
||||
const s = Math.sin(sr)
|
||||
const liftPos = (p: readonly [number, number, number]): [number, number, number] => [
|
||||
sx + (p[0] * c + p[2] * s),
|
||||
p[1],
|
||||
sz + (-p[0] * s + p[2] * c),
|
||||
]
|
||||
const liftDir = (d: readonly [number, number]): [number, number] => [
|
||||
d[0] * c + d[1] * s,
|
||||
-d[0] * s + d[1] * c,
|
||||
]
|
||||
return {
|
||||
plus: {
|
||||
pos: liftPos(local.plus.pos),
|
||||
awayDir: liftDir(local.plus.awayDir),
|
||||
outDir: liftDir(local.plus.outDir),
|
||||
},
|
||||
minus: {
|
||||
pos: liftPos(local.minus.pos),
|
||||
awayDir: liftDir(local.minus.awayDir),
|
||||
outDir: liftDir(local.minus.outDir),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Plan-space (X/Z) distance only — deliberately ignores Y. Gutters are
|
||||
// pinned to the eave line so they're coplanar in eave-Y, AND the
|
||||
// renderer draws them at the LIVE `computeEaveY(segment)`, not the
|
||||
// stored `position[1]` (which goes stale if the segment's wallHeight /
|
||||
// pitch changed after placement). Folding Y in would reject a real
|
||||
// corner whenever two gutters' stored Ys drifted apart even though they
|
||||
// visibly meet. The length-snap that feeds this also works purely in
|
||||
// plan, so the match must too.
|
||||
export function planDistSq(
|
||||
a: readonly [number, number, number],
|
||||
b: readonly [number, number, number],
|
||||
): number {
|
||||
const dx = a[0] - b[0]
|
||||
const dz = a[2] - b[2]
|
||||
return dx * dx + dz * dz
|
||||
}
|
||||
|
||||
function mitreBetween(a: Endpoint, b: Endpoint): number {
|
||||
// Both `awayDir`s point from the corner toward the FAR end of their
|
||||
// gutter. The interior angle of the joint is the angle between them.
|
||||
// Mitre = half the supplementary angle (standard carpenter formula).
|
||||
const dot = a.awayDir[0] * b.awayDir[0] + a.awayDir[1] * b.awayDir[1]
|
||||
const clamped = Math.max(-1, Math.min(1, dot))
|
||||
const interior = Math.acos(clamped)
|
||||
const mitre = (Math.PI - interior) / 2
|
||||
// Aligned-or-nearly so → straight run, no mitre needed.
|
||||
if (mitre < 1e-3) return 0
|
||||
|
||||
// Convex vs concave. `a.outDir` is THIS gutter's outward normal; `b`'s
|
||||
// body runs from the corner along `b.awayDir`. On a CONVEX (outer)
|
||||
// corner the neighbour's body sits on the INWARD side of our rim, so
|
||||
// `b.awayDir · a.outDir < 0` (it heads away from our outward face) —
|
||||
// the rim must EXTEND (positive). On a CONCAVE (inner) corner the
|
||||
// neighbour's body sits on our OUTWARD side (`· > 0`) and the rim must
|
||||
// RETRACT (negative) to the inner intersection.
|
||||
const concave = b.awayDir[0] * a.outDir[0] + b.awayDir[1] * a.outDir[1] > 0
|
||||
const signed = concave ? -mitre : mitre
|
||||
return Math.max(-MAX_MITRE, Math.min(MAX_MITRE, signed))
|
||||
}
|
||||
|
||||
// Intersection (in plan X/Z) of two infinite length-axis lines, each
|
||||
// given as a point + run direction. Returns null when the runs are
|
||||
// parallel (no single crossing — a straight collinear run, not a
|
||||
// corner). Mirrors the `length-snap.ts` corner solve.
|
||||
function axisIntersectionXZ(
|
||||
aPos: readonly [number, number, number],
|
||||
aDir: readonly [number, number],
|
||||
bPos: readonly [number, number, number],
|
||||
bDir: readonly [number, number],
|
||||
): readonly [number, number, number] | null {
|
||||
const cross = aDir[0] * bDir[1] - aDir[1] * bDir[0]
|
||||
if (Math.abs(cross) < AXIS_PARALLEL_EPSILON) return null
|
||||
const dx = bPos[0] - aPos[0]
|
||||
const dz = bPos[2] - aPos[2]
|
||||
const t = (dx * bDir[1] - dz * bDir[0]) / cross
|
||||
return [aPos[0] + t * aDir[0], 0, aPos[2] + t * aDir[1]]
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute mitres for `subject` against every other gutter under the
|
||||
* same parent.
|
||||
*
|
||||
* A corner is the INTERSECTION of the two gutters' length-axis lines —
|
||||
* not the proximity of their endpoints. This is what makes inner
|
||||
* (concave) corners work: there the two eave drip-lines meet out in the
|
||||
* notch, a full overhang away from where either gutter naturally ends,
|
||||
* so an endpoint-to-endpoint test never fired. Keying off the axis
|
||||
* crossing treats convex and concave identically. The length-snap pulls
|
||||
* both ends out to that shared point, so by the time we mitre the
|
||||
* subject's end AND the sibling's end both sit on the intersection — we
|
||||
* require both to be within `CORNER_EPSILON` of it, which also rejects
|
||||
* runs that merely cross in the middle (a T, not an L).
|
||||
*
|
||||
* First match per end wins; siblings order is the caller's, so the
|
||||
* result is deterministic.
|
||||
*/
|
||||
export function computeGutterMitres(
|
||||
subject: GutterNode,
|
||||
subjectSegment: Pick<RoofSegmentNode, 'position' | 'rotation'>,
|
||||
siblings: readonly GutterWithSegment[],
|
||||
): GutterMitres {
|
||||
if (siblings.length === 0) return NO_MITRES
|
||||
|
||||
const subj = gutterEndpointsInFrame(subject, subjectSegment)
|
||||
let leftMitre = 0
|
||||
let rightMitre = 0
|
||||
|
||||
for (const sib of siblings) {
|
||||
if (sib.gutter.id === subject.id) continue
|
||||
const other = gutterEndpointsInFrame(sib.gutter, sib.segment)
|
||||
|
||||
// `minus.awayDir` runs from the −X end toward +X, i.e. along the
|
||||
// length — so it's a valid direction for either gutter's axis line.
|
||||
const corner = axisIntersectionXZ(
|
||||
subj.minus.pos,
|
||||
subj.minus.awayDir,
|
||||
other.minus.pos,
|
||||
other.minus.awayDir,
|
||||
)
|
||||
if (!corner) continue
|
||||
|
||||
// The sibling end that sits on the corner is the one we mitre against.
|
||||
const otherPlusAtCorner = planDistSq(other.plus.pos, corner) <= CORNER_EPSILON_SQ
|
||||
const otherMinusAtCorner = planDistSq(other.minus.pos, corner) <= CORNER_EPSILON_SQ
|
||||
if (!otherPlusAtCorner && !otherMinusAtCorner) continue
|
||||
const otherEnd = otherPlusAtCorner ? other.plus : other.minus
|
||||
|
||||
if (leftMitre === 0 && planDistSq(subj.minus.pos, corner) <= CORNER_EPSILON_SQ) {
|
||||
leftMitre = mitreBetween(subj.minus, otherEnd)
|
||||
}
|
||||
if (rightMitre === 0 && planDistSq(subj.plus.pos, corner) <= CORNER_EPSILON_SQ) {
|
||||
rightMitre = mitreBetween(subj.plus, otherEnd)
|
||||
}
|
||||
if (leftMitre !== 0 && rightMitre !== 0) break
|
||||
}
|
||||
|
||||
return { left: leftMitre, right: rightMitre }
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
GutterNode as GutterNodeSchema,
|
||||
type GutterNode as GutterNodeType,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { buildGutterFloorplan } from './floorplan'
|
||||
import { snapLengthToCorner } from './length-snap'
|
||||
import { gutterParametrics } from './parametrics'
|
||||
import { GutterNode } from './schema'
|
||||
|
||||
// Edge-to-arrow-center offset, matching the box-vent / ridge-vent
|
||||
// cadence so a roof's worth of accessories all read at the same scale.
|
||||
const SIDE_HANDLE_OFFSET = 0.2
|
||||
// Gutter chevrons sit BELOW the gutter (the trough hangs below the
|
||||
// eave); the Y handle places its arrow under the cross-section apex.
|
||||
const SIZE_HANDLE_OFFSET = 0.15
|
||||
// Minimums — well below the inspector defaults (2.0 m length, 0.13 m
|
||||
// profile) so users can shrink freely without locking.
|
||||
const MIN_LENGTH = 0.2
|
||||
const MIN_SIZE = 0.05
|
||||
|
||||
// Centre of the gutter cross-section in vertical (Y) terms. The gutter
|
||||
// hangs from the eave (Y=0 in vent-mesh-local) down to Y=-size; chevrons
|
||||
// that want to read "beside the body" sit at -size/2.
|
||||
function getBodyMidY(n: GutterNodeType): number {
|
||||
return -Math.max(MIN_SIZE, n.size) / 2
|
||||
}
|
||||
|
||||
// Outward Z midpoint — the gutter's back wall sits at Z=0 and the rim
|
||||
// hangs out to Z≈+size (k-style) / +size (half-round / box). Side
|
||||
// handles place at Z=0 so they sit ABOVE the eave's fascia line.
|
||||
function getRimZ(n: GutterNodeType): number {
|
||||
return Math.max(MIN_SIZE, n.size) / 2
|
||||
}
|
||||
|
||||
// Length arrow on ±X (the eave direction). Asymmetric resize: drag one
|
||||
// end outward while the opposite end stays world-fixed by recentering
|
||||
// `position` along the gutter's own +X arm in segment frame. Same
|
||||
// yaw-aware projection as the box-vent / ridge-vent / chimney width
|
||||
// handles.
|
||||
//
|
||||
// Corner snap: when the dragged endpoint nears the geometric corner it
|
||||
// would form with another gutter (the crossing of their length axes),
|
||||
// `snapLengthToCorner` overrides the raw newLength so the endpoint lands
|
||||
// EXACTLY on that corner — the corner-mitre detector then fires reliably
|
||||
// without pixel-perfect dragging. Only this gutter's length changes.
|
||||
function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor<GutterNodeType> {
|
||||
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, sceneApi) => {
|
||||
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 snap = snapLengthToCorner(
|
||||
initial,
|
||||
newLength,
|
||||
sign,
|
||||
anchorX,
|
||||
anchorZ,
|
||||
armX,
|
||||
armZ,
|
||||
MIN_LENGTH,
|
||||
sceneApi,
|
||||
)
|
||||
// Only the dragged gutter's own length is snapped — `snapLengthToCorner`
|
||||
// never moves the corner-mate, so dragging one gutter can't reset
|
||||
// another the user placed deliberately.
|
||||
const newCenterX = anchorX + sign * (snap.length / 2) * armX
|
||||
const newCenterZ = anchorZ + sign * (snap.length / 2) * armZ
|
||||
return {
|
||||
length: snap.length,
|
||||
position: [newCenterX, initial.position[1], newCenterZ],
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n) => [
|
||||
sign * (n.length / 2 + SIDE_HANDLE_OFFSET),
|
||||
getBodyMidY(n),
|
||||
getRimZ(n),
|
||||
],
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Profile-size arrow below the rim. axis='y', anchor='max' pins the
|
||||
// top of the trough (Y=0, the eave line) and grows the bottom edge
|
||||
// downward as the user drags toward -Y. Plain chevron — at typical
|
||||
// sizes (5″–6″) a dashed tracker would clutter the eave line.
|
||||
function gutterSizeHandle(): HandleDescriptor<GutterNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
// 'max' = +Y edge anchored (top of the trough stays at Y=0); drag
|
||||
// pulls the bottom edge further down. The base linear-resize
|
||||
// factor for `max` is -1, which flips the cursor delta so dragging
|
||||
// downward grows the value 1:1.
|
||||
anchor: 'max',
|
||||
min: MIN_SIZE,
|
||||
currentValue: (n) => n.size,
|
||||
apply: (_n, newValue) => ({ size: Math.max(MIN_SIZE, newValue) }),
|
||||
placement: {
|
||||
// Sit at the bottom of the trough, pushed a bit further down so
|
||||
// the chevron clears the rim and reads as a downward indicator.
|
||||
position: (n) => [0, -Math.max(n.size, MIN_SIZE) - SIZE_HANDLE_OFFSET, getRimZ(n)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const gutterHandles: HandleDescriptor<GutterNodeType>[] = [
|
||||
gutterLengthHandle('right'),
|
||||
gutterLengthHandle('left'),
|
||||
gutterSizeHandle(),
|
||||
]
|
||||
|
||||
/**
|
||||
* Gutter — a rain-water channel running along the eave of a roof
|
||||
* segment. Parented to a `roof-segment`; position is segment-local.
|
||||
*
|
||||
* Three-checkbox model — same shape as box-vent / ridge-vent: custom
|
||||
* `def.renderer` for the parent-segment transform lookup + live
|
||||
* override merge, pure geometry builder in `./geometry` shared with
|
||||
* the placement preview, no per-frame system (no animation, no
|
||||
* cross-kind cascades).
|
||||
*
|
||||
* Placement tool snaps to the eave line (segment-local
|
||||
* `Z = +depth/2, Y = wallHeight`) wherever the cursor lands on a
|
||||
* segment. After commit, the length L/R handles cover trimming and
|
||||
* the inspector covers profile + size adjustments.
|
||||
*/
|
||||
export const gutterDefinition: NodeDefinition<typeof GutterNode> = {
|
||||
kind: 'gutter',
|
||||
schemaVersion: 1,
|
||||
schema: GutterNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'roof',
|
||||
|
||||
defaults: () => {
|
||||
const stub = GutterNodeSchema.parse({
|
||||
id: 'gutter_default' as never,
|
||||
type: 'gutter',
|
||||
})
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
// Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the
|
||||
// eave fascia — no `buildCut`, just the dirty cascade so the
|
||||
// parent roof's merged shell rebuilds when the gutter moves /
|
||||
// resizes.
|
||||
roofAccessory: {},
|
||||
},
|
||||
|
||||
parametrics: gutterParametrics,
|
||||
handles: gutterHandles,
|
||||
floorplan: buildGutterFloorplan,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
|
||||
preview: () => import('./preview'),
|
||||
tool: () => import('./tool'),
|
||||
affordanceTools: {
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place gutter on roof eave' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Gutter',
|
||||
description: 'Rain-water channel running along the eave of a roof segment.',
|
||||
icon: { kind: 'url', src: '/icons/roof.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 122,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A gutter strip running along the eave of a roof segment. Three profiles (k-style ogee fascia, half-round, square box), length / size / thickness parametric.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DownspoutNode,
|
||||
type DownspoutNode as DownspoutNodeType,
|
||||
type GutterNode,
|
||||
generateId,
|
||||
type RoofSegmentNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { ActionButton, ActionGroup, PanelSection, triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { computeEaveY } from './eave-snap'
|
||||
import { resolveGutterOutletById } from './outlet-lookup'
|
||||
|
||||
const DEFAULT_OUTLET_DIAMETER = 0.07
|
||||
|
||||
/**
|
||||
* Pick an along-length offset for a new outlet that doesn't land on an
|
||||
* existing one: the first goes near the right end, the rest drop into
|
||||
* the midpoint of the widest gap (including the gaps to each end). So
|
||||
* "Add Downspout" repeatedly spreads outlets along the run instead of
|
||||
* stacking them.
|
||||
*/
|
||||
function nextOutletOffset(gutter: GutterNode): number {
|
||||
const len = Math.max(0.05, gutter.length)
|
||||
const margin = 0.12
|
||||
const lo = -len / 2 + margin
|
||||
const hi = len / 2 - margin
|
||||
if (hi <= lo) return 0
|
||||
|
||||
const existing = (gutter.outlets ?? [])
|
||||
.map((o) => Math.max(lo, Math.min(hi, o.offset ?? 0)))
|
||||
.sort((a, b) => a - b)
|
||||
if (existing.length === 0) return hi
|
||||
|
||||
const bounds = [lo, ...existing, hi]
|
||||
let bestMid = (lo + hi) / 2
|
||||
let bestGap = -1
|
||||
for (let i = 0; i < bounds.length - 1; i++) {
|
||||
const gap = bounds[i + 1]! - bounds[i]!
|
||||
if (gap > bestGap) {
|
||||
bestGap = gap
|
||||
bestMid = (bounds[i]! + bounds[i + 1]!) / 2
|
||||
}
|
||||
}
|
||||
return bestMid
|
||||
}
|
||||
|
||||
/**
|
||||
* Downspouts subsection at the bottom of the gutter inspector. Lists the
|
||||
* downspouts attached to this gutter (one per outlet); "Add Downspout"
|
||||
* drills a fresh outlet at a spread-out position and drops a downspout
|
||||
* on it, and each row's ✕ removes both the downspout and its outlet.
|
||||
*/
|
||||
export default function DownspoutsPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
const gutter = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId] as GutterNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const downspouts = useScene(
|
||||
useShallow((s) => {
|
||||
if (!selectedId) return [] as DownspoutNodeType[]
|
||||
const out: DownspoutNodeType[] = []
|
||||
for (const n of Object.values(s.nodes)) {
|
||||
if (n?.type === 'downspout' && n.gutterId === selectedId) {
|
||||
out.push(n as DownspoutNodeType)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}),
|
||||
)
|
||||
|
||||
if (!gutter || gutter.type !== 'gutter') return null
|
||||
|
||||
const handleSelectDownspout = (id: AnyNodeId) => {
|
||||
setSelection({ selectedIds: [id] })
|
||||
}
|
||||
|
||||
const handleAddDownspout = () => {
|
||||
const segmentId = gutter.roofSegmentId as AnyNodeId | undefined
|
||||
if (!segmentId) return
|
||||
const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined
|
||||
if (!segment) return
|
||||
|
||||
const outletId = generateId('outlet')
|
||||
const outlets = [
|
||||
...(gutter.outlets ?? []),
|
||||
{ id: outletId, offset: nextOutletOffset(gutter), diameter: DEFAULT_OUTLET_DIAMETER },
|
||||
]
|
||||
const state = useScene.getState()
|
||||
state.updateNode(gutter.id as AnyNodeId, { outlets })
|
||||
state.dirtyNodes.add(gutter.id as AnyNodeId)
|
||||
|
||||
const outlet = resolveGutterOutletById({ ...gutter, outlets }, outletId)
|
||||
const dropLength = Math.max(0.1, computeEaveY(segment) + (outlet?.y ?? -gutter.size))
|
||||
|
||||
const downspout = DownspoutNode.parse({
|
||||
name: 'Downspout',
|
||||
gutterId: gutter.id,
|
||||
outletId,
|
||||
length: dropLength,
|
||||
diameter: (outlet?.bore ?? DEFAULT_OUTLET_DIAMETER / 2) * 2,
|
||||
})
|
||||
state.createNode(downspout, segmentId)
|
||||
state.dirtyNodes.add(segmentId)
|
||||
setSelection({ selectedIds: [downspout.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
}
|
||||
|
||||
const handleRemove = (downspout: DownspoutNodeType) => {
|
||||
const state = useScene.getState()
|
||||
// Drop the outlet this downspout drained so its hole closes up.
|
||||
if (downspout.outletId) {
|
||||
state.updateNode(gutter.id as AnyNodeId, {
|
||||
outlets: (gutter.outlets ?? []).filter((o) => o.id !== downspout.outletId),
|
||||
})
|
||||
state.dirtyNodes.add(gutter.id as AnyNodeId)
|
||||
}
|
||||
state.deleteNode(downspout.id as AnyNodeId)
|
||||
if (gutter.roofSegmentId) state.dirtyNodes.add(gutter.roofSegmentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [gutter.id as AnyNodeId] })
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelSection title="Downspouts">
|
||||
<div className="flex flex-col gap-1">
|
||||
{downspouts.map((d, i) => (
|
||||
<div
|
||||
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm"
|
||||
key={d.id}
|
||||
>
|
||||
<button
|
||||
className="flex-1 truncate text-left transition-colors hover:text-white"
|
||||
onClick={() => handleSelectDownspout(d.id as AnyNodeId)}
|
||||
type="button"
|
||||
>
|
||||
{d.name || `Downspout ${i + 1}`}
|
||||
</button>
|
||||
<button
|
||||
aria-label="Remove downspout"
|
||||
className="ml-2 text-muted-foreground text-xs transition-colors hover:text-red-400"
|
||||
onClick={() => handleRemove(d)}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<ActionGroup>
|
||||
<ActionButton label="Add Downspout" onClick={handleAddDownspout} />
|
||||
</ActionGroup>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { GutterNode, RoofSegmentNode } from '@pascal-app/core'
|
||||
import { CORNER_EPSILON_SQ, gutterEndpointsInFrame, planDistSq } from './corner-mitre'
|
||||
import { computeEaveY } from './eave-snap'
|
||||
|
||||
/**
|
||||
* Shared eave-Y for a connected run of gutters.
|
||||
*
|
||||
* Each gutter normally derives its mount height independently from its
|
||||
* own host segment via `computeEaveY` (wallHeight − overhang·tan(pitch)
|
||||
* + tuck, or wallHeight on a flat deck). Two segments that read as the
|
||||
* same height in the inspector can still land at different eave Ys when
|
||||
* their pitch / overhang / roofType differ — so gutters that meet at a
|
||||
* corner inherit two heights and the run visibly steps at the joint.
|
||||
*
|
||||
* This walks the connected component of gutters that meet at corners
|
||||
* (same plan-space endpoint test the mitre detector uses — Y is
|
||||
* deliberately ignored, so the grouping survives the very height drift
|
||||
* we're correcting) and returns ONE height for the whole run: the
|
||||
* HIGHEST member eave. Aligning up means no gutter ever sinks into a
|
||||
* roof surface; a lower roof gets a small fascia gap, which reads
|
||||
* cleaner than a gutter clipping through its slope.
|
||||
*
|
||||
* Deterministic + symmetric: every gutter in the run computes the same
|
||||
* component and the same max, so they all converge on the identical Y
|
||||
* without any shared coordinator or store write. Isolated gutters (no
|
||||
* corner neighbour) get their own eave Y unchanged.
|
||||
*
|
||||
* Pure: no React, no scene access, no store mutation.
|
||||
*/
|
||||
|
||||
/** A sibling gutter paired with its FULL host segment (needs the eave-Y inputs). */
|
||||
export type GutterWithSegment = {
|
||||
gutter: GutterNode
|
||||
segment: RoofSegmentNode
|
||||
}
|
||||
|
||||
function guttersMeet(
|
||||
a: GutterNode,
|
||||
aSeg: RoofSegmentNode,
|
||||
b: GutterNode,
|
||||
bSeg: RoofSegmentNode,
|
||||
): boolean {
|
||||
const ea = gutterEndpointsInFrame(a, aSeg)
|
||||
const eb = gutterEndpointsInFrame(b, bSeg)
|
||||
return (
|
||||
planDistSq(ea.minus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ ||
|
||||
planDistSq(ea.minus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ ||
|
||||
planDistSq(ea.plus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ ||
|
||||
planDistSq(ea.plus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ
|
||||
)
|
||||
}
|
||||
|
||||
// Each gutter mounts at `segment.position[1] + computeEaveY(segment)` in
|
||||
// the roof frame (the renderer adds the segment-local eave Y under the
|
||||
// segment's group). Segments can sit at different Y offsets, so the run
|
||||
// has to be compared — and the answer returned — in the SHARED roof
|
||||
// frame, not raw segment-local eave Ys.
|
||||
function worldEaveY(segment: RoofSegmentNode): number {
|
||||
return (segment.position?.[1] ?? 0) + computeEaveY(segment)
|
||||
}
|
||||
|
||||
export function computeSharedEaveY(
|
||||
subject: GutterNode,
|
||||
subjectSegment: RoofSegmentNode,
|
||||
siblings: readonly GutterWithSegment[],
|
||||
): number {
|
||||
const subjectBaseY = subjectSegment.position?.[1] ?? 0
|
||||
if (siblings.length === 0) return computeEaveY(subjectSegment)
|
||||
|
||||
// Index 0 is the subject; the rest are candidates. BFS the corner
|
||||
// graph from the subject and keep the tallest eave in its component.
|
||||
const nodes: GutterWithSegment[] = [{ gutter: subject, segment: subjectSegment }, ...siblings]
|
||||
const visited = new Array<boolean>(nodes.length).fill(false)
|
||||
visited[0] = true
|
||||
const queue = [0]
|
||||
let maxWorldEaveY = worldEaveY(subjectSegment)
|
||||
|
||||
while (queue.length > 0) {
|
||||
const i = queue.pop()!
|
||||
const cur = nodes[i]!
|
||||
for (let j = 0; j < nodes.length; j++) {
|
||||
if (visited[j]) continue
|
||||
const other = nodes[j]!
|
||||
if (guttersMeet(cur.gutter, cur.segment, other.gutter, other.segment)) {
|
||||
visited[j] = true
|
||||
queue.push(j)
|
||||
const eaveY = worldEaveY(other.segment)
|
||||
if (eaveY > maxWorldEaveY) maxWorldEaveY = eaveY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back to the SUBJECT's segment-local frame — the renderer applies the
|
||||
// returned value under the subject segment's group, which already adds
|
||||
// `subjectBaseY`.
|
||||
return maxWorldEaveY - subjectBaseY
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { RoofSegmentNode, RoofType } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Shared eave-snap math for the gutter's placement + move tools.
|
||||
*
|
||||
* `resolveEaveSnap` finds the drip edge of the eave closest to a
|
||||
* cursor hit in segment-local coords. It supports every roof type the
|
||||
* segment renderer can produce; the difference vs the original
|
||||
* `±Z`-only resolver is hip/flat awareness (4-way eave instead of 2)
|
||||
* and shed's single low eave.
|
||||
*
|
||||
* Why this lives outside the tools: the two tool files used to inline
|
||||
* an identical copy of the resolver + the tuck constants, with a
|
||||
* "keep in sync" comment that becomes a landmine as soon as the
|
||||
* resolver grows non-trivial. Hip's 4-way picker pushed it past that
|
||||
* threshold.
|
||||
*/
|
||||
|
||||
// Real gutters mount on the fascia (slightly inside the drip edge),
|
||||
// with the rim at the deck-top line rather than the slope-surface-at-
|
||||
// drip-edge. These tuck the snap so the gutter reads as "attached to
|
||||
// the fascia" rather than "floating at the very tip of the overhang".
|
||||
// Tuned by feel — bump them up if the gutter looks too low / outboard.
|
||||
export const EAVE_TUCK_INWARD = 0.04
|
||||
export const EAVE_TUCK_UP = 0.04
|
||||
|
||||
export type EaveSide = '+X' | '-X' | '+Z' | '-Z'
|
||||
|
||||
export type EaveSnap = {
|
||||
/** Segment-local X of the snapped gutter position. */
|
||||
eaveX: number
|
||||
/** Segment-local Y of the snapped gutter position (drip-edge Y). */
|
||||
eaveY: number
|
||||
/** Segment-local Z of the snapped gutter position. */
|
||||
eaveZ: number
|
||||
/**
|
||||
* Gutter's segment-local Y rotation: orients gutter's outward axis
|
||||
* (+Z local) toward the side picked. Length axis (+X local) falls
|
||||
* out along the eave direction (±X or ±Z depending on the side).
|
||||
*/
|
||||
rotation: number
|
||||
/** Which side of the segment the snap landed on. */
|
||||
side: EaveSide
|
||||
}
|
||||
|
||||
/**
|
||||
* Live eave Y from a segment's wallHeight + overhang + pitch. Pulled
|
||||
* out as a shared helper because the renderer derives Y from this same
|
||||
* formula on every frame (the gutter tracks the segment's height
|
||||
* instead of trusting `node.position[1]` from placement time), and
|
||||
* `resolveEaveSnap` uses the same formula at placement.
|
||||
*/
|
||||
export function computeEaveY(
|
||||
segment: Pick<RoofSegmentNode, 'wallHeight' | 'overhang' | 'pitch' | 'roofType'>,
|
||||
): number {
|
||||
const wallHeight = segment.wallHeight ?? 0
|
||||
// Flat roofs have no slope drop and no slope-surface-vs-deck-top
|
||||
// offset — the deck top IS the eave line. EAVE_TUCK_UP is a
|
||||
// correction that lifts a SLOPED gutter from the slope-surface up to
|
||||
// the deck-top line; applying it to a flat deck floats the gutter
|
||||
// above the roof and leaves a visible gap between the edge and the
|
||||
// gutter. So mount flat gutters right at the deck top.
|
||||
if ((segment.roofType ?? 'gable') === 'flat') return wallHeight
|
||||
const overhang = segment.overhang ?? 0
|
||||
const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180
|
||||
return wallHeight - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick which of the segment's eaves is closest to the cursor.
|
||||
*
|
||||
* - `shed`: low side only. The segment-hit's analytical surface for
|
||||
* a shed has `t = (lz + depth/2)/depth`, so the eave is at +Z
|
||||
* regardless of which side the cursor is on — clicking on the high
|
||||
* side still rolls the gutter down to the low eave.
|
||||
*
|
||||
* - `hip` / `flat` / `dutch`: 4-way. The slope the user is standing
|
||||
* on is determined by whichever of `|lx|/halfW` or `|lz|/halfD` is
|
||||
* larger — same `max(fx, fz)` discriminator the segment-hit's
|
||||
* `analyticalSurfaceY` uses for hip. Sign of the dominant axis
|
||||
* picks +/-. Dutch is a hip base with a gablet on top, so its
|
||||
* lower run has all four eaves at the eave line — it gets the same
|
||||
* 4-way snap as hip.
|
||||
*
|
||||
* - `gable` / `gambrel` / `mansard`: 2-way `±Z`. Mansard has real
|
||||
* 4-side eaves in plan, but the segment-hit formula approximates it
|
||||
* as 2-slope (depth-only), so we stay consistent here — the user
|
||||
* can re-place the gutter manually on a side eave if mansard
|
||||
* becomes important.
|
||||
*/
|
||||
function pickEaveSide(
|
||||
roofType: RoofType,
|
||||
localX: number,
|
||||
localZ: number,
|
||||
halfW: number,
|
||||
halfD: number,
|
||||
): EaveSide {
|
||||
if (roofType === 'shed') return '+Z'
|
||||
|
||||
if (roofType === 'hip' || roofType === 'flat' || roofType === 'dutch') {
|
||||
const fx = halfW > 0 ? Math.abs(localX) / halfW : 0
|
||||
const fz = halfD > 0 ? Math.abs(localZ) / halfD : 0
|
||||
if (fx > fz) return localX < 0 ? '-X' : '+X'
|
||||
return localZ < 0 ? '-Z' : '+Z'
|
||||
}
|
||||
|
||||
return localZ < 0 ? '-Z' : '+Z'
|
||||
}
|
||||
|
||||
export function resolveEaveSnap(
|
||||
segment: RoofSegmentNode,
|
||||
localX: number,
|
||||
localZ: number,
|
||||
): EaveSnap {
|
||||
const halfW = (segment.width ?? 0) / 2
|
||||
const halfD = (segment.depth ?? 0) / 2
|
||||
const overhang = segment.overhang ?? 0
|
||||
|
||||
// The slope keeps descending past the wall edge by the overhang
|
||||
// span; same drop on every eave (pitch is the segment-wide primary
|
||||
// slope). EAVE_TUCK_UP raises the rim back toward the deck-top line.
|
||||
// Shared formula with the renderer so placement and live tracking
|
||||
// agree exactly.
|
||||
const eaveY = computeEaveY(segment)
|
||||
|
||||
const side = pickEaveSide(segment.roofType ?? 'gable', localX, localZ, halfW, halfD)
|
||||
|
||||
// For `±Z` eaves the eave runs along ±X so the parallel axis stays
|
||||
// free (snapped to cursor's X), and Z pins to the drip edge. `±X`
|
||||
// eaves swap which axis is free vs pinned. Rotation aligns the
|
||||
// gutter's outward (+Z local) with the picked side; length (+X
|
||||
// local) then falls along the eave.
|
||||
switch (side) {
|
||||
case '+Z':
|
||||
return {
|
||||
eaveX: localX,
|
||||
eaveY,
|
||||
eaveZ: Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD),
|
||||
rotation: 0,
|
||||
side,
|
||||
}
|
||||
case '-Z':
|
||||
return {
|
||||
eaveX: localX,
|
||||
eaveY,
|
||||
eaveZ: -Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD),
|
||||
rotation: Math.PI,
|
||||
side,
|
||||
}
|
||||
case '+X':
|
||||
return {
|
||||
eaveX: Math.max(halfW, halfW + overhang - EAVE_TUCK_INWARD),
|
||||
eaveY,
|
||||
eaveZ: localZ,
|
||||
rotation: Math.PI / 2,
|
||||
side,
|
||||
}
|
||||
case '-X':
|
||||
return {
|
||||
eaveX: -Math.max(halfW, halfW + overhang - EAVE_TUCK_INWARD),
|
||||
eaveY,
|
||||
eaveZ: localZ,
|
||||
rotation: -Math.PI / 2,
|
||||
side,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
GutterNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
import { computeGutterMitres, type GutterWithSegment } from './corner-mitre'
|
||||
import { EAVE_TUCK_INWARD } from './eave-snap'
|
||||
import { outletDims, outletShapeForProfile, profileFloorMidZ } from './profile-geometry'
|
||||
|
||||
/**
|
||||
* Floor-plan builder for a gutter. A gutter is a thin rain-water channel
|
||||
* hosted on a roof segment, running along an eave. In plan it reads as a
|
||||
* narrow metal strip just outboard of the eave line: the trough (two long
|
||||
* edges), end caps where the trough is closed, hanger straps across the
|
||||
* run, and a downspout outlet symbol where one is fitted.
|
||||
*
|
||||
* The coordinate frame mirrors the 3D transform stack
|
||||
* (roof → roof-segment → gutter). The gutter's `position` is
|
||||
* segment-local and the segment's is roof-local, so we compose
|
||||
* world = roof.pos + R(roof) · (seg.pos + R(seg) · gutter.pos)
|
||||
* using the floor-plan's negated-rotation convention (see
|
||||
* `buildRoofSegmentFloorplan`). Gutter-local +X is the run (along the
|
||||
* eave); +Z hangs outward, away from the building.
|
||||
*/
|
||||
export function buildGutterFloorplan(
|
||||
node: GutterNode,
|
||||
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
|
||||
|
||||
// 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
|
||||
// convention the roof-segment builder establishes.
|
||||
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 gutterRot = -(roof.rotation + segment.rotation + node.rotation)
|
||||
const cos = Math.cos(gutterRot)
|
||||
const sin = Math.sin(gutterRot)
|
||||
const toPlan = (lx: number, lz: number): FloorplanPoint => [
|
||||
cx + lx * cos - lz * sin,
|
||||
cz + lx * sin + lz * cos,
|
||||
]
|
||||
|
||||
const halfLen = Math.max(node.length, 0.1) / 2
|
||||
const width = Math.max(node.size, 0.05) // outward extent of the trough
|
||||
|
||||
// The gutter's stored position is the eave drip edge — `halfD + overhang`
|
||||
// out from the segment centre (resolveEaveSnap). The roof floor plan
|
||||
// draws only the structural footprint (no overhang), so to seat the
|
||||
// trough on the drawn roof edge we shift it inward by that overhang
|
||||
// excess. Local Z then reads: `backZ` = back/fascia edge (on the roof
|
||||
// edge), `rimZ` = outward lip.
|
||||
//
|
||||
// A plain inward shift would break mitred corners — the two meeting
|
||||
// gutters lie on perpendicular eaves, so each shifts a different way and
|
||||
// their ends part (they cross). We compensate in the corner math below
|
||||
// by also retracting each MITRED end along the run by the same inset:
|
||||
// the net move at a corner is then identical for both gutters, so they
|
||||
// still meet — now at the structural corner. Exact for right-angle
|
||||
// (hip / rectangular) corners.
|
||||
const eaveInset = Math.max(0, (segment.overhang ?? 0) - EAVE_TUCK_INWARD)
|
||||
const backZ = -eaveInset
|
||||
const rimZ = width - eaveInset
|
||||
|
||||
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
|
||||
|
||||
// Gutters are a metal accessory — read them in a cooler grey than the
|
||||
// roof's black structural ink, accent on select, light blue on hover.
|
||||
const baseInk = '#475569'
|
||||
const stroke =
|
||||
showSelectedChrome && palette
|
||||
? palette.selectedStroke
|
||||
: isHovered && palette
|
||||
? palette.wallHoverStroke
|
||||
: baseInk
|
||||
const fill = showSelectedChrome ? '#fed7aa' : '#cbd5e1'
|
||||
const fillOpacity = showSelectedChrome ? 0.5 : 0.4
|
||||
const lineWidth = showSelectedChrome ? 0.03 : 0.022
|
||||
|
||||
// Corner mitres — when a sibling gutter meets this one at a roof
|
||||
// corner, that shared end is open: the back wall stays at the corner
|
||||
// while the rim extends outward to the mitre, and the cap is
|
||||
// suppressed. Mirrors the 3D builder's rule (`endCap* && mitre === 0`).
|
||||
// `left` = −X end, `right` = +X end.
|
||||
//
|
||||
// Cross-segment: collect every other gutter on the roof paired with its
|
||||
// host segment (mirrors the renderer's `mitreNodes` walk) so gutters
|
||||
// meeting where two segments join still mitre, not just same-segment.
|
||||
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
|
||||
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) {
|
||||
mitreSiblings.push({ gutter: g, segment: sib })
|
||||
}
|
||||
}
|
||||
}
|
||||
const mitres = computeGutterMitres(node, segment, mitreSiblings)
|
||||
const capRight = node.endCapRight && mitres.right === 0
|
||||
const capLeft = node.endCapLeft && mitres.left === 0
|
||||
|
||||
// Footprint corners. Back edge sits on the roof edge (lz = backZ); the
|
||||
// rim hangs outward (lz = rimZ). A mitred end (a) retracts along the run
|
||||
// by `eaveInset` so its back corner lands on the structural corner, then
|
||||
// (b) skews its rim corner by the SIGNED mitre (`Math.tan` carries the
|
||||
// sign: convex extends, concave retracts) so adjacent gutters' rims meet
|
||||
// at the corner. Non-mitred ends (mitre === 0) keep the full run.
|
||||
const backRightX = halfLen - (mitres.right !== 0 ? eaveInset : 0)
|
||||
const backLeftX = -(halfLen - (mitres.left !== 0 ? eaveInset : 0))
|
||||
const backLeft = toPlan(backLeftX, backZ)
|
||||
const backRight = toPlan(backRightX, backZ)
|
||||
const rimRight = toPlan(backRightX + width * Math.tan(mitres.right), rimZ)
|
||||
const rimLeft = toPlan(backLeftX - width * Math.tan(mitres.left), rimZ)
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
// Transparent hit-target across the whole channel so the thin strip
|
||||
// is easy to click-select in plan.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: [backLeft, backRight, rimRight, rimLeft],
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
// Channel fill.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: [backLeft, backRight, rimRight, rimLeft],
|
||||
fill,
|
||||
fillOpacity,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// Long edges — the back (fascia) line and the front lip. These two
|
||||
// parallel lines are the gutter's signature read in plan.
|
||||
{
|
||||
kind: 'line',
|
||||
x1: backLeft[0],
|
||||
y1: backLeft[1],
|
||||
x2: backRight[0],
|
||||
y2: backRight[1],
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinecap: 'round',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: rimLeft[0],
|
||||
y1: rimLeft[1],
|
||||
x2: rimRight[0],
|
||||
y2: rimRight[1],
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinecap: 'round',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]
|
||||
|
||||
// End edges. A capped end gets a square closure line; a mitred end gets
|
||||
// the slanted mitre seam (so the joint shows in plan); an open, uncapped
|
||||
// end gets nothing. `cap*` already excludes mitred ends, so an end never
|
||||
// draws both a cap and a seam.
|
||||
if (capLeft || mitres.left !== 0) {
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: backLeft[0],
|
||||
y1: backLeft[1],
|
||||
x2: rimLeft[0],
|
||||
y2: rimLeft[1],
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinecap: 'round',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
if (capRight || mitres.right !== 0) {
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: backRight[0],
|
||||
y1: backRight[1],
|
||||
x2: rimRight[0],
|
||||
y2: rimRight[1],
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinecap: 'round',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
// Hanger straps — short ticks across the trough at the real hanger
|
||||
// spacing, so the strip reads as a gutter rather than a thin wall.
|
||||
if (node.hangerStyle === 'strap') {
|
||||
const spacing = Math.max(node.hangerSpacing, 0.2)
|
||||
const inset = width * 0.15
|
||||
// Span the (possibly retracted) run between the two end corners.
|
||||
const mid = (backLeftX + backRightX) / 2
|
||||
const runLen = backRightX - backLeftX
|
||||
const count = Math.max(1, Math.floor(runLen / spacing))
|
||||
const span = count * spacing
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = mid - span / 2 + spacing / 2 + i * spacing
|
||||
if (x <= backLeftX + 0.02 || x >= backRightX - 0.02) continue
|
||||
const a = toPlan(x, backZ + inset)
|
||||
const b = toPlan(x, rimZ - inset)
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: a[0],
|
||||
y1: a[1],
|
||||
x2: b[0],
|
||||
y2: b[1],
|
||||
stroke,
|
||||
strokeWidth: lineWidth * 0.7,
|
||||
strokeLinecap: 'round',
|
||||
opacity: 0.6,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Downspout outlets — a leader symbol per outlet (round for half-round
|
||||
// gutters, rectangular for k-style / box, following the profile) at each
|
||||
// outlet's along-run position. The strongest "this is a gutter" cue in a
|
||||
// roof plan. `offset` is signed from the gutter centre along +X.
|
||||
// `outlets` is a recent schema addition — gutters persisted before it
|
||||
// existed deserialize without the field (the schema default only fills
|
||||
// on a fresh parse), so guard against `undefined`.
|
||||
const outlets = node.outlets ?? []
|
||||
if (outlets.length > 0) {
|
||||
const floorZ = Math.min(
|
||||
Math.max(profileFloorMidZ(node.profile, width), width * 0.25),
|
||||
width * 0.85,
|
||||
)
|
||||
const outletZ = backZ + floorZ
|
||||
const shape = outletShapeForProfile(node.profile)
|
||||
const outStroke = showSelectedChrome && palette ? palette.selectedStroke : '#1e293b'
|
||||
for (const outlet of outlets) {
|
||||
// Clamp inside the run so the symbol never rides out onto a cap line
|
||||
// (the 3D builder clamps the drill the same way).
|
||||
const outletX = Math.max(-halfLen * 0.9, Math.min(halfLen * 0.9, outlet.offset))
|
||||
const dims = outletDims(shape, outlet.diameter)
|
||||
if (shape === 'round') {
|
||||
const center = toPlan(outletX, outletZ)
|
||||
children.push({
|
||||
kind: 'circle',
|
||||
cx: center[0],
|
||||
cy: center[1],
|
||||
r: Math.max(dims.halfX, 0.03),
|
||||
fill: 'none',
|
||||
stroke: outStroke,
|
||||
strokeWidth: lineWidth,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
} else {
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: [
|
||||
toPlan(outletX - dims.halfX, outletZ - dims.halfZ),
|
||||
toPlan(outletX + dims.halfX, outletZ - dims.halfZ),
|
||||
toPlan(outletX + dims.halfX, outletZ + dims.halfZ),
|
||||
toPlan(outletX - dims.halfX, outletZ + dims.halfZ),
|
||||
],
|
||||
fill: 'none',
|
||||
stroke: outStroke,
|
||||
strokeWidth: lineWidth,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
import type { GutterNode } from '@pascal-app/core'
|
||||
import {
|
||||
Brush,
|
||||
csgEvaluator,
|
||||
csgGeometry,
|
||||
prepareBrushForCSG,
|
||||
SUBTRACTION,
|
||||
} from '@pascal-app/viewer'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { type GutterMitres, NO_MITRES } from './corner-mitre'
|
||||
import {
|
||||
OUTLET_STUB_LENGTH,
|
||||
OUTLET_WALL_THICKNESS,
|
||||
type OutletDims,
|
||||
type OutletShape,
|
||||
outletDims,
|
||||
outletShapeForProfile,
|
||||
profileFloorMidZ,
|
||||
} from './profile-geometry'
|
||||
|
||||
/**
|
||||
* Pure builder for the gutter mesh. The gutter is a hollow trough that
|
||||
* runs along the eave; we build its cross-section as a closed 2D Shape
|
||||
* in the (Z, Y) plane with the channel cavity carved out as a Path
|
||||
* hole, then extrude along the gutter's local +X (length direction).
|
||||
*
|
||||
* Three profiles share the same outer-outline-minus-cavity recipe; they
|
||||
* differ only in the OUTLINE shape:
|
||||
*
|
||||
* - `k-style`: flat back + flat bottom + ogee (S-curve) fascia.
|
||||
* Most common modern residential profile.
|
||||
* - `half-round`: half-cylinder (semicircle cross-section).
|
||||
* - `box`: square / rectangular u-channel; reads as commercial.
|
||||
*
|
||||
* The gutter mounts at the eave line (gutter-local Y=0) and drops
|
||||
* downward (negative Y) by `size`. +Z is "away from the building" —
|
||||
* positive Z is the outer face that hangs over the eave.
|
||||
*
|
||||
* End caps: when `endCapLeft` / `endCapRight` is true, the matching
|
||||
* end gets a thin SOLID slice (depth = wall thickness) instead of the
|
||||
* hollow U-channel. The solid slice's end face closes the trough so
|
||||
* water can't run out the side. Caps subtract from the user-set
|
||||
* `length` so the gutter's total span stays constant — capping doesn't
|
||||
* silently grow the geometry past what the inspector reads.
|
||||
*
|
||||
* Corner mitres: when a sibling gutter meets this gutter at a roof
|
||||
* corner, the corner-mitre detector passes a mitre angle for the
|
||||
* affected end. The end-face vertices are skewed (back wall held in
|
||||
* place, front rim extended outward) so two perpendicular gutters'
|
||||
* front rims meet at the outer eave intersection. A mitred end's cap
|
||||
* is force-suppressed — capping a corner would wall off the L.
|
||||
*
|
||||
* Pure: no React, no scene access, no store mutation.
|
||||
*/
|
||||
export function buildGutterGeometry(
|
||||
node: GutterNode,
|
||||
mitres: GutterMitres = NO_MITRES,
|
||||
): THREE.BufferGeometry {
|
||||
const len = Math.max(0.05, node.length)
|
||||
const size = Math.max(0.04, node.size)
|
||||
const t = Math.min(Math.max(0.001, node.thickness), size * 0.4)
|
||||
|
||||
const capLeft = (node.endCapLeft ?? true) && mitres.left === 0
|
||||
const capRight = (node.endCapRight ?? true) && mitres.right === 0
|
||||
|
||||
// Reserve cap slices at each capped end. Each cap is `t` thick
|
||||
// (matches the wall thickness — a real end cap is a stamped plate
|
||||
// welded onto the gutter). Clamp so a tiny gutter doesn't end up
|
||||
// all-cap-no-channel.
|
||||
const reserved = (capLeft ? t : 0) + (capRight ? t : 0)
|
||||
const channelLen = Math.max(len * 0.1, len - reserved)
|
||||
const totalCap = len - channelLen
|
||||
const capLeftLen = capLeft ? (capRight ? totalCap / 2 : totalCap) : 0
|
||||
const capRightLen = capRight ? (capLeft ? totalCap / 2 : totalCap) : 0
|
||||
|
||||
let channelCross: THREE.Shape
|
||||
let capCross: THREE.Shape
|
||||
if (node.profile === 'half-round') {
|
||||
channelCross = buildHalfRoundCross(size, t)
|
||||
capCross = buildHalfRoundOuterOnly(size)
|
||||
} else if (node.profile === 'box') {
|
||||
channelCross = buildBoxCross(size, t)
|
||||
capCross = buildBoxOuterOnly(size)
|
||||
} else {
|
||||
channelCross = buildKStyleCross(size, t)
|
||||
capCross = buildKStyleOuterOnly(size)
|
||||
}
|
||||
|
||||
// Each extrude below uses the same orient-and-recenter recipe:
|
||||
// ExtrudeGeometry produces (mesh-X = cross-X, mesh-Y = cross-Y,
|
||||
// mesh-Z = extrusion axis); we rotateY(-π/2) so the LENGTH lands
|
||||
// along mesh-+X and the OUTWARD direction lands along mesh-+Z, then
|
||||
// translate so the piece sits in its slot of the gutter's overall
|
||||
// [-len/2, +len/2] span. Z_cs = 0 maps to mesh-+X (right end);
|
||||
// Z_cs = depth maps to mesh--X (left end).
|
||||
const pieces: THREE.BufferGeometry[] = []
|
||||
|
||||
let channel: THREE.BufferGeometry = new THREE.ExtrudeGeometry(channelCross, {
|
||||
depth: channelLen,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 16,
|
||||
steps: 1,
|
||||
})
|
||||
// Apply the corner-mitre skew while we're still in the source frame.
|
||||
// Source axes (pre-rotation): X_cs = outward, Y_cs = vertical,
|
||||
// Z_cs = length (0 at right end of mesh, `channelLen` at left end).
|
||||
// After rotateY(-π/2): mesh-X = -Z_cs, mesh-Z = X_cs.
|
||||
//
|
||||
// Corner mitre rule: the back wall (X_cs = 0) stays at the original
|
||||
// end (mesh-X = ±len/2); the front rim (X_cs = +outward) is SKEWED
|
||||
// along the gutter's length to reach the eave intersection of the L.
|
||||
// The mitre is SIGNED — positive (convex / outer corner) extends the
|
||||
// rim past the end; negative (concave / inner corner) retracts it to
|
||||
// the inner intersection. `Math.tan` carries the sign, so the same
|
||||
// formula handles both. In mesh coords:
|
||||
// right end: Δmesh-X = +mesh-Z · tan(mitreRight)
|
||||
// left end: Δmesh-X = −mesh-Z · tan(mitreLeft)
|
||||
// Mapped back to source coords (Δmesh-X = −ΔZ_cs, mesh-Z = X_cs):
|
||||
// right end (Z_cs = 0): new Z_cs = −X_cs · tan(mitreRight)
|
||||
// left end (Z_cs = channelLen): new Z_cs = channelLen + X_cs · tan(mitreLeft)
|
||||
if (mitres.right !== 0 || mitres.left !== 0) {
|
||||
// Drop the cross-section CAP at each mitred end first. Both gutters
|
||||
// at a corner skew their end faces onto the SAME mitre plane, so the
|
||||
// thin metal-section caps land coincident and z-fight into the stray
|
||||
// seam lines visible at the joint (and the ink-edge pass outlines
|
||||
// them). With the caps gone the open troughs flow into each other and
|
||||
// the side walls carry the corner — a clean seam. Done before the
|
||||
// skew so the end planes are still the clean z=0 / z=channelLen.
|
||||
const stripped = stripEndCaps(channel, channelLen, mitres.right !== 0, mitres.left !== 0)
|
||||
channel.dispose()
|
||||
channel = stripped
|
||||
|
||||
const tanRight = Math.tan(mitres.right)
|
||||
const tanLeft = Math.tan(mitres.left)
|
||||
const eps = 1e-5
|
||||
const pos = channel.attributes.position!
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const x = pos.getX(i)
|
||||
const z = pos.getZ(i)
|
||||
if (mitres.right !== 0 && Math.abs(z) < eps) {
|
||||
pos.setZ(i, -x * tanRight)
|
||||
} else if (mitres.left !== 0 && Math.abs(z - channelLen) < eps) {
|
||||
pos.setZ(i, channelLen + x * tanLeft)
|
||||
}
|
||||
}
|
||||
pos.needsUpdate = true
|
||||
channel.computeVertexNormals()
|
||||
}
|
||||
channel.rotateY(-Math.PI / 2)
|
||||
// Channel spans [-len/2 + capLeftLen, +len/2 - capRightLen]: shift
|
||||
// the recentered extrude so its right end butts against the right cap.
|
||||
channel.translate(len / 2 - capRightLen, 0, 0)
|
||||
pieces.push(channel)
|
||||
|
||||
if (capLeft) {
|
||||
const leftCap = new THREE.ExtrudeGeometry(capCross, {
|
||||
depth: capLeftLen,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 16,
|
||||
steps: 1,
|
||||
})
|
||||
leftCap.rotateY(-Math.PI / 2)
|
||||
// Left cap spans [-len/2, -len/2 + capLeftLen]: translate by
|
||||
// -len/2 + capLeftLen so Z_cs=0 (mesh-+X end of the cap slice)
|
||||
// sits at -len/2 + capLeftLen and Z_cs=depth at -len/2.
|
||||
leftCap.translate(-len / 2 + capLeftLen, 0, 0)
|
||||
pieces.push(leftCap)
|
||||
}
|
||||
|
||||
if (capRight) {
|
||||
const rightCap = new THREE.ExtrudeGeometry(capCross, {
|
||||
depth: capRightLen,
|
||||
bevelEnabled: false,
|
||||
curveSegments: 16,
|
||||
steps: 1,
|
||||
})
|
||||
rightCap.rotateY(-Math.PI / 2)
|
||||
// Right cap spans [+len/2 - capRightLen, +len/2].
|
||||
rightCap.translate(len / 2, 0, 0)
|
||||
pieces.push(rightCap)
|
||||
}
|
||||
|
||||
// Hangers: thin metal straps spanning the rim from the back wall to
|
||||
// the front rim, repeated along the length. Each strap is a small
|
||||
// box centered on Y=0 (the eave line, where the rim sits) with its
|
||||
// top ~3mm above the rim — so it reads as a clip resting on the
|
||||
// gutter rather than buried in it.
|
||||
if ((node.hangerStyle ?? 'strap') !== 'none') {
|
||||
for (const hanger of buildHangers(node, len, size, capLeftLen, capRightLen, mitres)) {
|
||||
pieces.push(hanger)
|
||||
}
|
||||
}
|
||||
|
||||
// Downspout outlets: short drop-tube collars hanging off the gutter
|
||||
// floor at each outlet's position. Each collar is solid at the outer
|
||||
// (bore + wall) cross-section; the bore is then drilled through both
|
||||
// the floor and the collar via CSG, so the result is a real hole in
|
||||
// the trough floor and a hollow tube descending from it. CSG only
|
||||
// runs when at least one outlet exists — an empty `outlets` keeps the
|
||||
// fast merge path.
|
||||
const placements = resolveOutletPlacements(node, len, size, capLeftLen, capRightLen)
|
||||
for (const p of placements) {
|
||||
pieces.push(buildOutletStub(p, size))
|
||||
// Flared funnel lip at the floor so the drop reads as a real drop
|
||||
// outlet rather than a bare tube; the same bore drill cuts it open.
|
||||
pieces.push(buildOutletFunnel(p, size))
|
||||
}
|
||||
|
||||
const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!)
|
||||
// Free the intermediate pieces when merge returned a new geometry.
|
||||
if (merged !== pieces[0]) {
|
||||
for (const p of pieces) p.dispose()
|
||||
}
|
||||
merged.computeVertexNormals()
|
||||
|
||||
// CSG drill — punches each bore through the merged geometry. Runs
|
||||
// last so the floor + collars are already in one mesh; each drill
|
||||
// cuts both at once, subtracted sequentially.
|
||||
if (placements.length > 0) {
|
||||
let workingBrush = new Brush(merged)
|
||||
prepareBrushForCSG(workingBrush)
|
||||
for (const p of placements) {
|
||||
const drill = buildOutletDrill(p, size)
|
||||
const drillBrush = new Brush(drill)
|
||||
prepareBrushForCSG(drillBrush)
|
||||
const next = csgEvaluator.evaluate(workingBrush, drillBrush, SUBTRACTION) as Brush
|
||||
// Free the previous step's intermediate result (but not `merged`,
|
||||
// which is disposed once below).
|
||||
if (workingBrush.geometry !== merged) workingBrush.geometry.dispose()
|
||||
workingBrush = next
|
||||
drill.dispose()
|
||||
}
|
||||
const cutGeometry = csgGeometry(workingBrush)
|
||||
merged.dispose()
|
||||
return cutGeometry
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// Remove the extrude's cross-section CAP triangles at a mitred end so two
|
||||
// gutters meeting at a corner don't leave coincident, z-fighting end
|
||||
// faces (the seam lines at the joint). A cap triangle is one whose three
|
||||
// vertices ALL lie on an end plane — z≈0 (right end) or z≈channelLen
|
||||
// (left end). Wall triangles always span the two planes, so this cleanly
|
||||
// separates caps from walls without depending on ExtrudeGeometry's
|
||||
// internal vertex order. Input is non-indexed (ExtrudeGeometry), so every
|
||||
// three positions is one triangle; we copy through the survivors and let
|
||||
// the caller recompute normals.
|
||||
function stripEndCaps(
|
||||
geo: THREE.BufferGeometry,
|
||||
channelLen: number,
|
||||
removeRight: boolean,
|
||||
removeLeft: boolean,
|
||||
): THREE.BufferGeometry {
|
||||
const src = geo.index ? geo.toNonIndexed() : geo
|
||||
const pos = src.attributes.position!
|
||||
const uv = src.attributes.uv
|
||||
const eps = 1e-4
|
||||
const keepPos: number[] = []
|
||||
const keepUv: number[] | null = uv ? [] : null
|
||||
const triCount = Math.floor(pos.count / 3)
|
||||
for (let t = 0; t < triCount; t++) {
|
||||
const a = t * 3
|
||||
const za = pos.getZ(a)
|
||||
const zb = pos.getZ(a + 1)
|
||||
const zc = pos.getZ(a + 2)
|
||||
const allRight = Math.abs(za) < eps && Math.abs(zb) < eps && Math.abs(zc) < eps
|
||||
const allLeft =
|
||||
Math.abs(za - channelLen) < eps &&
|
||||
Math.abs(zb - channelLen) < eps &&
|
||||
Math.abs(zc - channelLen) < eps
|
||||
if ((removeRight && allRight) || (removeLeft && allLeft)) continue
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const idx = a + k
|
||||
keepPos.push(pos.getX(idx), pos.getY(idx), pos.getZ(idx))
|
||||
if (keepUv && uv) keepUv.push(uv.getX(idx), uv.getY(idx))
|
||||
}
|
||||
}
|
||||
if (src !== geo) src.dispose()
|
||||
const out = new THREE.BufferGeometry()
|
||||
out.setAttribute('position', new THREE.Float32BufferAttribute(keepPos, 3))
|
||||
if (keepUv) out.setAttribute('uv', new THREE.Float32BufferAttribute(keepUv, 2))
|
||||
return out
|
||||
}
|
||||
|
||||
// Each cross-section below is authored as a single closed polygon that
|
||||
// traces the U-channel's MATERIAL — outer wall down → bottom → outer
|
||||
// wall up → rim across → inner wall down → inner bottom → inner wall
|
||||
// up → closing rim. The interior of the U (where rainwater sits) is
|
||||
// empty space, not a hole — so the extrude has an OPEN TOP, which is
|
||||
// what makes the geometry read as a gutter rather than a sealed box
|
||||
// with a tunnel through it.
|
||||
//
|
||||
// Cross-section authoring frame: X is the gutter's outward direction
|
||||
// (X=0 against the fascia, X=+w hanging outward); Y is vertical (Y=0
|
||||
// at the eave line, Y=-size at the bottom of the trough). After the
|
||||
// rotateY(-π/2) in the parent builder, +X maps to segment-outward (+Z)
|
||||
// and the extrude axis (length) maps to segment-+X.
|
||||
|
||||
function buildKStyleCross(size: number, t: number): THREE.Shape {
|
||||
const wBot = size * 0.8 // bottom outer width — narrower than the rim
|
||||
const wTop = size * 0.95
|
||||
const ogeeY = -size * 0.65 // S-curve inflection on the fascia
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
// Outer trace — top-back → down the back → across the bottom → up
|
||||
// the ogee fascia.
|
||||
shape.moveTo(0, 0)
|
||||
shape.lineTo(0, -size)
|
||||
shape.lineTo(wBot, -size)
|
||||
shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0)
|
||||
// Front rim (thin top of the front wall): step inward by `t`.
|
||||
shape.lineTo(wTop - t, 0)
|
||||
// Inner trace — back down the ogee, across the inner bottom, up the
|
||||
// inner back wall. Same bezier control points pushed inward by `t`.
|
||||
shape.bezierCurveTo(
|
||||
wTop - size * 0.15 - t,
|
||||
ogeeY * 0.4,
|
||||
wBot + size * 0.15 - t,
|
||||
ogeeY,
|
||||
wBot - t,
|
||||
-size + t,
|
||||
)
|
||||
shape.lineTo(t, -size + t)
|
||||
shape.lineTo(t, 0)
|
||||
// closePath draws the back rim (t, 0) → (0, 0) — the thin top of
|
||||
// the back wall, sealing the cross-section.
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
// Half-round trough — a semicircular cross-section with a smaller
|
||||
// concentric semicircle carved out. Single closed trace: outer half
|
||||
// from (0,0) sweeping down and back up to (2r, 0), front rim across by
|
||||
// `t`, inner half from (2r-t, 0) sweeping back to (t, 0), back rim
|
||||
// closes the loop.
|
||||
function buildHalfRoundCross(size: number, t: number): THREE.Shape {
|
||||
const r = size // radius == size: half-circle drops `size` below the eave
|
||||
const ri = r - t // inner radius
|
||||
const segs = 24
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(0, 0)
|
||||
// Outer semicircle, lower half (angle π → 2π). At i=0 we'd be at
|
||||
// (0,0) — already there from moveTo — so start at i=1.
|
||||
for (let i = 1; i <= segs; i++) {
|
||||
const angle = Math.PI + (Math.PI * i) / segs
|
||||
shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle))
|
||||
}
|
||||
// Front rim — step inward by `t` to start the inner trace.
|
||||
shape.lineTo(2 * r - t, 0)
|
||||
// Inner semicircle, traced BACK toward the back wall (angle 2π → π).
|
||||
for (let i = 1; i <= segs; i++) {
|
||||
const angle = 2 * Math.PI - (Math.PI * i) / segs
|
||||
shape.lineTo(r + ri * Math.cos(angle), ri * Math.sin(angle))
|
||||
}
|
||||
// closePath draws (t, 0) → (0, 0) — back rim.
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
// Square / rectangular box U-channel. Width equals size (deep-and-
|
||||
// narrow ratio reads as commercial). Traced as outer rect → front rim
|
||||
// → inner rect (reverse) → back rim.
|
||||
function buildBoxCross(size: number, t: number): THREE.Shape {
|
||||
const w = size
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(0, 0)
|
||||
shape.lineTo(0, -size)
|
||||
shape.lineTo(w, -size)
|
||||
shape.lineTo(w, 0)
|
||||
// Front rim.
|
||||
shape.lineTo(w - t, 0)
|
||||
// Inner rect, reversed so the polygon doesn't self-intersect.
|
||||
shape.lineTo(w - t, -size + t)
|
||||
shape.lineTo(t, -size + t)
|
||||
shape.lineTo(t, 0)
|
||||
// closePath draws the back rim (t, 0) → (0, 0).
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
// Solid-outer outlines used for the end-cap slices: same outer
|
||||
// boundary as the channel cross-sections above but without the inner
|
||||
// trough carved out, so the extruded slice is a solid plug that closes
|
||||
// the open end of the trough.
|
||||
|
||||
function buildKStyleOuterOnly(size: number): THREE.Shape {
|
||||
const wBot = size * 0.8
|
||||
const wTop = size * 0.95
|
||||
const ogeeY = -size * 0.65
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(0, 0)
|
||||
shape.lineTo(0, -size)
|
||||
shape.lineTo(wBot, -size)
|
||||
shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0)
|
||||
// closePath draws (wTop, 0) → (0, 0) — the cap's rim line across
|
||||
// the top of the gutter cross-section.
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function buildHalfRoundOuterOnly(size: number): THREE.Shape {
|
||||
const r = size
|
||||
const segs = 24
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(0, 0)
|
||||
for (let i = 1; i <= segs; i++) {
|
||||
const angle = Math.PI + (Math.PI * i) / segs
|
||||
shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle))
|
||||
}
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
function buildBoxOuterOnly(size: number): THREE.Shape {
|
||||
const w = size
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(0, 0)
|
||||
shape.lineTo(0, -size)
|
||||
shape.lineTo(w, -size)
|
||||
shape.lineTo(w, 0)
|
||||
shape.closePath()
|
||||
return shape
|
||||
}
|
||||
|
||||
// ─── Hangers ───────────────────────────────────────────────────────
|
||||
|
||||
// Strap dimensions — a residential hidden hanger reads as a flat band
|
||||
// roughly 25mm wide along the gutter, 3mm thick, sitting on the rim.
|
||||
const HANGER_BAR_LEN = 0.025
|
||||
const HANGER_BAR_THICKNESS = 0.003
|
||||
// Extra spread past the rim's outward extent — so the strap looks like
|
||||
// it "wraps over" both edges rather than ending flush.
|
||||
const HANGER_OVERHANG = 0.005
|
||||
// Distance from each gutter end where a strap is allowed to sit; keeps
|
||||
// straps from clashing with end caps and from looking pinned to the
|
||||
// very edge.
|
||||
const HANGER_END_MARGIN = 0.05
|
||||
|
||||
/** Outward Z extent of each profile, used to size the strap. */
|
||||
function profileRimWidth(profile: GutterNode['profile'], size: number): number {
|
||||
if (profile === 'half-round') return 2 * size
|
||||
if (profile === 'box') return size
|
||||
return size * 0.95 // k-style wTop
|
||||
}
|
||||
|
||||
function buildHangers(
|
||||
node: GutterNode,
|
||||
len: number,
|
||||
size: number,
|
||||
capLeftLen: number,
|
||||
capRightLen: number,
|
||||
mitres: GutterMitres,
|
||||
): THREE.BufferGeometry[] {
|
||||
const spacing = Math.max(0.2, node.hangerSpacing ?? 0.6)
|
||||
const profile = node.profile ?? 'k-style'
|
||||
const rimWidth = profileRimWidth(profile, size)
|
||||
const strapDepth = rimWidth + HANGER_OVERHANG * 2
|
||||
|
||||
// At an INNER (concave) corner the two gutters' troughs fold into the
|
||||
// same notch, so a strap sitting near that end overlaps the neighbour
|
||||
// gutter's end strap — the two perpendicular straps read as an X across
|
||||
// the corner. Pull the run's bound back by a full strap depth at a
|
||||
// concave end so the nearest strap clears the joint. OUTER (convex)
|
||||
// corners diverge outward and never overlap, so they keep the tight
|
||||
// margin (`mitre > 0` and non-mitred ends → no extra inset).
|
||||
const concaveInset = (mitre: number) => (mitre < 0 ? strapDepth : 0)
|
||||
|
||||
// Inset by margin AND any cap so straps don't punch into the cap slab,
|
||||
// plus the concave-corner clearance above.
|
||||
const leftBound = -len / 2 + capLeftLen + HANGER_END_MARGIN + concaveInset(mitres.left)
|
||||
const rightBound = len / 2 - capRightLen - HANGER_END_MARGIN - concaveInset(mitres.right)
|
||||
const usable = rightBound - leftBound
|
||||
if (usable <= 0) return []
|
||||
|
||||
// Span the usable run with straps at `spacing` between centers, plus
|
||||
// one at each end. Symmetric layout for any length, including very
|
||||
// short gutters where two straps land at the bounds.
|
||||
const count = Math.max(1, Math.floor(usable / spacing) + 1)
|
||||
const stride = count > 1 ? usable / (count - 1) : 0
|
||||
|
||||
const pieces: THREE.BufferGeometry[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const x = count > 1 ? leftBound + i * stride : (leftBound + rightBound) / 2
|
||||
// BoxGeometry is indexed; the channel + cap ExtrudeGeometries are
|
||||
// not. `mergeGeometries` rejects mixed-index sets, so flatten the
|
||||
// box to non-indexed before pushing.
|
||||
const bar = new THREE.BoxGeometry(
|
||||
HANGER_BAR_LEN,
|
||||
HANGER_BAR_THICKNESS,
|
||||
strapDepth,
|
||||
).toNonIndexed()
|
||||
// Center the bar at X = position, Y just above the rim line, Z
|
||||
// straddling 0 so the strap covers the full back-to-front span.
|
||||
bar.translate(x, HANGER_BAR_THICKNESS / 2 + 0.001, rimWidth / 2)
|
||||
pieces.push(bar)
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
// ─── Outlet ────────────────────────────────────────────────────────
|
||||
|
||||
// Radial subdivisions — 24 reads as smooth at typical outlet
|
||||
// diameters; lower and the 3″ tube starts looking faceted from below.
|
||||
const OUTLET_RADIAL_SEGMENTS = 24
|
||||
// Drill overshoot past the floor and past the stub bottom — keeps the
|
||||
// CSG cut planes from coinciding with merged-geometry surfaces
|
||||
// (coplanar cuts produce degenerate output in three-bvh-csg).
|
||||
const OUTLET_DRILL_OVERSHOOT = 0.01
|
||||
// Funnel lip at the drop outlet — flares this much wider than the collar
|
||||
// over this height, just below the trough floor. The bore drill cuts it
|
||||
// open along with the collar.
|
||||
const OUTLET_FLARE_SCALE = 1.6
|
||||
const OUTLET_FLARE_HEIGHT = 0.02
|
||||
|
||||
type OutletPlacement = {
|
||||
x: number
|
||||
z: number
|
||||
shape: OutletShape
|
||||
/** Bore cross-section (the drilled hole). */
|
||||
inner: OutletDims
|
||||
/** Collar cross-section (bore + wall) — the solid stub body. */
|
||||
outer: OutletDims
|
||||
}
|
||||
|
||||
/**
|
||||
* One placement per `node.outlets` entry that fits between the caps. The
|
||||
* shape follows the gutter profile (`outletShapeForProfile`): round
|
||||
* leader on half-round, rectangular on k-style / box. Each outlet's
|
||||
* `offset` (signed from center) is clamped inside the trough-floor span
|
||||
* using the OUTER along-length half-extent so the collar can't poke into
|
||||
* a cap. Outlets that can't fit at all are dropped.
|
||||
*/
|
||||
function resolveOutletPlacements(
|
||||
node: GutterNode,
|
||||
len: number,
|
||||
size: number,
|
||||
capLeftLen: number,
|
||||
capRightLen: number,
|
||||
): OutletPlacement[] {
|
||||
const outlets = node.outlets ?? []
|
||||
if (outlets.length === 0) return []
|
||||
|
||||
const shape = outletShapeForProfile(node.profile)
|
||||
const z = profileFloorMidZ(node.profile ?? 'k-style', size)
|
||||
const placements: OutletPlacement[] = []
|
||||
for (const outlet of outlets) {
|
||||
const inner = outletDims(shape, outlet.diameter ?? 0.07)
|
||||
const outer: OutletDims = {
|
||||
shape,
|
||||
halfX: inner.halfX + OUTLET_WALL_THICKNESS,
|
||||
halfZ: inner.halfZ + OUTLET_WALL_THICKNESS,
|
||||
}
|
||||
const minX = -len / 2 + capLeftLen + outer.halfX
|
||||
const maxX = len / 2 - capRightLen - outer.halfX
|
||||
if (maxX <= minX) continue
|
||||
const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0))
|
||||
placements.push({ x, z, shape, inner, outer })
|
||||
}
|
||||
return placements
|
||||
}
|
||||
|
||||
/** Cylinder (round) or box (rect) sized to `dims`, height `h` along Y. */
|
||||
function outletSolid(dims: OutletDims, h: number): THREE.BufferGeometry {
|
||||
if (dims.shape === 'round') {
|
||||
return new THREE.CylinderGeometry(
|
||||
dims.halfX,
|
||||
dims.halfX,
|
||||
h,
|
||||
OUTLET_RADIAL_SEGMENTS,
|
||||
).toNonIndexed()
|
||||
}
|
||||
return new THREE.BoxGeometry(2 * dims.halfX, h, 2 * dims.halfZ).toNonIndexed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Solid collar at the OUTER cross-section; the CSG drill hollows out the
|
||||
* bore and leaves a tube wall. Top sits flush with the gutter floor
|
||||
* (Y = −size). Flattened to match the ExtrudeGeometries.
|
||||
*/
|
||||
function buildOutletStub(p: OutletPlacement, size: number): THREE.BufferGeometry {
|
||||
const stub = outletSolid(p.outer, OUTLET_STUB_LENGTH)
|
||||
stub.translate(p.x, -size - OUTLET_STUB_LENGTH / 2, p.z)
|
||||
return stub
|
||||
}
|
||||
|
||||
/**
|
||||
* Flared funnel lip just below the trough floor — wide at the floor,
|
||||
* tapering to the collar below (round → a cone; rect → a stepped wider
|
||||
* lip). Sits in the bore drill's span so it gets cut open too.
|
||||
*/
|
||||
function buildOutletFunnel(p: OutletPlacement, size: number): THREE.BufferGeometry {
|
||||
const centerY = -size - OUTLET_FLARE_HEIGHT / 2
|
||||
let funnel: THREE.BufferGeometry
|
||||
if (p.shape === 'round') {
|
||||
funnel = new THREE.CylinderGeometry(
|
||||
p.outer.halfX * OUTLET_FLARE_SCALE,
|
||||
p.outer.halfX,
|
||||
OUTLET_FLARE_HEIGHT,
|
||||
OUTLET_RADIAL_SEGMENTS,
|
||||
).toNonIndexed()
|
||||
} else {
|
||||
funnel = new THREE.BoxGeometry(
|
||||
2 * p.outer.halfX * OUTLET_FLARE_SCALE,
|
||||
OUTLET_FLARE_HEIGHT,
|
||||
2 * p.outer.halfZ * OUTLET_FLARE_SCALE,
|
||||
).toNonIndexed()
|
||||
}
|
||||
funnel.translate(p.x, centerY, p.z)
|
||||
return funnel
|
||||
}
|
||||
|
||||
/**
|
||||
* Bore drill — spans from slightly above the trough floor to slightly
|
||||
* below the collar's bottom; the overshoots keep CSG cut planes from
|
||||
* sitting coplanar with merged-geometry faces.
|
||||
*/
|
||||
function buildOutletDrill(p: OutletPlacement, size: number): THREE.BufferGeometry {
|
||||
const top = -size + OUTLET_DRILL_OVERSHOOT
|
||||
const bottom = -size - OUTLET_STUB_LENGTH - OUTLET_DRILL_OVERSHOOT
|
||||
const height = top - bottom
|
||||
const centerY = (top + bottom) / 2
|
||||
|
||||
const drill = outletSolid(p.inner, height)
|
||||
drill.translate(p.x, centerY, p.z)
|
||||
return drill
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { gutterDefinition } from './definition'
|
||||
export { buildGutterGeometry } from './geometry'
|
||||
export { GutterNode } from './schema'
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { AnyNodeId, GutterNode, RoofSegmentNode, SceneApi } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Length-handle snap. When the user drags a gutter's ±X length handle
|
||||
* and the proposed endpoint lands within `SNAP_RADIUS` of the geometric
|
||||
* CORNER it would form with another gutter — the intersection of their
|
||||
* two length-axis lines — the dragged gutter's length is pulled so its
|
||||
* end lands exactly on that corner. The corner-mitre detector's match
|
||||
* window then fires reliably without a pixel-perfect drag.
|
||||
*
|
||||
* ONLY the dragged gutter moves. The corner is the axis crossing — a
|
||||
* fixed point in plan, independent of where the other gutter currently
|
||||
* ends — so each gutter snaps onto the SAME shared corner on its own as
|
||||
* it's dragged in, and the L meets there without ever reaching over to
|
||||
* reposition a gutter the user placed deliberately. (An earlier version
|
||||
* adjusted both gutters at once; that yanked an already-placed gutter
|
||||
* whenever its corner-mate was dragged, which is the bug this avoids.)
|
||||
*
|
||||
* Cross-segment: the search covers every gutter under the SAME ROOF
|
||||
* (not just the dragged gutter's segment-mates), and all the geometry
|
||||
* runs in the shared ROOF frame — each gutter's endpoints are lifted
|
||||
* out of its own segment-local frame by that segment's position +
|
||||
* Y-rotation. So an L-shaped plan whose two eaves live on different
|
||||
* roof segments snaps + mitres at the corner where the segments meet,
|
||||
* exactly like a same-segment hip corner (which is the degenerate case
|
||||
* where both gutters share one segment transform).
|
||||
*
|
||||
* Pure: no React, no THREE. Reads through SceneApi; returns the snapped
|
||||
* length for the caller to apply.
|
||||
*/
|
||||
|
||||
// 10 cm catch radius — wide enough that the user doesn't need pixel-
|
||||
// perfect dragging, narrow enough that unrelated gutters on the
|
||||
// opposite eave don't accidentally bind.
|
||||
const SNAP_RADIUS = 0.1
|
||||
const SNAP_RADIUS_SQ = SNAP_RADIUS * SNAP_RADIUS
|
||||
|
||||
// Cross product below this counts as parallel axes — no intersection,
|
||||
// fall back to snapping A onto B's current endpoint without modifying B.
|
||||
const AXIS_PARALLEL_EPSILON = 1e-3
|
||||
|
||||
// How far a corner-mate's own nearer endpoint may sit from the corner
|
||||
// and still bind the dragged gutter to it. The corner is the crossing
|
||||
// of the two axis LINES, which can lie well beyond where a gutter ends —
|
||||
// at an inner/concave corner the mate's end is a full eave overhang
|
||||
// short of the crossing, so the bound has to clear a generous overhang.
|
||||
// It also stops a far perpendicular gutter, whose infinite axis happens
|
||||
// to cross near the dragged end, from binding by coincidence: a real
|
||||
// corner-mate is within reach, an unrelated run is metres away.
|
||||
const CORNER_MATE_REACH = 1.5
|
||||
const CORNER_MATE_REACH_SQ = CORNER_MATE_REACH * CORNER_MATE_REACH
|
||||
|
||||
export type GutterLengthSnap = {
|
||||
/** Length to apply to the dragged gutter. */
|
||||
length: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @param initial gutter at drag start (rotation, length, position)
|
||||
* @param proposedLength length the linear-resize pipeline computed
|
||||
* @param sign +1 for the gutter-local +X end being dragged, −1 for −X
|
||||
* @param anchorX,anchorZ the held-fixed endpoint (opposite of `sign`)
|
||||
* @param armX,armZ gutter +X direction in segment frame (cos r, −sin r)
|
||||
* @param minLength floor — typically the descriptor's `min` value
|
||||
* @param sceneApi scene access for corner-mate lookup
|
||||
*/
|
||||
export function snapLengthToCorner(
|
||||
initial: GutterNode,
|
||||
proposedLength: number,
|
||||
sign: 1 | -1,
|
||||
anchorX: number,
|
||||
anchorZ: number,
|
||||
armX: number,
|
||||
armZ: number,
|
||||
minLength: number,
|
||||
sceneApi: SceneApi,
|
||||
): GutterLengthSnap {
|
||||
const segmentId = initial.roofSegmentId as AnyNodeId | undefined
|
||||
if (!segmentId) return { length: proposedLength }
|
||||
const seg = sceneApi.get<RoofSegmentNode>(segmentId)
|
||||
if (!seg) return { length: proposedLength }
|
||||
|
||||
// Everything runs in the ROOF frame so gutters on different segments
|
||||
// can meet. The dragged gutter's anchor/arm come in segment-local
|
||||
// (the caller computes them from `initial`); lift them into the roof
|
||||
// frame with the dragged segment's transform.
|
||||
const selfTf = segmentTransform(seg)
|
||||
const anchorR = applyTf(selfTf, anchorX, anchorZ)
|
||||
const armR = applyTfDir(selfTf, armX, armZ)
|
||||
const aAnchorX = anchorR.x
|
||||
const aAnchorZ = anchorR.z
|
||||
const aArmX = armR.x
|
||||
const aArmZ = armR.z
|
||||
|
||||
const proposedEndX = aAnchorX + sign * proposedLength * aArmX
|
||||
const proposedEndZ = aAnchorZ + sign * proposedLength * aArmZ
|
||||
|
||||
// Candidate gutters: every gutter under the SAME ROOF, each carrying
|
||||
// its own segment's roof-frame transform.
|
||||
type Cand = { gutter: GutterNode; tf: SegmentTransform }
|
||||
const candidates: Cand[] = []
|
||||
const roofId = seg.parentId as AnyNodeId | undefined
|
||||
const roof = roofId ? sceneApi.get(roofId) : undefined
|
||||
const roofChildren = (roof as { children?: readonly string[] } | undefined)?.children
|
||||
for (const sid of roofChildren ?? []) {
|
||||
const s = sceneApi.get<RoofSegmentNode>(sid as AnyNodeId)
|
||||
if (!s || s.type !== 'roof-segment') continue
|
||||
const tf = segmentTransform(s)
|
||||
for (const gid of s.children ?? []) {
|
||||
const g = sceneApi.get(gid as AnyNodeId)
|
||||
if (g?.type === 'gutter' && g.id !== initial.id) {
|
||||
candidates.push({ gutter: g as GutterNode, tf })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the corner-mate whose CORNER with the dragged gutter lands
|
||||
// closest to the proposed dragged endpoint. The corner is the
|
||||
// intersection of the two length-axis LINES (roof frame) — NOT the
|
||||
// proximity of the two endpoints. That distinction is what unlocks
|
||||
// inner/concave corners: there the two eave drip-lines meet out in the
|
||||
// notch, a full overhang away from where either gutter naturally ends,
|
||||
// so the old endpoint-to-endpoint catch never fired. Keying off the
|
||||
// axis crossing treats convex and concave identically. Parallel axes
|
||||
// (a straight collinear run) have no crossing, so there we fall back to
|
||||
// the mate's nearer endpoint (flush join). Only the dragged gutter's
|
||||
// own length is snapped to the corner — the mate is never moved.
|
||||
let bestTargetX = 0
|
||||
let bestTargetZ = 0
|
||||
let bestDistSq = SNAP_RADIUS_SQ
|
||||
let found = false
|
||||
|
||||
for (const { gutter: mateG, tf } of candidates) {
|
||||
const mateRot = mateG.rotation ?? 0
|
||||
const arm = applyTfDir(tf, Math.cos(mateRot), -Math.sin(mateRot))
|
||||
const mateHalf = mateG.length / 2
|
||||
const center = applyTf(tf, mateG.position[0], mateG.position[2])
|
||||
const plusX = center.x + arm.x * mateHalf
|
||||
const plusZ = center.z + arm.z * mateHalf
|
||||
const minusX = center.x - arm.x * mateHalf
|
||||
const minusZ = center.z - arm.z * mateHalf
|
||||
|
||||
// Corner target T: axis intersection when the runs cross, else the
|
||||
// mate's endpoint nearest the dragged end (collinear extension).
|
||||
const crossDirs = aArmX * arm.z - aArmZ * arm.x
|
||||
let targetX: number
|
||||
let targetZ: number
|
||||
if (Math.abs(crossDirs) < AXIS_PARALLEL_EPSILON) {
|
||||
const dPlus = (plusX - proposedEndX) ** 2 + (plusZ - proposedEndZ) ** 2
|
||||
const dMinus = (minusX - proposedEndX) ** 2 + (minusZ - proposedEndZ) ** 2
|
||||
if (dPlus <= dMinus) {
|
||||
targetX = plusX
|
||||
targetZ = plusZ
|
||||
} else {
|
||||
targetX = minusX
|
||||
targetZ = minusZ
|
||||
}
|
||||
} else {
|
||||
const dx = center.x - aAnchorX
|
||||
const dz = center.z - aAnchorZ
|
||||
const t = (dx * arm.z - dz * arm.x) / crossDirs
|
||||
targetX = aAnchorX + t * aArmX
|
||||
targetZ = aAnchorZ + t * aArmZ
|
||||
}
|
||||
|
||||
// Reject a mate whose own ends are nowhere near the crossing — its
|
||||
// infinite axis lines up by coincidence, it's not a real corner-mate.
|
||||
const dPlusT = (plusX - targetX) ** 2 + (plusZ - targetZ) ** 2
|
||||
const dMinusT = (minusX - targetX) ** 2 + (minusZ - targetZ) ** 2
|
||||
if (Math.min(dPlusT, dMinusT) > CORNER_MATE_REACH_SQ) continue
|
||||
|
||||
const score = (targetX - proposedEndX) ** 2 + (targetZ - proposedEndZ) ** 2
|
||||
if (score < bestDistSq) {
|
||||
bestDistSq = score
|
||||
bestTargetX = targetX
|
||||
bestTargetZ = targetZ
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) return { length: proposedLength }
|
||||
|
||||
// Snap the dragged gutter's own end onto the corner: project
|
||||
// (corner − anchor) onto its roof-frame axis. Length is frame-invariant
|
||||
// (a scalar along the run), so the projection is the same in roof or
|
||||
// segment frame — no need to map back. The mate is left untouched.
|
||||
const projected = sign * ((bestTargetX - aAnchorX) * aArmX + (bestTargetZ - aAnchorZ) * aArmZ)
|
||||
return { length: Math.max(minLength, projected) }
|
||||
}
|
||||
|
||||
// ─── Segment-frame ↔ roof-frame transform ────────────────────────────
|
||||
//
|
||||
// A segment places its children at `seg.position` rotated by
|
||||
// `seg.rotation` about +Y. THREE's rotation-y convention: a point
|
||||
// (x, z) maps to (x·cos + z·sin, −x·sin + z·cos). These helpers lift a
|
||||
// gutter's segment-local X/Z into the shared roof frame and back so two
|
||||
// gutters on different segments can be compared in one frame.
|
||||
|
||||
type SegmentTransform = { x: number; z: number; cos: number; sin: number }
|
||||
|
||||
function segmentTransform(seg: Pick<RoofSegmentNode, 'position' | 'rotation'>): SegmentTransform {
|
||||
const r = seg.rotation ?? 0
|
||||
return {
|
||||
x: seg.position?.[0] ?? 0,
|
||||
z: seg.position?.[2] ?? 0,
|
||||
cos: Math.cos(r),
|
||||
sin: Math.sin(r),
|
||||
}
|
||||
}
|
||||
|
||||
function applyTf(tf: SegmentTransform, x: number, z: number): { x: number; z: number } {
|
||||
return { x: tf.x + (x * tf.cos + z * tf.sin), z: tf.z + (-x * tf.sin + z * tf.cos) }
|
||||
}
|
||||
|
||||
function applyTfDir(tf: SegmentTransform, x: number, z: number): { x: number; z: number } {
|
||||
return { x: x * tf.cos + z * tf.sin, z: -x * tf.sin + z * tf.cos }
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GutterNode,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||
import GutterPreview from './preview'
|
||||
|
||||
type PreviewTarget = {
|
||||
roof: { position: [number, number, number]; rotation: number }
|
||||
segment: { position: [number, number, number]; rotation: number }
|
||||
snap: EaveSnap
|
||||
}
|
||||
|
||||
/**
|
||||
* Gutter move tool. Mirrors the ridge-vent move flow — ghost follows
|
||||
* the cursor over any roof segment, click commits the new position +
|
||||
* parent segment in one undoable step. The eave-snap math from the
|
||||
* placement tool runs again on the new segment so the gutter lands on
|
||||
* the correct side of the new ridge.
|
||||
*
|
||||
* On commit the gutter rotation may flip from 0 ↔ π if the user moves
|
||||
* it from the front eave to the back eave (or vice versa). The
|
||||
* pre-drag rotation is restored on cancel.
|
||||
*
|
||||
* Ghost transform: mirrors the GutterRenderer chain (roof → segment →
|
||||
* snap), so the cursor preview lands at the exact world coords the
|
||||
* commit will store. GutterPreview applies no internal rotation, so
|
||||
* the gutter's CURRENT `rotation` doesn't bleed into the new snap.
|
||||
*/
|
||||
export default function MoveGutterTool({ node }: { node: GutterNode }) {
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
const [target, setTarget] = useState<PreviewTarget | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const original = {
|
||||
position: [...node.position] as [number, number, number],
|
||||
rotation: node.rotation ?? 0,
|
||||
roofSegmentId: node.roofSegmentId,
|
||||
parentId: node.parentId,
|
||||
metadata: node.metadata,
|
||||
}
|
||||
const meta =
|
||||
typeof node.metadata === 'object' && node.metadata !== null
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
const gutterObj = sceneRegistry.nodes.get(node.id)
|
||||
if (gutterObj) gutterObj.visible = false
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const roof = event.node as RoofNode
|
||||
const hit = resolveRoofSegmentHit(
|
||||
roof,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
|
||||
// Same snap math as the placement tool — picking-up and putting-
|
||||
// down round-trip identically. roofType-aware: hip/flat picks
|
||||
// ±X or ±Z based on which slope the cursor is on; shed always
|
||||
// snaps to its low (+Z) eave; gable / gambrel / mansard / dutch
|
||||
// stay on ±Z.
|
||||
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
|
||||
|
||||
const sx = Math.round(snap.eaveX * 20) / 20
|
||||
const sz = Math.round(snap.eaveZ * 20) / 20
|
||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
setTarget({
|
||||
roof: {
|
||||
position: (roof.position ?? [0, 0, 0]) as [number, number, number],
|
||||
rotation: roof.rotation ?? 0,
|
||||
},
|
||||
segment: {
|
||||
position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number],
|
||||
rotation: hit.segment.rotation ?? 0,
|
||||
},
|
||||
snap,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onRoofClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const targetSegmentId = hit.segment.id as AnyNodeId
|
||||
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
|
||||
const st = useScene.getState()
|
||||
|
||||
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
if (prevSegmentId && prevSegmentId !== targetSegmentId) {
|
||||
const oldSeg = st.nodes[prevSegmentId] as RoofSegmentNode | undefined
|
||||
if (oldSeg) {
|
||||
st.updateNode(prevSegmentId, {
|
||||
children: (oldSeg.children ?? []).filter((id) => id !== node.id),
|
||||
})
|
||||
}
|
||||
const newSeg = st.nodes[targetSegmentId] as RoofSegmentNode | undefined
|
||||
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
|
||||
st.updateNode(targetSegmentId, {
|
||||
children: [...(newSeg.children ?? []), node.id],
|
||||
})
|
||||
}
|
||||
st.dirtyNodes.add(prevSegmentId)
|
||||
}
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
st.updateNode(node.id as AnyNodeId, {
|
||||
roofSegmentId: targetSegmentId,
|
||||
parentId: targetSegmentId,
|
||||
position: [snap.eaveX, snap.eaveY, snap.eaveZ],
|
||||
rotation: snap.rotation,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
})
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
st.dirtyNodes.add(targetSegmentId)
|
||||
st.dirtyNodes.add(node.id as AnyNodeId)
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (isNew) {
|
||||
const parentId = original.roofSegmentId as AnyNodeId | undefined
|
||||
if (parentId) {
|
||||
const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined
|
||||
if (parent) {
|
||||
useScene.getState().updateNode(parentId, {
|
||||
children: (parent.children ?? []).filter((id) => id !== node.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
return
|
||||
}
|
||||
|
||||
useScene.getState().updateNode(node.id as AnyNodeId, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
roofSegmentId: original.roofSegmentId as AnyNodeId | undefined,
|
||||
parentId: original.parentId as AnyNodeId | undefined,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.roofSegmentId) {
|
||||
useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId)
|
||||
}
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updatePreview)
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onRoofClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onRoofClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
|
||||
const obj = sceneRegistry.nodes.get(node.id)
|
||||
if (obj) obj.visible = true
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
if (!target) return null
|
||||
|
||||
return (
|
||||
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
||||
<group position={target.segment.position} rotation-y={target.segment.rotation}>
|
||||
<group
|
||||
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
|
||||
rotation-y={target.snap.rotation}
|
||||
>
|
||||
<GutterPreview node={node} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { GutterNode, GutterOutlet } from '@pascal-app/core'
|
||||
import {
|
||||
OUTLET_WALL_THICKNESS,
|
||||
type OutletShape,
|
||||
outletDims,
|
||||
outletShapeForProfile,
|
||||
profileFloorMidZ,
|
||||
} from './profile-geometry'
|
||||
|
||||
/**
|
||||
* Outlet position lookup — used by the downspout (renderer / tool /
|
||||
* routing) to mount a pipe at one of the gutter's outlets without
|
||||
* walking the gutter's geometry pipeline.
|
||||
*
|
||||
* Returns the outlet's center in GUTTER-MESH-LOCAL frame (i.e. after
|
||||
* the gutter's own `position` + `rotation` have already been applied
|
||||
* by the renderer chain): X is along the gutter length, Y is the
|
||||
* gutter's vertical extent (−size, the trough floor), Z is outward
|
||||
* (the profile-dependent floor midpoint).
|
||||
*
|
||||
* The clamp mirrors the geometry's `resolveOutletPlacements` so the
|
||||
* lookup and the drilled hole agree on X. Ignores mitres — when a
|
||||
* gutter end is mitred its cap collapses, which shifts the clamp bound
|
||||
* by ≤ 6 mm; the drift is below what reads visually, and the gutter's
|
||||
* own CSG drill still cuts in the exact spot since it sees the full
|
||||
* mitre context.
|
||||
*/
|
||||
|
||||
export type GutterOutletPlacement = {
|
||||
/** Gutter-mesh-local X — along the length axis, signed from center. */
|
||||
x: number
|
||||
/** Gutter-mesh-local Y — the trough floor at −size. */
|
||||
y: number
|
||||
/** Gutter-mesh-local Z — profile-dependent floor midpoint. */
|
||||
z: number
|
||||
/** Nominal bore radius (= halfX); `bore * 2` is the outlet diameter. */
|
||||
bore: number
|
||||
/** Outlet cross-section — round on half-round, rect on k-style / box. */
|
||||
shape: OutletShape
|
||||
/** Bore half-extent along the gutter length (X) — the pipe nests just inside this. */
|
||||
innerHalfX: number
|
||||
/** Bore half-extent outward (Z) — the pipe nests just inside this. */
|
||||
innerHalfZ: number
|
||||
}
|
||||
|
||||
function placeOutlet(
|
||||
gutter: GutterNode,
|
||||
outlet: GutterOutlet,
|
||||
len: number,
|
||||
size: number,
|
||||
t: number,
|
||||
): GutterOutletPlacement | null {
|
||||
const shape = outletShapeForProfile(gutter.profile)
|
||||
const inner = outletDims(shape, outlet.diameter ?? 0.07)
|
||||
const outerHalfX = inner.halfX + OUTLET_WALL_THICKNESS
|
||||
|
||||
// Default-cap reservation — no mitre awareness here; see header note.
|
||||
const capLeftLen = (gutter.endCapLeft ?? true) ? t : 0
|
||||
const capRightLen = (gutter.endCapRight ?? true) ? t : 0
|
||||
|
||||
const minX = -len / 2 + capLeftLen + outerHalfX
|
||||
const maxX = len / 2 - capRightLen - outerHalfX
|
||||
if (maxX <= minX) return null
|
||||
const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0))
|
||||
|
||||
return {
|
||||
x,
|
||||
y: -size,
|
||||
z: profileFloorMidZ(gutter.profile ?? 'k-style', size),
|
||||
bore: inner.halfX,
|
||||
shape,
|
||||
innerHalfX: inner.halfX,
|
||||
innerHalfZ: inner.halfZ,
|
||||
}
|
||||
}
|
||||
|
||||
function gutterDims(gutter: GutterNode): { len: number; size: number; t: number } {
|
||||
const len = Math.max(0.05, gutter.length)
|
||||
const size = Math.max(0.04, gutter.size)
|
||||
const t = Math.min(Math.max(0.001, gutter.thickness), size * 0.4)
|
||||
return { len, size, t }
|
||||
}
|
||||
|
||||
/** Placement of the gutter's outlet with the given id, or null if absent / doesn't fit. */
|
||||
export function resolveGutterOutletById(
|
||||
gutter: GutterNode,
|
||||
outletId: string | undefined,
|
||||
): GutterOutletPlacement | null {
|
||||
if (!outletId) return null
|
||||
const outlet = (gutter.outlets ?? []).find((o) => o.id === outletId)
|
||||
if (!outlet) return null
|
||||
const { len, size, t } = gutterDims(gutter)
|
||||
return placeOutlet(gutter, outlet, len, size, t)
|
||||
}
|
||||
|
||||
/** Placements for every fitting outlet, tagged with its id. */
|
||||
export function resolveGutterOutlets(
|
||||
gutter: GutterNode,
|
||||
): Array<GutterOutletPlacement & { id: string }> {
|
||||
const { len, size, t } = gutterDims(gutter)
|
||||
const out: Array<GutterOutletPlacement & { id: string }> = []
|
||||
for (const outlet of gutter.outlets ?? []) {
|
||||
const p = placeOutlet(gutter, outlet, len, size, t)
|
||||
if (p) out.push({ ...p, id: outlet.id })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { GutterNode } from './schema'
|
||||
|
||||
export const gutterParametrics: ParametricDescriptor<GutterNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Profile',
|
||||
fields: [
|
||||
{
|
||||
key: 'profile',
|
||||
kind: 'enum',
|
||||
options: ['k-style', 'half-round', 'box'],
|
||||
display: 'segmented',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Dimensions',
|
||||
fields: [
|
||||
{ key: 'length', kind: 'number', unit: 'm', min: 0.2, max: 12, step: 0.05 },
|
||||
{ key: 'size', kind: 'number', unit: 'm', min: 0.05, max: 0.3, step: 0.005 },
|
||||
{
|
||||
key: 'thickness',
|
||||
kind: 'number',
|
||||
unit: 'm',
|
||||
min: 0.001,
|
||||
max: 0.02,
|
||||
step: 0.001,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'End caps',
|
||||
fields: [
|
||||
{ key: 'endCapLeft', kind: 'boolean' },
|
||||
{ key: 'endCapRight', kind: 'boolean' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Hangers',
|
||||
fields: [
|
||||
{
|
||||
key: 'hangerStyle',
|
||||
kind: 'enum',
|
||||
options: ['strap', 'none'],
|
||||
display: 'segmented',
|
||||
},
|
||||
{
|
||||
key: 'hangerSpacing',
|
||||
kind: 'number',
|
||||
unit: 'm',
|
||||
min: 0.2,
|
||||
max: 2.0,
|
||||
step: 0.05,
|
||||
visibleIf: (n) => (n.hangerStyle ?? 'strap') !== 'none',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// Lazy-loaded section that lists every downspout attached to this
|
||||
// gutter and offers an Add button at the bottom. Outlets are created
|
||||
// and removed through this panel (and the downspout placement tool) —
|
||||
// one outlet per downspout — so there's no separate outlet field group.
|
||||
trailingSection: () => import('./downspouts-panel'),
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { buildGutterGeometry } from './geometry'
|
||||
import type { GutterNode } from './schema'
|
||||
|
||||
/**
|
||||
* Translucent ghost of a gutter — built from the same `buildGutterGeometry`
|
||||
* the renderer commits, so the shape on screen during placement is the
|
||||
* shape that lands on click.
|
||||
*
|
||||
* No internal transform wrapper. Callers (placement tool + move tool)
|
||||
* mirror the GutterRenderer's transform chain around this component
|
||||
* (roof → segment → snap), so the ghost shares one bulletproof chain
|
||||
* with the committed mesh instead of a flattened-yaw shortcut that can
|
||||
* drift in edge cases.
|
||||
*
|
||||
* FrontSide matches the renderer; DoubleSide would render the inside
|
||||
* of the trough walls and visually thicken the ghost relative to the
|
||||
* placed gutter.
|
||||
*/
|
||||
const GutterPreview = ({ node }: { node: GutterNode }) => {
|
||||
const geometry = useMemo(
|
||||
() => buildGutterGeometry(node),
|
||||
[
|
||||
node.length,
|
||||
node.size,
|
||||
node.thickness,
|
||||
node.profile,
|
||||
node.endCapLeft,
|
||||
node.endCapRight,
|
||||
node.hangerStyle,
|
||||
node.hangerSpacing,
|
||||
JSON.stringify(node.outlets),
|
||||
],
|
||||
)
|
||||
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0xff_ff_ff,
|
||||
emissiveIntensity: 0.12,
|
||||
roughness: 0.7,
|
||||
metalness: 0.2,
|
||||
transparent: true,
|
||||
opacity: 0.55,
|
||||
depthWrite: false,
|
||||
side: THREE.FrontSide,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose()
|
||||
edgesGeometry.dispose()
|
||||
material.dispose()
|
||||
},
|
||||
[geometry, edgesGeometry, material],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<mesh
|
||||
geometry={geometry}
|
||||
material={material}
|
||||
// See box-vent preview note — never let the preview swallow
|
||||
// roof events meant for the placement tool's hit-tester.
|
||||
raycast={() => {}}
|
||||
/>
|
||||
<lineSegments geometry={edgesGeometry} renderOrder={1000}>
|
||||
<lineBasicMaterial color={0x6c_a3_ff} depthTest={false} opacity={0.9} transparent />
|
||||
</lineSegments>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default GutterPreview
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { GutterNode } from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Shared outlet/profile geometry constants + math used by the gutter
|
||||
* mesh builder, the outlet lookup the downspout mounts against, and the
|
||||
* downspout's own routing. Kept in one place so the trough-floor probe
|
||||
* and the collar dimensions can't drift between the three call sites
|
||||
* (they did before this file — `profileFloorMidZ` was copied verbatim
|
||||
* into both `geometry.ts` and `outlet-lookup.ts`).
|
||||
*/
|
||||
|
||||
// Wall thickness of the drop-tube collar — 3 mm matches typical
|
||||
// residential gauge. After the CSG drill the stub becomes a tube with
|
||||
// outer radius = bore + wall and inner radius = bore.
|
||||
export const OUTLET_WALL_THICKNESS = 0.003
|
||||
|
||||
// Collar length — how far the drop-tube stub hangs below the trough
|
||||
// floor. 6 cm reads as "drop outlet" without poking conspicuously far
|
||||
// below the eave; the downspout slip-fits up into this collar.
|
||||
export const OUTLET_STUB_LENGTH = 0.06
|
||||
|
||||
/**
|
||||
* Z (outward) coordinate of the trough floor's midpoint per profile —
|
||||
* where a drop outlet drills through. k-style bottom is `wBot = 0.8 ·
|
||||
* size` wide so its midpoint sits at `0.4 · size`; box bottom is `size`
|
||||
* wide → `size / 2`; half-round's lowest point is the centre of the
|
||||
* semicircle at Z = r = size.
|
||||
*/
|
||||
export function profileFloorMidZ(profile: GutterNode['profile'], size: number): number {
|
||||
if (profile === 'half-round') return size
|
||||
if (profile === 'box') return size / 2
|
||||
return size * 0.4
|
||||
}
|
||||
|
||||
// ─── Outlet cross-section shape ──────────────────────────────────────
|
||||
|
||||
export type OutletShape = 'round' | 'rect'
|
||||
|
||||
/**
|
||||
* Which cross-section a gutter's drop outlet (and the downspout that
|
||||
* plugs into it) takes. Half-round gutters use a round leader; the
|
||||
* flat-bottomed profiles (k-style, box) use a rectangular one — matching
|
||||
* real residential hardware (round leaders on half-round, 2×3 / 3×4
|
||||
* rectangular leaders on k-style / commercial box).
|
||||
*/
|
||||
export function outletShapeForProfile(profile: GutterNode['profile']): OutletShape {
|
||||
return (profile ?? 'k-style') === 'half-round' ? 'round' : 'rect'
|
||||
}
|
||||
|
||||
// Outward (Z) depth of a rectangular outlet as a fraction of its
|
||||
// along-length (X) width — a 2×3 leader is ~0.66; 0.7 reads cleanly and
|
||||
// still fits inside the k-style trough floor.
|
||||
export const RECT_OUTLET_DEPTH_RATIO = 0.7
|
||||
|
||||
export type OutletDims = {
|
||||
shape: OutletShape
|
||||
/** Half-extent along the gutter length (X). Round: = radius. */
|
||||
halfX: number
|
||||
/** Half-extent outward (Z). Round: = radius. */
|
||||
halfZ: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-section half-extents for a `nominalDiameter`-sized outlet of the
|
||||
* given shape. Round → a circle of that diameter (halfX = halfZ =
|
||||
* radius); rect → that diameter wide along the run, `RECT_OUTLET_DEPTH_
|
||||
* RATIO` as deep outward.
|
||||
*/
|
||||
export function outletDims(shape: OutletShape, nominalDiameter: number): OutletDims {
|
||||
const half = Math.max(0.01, nominalDiameter / 2)
|
||||
if (shape === 'round') return { shape, halfX: half, halfZ: half }
|
||||
return { shape, halfX: half, halfZ: half * RECT_OUTLET_DEPTH_RATIO }
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type GutterNode,
|
||||
type RoofSegmentNode,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
createSurfaceRoleMaterial,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner-mitre'
|
||||
import { computeSharedEaveY } from './eave-align'
|
||||
import { computeEaveY } from './eave-snap'
|
||||
import { buildGutterGeometry } from './geometry'
|
||||
|
||||
const defaultMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 0.7,
|
||||
metalness: 0.25,
|
||||
})
|
||||
|
||||
/**
|
||||
* Gutter renderer. Mounts at the eave of the host roof-segment — the
|
||||
* gutter hangs level off the eave line (gravity wins; no slope tilt).
|
||||
* Transform stack:
|
||||
*
|
||||
* segment.position → segment.rotation (Y) → gutter.position
|
||||
* → gutter.rotation (Y) → mesh
|
||||
*
|
||||
* The registered ref sits on the inner group that applies position +
|
||||
* rotation, so `NodeArrowHandles` reads gutter-mesh-local coords for
|
||||
* its chevron placements (same pattern as ridge-vent).
|
||||
*
|
||||
* `useLiveNodeOverrides` merges in-flight handle drags onto the store
|
||||
* node so the mesh tracks the drag without flushing zustand each
|
||||
* frame.
|
||||
*/
|
||||
const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
useRegistry(storeNode.id, 'gutter', ref)
|
||||
const handlers = useNodeEvents(storeNode, 'gutter')
|
||||
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)
|
||||
|
||||
const overrides = useLiveNodeOverrides(
|
||||
(s) => s.get(storeNode.id as AnyNodeId) as Partial<GutterNode> | undefined,
|
||||
)
|
||||
const node: GutterNode = overrides ? ({ ...storeNode, ...overrides } as GutterNode) : storeNode
|
||||
|
||||
const segment = useScene((state) =>
|
||||
node.roofSegmentId
|
||||
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
// While the user is dragging the segment's wall-height / overhang /
|
||||
// pitch handle, the drag pipeline writes to useLiveNodeOverrides
|
||||
// instead of the scene store — the scene entry above stays at the
|
||||
// pre-drag value until pointer-up. Subscribing to the segment's live
|
||||
// overrides too lets the gutter's `computeEaveY` see the in-flight
|
||||
// height and slide up/down on every frame of the drag.
|
||||
const segmentOverrides = useLiveNodeOverrides((s) =>
|
||||
node.roofSegmentId
|
||||
? (s.get(node.roofSegmentId as AnyNodeId) as Partial<RoofSegmentNode> | undefined)
|
||||
: undefined,
|
||||
)
|
||||
const effectiveSegment: RoofSegmentNode | undefined = segment
|
||||
? segmentOverrides
|
||||
? ({ ...segment, ...segmentOverrides } as RoofSegmentNode)
|
||||
: segment
|
||||
: undefined
|
||||
|
||||
// Corner-mitre inputs: every other gutter under the SAME ROOF, plus
|
||||
// the roof's segments (for the frame lift). A flat array of node refs
|
||||
// keeps `useShallow` stable — it only re-renders when one of those
|
||||
// nodes actually changes. Cross-segment so gutters on different
|
||||
// segments mitre where their segments meet (the mitres useMemo pairs
|
||||
// each gutter back to its segment).
|
||||
const mitreNodes = useScene(
|
||||
useShallow((state) => {
|
||||
const segmentId = node.roofSegmentId as AnyNodeId | undefined
|
||||
const seg = segmentId ? (state.nodes[segmentId] as RoofSegmentNode | undefined) : undefined
|
||||
const roofId = seg?.parentId as AnyNodeId | undefined
|
||||
const roof = roofId
|
||||
? (state.nodes[roofId] as { children?: readonly string[] } | undefined)
|
||||
: undefined
|
||||
if (!roof) return [] as (GutterNode | RoofSegmentNode)[]
|
||||
const out: (GutterNode | RoofSegmentNode)[] = []
|
||||
for (const sid of roof.children ?? []) {
|
||||
const s = state.nodes[sid as AnyNodeId]
|
||||
if (s?.type !== 'roof-segment') continue
|
||||
out.push(s as RoofSegmentNode)
|
||||
for (const gid of (s as RoofSegmentNode).children ?? []) {
|
||||
const g = state.nodes[gid as AnyNodeId]
|
||||
if (g?.type === 'gutter' && g.id !== storeNode.id) out.push(g as GutterNode)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}),
|
||||
)
|
||||
|
||||
// Mitres AND the run's shared eave height come from the same sibling
|
||||
// walk: both key off which gutters meet at corners. `siblings` carries
|
||||
// the FULL host segment (the alignment needs wallHeight / overhang /
|
||||
// pitch / roofType to derive each eave Y), which is a superset of what
|
||||
// the mitre detector reads — so one list feeds both.
|
||||
const { mitres, sharedEaveY } = useMemo(() => {
|
||||
if (!effectiveSegment) return { mitres: NO_MITRES, sharedEaveY: undefined }
|
||||
const segById = new Map<string, RoofSegmentNode>()
|
||||
for (const n of mitreNodes) {
|
||||
if (n.type === 'roof-segment') segById.set(n.id, n as RoofSegmentNode)
|
||||
}
|
||||
const siblings: GutterWithSegment[] = []
|
||||
for (const n of mitreNodes) {
|
||||
if (n.type !== 'gutter') continue
|
||||
const g = n as GutterNode
|
||||
const seg = g.roofSegmentId ? segById.get(g.roofSegmentId) : undefined
|
||||
if (seg) siblings.push({ gutter: g, segment: seg })
|
||||
}
|
||||
return {
|
||||
mitres: computeGutterMitres(node, effectiveSegment, siblings),
|
||||
// `siblings` is typed for the mitre detector (position/rotation),
|
||||
// but the segment objects are the full RoofSegmentNodes from
|
||||
// `mitreNodes`, so `computeSharedEaveY` gets the eave-Y inputs it
|
||||
// needs at runtime.
|
||||
sharedEaveY: computeSharedEaveY(
|
||||
node,
|
||||
effectiveSegment,
|
||||
siblings as unknown as Parameters<typeof computeSharedEaveY>[2],
|
||||
),
|
||||
}
|
||||
}, [
|
||||
node.position[0],
|
||||
node.position[1],
|
||||
node.position[2],
|
||||
node.rotation,
|
||||
node.length,
|
||||
effectiveSegment?.position?.[0],
|
||||
effectiveSegment?.position?.[2],
|
||||
effectiveSegment?.rotation,
|
||||
effectiveSegment?.wallHeight,
|
||||
effectiveSegment?.overhang,
|
||||
effectiveSegment?.pitch,
|
||||
effectiveSegment?.roofType,
|
||||
mitreNodes,
|
||||
])
|
||||
|
||||
const geometry = useMemo(
|
||||
() => buildGutterGeometry(node, mitres),
|
||||
[
|
||||
node.length,
|
||||
node.size,
|
||||
node.thickness,
|
||||
node.profile,
|
||||
node.endCapLeft,
|
||||
node.endCapRight,
|
||||
node.hangerStyle,
|
||||
node.hangerSpacing,
|
||||
// Value-compare the outlets array so the CSG drills only rebuild
|
||||
// when an outlet's offset / diameter changes or one is added.
|
||||
JSON.stringify(node.outlets),
|
||||
mitres.left,
|
||||
mitres.right,
|
||||
],
|
||||
)
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
// Paint surface: explicit material wins, then preset, then the cached
|
||||
// default. FrontSide everywhere — DoubleSide on any NodeMaterial inside
|
||||
// the MRT scenePass compiles a back-face shader variant that doesn't
|
||||
// declare outputs for every MRT target and poisons the render context
|
||||
// (see `materials.ts` line 77, and the glazing FrontSide fix in
|
||||
// 9400f1c5). The U-channel cross-section in `geometry.ts` is traced as
|
||||
// a single closed polygon around the material — both the exterior shell
|
||||
// and the interior trough walls are part of the same outward-wound
|
||||
// boundary, so ExtrudeGeometry produces outward-facing normals on every
|
||||
// visible face. FrontSide is therefore sufficient and DoubleSide is not
|
||||
// needed.
|
||||
const material = useMemo(() => {
|
||||
if (!textures || (!node.material && !node.materialPreset)) {
|
||||
return 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 (!segment || !effectiveSegment) return null
|
||||
|
||||
// `node.position` is segment-local — the placement tool resolves the
|
||||
// eave click via `segObj.worldToLocal`. The renderer mounts under
|
||||
// `roof-elements` (only the roof transform inherited), so we
|
||||
// re-apply the segment's roof-local transform here. Mirrors the
|
||||
// ridge-vent / box-vent pattern; without this gutters on rotated
|
||||
// segments would land on the first segment instead.
|
||||
//
|
||||
// Y is derived live from `effectiveSegment` (scene + drag overrides)
|
||||
// instead of trusting `node.position[1]` — so changing wallHeight,
|
||||
// overhang, or pitch on the parent segment moves the gutter on the
|
||||
// very next frame, including while a segment-height handle is
|
||||
// mid-drag. Matches the chimney/box-vent pattern of pulling host-
|
||||
// segment geometry at draw time rather than caching it at placement.
|
||||
const segPos = segment.position ?? [0, 0, 0]
|
||||
const segRotY = segment.rotation ?? 0
|
||||
// Prefer the connected run's shared height (aligns gutters meeting at
|
||||
// a corner whose segments derive different eave Ys); fall back to this
|
||||
// segment's own eave Y for an isolated gutter.
|
||||
const liveEaveY = sharedEaveY ?? computeEaveY(effectiveSegment)
|
||||
|
||||
return (
|
||||
<group position={segPos} rotation-y={segRotY}>
|
||||
<group
|
||||
position={[node.position[0] ?? 0, liveEaveY, node.position[2] ?? 0]}
|
||||
ref={ref}
|
||||
rotation-y={node.rotation ?? 0}
|
||||
visible={node.visible}
|
||||
>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={geometry}
|
||||
material={material}
|
||||
name="gutter-surface"
|
||||
receiveShadow
|
||||
{...handlers}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default GutterRenderer
|
||||
@@ -0,0 +1,3 @@
|
||||
// Schema lives in core (referenced by the AnyNode union). Re-export so
|
||||
// every gutter-related import stays inside @pascal-app/nodes/gutter.
|
||||
export { GutterNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
GutterNode,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { gutterDefinition } from './definition'
|
||||
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
|
||||
import GutterPreview from './preview'
|
||||
|
||||
type PreviewTarget = {
|
||||
roof: { position: [number, number, number]; rotation: number }
|
||||
segment: { position: [number, number, number]; rotation: number }
|
||||
snap: EaveSnap
|
||||
}
|
||||
|
||||
/**
|
||||
* Gutter placement tool. Cursor preview snaps to the OUTER eave — the
|
||||
* drip edge of the roof, NOT the wall line. The eave sits at
|
||||
* `Z = ±(depth/2 + overhang)` in segment-local frame; the gutter
|
||||
* mounts against the fascia there, hanging outward from the building.
|
||||
*
|
||||
* Which eave: `eave-snap.ts` picks the side closest to the cursor
|
||||
* (roof-type aware — 4-way for hip/flat, low side only for shed, ±Z
|
||||
* for the rest) and returns the segment-local snap pose. The same
|
||||
* snap drives the ghost AND the commit, so picking-up + putting-down
|
||||
* land at identical world coordinates.
|
||||
*
|
||||
* Ghost transform: we mount the GutterPreview under the exact same
|
||||
* chain the GutterRenderer applies — roof.position + roof.rotation →
|
||||
* segment.position + segment.rotation → snap.eave + snap.rotation.
|
||||
* No `worldToBuildingLocal` + `previewYaw`-sum shortcut: that
|
||||
* collapses three Y rotations into one scalar and converts world
|
||||
* coords back into building-local, which is mathematically
|
||||
* equivalent for pure-Y stacks but drifts under any future non-Y
|
||||
* roof/segment transform. Sharing the renderer's chain means the
|
||||
* ghost and the placed mesh are guaranteed pixel-identical.
|
||||
*/
|
||||
const GutterTool = () => {
|
||||
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
const [target, setTarget] = useState<PreviewTarget | null>(null)
|
||||
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
GutterNode.parse({
|
||||
...gutterDefinition.defaults(),
|
||||
name: 'Gutter',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) return
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const roof = event.node as RoofNode
|
||||
const hit = resolveRoofSegmentHit(
|
||||
roof,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
|
||||
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
|
||||
|
||||
// Grid-snap chime fires when the segment-local snap moves to a
|
||||
// new 5 cm cell along the eave — keeps SFX in lockstep with what
|
||||
// the commit will actually store.
|
||||
const sx = Math.round(snap.eaveX * 20) / 20
|
||||
const sz = Math.round(snap.eaveZ * 20) / 20
|
||||
const prev = lastSnapRef.current
|
||||
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
setTarget({
|
||||
roof: {
|
||||
position: (roof.position ?? [0, 0, 0]) as [number, number, number],
|
||||
rotation: roof.rotation ?? 0,
|
||||
},
|
||||
segment: {
|
||||
position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number],
|
||||
rotation: hit.segment.rotation ?? 0,
|
||||
},
|
||||
snap,
|
||||
})
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onClick = (event: RoofEvent) => {
|
||||
const hit = resolveRoofSegmentHit(
|
||||
event.node as RoofNode,
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
if (!hit) return
|
||||
const state = useScene.getState()
|
||||
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
|
||||
|
||||
const gutter = GutterNode.parse({
|
||||
...gutterDefinition.defaults(),
|
||||
name: 'Gutter',
|
||||
roofSegmentId: hit.segment.id,
|
||||
// (X, Y, Z) all come from the eave snap — on ±Z eaves X stays
|
||||
// free along the cursor; on ±X eaves Z stays free instead.
|
||||
// Rotation orients the gutter's outward axis away from the
|
||||
// building on whichever side the click landed.
|
||||
position: [snap.eaveX, snap.eaveY, snap.eaveZ],
|
||||
rotation: snap.rotation,
|
||||
})
|
||||
state.createNode(gutter, hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [gutter.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
emitter.on('roof:move', updatePreview)
|
||||
emitter.on('roof:enter', updatePreview)
|
||||
emitter.on('roof:click', onClick)
|
||||
|
||||
return () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onClick)
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !target) return null
|
||||
|
||||
return (
|
||||
<group position={target.roof.position} rotation-y={target.roof.rotation}>
|
||||
<group position={target.segment.position} rotation-y={target.segment.rotation}>
|
||||
<group
|
||||
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
|
||||
rotation-y={target.snap.rotation}
|
||||
>
|
||||
<GutterPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default GutterTool
|
||||
Reference in New Issue
Block a user