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
@@ -7,11 +7,15 @@ import type {
|
||||
CeilingNode,
|
||||
ChimneyNode,
|
||||
ColumnNode,
|
||||
CupolaNode,
|
||||
DoorNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
ElevatorNode,
|
||||
EyebrowVentNode,
|
||||
FenceNode,
|
||||
GuideNode,
|
||||
GutterNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
RidgeVentNode,
|
||||
@@ -26,6 +30,7 @@ import type {
|
||||
SpawnNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
TurbineVentNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
ZoneNode,
|
||||
@@ -63,6 +68,11 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
|
||||
object: Object3D
|
||||
stopPropagation: () => void
|
||||
nativeEvent: ThreeEvent<PointerEvent>
|
||||
// Set when the click originated from a dedicated selection affordance
|
||||
// (e.g. a ceiling corner handle) rather than the node's own surface
|
||||
// mesh. Lets selection logic accept handle clicks while ignoring clicks
|
||||
// on the body so they fall through to whatever sits below.
|
||||
viaHandle?: boolean
|
||||
}
|
||||
|
||||
export type WallEvent = NodeEvent<WallNode>
|
||||
@@ -88,10 +98,15 @@ export type ScanEvent = NodeEvent<ScanNode>
|
||||
export type GuideEvent = NodeEvent<GuideNode>
|
||||
export type BoxVentEvent = NodeEvent<BoxVentNode>
|
||||
export type RidgeVentEvent = NodeEvent<RidgeVentNode>
|
||||
export type TurbineVentEvent = NodeEvent<TurbineVentNode>
|
||||
export type CupolaEvent = NodeEvent<CupolaNode>
|
||||
export type EyebrowVentEvent = NodeEvent<EyebrowVentNode>
|
||||
export type GutterEvent = NodeEvent<GutterNode>
|
||||
export type ChimneyEvent = NodeEvent<ChimneyNode>
|
||||
export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
||||
export type SkylightEvent = NodeEvent<SkylightNode>
|
||||
export type DormerEvent = NodeEvent<DormerNode>
|
||||
export type DownspoutEvent = NodeEvent<DownspoutNode>
|
||||
|
||||
// Event suffixes - exported for use in hooks
|
||||
export const eventSuffixes = [
|
||||
@@ -229,10 +244,15 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'guide', GuideEvent> &
|
||||
NodeEvents<'box-vent', BoxVentEvent> &
|
||||
NodeEvents<'ridge-vent', RidgeVentEvent> &
|
||||
NodeEvents<'turbine-vent', TurbineVentEvent> &
|
||||
NodeEvents<'cupola', CupolaEvent> &
|
||||
NodeEvents<'eyebrow-vent', EyebrowVentEvent> &
|
||||
NodeEvents<'gutter', GutterEvent> &
|
||||
NodeEvents<'chimney', ChimneyEvent> &
|
||||
NodeEvents<'solar-panel', SolarPanelEvent> &
|
||||
NodeEvents<'skylight', SkylightEvent> &
|
||||
NodeEvents<'dormer', DormerEvent> &
|
||||
NodeEvents<'downspout', DownspoutEvent> &
|
||||
CameraControlEvents &
|
||||
ToolEvents &
|
||||
GuideEvents &
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||
import { getScaledDimensions, isLowProfileItemSurface } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { isCurvedWall, sampleWallCenterline } from '../../systems/wall/wall-curve'
|
||||
import { DEFAULT_WALL_THICKNESS } from '../../systems/wall/wall-footprint'
|
||||
import { SpatialGrid } from './spatial-grid'
|
||||
import { WallSpatialGrid } from './wall-spatial-grid'
|
||||
|
||||
@@ -284,21 +286,82 @@ function segmentsCollinearAndOverlap(
|
||||
return a1OnB && a2OnB
|
||||
}
|
||||
|
||||
type WallOverlapInput = {
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
curveOffset?: number
|
||||
thickness?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a wall segment overlaps with a polygon.
|
||||
* A wall is considered to overlap if:
|
||||
* - Its midpoint is inside the polygon (wall crosses through)
|
||||
* - At least one endpoint is inside (wall partially or fully in slab)
|
||||
* - It's collinear with and overlaps a polygon edge (wall on slab boundary)
|
||||
* - (curved walls) any sample along the centerline is inside
|
||||
*
|
||||
* Note: A wall with just one endpoint touching the edge but the rest outside
|
||||
* is NOT considered overlapping (adjacent only).
|
||||
*/
|
||||
export function wallOverlapsPolygon(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
polygon: Array<[number, number]>,
|
||||
startOrWall: [number, number] | WallOverlapInput,
|
||||
endOrPolygon: [number, number] | Array<[number, number]>,
|
||||
polygonArg?: Array<[number, number]>,
|
||||
): boolean {
|
||||
// Two call shapes:
|
||||
// wallOverlapsPolygon(wallLike, polygon) — preferred; curve-aware
|
||||
// wallOverlapsPolygon(start, end, polygon) — legacy chord-only
|
||||
let start: [number, number]
|
||||
let end: [number, number]
|
||||
let polygon: Array<[number, number]>
|
||||
let curveOffset = 0
|
||||
let thickness = DEFAULT_WALL_THICKNESS
|
||||
if (Array.isArray(startOrWall)) {
|
||||
start = startOrWall as [number, number]
|
||||
end = endOrPolygon as [number, number]
|
||||
polygon = polygonArg as Array<[number, number]>
|
||||
} else {
|
||||
start = startOrWall.start
|
||||
end = startOrWall.end
|
||||
curveOffset = startOrWall.curveOffset ?? 0
|
||||
thickness = startOrWall.thickness ?? DEFAULT_WALL_THICKNESS
|
||||
polygon = endOrPolygon as Array<[number, number]>
|
||||
}
|
||||
const halfThickness = Math.max(thickness / 2, 0)
|
||||
|
||||
// Curved walls: sample the centerline. The chord-based checks below miss
|
||||
// walls that bow into/out of the slab — e.g. endpoints on the slab
|
||||
// boundary with the curve arcing inward through the slab interior. Without
|
||||
// this, `getSlabElevationForWall` returns 0 (wall drops to floor) and
|
||||
// `markNodesOverlappingSlab` never re-dirties the wall when the slab Y
|
||||
// moves.
|
||||
if (curveOffset !== 0) {
|
||||
const wallLike = { start, end, curveOffset }
|
||||
if (isCurvedWall(wallLike)) {
|
||||
const samples = sampleWallCenterline(wallLike, 16)
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const point = samples[i]!
|
||||
if (pointInPolygon(point.x, point.y, polygon)) return true
|
||||
// Also test ±halfThickness perpendicular at each sample so a curve
|
||||
// skirting the slab edge with its centerline just outside still
|
||||
// registers — its body sits inside the slab.
|
||||
if (halfThickness > 0 && i > 0) {
|
||||
const prev = samples[i - 1]!
|
||||
const sx = point.x - prev.x
|
||||
const sz = point.y - prev.y
|
||||
const sl = Math.sqrt(sx * sx + sz * sz)
|
||||
if (sl > 1e-10) {
|
||||
const tnx = (-sz / sl) * halfThickness
|
||||
const tnz = (sx / sl) * halfThickness
|
||||
if (pointInPolygon(point.x + tnx, point.y + tnz, polygon)) return true
|
||||
if (pointInPolygon(point.x - tnx, point.y - tnz, polygon)) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const len = Math.sqrt(dx * dx + dz * dz)
|
||||
@@ -331,6 +394,26 @@ export function wallOverlapsPolygon(
|
||||
if (pointInPolygon(bx + pnx, bz + pnz, polygon)) return true
|
||||
if (pointInPolygon(bx - pnx, bz - pnz, polygon)) return true
|
||||
}
|
||||
|
||||
// Wall-thickness perpendicular test. Walls aren't infinitely thin lines;
|
||||
// a wall whose centerline sits just outside the slab boundary still has
|
||||
// half its body inside the slab and should follow the slab elevation.
|
||||
// Without this, the perimeter walls of a room often miss the slab-overlap
|
||||
// detection (the slab polygon is the room's interior, the wall centerline
|
||||
// sits on or just outside its edge) and stay at Y=0 while the slab moves
|
||||
// up.
|
||||
if (halfThickness > 0) {
|
||||
const ux = dx / len
|
||||
const uz = dz / len
|
||||
const tnx = -uz * halfThickness
|
||||
const tnz = ux * halfThickness
|
||||
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const bx = start[0] + dx * t
|
||||
const bz = start[1] + dz * t
|
||||
if (pointInPolygon(bx + tnx, bz + tnz, polygon)) return true
|
||||
if (pointInPolygon(bx - tnx, bz - tnz, polygon)) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if midpoint is inside (catches walls crossing through)
|
||||
@@ -762,15 +845,33 @@ export class SpatialGridManager {
|
||||
* Get the slab elevation for a wall by checking if it overlaps with any slab polygon (excluding holes).
|
||||
* Uses wallOverlapsPolygon which handles edge cases (points on boundary, collinear segments).
|
||||
* Returns the highest slab elevation found, or 0 if none.
|
||||
*
|
||||
* Accepts an optional `curveOffset` so curved walls evaluate overlap
|
||||
* against their actual centerline samples, not just the chord.
|
||||
*/
|
||||
getSlabElevationForWall(levelId: string, start: [number, number], end: [number, number]): number {
|
||||
getSlabElevationForWall(
|
||||
levelId: string,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
curveOffset = 0,
|
||||
thickness = DEFAULT_WALL_THICKNESS,
|
||||
): number {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) return 0
|
||||
|
||||
const wallLike: WallOverlapInput = { start, end, curveOffset, thickness }
|
||||
const isCurved = curveOffset !== 0 && isCurvedWall(wallLike)
|
||||
const holeSamplePoints: Array<{ x: number; y: number }> = isCurved
|
||||
? sampleWallCenterline(wallLike, 8)
|
||||
: [0, 0.25, 0.5, 0.75, 1].map((t) => ({
|
||||
x: start[0] + (end[0] - start[0]) * t,
|
||||
y: start[1] + (end[1] - start[1]) * t,
|
||||
}))
|
||||
|
||||
let maxElevation = Number.NEGATIVE_INFINITY
|
||||
for (const slab of slabMap.values()) {
|
||||
if (slab.polygon.length < 3) continue
|
||||
if (!wallOverlapsPolygon(start, end, slab.polygon)) continue
|
||||
if (!wallOverlapsPolygon(wallLike, slab.polygon)) continue
|
||||
|
||||
const holes = slab.holes || []
|
||||
if (holes.length === 0) {
|
||||
@@ -783,15 +884,11 @@ export class SpatialGridManager {
|
||||
// Sample multiple points along the wall to check whether any portion lies on
|
||||
// solid slab (not inside any hole). Checking only the midpoint fails when the
|
||||
// midpoint falls in a staircase hole but the wall's endpoints are on solid slab.
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
let hasValidPoint = false
|
||||
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const px = start[0] + dx * t
|
||||
const pz = start[1] + dz * t
|
||||
for (const sample of holeSamplePoints) {
|
||||
let inHole = false
|
||||
for (const hole of holes) {
|
||||
if (hole.length >= 3 && pointInPolygon(px, pz, hole)) {
|
||||
if (hole.length >= 3 && pointInPolygon(sample.x, sample.y, hole)) {
|
||||
inHole = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -166,7 +166,17 @@ function markNodesOverlappingSlab(
|
||||
if (node.type === 'wall') {
|
||||
const wall = node as WallNode
|
||||
if (resolveLevelId(node, nodes) !== slabLevelId) continue
|
||||
if (wallOverlapsPolygon(wall.start, wall.end, slab.polygon)) {
|
||||
if (
|
||||
wallOverlapsPolygon(
|
||||
{
|
||||
start: wall.start,
|
||||
end: wall.end,
|
||||
curveOffset: wall.curveOffset ?? 0,
|
||||
thickness: wall.thickness,
|
||||
},
|
||||
slab.polygon,
|
||||
)
|
||||
) {
|
||||
markDirty(node.id)
|
||||
}
|
||||
continue
|
||||
|
||||
@@ -13,6 +13,7 @@ export type {
|
||||
FenceEvent,
|
||||
GridEvent,
|
||||
GuideEvent,
|
||||
GutterEvent,
|
||||
ItemEvent,
|
||||
LevelEvent,
|
||||
NodeEvent,
|
||||
@@ -105,6 +106,7 @@ export {
|
||||
getEffectiveNode,
|
||||
type LiveNodeOverrides,
|
||||
} from './store/use-live-node-overrides'
|
||||
export { default as useAlignmentGuides } from './store/use-alignment-guides'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
export { resolveElevatorDispatchTarget } from './systems/elevator/elevator-dispatch'
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
// - endpoint-move : wall / fence endpoint drag (snapping is bespoke,
|
||||
// so it delegates to a kind-supplied callback)
|
||||
|
||||
import type { AnyNode } from '../schema/types'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import type { SceneApi } from './types'
|
||||
|
||||
/**
|
||||
@@ -110,9 +110,30 @@ export type LinearResizeHandle<N> = {
|
||||
anchor: HandleAnchor
|
||||
currentValue: (node: N) => number
|
||||
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
|
||||
/**
|
||||
* Cross-node redirect. By default the drag's live override + the
|
||||
* committed write both land on the SELECTED node. When this returns
|
||||
* another node's id, the editor publishes the override to / commits on
|
||||
* THAT node instead (and `apply` should return that node's patch).
|
||||
* Used when a node's handle edits a value owned by a sibling — e.g. a
|
||||
* downspout's side-move arrows slide its outlet, which lives on the
|
||||
* host gutter (`gutter.outlets[].offset`). The selected node is still
|
||||
* what `currentValue` / `apply` receive, so the descriptor can read
|
||||
* the downspout to find its gutter + outlet.
|
||||
*/
|
||||
overrideTarget?: (node: N, sceneApi: SceneApi) => AnyNodeId | undefined
|
||||
min?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
max?: number | ((node: N, sceneApi: SceneApi) => number)
|
||||
placement: HandlePlacement<N>
|
||||
/**
|
||||
* Dimension this handle steers (e.g. `'height'`). When set, the editor
|
||||
* publishes it to `activeHandleDrag.label` for the duration of the drag
|
||||
* so out-of-band overlays (the floating dimension pill) can react, and
|
||||
* the handle's own in-world value chip is suppressed to avoid showing
|
||||
* the same number twice. Leave unset for handles that keep their inline
|
||||
* chip and don't drive any external overlay.
|
||||
*/
|
||||
measureLabel?: string
|
||||
/**
|
||||
* Defaults to 'self' (arrow lives in the selected node's own mesh).
|
||||
* 'parent' uses the parent mesh — used by doors/windows whose handles
|
||||
@@ -122,6 +143,29 @@ export type LinearResizeHandle<N> = {
|
||||
cursor?: Cursor
|
||||
/** Optional visual guide shown while the arrow is hovered or dragging. */
|
||||
decoration?: HandleDecoration<N>
|
||||
/**
|
||||
* Visual override. Defaults to the standard chevron arrow.
|
||||
*
|
||||
* `'tracker'` swaps the chevron for a dashed vertical leader + a small
|
||||
* cube at `placement.position`. The leader runs from the floor (local
|
||||
* y=0) up to the cube; the cube is the drag target and reuses the same
|
||||
* linear-resize drag pipeline as the chevron. Intended for vertical
|
||||
* height handles where the dashed leader makes the "this is the wall
|
||||
* top" relationship readable at a glance — mirrors the `corner-picker`
|
||||
* shape on `tap-action` handles but with a draggable cube instead of a
|
||||
* one-tap hex disc. Use with `axis: 'y'`; horizontal axes will render
|
||||
* the leader vertically and look wrong.
|
||||
*/
|
||||
shape?: 'arrow' | 'tracker'
|
||||
/**
|
||||
* Optional override for the bottom Y of the tracker leader. Defaults
|
||||
* to 0 (floor of the rideObject's local frame). Use when the value
|
||||
* being tracked spans a region that doesn't start at the floor — e.g.
|
||||
* a chimney's body height runs from the roof deck up to the body top,
|
||||
* so the leader should start at the deck plane and not climb through
|
||||
* the roof shell below it. Only consulted when `shape === 'tracker'`.
|
||||
*/
|
||||
trackerBaseY?: (node: N, sceneApi: SceneApi) => number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,6 +220,28 @@ export type ArcResizeHandle<N = any> = {
|
||||
* arrow icon, intended for whole-node rotation handles.
|
||||
*/
|
||||
shape?: 'chevron' | 'rotate'
|
||||
/**
|
||||
* Plane the angular drag is measured in:
|
||||
* - 'horizontal' (default): cursor bearing around +Y — whole-node yaw
|
||||
* (floor items, elevator, stair, roof-segment).
|
||||
* - 'node-normal': cursor bearing around the node's local +Z axis, in
|
||||
* the plane perpendicular to it — spins a wall-mounted item flat
|
||||
* against its wall. The descriptor's `apply` writes the roll
|
||||
* component (rotation[2]). The gizmo icon stands up into that plane.
|
||||
*/
|
||||
rotationPlane?: 'horizontal' | 'node-normal'
|
||||
/**
|
||||
* Pivot point for the angular drag, in the rideObject's local space.
|
||||
* The renderer measures cursor angle (atan2 on the drag plane) around
|
||||
* this point — descriptors that write `rotation` should anchor it to
|
||||
* the node's visual center. Defaults to the rideObject's own origin,
|
||||
* which is correct for nodes whose mesh origin coincides with the
|
||||
* field they're rotating (roof-segment, elevator). Use this when the
|
||||
* node's pose is baked into its geometry (chimney) so the mesh origin
|
||||
* sits at the parent frame's origin rather than the rotating shape's
|
||||
* center.
|
||||
*/
|
||||
rotationCenter?: (node: N, sceneApi: SceneApi) => readonly [number, number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,12 +293,54 @@ export type TapActionHandle<N = any> = {
|
||||
cursor?: Cursor
|
||||
}
|
||||
|
||||
/**
|
||||
* Free ground-plane move. Drag the handle and the node slides across the
|
||||
* horizontal plane at its base — the renderer raycasts that plane, converts
|
||||
* the hit into the node's parent-local frame, and reports the new local XZ
|
||||
* (optionally grid-snapped via `snapExtents`) to `apply`. Press-drag-release
|
||||
* with the same live-override → commit-on-release flow as the resize / rotate
|
||||
* handles. Rendered as a 4-way cross of double-headed arrows.
|
||||
*/
|
||||
export type TranslateHandle<N = any> = {
|
||||
kind: 'translate'
|
||||
placement: HandlePlacement<N>
|
||||
/**
|
||||
* Plane the drag is constrained to (through the node origin):
|
||||
* - 'horizontal' (default): the ground plane (world-up normal) — slide
|
||||
* across the floor. The free axes are parent-local X / Z.
|
||||
* - 'node-normal': the plane perpendicular to the node's local +Z axis
|
||||
* (its facing direction) — slide across a wall face. The free axes are
|
||||
* parent-local X / Y; depth (Z) stays pinned to the surface.
|
||||
*/
|
||||
plane?: 'horizontal' | 'node-normal'
|
||||
/**
|
||||
* `localPos` is the dragged-to position in the node's PARENT-local frame,
|
||||
* with the two in-plane axes already grid-snapped (if `snapExtents` is set)
|
||||
* and the off-plane axis pinned to its drag-start value. Return the patch
|
||||
* that writes it to the node's position field.
|
||||
*/
|
||||
apply: (
|
||||
initialNode: N,
|
||||
localPos: readonly [number, number, number],
|
||||
sceneApi: SceneApi,
|
||||
) => Partial<N>
|
||||
/**
|
||||
* Optional grid-snap footprint for the two in-plane axes, in order
|
||||
* `[alongX, alongOther]` — `alongOther` is Z for the 'horizontal' plane and
|
||||
* Y for 'node-normal'. Used to align the node's edges to the grid (rotation-
|
||||
* aware: swap the pair at 90°). Omit / return null for free movement.
|
||||
*/
|
||||
snapExtents?: (node: N) => readonly [number, number] | null
|
||||
portal?: HandlePortal
|
||||
}
|
||||
|
||||
export type HandleDescriptor<N = any> =
|
||||
| LinearResizeHandle<N>
|
||||
| RadialResizeHandle<N>
|
||||
| ArcResizeHandle<N>
|
||||
| EndpointMoveHandle<N>
|
||||
| TapActionHandle<N>
|
||||
| TranslateHandle<N>
|
||||
|
||||
/**
|
||||
* Static array, or a function for shape-dependent cases (column
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
LinearResizeHandle,
|
||||
RadialResizeHandle,
|
||||
TapActionHandle,
|
||||
TranslateHandle,
|
||||
} from './handles'
|
||||
export {
|
||||
discoverPlugins,
|
||||
@@ -82,6 +83,7 @@ export type {
|
||||
PaintPatchArgs,
|
||||
PaintPreviewArgs,
|
||||
PaintResolveArgs,
|
||||
ParamAction,
|
||||
ParametricDescriptor,
|
||||
ParamField,
|
||||
ParamGroup,
|
||||
|
||||
@@ -410,6 +410,14 @@ export type FloorplanGeometry =
|
||||
angle: number
|
||||
affordance: string
|
||||
payload?: unknown
|
||||
/**
|
||||
* Rotation pivot (plan coords) this handle turns the node around.
|
||||
* When present, the floor-plan layer draws a live angle wedge + degree
|
||||
* readout swept from grab to the current pointer bearing during the
|
||||
* drag — the 2D twin of the 3D rotate gizmo's readout. Emitters that
|
||||
* already compute the pivot to place the handle should pass it through.
|
||||
*/
|
||||
pivot?: FloorplanPoint
|
||||
}
|
||||
/**
|
||||
* Centered length / distance label. Renders as a small rounded
|
||||
@@ -1238,6 +1246,31 @@ export type ParametricDescriptor<N> = {
|
||||
invariants?: ReadonlyArray<(n: N) => Issue[]>
|
||||
derive?: (n: N) => Partial<N>
|
||||
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
|
||||
/**
|
||||
* Extra buttons rendered in the inspector's Actions section
|
||||
* (below Move/Delete). Lets a kind declare "do this thing to the
|
||||
* current node" affordances without escaping to a full custom
|
||||
* panel. Buttons whose `enabledIf` returns false stay disabled.
|
||||
*/
|
||||
actions?: ParamAction<N>[]
|
||||
/**
|
||||
* Lazy-loaded React subsection rendered AFTER the auto-derived
|
||||
* groups and BEFORE the Actions section. Used by kinds that want
|
||||
* to list their child nodes inline — e.g. the gutter's downspout
|
||||
* list with an "Add Downspout" button at the bottom, same shape as
|
||||
* the roof panel's gutter / vent lists. Kind owns the layout; the
|
||||
* inspector just slots it in.
|
||||
*/
|
||||
trailingSection?: () => Promise<{ default: ComponentType<{ node: N }> }>
|
||||
}
|
||||
|
||||
export type ParamAction<N> = {
|
||||
label: string
|
||||
/** Optional asset URL for a leading icon — same shape as palette icons. */
|
||||
iconSrc?: string
|
||||
enabledIf?: (n: N) => boolean
|
||||
/** Click handler. Receives the current node value at click time. */
|
||||
onClick: (n: N) => void
|
||||
}
|
||||
|
||||
export type ParamGroup<N> = {
|
||||
|
||||
@@ -50,13 +50,16 @@ export {
|
||||
ColumnStyle,
|
||||
ColumnSupportStyle,
|
||||
} from './nodes/column'
|
||||
export { CupolaNode } from './nodes/cupola'
|
||||
export { DoorNode, DoorSegment } from './nodes/door'
|
||||
export { EyebrowVentNode } from './nodes/eyebrow-vent'
|
||||
export {
|
||||
DormerNode,
|
||||
type DormerSurfaceMaterialRole,
|
||||
type DormerSurfaceMaterialSpec,
|
||||
getEffectiveDormerSurfaceMaterial,
|
||||
} from './nodes/dormer'
|
||||
export { DownspoutNode } from './nodes/downspout'
|
||||
export {
|
||||
ElevatorDoorPanelStyle,
|
||||
ElevatorDoorStyle,
|
||||
@@ -65,6 +68,7 @@ export {
|
||||
} from './nodes/elevator'
|
||||
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||
export { GuideNode, GuideScaleReference } from './nodes/guide'
|
||||
export { GutterNode, GutterOutlet } from './nodes/gutter'
|
||||
export type {
|
||||
AnimationEffect,
|
||||
Asset,
|
||||
@@ -133,6 +137,7 @@ export {
|
||||
} from './nodes/stair'
|
||||
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
||||
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
||||
export { TurbineVentNode } from './nodes/turbine-vent'
|
||||
export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
|
||||
export {
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
|
||||
@@ -57,6 +57,10 @@ export const MaterialTarget = z.enum([
|
||||
'dormer',
|
||||
'box-vent',
|
||||
'ridge-vent',
|
||||
'turbine-vent',
|
||||
'cupola',
|
||||
'eyebrow-vent',
|
||||
'gutter',
|
||||
])
|
||||
export type MaterialTarget = z.infer<typeof MaterialTarget>
|
||||
|
||||
|
||||
@@ -37,10 +37,10 @@ export const BoxVentNode = BaseNode.extend({
|
||||
// cap's flange. Reads as the ventilation slot on real attic vents.
|
||||
// 0 = cap sits flush on the body (the original cap shape).
|
||||
capGap: z.number().default(0),
|
||||
// `dome`-only: radial decay of the dome cap. 1 = perfect ellipsoid,
|
||||
// <1 = soft pillow (wider for longer near the apex). Ignored by
|
||||
// `box` / `cap`.
|
||||
domeCurvature: z.number().default(0.65),
|
||||
// `dome`-only: radial decay of the dome cap. 1 = clean ellipsoid
|
||||
// (default — reads as a proper hemispherical dome), <1 = fuller pillow,
|
||||
// >1 = pointier. Ignored by `box` / `cap`.
|
||||
domeCurvature: z.number().default(1.0),
|
||||
// `box`-only: how much the lower (smaller) riser is inset from the
|
||||
// upper (larger) cover. 0 = same footprint as the cover.
|
||||
baseInset: z.number().default(0.06),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
|
||||
export const CupolaNode = BaseNode.extend({
|
||||
id: objectId('cupola'),
|
||||
type: nodeType('cupola'),
|
||||
|
||||
material: MaterialSchema.optional(),
|
||||
// Default to the white preset so a freshly-placed cupola reads as clean
|
||||
// painted metal and the paint inspector shows "White" (matches box-vent).
|
||||
materialPreset: z.string().default('preset-white'),
|
||||
|
||||
roofSegmentId: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.number().default(0),
|
||||
|
||||
// Cupolas are larger than the other vents — a roof lantern, not a cap.
|
||||
width: z.number().default(0.8),
|
||||
depth: z.number().default(0.8),
|
||||
height: z.number().default(1.0),
|
||||
|
||||
// Roof cap shape: `dome` (round) or `pyramid` (four-sided point).
|
||||
roofStyle: z.enum(['dome', 'pyramid']).default('dome'),
|
||||
// Decorative post + ball at the apex.
|
||||
finial: z.boolean().default(true),
|
||||
}).describe(
|
||||
dedent`
|
||||
Cupola — a louvered roof lantern that sits astride a roof ridge or slope:
|
||||
a louvered body topped by a dome or pyramid roof, optionally crowned with
|
||||
a finial. Parented to a roof-segment; position is segment-local. Rotation
|
||||
is around the segment's vertical axis (post-slope tilt).
|
||||
`,
|
||||
)
|
||||
|
||||
export type CupolaNode = z.infer<typeof CupolaNode>
|
||||
@@ -0,0 +1,74 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
|
||||
export const DownspoutNode = BaseNode.extend({
|
||||
id: objectId('downspout'),
|
||||
type: nodeType('downspout'),
|
||||
|
||||
material: MaterialSchema.optional(),
|
||||
// Match the gutter family default — paint inspector reads "White"
|
||||
// instead of "no material" on a freshly placed downspout.
|
||||
materialPreset: z.string().default('preset-white'),
|
||||
|
||||
// Logical attachment: the gutter this downspout drains. Scene-graph
|
||||
// parent is the same roof-segment that hosts the gutter, so the
|
||||
// renderer can be reached through the segment's children list (like
|
||||
// every other roof accessory). gutterId then drives the LOOKUP of
|
||||
// outlet X/Z/diameter — the downspout's actual mount position is
|
||||
// derived from the gutter, not stored.
|
||||
gutterId: z.string().optional(),
|
||||
// Which of the host gutter's `outlets` this downspout drains, by the
|
||||
// outlet's `id`. The mount position, bore, and cross-section shape are
|
||||
// looked up from that outlet — so several downspouts on one gutter no
|
||||
// longer stack on a single drop.
|
||||
outletId: z.string().optional(),
|
||||
|
||||
// Length the pipe extends DOWN from the gutter outlet, in metres.
|
||||
// Default 2.5 m covers a typical residential storey; the placement
|
||||
// tool can default to the gutter's eave-Y minus building floor on
|
||||
// commit so the user doesn't have to set it on every drop.
|
||||
length: z.number().default(2.5),
|
||||
// Bore diameter, default 0.07 m ≈ 3″ to match the gutter outlet
|
||||
// default. Larger downspouts are common on commercial gutters.
|
||||
diameter: z.number().default(0.07),
|
||||
// Gap between the pipe's wall-facing surface and the wall face, in
|
||||
// metres. The downspout's offset elbows step it back from the eave
|
||||
// overhang to the wall; this controls how far proud of the wall it
|
||||
// sits (real downspouts mount on standoff brackets ~1–2 cm off the
|
||||
// wall). Larger values pull the run back OUT toward the eave — the
|
||||
// escape hatch when the auto-routed wall position doesn't match the
|
||||
// actual wall (overshoots into it). Default 0.02 m.
|
||||
standoff: z.number().default(0.02),
|
||||
|
||||
// Cross-section shape. 'auto' follows the host gutter's profile (round
|
||||
// on half-round, rectangular on k-style / box); 'round' / 'rect'
|
||||
// override it for a mixed look.
|
||||
shape: z.enum(['auto', 'round', 'rect']).default('auto'),
|
||||
|
||||
// Wall straps clamping the run to the wall. 'band' renders periodic
|
||||
// bands; 'none' hides them. Spacing is metres between bands.
|
||||
strapStyle: z.enum(['band', 'none']).default('band'),
|
||||
strapSpacing: z.number().default(1.8),
|
||||
|
||||
// What happens at the bottom of the run:
|
||||
// - 'splash': kickout elbow + a splash block on the ground (default)
|
||||
// - 'kickout': kickout elbow only (e.g. into a drain pipe)
|
||||
// - 'straight': no kick — runs straight down (into a grade drain)
|
||||
terminal: z.enum(['splash', 'kickout', 'straight']).default('splash'),
|
||||
}).describe(
|
||||
dedent`
|
||||
Downspout — a vertical pipe that takes water from a gutter outlet
|
||||
down to ground level. Parented to a roof-segment (scene-graph),
|
||||
linked to a specific gutter via gutterId for outlet position.
|
||||
- length: vertical pipe length below the gutter outlet
|
||||
- diameter: bore diameter; should match the host gutter's outletDiameter
|
||||
- standoff: gap the pipe sits proud of the wall (pulls the run out toward the eave)
|
||||
- shape: cross-section (auto follows the gutter profile, or force round / rect)
|
||||
- strapStyle / strapSpacing: wall straps clamping the run
|
||||
- terminal: bottom treatment (splash block, kickout only, or straight down)
|
||||
`,
|
||||
)
|
||||
|
||||
export type DownspoutNode = z.infer<typeof DownspoutNode>
|
||||
@@ -0,0 +1,51 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
|
||||
export const EyebrowVentNode = BaseNode.extend({
|
||||
id: objectId('eyebrow-vent'),
|
||||
type: nodeType('eyebrow-vent'),
|
||||
|
||||
material: MaterialSchema.optional(),
|
||||
// Default to the white preset so a freshly-placed vent reads as clean
|
||||
// painted metal and the paint inspector shows "White" (matches box-vent).
|
||||
materialPreset: z.string().default('preset-white'),
|
||||
|
||||
roofSegmentId: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.number().default(0),
|
||||
|
||||
// `width` is the opening width (left↔right across the louvered face),
|
||||
// `depth` the front-to-back projection along the roof, `height` the rise.
|
||||
width: z.number().default(0.5),
|
||||
depth: z.number().default(0.6),
|
||||
height: z.number().default(0.3),
|
||||
|
||||
// Body shape:
|
||||
// - `scoop` : rounded louvered opening that sweeps back and tapers
|
||||
// into the flashing plate (the classic dormer "eyebrow").
|
||||
// - `half-round` : a D-shaped half-round louver vent (flat louvered face,
|
||||
// semicircular curved top, short body).
|
||||
// - `slant-box` : a low box with a slanted top and a screened front.
|
||||
style: z.enum(['scoop', 'half-round', 'slant-box']).default('scoop'),
|
||||
|
||||
// Number of horizontal louver slats across the front opening (0 = open /
|
||||
// screened only).
|
||||
louverCount: z.number().int().min(0).max(8).default(3),
|
||||
|
||||
// `slant-box` only: the low rear edge as a fraction of the tall front edge.
|
||||
// 1 = flat-topped box, lower = the top slopes down more steeply toward the
|
||||
// back.
|
||||
backRatio: z.number().min(0.15).max(1).default(0.5),
|
||||
}).describe(
|
||||
dedent`
|
||||
Eyebrow vent — a low roof ventilator with a rounded, louvered front that
|
||||
sits on a flashing plate. Three styles: a swept "scoop" eyebrow, a D-shaped
|
||||
"half-round" louver vent, and a "slant-box" hood. Parented to a roof-segment;
|
||||
position is segment-local. Rotation is around the segment's vertical axis
|
||||
(post-slope tilt).
|
||||
`,
|
||||
)
|
||||
|
||||
export type EyebrowVentNode = z.infer<typeof EyebrowVentNode>
|
||||
@@ -0,0 +1,90 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
|
||||
// A single drop outlet drilled in the gutter floor. A gutter can carry
|
||||
// several so a long run can split between multiple downspouts (each
|
||||
// downspout links to one outlet via its `outletId`).
|
||||
export const GutterOutlet = z.object({
|
||||
// Stable id the downspout references. Generated with `generateId('outlet')`.
|
||||
id: z.string(),
|
||||
// Position along the gutter length (gutter-local +X), signed from the
|
||||
// CENTER. The geometry clamps it inside the end caps at build time, so
|
||||
// a stored value that no longer fits just rides the nearest bound.
|
||||
offset: z.number().default(0),
|
||||
// Bore diameter of this drop. Default 0.07 m ≈ 3″. The cross-section
|
||||
// SHAPE (round vs rectangular) follows the gutter's profile, not this.
|
||||
diameter: z.number().default(0.07),
|
||||
})
|
||||
export type GutterOutlet = z.infer<typeof GutterOutlet>
|
||||
|
||||
export const GutterNode = BaseNode.extend({
|
||||
id: objectId('gutter'),
|
||||
type: nodeType('gutter'),
|
||||
|
||||
material: MaterialSchema.optional(),
|
||||
// White preset by default — matches the rest of the roof accessory
|
||||
// family (box-vent / ridge-vent) so the paint inspector reads as
|
||||
// "White" instead of "no material" on a freshly-placed gutter.
|
||||
materialPreset: z.string().default('preset-white'),
|
||||
|
||||
roofSegmentId: z.string().optional(),
|
||||
// Segment-local. The placement tool snaps to the eave line (Z =
|
||||
// +depth/2, Y = wallHeight) of the segment under the cursor; X is
|
||||
// wherever the user clicked. After placement the inspector + length
|
||||
// handles can shift X along the eave.
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around the gutter's own local Y. Kept at 0 by default
|
||||
// because the gutter's length axis is constrained to the eave
|
||||
// direction (segment-local +X) — but exposed in case the user wants
|
||||
// to tilt for a custom run.
|
||||
rotation: z.number().default(0),
|
||||
|
||||
// Length along the eave (gutter-local +X).
|
||||
length: z.number().default(2.0),
|
||||
// Profile size — the vertical drop of the U-channel below the eave
|
||||
// line. 5″ (0.127 m) is the most common residential gutter size; 6″
|
||||
// (0.152 m) is the common commercial / heavy-duty size. Default
|
||||
// rounds the residential value to 0.13 m.
|
||||
size: z.number().default(0.13),
|
||||
// Wall thickness of the U-channel. Visible on the rim from above; too
|
||||
// thin reads as a paper strip, too thick reads as a curb.
|
||||
thickness: z.number().default(0.006),
|
||||
|
||||
profile: z.enum(['k-style', 'half-round', 'box']).default('k-style'),
|
||||
|
||||
// End caps close the open ends of the U-channel so water can't run
|
||||
// out the sides. Independent per-end because a downspout typically
|
||||
// joins the gutter at one end while the other stays capped. Default
|
||||
// true on both — matches a freshly-installed residential gutter.
|
||||
endCapLeft: z.boolean().default(true),
|
||||
endCapRight: z.boolean().default(true),
|
||||
|
||||
// Hangers are the metal straps that hold the gutter onto the
|
||||
// fascia. 'strap' renders periodic bars across the rim; 'none'
|
||||
// hides them (some plastic gutters use hidden clips). Spacing is
|
||||
// metres between hanger centers; real residential code is roughly
|
||||
// 0.6 m for snow-load areas, 0.75 m elsewhere.
|
||||
hangerStyle: z.enum(['strap', 'none']).default('strap'),
|
||||
hangerSpacing: z.number().default(0.6),
|
||||
|
||||
// Downspout outlets — short drop tubes descending from the gutter
|
||||
// floor where downspouts connect. Empty by default so existing
|
||||
// gutters don't sprout outlets on schema upgrade. Each is drilled
|
||||
// through the trough floor via CSG; a downspout links to one by id.
|
||||
outlets: z.array(GutterOutlet).default([]),
|
||||
}).describe(
|
||||
dedent`
|
||||
Gutter — a rain-water channel running along the eave of a roof
|
||||
segment. Parented to a roof-segment; position is segment-local.
|
||||
- length: span along the eave (gutter-local +X)
|
||||
- size: profile drop below the eave line (vertical extent)
|
||||
- profile: k-style (ogee fascia), half-round, or square box
|
||||
- endCapLeft / endCapRight: close the trough at gutter-local -X / +X
|
||||
- hangerStyle / hangerSpacing: visible metal straps across the rim
|
||||
- outlets: drop-tube outlets (id + along-length offset + bore diameter)
|
||||
`,
|
||||
)
|
||||
|
||||
export type GutterNode = z.infer<typeof GutterNode>
|
||||
@@ -18,7 +18,9 @@ export const RidgeVentNode = BaseNode.extend({
|
||||
|
||||
length: z.number().default(2.0),
|
||||
width: z.number().default(0.3),
|
||||
height: z.number().default(0.08),
|
||||
// Taller default than the old paper-thin shell so the solid body reads
|
||||
// with real presence on the ridge; still adjustable down to 0.03.
|
||||
height: z.number().default(0.1),
|
||||
|
||||
style: z.enum(['standard', 'shingled', 'metal']).default('standard'),
|
||||
endCaps: z.boolean().default(true),
|
||||
|
||||
@@ -97,7 +97,7 @@ export const RoofSegmentNode = BaseNode.extend({
|
||||
.max(0.9)
|
||||
.default(ROOF_SHAPE_DEFAULTS.dutchHipHeightRatio),
|
||||
// Hosted accessories — chimney, dormer, skylight, box-vent,
|
||||
// ridge-vent, solar-panel. Each accessory's `parentId` points back
|
||||
// ridge-vent, solar-panel, gutter. Each accessory's `parentId` points back
|
||||
// here; the segment renderer mounts them recursively via
|
||||
// `<NodeRenderer>` so they inherit the segment's transform stack.
|
||||
// Required for `createNode(child, segmentId)` to append the child
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
|
||||
export const TurbineVentNode = BaseNode.extend({
|
||||
id: objectId('tvent'),
|
||||
type: nodeType('turbine-vent'),
|
||||
|
||||
material: MaterialSchema.optional(),
|
||||
// Default to the white preset so a freshly-placed turbine reads as
|
||||
// clean painted/galvanised metal and the paint inspector shows "White"
|
||||
// as the current selection (matches box-vent's reasoning).
|
||||
materialPreset: z.string().default('preset-white'),
|
||||
|
||||
roofSegmentId: z.string().optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.number().default(0),
|
||||
|
||||
// Overall diameter of the spinning head at its widest point.
|
||||
diameter: z.number().default(0.32),
|
||||
// Total height from the flange base to the top knob.
|
||||
height: z.number().default(0.42),
|
||||
// Height of the throat cylinder between the flange and the head. The
|
||||
// head occupies the remaining `height - neckHeight`.
|
||||
neckHeight: z.number().default(0.09),
|
||||
// How far the flange flashing flares past the throat on every side.
|
||||
baseOverhang: z.number().default(0.05),
|
||||
// Number of curved vanes around the head. More vanes read denser /
|
||||
// finer; fewer read as a chunkier industrial turbine.
|
||||
vaneCount: z.number().int().default(20),
|
||||
// Idle spin speed in radians/second. 0 = static (the head holds still),
|
||||
// which is the default — a newly-placed turbine starts paused and is set
|
||||
// spinning via the panel's Play toggle or the Spin Speed slider. Driven by
|
||||
// the renderer's `useFrame`, not stored per-frame.
|
||||
spinSpeed: z.number().default(0),
|
||||
|
||||
// `globe` → classic spherical whirlybird (bulged barrel + domed top)
|
||||
// `cylinder`→ straight-barrel turbine with a flat disc top
|
||||
style: z.enum(['globe', 'cylinder']).default('globe'),
|
||||
}).describe(
|
||||
dedent`
|
||||
Turbine vent (whirlybird) — a wind-driven spinning exhaust vent that
|
||||
sits on a roof slope. A finned head spins on a throat above a flange.
|
||||
Parented to a roof-segment; position is segment-local. Rotation is
|
||||
around the segment's vertical axis (post-slope tilt).
|
||||
`,
|
||||
)
|
||||
|
||||
export type TurbineVentNode = z.infer<typeof TurbineVentNode>
|
||||
@@ -4,11 +4,15 @@ import { BuildingNode } from './nodes/building'
|
||||
import { CeilingNode } from './nodes/ceiling'
|
||||
import { ChimneyNode } from './nodes/chimney'
|
||||
import { ColumnNode } from './nodes/column'
|
||||
import { CupolaNode } from './nodes/cupola'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { DormerNode } from './nodes/dormer'
|
||||
import { DownspoutNode } from './nodes/downspout'
|
||||
import { ElevatorNode } from './nodes/elevator'
|
||||
import { EyebrowVentNode } from './nodes/eyebrow-vent'
|
||||
import { FenceNode } from './nodes/fence'
|
||||
import { GuideNode } from './nodes/guide'
|
||||
import { GutterNode } from './nodes/gutter'
|
||||
import { ItemNode } from './nodes/item'
|
||||
import { LevelNode } from './nodes/level'
|
||||
import { RidgeVentNode } from './nodes/ridge-vent'
|
||||
@@ -23,6 +27,7 @@ import { SolarPanelNode } from './nodes/solar-panel'
|
||||
import { SpawnNode } from './nodes/spawn'
|
||||
import { StairNode } from './nodes/stair'
|
||||
import { StairSegmentNode } from './nodes/stair-segment'
|
||||
import { TurbineVentNode } from './nodes/turbine-vent'
|
||||
import { WallNode } from './nodes/wall'
|
||||
import { WindowNode } from './nodes/window'
|
||||
import { ZoneNode } from './nodes/zone'
|
||||
@@ -51,10 +56,15 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
DoorNode,
|
||||
BoxVentNode,
|
||||
RidgeVentNode,
|
||||
TurbineVentNode,
|
||||
CupolaNode,
|
||||
EyebrowVentNode,
|
||||
GutterNode,
|
||||
ChimneyNode,
|
||||
SolarPanelNode,
|
||||
SkylightNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
])
|
||||
|
||||
export type AnyNode = z.infer<typeof AnyNode>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AlignmentAnchor, bboxAnchors, resolveAlignment } from './alignment'
|
||||
|
||||
function center(nodeId: string, x: number, z: number): AlignmentAnchor {
|
||||
return { nodeId, kind: 'center', x, z }
|
||||
}
|
||||
|
||||
describe('resolveAlignment', () => {
|
||||
test('returns empty when no candidates within threshold', () => {
|
||||
const result = resolveAlignment({
|
||||
moving: [center('m', 0, 0)],
|
||||
candidates: [center('a', 1, 1)],
|
||||
threshold: 0.5,
|
||||
})
|
||||
expect(result.guides).toEqual([])
|
||||
expect(result.snap).toBeNull()
|
||||
})
|
||||
|
||||
test('snaps moving anchor onto candidate X when within threshold', () => {
|
||||
const result = resolveAlignment({
|
||||
moving: [center('m', 0.03, 5)],
|
||||
candidates: [center('a', 0, 2)],
|
||||
threshold: 0.1,
|
||||
})
|
||||
expect(result.snap).toEqual({ dx: -0.03, dz: 0 })
|
||||
expect(result.guides).toHaveLength(1)
|
||||
expect(result.guides[0]!.axis).toBe('x')
|
||||
expect(result.guides[0]!.coord).toBe(0)
|
||||
expect(result.guides[0]!.from.z).toBe(2)
|
||||
expect(result.guides[0]!.to.z).toBe(5)
|
||||
})
|
||||
|
||||
test('snaps both axes when both match', () => {
|
||||
const result = resolveAlignment({
|
||||
moving: [center('m', 0.03, 0.04)],
|
||||
candidates: [center('a', 0, 0)],
|
||||
threshold: 0.1,
|
||||
})
|
||||
expect(result.snap).toEqual({ dx: -0.03, dz: -0.04 })
|
||||
expect(result.guides).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('picks closest candidate per axis', () => {
|
||||
const result = resolveAlignment({
|
||||
moving: [center('m', 0.08, 0)],
|
||||
candidates: [center('a', 0, 5), center('b', 0.1, 5), center('c', 0.05, 10)],
|
||||
threshold: 0.1,
|
||||
})
|
||||
// |0.1 - 0.08| = 0.02 wins over |0.05 - 0.08| = 0.03 and |0 - 0.08| = 0.08
|
||||
expect(result.snap?.dx).toBeCloseTo(0.02, 10)
|
||||
expect(result.guides[0]!.candidateNodeId).toBe('b')
|
||||
})
|
||||
|
||||
test('threshold = 0 disables alignment', () => {
|
||||
const result = resolveAlignment({
|
||||
moving: [center('m', 0, 0)],
|
||||
candidates: [center('a', 0, 0)],
|
||||
threshold: 0,
|
||||
})
|
||||
expect(result.guides).toEqual([])
|
||||
expect(result.snap).toBeNull()
|
||||
})
|
||||
|
||||
test('distance is the perpendicular gap to the matched axis', () => {
|
||||
const result = resolveAlignment({
|
||||
moving: [center('m', 0.02, 3)],
|
||||
candidates: [center('a', 0, 0)],
|
||||
threshold: 0.1,
|
||||
})
|
||||
// After snap: moving at (0, 3). X guide runs along x=0 from z=0 to z=3.
|
||||
expect(result.guides[0]!.distance).toBeCloseTo(3, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bboxAnchors', () => {
|
||||
test('returns 9 anchors with correct kinds and positions', () => {
|
||||
const anchors = bboxAnchors('node', 0, 0, 2, 4)
|
||||
expect(anchors).toHaveLength(9)
|
||||
const corners = anchors.filter((a) => a.kind === 'corner')
|
||||
const edges = anchors.filter((a) => a.kind === 'edge-mid')
|
||||
const centers = anchors.filter((a) => a.kind === 'center')
|
||||
expect(corners).toHaveLength(4)
|
||||
expect(edges).toHaveLength(4)
|
||||
expect(centers).toHaveLength(1)
|
||||
expect(centers[0]).toEqual({ nodeId: 'node', kind: 'center', x: 1, z: 2 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Pure alignment-guide resolver — no React, no DOM, no scene access.
|
||||
*
|
||||
* Given a moving object's anchor points at its proposed position and a
|
||||
* pool of candidate anchors from nearby static objects, returns:
|
||||
* - the best per-axis matches as `Guide` rendering primitives, and
|
||||
* - an optional `{ dx, dz }` snap delta the caller can apply.
|
||||
*
|
||||
* Anchors are 2D points on the floor plane (XZ, in world meters). The
|
||||
* resolver picks at most one match per axis: the smallest |Δx| match
|
||||
* snaps X; the smallest |Δz| match snaps Z. This mirrors Figma's
|
||||
* behaviour — guides appear along the matched axes, regardless of how
|
||||
* many neighbours could have matched.
|
||||
*
|
||||
* Two guides max per call keeps the visual signal sharp at the cost of
|
||||
* not surfacing every possible alignment at once. Multi-guide ("this
|
||||
* lines up with three things") is intentionally out of scope for v1.
|
||||
*/
|
||||
|
||||
export type AnchorKind = 'corner' | 'edge-mid' | 'center'
|
||||
|
||||
export type AlignmentAnchor = {
|
||||
/** Owning node id — informational; resolver does not use it. */
|
||||
nodeId: string
|
||||
kind: AnchorKind
|
||||
x: number
|
||||
z: number
|
||||
}
|
||||
|
||||
export type AlignmentGuideAxis = 'x' | 'z'
|
||||
|
||||
/**
|
||||
* Rendering primitive — a guide line on the floor plane.
|
||||
*
|
||||
* `axis === 'x'`: vertical guide. Both endpoints share `coord` as their X.
|
||||
* `axis === 'z'`: horizontal guide. Both endpoints share `coord` as their Z.
|
||||
*
|
||||
* The line spans from the matched candidate anchor to the moving anchor
|
||||
* after snap. Renderers extend visually beyond the endpoints if they want
|
||||
* Figma-style "infinite line" feel.
|
||||
*/
|
||||
export type AlignmentGuide = {
|
||||
axis: AlignmentGuideAxis
|
||||
coord: number
|
||||
from: { x: number; z: number }
|
||||
to: { x: number; z: number }
|
||||
movingAnchorKind: AnchorKind
|
||||
candidateAnchorKind: AnchorKind
|
||||
candidateNodeId: string
|
||||
/** Perpendicular distance between the two anchors (used by the distance pill). */
|
||||
distance: number
|
||||
}
|
||||
|
||||
export type ResolveAlignmentInput = {
|
||||
/** Anchors of the moving node, positioned at the proposed (pre-snap) location. */
|
||||
moving: readonly AlignmentAnchor[]
|
||||
/** Anchors from every other candidate node the caller has already filtered. */
|
||||
candidates: readonly AlignmentAnchor[]
|
||||
/**
|
||||
* Max |Δ| (meters) for an anchor pair to count as a match. Typically
|
||||
* derived from a screen-pixel budget × current units-per-pixel so the
|
||||
* snap feel is zoom-invariant.
|
||||
*/
|
||||
threshold: number
|
||||
}
|
||||
|
||||
export type ResolveAlignmentResult = {
|
||||
guides: AlignmentGuide[]
|
||||
/**
|
||||
* Delta the caller should add to the moving node's planar position so
|
||||
* its anchors land on the matched axes. `null` when no axis matched.
|
||||
*/
|
||||
snap: { dx: number; dz: number } | null
|
||||
}
|
||||
|
||||
const EMPTY: ResolveAlignmentResult = { guides: [], snap: null }
|
||||
|
||||
export function resolveAlignment(input: ResolveAlignmentInput): ResolveAlignmentResult {
|
||||
const { moving, candidates, threshold } = input
|
||||
if (threshold <= 0 || moving.length === 0 || candidates.length === 0) return EMPTY
|
||||
|
||||
// Best match per axis: smallest |Δ| across all (moving, candidate) pairs.
|
||||
// Tie-break by candidate anchor kind priority (center > edge-mid > corner)
|
||||
// so visually meaningful matches win when |Δ| is equal.
|
||||
let bestX: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null
|
||||
let bestZ: { delta: number; m: AlignmentAnchor; c: AlignmentAnchor } | null = null
|
||||
|
||||
for (const m of moving) {
|
||||
for (const c of candidates) {
|
||||
const dx = c.x - m.x
|
||||
const dz = c.z - m.z
|
||||
const adx = Math.abs(dx)
|
||||
const adz = Math.abs(dz)
|
||||
if (adx <= threshold && (bestX === null || adx < Math.abs(bestX.delta))) {
|
||||
bestX = { delta: dx, m, c }
|
||||
}
|
||||
if (adz <= threshold && (bestZ === null || adz < Math.abs(bestZ.delta))) {
|
||||
bestZ = { delta: dz, m, c }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestX && !bestZ) return EMPTY
|
||||
|
||||
const dxSnap = bestX?.delta ?? 0
|
||||
const dzSnap = bestZ?.delta ?? 0
|
||||
const guides: AlignmentGuide[] = []
|
||||
|
||||
if (bestX) {
|
||||
// X-axis match: vertical guide at x = bestX.c.x. The moving anchor
|
||||
// ends up at (c.x, m.z + dzSnap). Span the line between them.
|
||||
const snappedMz = bestX.m.z + dzSnap
|
||||
const z1 = Math.min(bestX.c.z, snappedMz)
|
||||
const z2 = Math.max(bestX.c.z, snappedMz)
|
||||
guides.push({
|
||||
axis: 'x',
|
||||
coord: bestX.c.x,
|
||||
from: { x: bestX.c.x, z: z1 },
|
||||
to: { x: bestX.c.x, z: z2 },
|
||||
movingAnchorKind: bestX.m.kind,
|
||||
candidateAnchorKind: bestX.c.kind,
|
||||
candidateNodeId: bestX.c.nodeId,
|
||||
distance: Math.abs(snappedMz - bestX.c.z),
|
||||
})
|
||||
}
|
||||
|
||||
if (bestZ) {
|
||||
const snappedMx = bestZ.m.x + dxSnap
|
||||
const x1 = Math.min(bestZ.c.x, snappedMx)
|
||||
const x2 = Math.max(bestZ.c.x, snappedMx)
|
||||
guides.push({
|
||||
axis: 'z',
|
||||
coord: bestZ.c.z,
|
||||
from: { x: x1, z: bestZ.c.z },
|
||||
to: { x: x2, z: bestZ.c.z },
|
||||
movingAnchorKind: bestZ.m.kind,
|
||||
candidateAnchorKind: bestZ.c.kind,
|
||||
candidateNodeId: bestZ.c.nodeId,
|
||||
distance: Math.abs(snappedMx - bestZ.c.x),
|
||||
})
|
||||
}
|
||||
|
||||
return { guides, snap: { dx: dxSnap, dz: dzSnap } }
|
||||
}
|
||||
|
||||
// ─── Anchor extractors (pure) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Produces the 9 standard anchors for an axis-aligned bounding box on the
|
||||
* floor plane: 4 corners, 4 edge midpoints, 1 center. Suitable for any
|
||||
* floor-plan entity whose footprint can be expressed as a bbox.
|
||||
*
|
||||
* Caller is responsible for computing the bbox — the resolver doesn't
|
||||
* care how (per-kind dimensions, SVG getBBox(), etc.).
|
||||
*/
|
||||
export function bboxAnchors(
|
||||
nodeId: string,
|
||||
minX: number,
|
||||
minZ: number,
|
||||
maxX: number,
|
||||
maxZ: number,
|
||||
): AlignmentAnchor[] {
|
||||
const cx = (minX + maxX) / 2
|
||||
const cz = (minZ + maxZ) / 2
|
||||
return [
|
||||
{ nodeId, kind: 'corner', x: minX, z: minZ },
|
||||
{ nodeId, kind: 'corner', x: maxX, z: minZ },
|
||||
{ nodeId, kind: 'corner', x: maxX, z: maxZ },
|
||||
{ nodeId, kind: 'corner', x: minX, z: maxZ },
|
||||
{ nodeId, kind: 'edge-mid', x: cx, z: minZ },
|
||||
{ nodeId, kind: 'edge-mid', x: maxX, z: cz },
|
||||
{ nodeId, kind: 'edge-mid', x: cx, z: maxZ },
|
||||
{ nodeId, kind: 'edge-mid', x: minX, z: cz },
|
||||
{ nodeId, kind: 'center', x: cx, z: cz },
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
export {
|
||||
type AlignmentAnchor,
|
||||
type AlignmentGuide,
|
||||
type AlignmentGuideAxis,
|
||||
type AnchorKind,
|
||||
bboxAnchors,
|
||||
resolveAlignment,
|
||||
type ResolveAlignmentInput,
|
||||
type ResolveAlignmentResult,
|
||||
} from './alignment'
|
||||
export {
|
||||
createDragSession,
|
||||
type DragSession,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Ephemeral store for Figma-style alignment guides published during a
|
||||
// move / placement drag. The producer (a tool or move overlay) writes
|
||||
// guides on pointermove; the renderer (a 2D / 3D guide layer) subscribes
|
||||
// and draws them. Both sides clear on commit, cancel, and unmount.
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { AlignmentGuide } from '../services/alignment'
|
||||
|
||||
type AlignmentGuidesState = {
|
||||
guides: AlignmentGuide[]
|
||||
set(guides: AlignmentGuide[]): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
const useAlignmentGuides = create<AlignmentGuidesState>((set) => ({
|
||||
guides: [],
|
||||
set: (guides) => set({ guides }),
|
||||
clear: () => set({ guides: [] }),
|
||||
}))
|
||||
|
||||
export default useAlignmentGuides
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { useAlignmentGuides } from '@pascal-app/core'
|
||||
import { memo } from 'react'
|
||||
import { useFloorplanRender } from './floorplan-render-context'
|
||||
|
||||
/**
|
||||
* Figma-style alignment guides for the 2D floor plan.
|
||||
*
|
||||
* Subscribes to `useAlignmentGuides` — populated by
|
||||
* `FloorplanRegistryMoveOverlay` (Path 2) during a generic free-translate
|
||||
* drag. Each guide renders as a red line between the moving and matched
|
||||
* candidate anchors with small `×` end-caps. A distance pill is drawn at
|
||||
* the line's midpoint when the perpendicular gap is non-zero.
|
||||
*
|
||||
* Stroke widths and handle radii are scaled by `unitsPerPixel` so they
|
||||
* stay a constant size on screen no matter the zoom. Text labels are
|
||||
* counter-rotated by `sceneRotationDeg` so they read upright even when
|
||||
* the building rotation rotates the scene `<g>`.
|
||||
*
|
||||
* Mounted inside the `data-floorplan-scene` group so coordinates match
|
||||
* world meters 1:1 with the rest of the floor plan.
|
||||
*/
|
||||
export const FloorplanAlignmentGuideLayer = memo(function FloorplanAlignmentGuideLayer() {
|
||||
const guides = useAlignmentGuides((s) => s.guides)
|
||||
const ctx = useFloorplanRender()
|
||||
|
||||
if (guides.length === 0) return null
|
||||
|
||||
const upp = ctx?.unitsPerPixel ?? 0.01
|
||||
const sceneRot = ctx?.sceneRotationDeg ?? 0
|
||||
|
||||
// Pixel-budgeted sizes converted to world meters so visuals stay
|
||||
// constant across zoom. Numbers picked to mirror Figma's snap chrome.
|
||||
const stroke = 1 * upp
|
||||
const xCapSize = 4 * upp
|
||||
const pillFontSize = 11 * upp
|
||||
const pillPadX = 5 * upp
|
||||
const pillPadY = 3 * upp
|
||||
const pillRadius = 3 * upp
|
||||
const pillOffset = 8 * upp
|
||||
|
||||
const color = '#ef4444' // tailwind red-500 — matches Figma's snap red
|
||||
|
||||
return (
|
||||
<g pointerEvents="none">
|
||||
{guides.map((guide, i) => {
|
||||
const { from, to, axis } = guide
|
||||
const midX = (from.x + to.x) / 2
|
||||
const midZ = (from.z + to.z) / 2
|
||||
const distMeters = guide.distance
|
||||
|
||||
// Pill placed offset perpendicular to the guide's axis so it
|
||||
// doesn't sit on top of the line itself. For an X-axis guide
|
||||
// (vertical line) we offset along Z; for a Z-axis guide we
|
||||
// offset along X.
|
||||
const pillX = axis === 'x' ? midX + pillOffset : midX
|
||||
const pillZ = axis === 'z' ? midZ + pillOffset : midZ
|
||||
const distLabel = formatMeters(distMeters)
|
||||
const charWidth = pillFontSize * 0.55
|
||||
const pillWidth = distLabel.length * charWidth + pillPadX * 2
|
||||
const pillHeight = pillFontSize + pillPadY * 2
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
<line
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
x1={from.x}
|
||||
x2={to.x}
|
||||
y1={from.z}
|
||||
y2={to.z}
|
||||
/>
|
||||
<XCap color={color} size={xCapSize} stroke={stroke} x={from.x} y={from.z} />
|
||||
<XCap color={color} size={xCapSize} stroke={stroke} x={to.x} y={to.z} />
|
||||
{distMeters > 1e-4 && (
|
||||
// Counter-rotate the pill so it stays upright when the
|
||||
// scene `<g>` is rotated by building rotation. SVG's
|
||||
// `transform` runs in the local coord system, so the
|
||||
// rotation pivots around the pill's center.
|
||||
<g transform={`rotate(${-sceneRot} ${pillX} ${pillZ})`}>
|
||||
<rect
|
||||
fill={color}
|
||||
height={pillHeight}
|
||||
rx={pillRadius}
|
||||
ry={pillRadius}
|
||||
width={pillWidth}
|
||||
x={pillX - pillWidth / 2}
|
||||
y={pillZ - pillHeight / 2}
|
||||
/>
|
||||
<text
|
||||
fill="#ffffff"
|
||||
fontFamily="-apple-system, system-ui, sans-serif"
|
||||
fontSize={pillFontSize}
|
||||
fontWeight={500}
|
||||
textAnchor="middle"
|
||||
x={pillX}
|
||||
y={pillZ + pillFontSize * 0.35}
|
||||
>
|
||||
{distLabel}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
|
||||
function XCap({
|
||||
color,
|
||||
size,
|
||||
stroke,
|
||||
x,
|
||||
y,
|
||||
}: {
|
||||
color: string
|
||||
size: number
|
||||
stroke: number
|
||||
x: number
|
||||
y: number
|
||||
}) {
|
||||
return (
|
||||
<g>
|
||||
<line stroke={color} strokeWidth={stroke} x1={x - size} x2={x + size} y1={y - size} y2={y + size} />
|
||||
<line stroke={color} strokeWidth={stroke} x1={x - size} x2={x + size} y1={y + size} y2={y - size} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function formatMeters(meters: number): string {
|
||||
// Sub-centimetre = "0". Otherwise show with up to 2 decimals, trimmed.
|
||||
if (meters < 0.005) return '0'
|
||||
const fixed = meters.toFixed(2)
|
||||
return `${fixed.replace(/\.?0+$/, '')}m`
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
bboxAnchors,
|
||||
type FloorplanMoveTargetSession,
|
||||
nodeRegistry,
|
||||
pauseSceneHistory,
|
||||
resolveAlignment,
|
||||
resumeSceneHistory,
|
||||
snapPointToGrid,
|
||||
useAlignmentGuides,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
@@ -20,6 +24,12 @@ import { useWallMoveGhosts } from '../../store/use-wall-move-ghosts'
|
||||
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
// Figma-style alignment snap threshold. Meters in world space; 8cm gives
|
||||
// a comfortable "magnetic" pull at default zoom without fighting the
|
||||
// grid snap. Held fixed for v1 — a future revision can scale this with
|
||||
// the SVG's units-per-pixel so the feel stays constant across zoom.
|
||||
const ALIGNMENT_THRESHOLD_M = 0.08
|
||||
|
||||
/**
|
||||
* Cursor-driven placement for registered kinds in the floor plan.
|
||||
*
|
||||
@@ -374,6 +384,24 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
}
|
||||
).position ?? [0, 0, 0]) as [number, number, number]
|
||||
|
||||
// SVG units in this floorplan map 1:1 to world meters, and the
|
||||
// `<g data-node-id>` entry has no transform of its own when at rest,
|
||||
// so its untransformed bbox IS the world-space footprint. Cache the
|
||||
// moving entry's local bbox once (relative to originalPosition) and
|
||||
// derive anchors at any proposed (sx, sz) by translating it.
|
||||
const movingLocalBBox = entry.getBBox()
|
||||
const candidateAnchors: AlignmentAnchor[] = []
|
||||
const allEntries = scene.querySelectorAll('[data-node-id]')
|
||||
for (const el of Array.from(allEntries)) {
|
||||
const otherId = el.getAttribute('data-node-id')
|
||||
if (!otherId || otherId === movingNode.id) continue
|
||||
const b = (el as SVGGraphicsElement).getBBox()
|
||||
if (b.width <= 0 || b.height <= 0) continue
|
||||
candidateAnchors.push(
|
||||
...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height),
|
||||
)
|
||||
}
|
||||
|
||||
let lastSnapped: [number, number] | null = null
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
@@ -384,11 +412,49 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
if (!target?.closest('[data-floorplan-scene]')) return
|
||||
const m = toMeters(event.clientX, event.clientY)
|
||||
if (!m) return
|
||||
const [sx, sz] = snapPointToGrid([m[0], m[1]], GRID_STEP)
|
||||
const dx = sx - originalPosition[0]
|
||||
const dz = sz - originalPosition[2]
|
||||
|
||||
// 1) Grid snap baseline (unchanged behaviour with Alt held).
|
||||
const [gridX, gridZ] = snapPointToGrid([m[0], m[1]], GRID_STEP)
|
||||
|
||||
// 2) Alignment snap layered on top. Treat the grid-snapped point
|
||||
// as the "proposed" position so alignment competes from a stable
|
||||
// base rather than the raw cursor jitter. Alt bypasses alignment
|
||||
// entirely — same affordance Path 1 advertises in its "No Snap"
|
||||
// hint chip.
|
||||
let finalX = gridX
|
||||
let finalZ = gridZ
|
||||
if (!event.altKey && candidateAnchors.length > 0) {
|
||||
// Translate the cached local bbox to the proposed pos to get the
|
||||
// moving anchors at that location. The entry's untransformed
|
||||
// bbox is in world meters relative to the node's origin, so a
|
||||
// simple translate suffices.
|
||||
const dxProposed = gridX - originalPosition[0]
|
||||
const dzProposed = gridZ - originalPosition[2]
|
||||
const movingAnchors = bboxAnchors(
|
||||
movingNode.id,
|
||||
movingLocalBBox.x + dxProposed,
|
||||
movingLocalBBox.y + dzProposed,
|
||||
movingLocalBBox.x + movingLocalBBox.width + dxProposed,
|
||||
movingLocalBBox.y + movingLocalBBox.height + dzProposed,
|
||||
)
|
||||
const result = resolveAlignment({
|
||||
moving: movingAnchors,
|
||||
candidates: candidateAnchors,
|
||||
threshold: ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
if (result.snap) {
|
||||
finalX += result.snap.dx
|
||||
finalZ += result.snap.dz
|
||||
}
|
||||
useAlignmentGuides.getState().set(result.guides)
|
||||
} else {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
const dx = finalX - originalPosition[0]
|
||||
const dz = finalZ - originalPosition[2]
|
||||
entry.setAttribute('transform', `translate(${dx} ${dz})`)
|
||||
lastSnapped = [sx, sz]
|
||||
lastSnapped = [finalX, finalZ]
|
||||
}
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
@@ -414,12 +480,14 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
}
|
||||
}
|
||||
entry.removeAttribute('transform')
|
||||
useAlignmentGuides.getState().clear()
|
||||
setMovingNode(null)
|
||||
}
|
||||
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
entry.removeAttribute('transform')
|
||||
useAlignmentGuides.getState().clear()
|
||||
setMovingNode(null)
|
||||
}
|
||||
}
|
||||
@@ -432,6 +500,7 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
entry.removeAttribute('transform')
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type FloorplanAffordanceSession,
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPalette,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
kindsWithFloorplanScope,
|
||||
nodeRegistry,
|
||||
@@ -85,6 +86,26 @@ type ActiveDrag = {
|
||||
session: FloorplanAffordanceSession
|
||||
snapshots: NodeSnapshot[]
|
||||
historyPaused: boolean
|
||||
/**
|
||||
* Set only for rotate-arrow drags (handles that carry a `pivot`). Drives
|
||||
* the live angle wedge + degree readout — the 2D twin of the 3D rotate
|
||||
* gizmo's readout. The bearing sweep is measured the same way every
|
||||
* rotate affordance measures it: `atan2(pointer − pivot)`.
|
||||
*/
|
||||
rotation?: { pivot: FloorplanPoint; initialAngle: number; radius: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient live-rotation readout state. Rebuilt each pointer-move while a
|
||||
* rotate-arrow is dragged and cleared on release. World-plan coords.
|
||||
*/
|
||||
type RotationOverlayState = {
|
||||
pivot: FloorplanPoint
|
||||
startAngle: number
|
||||
endAngle: number
|
||||
radius: number
|
||||
/** Swept magnitude in radians, for the degree chip. */
|
||||
sweep: number
|
||||
}
|
||||
|
||||
function snapshotNode(node: AnyNode): NodeSnapshot {
|
||||
@@ -193,6 +214,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const dragRef = useRef<ActiveDrag | null>(null)
|
||||
const [hoveredHandleId, setHoveredHandleId] = useState<string | null>(null)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [rotationOverlay, setRotationOverlay] = useState<RotationOverlayState | null>(null)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
|
||||
@@ -436,6 +458,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
affordance: string,
|
||||
payload: unknown,
|
||||
event: ReactPointerEvent<SVGGElement>,
|
||||
// Present only for rotate-arrow handles — the pivot the node turns
|
||||
// around, used to drive the live angle wedge + degree readout.
|
||||
rotationPivot?: FloorplanPoint,
|
||||
) => {
|
||||
if (event.button !== 0) return
|
||||
if (movingNode) return
|
||||
@@ -469,12 +494,28 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
|
||||
// Rotation readout setup. The wedge radius tracks the grab distance
|
||||
// from the pivot (≈ the handle's orbit), nudged inward so the swept
|
||||
// fill reads as the handle swinging round rather than overlapping it,
|
||||
// and floored so a tight footprint still shows a legible wedge.
|
||||
let rotation: ActiveDrag['rotation']
|
||||
if (rotationPivot) {
|
||||
const dx = initialPlanPoint[0] - rotationPivot[0]
|
||||
const dz = initialPlanPoint[1] - rotationPivot[1]
|
||||
rotation = {
|
||||
pivot: rotationPivot,
|
||||
initialAngle: Math.atan2(dz, dx),
|
||||
radius: Math.max(Math.hypot(dx, dz) * 0.72, 0.25),
|
||||
}
|
||||
}
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
handleId,
|
||||
session,
|
||||
snapshots,
|
||||
historyPaused: true,
|
||||
rotation,
|
||||
}
|
||||
setActiveDragId(handleId)
|
||||
setSelection({ selectedIds: [nodeId] })
|
||||
@@ -500,6 +541,30 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
metaKey: event.metaKey,
|
||||
},
|
||||
})
|
||||
|
||||
// Live rotation readout. Sweep from the bearing at grab to the
|
||||
// current pointer bearing around the pivot — the same measurement
|
||||
// every rotate affordance applies — and surface it as a wedge +
|
||||
// degree chip. Suppressed below ~0.5° so a fresh grab doesn't flash
|
||||
// a zero-width sliver.
|
||||
const rot = drag.rotation
|
||||
if (rot) {
|
||||
const current = Math.atan2(planPoint[1] - rot.pivot[1], planPoint[0] - rot.pivot[0])
|
||||
let delta = current - rot.initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
if (Math.abs(delta) < 0.0087) {
|
||||
setRotationOverlay(null)
|
||||
} else {
|
||||
setRotationOverlay({
|
||||
pivot: rot.pivot,
|
||||
startAngle: rot.initialAngle,
|
||||
endAngle: rot.initialAngle + delta,
|
||||
radius: rot.radius,
|
||||
sweep: Math.abs(delta),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
@@ -524,6 +589,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
dragRef.current = null
|
||||
setActiveDragId(null)
|
||||
setRotationOverlay(null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -574,6 +640,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
|
||||
dragRef.current = null
|
||||
setActiveDragId(null)
|
||||
setRotationOverlay(null)
|
||||
}
|
||||
|
||||
const onPointerCancel = (event: PointerEvent) => {
|
||||
@@ -595,6 +662,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
|
||||
dragRef.current = null
|
||||
setActiveDragId(null)
|
||||
setRotationOverlay(null)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
@@ -656,8 +724,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
hoveredHandleId={hoveredHandleId}
|
||||
nodeId={id}
|
||||
onHandleHoverChange={setHoveredHandleId}
|
||||
onHandlePointerDown={(affordance, payload, event) =>
|
||||
startAffordanceDrag(id, makeHandleId(id, payload), affordance, payload, event)
|
||||
onHandlePointerDown={(affordance, payload, event, rotationPivot) =>
|
||||
startAffordanceDrag(
|
||||
id,
|
||||
makeHandleId(id, payload),
|
||||
affordance,
|
||||
payload,
|
||||
event,
|
||||
rotationPivot,
|
||||
)
|
||||
}
|
||||
onMoveHandlePointerDown={(event) => {
|
||||
if (event.button !== 0) return
|
||||
@@ -715,6 +790,16 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
overlay ? renderEntry(id, overlay, `overlay-${id}`) : null,
|
||||
)}
|
||||
</g>
|
||||
{/* Transient live-rotation readout — drawn last so the wedge + degree
|
||||
chip sit above all handle chrome while a rotate-arrow is dragged. */}
|
||||
{rotationOverlay && palette ? (
|
||||
<RotationAngleOverlay
|
||||
overlay={rotationOverlay}
|
||||
palette={palette}
|
||||
sceneRotationDeg={renderCtx?.sceneRotationDeg ?? 0}
|
||||
unitsPerPixel={unitsPerPixel}
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
@@ -747,6 +832,9 @@ function InteractiveGeometry({
|
||||
affordance: string,
|
||||
payload: unknown,
|
||||
event: ReactPointerEvent<SVGGElement>,
|
||||
// Forwarded only by rotate-arrow handles — the pivot the drag turns
|
||||
// the node around, used to drive the live angle wedge + degree chip.
|
||||
rotationPivot?: FloorplanPoint,
|
||||
) => void
|
||||
onMoveHandlePointerDown: (event: ReactPointerEvent<SVGGElement>) => void
|
||||
}): React.ReactElement {
|
||||
@@ -1010,6 +1098,7 @@ function InteractiveGeometry({
|
||||
const angleDeg = (g.angle * 180) / Math.PI
|
||||
const affordance = g.affordance
|
||||
const payload = g.payload
|
||||
const pivot = g.pivot
|
||||
return (
|
||||
<g
|
||||
key={keyHint}
|
||||
@@ -1034,7 +1123,12 @@ function InteractiveGeometry({
|
||||
d={arcPath}
|
||||
fill="none"
|
||||
onPointerDown={(e) =>
|
||||
onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGPathElement>)
|
||||
onHandlePointerDown(
|
||||
affordance,
|
||||
payload,
|
||||
e as ReactPointerEvent<SVGPathElement>,
|
||||
pivot,
|
||||
)
|
||||
}
|
||||
onPointerEnter={() => onHandleHoverChange(handleId)}
|
||||
onPointerLeave={() => onHandleHoverChange(null)}
|
||||
@@ -1047,7 +1141,12 @@ function InteractiveGeometry({
|
||||
d={`${head1} ${head2}`}
|
||||
fill="transparent"
|
||||
onPointerDown={(e) =>
|
||||
onHandlePointerDown(affordance, payload, e as ReactPointerEvent<SVGPathElement>)
|
||||
onHandlePointerDown(
|
||||
affordance,
|
||||
payload,
|
||||
e as ReactPointerEvent<SVGPathElement>,
|
||||
pivot,
|
||||
)
|
||||
}
|
||||
onPointerEnter={() => onHandleHoverChange(handleId)}
|
||||
onPointerLeave={() => onHandleHoverChange(null)}
|
||||
@@ -1370,9 +1469,17 @@ function InteractiveGeometry({
|
||||
const gapStart: [number, number] = [midX - dirX * gapHalf, midY - dirY * gapHalf]
|
||||
const gapEnd: [number, number] = [midX + dirX * gapHalf, midY + dirY * gapHalf]
|
||||
|
||||
// Keep the label parallel to the dimension line, but decide the
|
||||
// 180° flip from the on-SCREEN angle, not the local one. The parent
|
||||
// `<g>` is rotated by `sceneRotationDeg` (default 90° in the floor
|
||||
// plan), so a label kept upright in local coords still renders
|
||||
// upside down for half of the wall orientations. Same fix as the
|
||||
// `dimension-label` case above.
|
||||
let labelDeg = (Math.atan2(dy, dx) * 180) / Math.PI
|
||||
if (labelDeg > 90) labelDeg -= 180
|
||||
else if (labelDeg <= -90) labelDeg += 180
|
||||
let screenDeg = labelDeg + sceneRotationDeg
|
||||
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
|
||||
if (screenDeg > 90) labelDeg -= 180
|
||||
else if (screenDeg <= -90) labelDeg += 180
|
||||
|
||||
return (
|
||||
<g key={keyHint} pointerEvents="none">
|
||||
@@ -1651,6 +1758,95 @@ function deepEqual(a: unknown, b: unknown): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const ROTATION_WEDGE_COLOR = '#8381ed'
|
||||
const ROTATION_WEDGE_SEGMENTS = 48
|
||||
|
||||
/**
|
||||
* Live rotation readout for the floor plan — the 2D twin of the 3D rotate
|
||||
* gizmo's wedge. Draws a filled sector + outline swept from the pointer's
|
||||
* bearing at grab (`startAngle`) to its current bearing (`endAngle`) around
|
||||
* the pivot, plus an upright degree chip at the wedge midpoint. All geometry
|
||||
* is in plan coords; the chip counter-rotates `sceneRotationDeg` so it reads
|
||||
* horizontally regardless of the building's on-screen orientation.
|
||||
*/
|
||||
function RotationAngleOverlay({
|
||||
overlay,
|
||||
palette,
|
||||
unitsPerPixel,
|
||||
sceneRotationDeg,
|
||||
}: {
|
||||
overlay: RotationOverlayState
|
||||
palette: FloorplanPalette
|
||||
unitsPerPixel: number
|
||||
sceneRotationDeg: number
|
||||
}): React.ReactElement {
|
||||
const { pivot, startAngle, endAngle, radius, sweep } = overlay
|
||||
const span = endAngle - startAngle
|
||||
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_WEDGE_SEGMENTS))
|
||||
let d = `M ${pivot[0]} ${pivot[1]}`
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const a = startAngle + (span * i) / count
|
||||
d += ` L ${pivot[0] + Math.cos(a) * radius} ${pivot[1] + Math.sin(a) * radius}`
|
||||
}
|
||||
d += ' Z'
|
||||
|
||||
const midAngle = startAngle + span / 2
|
||||
const labelDist = radius + unitsPerPixel * 14
|
||||
const lx = pivot[0] + Math.cos(midAngle) * labelDist
|
||||
const ly = pivot[1] + Math.sin(midAngle) * labelDist
|
||||
|
||||
const text = `${Math.round((sweep * 180) / Math.PI)}°`
|
||||
const padX = unitsPerPixel * 6
|
||||
const padY = unitsPerPixel * 3
|
||||
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
||||
const textWidth = text.length * unitsPerPixel * 6.2
|
||||
const plateW = textWidth + padX * 2
|
||||
const plateH = fontSize + padY * 2
|
||||
|
||||
return (
|
||||
<g className="floorplan-rotation-readout" pointerEvents="none">
|
||||
<path d={d} fill={ROTATION_WEDGE_COLOR} fillOpacity={0.18} stroke="none" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={ROTATION_WEDGE_COLOR}
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.95}
|
||||
strokeWidth={1.8}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{/* Counter-rotate the scene transform so the chip stays horizontal. */}
|
||||
<g transform={`translate(${lx} ${ly}) rotate(${-sceneRotationDeg})`}>
|
||||
<rect
|
||||
fill={palette.measurementLabelBackground}
|
||||
height={plateH}
|
||||
opacity={0.92}
|
||||
rx={unitsPerPixel * 3}
|
||||
ry={unitsPerPixel * 3}
|
||||
stroke={palette.measurementStroke}
|
||||
strokeWidth={unitsPerPixel * 0.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
width={plateW}
|
||||
x={-plateW / 2}
|
||||
y={-plateH / 2}
|
||||
/>
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill={palette.measurementLabelText}
|
||||
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||
fontSize={fontSize}
|
||||
fontWeight={600}
|
||||
textAnchor="middle"
|
||||
x={0}
|
||||
y={0}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function formatGroupTransform(t?: {
|
||||
translate?: readonly [number, number]
|
||||
rotate?: number
|
||||
|
||||
@@ -545,12 +545,12 @@ export const CustomCameraControls = () => {
|
||||
}
|
||||
|
||||
// Preset capture mode frames a single subtree (often a 0.3–2m preset),
|
||||
// so the default 10m minDistance prevents the user from getting close
|
||||
// so the default 6m minDistance prevents the user from getting close
|
||||
// enough to compose a good thumbnail. Relax the clamp to 0.5m while
|
||||
// capturing presets; reset on exit so general editing keeps the looser
|
||||
// navigation guardrails.
|
||||
const isPresetCapture = captureMode.mode === 'preset'
|
||||
const minDistance = isPresetCapture ? 0.5 : 10
|
||||
const minDistance = isPresetCapture ? 0.5 : 6
|
||||
|
||||
return (
|
||||
<CameraControls
|
||||
|
||||
@@ -56,6 +56,24 @@ function isColliderMaterialVisible(material: THREE.Material | THREE.Material[])
|
||||
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
|
||||
}
|
||||
|
||||
// Decode any attribute (interleaved, quantized/normalized integer, Float64…) into a
|
||||
// plain, non-normalized Float32Array BufferAttribute. mergeGeometries() requires every
|
||||
// merged geometry to share the same typed-array constructor for matching attributes, so
|
||||
// imported item GLBs using KHR_mesh_quantization or interleaved buffers must be coerced
|
||||
// to Float32 to match wall/slab geometry.
|
||||
function toFloat32Attribute(source: THREE.BufferAttribute | THREE.InterleavedBufferAttribute) {
|
||||
const itemSize = source.itemSize
|
||||
const array = new Float32Array(source.count * itemSize)
|
||||
for (let i = 0; i < source.count; i++) {
|
||||
const offset = i * itemSize
|
||||
array[offset] = source.getX(i)
|
||||
if (itemSize > 1) array[offset + 1] = source.getY(i)
|
||||
if (itemSize > 2) array[offset + 2] = source.getZ(i)
|
||||
if (itemSize > 3) array[offset + 3] = source.getW(i)
|
||||
}
|
||||
return new THREE.BufferAttribute(array, itemSize)
|
||||
}
|
||||
|
||||
function cloneWorldGeometry(mesh: THREE.Mesh) {
|
||||
const sourceGeometry = mesh.geometry
|
||||
const position = sourceGeometry.getAttribute('position')
|
||||
@@ -65,11 +83,11 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
|
||||
? sourceGeometry.toNonIndexed()
|
||||
: sourceGeometry.clone()
|
||||
const cleanGeometry = new THREE.BufferGeometry()
|
||||
cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone())
|
||||
cleanGeometry.setAttribute('position', toFloat32Attribute(workingGeometry.getAttribute('position')))
|
||||
|
||||
const normal = workingGeometry.getAttribute('normal')
|
||||
if (normal) {
|
||||
cleanGeometry.setAttribute('normal', normal.clone())
|
||||
cleanGeometry.setAttribute('normal', toFloat32Attribute(normal))
|
||||
} else {
|
||||
cleanGeometry.computeVertexNormals()
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
ColumnNode,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
DoorNode,
|
||||
ElevatorNode,
|
||||
FenceNode,
|
||||
generateId,
|
||||
getWallCurveLength,
|
||||
getWallThickness,
|
||||
ItemNode,
|
||||
isRegistryMovable,
|
||||
isRegistrySelectable,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
@@ -32,6 +36,7 @@ import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
||||
import { NodeActionMenu } from './node-action-menu'
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
@@ -76,9 +81,10 @@ const MENU_Y_OFFSETS: Record<string, number> = {
|
||||
door: 0.6,
|
||||
window: 0.6,
|
||||
column: 0.6,
|
||||
// Fence: clears the height-resize arrow (sits at fence.height + 0.45)
|
||||
// plus the chevron's own visual size, so the menu floats just above it.
|
||||
fence: 1.05,
|
||||
// Fence: still clears the height-resize arrow (sits at fence.height +
|
||||
// 0.45) plus the chevron's visual size, but kept low so the menu sits
|
||||
// close to the fence rather than floating well above it.
|
||||
fence: 0.7,
|
||||
// Elevator: clears the cab-height arrow which sits above the SHAFT
|
||||
// top (resolved through level entries), so the menu floats above it.
|
||||
elevator: 0.9,
|
||||
@@ -105,6 +111,32 @@ function getMenuYOffset(node: AnyNode | null): number {
|
||||
return (MENU_Y_OFFSETS[node.type] ?? MENU_Y_OFFSET_DEFAULT) + EXTRA_MENU_LIFT
|
||||
}
|
||||
|
||||
// Fence schema defaults — mirror packages/nodes/src/fence/definition.ts so the
|
||||
// pill reads sensibly before an explicit height / thickness is set.
|
||||
const FENCE_DEFAULT_HEIGHT = 1.8
|
||||
const FENCE_DEFAULT_THICKNESS = 0.08
|
||||
|
||||
// Dimensions for the height-drag pill. Walls and fences both carry
|
||||
// start/end/curveOffset, so getWallCurveLength covers length for either.
|
||||
function getHeightPillDimensions(node: WallNode | FenceNode): {
|
||||
height: number
|
||||
length: number
|
||||
thickness: number
|
||||
} {
|
||||
if (node.type === 'wall') {
|
||||
return {
|
||||
height: node.height ?? DEFAULT_WALL_HEIGHT,
|
||||
length: getWallCurveLength(node),
|
||||
thickness: getWallThickness(node),
|
||||
}
|
||||
}
|
||||
return {
|
||||
height: node.height ?? FENCE_DEFAULT_HEIGHT,
|
||||
length: getWallCurveLength(node),
|
||||
thickness: node.thickness ?? FENCE_DEFAULT_THICKNESS,
|
||||
}
|
||||
}
|
||||
|
||||
export function FloatingActionMenu() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
@@ -118,9 +150,31 @@ export function FloatingActionMenu() {
|
||||
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
// Drives the height-drag dimension pill below the menu. `activeHandleDrag`
|
||||
// flips only at drag start / end, so subscribing here is cheap — the live
|
||||
// height value is written imperatively in the useFrame below.
|
||||
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
||||
|
||||
const groupRef = useRef<THREE.Group>(null)
|
||||
const menuScaleRef = useRef<HTMLDivElement>(null)
|
||||
const pillHeightRef = useRef<HTMLSpanElement>(null)
|
||||
|
||||
// Cached world anchor. The anchor is derived from `Box3.setFromObject`,
|
||||
// which traverses the selected object's children — so a node with a
|
||||
// continuously-animating child (the spinning turbine-vent head) makes the
|
||||
// AABB wobble a few millimetres every frame, and the menu drifts even with
|
||||
// a still camera. We instead recompute the box only when the selection,
|
||||
// the object's world transform, or its geometry actually changes, and
|
||||
// reuse the cached anchor otherwise. (Also removes a per-frame
|
||||
// `setFromObject` for every selection.)
|
||||
const anchorRef = useRef(new THREE.Vector3())
|
||||
const hasAnchorRef = useRef(false)
|
||||
const lastMatrixRef = useRef(new THREE.Matrix4())
|
||||
const lastAnchorKeyRef = useRef<{ id: string | null; node: AnyNode | null }>({
|
||||
id: null,
|
||||
node: null,
|
||||
})
|
||||
|
||||
// Only show for single selection of specific types
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
|
||||
@@ -135,6 +189,17 @@ export function FloatingActionMenu() {
|
||||
? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type)
|
||||
: false
|
||||
|
||||
// Height-drag pill: shown just above the menu only while the selected
|
||||
// wall/fence height arrow is being dragged. Length + thickness are fixed
|
||||
// during a height drag, so they're computed here; the live height value
|
||||
// is updated imperatively in the useFrame (same pattern as the scale).
|
||||
const pillNode = node?.type === 'wall' || node?.type === 'fence' ? node : null
|
||||
const isHeightDragPill =
|
||||
pillNode !== null &&
|
||||
activeHandleDrag?.nodeId === selectedId &&
|
||||
activeHandleDrag?.label === 'height'
|
||||
const pillDims = pillNode ? getHeightPillDimensions(pillNode) : null
|
||||
|
||||
// Boolean selector, only re-renders when curving availability actually flips.
|
||||
const canCurveSelectedWall = useScene((s) => {
|
||||
if (!selectedId) return false
|
||||
@@ -169,15 +234,52 @@ export function FloatingActionMenu() {
|
||||
menuScaleRef.current.style.transform = `scale(${scale})`
|
||||
}
|
||||
|
||||
// Live height readout for the drag pill. The dragged height lands in
|
||||
// `useLiveNodeOverrides` (not the scene store) each frame, so read it
|
||||
// imperatively here instead of forcing a per-frame React re-render.
|
||||
if (
|
||||
pillHeightRef.current &&
|
||||
(node?.type === 'wall' || node?.type === 'fence') &&
|
||||
activeHandleDrag?.nodeId === selectedId &&
|
||||
activeHandleDrag?.label === 'height'
|
||||
) {
|
||||
const override = useLiveNodeOverrides.getState().overrides.get(selectedId) as
|
||||
| { height?: number }
|
||||
| undefined
|
||||
const fallbackHeight = node.type === 'wall' ? DEFAULT_WALL_HEIGHT : FENCE_DEFAULT_HEIGHT
|
||||
const liveHeight = override?.height ?? node.height ?? fallbackHeight
|
||||
pillHeightRef.current.textContent = `H ${formatMeasurement(liveHeight, unit)}`
|
||||
}
|
||||
|
||||
const obj = sceneRegistry.nodes.get(selectedId)
|
||||
if (obj) {
|
||||
// Calculate bounding box in world space
|
||||
// Recompute the anchor only when the object genuinely changes —
|
||||
// reselected, moved (its own world matrix changed), or resized
|
||||
// (a fresh store node on commit, or a live override / handle drag
|
||||
// mid-resize). A spinning child changes the head's matrix, not the
|
||||
// registered group's, so it never triggers a recompute → the menu
|
||||
// holds still.
|
||||
const overrideActive = useLiveNodeOverrides.getState().overrides.get(selectedId) != null
|
||||
const dragActive = activeHandleDrag?.nodeId === selectedId
|
||||
const selectionChanged =
|
||||
lastAnchorKeyRef.current.id !== selectedId || lastAnchorKeyRef.current.node !== node
|
||||
const matrixChanged = !lastMatrixRef.current.equals(obj.matrixWorld)
|
||||
|
||||
if (selectionChanged || matrixChanged || overrideActive || dragActive) {
|
||||
const box = new THREE.Box3().setFromObject(obj)
|
||||
if (!box.isEmpty()) {
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
// Position above the object. Per-type offsets clear each kind's
|
||||
// in-world chrome (height-resize arrows, measurement labels).
|
||||
groupRef.current.position.set(center.x, box.max.y + getMenuYOffset(node), center.z)
|
||||
anchorRef.current.set(center.x, box.max.y + getMenuYOffset(node), center.z)
|
||||
hasAnchorRef.current = true
|
||||
}
|
||||
lastMatrixRef.current.copy(obj.matrixWorld)
|
||||
lastAnchorKeyRef.current = { id: selectedId, node }
|
||||
}
|
||||
|
||||
if (hasAnchorRef.current) {
|
||||
groupRef.current.position.copy(anchorRef.current)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -313,15 +415,22 @@ export function FloatingActionMenu() {
|
||||
}
|
||||
|
||||
// Duplicate children for stair nodes
|
||||
} else if (duplicate.type === 'chimney' || duplicate.type === 'dormer') {
|
||||
// Chimney & dormer use pure drag-to-place: NO node is
|
||||
// inserted into the scene until the user clicks a roof
|
||||
// segment. The `setMovingNode` call below hands the clone
|
||||
// (with `metadata.isNew = true` + no id) to
|
||||
// `MoveChimneyTool` / `MoveDormerTool`, which call
|
||||
// `createNode` on the click that commits the placement.
|
||||
// Skipping the auto-create avoids the "duplicate appears at
|
||||
// +1 offset before drag" UX the other registry kinds use.
|
||||
} else if (
|
||||
duplicate.type === 'item' ||
|
||||
duplicate.type === 'chimney' ||
|
||||
duplicate.type === 'dormer'
|
||||
) {
|
||||
// Items, chimneys & dormers use pure drag-to-place: NO node is
|
||||
// inserted into the scene until the user clicks to commit. The
|
||||
// `setMovingNode` call below hands the clone (with
|
||||
// `metadata.isNew = true` + no id) to its move tool —
|
||||
// `MoveItemTool` / `MoveChimneyTool` / `MoveDormerTool` — which
|
||||
// create a draft and call `createNode` on the commit click.
|
||||
// Pre-creating here would drop a second copy into the scene
|
||||
// before any click — the furnish-tab "duplicate auto-places an
|
||||
// item without clicking" bug. (Item has its own
|
||||
// draft-committing move tool, so it must skip the generic
|
||||
// registry auto-create branch below.)
|
||||
} else if (nodeRegistry.has(duplicate.type)) {
|
||||
// Registry-driven kinds: offset the position slightly so the
|
||||
// duplicate doesn't overlap exactly, then create + hand to the
|
||||
@@ -435,7 +544,7 @@ export function FloatingActionMenu() {
|
||||
}}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
|
||||
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
|
||||
<NodeActionMenu
|
||||
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
||||
onCurve={
|
||||
@@ -444,12 +553,10 @@ export function FloatingActionMenu() {
|
||||
: undefined
|
||||
}
|
||||
onMove={
|
||||
// Registry-driven: any kind that declares
|
||||
// Fully registry-driven: any kind that declares
|
||||
// `capabilities.movable`, a `floorplanMoveTarget`, or a
|
||||
// 3D `affordanceTools.move` mover gets the Move button.
|
||||
// Replaces the previous 13-arm `node?.type === '…'`
|
||||
// chain so adding a new movable kind doesn't touch this
|
||||
// file.
|
||||
// Adding a new movable kind never touches this file.
|
||||
node && isRegistryMovable(node.type) ? handleMove : undefined
|
||||
}
|
||||
onDelete={handleDelete}
|
||||
@@ -464,6 +571,23 @@ export function FloatingActionMenu() {
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{/* Height-drag dimension pill. Absolutely positioned just above
|
||||
the menu (away from the height arrow below it) so it rides the
|
||||
same scale transform + anchor, never overlaps the menu, and
|
||||
needs no menu lift — which is what caused the click flicker.
|
||||
Non-interactive. */}
|
||||
{isHeightDragPill && pillDims ? (
|
||||
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2">
|
||||
<MeasurementPill
|
||||
height={pillDims.height}
|
||||
length={pillDims.length}
|
||||
primary="height"
|
||||
ref={pillHeightRef}
|
||||
thickness={pillDims.thickness}
|
||||
unit={unit}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Vector3 } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
buildFloorplanItemEntry,
|
||||
@@ -73,6 +74,7 @@ import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { GuideUiState } from '../../store/use-editor'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
|
||||
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
|
||||
@@ -4516,6 +4518,21 @@ export function FloorplanPanel() {
|
||||
const displaySlabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
|
||||
const ceilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
|
||||
const displayCeilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
|
||||
// Ceilings on the active level, projected to 2D polygons for hit-testing
|
||||
// ceiling-item placement clicks/moves. The legacy `ceilingPolygons` above
|
||||
// is intentionally empty (ceilings render via the registry layer); this
|
||||
// memo is the placement-side counterpart, separate from rendering.
|
||||
const ceilingHitEntries = useMemo(
|
||||
() =>
|
||||
ceilings.map((ceiling) => ({
|
||||
ceiling,
|
||||
polygon: toFloorplanPolygon(ceiling.polygon),
|
||||
holes: (ceiling.holes ?? [])
|
||||
.map((hole) => toFloorplanPolygon(hole))
|
||||
.filter((hole) => hole.length >= 3),
|
||||
})),
|
||||
[ceilings],
|
||||
)
|
||||
// Zone fully registry-driven via `def.floorplan`.
|
||||
const zonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
|
||||
const displayZonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
|
||||
@@ -4799,6 +4816,18 @@ export function FloorplanPanel() {
|
||||
(mode === 'build' && tool === 'item') || movingNode?.type === 'item'
|
||||
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
|
||||
const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo
|
||||
// Ceiling-attached items (lights, fans). The 3D viewer drives these via
|
||||
// `ceiling:enter/move/click` raycast events on the ceiling mesh; the 2D
|
||||
// floor plan has no such mesh, so we synthesise the same events when the
|
||||
// cursor is over a ceiling polygon. Without this the placement system
|
||||
// never transitions out of `surface: 'floor'` and the draft sits at floor
|
||||
// height while the 2D cursor moves freely — what the user perceived as
|
||||
// "2D and 3D positions are out of sync".
|
||||
const isCeilingItemBuildActive =
|
||||
mode === 'build' && tool === 'item' && selectedItem?.attachTo === 'ceiling'
|
||||
const isCeilingItemMoveActive =
|
||||
movingNode?.type === 'item' && movingNode.asset.attachTo === 'ceiling'
|
||||
const isCeilingItemPlacementActive = isCeilingItemBuildActive || isCeilingItemMoveActive
|
||||
// Any registry-driven kind whose tool is currently active. Lets the floor
|
||||
// plan emit `grid:click` / `grid:move` events to that kind's placement tool
|
||||
// (shelf today; future Phase 5 kinds the moment they register a `tool`).
|
||||
@@ -7119,6 +7148,7 @@ export function FloorplanPanel() {
|
||||
}, [])
|
||||
|
||||
const hoveredWallIdRef = useRef<string | null>(null)
|
||||
const hoveredCeilingIdRef = useRef<string | null>(null)
|
||||
const floorplanGridLocalY = useMemo(() => {
|
||||
if (movingNode?.type === 'item' || movingNode?.type === 'spawn') {
|
||||
return movingNode.position[1]
|
||||
@@ -7171,6 +7201,156 @@ export function FloorplanPanel() {
|
||||
[buildingPosition, buildingRotationY, floorplanGridLocalY, floorplanGridWorldY],
|
||||
)
|
||||
|
||||
// Build a synthetic `CeilingEvent` from a 2D plan point so the placement
|
||||
// coordinator's existing ceiling handlers (which expect the same payload
|
||||
// shape the 3D raycaster produces) can drive a ceiling-attached item
|
||||
// placement from the floor plan. Returns null if the ceiling mesh isn't
|
||||
// registered yet — the placement strategy needs `event.object` for the
|
||||
// ceiling-local↔world transform.
|
||||
const buildFloorplanCeilingEventPayload = useCallback(
|
||||
(
|
||||
ceiling: CeilingNode,
|
||||
planPoint: WallPlanPoint,
|
||||
nativeEvent?: ReactMouseEvent<SVGSVGElement> | ReactPointerEvent<SVGSVGElement>,
|
||||
) => {
|
||||
const ceilingMesh = sceneRegistry.nodes.get(ceiling.id as AnyNodeId)
|
||||
if (!ceilingMesh) return null
|
||||
|
||||
const cos = Math.cos(buildingRotationY)
|
||||
const sin = Math.sin(buildingRotationY)
|
||||
const worldX = buildingPosition[0] + planPoint[0] * cos + planPoint[1] * sin
|
||||
const worldZ = buildingPosition[2] - planPoint[0] * sin + planPoint[1] * cos
|
||||
const worldY = ceilingMesh.getWorldPosition(new Vector3()).y
|
||||
const localVec = ceilingMesh.worldToLocal(new Vector3(worldX, worldY, worldZ))
|
||||
|
||||
return {
|
||||
node: ceiling,
|
||||
position: [worldX, worldY, worldZ] as [number, number, number],
|
||||
localPosition: [localVec.x, localVec.y, localVec.z] as [number, number, number],
|
||||
normal: [0, -1, 0] as [number, number, number],
|
||||
object: ceilingMesh,
|
||||
stopPropagation: () => {},
|
||||
nativeEvent: nativeEvent?.nativeEvent,
|
||||
}
|
||||
},
|
||||
[buildingPosition, buildingRotationY],
|
||||
)
|
||||
|
||||
const emitFloorplanCeilingLeave = useCallback((ceilingId: string | null) => {
|
||||
if (!ceilingId) return
|
||||
const ceilingNode = useScene.getState().nodes[ceilingId as AnyNodeId]
|
||||
if (!ceilingNode || ceilingNode.type !== 'ceiling') return
|
||||
|
||||
emitter.emit('ceiling:leave', {
|
||||
node: ceilingNode,
|
||||
position: [0, 0, 0],
|
||||
localPosition: [0, 0, 0],
|
||||
normal: [0, -1, 0],
|
||||
stopPropagation: () => {},
|
||||
} as any)
|
||||
}, [])
|
||||
|
||||
// Clear the hovered-ceiling tracker whenever placement mode ends, so we
|
||||
// don't carry a stale ceiling id into the next placement session.
|
||||
useEffect(() => {
|
||||
if (!isCeilingItemPlacementActive && hoveredCeilingIdRef.current) {
|
||||
hoveredCeilingIdRef.current = null
|
||||
}
|
||||
}, [isCeilingItemPlacementActive])
|
||||
|
||||
const findCeilingAtPlanPoint = useCallback(
|
||||
(planPoint: WallPlanPoint): CeilingNode | null => {
|
||||
if (ceilingHitEntries.length === 0) return null
|
||||
const point: Point2D = { x: planPoint[0], y: planPoint[1] }
|
||||
for (const entry of ceilingHitEntries) {
|
||||
if (isPointInsidePolygonWithHoles(point, entry.polygon, entry.holes)) {
|
||||
return entry.ceiling
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
[ceilingHitEntries],
|
||||
)
|
||||
|
||||
// Route a 2D plan point through ceiling enter/move/leave events when a
|
||||
// ceiling-attached item is being placed. Returns true when the panel
|
||||
// should stop further pointer-move processing (we either emitted a
|
||||
// ceiling event or are between ceilings).
|
||||
const handleCeilingItemPlacementMove = useCallback(
|
||||
(
|
||||
planPoint: WallPlanPoint,
|
||||
nativeEvent: ReactPointerEvent<SVGSVGElement>,
|
||||
): boolean => {
|
||||
if (!isCeilingItemPlacementActive) return false
|
||||
|
||||
const ceiling = findCeilingAtPlanPoint(planPoint)
|
||||
if (!ceiling) {
|
||||
if (hoveredCeilingIdRef.current) {
|
||||
emitFloorplanCeilingLeave(hoveredCeilingIdRef.current)
|
||||
hoveredCeilingIdRef.current = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const payload = buildFloorplanCeilingEventPayload(ceiling, planPoint, nativeEvent)
|
||||
if (!payload) return true
|
||||
|
||||
if (hoveredCeilingIdRef.current !== ceiling.id) {
|
||||
if (hoveredCeilingIdRef.current) {
|
||||
emitFloorplanCeilingLeave(hoveredCeilingIdRef.current)
|
||||
}
|
||||
hoveredCeilingIdRef.current = ceiling.id
|
||||
emitter.emit('ceiling:enter', payload as any)
|
||||
} else {
|
||||
emitter.emit('ceiling:move', payload as any)
|
||||
}
|
||||
return true
|
||||
},
|
||||
[
|
||||
buildFloorplanCeilingEventPayload,
|
||||
emitFloorplanCeilingLeave,
|
||||
findCeilingAtPlanPoint,
|
||||
isCeilingItemPlacementActive,
|
||||
],
|
||||
)
|
||||
|
||||
// Click counterpart — used by the background placement click handler.
|
||||
// Returns true if the click was a valid ceiling-item placement.
|
||||
const handleCeilingItemPlacementClick = useCallback(
|
||||
(
|
||||
planPoint: WallPlanPoint,
|
||||
nativeEvent: ReactMouseEvent<SVGSVGElement>,
|
||||
): boolean => {
|
||||
if (!isCeilingItemPlacementActive) return false
|
||||
const ceiling = findCeilingAtPlanPoint(planPoint)
|
||||
if (!ceiling) return true
|
||||
|
||||
// Ensure the placement state has entered the ceiling surface; the
|
||||
// user can click without a prior move (e.g. fresh placement after
|
||||
// selecting the asset from the catalog).
|
||||
if (hoveredCeilingIdRef.current !== ceiling.id) {
|
||||
const enterPayload = buildFloorplanCeilingEventPayload(ceiling, planPoint, nativeEvent)
|
||||
if (!enterPayload) return true
|
||||
if (hoveredCeilingIdRef.current) {
|
||||
emitFloorplanCeilingLeave(hoveredCeilingIdRef.current)
|
||||
}
|
||||
hoveredCeilingIdRef.current = ceiling.id
|
||||
emitter.emit('ceiling:enter', enterPayload as any)
|
||||
}
|
||||
|
||||
const clickPayload = buildFloorplanCeilingEventPayload(ceiling, planPoint, nativeEvent)
|
||||
if (!clickPayload) return true
|
||||
emitter.emit('ceiling:click', clickPayload as any)
|
||||
return true
|
||||
},
|
||||
[
|
||||
buildFloorplanCeilingEventPayload,
|
||||
emitFloorplanCeilingLeave,
|
||||
findCeilingAtPlanPoint,
|
||||
isCeilingItemPlacementActive,
|
||||
],
|
||||
)
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: ReactPointerEvent<SVGSVGElement>) => {
|
||||
if (panStateRef.current?.pointerId === event.pointerId) {
|
||||
@@ -7357,6 +7537,20 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
// Ceiling-attached item placement. Same shape as the opening branch
|
||||
// above: synthesise the surface events the placement coordinator
|
||||
// already listens for (ceiling:enter / move / leave) instead of
|
||||
// routing through `grid:move`, which would otherwise be processed
|
||||
// by the floor strategy and drop the item to floor height.
|
||||
if (isCeilingItemPlacementActive) {
|
||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
)
|
||||
handleCeilingItemPlacementMove(snappedPoint, event)
|
||||
return
|
||||
}
|
||||
|
||||
// Registry-driven catch-all for kinds without bespoke 2D handling
|
||||
// (shelf, etc.). Must run AFTER the opening branch above (door /
|
||||
// window are also registered kinds, but need wall events — see
|
||||
@@ -7427,7 +7621,9 @@ export function FloorplanPanel() {
|
||||
fittedViewport,
|
||||
getPlanPointFromClientPoint,
|
||||
activePolygonDraftPoints,
|
||||
handleCeilingItemPlacementMove,
|
||||
isCeilingBuildActive,
|
||||
isCeilingItemPlacementActive,
|
||||
isFenceBuildActive,
|
||||
isFloorplanGridInteractionActive,
|
||||
isMarqueeSelectionToolActive,
|
||||
@@ -7650,11 +7846,13 @@ export function FloorplanPanel() {
|
||||
findClosestWallPoint,
|
||||
floorplanOpeningLocalY,
|
||||
getSnappedFloorplanPoint,
|
||||
handleCeilingItemPlacementClick,
|
||||
handleCeilingPlacementPoint,
|
||||
handleSlabPlacementPoint,
|
||||
handleWallPlacementPoint,
|
||||
handleZonePlacementPoint,
|
||||
isCeilingBuildActive,
|
||||
isCeilingItemPlacementActive,
|
||||
isFenceBuildActive,
|
||||
isFloorplanGridInteractionActive,
|
||||
isOpeningPlacementActive,
|
||||
@@ -8937,6 +9135,12 @@ export function FloorplanPanel() {
|
||||
attribute below); see floorplan-registry-move-overlay.tsx. */}
|
||||
<FloorplanRegistryMoveOverlay />
|
||||
|
||||
{/* Figma-style alignment guides published by the move
|
||||
overlay during a free-translate drag. Sits above the
|
||||
registry layer so the red lines and distance pills
|
||||
paint on top of node geometry. */}
|
||||
<FloorplanAlignmentGuideLayer />
|
||||
|
||||
<FloorplanMarqueeLayer
|
||||
bounds={visibleSvgMarqueeBounds}
|
||||
cursorColor={palette.cursor}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Box3, OrthographicCamera, Plane, Vector2, Vector3 } from 'three'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
ARROW_COLOR,
|
||||
ARROW_HOVER_COLOR,
|
||||
ARROW_SCALE,
|
||||
createRotateArrowHandleGeometry,
|
||||
GuideRing,
|
||||
RotationGuide,
|
||||
type RotationGuideData,
|
||||
useArrowMaterial,
|
||||
} from './node-arrow-handles'
|
||||
|
||||
const ROTATE_SNAP = Math.PI / 12 // 15°
|
||||
|
||||
type MovableNode = AnyNode & {
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
}
|
||||
|
||||
function isMovable(node: AnyNode | undefined, levelId: string | null): node is MovableNode {
|
||||
if (!node || node.parentId !== levelId) return false
|
||||
const p = (node as { position?: unknown }).position
|
||||
const r = (node as { rotation?: unknown }).rotation
|
||||
const isVec3 = (v: unknown): v is [number, number, number] =>
|
||||
Array.isArray(v) && v.length === 3 && v.every((n) => typeof n === 'number')
|
||||
return isVec3(p) && isVec3(r)
|
||||
}
|
||||
|
||||
/**
|
||||
* Group-rotate gizmo. When 2+ "movable" nodes (position + rotation, sitting
|
||||
* directly on the active level) are selected, a single rotation handle appears
|
||||
* at the selection's bounding-box center. Dragging it spins every selected node
|
||||
* rigidly around that shared center — orbiting each node's position AND turning
|
||||
* its yaw by the same delta, so the group rotates as one piece.
|
||||
*
|
||||
* The single-selection case is handled by `NodeArrowHandles`; a full-level
|
||||
* box-select promotes to a building selection, so neither reaches this gizmo.
|
||||
*/
|
||||
export function GroupRotateHandle() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||
// Re-derive participants whenever the scene mutates (e.g. after a commit).
|
||||
// Drags only touch `useLiveNodeOverrides`, so this does not fire mid-drag.
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
|
||||
const participantIds = useMemo(
|
||||
() => selectedIds.filter((id) => isMovable(nodes[id as AnyNodeId], levelId)),
|
||||
[selectedIds, levelId, nodes],
|
||||
)
|
||||
|
||||
const shouldRender =
|
||||
participantIds.length >= 2 && mode !== 'delete' && !movingNode && !isFloorplanHovered
|
||||
|
||||
if (!shouldRender) return null
|
||||
// Remount when the participant set changes so the rest pivot re-seeds cleanly.
|
||||
return <GroupRotateHandleInner ids={participantIds} key={participantIds.join(',')} />
|
||||
}
|
||||
|
||||
function GroupRotateHandleInner({ ids }: { ids: string[] }) {
|
||||
const { camera, raycaster, gl, scene } = useThree()
|
||||
const arrowGeometry = useMemo(() => createRotateArrowHandleGeometry(), [])
|
||||
const arrowMaterial = useArrowMaterial()
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [guide, setGuide] = useState<RotationGuideData | null>(null)
|
||||
const dragCleanupRef = useRef<(() => void) | null>(null)
|
||||
const frozenPivot = useRef<Vector3 | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||
}, [arrowMaterial, isHovered])
|
||||
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
|
||||
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
|
||||
useEffect(() => () => dragCleanupRef.current?.(), [])
|
||||
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE * 1.05
|
||||
|
||||
// World-space bounding-box center of the selected meshes (XZ), Y at the
|
||||
// group's base. Levels are axis-aligned in XZ, so world XZ coincides with
|
||||
// each node's level-local `position` XZ — letting us rotate `position`
|
||||
// directly against this pivot without per-node frame conversion.
|
||||
const restPivot = useMemo(() => {
|
||||
const box = new Box3()
|
||||
const tmp = new Box3()
|
||||
let found = false
|
||||
for (const id of ids) {
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (!obj) continue
|
||||
obj.updateWorldMatrix(true, true)
|
||||
tmp.setFromObject(obj)
|
||||
if (tmp.isEmpty()) continue
|
||||
box.union(tmp)
|
||||
found = true
|
||||
}
|
||||
if (!found) return null
|
||||
return new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
|
||||
}, [ids])
|
||||
|
||||
if (!restPivot) return null
|
||||
const pivot = isDragging && frozenPivot.current ? frozenPivot.current : restPivot
|
||||
|
||||
const activate = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
|
||||
const center = restPivot.clone()
|
||||
frozenPivot.current = center
|
||||
|
||||
// Snapshot each participant's pre-drag transform from the store.
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const starts = ids
|
||||
.map((id) => {
|
||||
const node = sceneNodes[id as AnyNodeId] as MovableNode | undefined
|
||||
if (!node) return null
|
||||
return {
|
||||
id: id as AnyNodeId,
|
||||
position: [...node.position] as [number, number, number],
|
||||
rotation: [...node.rotation] as [number, number, number],
|
||||
}
|
||||
})
|
||||
.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||
if (starts.length === 0) return
|
||||
|
||||
// Horizontal drag plane at the pivot; bearing measured around the pivot.
|
||||
const plane = new Plane(new Vector3(0, 1, 0), -center.y)
|
||||
const angleOf = (p: Vector3) => Math.atan2(p.z - center.z, p.x - center.x)
|
||||
|
||||
// Wedge radius tracks how far the group spreads from the pivot.
|
||||
let spread = 0
|
||||
for (const s of starts) {
|
||||
spread = Math.max(spread, Math.hypot(s.position[0] - center.x, s.position[2] - center.z))
|
||||
}
|
||||
const guideRadius = Math.min(Math.max(spread * 0.6, 0.3), 3)
|
||||
|
||||
const ndc = new Vector2()
|
||||
const setNDC = (clientX: number, clientY: number) => {
|
||||
const rect = gl.domElement.getBoundingClientRect()
|
||||
ndc.set(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
}
|
||||
|
||||
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
const hit = new Vector3()
|
||||
if (!raycaster.ray.intersectPlane(plane, hit)) return
|
||||
const initialAngle = angleOf(hit)
|
||||
|
||||
document.body.style.cursor = 'grabbing'
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useViewer.getState().setInputDragging(true)
|
||||
useScene.temporal.getState().pause()
|
||||
setIsDragging(true)
|
||||
|
||||
const onMove = (e: PointerEvent) => {
|
||||
setNDC(e.clientX, e.clientY)
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
const moveHit = new Vector3()
|
||||
if (!raycaster.ray.intersectPlane(plane, moveHit)) return
|
||||
let delta = angleOf(moveHit) - initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
if (e.shiftKey) delta = Math.round(delta / ROTATE_SNAP) * ROTATE_SNAP
|
||||
|
||||
// Orbit each node's position CCW by `delta` (atan2 x→z sense) and turn
|
||||
// its yaw by `-delta` to match three.js Y-rotation handedness (same
|
||||
// convention as the single-item rotate handle in item/definition.ts).
|
||||
const cos = Math.cos(delta)
|
||||
const sin = Math.sin(delta)
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
for (const s of starts) {
|
||||
const dx = s.position[0] - center.x
|
||||
const dz = s.position[2] - center.z
|
||||
const position: [number, number, number] = [
|
||||
center.x + dx * cos - dz * sin,
|
||||
s.position[1],
|
||||
center.z + dx * sin + dz * cos,
|
||||
]
|
||||
const rotation: [number, number, number] = [
|
||||
s.rotation[0],
|
||||
s.rotation[1] - delta,
|
||||
s.rotation[2],
|
||||
]
|
||||
overrides.set(s.id, { position, rotation })
|
||||
useScene.getState().markDirty(s.id)
|
||||
}
|
||||
|
||||
if (Math.abs(delta) < 0.0087) {
|
||||
setGuide(null)
|
||||
} else {
|
||||
const midAngle = initialAngle + delta / 2
|
||||
const labelRadius = guideRadius + 0.22
|
||||
setGuide({
|
||||
center: [center.x, center.y, center.z],
|
||||
startAngle: initialAngle,
|
||||
endAngle: initialAngle + delta,
|
||||
radius: guideRadius,
|
||||
labelPos: [
|
||||
center.x + Math.cos(midAngle) * labelRadius,
|
||||
center.y + 0.02,
|
||||
center.z + Math.sin(midAngle) * labelRadius,
|
||||
],
|
||||
sweep: Math.abs(delta),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onCancel)
|
||||
if (document.body.style.cursor === 'grabbing') document.body.style.cursor = ''
|
||||
useScene.temporal.getState().resume()
|
||||
useViewer.getState().setInputDragging(false)
|
||||
setIsDragging(false)
|
||||
setGuide(null)
|
||||
frozenPivot.current = null
|
||||
dragCleanupRef.current = null
|
||||
}
|
||||
|
||||
const commitFromOverrides = () => {
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
||||
for (const s of starts) {
|
||||
const patch = overrides.get(s.id)
|
||||
if (patch) updates.push({ id: s.id, data: patch as Partial<AnyNode> })
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
const updates = commitFromOverrides()
|
||||
// Resume before the commit so the single batched `updateNodes` is the
|
||||
// one tracked set — collapsing the whole group rotation into one undo.
|
||||
useScene.temporal.getState().resume()
|
||||
if (updates.length > 0) useScene.getState().updateNodes(updates)
|
||||
for (const s of starts) {
|
||||
useLiveNodeOverrides.getState().clear(s.id)
|
||||
useScene.getState().markDirty(s.id)
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
// Revert: drop overrides + mark dirty so renderers rebuild from the store.
|
||||
for (const s of starts) {
|
||||
useLiveNodeOverrides.getState().clear(s.id)
|
||||
useScene.getState().markDirty(s.id)
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
dragCleanupRef.current = cleanup
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onCancel)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{(isHovered || isDragging) && (
|
||||
<group position={[pivot.x, pivot.y, pivot.z]}>
|
||||
<GuideRing radius={0.2 * scale} y={0} />
|
||||
</group>
|
||||
)}
|
||||
<group position={[pivot.x, pivot.y, pivot.z]} scale={scale}>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={arrowGeometry}
|
||||
material={arrowMaterial}
|
||||
onPointerDown={activate}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setIsHovered(true)
|
||||
if (document.body.style.cursor !== 'grabbing') document.body.style.cursor = 'grab'
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
event.stopPropagation()
|
||||
setIsHovered(false)
|
||||
if (document.body.style.cursor === 'grab') document.body.style.cursor = ''
|
||||
}}
|
||||
renderOrder={1010}
|
||||
/>
|
||||
</group>
|
||||
{guide ? <RotationGuide data={guide} /> : null}
|
||||
</>,
|
||||
scene,
|
||||
)
|
||||
}
|
||||
|
||||
export default GroupRotateHandle
|
||||
@@ -60,6 +60,7 @@ import { FloatingActionMenu } from './floating-action-menu'
|
||||
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
||||
import { FloorplanPanel } from './floorplan-panel'
|
||||
import { Grid } from './grid'
|
||||
import { GroupRotateHandle } from './group-rotate-handle'
|
||||
import { NodeArrowHandles } from './node-arrow-handles'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
@@ -67,6 +68,7 @@ import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
|
||||
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
|
||||
import { WallMeasurementLabel } from './wall-measurement-label'
|
||||
import { WallMoveSideHandles } from './wall-move-side-handles'
|
||||
import { WallOpeningHighlights } from './wall-opening-highlights'
|
||||
|
||||
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
|
||||
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
|
||||
@@ -599,6 +601,8 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
{!isFirstPersonMode && <SelectionManager />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <BoxSelectTool />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <NodeArrowHandles />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <GroupRotateHandle />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <WallOpeningHighlights />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <WallMoveSideHandles />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingActionMenu />}
|
||||
{!(isVersionPreviewMode || isFirstPersonMode) && <FloatingBuildingActionMenu />}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import { type ForwardedRef, Fragment, forwardRef } from 'react'
|
||||
|
||||
// Canonical in-world dimension formatter — metric metres or imperial
|
||||
// feet/inches. Shared by every measurement readout so they read the same.
|
||||
export function formatMeasurement(value: number, unit: 'metric' | 'imperial'): string {
|
||||
if (unit === 'imperial') {
|
||||
const feet = value * 3.280_84
|
||||
const wholeFeet = Math.floor(feet)
|
||||
const inches = Math.round((feet - wholeFeet) * 12)
|
||||
if (inches === 12) return `${wholeFeet + 1}'0"`
|
||||
return `${wholeFeet}'${inches}"`
|
||||
}
|
||||
return `${Number.parseFloat(value.toFixed(2))}m`
|
||||
}
|
||||
|
||||
type MeasurePart = 'height' | 'length' | 'thickness'
|
||||
|
||||
const PART_ORDER: { key: MeasurePart; prefix: string }[] = [
|
||||
{ key: 'height', prefix: 'H' },
|
||||
{ key: 'length', prefix: 'L' },
|
||||
{ key: 'thickness', prefix: 'T' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Floating dimension pill shown during wall / fence drags: `H · L · T` with
|
||||
* the actively-dragged dimension emphasised. Styled to match the top-center
|
||||
* floating info bar (rounded-full, design-token colours) so it tracks the
|
||||
* app theme.
|
||||
*
|
||||
* The forwarded ref points at the `primary` value's `<span>` so a caller
|
||||
* driving a per-frame drag (the height arrow) can rewrite its text
|
||||
* imperatively without a React re-render. Callers that re-render naturally
|
||||
* (the endpoint tools) ignore the ref and just pass live values as props.
|
||||
*/
|
||||
export const MeasurementPill = forwardRef(function MeasurementPill(
|
||||
{
|
||||
height,
|
||||
length,
|
||||
thickness,
|
||||
unit,
|
||||
primary,
|
||||
}: {
|
||||
height: number
|
||||
length: number
|
||||
thickness: number
|
||||
unit: 'metric' | 'imperial'
|
||||
primary: MeasurePart
|
||||
},
|
||||
primaryRef: ForwardedRef<HTMLSpanElement>,
|
||||
) {
|
||||
const values: Record<MeasurePart, number> = { height, length, thickness }
|
||||
return (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||
{PART_ORDER.map((part, index) => (
|
||||
<Fragment key={part.key}>
|
||||
{index > 0 ? (
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={
|
||||
part.key === primary ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||
}
|
||||
ref={part.key === primary ? primaryRef : undefined}
|
||||
>
|
||||
{`${part.prefix} ${formatMeasurement(values[part.key], unit)}`}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BoxVentNode,
|
||||
type BuildingNode,
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
isRegistrySelectable,
|
||||
type NodeEvent,
|
||||
nodeRegistry,
|
||||
type RidgeVentNode,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentEvent,
|
||||
@@ -353,7 +351,7 @@ function applyStairPaintPreview(
|
||||
}
|
||||
|
||||
function applySingleSurfacePaintPreview(
|
||||
node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode | BoxVentNode | RidgeVentNode,
|
||||
node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
||||
material: ActivePaintMaterial,
|
||||
): PaintPreviewCleanup | null {
|
||||
if (node.type === 'ceiling') {
|
||||
@@ -421,10 +419,11 @@ function applySingleSurfacePaintPreview(
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'shelf' || node.type === 'box-vent' || node.type === 'ridge-vent') {
|
||||
// These kinds register a `<group>` (not a Mesh) with `useRegistry`,
|
||||
// so we walk the subtree and preview-swap every child mesh — same
|
||||
// approach `column` uses.
|
||||
if (node.type === 'shelf') {
|
||||
// Shelf registers a `<group>` (not a Mesh) with `useRegistry`, so we walk
|
||||
// the subtree and preview-swap every child mesh — same approach `column`
|
||||
// uses. (The roof vents previously shared this arm; they now route through
|
||||
// their `capabilities.paint` dispatcher.)
|
||||
if (!registeredObject) return null
|
||||
const restores: PaintPreviewCleanup[] = []
|
||||
registeredObject.traverse((object) => {
|
||||
@@ -1020,9 +1019,7 @@ export const SelectionManager = () => {
|
||||
node.type === 'column' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'shelf' ||
|
||||
node.type === 'box-vent' ||
|
||||
node.type === 'ridge-vent'
|
||||
node.type === 'shelf'
|
||||
) {
|
||||
const compatible = hasActivePaintMaterial(activePaintMaterial)
|
||||
|
||||
@@ -1037,13 +1034,7 @@ export const SelectionManager = () => {
|
||||
.updateNode(
|
||||
node.id as AnyNodeId,
|
||||
buildSingleSurfaceMaterialPatch<
|
||||
| FenceNode
|
||||
| ColumnNode
|
||||
| SlabNode
|
||||
| CeilingNode
|
||||
| ShelfNode
|
||||
| BoxVentNode
|
||||
| RidgeVentNode
|
||||
FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
|
||||
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
|
||||
)
|
||||
}
|
||||
@@ -1051,14 +1042,7 @@ export const SelectionManager = () => {
|
||||
preview: compatible
|
||||
? () =>
|
||||
applySingleSurfacePaintPreview(
|
||||
node as
|
||||
| FenceNode
|
||||
| ColumnNode
|
||||
| SlabNode
|
||||
| CeilingNode
|
||||
| ShelfNode
|
||||
| BoxVentNode
|
||||
| RidgeVentNode,
|
||||
node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
||||
activePaintMaterial,
|
||||
)
|
||||
: () => previewCursor('not-allowed'),
|
||||
@@ -1214,6 +1198,16 @@ export const SelectionManager = () => {
|
||||
if (boxSelectHandled) return
|
||||
|
||||
const node = event.node
|
||||
|
||||
// A ceiling is selectable only through its corner handles, never via
|
||||
// the `ceiling-grid` body mesh. When the grid is revealed (ceiling
|
||||
// selected, or an item placed beneath it) a top-down click hits the
|
||||
// grid first; selecting the ceiling there both re-selects it as a
|
||||
// no-op and stops propagation, blocking the hosted item below. By
|
||||
// ignoring non-handle ceiling clicks (without stopping propagation)
|
||||
// the click falls through to the item underneath.
|
||||
if (node.type === 'ceiling' && !event.viaHandle) return
|
||||
|
||||
let currentPhase = useEditor.getState().phase
|
||||
let currentStructureLayer = useEditor.getState().structureLayer
|
||||
|
||||
|
||||
@@ -30,11 +30,16 @@ type UseFloorplanBackgroundPlacementArgs = {
|
||||
} | null
|
||||
floorplanOpeningLocalY: number
|
||||
getSnappedFloorplanPoint: (point: WallPlanPoint) => WallPlanPoint
|
||||
handleCeilingItemPlacementClick: (
|
||||
planPoint: WallPlanPoint,
|
||||
nativeEvent: ReactMouseEvent<SVGSVGElement>,
|
||||
) => boolean
|
||||
handleCeilingPlacementPoint: (point: WallPlanPoint) => void
|
||||
handleSlabPlacementPoint: (point: WallPlanPoint) => void
|
||||
handleWallPlacementPoint: (point: WallPlanPoint) => void
|
||||
handleZonePlacementPoint: (point: WallPlanPoint) => void
|
||||
isCeilingBuildActive: boolean
|
||||
isCeilingItemPlacementActive: boolean
|
||||
isFenceBuildActive: boolean
|
||||
isFloorplanGridInteractionActive: boolean
|
||||
isOpeningPlacementActive: boolean
|
||||
@@ -76,11 +81,13 @@ export function useFloorplanBackgroundPlacement({
|
||||
findClosestWallPoint,
|
||||
floorplanOpeningLocalY,
|
||||
getSnappedFloorplanPoint,
|
||||
handleCeilingItemPlacementClick,
|
||||
handleCeilingPlacementPoint,
|
||||
handleSlabPlacementPoint,
|
||||
handleWallPlacementPoint,
|
||||
handleZonePlacementPoint,
|
||||
isCeilingBuildActive,
|
||||
isCeilingItemPlacementActive,
|
||||
isFenceBuildActive,
|
||||
isFloorplanGridInteractionActive,
|
||||
isOpeningPlacementActive,
|
||||
@@ -225,6 +232,15 @@ export function useFloorplanBackgroundPlacement({
|
||||
return true
|
||||
}
|
||||
|
||||
// Ceiling-attached item placement (lights, fans). Routes the click
|
||||
// through `ceiling:click` instead of `grid:click` so the placement
|
||||
// strategy parents the new item to the ceiling at the correct
|
||||
// height — mirrors the pointer-move handler in `floorplan-panel`.
|
||||
if (isCeilingItemPlacementActive) {
|
||||
handleCeilingItemPlacementClick(planPoint, event)
|
||||
return true
|
||||
}
|
||||
|
||||
// Generic catch-all — registry-driven tool whose kind has no
|
||||
// local floor-plan draft handler (column / spawn / shelf / etc.).
|
||||
// The tool's `grid:click` subscriber owns the placement.
|
||||
@@ -247,10 +263,12 @@ export function useFloorplanBackgroundPlacement({
|
||||
findClosestWallPoint,
|
||||
floorplanOpeningLocalY,
|
||||
getSnappedFloorplanPoint,
|
||||
handleCeilingItemPlacementClick,
|
||||
handleCeilingPlacementPoint,
|
||||
handleSlabPlacementPoint,
|
||||
handleZonePlacementPoint,
|
||||
isCeilingBuildActive,
|
||||
isCeilingItemPlacementActive,
|
||||
isFenceBuildActive,
|
||||
isFloorplanGridInteractionActive,
|
||||
isOpeningPlacementActive,
|
||||
|
||||
@@ -143,6 +143,17 @@ export function WallMoveSideHandles() {
|
||||
}
|
||||
|
||||
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
|
||||
// Merge the in-flight drag override so every handle (side-move arrows,
|
||||
// height arrow, corner leaders) tracks the live height in real time
|
||||
// during a height drag — the scene store stays at the pre-drag value
|
||||
// until commit, so reading `wall` alone would freeze them. Same pattern
|
||||
// as node-arrow-handles.
|
||||
const liveOverride = useLiveNodeOverrides((state) => state.overrides.get(wall.id))
|
||||
const effectiveWall = useMemo(
|
||||
() => (liveOverride ? ({ ...wall, ...liveOverride } as WallNode) : wall),
|
||||
[wall, liveOverride],
|
||||
)
|
||||
|
||||
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
|
||||
wall.parentId ? (sceneRegistry.nodes.get(wall.parentId) ?? null) : null,
|
||||
)
|
||||
@@ -175,18 +186,18 @@ function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
|
||||
}
|
||||
}, [wall.parentId])
|
||||
|
||||
const handles = useMemo(() => getWallMoveHandles(wall), [wall])
|
||||
const handles = useMemo(() => getWallMoveHandles(effectiveWall), [effectiveWall])
|
||||
|
||||
if (!levelObject || handles.length === 0) return null
|
||||
|
||||
return createPortal(
|
||||
<group>
|
||||
{handles.map((handle) => (
|
||||
<WallMoveArrowHandle handle={handle} key={handle.key} wall={wall} />
|
||||
<WallMoveArrowHandle handle={handle} key={handle.key} wall={effectiveWall} />
|
||||
))}
|
||||
<WallHeightArrowHandle wall={wall} />
|
||||
<WallCornerLeaderHandle endpoint="start" wall={wall} />
|
||||
<WallCornerLeaderHandle endpoint="end" wall={wall} />
|
||||
<WallHeightArrowHandle wall={effectiveWall} />
|
||||
<WallCornerLeaderHandle endpoint="start" wall={effectiveWall} />
|
||||
<WallCornerLeaderHandle endpoint="end" wall={effectiveWall} />
|
||||
</group>,
|
||||
levelObject,
|
||||
)
|
||||
@@ -395,6 +406,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) {
|
||||
const dirX = curveFrame ? curveFrame.tangent.x : wall.end[0] - wall.start[0]
|
||||
const dirZ = curveFrame ? curveFrame.tangent.y : wall.end[1] - wall.start[1]
|
||||
const wallAngle = Math.atan2(-dirZ, dirX)
|
||||
// `wall` is the override-merged effective wall (see
|
||||
// WallMoveSideHandlesForWall), so this height is already live during a drag.
|
||||
const wallHeight = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
const handleY = wallHeight + HEIGHT_HANDLE_OFFSET
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
BoxGeometry,
|
||||
type BufferGeometry,
|
||||
DoubleSide,
|
||||
EdgesGeometry,
|
||||
type Group,
|
||||
PlaneGeometry,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
|
||||
// How far the outline sits outside the opening's own extents, so it reads as
|
||||
// a frame *around* the opening rather than coinciding with its edges.
|
||||
const PAD = 0.05
|
||||
|
||||
const ACCENT = 0x83_81_ed
|
||||
|
||||
const NO_RAYCAST = () => null
|
||||
const scratchScale = new Vector3()
|
||||
|
||||
// Indigo accent matching the resize-arrow handles — deliberately distinct
|
||||
// from the white selection outline so the highlight reads as "editable child
|
||||
// here", not "this is selected". `depthTest: false` keeps both layers drawn
|
||||
// on top of the wall so frameless openings (which have no visible geometry)
|
||||
// are still located. Shared across every box; never disposed.
|
||||
const outlineMaterial = new LineBasicNodeMaterial({
|
||||
color: ACCENT,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
// Translucent pane that fills the opening so it reads as a highlighted
|
||||
// region. Sits in the opening's plane (XY, facing the wall normal) and is
|
||||
// double-sided so it shows from either side of the wall.
|
||||
const fillMaterial = new MeshBasicNodeMaterial({
|
||||
color: ACCENT,
|
||||
transparent: true,
|
||||
opacity: 0.22,
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
function makeOutlineGeometry(width: number, height: number, depth: number): BufferGeometry {
|
||||
const box = new BoxGeometry(width + PAD, height + PAD, depth + PAD)
|
||||
const edges = new EdgesGeometry(box)
|
||||
box.dispose()
|
||||
return edges
|
||||
}
|
||||
|
||||
/**
|
||||
* When a wall is selected, draws a translucent indigo highlight (filled pane
|
||||
* + outline) over each door / window it hosts. Openings whose `openingKind`
|
||||
* is `'opening'` have no visible geometry, so without this affordance the
|
||||
* user can't tell an editable cutout lives there — the fill marks it (and
|
||||
* stays out of the way of clicking the opening itself, which selects it).
|
||||
*
|
||||
* Highlights are portalled to the scene root so they sit outside the wall's
|
||||
* selection-outline subtree and keep their own accent colour.
|
||||
*/
|
||||
export function WallOpeningHighlights() {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const { scene } = useThree()
|
||||
|
||||
if (selectedIds.length === 0) return null
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{selectedIds.map((id) => (
|
||||
<WallOpenings key={id} wallId={id} />
|
||||
))}
|
||||
</>,
|
||||
scene,
|
||||
)
|
||||
}
|
||||
|
||||
function WallOpenings({ wallId }: { wallId: string }) {
|
||||
const wall = useScene((state) => state.nodes[wallId as AnyNodeId])
|
||||
|
||||
if (!wall || wall.type !== 'wall') return null
|
||||
|
||||
const depth = wall.thickness ?? 0.1
|
||||
return (
|
||||
<>
|
||||
{(wall.children ?? []).map((childId) => (
|
||||
<OpeningHighlight depth={depth} key={childId} openingId={childId} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function OpeningHighlight({ openingId, depth }: { openingId: string; depth: number }) {
|
||||
const node = useScene((state) => state.nodes[openingId as AnyNodeId])
|
||||
const groupRef = useRef<Group>(null)
|
||||
|
||||
const isOpening = node?.type === 'door' || node?.type === 'window'
|
||||
const width = isOpening ? node.width : 0
|
||||
const height = isOpening ? node.height : 0
|
||||
|
||||
const outlineGeometry = useMemo(
|
||||
() => (isOpening ? makeOutlineGeometry(width, height, depth) : null),
|
||||
[isOpening, width, height, depth],
|
||||
)
|
||||
const fillGeometry = useMemo(
|
||||
() => (isOpening ? new PlaneGeometry(width, height) : null),
|
||||
[isOpening, width, height],
|
||||
)
|
||||
useEffect(() => () => outlineGeometry?.dispose(), [outlineGeometry])
|
||||
useEffect(() => () => fillGeometry?.dispose(), [fillGeometry])
|
||||
|
||||
// The opening's mesh (registered by its renderer) already carries the wall
|
||||
// transform + its own local pose baked into `matrixWorld`. Copy that
|
||||
// straight onto the highlight each frame so it tracks moves, resizes, and
|
||||
// wall rotation without any wall-local maths here.
|
||||
useFrame(() => {
|
||||
const group = groupRef.current
|
||||
if (!group) return
|
||||
const obj = sceneRegistry.nodes.get(openingId as AnyNodeId)
|
||||
if (!obj) {
|
||||
group.visible = false
|
||||
return
|
||||
}
|
||||
group.visible = true
|
||||
obj.matrixWorld.decompose(group.position, group.quaternion, scratchScale)
|
||||
})
|
||||
|
||||
if (!isOpening || !outlineGeometry || !fillGeometry) return null
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={fillGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={fillMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1004}
|
||||
/>
|
||||
<lineSegments
|
||||
frustumCulled={false}
|
||||
geometry={outlineGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={outlineMaterial}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1005}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default WallOpeningHighlights
|
||||
+26
-3
@@ -18,6 +18,10 @@ const BRACKET_THICKNESS = 0.04
|
||||
const BRACKET_HEIGHT = 0.04
|
||||
const BRACKET_Y_OFFSET = 0.035
|
||||
const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
|
||||
// Draw the corner handles after everything else and with depth testing
|
||||
// off (see materials below) so they stay visible — and clickable — even
|
||||
// when a wall, roof, or the ceiling itself would otherwise occlude them.
|
||||
const CORNER_RENDER_ORDER = 1000
|
||||
|
||||
type CornerBracketData = {
|
||||
corner: [number, number]
|
||||
@@ -149,6 +153,7 @@ const CornerBracket = ({
|
||||
localPosition: [0, 0, 0],
|
||||
position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]],
|
||||
stopPropagation: () => e.stopPropagation(),
|
||||
viaHandle: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -179,9 +184,16 @@ const CornerBracket = ({
|
||||
e.stopPropagation()
|
||||
setIsHovered(false)
|
||||
}}
|
||||
renderOrder={CORNER_RENDER_ORDER}
|
||||
>
|
||||
<boxGeometry args={HIT_BOX_SIZE} />
|
||||
<meshBasicMaterial color={cubeColor} depthWrite={false} opacity={cubeOpacity} transparent />
|
||||
<meshBasicMaterial
|
||||
color={cubeColor}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={cubeOpacity}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
@@ -208,9 +220,20 @@ const BracketLeg = ({
|
||||
]
|
||||
|
||||
return (
|
||||
<mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
|
||||
<mesh
|
||||
onClick={onClick}
|
||||
position={position}
|
||||
renderOrder={CORNER_RENDER_ORDER}
|
||||
rotation={[0, angle, 0]}
|
||||
>
|
||||
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
||||
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={opacity}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,15 +25,19 @@ export const CeilingSystem = () => {
|
||||
}
|
||||
|
||||
for (const id of selectedIds) {
|
||||
let currentId: string | null = id
|
||||
let isCeilingRelated = false
|
||||
let levelId: string | null = null
|
||||
// Only treat a directly-selected ceiling as "reveal the grid"; a
|
||||
// selected descendant (e.g. a freshly-placed ceiling light) used to
|
||||
// count too, which left the opaque grid overlay covering the room
|
||||
// even after the user moved on. With the grid still in front, every
|
||||
// subsequent click in 3D hit the grid mesh (its `useNodeEvents`
|
||||
// handlers re-selected the ceiling) instead of the items below.
|
||||
const selectedNode = nodes[id as AnyNodeId]
|
||||
if (selectedNode?.type !== 'ceiling') continue
|
||||
|
||||
let currentId: string | null = selectedNode.parentId as string | null
|
||||
let levelId: string | null = null
|
||||
while (currentId && nodes[currentId as AnyNodeId]) {
|
||||
const node = nodes[currentId as AnyNodeId]
|
||||
if (node?.type === 'ceiling') {
|
||||
isCeilingRelated = true
|
||||
}
|
||||
if (node?.type === 'level') {
|
||||
levelId = node.id
|
||||
break
|
||||
@@ -41,7 +45,7 @@ export const CeilingSystem = () => {
|
||||
currentId = node?.parentId as string | null
|
||||
}
|
||||
|
||||
if (isCeilingRelated && levelId) {
|
||||
if (levelId) {
|
||||
levelsToShowCeilings.add(levelId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,92 @@
|
||||
import { type AnyNodeId, type RoofNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
|
||||
// Empty placeholder geometry used when we reveal segments-wrapper for
|
||||
// accessory editing. The roof's CSG-merged shell is the only thing
|
||||
// that should render the roof surface in this mode — the per-segment
|
||||
// CSG geometry (if any was left over from a prior edit) would visually
|
||||
// double the cut shape, so we strip each segment mesh back to nothing.
|
||||
// `RoofSystem` rebuilds CSG on demand if the user later selects a
|
||||
// segment, so destroying the cached geometry here only costs one
|
||||
// recomputation per segment when the user actually wants it back.
|
||||
function makeEmptySegmentGeometry(): THREE.BufferGeometry {
|
||||
const g = new THREE.BufferGeometry()
|
||||
g.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
// Match the four material slots the roof-segment renderer's material
|
||||
// array expects (0=top, 1=side, 2=interior, 3=shingle). Without these
|
||||
// groups, mesh.material is a single-material lookup that mismatches
|
||||
// the array — same crash mode the BoxGeometry workaround in
|
||||
// `roof-system.tsx:144` guards against.
|
||||
g.addGroup(0, 0, 0)
|
||||
g.addGroup(0, 0, 1)
|
||||
g.addGroup(0, 0, 2)
|
||||
g.addGroup(0, 0, 3)
|
||||
return g
|
||||
}
|
||||
|
||||
/**
|
||||
* Imperatively toggles the Three.js visibility of roof objects based on the
|
||||
* editor selection — without causing React re-renders in RoofRenderer.
|
||||
*
|
||||
* When a roof (or one of its segments) is selected:
|
||||
* Full edit-mode (segment selected):
|
||||
* - merged-roof mesh is hidden
|
||||
* - segments-wrapper group is shown (individual segments visible for editing)
|
||||
* - all children are marked dirty so RoofSystem rebuilds their geometry
|
||||
*
|
||||
* When deselected:
|
||||
* - merged-roof mesh is shown
|
||||
* - segments-wrapper group is hidden
|
||||
* Accessory-reveal mode (a dormer/chimney/etc. hosted on a segment is selected):
|
||||
* - merged-roof mesh stays visible (we don't want the appearance to jump)
|
||||
* - segments-wrapper group is shown ANYWAY so anything portaled into a
|
||||
* segment's registered mesh (e.g. dormer in-world handle arrows that
|
||||
* don't use `portal: 'grandparent'`) is no longer inheriting the
|
||||
* wrapper's hidden flag
|
||||
* - segment placeholder geometry is empty, so revealing the wrapper has
|
||||
* no visible cost beyond letting the handle arrows render
|
||||
*
|
||||
* When deselected: merged-roof shown, segments-wrapper hidden.
|
||||
*/
|
||||
export const RoofEditSystem = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const prevActiveRoofIds = useRef(new Set<string>())
|
||||
const prevRevealRoofIds = useRef(new Set<string>())
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
// Collect which roof nodes should be in "edit mode".
|
||||
// Selecting the roof itself should keep the merged visual intact so
|
||||
// material appearance does not jump between merged and per-segment meshes.
|
||||
// Roofs where a segment itself is selected -> full edit mode (hide
|
||||
// merged, show wrapper).
|
||||
const activeRoofIds = new Set<string>()
|
||||
// Roofs where an accessory (dormer/chimney/etc.) is selected -> only
|
||||
// reveal the wrapper so handle portals into the segment mesh become
|
||||
// visible. Merged stays on.
|
||||
const revealRoofIds = new Set<string>()
|
||||
|
||||
for (const id of selectedIds) {
|
||||
const node = nodes[id as AnyNodeId]
|
||||
if (!node) continue
|
||||
if (node.type === 'roof-segment' && node.parentId) {
|
||||
activeRoofIds.add(node.parentId)
|
||||
continue
|
||||
}
|
||||
// Walk up one level: if the parent is a roof-segment, this is a
|
||||
// hosted accessory and we want to reveal its grandparent roof's
|
||||
// wrapper. Two-step lookup keeps it scoped to roof children
|
||||
// without enumerating all accessory kinds.
|
||||
if (!node.parentId) continue
|
||||
const parent = nodes[node.parentId as AnyNodeId]
|
||||
if (parent?.type === 'roof-segment' && parent.parentId) {
|
||||
revealRoofIds.add(parent.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
// Update all roofs that are currently active OR were previously active
|
||||
const roofIdsToUpdate = new Set([...activeRoofIds, ...prevActiveRoofIds.current])
|
||||
// Union of roofs that need ANY state change this tick.
|
||||
const roofIdsToUpdate = new Set([
|
||||
...activeRoofIds,
|
||||
...revealRoofIds,
|
||||
...prevActiveRoofIds.current,
|
||||
...prevRevealRoofIds.current,
|
||||
])
|
||||
|
||||
for (const roofId of roofIdsToUpdate) {
|
||||
const group = sceneRegistry.nodes.get(roofId)
|
||||
@@ -44,25 +95,44 @@ export const RoofEditSystem = () => {
|
||||
const mergedMesh = group.getObjectByName('merged-roof')
|
||||
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
||||
const isActive = activeRoofIds.has(roofId)
|
||||
const isReveal = revealRoofIds.has(roofId)
|
||||
|
||||
if (mergedMesh) mergedMesh.visible = !isActive
|
||||
if (segmentsWrapper) segmentsWrapper.visible = isActive
|
||||
if (segmentsWrapper) segmentsWrapper.visible = isActive || isReveal
|
||||
|
||||
const roofNode = nodes[roofId as AnyNodeId] as RoofNode | undefined
|
||||
if (roofNode?.children?.length) {
|
||||
const wasActive = prevActiveRoofIds.current.has(roofId)
|
||||
const wasReveal = prevRevealRoofIds.current.has(roofId)
|
||||
if (isActive !== wasActive) {
|
||||
// Entering edit mode: rebuild individual segment geometries
|
||||
// Exiting edit mode: sync transforms + rebuild merged mesh
|
||||
// Entering / exiting full edit mode: rebuild segment / merged
|
||||
// geometries. Accessory-reveal doesn't need this — segments
|
||||
// keep their placeholder; only their visibility flips.
|
||||
const { markDirty } = useScene.getState()
|
||||
for (const childId of roofNode.children) {
|
||||
markDirty(childId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
// Entering reveal mode (and NOT also full-edit, which already
|
||||
// owns its own rebuild path): strip each segment mesh back to
|
||||
// an empty placeholder so the wrapper-now-visible doesn't
|
||||
// re-show stale CSG geometry from a previous segment edit.
|
||||
// Without this, the host segment's CSG cut renders ON TOP of
|
||||
// the merged-roof, doubling the dormer's cut shape and
|
||||
// bleeding the host wall material through the dormer body.
|
||||
if (isReveal && !isActive && !wasReveal && segmentsWrapper) {
|
||||
for (const child of segmentsWrapper.children) {
|
||||
const mesh = child as THREE.Mesh
|
||||
if (!mesh.isMesh) continue
|
||||
mesh.geometry?.dispose()
|
||||
mesh.geometry = makeEmptySegmentGeometry()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevActiveRoofIds.current = activeRoofIds
|
||||
prevRevealRoofIds.current = revealRoofIds
|
||||
}, [selectedIds])
|
||||
|
||||
return null
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
BuildingNode,
|
||||
ElevatorNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
SpawnNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
|
||||
import { nodeRegistry } from '@pascal-app/core'
|
||||
import { Suspense } from 'react'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { MoveBuildingContent } from '../building/move-building-tool'
|
||||
import { MoveElevatorTool } from '../elevator/move-elevator-tool'
|
||||
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
|
||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||
import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
|
||||
|
||||
/**
|
||||
@@ -23,14 +12,13 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
|
||||
* 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
|
||||
* declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
|
||||
* …).
|
||||
* 2. `def.affordanceTools.move` — kind-owned move component
|
||||
* (slab / ceiling / wall / fence / column / item / door / window).
|
||||
* Lazy-loaded via `getRegistryAffordanceTool`.
|
||||
* 3. The narrow set of kinds that still have legacy movers because no
|
||||
* registry equivalent has been written yet (building / elevator /
|
||||
* roof / stair). Each of these has bespoke move semantics that
|
||||
* don't fit the generic mover and are not yet ported to a
|
||||
* kind-owned affordance.
|
||||
* 2. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
|
||||
* via `getRegistryAffordanceTool`. Covers both generic movers
|
||||
* (slab / ceiling / wall / fence / column / item / door / window) and
|
||||
* the bespoke roof / roof-segment / stair / stair-segment / building
|
||||
* movers ported into `@pascal-app/nodes`.
|
||||
* 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft
|
||||
* mover hasn't been ported to a kind-owned affordance yet.
|
||||
*/
|
||||
export const MoveTool: React.FC<{
|
||||
onNodeMoved?: (nodeId: AnyNodeId) => void
|
||||
@@ -54,13 +42,7 @@ export const MoveTool: React.FC<{
|
||||
)
|
||||
}
|
||||
|
||||
if (movingNode.type === 'building')
|
||||
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
||||
if (movingNode.type === 'elevator')
|
||||
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
|
||||
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
||||
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
||||
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1168,9 +1168,15 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||
if (mesh) mesh.position.copy(gridPosition.current)
|
||||
|
||||
// Publish live transform for 2D floorplan
|
||||
// Publish live transform for 2D floorplan. The item override in
|
||||
// `floorplan-registry-layer` treats `live.position` as building-local
|
||||
// plan coords (parentId forced to null so the resolver renders it
|
||||
// directly), so publish the building-local cursor — not the
|
||||
// world-space `result.cursorPosition`, which otherwise lands the 2D
|
||||
// visual off the cursor whenever the building isn't at the origin
|
||||
// with zero rotation.
|
||||
useLiveTransforms.getState().set(draft.id, {
|
||||
position: result.cursorPosition,
|
||||
position: [cc.x, cc.y, cc.z],
|
||||
rotation: cursorGroupRef.current.rotation.y,
|
||||
})
|
||||
}
|
||||
@@ -1324,7 +1330,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
// ---- Keyboard rotation ----
|
||||
|
||||
const ROTATION_STEP = Math.PI / 2
|
||||
// 45° increments — matches the R-key rotation step for already-placed
|
||||
// items (use-keyboard.ts) so the ghost/duplicate rotates the same way.
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftFreeRef.current = true
|
||||
|
||||
@@ -192,7 +192,14 @@ function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
||||
/**
|
||||
* Collect the ids of selectable nodes on the active level. When `bounds` is a
|
||||
* rectangle, only nodes intersecting it are returned (a box-select hit-test).
|
||||
* When `bounds` is `null`, every selectable node on the level is returned —
|
||||
* the full set the box-select could ever capture — which lets the caller tell
|
||||
* whether a drag selected the entire level.
|
||||
*/
|
||||
function collectNodeIdsInBounds(bounds: Bounds | null): string[] {
|
||||
const { levelId } = useViewer.getState().selection
|
||||
const { nodes } = useScene.getState()
|
||||
const { phase, structureLayer } = useEditor.getState()
|
||||
@@ -208,7 +215,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
||||
const node = nodes[childId as AnyNodeId]
|
||||
if (!node || node.type !== 'zone') continue
|
||||
const zone = node as ZoneNode
|
||||
if (polygonIntersectsBounds(zone.polygon, bounds)) {
|
||||
if (!bounds || polygonIntersectsBounds(zone.polygon, bounds)) {
|
||||
result.push(zone.id)
|
||||
}
|
||||
}
|
||||
@@ -221,6 +228,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
||||
if (node.type === 'wall' || node.type === 'fence') {
|
||||
const wall = node as WallNode
|
||||
if (
|
||||
!bounds ||
|
||||
segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds)
|
||||
) {
|
||||
result.push(wall.id)
|
||||
@@ -237,40 +245,40 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
||||
(child as ItemNode).asset.category === 'window'))
|
||||
) {
|
||||
const xz = getNodeWorldXZ(child.id)
|
||||
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
||||
if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) {
|
||||
result.push(child.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (node.type === 'slab') {
|
||||
const slab = node as SlabNode
|
||||
if (polygonIntersectsBounds(slab.polygon, bounds)) {
|
||||
if (!bounds || polygonIntersectsBounds(slab.polygon, bounds)) {
|
||||
result.push(slab.id)
|
||||
}
|
||||
} else if (node.type === 'ceiling') {
|
||||
const ceiling = node as CeilingNode
|
||||
if (polygonIntersectsBounds(ceiling.polygon, bounds)) {
|
||||
if (!bounds || polygonIntersectsBounds(ceiling.polygon, bounds)) {
|
||||
result.push(ceiling.id)
|
||||
}
|
||||
} else if (node.type === 'roof') {
|
||||
const xz = getNodeWorldXZ(node.id)
|
||||
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
||||
if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) {
|
||||
result.push(node.id)
|
||||
}
|
||||
} else if (node.type === 'stair') {
|
||||
if (objectBoundsIntersectsBounds(node.id, bounds)) {
|
||||
if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) {
|
||||
result.push(node.id)
|
||||
}
|
||||
} else if (node.type === 'column') {
|
||||
const column = node as ColumnNode
|
||||
if (objectBoundsIntersectsBounds(column.id, bounds)) {
|
||||
if (!bounds || objectBoundsIntersectsBounds(column.id, bounds)) {
|
||||
result.push(column.id)
|
||||
}
|
||||
} else if (node.type === 'item') {
|
||||
const item = node as ItemNode
|
||||
if (item.asset.category === 'door' || item.asset.category === 'window') continue
|
||||
const xz = getNodeWorldXZ(item.id)
|
||||
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
||||
if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) {
|
||||
result.push(item.id)
|
||||
}
|
||||
} else if (isRegistrySelectable(node.type)) {
|
||||
@@ -524,9 +532,19 @@ const BoxSelectToolInner: React.FC = () => {
|
||||
const currentIds = useViewer.getState().selection.selectedIds
|
||||
const merged = Array.from(new Set([...currentIds, ...ids]))
|
||||
useViewer.getState().setSelection({ selectedIds: merged })
|
||||
} else {
|
||||
// If the box captured every selectable node on the level, promote to
|
||||
// selecting the parent building — same as clicking it in the side menu.
|
||||
const allOnLevel = collectNodeIdsInBounds(null)
|
||||
const { buildingId } = useViewer.getState().selection
|
||||
const selectedEntireLevel = allOnLevel.length > 0 && ids.length === allOnLevel.length
|
||||
|
||||
if (selectedEntireLevel && buildingId) {
|
||||
useViewer.getState().setSelection({ buildingId })
|
||||
} else {
|
||||
useViewer.getState().setSelection({ selectedIds: ids })
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent the subsequent grid:click from deselecting
|
||||
boxSelectHandled = true
|
||||
|
||||
@@ -609,10 +609,17 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
{/* Per-side resize arrow — points outward from the edge.
|
||||
Dragging it pulls (or pushes) only this edge's two
|
||||
vertices along the outward normal; the opposite side
|
||||
of the polygon stays put. */}
|
||||
of the polygon stays put.
|
||||
|
||||
Stays on SCENE_LAYER (no `layers={EDITOR_LAYER}`) so the
|
||||
post-processing scenePass picks it up in the depth/normal
|
||||
MRT and the ink-edge shader paints dark outlines on the
|
||||
chevron — same treatment as the wall and registry height
|
||||
arrows. The surrounding line/vertex/edge-box handles stay
|
||||
on EDITOR_LAYER because they're not chevrons and reading
|
||||
as flat overlays is the intended look there. */}
|
||||
<mesh
|
||||
geometry={arrowGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
@@ -638,8 +645,12 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
color={
|
||||
isDragging ? '#22c55e' : isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR
|
||||
}
|
||||
// depthTest off → still drawn on top of underlying surface.
|
||||
// depthWrite on → silhouette enters the depth buffer so the
|
||||
// ink-edge shader paints it from every angle, like all the
|
||||
// other registry chevrons.
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
depthWrite={true}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type IconRef,
|
||||
nodeRegistry,
|
||||
type ParamAction,
|
||||
type ParamField,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -104,6 +105,10 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
||||
const canMove = !!def.capabilities.movable
|
||||
const canDelete = def.capabilities.deletable !== false
|
||||
|
||||
const TrailingSection = parametrics.trailingSection
|
||||
? resolveCustomPanel(parametrics.trailingSection)
|
||||
: null
|
||||
|
||||
return (
|
||||
<PanelWrapper footer={footer} icon={iconNode} onClose={handleClose} title={title} width={320}>
|
||||
{parametrics.groups.map((group, gi) => (
|
||||
@@ -118,12 +123,20 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
||||
))}
|
||||
</PanelSection>
|
||||
))}
|
||||
{(canMove || canDelete) && (
|
||||
{TrailingSection && (
|
||||
<Suspense fallback={null}>
|
||||
<TrailingSection />
|
||||
</Suspense>
|
||||
)}
|
||||
{(canMove || canDelete || (parametrics.actions && parametrics.actions.length > 0)) && (
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
{canMove && (
|
||||
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||
)}
|
||||
{parametrics.actions?.map((action, i) => (
|
||||
<ParamActionButton action={action} key={`paramaction-${i}`} nodeId={selectedId} />
|
||||
))}
|
||||
{canDelete && (
|
||||
<ActionButton
|
||||
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||
@@ -139,6 +152,34 @@ export function ParametricInspector({ footer }: { footer?: React.ReactNode } = {
|
||||
)
|
||||
}
|
||||
|
||||
// One inspector action button. Subscribes to `enabledIf`'s boolean result
|
||||
// (same pattern as FieldRenderer's `visible`) so the disabled state stays
|
||||
// live as the scene mutates — `===` on the boolean keeps unrelated ticks
|
||||
// from re-rendering it. The click handler re-reads the live node so the
|
||||
// handler always acts on current state.
|
||||
function ParamActionButton({ action, nodeId }: { action: ParamAction<AnyNode>; nodeId: AnyNodeId }) {
|
||||
const disabled = useScene((s) => {
|
||||
if (!action.enabledIf) return false
|
||||
const n = s.nodes[nodeId]
|
||||
return n ? !action.enabledIf(n as AnyNode) : false
|
||||
})
|
||||
return (
|
||||
<ActionButton
|
||||
className={disabled ? 'opacity-40 pointer-events-none' : ''}
|
||||
icon={
|
||||
action.iconSrc ? (
|
||||
<img alt="" className="h-4 w-4 shrink-0 object-contain" src={action.iconSrc} />
|
||||
) : undefined
|
||||
}
|
||||
label={action.label}
|
||||
onClick={() => {
|
||||
const live = useScene.getState().nodes[nodeId]
|
||||
if (live) action.onClick(live as AnyNode)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderIcon(ref: IconRef | undefined): React.ReactNode | undefined {
|
||||
if (!ref) return undefined
|
||||
if (ref.kind === 'url') {
|
||||
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
import { type AnyNodeId, type BoxVentNode, useScene } from '@pascal-app/core'
|
||||
import { type AnyNodeId, type GutterNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
@@ -7,20 +7,20 @@ import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface BoxVentTreeNodeProps {
|
||||
interface GutterTreeNodeProps {
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export const BoxVentTreeNode = memo(function BoxVentTreeNode({
|
||||
export const GutterTreeNode = memo(function GutterTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: BoxVentTreeNodeProps) {
|
||||
}: GutterTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const node = useScene((s) => s.nodes[nodeId] as BoxVentNode | undefined)
|
||||
const node = useScene((s) => s.nodes[nodeId] as GutterNode | undefined)
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
@@ -42,7 +42,7 @@ export const BoxVentTreeNode = memo(function BoxVentTreeNode({
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const defaultName = node?.name || 'Box Vent'
|
||||
const defaultName = node?.name || 'Gutter'
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
+18
-8
@@ -1,4 +1,4 @@
|
||||
import { type AnyNodeId, type RidgeVentNode, useScene } from '@pascal-app/core'
|
||||
import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { memo, useCallback, useState } from 'react'
|
||||
@@ -7,25 +7,37 @@ import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface RidgeVentTreeNodeProps {
|
||||
interface RegistryTreeNodeProps {
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export const RidgeVentTreeNode = memo(function RidgeVentTreeNode({
|
||||
/**
|
||||
* Generic, leaf tree-node row driven entirely by the kind's
|
||||
* `def.presentation` (icon + label). Replaces the per-kind boilerplate
|
||||
* components that differed only in their default name and icon — today the
|
||||
* roof vents (box / ridge / turbine / cupola / eyebrow). Register a kind in
|
||||
* `treeNodeByType` against this component instead of authoring another copy.
|
||||
*/
|
||||
export const RegistryTreeNode = memo(function RegistryTreeNode({
|
||||
nodeId,
|
||||
depth,
|
||||
isLast,
|
||||
}: RidgeVentTreeNodeProps) {
|
||||
}: RegistryTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
|
||||
const node = useScene((s) => s.nodes[nodeId] as RidgeVentNode | undefined)
|
||||
const node = useScene((s) => s.nodes[nodeId])
|
||||
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
|
||||
const icon = presentation?.icon
|
||||
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png'
|
||||
const defaultName = node?.name || presentation?.label || 'Node'
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -42,8 +54,6 @@ export const RidgeVentTreeNode = memo(function RidgeVentTreeNode({
|
||||
[nodeId, setSelection],
|
||||
)
|
||||
|
||||
const defaultName = node?.name || 'Ridge Vent'
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions nodeId={nodeId} />}
|
||||
@@ -55,7 +65,7 @@ export const RidgeVentTreeNode = memo(function RidgeVentTreeNode({
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src="/icons/roof.png"
|
||||
src={iconSrc}
|
||||
width={14}
|
||||
/>
|
||||
}
|
||||
@@ -54,7 +54,6 @@ export function focusTreeNode(nodeId: AnyNodeId) {
|
||||
}
|
||||
|
||||
import { cn } from '../../../../../lib/utils'
|
||||
import { BoxVentTreeNode } from './box-vent-tree-node'
|
||||
import { BuildingTreeNode } from './building-tree-node'
|
||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||
import { ChimneyTreeNode } from './chimney-tree-node'
|
||||
@@ -63,9 +62,10 @@ import { DoorTreeNode } from './door-tree-node'
|
||||
import { DormerTreeNode } from './dormer-tree-node'
|
||||
import { ElevatorTreeNode } from './elevator-tree-node'
|
||||
import { FenceTreeNode } from './fence-tree-node'
|
||||
import { GutterTreeNode } from './gutter-tree-node'
|
||||
import { ItemTreeNode } from './item-tree-node'
|
||||
import { LevelTreeNode } from './level-tree-node'
|
||||
import { RidgeVentTreeNode } from './ridge-vent-tree-node'
|
||||
import { RegistryTreeNode } from './registry-tree-node'
|
||||
import { RoofTreeNode } from './roof-tree-node'
|
||||
import { ShelfTreeNode } from './shelf-tree-node'
|
||||
import { SlabTreeNode } from './slab-tree-node'
|
||||
@@ -98,7 +98,7 @@ const treeNodeByType: Record<
|
||||
isLast?: boolean
|
||||
nodeId: AnyNodeId
|
||||
}>,
|
||||
'box-vent': BoxVentTreeNode,
|
||||
'box-vent': RegistryTreeNode,
|
||||
ceiling: CeilingTreeNode,
|
||||
chimney: ChimneyTreeNode,
|
||||
dormer: DormerTreeNode,
|
||||
@@ -123,7 +123,11 @@ const treeNodeByType: Record<
|
||||
}>,
|
||||
wall: WallTreeNode,
|
||||
fence: FenceTreeNode,
|
||||
'ridge-vent': RidgeVentTreeNode,
|
||||
gutter: GutterTreeNode,
|
||||
'ridge-vent': RegistryTreeNode,
|
||||
'turbine-vent': RegistryTreeNode,
|
||||
cupola: RegistryTreeNode,
|
||||
'eyebrow-vent': RegistryTreeNode,
|
||||
roof: RoofTreeNode,
|
||||
stair: StairTreeNode,
|
||||
door: DoorTreeNode,
|
||||
|
||||
@@ -12,6 +12,7 @@ export { default as Editor } from './components/editor'
|
||||
// surface uses the shorter, shell-friendly names from the unified
|
||||
// preset-system spec.
|
||||
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
||||
export { formatMeasurement, MeasurementPill } from './components/editor/measurement-pill'
|
||||
export {
|
||||
type SnapshotCameraData,
|
||||
ThumbnailGenerator,
|
||||
@@ -191,7 +192,7 @@ export {
|
||||
getActivePaintMaterialLabel,
|
||||
hasActivePaintMaterial,
|
||||
} from './lib/material-paint'
|
||||
export { duplicateRoofSubtree } from './lib/roof-duplication'
|
||||
export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication'
|
||||
export type { SceneGraph } from './lib/scene'
|
||||
export { applySceneGraphToEditor } from './lib/scene'
|
||||
export { triggerSFX } from './lib/sfx-bus'
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BoxVentNode,
|
||||
type CeilingNode,
|
||||
type ChimneyMaterialRole,
|
||||
type ChimneyNode,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
type MaterialSchema,
|
||||
type MaterialTarget,
|
||||
nodeRegistry,
|
||||
type RidgeVentNode,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
type RoofSegmentSurfaceMaterialRole,
|
||||
@@ -46,6 +44,9 @@ export type PaintableMaterialTarget = Extract<
|
||||
| 'dormer'
|
||||
| 'box-vent'
|
||||
| 'ridge-vent'
|
||||
| 'turbine-vent'
|
||||
| 'cupola'
|
||||
| 'eyebrow-vent'
|
||||
>
|
||||
|
||||
export type SingleSurfaceMaterialRole = 'surface'
|
||||
@@ -221,14 +222,7 @@ export function buildStairSurfaceMaterialPatch(
|
||||
}
|
||||
|
||||
export function buildSingleSurfaceMaterialPatch<
|
||||
TNode extends
|
||||
| FenceNode
|
||||
| ColumnNode
|
||||
| SlabNode
|
||||
| CeilingNode
|
||||
| ShelfNode
|
||||
| BoxVentNode
|
||||
| RidgeVentNode,
|
||||
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
||||
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
|
||||
return {
|
||||
material,
|
||||
@@ -375,11 +369,11 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
||||
selectedNode.type === 'column' ||
|
||||
selectedNode.type === 'slab' ||
|
||||
selectedNode.type === 'ceiling' ||
|
||||
selectedNode.type === 'shelf' ||
|
||||
selectedNode.type === 'box-vent' ||
|
||||
selectedNode.type === 'ridge-vent') &&
|
||||
selectedNode.type === 'shelf') &&
|
||||
selectedMaterialTarget.role === 'surface'
|
||||
) {
|
||||
// Roof vents previously lived here too; they now resolve via the
|
||||
// registry-driven `getEffectiveMaterial` path at the top of this function.
|
||||
const target = selectedNode.type
|
||||
return hasActivePaintMaterial({
|
||||
material: selectedNode.material,
|
||||
@@ -455,5 +449,17 @@ export function resolvePaintTargetFromSelection(params: {
|
||||
return 'ridge-vent'
|
||||
}
|
||||
|
||||
if (selectedNode.type === 'turbine-vent') {
|
||||
return 'turbine-vent'
|
||||
}
|
||||
|
||||
if (selectedNode.type === 'cupola') {
|
||||
return 'cupola'
|
||||
}
|
||||
|
||||
if (selectedNode.type === 'eyebrow-vent') {
|
||||
return 'eyebrow-vent'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -86,10 +86,15 @@ export type StructureTool =
|
||||
| 'shelf'
|
||||
| 'box-vent'
|
||||
| 'ridge-vent'
|
||||
| 'turbine-vent'
|
||||
| 'cupola'
|
||||
| 'eyebrow-vent'
|
||||
| 'chimney'
|
||||
| 'solar-panel'
|
||||
| 'skylight'
|
||||
| 'dormer'
|
||||
| 'gutter'
|
||||
| 'downspout'
|
||||
|
||||
// Furnish mode tools (items and decoration)
|
||||
export type FurnishTool = 'item'
|
||||
|
||||
@@ -1,7 +1,152 @@
|
||||
import { BoxVentNode as BoxVentNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import {
|
||||
BoxVentNode as BoxVentNodeSchema,
|
||||
type BoxVentNode as BoxVentNodeType,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { buildBoxVentFloorplan } from './floorplan'
|
||||
import { surfacePaintCapability } from '../shared/surface-paint'
|
||||
import { boxVentParametrics } from './parametrics'
|
||||
import { BoxVentNode } from './schema'
|
||||
|
||||
// Edge-to-arrow-center offset, matching the chimney / dormer cadence.
|
||||
const SIDE_HANDLE_OFFSET = 0.25
|
||||
const HEIGHT_HANDLE_OFFSET = 0.2
|
||||
// Snug to the vent corner — vents are small (default 0.4 m), so the big
|
||||
// chimney/dormer offset floated the rotate icon far off the item.
|
||||
const ROTATE_CORNER_OFFSET = 0.1
|
||||
// Min sizes — vents are small (default 0.4 × 0.4 × 0.15), so the floor
|
||||
// is well below the default values to allow shrinking without locking.
|
||||
const MIN_DIM = 0.1
|
||||
const MIN_HEIGHT = 0.05
|
||||
|
||||
// Mid-Y of the vent body in vent-mesh-local. The vent sits with its base
|
||||
// at y=0 on the slope, so mid is half the height. Side / depth / rotate
|
||||
// chevrons all place their handle at this Y to read as "this dimension
|
||||
// is the vent body".
|
||||
function getBodyMidY(n: BoxVentNodeType): number {
|
||||
return Math.max(0.001, n.height) / 2
|
||||
}
|
||||
|
||||
// Width arrow on the +X (right) or -X (left) side of the vent body.
|
||||
// Asymmetric resize — anchored edge stays world-fixed by recentering
|
||||
// `position` along the vent's own +X arm in segment frame (matches the
|
||||
// chimney / dormer width handle math). The slope tilt rotates around
|
||||
// the vent's base point, so segment-local XZ of the anchored edge stays
|
||||
// the same regardless of tilt; only the yaw matters for the projection.
|
||||
function boxVentWidthHandle(side: 'left' | 'right'): HandleDescriptor<BoxVentNodeType> {
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
min: MIN_DIM,
|
||||
currentValue: (n) => n.width,
|
||||
apply: (initial, newWidth) => {
|
||||
const rotY = initial.rotation ?? 0
|
||||
const armX = Math.cos(rotY)
|
||||
const armZ = -Math.sin(rotY)
|
||||
const anchorX = initial.position[0] - sign * (initial.width / 2) * armX
|
||||
const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ
|
||||
const newCenterX = anchorX + sign * (newWidth / 2) * armX
|
||||
const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ
|
||||
return {
|
||||
width: newWidth,
|
||||
position: [newCenterX, initial.position[1], newCenterZ],
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), 0],
|
||||
// Flip the left chevron so it points outward toward -X. The
|
||||
// generic LinearArrow auto-orients for axis 'z'; +X / -X facing
|
||||
// is up to the descriptor.
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Depth arrow on the +Z side. Symmetric (anchor 'center') matches the
|
||||
// chimney / dormer handle count budget — splitting into asymmetric front /
|
||||
// back chevrons here would push the vent over the same TSL/MRT pipeline
|
||||
// threshold those nodes already document. Single symmetric chevron grows
|
||||
// the depth from the centre.
|
||||
function boxVentDepthHandle(): HandleDescriptor<BoxVentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'z',
|
||||
anchor: 'center',
|
||||
min: MIN_DIM,
|
||||
currentValue: (n) => n.depth,
|
||||
apply: (_n, newValue) => ({ depth: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Height arrow above the top of the vent. anchor='min' so the base stays
|
||||
// pinned to the slope at vent-local y=0 and the top edge follows the
|
||||
// pointer. Plain chevron (not tracker) — at default sizes (~0.15 m) a
|
||||
// dashed leader from base to top reads as visual noise rather than a
|
||||
// dimension cue.
|
||||
function boxVentHeightHandle(): HandleDescriptor<BoxVentNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_HEIGHT,
|
||||
currentValue: (n) => n.height,
|
||||
apply: (_n, newValue) => ({ height: Math.max(MIN_HEIGHT, newValue) }),
|
||||
placement: {
|
||||
position: (n) => [0, Math.max(n.height, MIN_HEIGHT) + HEIGHT_HANDLE_OFFSET, 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-vent rotation gizmo at the +X/+Z corner of the body footprint.
|
||||
// The registered group already centres on the vent and applies its
|
||||
// composed slope+yaw quaternion, so the default rotation pivot is
|
||||
// correct — no `rotationCenter` override needed.
|
||||
function boxVentRotateHandle(): HandleDescriptor<BoxVentNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
position: (n) => [
|
||||
n.width / 2 + ROTATE_CORNER_OFFSET,
|
||||
getBodyMidY(n),
|
||||
n.depth / 2 + ROTATE_CORNER_OFFSET,
|
||||
],
|
||||
// Aim the two-headed icon along the +X+Z corner bisector.
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
// Guide ring centred on the vent (drawn at the handle-frame origin),
|
||||
// sized to pass through the corner icon so the icon rides the ring and
|
||||
// the whole control reads as encircling the item — matches solar-panel.
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) =>
|
||||
Math.hypot(n.width / 2 + ROTATE_CORNER_OFFSET, n.depth / 2 + ROTATE_CORNER_OFFSET),
|
||||
y: (n) => getBodyMidY(n),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// `portal: 'grandparent'` on every handle: the vent mesh is mounted under
|
||||
// the roof's `roof-elements` group (reproducing the segment transform), so
|
||||
// the handle rig must ride the roof→segment→node frame chain — same as
|
||||
// solar-panel / skylight. Without it the handles (and the rotate arc) mount
|
||||
// in the bare segment-mesh frame and render offset from the vent.
|
||||
const boxVentHandles: HandleDescriptor<BoxVentNodeType>[] = [
|
||||
boxVentWidthHandle('right'),
|
||||
boxVentWidthHandle('left'),
|
||||
boxVentDepthHandle(),
|
||||
boxVentHeightHandle(),
|
||||
boxVentRotateHandle(),
|
||||
].map((h): HandleDescriptor<BoxVentNodeType> => ({ ...h, portal: 'grandparent' }))
|
||||
|
||||
/**
|
||||
* Box vent — a small louvered ventilation box that sits on a roof
|
||||
* slope. Parented to a `roof-segment`; position is segment-local;
|
||||
@@ -45,6 +190,8 @@ export const boxVentDefinition: NodeDefinition<typeof BoxVentNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
// Single painted surface — registry-driven paint dispatch (see chimney).
|
||||
paint: surfacePaintCapability,
|
||||
// Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the
|
||||
// slope — no `buildCut`, just the dirty cascade so the parent
|
||||
// roof's merged shell rebuilds when the vent moves / resizes.
|
||||
@@ -52,6 +199,8 @@ export const boxVentDefinition: NodeDefinition<typeof BoxVentNode> = {
|
||||
},
|
||||
|
||||
parametrics: boxVentParametrics,
|
||||
handles: boxVentHandles,
|
||||
floorplan: buildBoxVentFloorplan,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
BoxVentNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Floor-plan builder for a box vent — a small attic-exhaust vent on a roof
|
||||
* slope. Seen from above it reads as its footprint per style: `box` is a
|
||||
* cover with an inset riser, `cap` flares to a flange past the body, and
|
||||
* `dome` is a flush ellipse.
|
||||
*
|
||||
* Coordinate frame mirrors the 3D transform stack
|
||||
* (roof → roof-segment → vent), same as the chimney builder. `position`
|
||||
* is segment-local (X = width, Z = depth; Y ignored — anchored to the
|
||||
* slope). `rotation` is yaw. Rotations negated for the floor plan's y-down
|
||||
* convention.
|
||||
*/
|
||||
export function buildBoxVentFloorplan(
|
||||
node: BoxVentNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const segment = ctx.parent as RoofSegmentNode | null
|
||||
if (!segment || segment.type !== 'roof-segment') return null
|
||||
const roofId = segment.parentId as AnyNodeId | null
|
||||
const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined
|
||||
if (!roof || roof.type !== 'roof') return null
|
||||
|
||||
const cosR = Math.cos(-roof.rotation)
|
||||
const sinR = Math.sin(-roof.rotation)
|
||||
const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR
|
||||
const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR
|
||||
|
||||
const segRot = -(roof.rotation + segment.rotation)
|
||||
const cosS = Math.cos(segRot)
|
||||
const sinS = Math.sin(segRot)
|
||||
const cx = segCx + node.position[0] * cosS - node.position[2] * sinS
|
||||
const cz = segCz + node.position[0] * sinS + node.position[2] * cosS
|
||||
|
||||
const rot = -(roof.rotation + segment.rotation + node.rotation)
|
||||
const cos = Math.cos(rot)
|
||||
const sin = Math.sin(rot)
|
||||
const toPlan = (lx: number, lz: number): FloorplanPoint => [
|
||||
cx + lx * cos - lz * sin,
|
||||
cz + lx * sin + lz * cos,
|
||||
]
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const isHovered = view?.hovered ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
// Painted-metal vent — cool grey, 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' : '#dbe1e8'
|
||||
const fillOpacity = showSelectedChrome ? 0.55 : 0.7
|
||||
const lineWidth = showSelectedChrome ? 0.03 : 0.02
|
||||
|
||||
const hw = Math.max(node.width, 0.05) / 2
|
||||
const hd = Math.max(node.depth, 0.05) / 2
|
||||
const style = node.style ?? 'cap'
|
||||
|
||||
const rect = (halfX: number, halfZ: number): FloorplanPoint[] => [
|
||||
toPlan(-halfX, -halfZ),
|
||||
toPlan(halfX, -halfZ),
|
||||
toPlan(halfX, halfZ),
|
||||
toPlan(-halfX, halfZ),
|
||||
]
|
||||
const ellipse = (halfX: number, halfZ: number): FloorplanPoint[] => {
|
||||
const pts: FloorplanPoint[] = []
|
||||
const N = 28
|
||||
for (let i = 0; i < N; i++) {
|
||||
const a = (i / N) * Math.PI * 2
|
||||
pts.push(toPlan(halfX * Math.cos(a), halfZ * Math.sin(a)))
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
const children: FloorplanGeometry[] = []
|
||||
|
||||
if (style === 'dome') {
|
||||
// Outer = the round flange plate (dome radius + flange overhang).
|
||||
const ovh = Math.max(0, node.hoodOverhang ?? 0.04)
|
||||
const outer = ellipse(hw + ovh, hd + ovh)
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outer,
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
})
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outer,
|
||||
fill,
|
||||
fillOpacity,
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
// Dome footprint inside the flange.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: ellipse(hw, hd),
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: lineWidth * 0.8,
|
||||
strokeOpacity: 0.7,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
// Inner ring suggests the dome bulge.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: ellipse(hw * 0.5, hd * 0.5),
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: lineWidth * 0.8,
|
||||
strokeOpacity: 0.5,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
} else if (style === 'cap') {
|
||||
// Flange flares past the body by `hoodOverhang` on all sides.
|
||||
const ovh = Math.max(0, node.hoodOverhang ?? 0.04)
|
||||
const outer = rect(hw + ovh, hd + ovh)
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outer,
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
})
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outer,
|
||||
fill,
|
||||
fillOpacity,
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
// Body footprint inside the flange.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: rect(hw, hd),
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: lineWidth * 0.8,
|
||||
strokeOpacity: 0.7,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
} else {
|
||||
// box: cover footprint + inset riser.
|
||||
const outer = rect(hw, hd)
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outer,
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
})
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outer,
|
||||
fill,
|
||||
fillOpacity,
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
const inset = Math.max(0, Math.min(node.baseInset ?? 0.06, Math.min(hw, hd) - 0.01))
|
||||
if (inset > 0.001) {
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: rect(hw - inset, hd - inset),
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: lineWidth * 0.8,
|
||||
strokeOpacity: 0.7,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type BoxVentNode, getActiveRoofHeight, type RoofType } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
|
||||
/**
|
||||
* Pure builder for the box-vent mesh. Models a real attic box vent:
|
||||
@@ -27,8 +26,6 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
||||
export function buildBoxVentGeometry(node: BoxVentNode): THREE.BufferGeometry {
|
||||
if (node.style === 'box') return buildBoxShape(node)
|
||||
if (node.style === 'cap') return buildCapShape(node)
|
||||
// `dome` will get its own dedicated builder in Step 3. For now it
|
||||
// keeps the unified dome+skirt shape so the visual doesn't regress.
|
||||
return buildDomeStyleShape(node)
|
||||
}
|
||||
|
||||
@@ -374,191 +371,167 @@ function clamp01(value: number): number {
|
||||
return value < 0 ? 0 : value > 1 ? 1 : value
|
||||
}
|
||||
|
||||
// ─── Dome style (current implementation) ─────────────────────────────
|
||||
// Body + dome cap with flange skirt. Drives the `dome` style until
|
||||
// Step 3 swaps it for a dedicated builder.
|
||||
// ─── Dome style (bullnose vent) ──────────────────────────────────────
|
||||
// A stainless bullnose vent: a smooth half-ellipsoid dome that overhangs a
|
||||
// short lifted collar — the collar is what gives the vent its visible depth
|
||||
// standing off the roof — all seated on a wide round FLANGE plate that
|
||||
// mounts to the roof. Every part is a surface of revolution; no square
|
||||
// geometry anywhere.
|
||||
//
|
||||
// .-""-. ← dome (overhangs the collar lip)
|
||||
// / \
|
||||
// | | | | ← lifted collar (the depth above the flange)
|
||||
// __| |__
|
||||
// |______________| ← round flange plate on the roof
|
||||
//
|
||||
// `domeCurvature` shapes the cap; `hoodOverhang` sets how far the flange
|
||||
// plate extends past the dome.
|
||||
|
||||
function buildDomeStyleShape(node: BoxVentNode): THREE.BufferGeometry {
|
||||
const w = node.width
|
||||
const d = node.depth
|
||||
const rx = node.width / 2
|
||||
const rz = node.depth / 2
|
||||
const h = node.height
|
||||
// Dome has no flange — the cap rolls down flush to the body footprint.
|
||||
// `hoodOverhang` is hidden from the panel for this style; we ignore any
|
||||
// stored value so legacy nodes still render flush.
|
||||
const overhang = 0
|
||||
const power = Math.max(0.3, Math.min(1.5, node.domeCurvature ?? 1.0))
|
||||
const lng = 24
|
||||
const lat = 8
|
||||
|
||||
const bodyH = h * 0.32
|
||||
const hoodH = h - bodyH
|
||||
// Wide flange plate (extends past the dome by `hoodOverhang`) + thickness.
|
||||
const brim = Math.max(0, node.hoodOverhang ?? 0.04)
|
||||
const rxF = rx + brim
|
||||
const rzF = rz + brim
|
||||
const brimThk = Math.min(0.015, h * 0.18)
|
||||
// Collar: a touch narrower than the dome so the cap overhangs it, and tall
|
||||
// enough to read as real lift above the flange.
|
||||
const collarRx = rx * 0.9
|
||||
const collarRz = rz * 0.9
|
||||
const collarH = Math.max(0.012, Math.min(h * 0.4, h - brimThk - 0.02))
|
||||
const domeBaseY = brimThk + collarH
|
||||
const domeH = h - domeBaseY
|
||||
|
||||
return (
|
||||
mergeGeometries(
|
||||
[buildBody(w, d, bodyH), buildDomeHood(w, d, overhang, bodyH, hoodH, 'dome')],
|
||||
false,
|
||||
) ?? buildBody(w, d, bodyH)
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Body ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildBody(w: number, d: number, bodyH: number): THREE.BufferGeometry {
|
||||
const hw = w / 2
|
||||
const hd = d / 2
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
|
||||
// +X side
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[hw, 0, -hd],
|
||||
[hw, 0, hd],
|
||||
[hw, bodyH, hd],
|
||||
[hw, bodyH, -hd],
|
||||
[1, 0, 0],
|
||||
)
|
||||
// -X side
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, 0, hd],
|
||||
[-hw, 0, -hd],
|
||||
[-hw, bodyH, -hd],
|
||||
[-hw, bodyH, hd],
|
||||
[-1, 0, 0],
|
||||
)
|
||||
// +Z side
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[hw, 0, hd],
|
||||
[-hw, 0, hd],
|
||||
[-hw, bodyH, hd],
|
||||
[hw, bodyH, hd],
|
||||
[0, 0, 1],
|
||||
)
|
||||
// -Z side
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, 0, -hd],
|
||||
[hw, 0, -hd],
|
||||
[hw, bodyH, -hd],
|
||||
[-hw, bodyH, -hd],
|
||||
[0, 0, -1],
|
||||
)
|
||||
// Bottom (closes the body so it reads as solid from below)
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-hw, 0, -hd],
|
||||
[-hw, 0, hd],
|
||||
[hw, 0, hd],
|
||||
[hw, 0, -hd],
|
||||
[0, -1, 0],
|
||||
)
|
||||
const radial = (a: number[], _b: number[], c: number[]): number[] => {
|
||||
const mx = (a[0]! + c[0]!) / 2
|
||||
const mz = (a[2]! + c[2]!) / 2
|
||||
const l = Math.hypot(mx, mz) || 1
|
||||
return [mx / l, 0, mz / l]
|
||||
}
|
||||
const up = (): number[] => [0, 1, 0]
|
||||
const down = (): number[] => [0, -1, 0]
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
const flangeBottom = ringAt(rxF, rzF, 0, lng)
|
||||
const flangeTop = ringAt(rxF, rzF, brimThk, lng)
|
||||
const collarFoot = ringAt(collarRx, collarRz, brimThk, lng)
|
||||
const collarTop = ringAt(collarRx, collarRz, domeBaseY, lng)
|
||||
const domeBase = ringAt(rx, rz, domeBaseY, lng)
|
||||
const center = ringAt(0, 0, 0, lng)
|
||||
|
||||
// ─── Dome hood ───────────────────────────────────────────────────────
|
||||
// Closed rounded cap (half-ellipsoid sampled on a lat × lng grid) plus
|
||||
// a flat skirt that extends past the body by `overhang` — that skirt is
|
||||
// what reads as the flashing flange in the reference photo. The cap is
|
||||
// fully closed at the apex (single pole vertex), so there's no empty
|
||||
// plateau like the old pyramid hood had.
|
||||
//
|
||||
// `style` shifts the dome shape subtly:
|
||||
// - 'standard' → moderate dome, gentle roll-off near the apex
|
||||
// - 'low-profile' → very shallow dome (mostly a curved pillow)
|
||||
// - 'dome' → near-hemisphere with sharper apex curvature
|
||||
// Flange: underside (down), outer rim (radial), top face (up).
|
||||
addBand(positions, normals, uvs, flangeBottom, center, lng, down)
|
||||
addBand(positions, normals, uvs, flangeBottom, flangeTop, lng, radial)
|
||||
addBand(positions, normals, uvs, flangeTop, collarFoot, lng, up)
|
||||
// Lifted collar wall (radial) + the overhanging dome-lip underside (down).
|
||||
addBand(positions, normals, uvs, collarFoot, collarTop, lng, radial)
|
||||
addBand(positions, normals, uvs, collarTop, domeBase, lng, down)
|
||||
|
||||
function buildDomeHood(
|
||||
w: number,
|
||||
d: number,
|
||||
overhang: number,
|
||||
bodyH: number,
|
||||
hoodH: number,
|
||||
style: BoxVentNode['style'],
|
||||
): THREE.BufferGeometry {
|
||||
const positions: number[] = []
|
||||
const normals: number[] = []
|
||||
const uvs: number[] = []
|
||||
|
||||
const bw = w / 2 + overhang
|
||||
const bd = d / 2 + overhang
|
||||
const y0 = bodyH
|
||||
|
||||
// Skirt underside
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[-bw, y0, -bd],
|
||||
[-bw, y0, bd],
|
||||
[bw, y0, bd],
|
||||
[bw, y0, -bd],
|
||||
[0, -1, 0],
|
||||
)
|
||||
|
||||
// Sample a low-resolution dome on a lat × lng grid. The radial decay
|
||||
// is `cos(phi) ^ radialPower` — `radialPower < 1` keeps the dome wide
|
||||
// longer near the top (soft pillow silhouette, like the reference
|
||||
// photo). `dome` uses a true ellipsoid; `cap` defaults to a softer
|
||||
// pillow until Step 2 swaps it for the pyramid hood.
|
||||
const radialPower = style === 'dome' ? 1.0 : 0.65
|
||||
const lat = 6
|
||||
const lng = 14
|
||||
const points: THREE.Vector3[][] = []
|
||||
for (let i = 0; i <= lat; i++) {
|
||||
const row: THREE.Vector3[] = []
|
||||
// Dome cap, base ring → apex.
|
||||
const domeCenterY = domeBaseY
|
||||
const domeHint = (a: number[], _b: number[], c: number[]): number[] => {
|
||||
const x = (a[0]! + c[0]!) / 2
|
||||
const y = (a[1]! + c[1]!) / 2 - domeCenterY
|
||||
const z = (a[2]! + c[2]!) / 2
|
||||
const l = Math.hypot(x, y, z) || 1
|
||||
return [x / l, y / l, z / l]
|
||||
}
|
||||
let prev = domeBase
|
||||
for (let i = 1; i <= lat; i++) {
|
||||
const phi = (Math.PI / 2) * (i / lat)
|
||||
const r = Math.cos(phi) ** radialPower
|
||||
const y = y0 + hoodH * Math.sin(phi)
|
||||
for (let j = 0; j <= lng; j++) {
|
||||
const theta = Math.PI * 2 * (j / lng)
|
||||
const x = bw * r * Math.cos(theta)
|
||||
const z = bd * r * Math.sin(theta)
|
||||
row.push(new THREE.Vector3(x, y, z))
|
||||
}
|
||||
points.push(row)
|
||||
}
|
||||
|
||||
const ab = new THREE.Vector3()
|
||||
const ad = new THREE.Vector3()
|
||||
for (let i = 0; i < lat; i++) {
|
||||
for (let j = 0; j < lng; j++) {
|
||||
const a = points[i]![j]!
|
||||
const b = points[i]![j + 1]!
|
||||
const c = points[i + 1]![j + 1]!
|
||||
const d2 = points[i + 1]![j]!
|
||||
ab.subVectors(b, a)
|
||||
ad.subVectors(d2, a)
|
||||
// Outward dome normal: `ad × ab` matches pushQuad's `(a,c,b)+(a,d,c)`
|
||||
// winding (see note in `pushQuad`). Swapping the cross operands here
|
||||
// keeps the dome lit from the outside, not from inside.
|
||||
const n = new THREE.Vector3().crossVectors(ad, ab).normalize()
|
||||
pushQuad(
|
||||
positions,
|
||||
normals,
|
||||
uvs,
|
||||
[a.x, a.y, a.z],
|
||||
[b.x, b.y, b.z],
|
||||
[c.x, c.y, c.z],
|
||||
[d2.x, d2.y, d2.z],
|
||||
[n.x, n.y, n.z],
|
||||
)
|
||||
}
|
||||
const rf = Math.cos(phi) ** power
|
||||
const y = domeBaseY + domeH * Math.sin(phi)
|
||||
const ring = ringAt(rx * rf, rz * rf, y, lng)
|
||||
addBand(positions, normals, uvs, prev, ring, lng, domeHint)
|
||||
prev = ring
|
||||
}
|
||||
|
||||
return buildBufferGeometry(positions, normals, uvs)
|
||||
}
|
||||
|
||||
// One ellipse ring of `lng` segments at height `y`. First and last points
|
||||
// coincide (closing the loop) so callers iterate j < lng.
|
||||
function ringAt(ax: number, az: number, y: number, lng: number): number[][] {
|
||||
const row: number[][] = []
|
||||
for (let j = 0; j <= lng; j++) {
|
||||
const t = (Math.PI * 2 * j) / lng
|
||||
row.push([ax * Math.cos(t), y, az * Math.sin(t)])
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// Connect two rings with a band of quads. `hintFn` returns the outward
|
||||
// direction for each quad so pushQuadOriented can orient the face correctly.
|
||||
function addBand(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
rA: number[][],
|
||||
rB: number[][],
|
||||
lng: number,
|
||||
hintFn: (a: number[], b: number[], c: number[], d: number[]) => number[],
|
||||
): void {
|
||||
for (let j = 0; j < lng; j++) {
|
||||
const a = rA[j]!
|
||||
const b = rA[j + 1]!
|
||||
const c = rB[j + 1]!
|
||||
const d = rB[j]!
|
||||
pushQuadOriented(positions, normals, uvs, a, b, c, d, hintFn(a, b, c, d))
|
||||
}
|
||||
}
|
||||
|
||||
// Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so
|
||||
// the shared flat normal points toward `hint`.
|
||||
function pushQuadOriented(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
a: number[],
|
||||
b: number[],
|
||||
c: number[],
|
||||
d: number[],
|
||||
hint: number[],
|
||||
) {
|
||||
let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!)
|
||||
let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!)
|
||||
let nz = (c[0]! - a[0]!) * (b[1]! - a[1]!) - (c[1]! - a[1]!) * (b[0]! - a[0]!)
|
||||
const flip = nx * hint[0]! + ny * hint[1]! + nz * hint[2]! < 0
|
||||
if (flip) {
|
||||
nx = -nx
|
||||
ny = -ny
|
||||
nz = -nz
|
||||
}
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1
|
||||
nx /= len
|
||||
ny /= len
|
||||
nz /= len
|
||||
|
||||
const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!)
|
||||
const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!)
|
||||
|
||||
if (flip) {
|
||||
positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!)
|
||||
uvs.push(0, 0, u, 0, u, v)
|
||||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!)
|
||||
uvs.push(0, 0, u, v, 0, v)
|
||||
} else {
|
||||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
|
||||
uvs.push(0, 0, u, v, u, 0)
|
||||
positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!)
|
||||
uvs.push(0, 0, 0, v, u, v)
|
||||
}
|
||||
for (let i = 0; i < 6; i++) normals.push(nx, ny, nz)
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function buildBufferGeometry(
|
||||
|
||||
@@ -14,8 +14,8 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import BoxVentPreview from './preview'
|
||||
|
||||
/**
|
||||
|
||||
@@ -321,9 +321,10 @@ export default function BoxVentPanel() {
|
||||
</>
|
||||
)}
|
||||
{node.style === 'dome' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Dome Curvature"
|
||||
max={1.2}
|
||||
max={1.5}
|
||||
min={0.3}
|
||||
onChange={(v) => previewProp({ domeCurvature: v })}
|
||||
onCommit={(v) => handleUpdate({ domeCurvature: v })}
|
||||
@@ -331,8 +332,21 @@ export default function BoxVentPanel() {
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit=""
|
||||
value={Math.round((node.domeCurvature ?? 0.65) * 100) / 100}
|
||||
value={Math.round((node.domeCurvature ?? 1.0) * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Base Flange"
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => previewProp({ hoodOverhang: v })}
|
||||
onCommit={(v) => handleUpdate({ hoodOverhang: v })}
|
||||
precision={3}
|
||||
restoreOnCommit={false}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round((node.hoodOverhang ?? 0.04) * 1000) / 1000}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
|
||||
@@ -18,14 +18,13 @@ import {
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { buildBoxVentGeometry } from './geometry'
|
||||
|
||||
const defaultMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 0.85,
|
||||
metalness: 0.1,
|
||||
side: THREE.DoubleSide,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -108,26 +107,32 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
||||
}, [segment, node.position[0], node.position[2]])
|
||||
|
||||
// Paint surface: explicit material wins, then preset, then the cached
|
||||
// default. Mirrors the slab / stair / wall pattern. Preset materials
|
||||
// come from the shared cache with `side: FrontSide`; clone + force
|
||||
// DoubleSide locally so back faces of the vent body / hood don't drop
|
||||
// out when the camera looks up at the eaves.
|
||||
// default. FrontSide everywhere — DoubleSide on the role material's
|
||||
// NodeMaterial poisons the MRT scene pass (see `materials.ts` line 77 /
|
||||
// glazing fix 9400f1c5). Earlier this path forced DoubleSide so back
|
||||
// faces of the vent body / hood wouldn't drop out when looking up at the
|
||||
// eaves; that's now a known visual tradeoff — a closed-solid extrude in
|
||||
// `geometry.ts` is the right fix if undersides become noticeable.
|
||||
const material = useMemo(() => {
|
||||
// Untextured box vent (and textures-off mode) takes the themed 'roof'
|
||||
// role colour. Request DoubleSide directly so the cached role material
|
||||
// is the right side — no clone/mutation of a shared material.
|
||||
if (!textures || (!node.material && !node.materialPreset)) {
|
||||
return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme)
|
||||
return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
|
||||
}
|
||||
const base = node.material
|
||||
return node.material
|
||||
? createMaterial(node.material, shading)
|
||||
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
|
||||
if (base.side === THREE.DoubleSide) return base
|
||||
const cloned = base.clone()
|
||||
cloned.side = THREE.DoubleSide
|
||||
return cloned
|
||||
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
|
||||
|
||||
// Compose slope tilt + yaw onto a single quaternion so the registered
|
||||
// ref's local frame is vent-mesh-local. `NodeArrowHandles` reads this
|
||||
// frame to place its chevrons; collapsing the nested-group stack onto
|
||||
// the registered group lets handles use vent-local coords directly,
|
||||
// without per-arrow tilt compensation. Mirrors solar-panel's renderer.
|
||||
const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), [])
|
||||
const composedQuat = useMemo(() => {
|
||||
const yawQuat = new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0)
|
||||
return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat)
|
||||
}, [surfaceQuat, node.rotation, yAxis])
|
||||
|
||||
if (!segment) return null
|
||||
|
||||
// `node.position` is segment-local (the placement + move tools resolve
|
||||
@@ -146,11 +151,10 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
||||
<group position={segPos} rotation-y={segRotY}>
|
||||
<group
|
||||
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
|
||||
quaternion={composedQuat}
|
||||
ref={ref}
|
||||
visible={node.visible}
|
||||
>
|
||||
<group quaternion={surfaceQuat}>
|
||||
<group rotation-y={node.rotation ?? 0}>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={geometry}
|
||||
@@ -161,8 +165,6 @@ const BoxVentRenderer = ({ node: storeNode }: { node: BoxVentNode }) => {
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../solar-panel/geometry'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { boxVentDefinition } from './definition'
|
||||
import BoxVentPreview from './preview'
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
// Building-wide drag (whole-building translate + R/T rotation). Routed
|
||||
// through `MoveTool`'s registry-affordance lookup rather than a
|
||||
// hardcoded dispatcher arm.
|
||||
affordanceTools: {
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
|
||||
parametrics: buildingParametrics,
|
||||
|
||||
renderer: {
|
||||
|
||||
+6
-7
@@ -8,13 +8,10 @@ import {
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const Y_AXIS = new THREE.Vector3(0, 1, 0)
|
||||
|
||||
@@ -96,7 +93,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
|
||||
if (rotationDelta !== 0) {
|
||||
event.preventDefault()
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
triggerSFX('sfx:item-rotate')
|
||||
pendingRotationRef.current += rotationDelta
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(nodeId)
|
||||
@@ -124,7 +121,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
|
||||
previousGridPosRef.current = [gridX, gridZ]
|
||||
@@ -154,7 +151,7 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
})
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
triggerSFX('sfx:item-place')
|
||||
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
@@ -207,3 +204,5 @@ export function MoveBuildingContent({ node }: { node: BuildingNode }) {
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default MoveBuildingContent
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
createSurfaceRoleMaterial,
|
||||
NodeRenderer,
|
||||
resolveSurfaceColor,
|
||||
useNodeEvents,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
@@ -86,8 +87,17 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
// 'ceiling' role colour; only an explicit preset/material keeps a texture.
|
||||
const hasExplicit = Boolean(node.materialPreset || node.material)
|
||||
if (!textures || !hasExplicit) {
|
||||
// Bottom (seen from inside the room, looking up) stays opaque so the
|
||||
// ceiling reads as a solid surface. Top uses the transparent
|
||||
// grid-pattern material so the ceiling stays see-through whenever
|
||||
// the editor reveals the `ceiling-grid` overlay (placing a
|
||||
// ceiling-hosted item, or selecting one of its children — e.g.
|
||||
// after committing a placement). Without this the top mesh shipped
|
||||
// an opaque surface-role material, so a top-down camera lost view
|
||||
// of everything under the ceiling once the overlay turned on.
|
||||
const ceilingColor = resolveSurfaceColor('ceiling', colorPreset, sceneTheme)
|
||||
return {
|
||||
topMaterial: createSurfaceRoleMaterial('ceiling', colorPreset, FrontSide, sceneTheme),
|
||||
topMaterial: getCeilingMaterials(ceilingColor).topMaterial,
|
||||
bottomMaterial: createSurfaceRoleMaterial('ceiling', colorPreset, BackSide, sceneTheme),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,327 @@
|
||||
import { ChimneyNode as ChimneyNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ChimneyNode as ChimneyNodeType,
|
||||
ChimneyNode as ChimneyNodeSchema,
|
||||
getActiveRoofHeight,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type RoofSegmentNode as RoofSegmentNodeType,
|
||||
type SceneApi,
|
||||
} from '@pascal-app/core'
|
||||
import { buildChimneyFloorplan } from './floorplan'
|
||||
import { chimneyPaint } from './paint'
|
||||
import { chimneyParametrics } from './parametrics'
|
||||
import { ChimneyNode } from './schema'
|
||||
|
||||
// Side handle offsets in metres. Match the roof-segment values so a
|
||||
// segment + chimney selected back-to-back use the same visual rhythm.
|
||||
const SIDE_HANDLE_OFFSET = 0.25
|
||||
const HEIGHT_HANDLE_OFFSET = 0.25
|
||||
const ROTATE_CORNER_OFFSET = 0.35
|
||||
const MIN_BODY_DIM = 0.3
|
||||
const MIN_HEIGHT_ABOVE_RIDGE = 0.05
|
||||
const MIN_FLUE_HEIGHT = 0.05
|
||||
const MAX_FLUE_HEIGHT = 1.5
|
||||
const MIN_CAP_THICKNESS = 0.02
|
||||
const MAX_CAP_THICKNESS = 0.5
|
||||
const MIN_CAP_OVERHANG = 0
|
||||
const MAX_CAP_OVERHANG = 0.3
|
||||
// Cap-reveal gap between body top and cap base — mirrors the
|
||||
// `CAP_REVEAL` constant in `geometry.ts`. Local copy because handle
|
||||
// placements need to know the cap's Y range and the geometry's
|
||||
// constant isn't exported. If you change it here, change it there.
|
||||
const CAP_REVEAL = 0.003
|
||||
// Fallback Y when the host segment can't be resolved (shouldn't happen
|
||||
// for a placed chimney, but `placement.position` runs synchronously and
|
||||
// must always return a vector).
|
||||
const FALLBACK_BODY_MID_Y = 1.5
|
||||
|
||||
// Resolve the segment that hosts this chimney. Returns undefined if the
|
||||
// chimney is unparented or the parent isn't in the scene yet.
|
||||
function resolveHostSegment(
|
||||
node: ChimneyNodeType,
|
||||
sceneApi: SceneApi,
|
||||
): RoofSegmentNodeType | undefined {
|
||||
if (!node.roofSegmentId) return undefined
|
||||
return sceneApi.get<RoofSegmentNodeType>(node.roofSegmentId as AnyNodeId)
|
||||
}
|
||||
|
||||
// Mid-Y of the visible chimney body in the host segment's local frame.
|
||||
// Matches the geometry builder: body runs from `baseY = max(0, wallHeight
|
||||
// - 0.2)` up to `peakY + heightAboveRidge`. The handle Y picks the
|
||||
// midpoint of the *visible* portion (deck plane → top) so chevrons sit
|
||||
// next to the body, not buried inside the roof deck or floating over
|
||||
// the eave.
|
||||
function getBodyMidY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number {
|
||||
const peakY = segment.wallHeight + getActiveRoofHeight(segment)
|
||||
const topY = peakY + node.heightAboveRidge
|
||||
return (segment.wallHeight + topY) / 2
|
||||
}
|
||||
|
||||
// Top of the chimney body (where the cap reveal gap begins). Tracker
|
||||
// handle and cap-thickness handle both reference this Y.
|
||||
function getBodyTopY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number {
|
||||
return segment.wallHeight + getActiveRoofHeight(segment) + node.heightAboveRidge
|
||||
}
|
||||
|
||||
// Cap base Y — the bottom of the cap slab. Sits just above the body
|
||||
// top with a small reveal gap so a shadow line separates them.
|
||||
function getCapBaseY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number {
|
||||
return getBodyTopY(node, segment) + CAP_REVEAL
|
||||
}
|
||||
|
||||
// Cap top Y — the top of the cap slab. Falls back to body top when no
|
||||
// cap is rendered (flues mount on whichever is the upper surface).
|
||||
function getCapTopY(node: ChimneyNodeType, segment: RoofSegmentNodeType): number {
|
||||
if (!node.cap || node.capShape === 'none') return getBodyTopY(node, segment)
|
||||
return getCapBaseY(node, segment) + node.capThickness
|
||||
}
|
||||
|
||||
// Width arrow on the +X (right) or -X (left) side. Asymmetric resize:
|
||||
// dragging one arrow grows the chimney outward from its own edge while
|
||||
// the opposite edge stays world-fixed. Handles live in the chimney's
|
||||
// registered ref frame (the nested inner group in the renderer that
|
||||
// applies `node.position` / `node.rotation`), so placements are in
|
||||
// chimney-local coordinates — no per-arrow rotation/translation
|
||||
// compensation. `apply` keeps the world-fixed edge anchored even when
|
||||
// the chimney is rotated by recentering `position` along the chimney's
|
||||
// own +X arm in segment frame.
|
||||
function chimneyWidthHandle(side: 'left' | 'right'): HandleDescriptor<ChimneyNodeType> {
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
// Portal into the roof (grandparent), not the segment (parent). Unpainted
|
||||
// roof segments live inside a `visible={false}` wrapper, which would
|
||||
// hide the handles. The roof itself is always visible. Skylight does
|
||||
// the same.
|
||||
portal: 'grandparent',
|
||||
min: MIN_BODY_DIM,
|
||||
currentValue: (n) => n.width,
|
||||
apply: (initial, newWidth) => {
|
||||
const rotY = initial.rotation ?? 0
|
||||
const armX = Math.cos(rotY)
|
||||
const armZ = -Math.sin(rotY)
|
||||
const anchorX = initial.position[0] - sign * (initial.width / 2) * armX
|
||||
const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ
|
||||
const newCenterX = anchorX + sign * (newWidth / 2) * armX
|
||||
const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ
|
||||
return {
|
||||
width: newWidth,
|
||||
position: [newCenterX, initial.position[1], newCenterZ],
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n, sceneApi) => {
|
||||
const segment = resolveHostSegment(n, sceneApi)
|
||||
const y = segment ? getBodyMidY(n, segment) : FALLBACK_BODY_MID_Y
|
||||
return [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), y, 0]
|
||||
},
|
||||
// Chevron faces along the chimney's own ±X; the left arrow flips
|
||||
// 180°. No node.rotation here — the registered inner group is
|
||||
// already rotated by `node.rotation`, so chimney-local +X is the
|
||||
// chevron's natural direction.
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Depth arrow — symmetric on the +Z side. Only meaningful for square
|
||||
// bodies; round chimneys are circular (depth field is ignored by the
|
||||
// geometry builder, so a depth handle would just resize an invisible
|
||||
// field). The chimneys factory below omits this descriptor for round
|
||||
// bodies.
|
||||
function chimneyDepthHandle(): HandleDescriptor<ChimneyNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'z',
|
||||
anchor: 'center',
|
||||
min: MIN_BODY_DIM,
|
||||
currentValue: (n) => n.depth,
|
||||
apply: (_n, newValue) => ({ depth: newValue }),
|
||||
placement: {
|
||||
position: (n, sceneApi) => {
|
||||
const segment = resolveHostSegment(n, sceneApi)
|
||||
const y = segment ? getBodyMidY(n, segment) : FALLBACK_BODY_MID_Y
|
||||
return [0, y, n.depth / 2 + SIDE_HANDLE_OFFSET]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Height-above-ridge tracker. Dashed leader spans the chimney body's
|
||||
// visible extent — from the roof deck plane up to the body top — and
|
||||
// terminates in a draggable cube at the body top. Cap, flues, cricket
|
||||
// and bands sit ABOVE the body and are explicitly excluded from the
|
||||
// leader so the height affordance reads as "this is the body height",
|
||||
// not "this is the whole stack height". Dragging the cube vertically
|
||||
// adjusts `heightAboveRidge` 1:1.
|
||||
function chimneyHeightAboveRidgeHandle(): HandleDescriptor<ChimneyNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
shape: 'tracker',
|
||||
min: MIN_HEIGHT_ABOVE_RIDGE,
|
||||
currentValue: (n) => n.heightAboveRidge,
|
||||
apply: (initial, newValue) => ({
|
||||
heightAboveRidge: Math.max(MIN_HEIGHT_ABOVE_RIDGE, newValue),
|
||||
}),
|
||||
placement: {
|
||||
// Cube sits AT the body top (no offset) so the leader terminates
|
||||
// exactly at the body's top edge — visually the "ceiling" of the
|
||||
// body, before the cap reveal gap.
|
||||
position: (n, sceneApi) => {
|
||||
const segment = resolveHostSegment(n, sceneApi)
|
||||
const y = segment ? getBodyTopY(n, segment) : FALLBACK_BODY_MID_Y
|
||||
return [0, y, 0]
|
||||
},
|
||||
},
|
||||
// Leader bottom = deck plane (segment.wallHeight). The chimney body
|
||||
// geometry actually extends a touch below the deck so the bottom
|
||||
// doesn't show above the eave on low-slope roofs (`baseY = max(0,
|
||||
// wallHeight - 0.2)`), but the visible portion starts at the deck —
|
||||
// and starting the leader there is what reads as "body height" to
|
||||
// the user.
|
||||
trackerBaseY: (n, sceneApi) => {
|
||||
const segment = resolveHostSegment(n, sceneApi)
|
||||
return segment?.wallHeight ?? 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-chimney rotation gizmo at the +X/+Z corner of the body
|
||||
// footprint. The registered inner group already centers on the chimney
|
||||
// and applies its yaw, so the default rotation pivot (rideObject origin)
|
||||
// is correct — no `rotationCenter` override needed.
|
||||
function chimneyRotateHandle(): HandleDescriptor<ChimneyNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
position: (n, sceneApi) => {
|
||||
const segment = resolveHostSegment(n, sceneApi)
|
||||
const y = segment ? getBodyMidY(n, segment) : FALLBACK_BODY_MID_Y
|
||||
const isRound = n.bodyShape === 'round'
|
||||
const halfX = n.width / 2 + ROTATE_CORNER_OFFSET
|
||||
const halfZ = (isRound ? n.width : n.depth) / 2 + ROTATE_CORNER_OFFSET
|
||||
return [halfX, y, halfZ]
|
||||
},
|
||||
// The two-headed icon's natural bias points along +X; aim it
|
||||
// toward the corner (45° outward from the chimney's local frame).
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Flue-height chevron at the center of the cap top, pointing upward.
|
||||
// Drag adjusts `flueHeight` for ALL flues uniformly — the schema only
|
||||
// carries a single scalar. Placed at chimney center (X=Z=0) so the
|
||||
// handle stays valid regardless of `flueCount` / `flueSpacing`. Anchor
|
||||
// is 'min' so the flue base stays pinned to the cap and the top edge
|
||||
// follows the pointer.
|
||||
function chimneyFlueHeightHandle(): HandleDescriptor<ChimneyNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
portal: 'grandparent',
|
||||
min: MIN_FLUE_HEIGHT,
|
||||
max: MAX_FLUE_HEIGHT,
|
||||
currentValue: (n) => n.flueHeight,
|
||||
apply: (_n, newValue) => ({ flueHeight: newValue }),
|
||||
placement: {
|
||||
position: (n, sceneApi) => {
|
||||
const segment = resolveHostSegment(n, sceneApi)
|
||||
// Sit the chevron at the flue top so it visually attaches to
|
||||
// the thing being dragged. Fallback Y mirrors the body-top
|
||||
// fallback above.
|
||||
const baseY = segment ? getCapTopY(n, segment) : FALLBACK_BODY_MID_Y
|
||||
return [0, baseY + n.flueHeight, 0]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Cap-thickness chevron above the cap top, pointing upward. Anchor is
|
||||
// 'min' so the cap base stays at body-top + reveal and the top edge
|
||||
// follows the pointer.
|
||||
function chimneyCapThicknessHandle(): HandleDescriptor<ChimneyNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
portal: 'grandparent',
|
||||
min: MIN_CAP_THICKNESS,
|
||||
max: MAX_CAP_THICKNESS,
|
||||
currentValue: (n) => n.capThickness,
|
||||
apply: (_n, newValue) => ({ capThickness: newValue }),
|
||||
placement: {
|
||||
position: (n, sceneApi) => {
|
||||
const segment = resolveHostSegment(n, sceneApi)
|
||||
// Offset toward +Z (away from chimney center on the depth axis)
|
||||
// so the cap-thickness chevron doesn't overlap the flue-height
|
||||
// chevron at X=0,Z=0. Pick the cap edge minus a small margin so
|
||||
// it sits on top of the cap, not floating off to the side.
|
||||
const isRound = n.bodyShape === 'round'
|
||||
const halfZ = (isRound ? n.width : n.depth) / 2
|
||||
const z = halfZ * 0.35
|
||||
const y = segment ? getCapTopY(n, segment) : FALLBACK_BODY_MID_Y
|
||||
return [0, y, z]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Cap-overhang radial chevron on the +X edge of the cap. Outward 1:1
|
||||
// drag grows the overhang; the cap's half-extent is `width/2 + overhang`.
|
||||
function chimneyCapOverhangHandle(): HandleDescriptor<ChimneyNodeType> {
|
||||
return {
|
||||
kind: 'radial-resize',
|
||||
axis: 'x',
|
||||
portal: 'grandparent',
|
||||
min: MIN_CAP_OVERHANG,
|
||||
max: MAX_CAP_OVERHANG,
|
||||
currentValue: (n) => n.capOverhang,
|
||||
apply: (_n, newValue) => ({ capOverhang: newValue }),
|
||||
placement: {
|
||||
position: (n, sceneApi) => {
|
||||
const segment = resolveHostSegment(n, sceneApi)
|
||||
// Cap mid-height in the chimney-local frame so the chevron sits
|
||||
// on the cap edge, not above or below it.
|
||||
const capBaseY = segment ? getCapBaseY(n, segment) : FALLBACK_BODY_MID_Y
|
||||
const y = capBaseY + n.capThickness / 2
|
||||
return [n.width / 2 + n.capOverhang + SIDE_HANDLE_OFFSET, y, 0]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const chimneyHandles = (node: ChimneyNodeType): HandleDescriptor<ChimneyNodeType>[] => {
|
||||
const descriptors: HandleDescriptor<ChimneyNodeType>[] = [
|
||||
chimneyWidthHandle('right'),
|
||||
chimneyWidthHandle('left'),
|
||||
]
|
||||
if (node.bodyShape !== 'round') descriptors.push(chimneyDepthHandle())
|
||||
descriptors.push(chimneyHeightAboveRidgeHandle(), chimneyRotateHandle())
|
||||
// Conditional flue/cap handles are temporarily disabled — they fired
|
||||
// a "Color target has no corresponding fragment stage output" WebGPU
|
||||
// validation error that the original four handles don't trigger. The
|
||||
// descriptor shapes (linear-resize y / radial-resize x) match other
|
||||
// working handles in the codebase, so the cause is likely a TSL/MRT
|
||||
// pipeline interaction we haven't pinned down. Re-enable one at a
|
||||
// time after isolating the trigger; the factory + helpers are kept so
|
||||
// we can flip them back on without re-deriving the placement math.
|
||||
// if (node.cap && node.capShape !== 'none') {
|
||||
// descriptors.push(chimneyCapThicknessHandle(), chimneyCapOverhangHandle())
|
||||
// }
|
||||
// if (node.flueCount > 0) descriptors.push(chimneyFlueHeightHandle())
|
||||
return descriptors
|
||||
}
|
||||
|
||||
// Every fresh chimney starts as plain white (body + top). The paint
|
||||
// flow / material picker writes preset refs or full `MaterialSchema`
|
||||
// objects on top of this; until then both roles render `#ffffff`.
|
||||
@@ -73,6 +392,8 @@ export const chimneyDefinition: NodeDefinition<typeof ChimneyNode> = {
|
||||
},
|
||||
|
||||
parametrics: chimneyParametrics,
|
||||
handles: chimneyHandles,
|
||||
floorplan: buildChimneyFloorplan,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
ChimneyNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
import { flueXPositions } from './geometry'
|
||||
|
||||
/**
|
||||
* Floor-plan builder for a chimney. A chimney is a masonry stack hosted on
|
||||
* a roof segment. Seen from above it reads as its crown/cap footprint with
|
||||
* the body shaft nested inside (the cap overhangs the body) and the flue
|
||||
* openings poking out the top.
|
||||
*
|
||||
* Coordinate frame mirrors the 3D transform stack
|
||||
* (roof → roof-segment → chimney). The chimney's `position` is
|
||||
* segment-local (X = width axis, Z = depth axis; Y is ignored — the 3D
|
||||
* renderer anchors it to the slope). `rotation` is yaw. We compose with
|
||||
* the floor-plan's negated-rotation convention (see
|
||||
* `buildRoofSegmentFloorplan`). Unlike the gutter there's no eave/overhang
|
||||
* offset — a chimney sits at its own footprint, not on the drip edge.
|
||||
*/
|
||||
export function buildChimneyFloorplan(
|
||||
node: ChimneyNode,
|
||||
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 → chimney in plan coords. Each rotation is
|
||||
// negated so SVG's y-down CW matches Three.js' top-down CCW.
|
||||
const cosR = Math.cos(-roof.rotation)
|
||||
const sinR = Math.sin(-roof.rotation)
|
||||
const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR
|
||||
const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR
|
||||
|
||||
const segRot = -(roof.rotation + segment.rotation)
|
||||
const cosS = Math.cos(segRot)
|
||||
const sinS = Math.sin(segRot)
|
||||
const cx = segCx + node.position[0] * cosS - node.position[2] * sinS
|
||||
const cz = segCz + node.position[0] * sinS + node.position[2] * cosS
|
||||
|
||||
const rot = -(roof.rotation + segment.rotation + node.rotation)
|
||||
const cos = Math.cos(rot)
|
||||
const sin = Math.sin(rot)
|
||||
const toPlan = (lx: number, lz: number): FloorplanPoint => [
|
||||
cx + lx * cos - lz * sin,
|
||||
cz + lx * sin + lz * cos,
|
||||
]
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const isHovered = view?.hovered ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
// Masonry — warm stone grey, accent on select, light blue on hover.
|
||||
const baseInk = '#44403c'
|
||||
const stroke =
|
||||
showSelectedChrome && palette
|
||||
? palette.selectedStroke
|
||||
: isHovered && palette
|
||||
? palette.wallHoverStroke
|
||||
: baseInk
|
||||
const fill = showSelectedChrome ? '#fed7aa' : '#d6d3d1'
|
||||
const fillOpacity = showSelectedChrome ? 0.55 : 0.6
|
||||
const lineWidth = showSelectedChrome ? 0.03 : 0.022
|
||||
|
||||
const isRound = node.bodyShape === 'round'
|
||||
const halfW = Math.max(node.width, 0.05) / 2
|
||||
// Round bodies use `width` as the diameter and ignore `depth`.
|
||||
const halfD = isRound ? halfW : Math.max(node.depth, 0.05) / 2
|
||||
const hasCap = node.cap && node.capShape !== 'none'
|
||||
const overhang = hasCap ? Math.max(node.capOverhang, 0) : 0
|
||||
const capHalfW = halfW + overhang
|
||||
const capHalfD = halfD + overhang
|
||||
const showBodyInset = hasCap && overhang > 0.001
|
||||
|
||||
const children: FloorplanGeometry[] = []
|
||||
|
||||
if (isRound) {
|
||||
const c = toPlan(0, 0)
|
||||
// Transparent hit-target across the crown footprint.
|
||||
children.push({
|
||||
kind: 'circle',
|
||||
cx: c[0],
|
||||
cy: c[1],
|
||||
r: capHalfW,
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
})
|
||||
// Crown / outer body footprint, filled.
|
||||
children.push({
|
||||
kind: 'circle',
|
||||
cx: c[0],
|
||||
cy: c[1],
|
||||
r: capHalfW,
|
||||
fill,
|
||||
fillOpacity,
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
// Body shaft inside the cap overhang.
|
||||
if (showBodyInset) {
|
||||
children.push({
|
||||
kind: 'circle',
|
||||
cx: c[0],
|
||||
cy: c[1],
|
||||
r: halfW,
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: lineWidth * 0.8,
|
||||
strokeOpacity: 0.7,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const capCorners: FloorplanPoint[] = [
|
||||
toPlan(-capHalfW, -capHalfD),
|
||||
toPlan(capHalfW, -capHalfD),
|
||||
toPlan(capHalfW, capHalfD),
|
||||
toPlan(-capHalfW, capHalfD),
|
||||
]
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: capCorners,
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
})
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: capCorners,
|
||||
fill,
|
||||
fillOpacity,
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
if (showBodyInset) {
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: [
|
||||
toPlan(-halfW, -halfD),
|
||||
toPlan(halfW, -halfD),
|
||||
toPlan(halfW, halfD),
|
||||
toPlan(-halfW, halfD),
|
||||
],
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: lineWidth * 0.8,
|
||||
strokeOpacity: 0.7,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Flue openings poking out the crown — drawn along the chimney's local X
|
||||
// at z = 0, matching `flueXPositions` (the same layout the 3D pots use).
|
||||
// Round or square per `flueShape`. Hollow so they read as openings.
|
||||
const flueCount = Math.max(0, Math.min(4, node.flueCount))
|
||||
if (flueCount > 0) {
|
||||
const d = Math.max(0.02, node.flueDiameter)
|
||||
const r = d / 2
|
||||
const xs = flueXPositions(flueCount, node.width, d, node.flueSpacing)
|
||||
const flueStroke = showSelectedChrome && palette ? palette.selectedStroke : '#292524'
|
||||
for (const fx of xs) {
|
||||
if (node.flueShape === 'square') {
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: [
|
||||
toPlan(fx - r, -r),
|
||||
toPlan(fx + r, -r),
|
||||
toPlan(fx + r, r),
|
||||
toPlan(fx - r, r),
|
||||
],
|
||||
fill: 'none',
|
||||
stroke: flueStroke,
|
||||
strokeWidth: lineWidth * 0.8,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
} else {
|
||||
const c = toPlan(fx, 0)
|
||||
children.push({
|
||||
kind: 'circle',
|
||||
cx: c[0],
|
||||
cy: c[1],
|
||||
r,
|
||||
fill: 'none',
|
||||
stroke: flueStroke,
|
||||
strokeWidth: lineWidth * 0.8,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -138,7 +138,6 @@ function buildBodyGeometry(node: ChimneyNode, baseY: number, topY: number): THRE
|
||||
parts.push(buildSmoothCylinder(baseY + sh, topY, r, r))
|
||||
}
|
||||
const merged = mergeAndDispose(parts)
|
||||
applyNodeTransform(merged, node)
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -213,7 +212,6 @@ function buildCapGeometry(node: ChimneyNode, capBaseY: number): THREE.BufferGeom
|
||||
break
|
||||
}
|
||||
const merged = mergeAndDispose(parts)
|
||||
applyNodeTransform(merged, node)
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -450,7 +448,6 @@ function buildBandsGeometry(
|
||||
}
|
||||
if (parts.length === 0) return null
|
||||
const merged = mergeAndDispose(parts)
|
||||
applyNodeTransform(merged, node)
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -484,10 +481,14 @@ function buildBandsGeometry(
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function applyNodeTransform(geo: THREE.BufferGeometry, node: ChimneyNode) {
|
||||
if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation)
|
||||
geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0)
|
||||
}
|
||||
// Each builder returns geometry in chimney-local frame (chimney center
|
||||
// at X/Z origin, Y absolute in the host segment's frame). The renderer
|
||||
// applies `node.position` / `node.rotation` via a nested registered
|
||||
// group, which lets `NodeArrowHandles` read a chimney-local mesh frame
|
||||
// when placing the resize / rotation arrows. Kept as a no-op shim so
|
||||
// the existing call sites don't need to be touched if a future refactor
|
||||
// re-introduces per-builder baking.
|
||||
function applyNodeTransform(_geo: THREE.BufferGeometry, _node: ChimneyNode) {}
|
||||
|
||||
function buildBufferGeometry(positions: number[], uvs: number[]): THREE.BufferGeometry {
|
||||
const geo = new THREE.BufferGeometry()
|
||||
|
||||
@@ -270,8 +270,9 @@ function buildPanelCutters(node: ChimneyNode, topY: number): Brush[] {
|
||||
for (const f of faces) {
|
||||
const geo = new THREE.BoxGeometry(f.sizeX, h, f.sizeZ)
|
||||
geo.translate(f.cx, midY, f.cz)
|
||||
if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation)
|
||||
geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0)
|
||||
// Body geometry is in chimney-local frame (node.position/rotation are
|
||||
// applied by the renderer's nested ref'd group, not baked into the
|
||||
// buffer geometry), so cutters need to stay in chimney-local too.
|
||||
|
||||
const idx = geo.getIndex()?.count ?? 0
|
||||
geo.clearGroups()
|
||||
@@ -298,10 +299,9 @@ function buildCutter(
|
||||
? new THREE.CylinderGeometry(spec.sizeX / 2, spec.sizeX / 2, h, 24, 1, false)
|
||||
: new THREE.BoxGeometry(spec.sizeX, h, spec.sizeZ)
|
||||
geo.translate(spec.xCenter, midY, 0)
|
||||
// Match the same node-local transform that `geometry.ts:applyNodeTransform`
|
||||
// bakes into the body/cap/flue vertices.
|
||||
if (Math.abs(node.rotation) > 1e-4) geo.rotateY(node.rotation)
|
||||
geo.translate(node.position[0] ?? 0, 0, node.position[2] ?? 0)
|
||||
// Cutter stays in chimney-local frame to match the body/cap/flue
|
||||
// geometry (node.position/rotation are applied via the renderer's
|
||||
// nested ref'd group, not baked into the buffer geometry).
|
||||
|
||||
const idx = geo.getIndex()?.count ?? 0
|
||||
geo.clearGroups()
|
||||
|
||||
@@ -15,7 +15,7 @@ import { triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import ChimneyPreview from './preview'
|
||||
|
||||
const tmpMatrix = new THREE.Matrix4()
|
||||
|
||||
@@ -223,23 +223,26 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
|
||||
|
||||
if (!segment || !geo) return null
|
||||
|
||||
// The chimney's geometry bakes its baseY using segment.wallHeight inside
|
||||
// the builder, so the outer group only needs the segment-local X/Z
|
||||
// offset. Y stays at 0 here.
|
||||
|
||||
// Chimneys are mounted inside `RoofRenderer`'s `roof-elements` group,
|
||||
// which sits at the ROOF's origin — not inside the host segment's
|
||||
// transform. Apply the segment's own position/rotation here so a
|
||||
// chimney parented to segment N lands on segment N (and not on the
|
||||
// first segment) once the chimney's segment-local `node.position[0/2]`
|
||||
// is layered in by `geometry.ts`. Mirrors skylight's renderer.
|
||||
// transform. Apply the segment's pose on the outer group, then nest a
|
||||
// ref'd inner group at the chimney's segment-local position +
|
||||
// rotation so the registered Object3D's local frame is *chimney-local*
|
||||
// — that's what `NodeArrowHandles` reads to place its arrows.
|
||||
// Mirrors skylight's renderer; geometry comes from `geometry.ts` in
|
||||
// chimney-local frame (no transform baking) and lands in the right
|
||||
// world spot via the two-group stack below.
|
||||
return (
|
||||
<group
|
||||
position={segment.position}
|
||||
ref={ref}
|
||||
rotation-y={segment.rotation}
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
>
|
||||
<group
|
||||
position={[node.position[0] ?? 0, 0, node.position[2] ?? 0]}
|
||||
ref={ref}
|
||||
rotation-y={node.rotation ?? 0}
|
||||
>
|
||||
<mesh
|
||||
castShadow
|
||||
@@ -285,6 +288,7 @@ const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
|
||||
/>
|
||||
)}
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,12 +40,21 @@ export function trimChimneyBodyAgainstRoof(
|
||||
): THREE.BufferGeometry {
|
||||
const { shinSlab, wallBrush } = segBrushes
|
||||
|
||||
// Wrap the chimney body in a Brush. The body has `node.position` /
|
||||
// `node.rotation` baked into its vertices via `applyNodeTransform`
|
||||
// in `geometry.ts`, so it's already in segment-local space — the
|
||||
// same frame as the roof brushes from `getRoofSegmentBrushes`.
|
||||
// The body comes in chimney-local frame — `node.position` /
|
||||
// `node.rotation` are applied by the renderer's nested ref'd group
|
||||
// rather than baked into the geometry. Segment brushes from
|
||||
// `getRoofSegmentBrushes` are in segment-local frame, so we move the
|
||||
// chimney brush into segment-local space for the CSG pass, then strip
|
||||
// the same transform back off the result before returning, so the
|
||||
// mesh stays in chimney-local for the renderer to position via the
|
||||
// inner ref group.
|
||||
const indexed = mergeVertices(body, 1e-4)
|
||||
if (!indexed.getAttribute('normal')) indexed.computeVertexNormals()
|
||||
const hasRotation = Math.abs(node.rotation) > 1e-4
|
||||
const posX = node.position[0] ?? 0
|
||||
const posZ = node.position[2] ?? 0
|
||||
if (hasRotation) indexed.rotateY(node.rotation)
|
||||
indexed.translate(posX, 0, posZ)
|
||||
const indexCount = indexed.getIndex()?.count ?? 0
|
||||
indexed.clearGroups()
|
||||
if (indexCount > 0) indexed.addGroup(0, indexCount, 0)
|
||||
@@ -70,6 +79,11 @@ export function trimChimneyBodyAgainstRoof(
|
||||
const step2 = csgEvaluator.evaluate(step1, shinSlab, SUBTRACTION) as Brush
|
||||
|
||||
const out = csgGeometry(step2).clone()
|
||||
// Strip the same node transform we baked onto the input so the
|
||||
// returned geometry is back in chimney-local frame (the renderer's
|
||||
// inner ref'd group applies `node.position` / `node.rotation`).
|
||||
out.translate(-posX, 0, -posZ)
|
||||
if (hasRotation) out.rotateY(-node.rotation)
|
||||
const ic = out.getIndex()?.count ?? 0
|
||||
out.clearGroups()
|
||||
if (ic > 0) out.addGroup(0, ic, 0)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { chimneyDefinition } from './definition'
|
||||
import ChimneyPreview from './preview'
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ export function buildColumnFloorplan(
|
||||
point: [cx + cornerWorldX, cz + cornerWorldZ],
|
||||
angle: Math.atan2(radialZ, radialX),
|
||||
affordance: 'column-rotate',
|
||||
pivot: [cx, cz],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { buildCupolaGeometry } from '../geometry'
|
||||
import { CupolaNode } from '../schema'
|
||||
|
||||
function allFinite(geo: { getAttribute: (n: string) => { array: ArrayLike<number> } }): boolean {
|
||||
const arr = geo.getAttribute('position').array
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (!Number.isFinite(arr[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
describe('buildCupolaGeometry', () => {
|
||||
test('returns a non-empty BufferGeometry with matching attributes', () => {
|
||||
const geo = buildCupolaGeometry(CupolaNode.parse({}))
|
||||
const p = geo.getAttribute('position')
|
||||
expect(p.count).toBeGreaterThan(0)
|
||||
expect(geo.getAttribute('normal').count).toBe(p.count)
|
||||
expect(geo.getAttribute('uv').count).toBe(p.count)
|
||||
})
|
||||
|
||||
test('both roof styles build finite geometry', () => {
|
||||
for (const roofStyle of ['dome', 'pyramid'] as const) {
|
||||
const geo = buildCupolaGeometry(CupolaNode.parse({ roofStyle }))
|
||||
expect(geo.getAttribute('position').count).toBeGreaterThan(0)
|
||||
expect(allFinite(geo)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('finial adds vertices', () => {
|
||||
const withFinial = buildCupolaGeometry(CupolaNode.parse({ finial: true })).getAttribute(
|
||||
'position',
|
||||
).count
|
||||
const without = buildCupolaGeometry(CupolaNode.parse({ finial: false })).getAttribute(
|
||||
'position',
|
||||
).count
|
||||
expect(withFinial).toBeGreaterThan(without)
|
||||
})
|
||||
|
||||
test('extreme dimensions never go NaN', () => {
|
||||
const geo = buildCupolaGeometry(
|
||||
CupolaNode.parse({ width: 0.01, depth: 5, height: 0.01, roofStyle: 'pyramid' }),
|
||||
)
|
||||
expect(geo.getAttribute('position').count).toBeGreaterThan(0)
|
||||
expect(allFinite(geo)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
CupolaNode as CupolaNodeSchema,
|
||||
type CupolaNode as CupolaNodeType,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { surfacePaintCapability } from '../shared/surface-paint'
|
||||
import { buildCupolaFloorplan } from './floorplan'
|
||||
import { cupolaParametrics } from './parametrics'
|
||||
import { CupolaNode } from './schema'
|
||||
|
||||
const SIDE_HANDLE_OFFSET = 0.3
|
||||
const HEIGHT_HANDLE_OFFSET = 0.25
|
||||
// Snug to the cupola corner so the rotate icon stays close to the item.
|
||||
const ROTATE_CORNER_OFFSET = 0.12
|
||||
const MIN_DIM = 0.3
|
||||
const MIN_HEIGHT = 0.4
|
||||
|
||||
function getBodyMidY(n: CupolaNodeType): number {
|
||||
return Math.max(0.001, n.height) / 2
|
||||
}
|
||||
|
||||
// Width / depth grow symmetrically from the centre (cupolas are placed by
|
||||
// their centre on the ridge), so a single centred chevron per axis.
|
||||
function cupolaWidthHandle(): HandleDescriptor<CupolaNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: 'center',
|
||||
min: MIN_DIM,
|
||||
currentValue: (n) => n.width,
|
||||
apply: (_n, newValue) => ({ width: Math.max(MIN_DIM, newValue) }),
|
||||
placement: {
|
||||
position: (n) => [n.width / 2 + SIDE_HANDLE_OFFSET, getBodyMidY(n), 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function cupolaDepthHandle(): HandleDescriptor<CupolaNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'z',
|
||||
anchor: 'center',
|
||||
min: MIN_DIM,
|
||||
currentValue: (n) => n.depth,
|
||||
apply: (_n, newValue) => ({ depth: Math.max(MIN_DIM, newValue) }),
|
||||
placement: {
|
||||
position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function cupolaHeightHandle(): HandleDescriptor<CupolaNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_HEIGHT,
|
||||
currentValue: (n) => n.height,
|
||||
apply: (_n, newValue) => ({ height: Math.max(MIN_HEIGHT, newValue) }),
|
||||
placement: {
|
||||
position: (n) => [0, Math.max(n.height, MIN_HEIGHT) + HEIGHT_HANDLE_OFFSET, 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function cupolaRotateHandle(): HandleDescriptor<CupolaNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
position: (n) => [
|
||||
n.width / 2 + ROTATE_CORNER_OFFSET,
|
||||
getBodyMidY(n),
|
||||
n.depth / 2 + ROTATE_CORNER_OFFSET,
|
||||
],
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
// Guide ring centred on the cupola, sized to pass through the corner
|
||||
// icon so the icon rides the ring — matches solar-panel / skylight.
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) =>
|
||||
Math.hypot(n.width / 2 + ROTATE_CORNER_OFFSET, n.depth / 2 + ROTATE_CORNER_OFFSET),
|
||||
y: (n) => getBodyMidY(n),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// `portal: 'grandparent'` on every handle — see box-vent's note. The cupola
|
||||
// rides the roof→segment→node frame chain, so the handle rig must too, or
|
||||
// the handles (and rotate arc) render offset from the cupola.
|
||||
const cupolaHandles: HandleDescriptor<CupolaNodeType>[] = [
|
||||
cupolaWidthHandle(),
|
||||
cupolaDepthHandle(),
|
||||
cupolaHeightHandle(),
|
||||
cupolaRotateHandle(),
|
||||
].map((h): HandleDescriptor<CupolaNodeType> => ({ ...h, portal: 'grandparent' }))
|
||||
|
||||
/**
|
||||
* Cupola — a louvered roof lantern. Parented to a `roof-segment`; position
|
||||
* is segment-local; rotation rotates it around the segment's vertical axis
|
||||
* after the slope tilt is applied. Same composition as the box vent (custom
|
||||
* renderer, pure geometry builder, no system) — see box-vent's definition
|
||||
* for the rationale on why roof accessories need a custom renderer.
|
||||
*/
|
||||
export const cupolaDefinition: NodeDefinition<typeof CupolaNode> = {
|
||||
kind: 'cupola',
|
||||
schemaVersion: 1,
|
||||
schema: CupolaNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'roof',
|
||||
|
||||
defaults: () => {
|
||||
const stub = CupolaNodeSchema.parse({ id: 'cupola_default' as never, type: 'cupola' })
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
// Single painted surface — registry-driven paint dispatch (see chimney).
|
||||
paint: surfacePaintCapability,
|
||||
// Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the
|
||||
// slope — no `buildCut`, just the dirty cascade so the parent roof's
|
||||
// merged shell rebuilds when the cupola moves / resizes.
|
||||
roofAccessory: {},
|
||||
},
|
||||
|
||||
parametrics: cupolaParametrics,
|
||||
handles: cupolaHandles,
|
||||
floorplan: buildCupolaFloorplan,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
|
||||
preview: () => import('./preview'),
|
||||
tool: () => import('./tool'),
|
||||
affordanceTools: {
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place cupola on roof' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Cupola',
|
||||
description: 'Louvered roof lantern with a dome or pyramid cap and optional finial.',
|
||||
icon: { kind: 'url', src: '/icons/roof.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 122,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A louvered cupola (roof lantern) on a roof segment. Roof style: dome / pyramid, optional finial. Parametric width/depth/height.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
CupolaNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Floor-plan builder for a cupola — seen from above it reads as the
|
||||
* overhanging cornice/roof square with the louvered body square inside.
|
||||
* Coordinate frame mirrors the 3D transform stack (roof → roof-segment →
|
||||
* cupola), same as the box-vent builder.
|
||||
*/
|
||||
export function buildCupolaFloorplan(
|
||||
node: CupolaNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const segment = ctx.parent as RoofSegmentNode | null
|
||||
if (!segment || segment.type !== 'roof-segment') return null
|
||||
const roofId = segment.parentId as AnyNodeId | null
|
||||
const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined
|
||||
if (!roof || roof.type !== 'roof') return null
|
||||
|
||||
const cosR = Math.cos(-roof.rotation)
|
||||
const sinR = Math.sin(-roof.rotation)
|
||||
const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR
|
||||
const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR
|
||||
|
||||
const segRot = -(roof.rotation + segment.rotation)
|
||||
const cosS = Math.cos(segRot)
|
||||
const sinS = Math.sin(segRot)
|
||||
const cx = segCx + node.position[0] * cosS - node.position[2] * sinS
|
||||
const cz = segCz + node.position[0] * sinS + node.position[2] * cosS
|
||||
|
||||
const rot = -(roof.rotation + segment.rotation + node.rotation)
|
||||
const cos = Math.cos(rot)
|
||||
const sin = Math.sin(rot)
|
||||
const toPlan = (lx: number, lz: number): FloorplanPoint => [
|
||||
cx + lx * cos - lz * sin,
|
||||
cz + lx * sin + lz * cos,
|
||||
]
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const isHovered = view?.hovered ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
const baseInk = '#475569'
|
||||
const stroke =
|
||||
showSelectedChrome && palette
|
||||
? palette.selectedStroke
|
||||
: isHovered && palette
|
||||
? palette.wallHoverStroke
|
||||
: baseInk
|
||||
const fill = showSelectedChrome ? '#fed7aa' : '#dbe1e8'
|
||||
const fillOpacity = showSelectedChrome ? 0.55 : 0.7
|
||||
const lineWidth = showSelectedChrome ? 0.03 : 0.02
|
||||
|
||||
const hw = Math.max(node.width, 0.1) / 2
|
||||
const hd = Math.max(node.depth, 0.1) / 2
|
||||
const cornOvh = Math.min(hw, hd) * 0.22
|
||||
|
||||
const rect = (halfX: number, halfZ: number): FloorplanPoint[] => [
|
||||
toPlan(-halfX, -halfZ),
|
||||
toPlan(halfX, -halfZ),
|
||||
toPlan(halfX, halfZ),
|
||||
toPlan(-halfX, halfZ),
|
||||
]
|
||||
|
||||
const children: FloorplanGeometry[] = []
|
||||
|
||||
// Cornice / roof footprint — outer outline + hit target.
|
||||
const outer = rect(hw + cornOvh, hd + cornOvh)
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outer,
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
})
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outer,
|
||||
fill,
|
||||
fillOpacity,
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
// Louvered body footprint inside the cornice.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: rect(hw, hd),
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: lineWidth * 0.8,
|
||||
strokeOpacity: 0.7,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import type { CupolaNode } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
|
||||
/**
|
||||
* Pure builder for the cupola mesh — a small louvered roof lantern:
|
||||
*
|
||||
* ✦ ← finial (post + ball)
|
||||
* /\
|
||||
* / \ ← roof (dome or pyramid)
|
||||
* /____\
|
||||
* |‖‖‖‖‖‖| ← louvered body (angled slats on 4 faces)
|
||||
* |‖‖‖‖‖‖|
|
||||
* _|______|_
|
||||
* |__________| ← base plinth, seats on the roof
|
||||
*
|
||||
* Built bottom → top from primitive revolves / boxes. Every face is placed
|
||||
* through a winding-safe oriented quad/tri, so the whole thing is lit
|
||||
* correctly from outside without hand-traced winding.
|
||||
*
|
||||
* Pure: no React, no scene access, no store mutation. Safe for unit tests,
|
||||
* the placement preview, and the move-tool ghost.
|
||||
*/
|
||||
export function buildCupolaGeometry(node: CupolaNode): THREE.BufferGeometry {
|
||||
const w = Math.max(0.2, node.width)
|
||||
const d = Math.max(0.2, node.depth)
|
||||
const h = Math.max(0.3, node.height)
|
||||
const hw = w / 2
|
||||
const hd = d / 2
|
||||
|
||||
// Vertical budget.
|
||||
const baseH = h * 0.05
|
||||
const bodyH = h * 0.42
|
||||
const corniceH = h * 0.06
|
||||
const roofH = h * 0.32
|
||||
const baseTop = baseH
|
||||
const bodyTop = baseTop + bodyH
|
||||
const corniceTop = bodyTop + corniceH
|
||||
const apexY = corniceTop + roofH
|
||||
|
||||
// Footprints.
|
||||
const baseOvh = Math.min(hw, hd) * 0.12
|
||||
const cornOvh = Math.min(hw, hd) * 0.22
|
||||
|
||||
const p: number[] = []
|
||||
const n: number[] = []
|
||||
const uv: number[] = []
|
||||
|
||||
// Base plinth (slightly wider than the body) — closed box.
|
||||
addBox(p, n, uv, hw + baseOvh, hd + baseOvh, 0, baseTop)
|
||||
// Body — closed box; the louvers are applied as relief on its walls.
|
||||
addBox(p, n, uv, hw, hd, baseTop, bodyTop)
|
||||
// Cornice — overhanging slab the roof sits on.
|
||||
addBox(p, n, uv, hw + cornOvh, hd + cornOvh, bodyTop, corniceTop)
|
||||
|
||||
// Louvered slats on all four body faces.
|
||||
addLouvers(p, n, uv, hw, hd, baseTop, bodyTop)
|
||||
|
||||
// Roof.
|
||||
const rhw = hw + cornOvh
|
||||
const rhd = hd + cornOvh
|
||||
if (node.roofStyle === 'pyramid') {
|
||||
addPyramidRoof(p, n, uv, rhw, rhd, corniceTop, apexY)
|
||||
} else {
|
||||
addDomeRoof(p, n, uv, rhw, rhd, corniceTop, roofH)
|
||||
}
|
||||
|
||||
// Finial: a short post topped by a ball.
|
||||
if (node.finial) {
|
||||
const ballR = Math.min(w, d) * 0.05
|
||||
const postR = ballR * 0.45
|
||||
const postTop = apexY + roofH * 0.18
|
||||
addCylinder(p, n, uv, postR, apexY, postTop)
|
||||
addSphere(p, n, uv, ballR, postTop + ballR * 0.7)
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry()
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(p, 3))
|
||||
geo.setAttribute('normal', new THREE.Float32BufferAttribute(n, 3))
|
||||
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2))
|
||||
geo.computeBoundingSphere()
|
||||
return geo
|
||||
}
|
||||
|
||||
// ─── Body / cornice / base ───────────────────────────────────────────
|
||||
|
||||
// Closed axis-aligned box centred on the Y axis in X/Z, spanning y0..y1.
|
||||
function addBox(
|
||||
p: number[],
|
||||
n: number[],
|
||||
uv: number[],
|
||||
hw: number,
|
||||
hd: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): void {
|
||||
// Walls.
|
||||
pushQuad(p, n, uv, [hw, y0, -hd], [hw, y0, hd], [hw, y1, hd], [hw, y1, -hd], [1, 0, 0])
|
||||
pushQuad(p, n, uv, [-hw, y0, hd], [-hw, y0, -hd], [-hw, y1, -hd], [-hw, y1, hd], [-1, 0, 0])
|
||||
pushQuad(p, n, uv, [hw, y0, hd], [-hw, y0, hd], [-hw, y1, hd], [hw, y1, hd], [0, 0, 1])
|
||||
pushQuad(p, n, uv, [-hw, y0, -hd], [hw, y0, -hd], [hw, y1, -hd], [-hw, y1, -hd], [0, 0, -1])
|
||||
// Top + bottom.
|
||||
pushQuad(p, n, uv, [-hw, y1, -hd], [-hw, y1, hd], [hw, y1, hd], [hw, y1, -hd], [0, 1, 0])
|
||||
pushQuad(p, n, uv, [-hw, y0, -hd], [hw, y0, -hd], [hw, y0, hd], [-hw, y0, hd], [0, -1, 0])
|
||||
}
|
||||
|
||||
// ─── Louvers ─────────────────────────────────────────────────────────
|
||||
// Angled slats standing proud of each body face. Each slat is a double-
|
||||
// sided angled quad (emitted twice with opposite hints) so it reads from
|
||||
// both above and below. The solid body wall behind them blocks see-through.
|
||||
|
||||
const SLAT_COUNT = 5
|
||||
|
||||
function addLouvers(
|
||||
p: number[],
|
||||
n: number[],
|
||||
uv: number[],
|
||||
hw: number,
|
||||
hd: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): void {
|
||||
// Each face: a local→world mapper map(u, y, out) and the slat half-width.
|
||||
const faces: Array<{ map: (u: number, y: number, out: number) => number[]; halfU: number }> = [
|
||||
{ map: (u, y, out) => [u, y, hd + out], halfU: hw * 0.82 }, // +Z
|
||||
{ map: (u, y, out) => [u, y, -(hd + out)], halfU: hw * 0.82 }, // -Z
|
||||
{ map: (u, y, out) => [hw + out, y, u], halfU: hd * 0.82 }, // +X
|
||||
{ map: (u, y, out) => [-(hw + out), y, u], halfU: hd * 0.82 }, // -X
|
||||
]
|
||||
|
||||
const margin = (y1 - y0) * 0.12
|
||||
const top = y1 - margin
|
||||
const bottom = y0 + margin
|
||||
const span = top - bottom
|
||||
const drop = span / (SLAT_COUNT + 1)
|
||||
const proj = drop * 0.9
|
||||
|
||||
for (const { map, halfU } of faces) {
|
||||
const outDir = sub(map(0, 0, 1), map(0, 0, 0))
|
||||
const upOut: number[] = [outDir[0]!, 1, outDir[2]!]
|
||||
const downOut: number[] = [outDir[0]!, -1, outDir[2]!]
|
||||
for (let k = 1; k <= SLAT_COUNT; k++) {
|
||||
const yTop = bottom + (span * k) / SLAT_COUNT
|
||||
const a = map(-halfU, yTop, 0)
|
||||
const b = map(halfU, yTop, 0)
|
||||
const c = map(halfU, yTop - drop, proj)
|
||||
const dd = map(-halfU, yTop - drop, proj)
|
||||
// Top + underside of the slat.
|
||||
pushQuad(p, n, uv, a, b, c, dd, upOut)
|
||||
pushQuad(p, n, uv, a, b, c, dd, downOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Roofs ───────────────────────────────────────────────────────────
|
||||
|
||||
function addPyramidRoof(
|
||||
p: number[],
|
||||
n: number[],
|
||||
uv: number[],
|
||||
hw: number,
|
||||
hd: number,
|
||||
y0: number,
|
||||
apexY: number,
|
||||
): void {
|
||||
const apex = [0, apexY, 0]
|
||||
const corners = [
|
||||
[hw, y0, -hd],
|
||||
[hw, y0, hd],
|
||||
[-hw, y0, hd],
|
||||
[-hw, y0, -hd],
|
||||
]
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const a = corners[i]!
|
||||
const b = corners[(i + 1) % 4]!
|
||||
// Outward + up hint from the edge midpoint.
|
||||
const mx = (a[0]! + b[0]!) / 2
|
||||
const mz = (a[2]! + b[2]!) / 2
|
||||
pushTri(p, n, uv, a, b, apex, [mx, Math.max(hw, hd), mz])
|
||||
}
|
||||
}
|
||||
|
||||
function addDomeRoof(
|
||||
p: number[],
|
||||
n: number[],
|
||||
uv: number[],
|
||||
rx: number,
|
||||
rz: number,
|
||||
y0: number,
|
||||
domeH: number,
|
||||
): void {
|
||||
const lng = 20
|
||||
const lat = 6
|
||||
let prev = ringAt(rx, rz, y0, lng)
|
||||
for (let i = 1; i <= lat; i++) {
|
||||
const phi = (Math.PI / 2) * (i / lat)
|
||||
const rf = Math.cos(phi)
|
||||
const y = y0 + domeH * Math.sin(phi)
|
||||
const ring = ringAt(rx * rf, rz * rf, y, lng)
|
||||
addBand(p, n, uv, prev, ring, lng, (a, _b, c) => {
|
||||
const x = (a[0]! + c[0]!) / 2
|
||||
const yy = (a[1]! + c[1]!) / 2 - y0
|
||||
const z = (a[2]! + c[2]!) / 2
|
||||
return [x, yy, z]
|
||||
})
|
||||
prev = ring
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Finial primitives ───────────────────────────────────────────────
|
||||
|
||||
function addCylinder(
|
||||
p: number[],
|
||||
n: number[],
|
||||
uv: number[],
|
||||
r: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): void {
|
||||
const lng = 12
|
||||
const bottom = ringAt(r, r, y0, lng)
|
||||
const top = ringAt(r, r, y1, lng)
|
||||
addBand(p, n, uv, bottom, top, lng, (a, _b, c) => {
|
||||
const x = (a[0]! + c[0]!) / 2
|
||||
const z = (a[2]! + c[2]!) / 2
|
||||
return [x, 0, z]
|
||||
})
|
||||
}
|
||||
|
||||
function addSphere(p: number[], n: number[], uv: number[], r: number, cy: number): void {
|
||||
const lng = 14
|
||||
const lat = 8
|
||||
let prev = ringAt(0, 0, cy - r, lng)
|
||||
for (let i = 1; i <= lat; i++) {
|
||||
const theta = Math.PI * (i / lat) - Math.PI / 2
|
||||
const ry = r * Math.sin(theta)
|
||||
const rr = r * Math.cos(theta)
|
||||
const ring = ringAt(rr, rr, cy + ry, lng)
|
||||
addBand(p, n, uv, prev, ring, lng, (a, _b, c) => {
|
||||
const x = (a[0]! + c[0]!) / 2
|
||||
const yy = (a[1]! + c[1]!) / 2 - cy
|
||||
const z = (a[2]! + c[2]!) / 2
|
||||
return [x, yy, z]
|
||||
})
|
||||
prev = ring
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Revolve plumbing ────────────────────────────────────────────────
|
||||
|
||||
function ringAt(ax: number, az: number, y: number, lng: number): number[][] {
|
||||
const row: number[][] = []
|
||||
for (let j = 0; j <= lng; j++) {
|
||||
const t = (Math.PI * 2 * j) / lng
|
||||
row.push([ax * Math.cos(t), y, az * Math.sin(t)])
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function addBand(
|
||||
p: number[],
|
||||
n: number[],
|
||||
uv: number[],
|
||||
rA: number[][],
|
||||
rB: number[][],
|
||||
lng: number,
|
||||
hintFn: (a: number[], b: number[], c: number[], d: number[]) => number[],
|
||||
): void {
|
||||
for (let j = 0; j < lng; j++) {
|
||||
const a = rA[j]!
|
||||
const b = rA[j + 1]!
|
||||
const c = rB[j + 1]!
|
||||
const d = rB[j]!
|
||||
pushQuad(p, n, uv, a, b, c, d, hintFn(a, b, c, d))
|
||||
}
|
||||
}
|
||||
|
||||
function sub(a: number[], b: number[]): number[] {
|
||||
return [a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!]
|
||||
}
|
||||
|
||||
// ─── Winding-safe primitives ─────────────────────────────────────────
|
||||
|
||||
function pushQuad(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
a: number[],
|
||||
b: number[],
|
||||
c: number[],
|
||||
d: number[],
|
||||
hint: number[],
|
||||
) {
|
||||
let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!)
|
||||
let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!)
|
||||
let nz = (c[0]! - a[0]!) * (b[1]! - a[1]!) - (c[1]! - a[1]!) * (b[0]! - a[0]!)
|
||||
const flip = nx * hint[0]! + ny * hint[1]! + nz * hint[2]! < 0
|
||||
if (flip) {
|
||||
nx = -nx
|
||||
ny = -ny
|
||||
nz = -nz
|
||||
}
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1
|
||||
nx /= len
|
||||
ny /= len
|
||||
nz /= len
|
||||
|
||||
const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!)
|
||||
const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!)
|
||||
|
||||
if (flip) {
|
||||
positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!)
|
||||
uvs.push(0, 0, u, 0, u, v)
|
||||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!)
|
||||
uvs.push(0, 0, u, v, 0, v)
|
||||
} else {
|
||||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
|
||||
uvs.push(0, 0, u, v, u, 0)
|
||||
positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!)
|
||||
uvs.push(0, 0, 0, v, u, v)
|
||||
}
|
||||
for (let i = 0; i < 6; i++) normals.push(nx, ny, nz)
|
||||
}
|
||||
|
||||
function pushTri(
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
a: number[],
|
||||
b: number[],
|
||||
c: number[],
|
||||
hint: number[],
|
||||
) {
|
||||
let nx = (b[1]! - a[1]!) * (c[2]! - a[2]!) - (b[2]! - a[2]!) * (c[1]! - a[1]!)
|
||||
let ny = (b[2]! - a[2]!) * (c[0]! - a[0]!) - (b[0]! - a[0]!) * (c[2]! - a[2]!)
|
||||
let nz = (b[0]! - a[0]!) * (c[1]! - a[1]!) - (b[1]! - a[1]!) * (c[0]! - a[0]!)
|
||||
const flip = nx * hint[0]! + ny * hint[1]! + nz * hint[2]! < 0
|
||||
if (flip) {
|
||||
nx = -nx
|
||||
ny = -ny
|
||||
nz = -nz
|
||||
}
|
||||
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1
|
||||
nx /= len
|
||||
ny /= len
|
||||
nz /= len
|
||||
|
||||
if (flip) {
|
||||
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
|
||||
} else {
|
||||
positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!)
|
||||
}
|
||||
uvs.push(0, 0, 1, 0, 0, 1)
|
||||
for (let i = 0; i < 3; i++) normals.push(nx, ny, nz)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { cupolaDefinition } from './definition'
|
||||
export { buildCupolaGeometry } from './geometry'
|
||||
export { CupolaNode } from './schema'
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CupolaNode,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import CupolaPreview from './preview'
|
||||
|
||||
/**
|
||||
* Cupola move tool. Mirrors the box-vent move flow: the original mesh hides
|
||||
* during the drag, a ghost tracks the cursor with the correct slope tilt +
|
||||
* segment yaw, and the click updates the node's position + parent segment in
|
||||
* one undoable step (reparenting between segments when needed). Cancel
|
||||
* restores the original transform, or deletes a freshly-cloned cupola.
|
||||
*/
|
||||
export default function MoveCupolaTool({ node }: { node: CupolaNode }) {
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
|
||||
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
|
||||
const [previewYaw, setPreviewYaw] = useState(0)
|
||||
|
||||
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 cupolaObj = sceneRegistry.nodes.get(node.id)
|
||||
if (cupolaObj) cupolaObj.visible = false
|
||||
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
const v = new THREE.Vector3(wx, wy, wz)
|
||||
buildingObj.worldToLocal(v)
|
||||
return [v.x, v.y, v.z]
|
||||
}
|
||||
|
||||
let lastSnap: [number, number] | null = null
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnap = [sx, sz]
|
||||
}
|
||||
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
if (!hit) return
|
||||
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
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 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: [hit.localX, hit.localY, hit.localZ],
|
||||
rotation: original.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 (!(previewPos && previewSurfaceQuat)) return null
|
||||
|
||||
return (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<CupolaPreview node={node} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
CupolaNode as CupolaSchema,
|
||||
getActiveRoofHeight,
|
||||
type RoofSegmentNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
SegmentedControl,
|
||||
SliderControl,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import type { CupolaNode } from './schema'
|
||||
|
||||
/**
|
||||
* Inspector panel for a placed cupola. Roof style + finial + dimensions
|
||||
* plus Move / Duplicate / Delete wired into the same ghost-preview drag
|
||||
* flow the placement tool uses. Mirrors the box-vent panel.
|
||||
*/
|
||||
export default function CupolaPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const storeNode = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as CupolaNode | undefined) : undefined,
|
||||
)
|
||||
const overrides = useLiveNodeOverrides((s) =>
|
||||
selectedId ? (s.get(selectedId as AnyNodeId) as Partial<CupolaNode> | undefined) : undefined,
|
||||
)
|
||||
const node: CupolaNode | undefined =
|
||||
storeNode && overrides ? ({ ...storeNode, ...overrides } as CupolaNode) : storeNode
|
||||
|
||||
const segment = useScene((s) =>
|
||||
node?.roofSegmentId
|
||||
? (s.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const previewProp = useCallback(
|
||||
(updates: Partial<CupolaNode>) => {
|
||||
if (!selectedId) return
|
||||
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates)
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const commitProp = useCallback(
|
||||
(updates: Partial<CupolaNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleUpdate = commitProp
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (node?.roofSegmentId) {
|
||||
setSelection({ selectedIds: [node.roofSegmentId as AnyNode['id']] })
|
||||
}
|
||||
}, [node?.roofSegmentId, setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
triggerSFX('sfx:item-pick')
|
||||
setMovingNode(node as never)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node) return
|
||||
triggerSFX('sfx:item-pick')
|
||||
const parentId = node.roofSegmentId as AnyNodeId | undefined
|
||||
if (!parentId) return
|
||||
|
||||
const state = useScene.getState()
|
||||
const meta =
|
||||
typeof node.metadata === 'object' && node.metadata !== null
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const cloneInput = {
|
||||
...node,
|
||||
id: undefined,
|
||||
metadata: { ...meta, isNew: true },
|
||||
} as Record<string, unknown>
|
||||
const cloned = CupolaSchema.parse(cloneInput) as CupolaNode
|
||||
|
||||
state.createNode(cloned, parentId)
|
||||
state.dirtyNodes.add(parentId)
|
||||
setMovingNode(cloned as never)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
triggerSFX('sfx:item-delete')
|
||||
const segmentId = node.roofSegmentId
|
||||
if (segmentId) {
|
||||
const state = useScene.getState()
|
||||
const seg = state.nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
|
||||
if (seg) {
|
||||
state.updateNode(segmentId as AnyNode['id'], {
|
||||
children: (seg.children ?? []).filter((id) => id !== selectedId),
|
||||
})
|
||||
}
|
||||
}
|
||||
deleteNode(selectedId as AnyNodeId)
|
||||
if (segmentId) {
|
||||
useScene.getState().dirtyNodes.add(segmentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||||
} else {
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [selectedId, node, deleteNode, setSelection])
|
||||
|
||||
if (!(node && node.type === 'cupola' && selectedId)) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/roof.png"
|
||||
onBack={node.roofSegmentId ? handleBack : undefined}
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Cupola'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Style">
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ roofStyle: v as CupolaNode['roofStyle'] })}
|
||||
options={[
|
||||
{ label: 'Dome', value: 'dome' },
|
||||
{ label: 'Pyramid', value: 'pyramid' },
|
||||
]}
|
||||
value={node.roofStyle ?? 'dome'}
|
||||
/>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ finial: v === 'on' })}
|
||||
options={[
|
||||
{ label: 'Finial', value: 'on' },
|
||||
{ label: 'No Finial', value: 'off' },
|
||||
]}
|
||||
value={(node.finial ?? true) ? 'on' : 'off'}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
max={2}
|
||||
min={0.3}
|
||||
onChange={(v) => previewProp({ width: v })}
|
||||
onCommit={(v) => handleUpdate({ width: v })}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
max={2}
|
||||
min={0.3}
|
||||
onChange={(v) => previewProp({ depth: v })}
|
||||
onCommit={(v) => handleUpdate({ depth: v })}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.depth * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={2.5}
|
||||
min={0.4}
|
||||
onChange={(v) => previewProp({ height: v })}
|
||||
onCommit={(v) => handleUpdate({ height: v })}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label="X"
|
||||
max={Math.round(((segment?.width ?? 10) / 2) * 100) / 100}
|
||||
min={-Math.round(((segment?.width ?? 10) / 2) * 100) / 100}
|
||||
onChange={(v) =>
|
||||
previewProp({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0] })
|
||||
}
|
||||
onCommit={(v) =>
|
||||
handleUpdate({ position: [v, node.position[1] ?? 0, node.position[2] ?? 0] })
|
||||
}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.position[0] ?? 0) * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Y"
|
||||
max={Math.max(
|
||||
(segment?.wallHeight ?? 3) + (segment ? getActiveRoofHeight(segment) : 3) + 2,
|
||||
(node.position[1] ?? 0) + 0.1,
|
||||
)}
|
||||
min={Math.min(0, (node.position[1] ?? 0) - 0.5)}
|
||||
onChange={(v) =>
|
||||
previewProp({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0] })
|
||||
}
|
||||
onCommit={(v) =>
|
||||
handleUpdate({ position: [node.position[0] ?? 0, v, node.position[2] ?? 0] })
|
||||
}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.position[1] ?? 0) * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Z"
|
||||
max={Math.round(((segment?.depth ?? 10) / 2) * 100) / 100}
|
||||
min={-Math.round(((segment?.depth ?? 10) / 2) * 100) / 100}
|
||||
onChange={(v) =>
|
||||
previewProp({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v] })
|
||||
}
|
||||
onCommit={(v) =>
|
||||
handleUpdate({ position: [node.position[0] ?? 0, node.position[1] ?? 0, v] })
|
||||
}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.position[2] ?? 0) * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rotation"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(deg) => previewProp({ rotation: (deg * Math.PI) / 180 })}
|
||||
onCommit={(deg) => handleUpdate({ rotation: (deg * Math.PI) / 180 })}
|
||||
precision={0}
|
||||
restoreOnCommit={false}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round(((node.rotation ?? 0) * 180) / Math.PI)}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { CupolaNode } from './schema'
|
||||
|
||||
/**
|
||||
* Inspector descriptor for the cupola. Move / Duplicate use the kind-owned
|
||||
* ghost-preview flow (see `./move-tool.tsx`), so the panel hosts those
|
||||
* actions itself — same pattern as box-vent.
|
||||
*/
|
||||
export const cupolaParametrics: ParametricDescriptor<CupolaNode> = {
|
||||
customPanel: () => import('./panel'),
|
||||
groups: [
|
||||
{
|
||||
label: 'Style',
|
||||
fields: [
|
||||
{
|
||||
key: 'roofStyle',
|
||||
kind: 'enum',
|
||||
options: ['dome', 'pyramid'],
|
||||
display: 'segmented',
|
||||
},
|
||||
{ key: 'finial', kind: 'boolean' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Dimensions',
|
||||
fields: [
|
||||
{ key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 },
|
||||
{ key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 2, step: 0.05 },
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 0.4, max: 2.5, step: 0.05 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { buildCupolaGeometry } from './geometry'
|
||||
import type { CupolaNode } from './schema'
|
||||
|
||||
/**
|
||||
* Translucent ghost of a cupola, used by the placement tool's cursor and
|
||||
* the move-tool preview. Builds geometry through the shared pure builder so
|
||||
* the ghost stays in lockstep with the committed cupola. Raycast disabled
|
||||
* so the preview doesn't intercept the cursor ray feeding the tool.
|
||||
*/
|
||||
const CupolaPreview = ({ node }: { node: CupolaNode }) => {
|
||||
const geometry = useMemo(
|
||||
() => buildCupolaGeometry(node),
|
||||
[node.width, node.depth, node.height, node.roofStyle, node.finial],
|
||||
)
|
||||
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
emissive: 0x6c_a3_ff,
|
||||
emissiveIntensity: 0.18,
|
||||
roughness: 0.7,
|
||||
metalness: 0.1,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose()
|
||||
edgesGeometry.dispose()
|
||||
material.dispose()
|
||||
},
|
||||
[geometry, edgesGeometry, material],
|
||||
)
|
||||
|
||||
return (
|
||||
<group rotation-y={node.rotation ?? 0}>
|
||||
<mesh
|
||||
geometry={geometry}
|
||||
material={material}
|
||||
raycast={() => {
|
||||
/* disabled — see component-level note */
|
||||
}}
|
||||
/>
|
||||
<lineSegments geometry={edgesGeometry} renderOrder={1000}>
|
||||
<lineBasicMaterial color={0x6c_a3_ff} depthTest={false} opacity={0.95} transparent />
|
||||
</lineSegments>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default CupolaPreview
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CupolaNode,
|
||||
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 { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { buildCupolaGeometry } from './geometry'
|
||||
|
||||
const defaultMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 0.7,
|
||||
metalness: 0.2,
|
||||
})
|
||||
|
||||
/**
|
||||
* Cupola renderer. Same transform stack as the box vent — the cupola is
|
||||
* parented to a roof-segment, so this reads the segment directly and
|
||||
* reproduces the segment-local transform (segment position → rotation →
|
||||
* cupola position → slope tilt → cupola yaw → mesh). No animation.
|
||||
*/
|
||||
const CupolaRenderer = ({ node: storeNode }: { node: CupolaNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
useRegistry(storeNode.id, 'cupola', ref)
|
||||
const handlers = useNodeEvents(storeNode, 'cupola')
|
||||
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<CupolaNode> | undefined,
|
||||
)
|
||||
const node: CupolaNode = overrides ? ({ ...storeNode, ...overrides } as CupolaNode) : storeNode
|
||||
|
||||
const segment = useScene((state) =>
|
||||
node.roofSegmentId
|
||||
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const geometry = useMemo(
|
||||
() => buildCupolaGeometry(node),
|
||||
[node.width, node.depth, node.height, node.roofStyle, node.finial],
|
||||
)
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
const surfaceQuat = useMemo(() => {
|
||||
if (!segment) return new THREE.Quaternion()
|
||||
const normal = getAnalyticalNormal(node.position[0] ?? 0, node.position[2] ?? 0, segment)
|
||||
return surfaceQuatFromNormal(normal, new THREE.Quaternion())
|
||||
}, [segment, node.position[0], node.position[2]])
|
||||
|
||||
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])
|
||||
|
||||
const yAxis = useMemo(() => new THREE.Vector3(0, 1, 0), [])
|
||||
const composedQuat = useMemo(() => {
|
||||
const yawQuat = new THREE.Quaternion().setFromAxisAngle(yAxis, node.rotation ?? 0)
|
||||
return new THREE.Quaternion().copy(surfaceQuat).multiply(yawQuat)
|
||||
}, [surfaceQuat, node.rotation, yAxis])
|
||||
|
||||
if (!segment) return null
|
||||
|
||||
const segPos = segment.position ?? [0, 0, 0]
|
||||
const segRotY = segment.rotation ?? 0
|
||||
|
||||
return (
|
||||
<group position={segPos} rotation-y={segRotY}>
|
||||
<group
|
||||
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
|
||||
quaternion={composedQuat}
|
||||
ref={ref}
|
||||
visible={node.visible}
|
||||
>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={geometry}
|
||||
material={material}
|
||||
name="cupola-surface"
|
||||
receiveShadow
|
||||
{...handlers}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default CupolaRenderer
|
||||
@@ -0,0 +1,3 @@
|
||||
// Schema lives in core (referenced by the AnyNode union). Re-export so
|
||||
// every cupola-related import stays inside @pascal-app/nodes/cupola.
|
||||
export { CupolaNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
CupolaNode,
|
||||
emitter,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
sceneRegistry,
|
||||
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 * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
|
||||
import { cupolaDefinition } from './definition'
|
||||
import CupolaPreview from './preview'
|
||||
|
||||
const worldPoint = new THREE.Vector3()
|
||||
|
||||
/**
|
||||
* Cupola placement tool. Mounts when the palette activates the cupola kind;
|
||||
* listens for `roof:*` events; on click commits a new `CupolaNode` parented
|
||||
* to the targeted segment with segment-local coordinates. Mirrors box-vent.
|
||||
*/
|
||||
const CupolaTool = () => {
|
||||
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
const [previewPos, setPreviewPos] = useState<[number, number, number] | null>(null)
|
||||
const [previewSurfaceQuat, setPreviewSurfaceQuat] = useState<THREE.Quaternion | null>(null)
|
||||
const [previewYaw, setPreviewYaw] = useState(0)
|
||||
const lastSnapRef = useRef<[number, number] | null>(null)
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
CupolaNode.parse({
|
||||
...cupolaDefinition.defaults(),
|
||||
name: 'Cupola',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) return
|
||||
|
||||
const worldToBuildingLocal = (wx: number, wy: number, wz: number): [number, number, number] => {
|
||||
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
|
||||
if (!buildingObj) return [wx, wy, wz]
|
||||
worldPoint.set(wx, wy, wz)
|
||||
buildingObj.worldToLocal(worldPoint)
|
||||
return [worldPoint.x, worldPoint.y, worldPoint.z]
|
||||
}
|
||||
|
||||
const updatePreview = (event: RoofEvent) => {
|
||||
const wx = event.position[0]
|
||||
const wy = event.position[1]
|
||||
const wz = event.position[2]
|
||||
|
||||
const sx = Math.round(wx * 20) / 20
|
||||
const sz = Math.round(wz * 20) / 20
|
||||
const prev = lastSnapRef.current
|
||||
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapRef.current = [sx, sz]
|
||||
}
|
||||
|
||||
const hit = resolveRoofSegmentHit(event.node as RoofNode, wx, wy, wz)
|
||||
if (!hit) return
|
||||
|
||||
const normal = getAnalyticalNormal(hit.localX, hit.localZ, hit.segment)
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
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 cupola = CupolaNode.parse({
|
||||
...cupolaDefinition.defaults(),
|
||||
name: 'Cupola',
|
||||
roofSegmentId: hit.segment.id,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
rotation: 0,
|
||||
})
|
||||
state.createNode(cupola, hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [cupola.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 || !previewPos || !previewSurfaceQuat) return null
|
||||
|
||||
return (
|
||||
<group position={previewPos}>
|
||||
<group rotation-y={previewYaw}>
|
||||
<group quaternion={previewSurfaceQuat}>
|
||||
<CupolaPreview node={previewNode} />
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default CupolaTool
|
||||
@@ -57,11 +57,16 @@ export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeo
|
||||
const isFlat = dormer.roofType === 'flat' || roofH === 0
|
||||
|
||||
// Body box: foot at y = -skirt, top at y = wallH.
|
||||
const body = new THREE.BoxGeometry(w, wallH + skirt, d)
|
||||
body.translate(0, (wallH - skirt) / 2, 0)
|
||||
const bIdx = body.getIndex()?.count ?? 0
|
||||
// BoxGeometry is indexed; ExtrudeGeometry below is not. mergeGeometries
|
||||
// refuses mixed input ("index attribute exists among all geometries,
|
||||
// or in none of them") — drop the body's index so both inputs match.
|
||||
const indexedBody = new THREE.BoxGeometry(w, wallH + skirt, d)
|
||||
indexedBody.translate(0, (wallH - skirt) / 2, 0)
|
||||
const body = indexedBody.toNonIndexed()
|
||||
indexedBody.dispose()
|
||||
const bVtx = body.getAttribute('position').count
|
||||
body.clearGroups()
|
||||
body.addGroup(0, bIdx, 0)
|
||||
body.addGroup(0, bVtx, 0)
|
||||
|
||||
if (isFlat) {
|
||||
if (!body.getAttribute('normal')) body.computeVertexNormals()
|
||||
@@ -78,9 +83,9 @@ export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeo
|
||||
const roof = new THREE.ExtrudeGeometry(roofShape, { depth: d, bevelEnabled: false })
|
||||
roof.translate(0, wallH, -d / 2)
|
||||
|
||||
const rIdx = roof.getIndex()?.count ?? 0
|
||||
const rVtx = roof.getAttribute('position').count
|
||||
roof.clearGroups()
|
||||
roof.addGroup(0, rIdx, 3)
|
||||
roof.addGroup(0, rVtx, 3)
|
||||
|
||||
const merged = mergeGeometries([body, roof], true) ?? body
|
||||
body.dispose()
|
||||
@@ -187,11 +192,20 @@ function createDormerWindowCutGeometry(
|
||||
}
|
||||
|
||||
/**
|
||||
* Which faces of a dormer are exposed (not fully buried in the host
|
||||
* roof). "front" = mesh-local +Z, "back" = mesh-local −Z (after the
|
||||
* +π/2 yaw bake for non-shed roofs). A face is exposed when the
|
||||
* dormer's total wall top exceeds the host roof surface at that face's
|
||||
* Z position.
|
||||
* Which gable faces of a dormer have a *fully visible window opening*
|
||||
* (not clipped by the host roof slope). "front" = mesh-local +Z,
|
||||
* "back" = mesh-local −Z (after the +π/2 yaw bake for non-shed roofs).
|
||||
*
|
||||
* The criterion is window-bottom-above-slope, not wall-top-above-slope:
|
||||
* the dormer wall extends well below the window into the skirt that's
|
||||
* buried inside the roof, so checking just "does any wall poke above
|
||||
* the slope" is far too lenient — a dormer whose eave barely clears
|
||||
* the roof would pass even though the entire window (which sits inside
|
||||
* the skirt, well below the eave) is buried. Switching to the window
|
||||
* bottom collapses both the CSG window-cut decision (which calls into
|
||||
* this function in `generateDormerGeometry`) and the live render gate
|
||||
* (window-assembly.tsx) onto the right line: the window only renders
|
||||
* where it's actually visible from outside.
|
||||
*/
|
||||
export function getDormerExposedFaces(
|
||||
dormer: DormerNode,
|
||||
@@ -206,7 +220,18 @@ export function getDormerExposedFaces(
|
||||
const frontZ = dormerZ + halfDepth * Math.cos(rot)
|
||||
const backZ = dormerZ - halfDepth * Math.cos(rot)
|
||||
|
||||
const dormerWallTop = dormerY + dormer.height
|
||||
// Window bottom in dormer-local Y. Mirrors `getDormerSkirtWindowDims`
|
||||
// so both functions read the same window position. The window sits
|
||||
// in the skirt below the eave (dormer-local Y=0), so `centerY` is
|
||||
// typically negative; subtracting half the window height lands us at
|
||||
// the bottom edge.
|
||||
const skirtH = dormerSkirtHeight(dormer)
|
||||
const winH = Math.max(0, dormer.windowHeight ?? 0)
|
||||
const winOffsetY = dormer.windowOffsetY ?? 0
|
||||
const windowCenterDormerY = -(skirtH / 2) + winOffsetY
|
||||
const windowBottomDormerY = windowCenterDormerY - winH / 2
|
||||
// Lift into segment-local Y: dormer-local Y=0 sits at `dormer.position[1]`.
|
||||
const windowBottomSegY = dormerY + windowBottomDormerY
|
||||
|
||||
const hostWh = hostSegment.wallHeight ?? 0.5
|
||||
const hostRh = getActiveRoofHeight(hostSegment)
|
||||
@@ -224,15 +249,15 @@ export function getDormerExposedFaces(
|
||||
return hostWh + hostRh * (1 - t)
|
||||
}
|
||||
|
||||
// A face is "exposed" only if the dormer's wall actually pokes above
|
||||
// the host roof there by a meaningful amount — otherwise the wall is
|
||||
// CSG-buried and any window we render at that face will hover with
|
||||
// no wall behind it. A 5cm threshold suppresses the borderline-cases
|
||||
// where the wall top is essentially level with the slope.
|
||||
// A face is "exposed" only if the *window bottom* clears the host
|
||||
// slope at that face's Z by a meaningful amount — borderline cases
|
||||
// (slope grazing the window bottom) suppress the window so we don't
|
||||
// render a partially-clipped frame poking out of the roof. 5cm
|
||||
// matches the threshold the prior wall-top check used.
|
||||
const minPokeOut = 0.05
|
||||
return {
|
||||
front: dormerWallTop - roofHeightAtZ(frontZ) > minPokeOut,
|
||||
back: dormerWallTop - roofHeightAtZ(backZ) > minPokeOut,
|
||||
front: windowBottomSegY - roofHeightAtZ(frontZ) > minPokeOut,
|
||||
back: windowBottomSegY - roofHeightAtZ(backZ) > minPokeOut,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,379 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
DormerNode as DormerNodeSchema,
|
||||
type DormerNode as DormerNodeType,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
type RoofSegmentNode as RoofSegmentNodeType,
|
||||
type SceneApi,
|
||||
} from '@pascal-app/core'
|
||||
import { buildDormerRoofCut } from './csg-geometry'
|
||||
import { buildDormerRoofCut, getDormerExposedFaces } from './csg-geometry'
|
||||
import { buildDormerFloorplan } from './floorplan'
|
||||
import { dormerPaint } from './paint'
|
||||
import { dormerParametrics } from './parametrics'
|
||||
import { DormerNode } from './schema'
|
||||
|
||||
const SIDE_HANDLE_OFFSET = 0.25
|
||||
const HEIGHT_HANDLE_OFFSET = 0.25
|
||||
const ROTATE_CORNER_OFFSET = 0.35
|
||||
const ROTATE_RING_OFFSET = 0.08
|
||||
// Schema/parametrics ranges — keep these aligned with `dormerParametrics`
|
||||
// so the in-world drag and the inspector slider clamp identically.
|
||||
const MIN_DIM = 0.5
|
||||
const MIN_HEIGHT = 0
|
||||
const MIN_ROOF_HEIGHT = 0
|
||||
const MAX_ROOF_HEIGHT = 2
|
||||
const MIN_SKIRT = 0.2
|
||||
const MAX_SKIRT = 6
|
||||
// Window-handle constants. The window opening is parametric geometry
|
||||
// on the dormer's +Z gable face; chevrons sit just outside its rim
|
||||
// with a small forward Z offset so they pop in front of the wall plane
|
||||
// instead of z-fighting with the frame bars.
|
||||
const WINDOW_SIDE_HANDLE_OFFSET = 0.15
|
||||
const WINDOW_HEIGHT_HANDLE_OFFSET = 0.15
|
||||
const WINDOW_FACE_Z_OFFSET = 0.05
|
||||
// Lower clamp for window dims matches the geometry's internal clamp
|
||||
// in `getDormerSkirtWindowDims` (0.1m). Upper clamps depend on the
|
||||
// dormer dimensions and are resolved per-handle via the function form
|
||||
// of `max`.
|
||||
const MIN_WINDOW_DIM = 0.1
|
||||
// Clamp used for handle Y placement so side chevrons stay reachable on
|
||||
// dormers whose wall is flat (`height ≈ 0`). The dormer body is
|
||||
// `height + roofHeight` tall; if that collapses too, the side arrows
|
||||
// would bury into the deck — this floor keeps them visible.
|
||||
const MIN_BODY_DISPLAY = 0.3
|
||||
|
||||
// Mid-Y of the dormer body in dormer-local frame. Y=0 is the eave
|
||||
// (where wall meets skirt); body extends up to `height + roofHeight`.
|
||||
// Side chevrons sit at the body midpoint so they read as "this is the
|
||||
// dormer's footprint" rather than floating at the apex or the eave.
|
||||
function getBodyMidY(n: DormerNodeType): number {
|
||||
return Math.max(n.height + n.roofHeight, MIN_BODY_DISPLAY) / 2
|
||||
}
|
||||
|
||||
// Width arrow on the +X (right) or -X (left) side. Asymmetric resize:
|
||||
// dragging one arrow grows the dormer outward from its own edge while
|
||||
// the opposite edge stays world-fixed in segment frame. The dormer's
|
||||
// registered ref frame is dormer-local (renderer applies position +
|
||||
// rotation on the registered group), so placements are in dormer-local
|
||||
// coords — no per-arrow rotation/translation compensation here.
|
||||
//
|
||||
// `apply` recomputes `position` so the anchored edge stays at the same
|
||||
// segment-local point even when the dormer is Y-rotated: project the
|
||||
// dormer's local +X onto segment frame via (cos r, -sin r), find the
|
||||
// anchored edge's segment-local XZ from the pre-drag node, then place
|
||||
// the new center half a new-width away from that anchor in the same
|
||||
// direction. Mirrors chimney + roof-segment width handle math.
|
||||
function dormerWidthHandle(side: 'left' | 'right'): HandleDescriptor<DormerNodeType> {
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
// 'min' = -X edge anchored (right arrow grows the +X edge outward).
|
||||
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
// Default 'parent' portal (no `'grandparent'` escape). Arrows
|
||||
// portal into the host roof segment's registered mesh, which lives
|
||||
// inside the roof renderer's `<group name="segments-wrapper">`.
|
||||
// That wrapper is `visible={false}` by default; `RoofEditSystem`
|
||||
// imperatively flips it to `visible={true}` whenever any accessory
|
||||
// hosted on a segment of this roof is selected, so the portaled
|
||||
// arrows become visible during selection without us reaching for
|
||||
// `portal: 'grandparent'` — which trips the same "Color target has
|
||||
// no corresponding fragment stage output" WebGPU pipeline error
|
||||
// chimney already documents (likely an MRT interaction with the
|
||||
// window-assembly's transparent glazing meshes).
|
||||
min: MIN_DIM,
|
||||
currentValue: (n) => n.width,
|
||||
apply: (initial, newWidth) => {
|
||||
const rotY = initial.rotation ?? 0
|
||||
const armX = Math.cos(rotY)
|
||||
const armZ = -Math.sin(rotY)
|
||||
const anchorX = initial.position[0] - sign * (initial.width / 2) * armX
|
||||
const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ
|
||||
const newCenterX = anchorX + sign * (newWidth / 2) * armX
|
||||
const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ
|
||||
return {
|
||||
width: newWidth,
|
||||
position: [newCenterX, initial.position[1], newCenterZ],
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n) => [sign * (n.width / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), 0],
|
||||
// Flip the left chevron so it points outward toward -X. The
|
||||
// generic LinearArrow only auto-orients for axis 'z'; +X / -X
|
||||
// facing is up to the descriptor.
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Depth arrow on the +Z side. Symmetric (anchor 'center') to match
|
||||
// chimney's known-working handle count — splitting depth into asymmetric
|
||||
// front + back chevrons puts the dormer over the per-node MRT/TSL
|
||||
// budget that chimney already documents (see `chimneyHandles` factory).
|
||||
// Re-evaluate the split once that pipeline issue is pinned down.
|
||||
function dormerDepthHandle(): HandleDescriptor<DormerNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'z',
|
||||
anchor: 'center',
|
||||
min: MIN_DIM,
|
||||
currentValue: (n) => n.depth,
|
||||
apply: (_n, newValue) => ({ depth: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Wall-height tracker — dashed vertical leader from the eave (y=0) up
|
||||
// to a draggable cube at the wall top (y=height), centred on the
|
||||
// footprint. Reads as "the dormer wall is THIS tall" without claiming
|
||||
// the roof apex. Same `linear-resize axis='y'` pipeline as every other
|
||||
// height handle; `shape: 'tracker'` only swaps the visual.
|
||||
function dormerWallHeightHandle(): HandleDescriptor<DormerNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
shape: 'tracker',
|
||||
min: MIN_HEIGHT,
|
||||
currentValue: (n) => n.height,
|
||||
apply: (_n, newValue) => ({ height: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, Math.max(n.height, 0.001), 0],
|
||||
},
|
||||
trackerBaseY: () => 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Wall-skirt chevron — sits BELOW the eave, at the bottom of the
|
||||
// hung-wall skirt that extends down into the host roof. Drag pulls the
|
||||
// skirt's bottom edge further down (or up) to grow / shrink
|
||||
// `wallSkirtHeight`. Anchor 'max' keeps the eave (y=0) fixed; the
|
||||
// linear-resize factor (-1 for anchor 'max') flips the drag sign so
|
||||
// dragging the chevron downward increases the value 1:1.
|
||||
//
|
||||
// Plain arrow (not tracker) because tracker only renders an upward
|
||||
// leader; the dashed line would point the wrong way for a downward
|
||||
// span. The auto-orient logic in `ArrowHandle` flips the chevron to
|
||||
// point -Y when placement.y < 0, so the arrow visibly points down.
|
||||
function dormerWallSkirtHandle(): HandleDescriptor<DormerNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'max',
|
||||
min: MIN_SKIRT,
|
||||
max: MAX_SKIRT,
|
||||
currentValue: (n) => n.wallSkirtHeight,
|
||||
apply: (_n, newValue) => ({ wallSkirtHeight: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, -(n.wallSkirtHeight + HEIGHT_HANDLE_OFFSET), 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Roof-height chevron at the dormer's peak. Drag adjusts `roofHeight`
|
||||
// directly — unlike roof-segment there's no pitch back-solve because
|
||||
// dormer stores roof height as a literal scalar, not a pitch angle.
|
||||
// Placed slightly above the apex (height + roofHeight) so the chevron
|
||||
// visually attaches to the ridge.
|
||||
function dormerRoofHeightHandle(): HandleDescriptor<DormerNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'min',
|
||||
min: MIN_ROOF_HEIGHT,
|
||||
max: MAX_ROOF_HEIGHT,
|
||||
currentValue: (n) => n.roofHeight,
|
||||
apply: (_n, newValue) => ({ roofHeight: newValue }),
|
||||
placement: {
|
||||
position: (n) => [0, n.height + n.roofHeight + HEIGHT_HANDLE_OFFSET, 0],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-dormer rotation gizmo at the +X / +Z corner of the footprint,
|
||||
// guide ring traces the corner-diagonal radius on hover / drag.
|
||||
// Dormer-local frame is the registered group (renderer applies
|
||||
// node.position + node.rotation there), so the default rotation pivot
|
||||
// (rideObject origin = dormer center) is correct — no
|
||||
// `rotationCenter` override needed.
|
||||
function dormerRotateHandle(): HandleDescriptor<DormerNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
// Negate the cursor delta to match three.js Y-rotation handedness
|
||||
// (cursor atan2 ticks opposite-handed from `rotation-y`).
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const halfX = n.width / 2 + ROTATE_CORNER_OFFSET
|
||||
const halfZ = n.depth / 2 + ROTATE_CORNER_OFFSET
|
||||
return [halfX, getBodyMidY(n), halfZ]
|
||||
},
|
||||
// The two-headed icon's natural bias points along +X; aim it at
|
||||
// the corner (45° outward from the dormer's local frame).
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: (n) => Math.hypot(n.width / 2, n.depth / 2) + ROTATE_RING_OFFSET,
|
||||
y: (n) => getBodyMidY(n),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Window-center Y in dormer-local frame. The schema stores
|
||||
// `windowOffsetY` as the bottom-relative offset of the window center
|
||||
// from the bottom of the skirt; the geometry then maps it to
|
||||
// `centerY = -(skirtH / 2) + offsetY`. We mirror that here so handle
|
||||
// placements line up with what the inspector + window-assembly use.
|
||||
function getWindowCenterY(n: DormerNodeType): number {
|
||||
return -(n.wallSkirtHeight / 2) + n.windowOffsetY
|
||||
}
|
||||
|
||||
// Sign of the dormer-local Z direction where the visible window face
|
||||
// sits. The dormer renders the window on both +Z (front) and -Z (back)
|
||||
// gable faces, but only whichever face actually pokes above the host
|
||||
// roof slope is exposed — `getDormerExposedFaces` is the source of
|
||||
// truth there. The in-world handles need to attach to that exposed
|
||||
// face so the user is editing the window they can see; as the dormer
|
||||
// drags across the ridge, the exposed face flips and the chevrons
|
||||
// follow.
|
||||
//
|
||||
// Preference order when both faces are exposed (e.g. a tall gable that
|
||||
// pokes above the roof on both ends): keep handles on +Z so the
|
||||
// affordance stays put visually instead of flipping when the slope
|
||||
// math grazes the threshold from the other side. When neither face is
|
||||
// exposed (degenerate — wall buried on both sides), fall back to +Z so
|
||||
// the placement still produces a valid vector; the chevrons are just
|
||||
// not useful there.
|
||||
function getExposedFaceZSign(n: DormerNodeType, sceneApi: SceneApi): 1 | -1 {
|
||||
if (!n.roofSegmentId) return 1
|
||||
const segment = sceneApi.get<RoofSegmentNodeType>(n.roofSegmentId as AnyNodeId)
|
||||
if (!segment) return 1
|
||||
const exposed = getDormerExposedFaces(n, segment)
|
||||
if (exposed.front) return 1
|
||||
if (exposed.back) return -1
|
||||
return 1
|
||||
}
|
||||
|
||||
// Window-width chevron on the +X (right) or -X (left) edge of the
|
||||
// opening. Asymmetric: dragging one arrow grows the window outward
|
||||
// from its own edge while the opposite edge stays put. The framework
|
||||
// only knows about the scalar `windowWidth`; we re-emit `windowOffsetX`
|
||||
// in `apply` so the anchored edge stays at the same X in dormer-local.
|
||||
// Placement sits on the dormer's +Z gable face, where the window opens.
|
||||
function dormerWindowWidthHandle(side: 'left' | 'right'): HandleDescriptor<DormerNodeType> {
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: side === 'right' ? 'min' : 'max',
|
||||
min: MIN_WINDOW_DIM,
|
||||
// Cap at the dormer's window field — keep a 0.1m gap on each side
|
||||
// to match the geometry's interior clamp (`maxW = width - 0.1`).
|
||||
max: (n) => Math.max(MIN_WINDOW_DIM, n.width - 0.1),
|
||||
currentValue: (n) => n.windowWidth,
|
||||
apply: (initial, newWidth) => {
|
||||
// Anchored edge stays fixed: anchor X = initial.windowOffsetX -
|
||||
// sign * initial.windowWidth/2. New center = anchor + sign *
|
||||
// newWidth/2 → new windowOffsetX.
|
||||
const anchorX = initial.windowOffsetX - sign * (initial.windowWidth / 2)
|
||||
const newOffsetX = anchorX + sign * (newWidth / 2)
|
||||
return {
|
||||
windowWidth: newWidth,
|
||||
windowOffsetX: newOffsetX,
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n, sceneApi) => {
|
||||
const faceSign = getExposedFaceZSign(n, sceneApi)
|
||||
return [
|
||||
n.windowOffsetX + sign * (n.windowWidth / 2 + WINDOW_SIDE_HANDLE_OFFSET),
|
||||
getWindowCenterY(n),
|
||||
faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET),
|
||||
]
|
||||
},
|
||||
// Left chevron points -X; right points +X. LinearArrow doesn't
|
||||
// auto-orient axis 'x' — descriptor handles the flip.
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Window-height chevron on the +Y (top) or -Y (bottom) edge of the
|
||||
// opening. Same asymmetric pattern as the width handle, projected onto
|
||||
// the Y axis. The schema stores the window's vertical position as
|
||||
// `windowOffsetY` (distance from the BOTTOM of the skirt to the window
|
||||
// CENTER), not as a centerY in dormer-local — so `apply` translates
|
||||
// back through that mapping when it re-emits the offset.
|
||||
function dormerWindowHeightHandle(side: 'top' | 'bottom'): HandleDescriptor<DormerNodeType> {
|
||||
const sign = side === 'top' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
// 'min' = bottom edge anchored (top arrow grows the top edge up).
|
||||
// 'max' = top edge anchored (bottom arrow drops the bottom edge).
|
||||
anchor: side === 'top' ? 'min' : 'max',
|
||||
min: MIN_WINDOW_DIM,
|
||||
// Cap at the skirt with a 0.1m interior margin — matches
|
||||
// `maxH = skirtH - 0.1` from `getDormerSkirtWindowDims`.
|
||||
max: (n) => Math.max(MIN_WINDOW_DIM, n.wallSkirtHeight - 0.1),
|
||||
currentValue: (n) => n.windowHeight,
|
||||
apply: (initial, newHeight) => {
|
||||
// Compute the anchored edge in dormer-local Y, derive the new
|
||||
// centerY, then map back to schema-form `windowOffsetY`.
|
||||
const initialCenterY = -(initial.wallSkirtHeight / 2) + initial.windowOffsetY
|
||||
const anchorY = initialCenterY - sign * (initial.windowHeight / 2)
|
||||
const newCenterY = anchorY + sign * (newHeight / 2)
|
||||
const newOffsetY = newCenterY + initial.wallSkirtHeight / 2
|
||||
return {
|
||||
windowHeight: newHeight,
|
||||
windowOffsetY: newOffsetY,
|
||||
}
|
||||
},
|
||||
placement: {
|
||||
position: (n, sceneApi) => {
|
||||
const faceSign = getExposedFaceZSign(n, sceneApi)
|
||||
return [
|
||||
n.windowOffsetX,
|
||||
getWindowCenterY(n) + sign * (n.windowHeight / 2 + WINDOW_HEIGHT_HANDLE_OFFSET),
|
||||
faceSign * (n.depth / 2 + WINDOW_FACE_Z_OFFSET),
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const dormerHandles: HandleDescriptor<DormerNodeType>[] = [
|
||||
dormerWidthHandle('right'),
|
||||
dormerWidthHandle('left'),
|
||||
dormerDepthHandle(),
|
||||
dormerWallHeightHandle(),
|
||||
dormerRotateHandle(),
|
||||
dormerWindowWidthHandle('right'),
|
||||
dormerWindowWidthHandle('left'),
|
||||
dormerWindowHeightHandle('top'),
|
||||
dormerWindowHeightHandle('bottom'),
|
||||
// The wall-skirt (downward chevron), roof-height (peak chevron), and
|
||||
// the asymmetric front/back depth split stay out for now. Re-adding
|
||||
// any of them previously fired the "Color target has no
|
||||
// corresponding fragment stage output" WebGPU pipeline error chimney
|
||||
// already documented for its flue / cap-thickness / cap-overhang
|
||||
// extras — only reproducible while `portal: 'grandparent'` was set,
|
||||
// which we no longer rely on (RoofEditSystem reveals the wrapper
|
||||
// instead). The shapes themselves are valid; if the count budget
|
||||
// turns out to also be sensitive without grandparent portal, drop
|
||||
// the window handles first since the inspector covers them too.
|
||||
// dormerWallSkirtHandle(),
|
||||
// dormerRoofHeightHandle(),
|
||||
]
|
||||
|
||||
/**
|
||||
* Dormer — a small house-shaped protrusion sitting on top of a roof
|
||||
* segment. The window opening is inlined into the dormer's schema
|
||||
@@ -69,6 +434,8 @@ export const dormerDefinition: NodeDefinition<typeof DormerNode> = {
|
||||
},
|
||||
|
||||
parametrics: dormerParametrics,
|
||||
handles: dormerHandles,
|
||||
floorplan: buildDormerFloorplan,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
DormerNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Floor-plan builder for a dormer — a small house-shaped structure that
|
||||
* projects from a roof slope, with its own little roof and a window on the
|
||||
* front face. Seen from above it reads as a `width × depth` footprint plus
|
||||
* its roof's ridge/hip linework, and a line marking the window on the
|
||||
* down-slope (+Z) front face.
|
||||
*
|
||||
* Coordinate frame mirrors the 3D transform stack
|
||||
* (roof → roof-segment → dormer), same as the chimney builder. The
|
||||
* dormer's `position` is segment-local: X = width axis (along the eave),
|
||||
* Z = depth axis (projecting down-slope; +Z is the front/window face).
|
||||
* `rotation` is yaw. Rotations are negated for the floor plan's y-down
|
||||
* convention (see `buildRoofSegmentFloorplan`).
|
||||
*
|
||||
* Per-type roof linework follows the dormer's own roof geometry
|
||||
* (`buildDormerCutShape` in csg-geometry.ts): gable ridge runs along Z,
|
||||
* shed slopes high-at-back (−Z) to low-at-front (+Z), hip ridges along the
|
||||
* longer axis. Gambrel falls back to gable; dutch/mansard to hip — the
|
||||
* same fallbacks the 3D cut uses.
|
||||
*/
|
||||
export function buildDormerFloorplan(
|
||||
node: DormerNode,
|
||||
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 → dormer in plan coords. Each rotation negated
|
||||
// so SVG's y-down CW matches Three.js' top-down CCW.
|
||||
const cosR = Math.cos(-roof.rotation)
|
||||
const sinR = Math.sin(-roof.rotation)
|
||||
const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR
|
||||
const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR
|
||||
|
||||
const segRot = -(roof.rotation + segment.rotation)
|
||||
const cosS = Math.cos(segRot)
|
||||
const sinS = Math.sin(segRot)
|
||||
const cx = segCx + node.position[0] * cosS - node.position[2] * sinS
|
||||
const cz = segCz + node.position[0] * sinS + node.position[2] * cosS
|
||||
|
||||
const rot = -(roof.rotation + segment.rotation + node.rotation)
|
||||
const cos = Math.cos(rot)
|
||||
const sin = Math.sin(rot)
|
||||
const toPlan = (lx: number, lz: number): FloorplanPoint => [
|
||||
cx + lx * cos - lz * sin,
|
||||
cz + lx * sin + lz * cos,
|
||||
]
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const isHovered = view?.hovered ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
// Reads as a small structure on the roof — neutral grey, accent on
|
||||
// select, light blue on hover.
|
||||
const baseInk = '#52525b'
|
||||
const stroke =
|
||||
showSelectedChrome && palette
|
||||
? palette.selectedStroke
|
||||
: isHovered && palette
|
||||
? palette.wallHoverStroke
|
||||
: baseInk
|
||||
const fill = showSelectedChrome ? '#fed7aa' : '#e4e4e7'
|
||||
const fillOpacity = showSelectedChrome ? 0.55 : 0.6
|
||||
const lineWidth = showSelectedChrome ? 0.03 : 0.022
|
||||
const ridgeWidth = showSelectedChrome ? 0.04 : 0.03
|
||||
|
||||
const hw = Math.max(node.width, 0.1) / 2
|
||||
const hd = Math.max(node.depth, 0.1) / 2
|
||||
|
||||
const corners: FloorplanPoint[] = [
|
||||
toPlan(-hw, -hd),
|
||||
toPlan(hw, -hd),
|
||||
toPlan(hw, hd),
|
||||
toPlan(-hw, hd),
|
||||
]
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
// Transparent hit-target across the footprint.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: corners,
|
||||
fill: stroke,
|
||||
fillOpacity: 0,
|
||||
stroke: 'none',
|
||||
strokeWidth: 0,
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
// Body footprint, filled.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: corners,
|
||||
fill,
|
||||
fillOpacity,
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinejoin: 'miter',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]
|
||||
|
||||
const line = (a: readonly [number, number], b: readonly [number, number], w: number) => {
|
||||
const pa = toPlan(a[0], a[1])
|
||||
const pb = toPlan(b[0], b[1])
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: pa[0],
|
||||
y1: pa[1],
|
||||
x2: pb[0],
|
||||
y2: pb[1],
|
||||
stroke,
|
||||
strokeWidth: w,
|
||||
strokeLinecap: 'round',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
// Roof linework per dormer roof type (skipped for flat / zero-height).
|
||||
const type = node.roofType
|
||||
if (node.roofHeight > 0 && type !== 'flat') {
|
||||
if (type === 'shed') {
|
||||
// Slopes from the high back (−Z) down to the low front (+Z); show a
|
||||
// downslope arrow pointing toward the front.
|
||||
const tail = toPlan(0, -hd * 0.55)
|
||||
const head = toPlan(0, hd * 0.55)
|
||||
const dx = head[0] - tail[0]
|
||||
const dy = head[1] - tail[1]
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
const ux = dx / len
|
||||
const uy = dy / len
|
||||
const headLen = Math.min(0.25, len * 0.4)
|
||||
const wing = headLen * 0.6
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: tail[0],
|
||||
y1: tail[1],
|
||||
x2: head[0],
|
||||
y2: head[1],
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinecap: 'round',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
children.push({
|
||||
kind: 'polyline',
|
||||
points: [
|
||||
[head[0] - headLen * ux - wing * uy, head[1] - headLen * uy + wing * ux],
|
||||
[head[0], head[1]],
|
||||
[head[0] - headLen * ux + wing * uy, head[1] - headLen * uy - wing * ux],
|
||||
],
|
||||
stroke,
|
||||
strokeWidth: lineWidth,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
} else if (type === 'hip' || type === 'dutch' || type === 'mansard') {
|
||||
// Ridge along the longer axis + four hips from the corners (a single
|
||||
// apex when square). Mirrors the dormer cut's pyramid/hip.
|
||||
if (Math.abs(hw - hd) < 0.01) {
|
||||
line([-hw, -hd], [0, 0], lineWidth)
|
||||
line([hw, -hd], [0, 0], lineWidth)
|
||||
line([hw, hd], [0, 0], lineWidth)
|
||||
line([-hw, hd], [0, 0], lineWidth)
|
||||
} else if (hd >= hw) {
|
||||
const rl = hd - hw // ridge along Z
|
||||
line([0, -rl], [0, rl], ridgeWidth)
|
||||
line([-hw, hd], [0, rl], lineWidth)
|
||||
line([hw, hd], [0, rl], lineWidth)
|
||||
line([-hw, -hd], [0, -rl], lineWidth)
|
||||
line([hw, -hd], [0, -rl], lineWidth)
|
||||
} else {
|
||||
const rl = hw - hd // ridge along X
|
||||
line([-rl, 0], [rl, 0], ridgeWidth)
|
||||
line([-hw, -hd], [-rl, 0], lineWidth)
|
||||
line([-hw, hd], [-rl, 0], lineWidth)
|
||||
line([hw, -hd], [rl, 0], lineWidth)
|
||||
line([hw, hd], [rl, 0], lineWidth)
|
||||
}
|
||||
} else {
|
||||
// Gable (and gambrel fallback): ridge runs front-to-back along Z.
|
||||
line([0, -hd], [0, hd], ridgeWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// Window on the +Z (front) face — a line just inside the front edge,
|
||||
// spanning the window width centred at its X offset. Marks the glazing
|
||||
// and which way the dormer faces.
|
||||
const ww = node.windowWidth ?? 0
|
||||
if (ww > 0.01) {
|
||||
const halfWin = Math.min(ww, node.width) / 2
|
||||
const center = Math.max(-hw + halfWin, Math.min(hw - halfWin, node.windowOffsetX ?? 0))
|
||||
const inset = Math.min(hd * 0.2, 0.08)
|
||||
line([center - halfWin, hd - inset], [center + halfWin, hd - inset], lineWidth)
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -152,15 +152,23 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
||||
// dormer-mesh-local with `dormer.position` + `dormer.rotation`
|
||||
// already accounted for by `segToMesh`, so we layer them as group
|
||||
// transforms here too.
|
||||
//
|
||||
// The registered ref sits on the inner group that applies the
|
||||
// dormer's own position + rotation so the registered Object3D's
|
||||
// local frame is *dormer-local* — that's what `NodeArrowHandles`
|
||||
// reads to place its chevrons. Mirrors chimney's structure.
|
||||
return (
|
||||
<group
|
||||
position={segment.position}
|
||||
ref={ref}
|
||||
rotation-y={segment.rotation ?? 0}
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
>
|
||||
<group
|
||||
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
|
||||
ref={ref}
|
||||
rotation-y={node.rotation ?? 0}
|
||||
>
|
||||
<group position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}>
|
||||
<group rotation-y={node.rotation ?? 0} {...handlers}>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={geometry}
|
||||
@@ -176,7 +184,6 @@ const DormerRenderer = ({ node: storeNode }: { node: DormerNode }) => {
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { resolveRoofSegmentHit } from '../roof/segment-hit'
|
||||
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
|
||||
import { DORMER_PLACEMENT_ROTATION_STEP, DORMER_PLACEMENT_SNAP_M } from './geometry'
|
||||
|
||||
const tmpMatrix = new THREE.Matrix4()
|
||||
|
||||
@@ -113,6 +113,19 @@ const DormerWindowAssembly = ({
|
||||
node.position[0],
|
||||
node.position[1],
|
||||
node.position[2],
|
||||
// Rotation flips which dormer-local face projects to which Z in
|
||||
// segment frame, so dragging the dormer across the ridge with a
|
||||
// non-zero yaw needs to recompute exposure to know which gable
|
||||
// is now poking above the slope.
|
||||
node.rotation,
|
||||
// Window position + height feed `getDormerExposedFaces` now that
|
||||
// it's gating on window-bottom-above-slope (not wall-top-above-
|
||||
// slope) — dragging the window down via inspector or the new
|
||||
// window-height/offset handles must re-evaluate which gable
|
||||
// still has a fully-visible opening.
|
||||
node.windowHeight,
|
||||
node.windowOffsetY,
|
||||
node.wallSkirtHeight,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -120,8 +133,18 @@ const DormerWindowAssembly = ({
|
||||
const winX = skirtWin.offsetX
|
||||
const winY = skirtWin.centerY
|
||||
|
||||
const renderFace = (zPos: number, outDir: number, keyPrefix: string) => (
|
||||
<group name={`dormer-window-${keyPrefix}`} position={[winX, winY, zPos]}>
|
||||
// The glazing role material is FrontSide (DoubleSide on a NodeMaterial
|
||||
// poisons the MRT scene pass — see `createSurfaceRoleMaterial`). The
|
||||
// back gable face therefore renders inside a Y-rotated group so its
|
||||
// FrontSide points outward (-Z in segment frame). With the rotation,
|
||||
// the sill always extrudes along the group's local +Z, so its position
|
||||
// no longer needs to flip per-face.
|
||||
const renderFace = (zPos: number, yRot: number, keyPrefix: string) => (
|
||||
<group
|
||||
name={`dormer-window-${keyPrefix}`}
|
||||
position={[winX, winY, zPos]}
|
||||
rotation-y={yRot}
|
||||
>
|
||||
{winGeo.glassPanes.map((pane, i) => (
|
||||
<mesh
|
||||
geometry={pane.geo}
|
||||
@@ -149,7 +172,7 @@ const DormerWindowAssembly = ({
|
||||
geometry={sillGeo}
|
||||
material={frameMaterial}
|
||||
name={`dormer-sill-${keyPrefix}`}
|
||||
position={[0, -winH / 2 - sillT / 2, (outDir * sillD) / 2]}
|
||||
position={[0, -winH / 2 - sillT / 2, sillD / 2]}
|
||||
receiveShadow
|
||||
/>
|
||||
)}
|
||||
@@ -158,8 +181,8 @@ const DormerWindowAssembly = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{exposed.front && renderFace(gableHalfZ, +1, 'front')}
|
||||
{exposed.back && renderFace(-gableHalfZ, -1, 'back')}
|
||||
{exposed.front && renderFace(gableHalfZ, 0, 'front')}
|
||||
{exposed.back && renderFace(-gableHalfZ, Math.PI, 'back')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DownspoutNode as DownspoutNodeSchema,
|
||||
type DownspoutNode as DownspoutNodeType,
|
||||
type GutterNode,
|
||||
type GutterOutlet,
|
||||
type HandleDescriptor,
|
||||
type NodeDefinition,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { downspoutParametrics } from './parametrics'
|
||||
import {
|
||||
computeDownspoutPath,
|
||||
downspoutPipeDims,
|
||||
effectiveWallJog,
|
||||
resolveDownspoutRouting,
|
||||
} from './routing'
|
||||
import { DownspoutNode } from './schema'
|
||||
|
||||
// Mirrors the parametric `min`s so handle drags can't shrink the pipe
|
||||
// past what the inspector would accept.
|
||||
const MIN_LENGTH = 0.1
|
||||
// The length cube + dashed leader ride the straight WALL RUN, offset
|
||||
// outward (+Z, over the eave) past the pipe surface so they float clear
|
||||
// of the pipe instead of touching it.
|
||||
const LENGTH_HANDLE_PAD = 0.12
|
||||
// Lift the length cube a little up the run from the very bottom so it
|
||||
// reads as a height grip rather than sitting at the pipe's end. Clamped
|
||||
// to the run top so it never climbs above the straight section.
|
||||
const CUBE_LIFT = 0.18
|
||||
// Side-move arrows: how far ±X (along the eave) they sit from the pipe,
|
||||
// and how far below the gutter floor — near the top so they read as
|
||||
// "grab and slide along the eave".
|
||||
const SIDE_MOVE_OFFSET = 0.22
|
||||
const SIDE_MOVE_Y = -0.12
|
||||
|
||||
/**
|
||||
* Length tracker — a dashed vertical leader from the outlet (Y = 0,
|
||||
* the gutter floor) down to a small cube near the bottom of the
|
||||
* straight wall run, `anchor: 'max'` + `axis: 'y'` so dragging the
|
||||
* cube down extends the pipe 1:1.
|
||||
*
|
||||
* Both the cube and the leader sit on the wall-run line but offset
|
||||
* outward (away from the pipe) by `radius + LENGTH_HANDLE_PAD`, so the
|
||||
* whole dimension floats clear of the pipe — it reads as "change the
|
||||
* height" rather than a box jammed onto the kicked-out mouth. The cube
|
||||
* rides the run BOTTOM (above the kickout), not the mouth, so the
|
||||
* dimension stays on the straight part.
|
||||
*/
|
||||
function downspoutLengthHandle(): HandleDescriptor<DownspoutNodeType> {
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'y',
|
||||
anchor: 'max',
|
||||
shape: 'tracker',
|
||||
min: MIN_LENGTH,
|
||||
currentValue: (n) => n.length,
|
||||
apply: (_n, newValue) => ({ length: Math.max(MIN_LENGTH, newValue) }),
|
||||
placement: {
|
||||
position: (n, scene) => {
|
||||
const routing = resolveDownspoutRouting(n, scene)
|
||||
const path = computeDownspoutPath(
|
||||
n.length,
|
||||
effectiveWallJog(n, routing),
|
||||
(n.terminal ?? 'splash') !== 'straight',
|
||||
)
|
||||
const halfZ = downspoutPipeDims(n, routing).halfZ
|
||||
const y = Math.min(path.wallRunTopY, path.wallRunBottomY + CUBE_LIFT)
|
||||
return [0, y, path.wallRunZ + halfZ + LENGTH_HANDLE_PAD]
|
||||
},
|
||||
},
|
||||
// Leader starts at Y = 0 (outlet / gutter floor) and runs DOWN past
|
||||
// the cube — same tracker the wall / chimney height fields use.
|
||||
trackerBaseY: () => 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Usable half-span — keep the outlet a hair inside each end so the
|
||||
// collar never lands on a cap (the geometry clamps too; this bounds the
|
||||
// drag). Reads the host gutter's length.
|
||||
function moveBound(n: DownspoutNodeType, gutter: GutterNode | undefined): number {
|
||||
return Math.max(0.05, Math.max(0.05, gutter?.length ?? 2) / 2 - 0.1)
|
||||
}
|
||||
|
||||
// Effective outlet offset for `currentValue` — reads the gutter's live
|
||||
// override first (so the dragged value tracks) then the store.
|
||||
function readOutletOffset(n: DownspoutNodeType): number {
|
||||
if (!n.gutterId) return 0
|
||||
const id = n.gutterId as AnyNodeId
|
||||
const override = useLiveNodeOverrides.getState().get(id) as Partial<GutterNode> | undefined
|
||||
const gutter = useScene.getState().nodes[id] as GutterNode | undefined
|
||||
const outlets = (override?.outlets as GutterOutlet[] | undefined) ?? gutter?.outlets ?? []
|
||||
return outlets.find((o) => o.id === n.outletId)?.offset ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-move arrow — one of a ±X pair that slides the downspout along the
|
||||
* eave. The position lives on the host gutter's outlet
|
||||
* (`gutter.outlets[].offset`), not on the downspout, so `overrideTarget`
|
||||
* redirects the drag's live override + commit to the gutter and `apply`
|
||||
* returns the gutter's patch. The arrows sit near the top of the pipe
|
||||
* and ride its group, which moves with the outlet — so they track the
|
||||
* cursor 1:1 (`anchor: 'min'` → factor +1).
|
||||
*/
|
||||
function downspoutMoveHandle(side: 'left' | 'right'): HandleDescriptor<DownspoutNodeType> {
|
||||
const sign = side === 'right' ? 1 : -1
|
||||
return {
|
||||
kind: 'linear-resize',
|
||||
axis: 'x',
|
||||
anchor: 'min',
|
||||
cursor: 'ew-resize',
|
||||
overrideTarget: (n) => (n.gutterId ? (n.gutterId as AnyNodeId) : undefined),
|
||||
currentValue: (n) => readOutletOffset(n),
|
||||
apply: (n, newOffset, scene) => {
|
||||
const gutter = n.gutterId ? scene.get<GutterNode>(n.gutterId as AnyNodeId) : undefined
|
||||
if (!gutter) return {}
|
||||
const outlets = (gutter.outlets ?? []).map((o) =>
|
||||
o.id === n.outletId ? { ...o, offset: newOffset } : o,
|
||||
)
|
||||
// Patch targets the GUTTER (overrideTarget), not the downspout.
|
||||
return { outlets } as unknown as Partial<DownspoutNodeType>
|
||||
},
|
||||
min: (n, scene) =>
|
||||
-moveBound(n, n.gutterId ? scene.get<GutterNode>(n.gutterId as AnyNodeId) : undefined),
|
||||
max: (n, scene) =>
|
||||
moveBound(n, n.gutterId ? scene.get<GutterNode>(n.gutterId as AnyNodeId) : undefined),
|
||||
placement: {
|
||||
// Static ±X beside the top of the pipe; the group it rides moves
|
||||
// with the outlet, so the arrow stays under the cursor as it slides.
|
||||
position: () => [sign * SIDE_MOVE_OFFSET, SIDE_MOVE_Y, 0],
|
||||
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const downspoutHandles: HandleDescriptor<DownspoutNodeType>[] = [
|
||||
downspoutLengthHandle(),
|
||||
downspoutMoveHandle('left'),
|
||||
downspoutMoveHandle('right'),
|
||||
]
|
||||
|
||||
/**
|
||||
* Downspout — vertical drop pipe taking water from a gutter outlet to
|
||||
* the ground. Scene-graph parent is the same roof-segment the host
|
||||
* gutter sits on (so it renders under `roof-elements` like every
|
||||
* other accessory); the logical link to the gutter is via the
|
||||
* `gutterId` field, which the renderer uses to look up the outlet
|
||||
* position.
|
||||
*
|
||||
* No `handles` yet — the downspout's geometry is anchored to the
|
||||
* gutter's outlet. Length (tracker cube at the routed mouth) and
|
||||
* diameter (chevron on the wall run) are draggable arrows; the wall
|
||||
* standoff lives in the inspector.
|
||||
*/
|
||||
export const downspoutDefinition: NodeDefinition<typeof DownspoutNode> = {
|
||||
kind: 'downspout',
|
||||
schemaVersion: 1,
|
||||
schema: DownspoutNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'roof',
|
||||
|
||||
defaults: () => {
|
||||
const stub = DownspoutNodeSchema.parse({
|
||||
id: 'downspout_default' as never,
|
||||
type: 'downspout',
|
||||
})
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
// Logically a roof accessory — registers under the segment, has
|
||||
// no buildCut, just the standard dirty cascade.
|
||||
roofAccessory: {},
|
||||
},
|
||||
|
||||
parametrics: downspoutParametrics,
|
||||
handles: downspoutHandles,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
|
||||
preview: () => import('./preview'),
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Hover gutter', label: 'Highlight outlet' },
|
||||
{ key: 'Left click', label: 'Drop downspout from outlet' },
|
||||
{ key: 'Esc', label: 'Cancel' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Downspout',
|
||||
description: 'Vertical drop pipe from a gutter outlet to the ground.',
|
||||
icon: { kind: 'url', src: '/icons/roof.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 123,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A downspout — drop pipe from a gutter outlet that elbows back to the wall, runs down the wall face, and kicks out at the bottom. length / diameter / standoff parametric.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { DownspoutNode } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import type { OutletDims } from '../gutter/profile-geometry'
|
||||
import {
|
||||
computeDownspoutPath,
|
||||
type DownspoutPath,
|
||||
type DownspoutRouting,
|
||||
downspoutPipeDims,
|
||||
effectiveWallJog,
|
||||
} from './routing'
|
||||
|
||||
/**
|
||||
* Downspout pipe builder. The pipe follows a real downspout's path —
|
||||
* a short DROP out of the collar, an OFFSET ELBOW back to the wall, the
|
||||
* VERTICAL RUN down the wall, and a bottom KICKOUT — plus the hardware
|
||||
* that makes it read real: WALL STRAPS clamping the run to the wall,
|
||||
* an open (hollow) mouth at the kickout, and a SPLASH BLOCK on the
|
||||
* ground under the mouth.
|
||||
*
|
||||
* Mesh frame is centred on the outlet: local Y = 0 is the gutter floor,
|
||||
* −Y is down, −Z is toward the wall (+Z is outward over the eave). The
|
||||
* path lives in the local Y/Z plane; X is the gutter-length axis.
|
||||
*
|
||||
* Cross-section follows the host gutter's profile: round on half-round,
|
||||
* rectangular on k-style / box. Straight legs are solid cylinders /
|
||||
* boxes welded at the corners with a small joint; the kickout leg is a
|
||||
* hollow tube so the open mouth reads through.
|
||||
*
|
||||
* Pure: no React, no scene access.
|
||||
*/
|
||||
const RADIAL_SEGMENTS = 16
|
||||
const JOINT_SEGMENTS = 12
|
||||
const FWD = new THREE.Vector3(0, 0, 1)
|
||||
const UP = new THREE.Vector3(0, 1, 0)
|
||||
|
||||
// Pipe wall thickness for the hollow (open-mouth) kickout leg.
|
||||
const PIPE_WALL = 0.004
|
||||
// Wall straps — a thin band clamps the run to the wall, set in a margin
|
||||
// from each end (spacing comes from the node).
|
||||
const STRAP_END_MARGIN = 0.3
|
||||
const STRAP_THICKNESS = 0.022
|
||||
const STRAP_OVERHANG = 0.014
|
||||
// Splash block — a tilted slab on the ground under the mouth that
|
||||
// carries water away from the foundation.
|
||||
const SPLASH_WIDTH = 0.22
|
||||
const SPLASH_LENGTH = 0.34
|
||||
const SPLASH_THICKNESS = 0.05
|
||||
const SPLASH_TILT = 0.1
|
||||
|
||||
export function buildDownspoutGeometry(
|
||||
node: DownspoutNode,
|
||||
routing?: DownspoutRouting | null,
|
||||
): THREE.BufferGeometry {
|
||||
const dims = downspoutPipeDims(node, routing)
|
||||
const terminal = node.terminal ?? 'splash'
|
||||
// 'straight' runs the pipe to grade with no kickout leg.
|
||||
const pathData = computeDownspoutPath(
|
||||
node.length,
|
||||
effectiveWallJog(node, routing),
|
||||
terminal !== 'straight',
|
||||
)
|
||||
|
||||
// Drop consecutive duplicates (jog == 0 collapses the elbow; no kick
|
||||
// collapses the bottom two) so we never build a zero-length segment.
|
||||
const path: THREE.Vector3[] = []
|
||||
for (const [x, y, z] of pathData.points) {
|
||||
const p = new THREE.Vector3(x, y, z)
|
||||
const last = path.at(-1)
|
||||
if (!last || last.distanceTo(p) > 1e-4) path.push(p)
|
||||
}
|
||||
|
||||
const pieces: THREE.BufferGeometry[] = []
|
||||
const lastLeg = path.length - 2
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
// The final leg (the kickout mouth) is a hollow tube so you can see
|
||||
// up the open end; the rest stay solid (their outer surface reads
|
||||
// identically, and they're capped by the collar / joints anyway).
|
||||
pieces.push(
|
||||
i === lastLeg
|
||||
? ringTube(path[i]!, path[i + 1]!, dims)
|
||||
: segmentBetween(path[i]!, path[i + 1]!, dims),
|
||||
)
|
||||
if (i > 0) pieces.push(jointAt(path[i]!, path[i - 1]!, path[i + 1]!, dims))
|
||||
}
|
||||
|
||||
if ((node.strapStyle ?? 'band') !== 'none') {
|
||||
for (const strap of buildStraps(pathData, dims, node.strapSpacing ?? 1.8)) pieces.push(strap)
|
||||
}
|
||||
if (terminal === 'splash') {
|
||||
const splash = buildSplash(pathData)
|
||||
if (splash) pieces.push(splash)
|
||||
}
|
||||
|
||||
const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!)
|
||||
if (merged !== pieces[0]) {
|
||||
for (const p of pieces) p.dispose()
|
||||
}
|
||||
merged.computeVertexNormals()
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Solid segment spanning two points. Round → a cylinder; rect → a box
|
||||
* (2·halfX wide along the gutter length, 2·halfZ deep outward). The
|
||||
* orient-onto-direction rotation is purely about X for our planar path,
|
||||
* so the box's width stays aligned with the gutter length axis.
|
||||
*/
|
||||
function segmentBetween(
|
||||
a: THREE.Vector3,
|
||||
b: THREE.Vector3,
|
||||
dims: OutletDims,
|
||||
): THREE.BufferGeometry {
|
||||
const dir = new THREE.Vector3().subVectors(b, a)
|
||||
const len = dir.length()
|
||||
const geo =
|
||||
dims.shape === 'round'
|
||||
? new THREE.CylinderGeometry(dims.halfX, dims.halfX, len, RADIAL_SEGMENTS).toNonIndexed()
|
||||
: new THREE.BoxGeometry(2 * dims.halfX, len, 2 * dims.halfZ).toNonIndexed()
|
||||
// The primitive runs along +Y centred at origin; rotate +Y onto the
|
||||
// segment direction, then drop it on the midpoint.
|
||||
geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, dir.normalize()))
|
||||
geo.translate((a.x + b.x) / 2, (a.y + b.y) / 2, (a.z + b.z) / 2)
|
||||
return geo
|
||||
}
|
||||
|
||||
/**
|
||||
* Hollow tube spanning two points — a ring (round) / rectangular-ring
|
||||
* cross-section extruded along the leg, so both ends are open and the
|
||||
* bore reads through. Used for the kickout mouth.
|
||||
*/
|
||||
function ringTube(a: THREE.Vector3, b: THREE.Vector3, dims: OutletDims): THREE.BufferGeometry {
|
||||
const dir = new THREE.Vector3().subVectors(b, a)
|
||||
const len = dir.length()
|
||||
const shape = new THREE.Shape()
|
||||
const hole = new THREE.Path()
|
||||
if (dims.shape === 'round') {
|
||||
shape.absarc(0, 0, dims.halfX, 0, Math.PI * 2, false)
|
||||
hole.absarc(0, 0, Math.max(0.002, dims.halfX - PIPE_WALL), 0, Math.PI * 2, true)
|
||||
} else {
|
||||
const ox = dims.halfX
|
||||
const oz = dims.halfZ
|
||||
const ix = Math.max(0.002, ox - PIPE_WALL)
|
||||
const iz = Math.max(0.002, oz - PIPE_WALL)
|
||||
shape.moveTo(-ox, -oz)
|
||||
shape.lineTo(ox, -oz)
|
||||
shape.lineTo(ox, oz)
|
||||
shape.lineTo(-ox, oz)
|
||||
shape.closePath()
|
||||
hole.moveTo(-ix, -iz)
|
||||
hole.lineTo(-ix, iz)
|
||||
hole.lineTo(ix, iz)
|
||||
hole.lineTo(ix, -iz)
|
||||
hole.closePath()
|
||||
}
|
||||
shape.holes.push(hole)
|
||||
// ExtrudeGeometry runs the shape (in XY) along +Z from 0 to depth;
|
||||
// orient +Z onto the leg direction, then move the z=0 end to `a`.
|
||||
// ExtrudeGeometry is already non-indexed, matching the merge set.
|
||||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: len,
|
||||
bevelEnabled: false,
|
||||
steps: 1,
|
||||
curveSegments: RADIAL_SEGMENTS,
|
||||
})
|
||||
geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(FWD, dir.normalize()))
|
||||
geo.translate(a.x, a.y, a.z)
|
||||
return geo
|
||||
}
|
||||
|
||||
/**
|
||||
* Corner joint at `p` between the segments (prev→p) and (p→next). Round
|
||||
* → a sphere; rect → a box aligned to the bend bisector so it bridges
|
||||
* the wedge the two box ends leave open at the outer corner.
|
||||
*/
|
||||
function jointAt(
|
||||
p: THREE.Vector3,
|
||||
prev: THREE.Vector3,
|
||||
next: THREE.Vector3,
|
||||
dims: OutletDims,
|
||||
): THREE.BufferGeometry {
|
||||
if (dims.shape === 'round') {
|
||||
const geo = new THREE.SphereGeometry(dims.halfX, JOINT_SEGMENTS, JOINT_SEGMENTS).toNonIndexed()
|
||||
geo.translate(p.x, p.y, p.z)
|
||||
return geo
|
||||
}
|
||||
const dirIn = new THREE.Vector3().subVectors(p, prev).normalize()
|
||||
const dirOut = new THREE.Vector3().subVectors(next, p).normalize()
|
||||
const bis = new THREE.Vector3().addVectors(dirIn, dirOut)
|
||||
if (bis.lengthSq() < 1e-8) bis.copy(dirOut) // straight-through; degenerate
|
||||
bis.normalize()
|
||||
const geo = new THREE.BoxGeometry(2 * dims.halfX, 2 * dims.halfZ, 2 * dims.halfZ).toNonIndexed()
|
||||
geo.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(UP, bis))
|
||||
geo.translate(p.x, p.y, p.z)
|
||||
return geo
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin bands clamping the wall run to the wall, ~`STRAP_SPACING` apart
|
||||
* and set in from each end. Each is a flat box a touch proud of the
|
||||
* pipe so it reads as a strap wrapping the run.
|
||||
*/
|
||||
function buildStraps(
|
||||
path: DownspoutPath,
|
||||
dims: OutletDims,
|
||||
spacing: number,
|
||||
): THREE.BufferGeometry[] {
|
||||
const top = path.wallRunTopY
|
||||
const bottom = path.wallRunBottomY
|
||||
const z = path.wallRunZ
|
||||
const runLen = top - bottom
|
||||
if (runLen < STRAP_END_MARGIN * 2 + 0.05) return []
|
||||
|
||||
const usable = runLen - STRAP_END_MARGIN * 2
|
||||
const count = Math.max(1, Math.floor(usable / Math.max(0.2, spacing)) + 1)
|
||||
const stride = count > 1 ? usable / (count - 1) : 0
|
||||
const w = 2 * dims.halfX + 2 * STRAP_OVERHANG
|
||||
const d = 2 * dims.halfZ + 2 * STRAP_OVERHANG
|
||||
|
||||
const straps: THREE.BufferGeometry[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const y = count > 1 ? top - STRAP_END_MARGIN - i * stride : (top + bottom) / 2
|
||||
const band = new THREE.BoxGeometry(w, STRAP_THICKNESS, d).toNonIndexed()
|
||||
band.translate(0, y, z)
|
||||
straps.push(band)
|
||||
}
|
||||
return straps
|
||||
}
|
||||
|
||||
/**
|
||||
* Tilted slab on the ground under the mouth, extending outward (+Z,
|
||||
* away from the wall) so it carries water off from the foundation.
|
||||
*/
|
||||
function buildSplash(path: DownspoutPath): THREE.BufferGeometry | null {
|
||||
const [bx, by, bz] = path.bottom
|
||||
const slab = new THREE.BoxGeometry(SPLASH_WIDTH, SPLASH_THICKNESS, SPLASH_LENGTH).toNonIndexed()
|
||||
// Tilt the far (+Z) end down so it slopes away from the wall.
|
||||
slab.rotateX(SPLASH_TILT)
|
||||
slab.translate(bx, by - SPLASH_THICKNESS / 2, bz + SPLASH_LENGTH / 2)
|
||||
return slab
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { downspoutDefinition } from './definition'
|
||||
export { buildDownspoutGeometry } from './geometry'
|
||||
export { DownspoutNode } from './schema'
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DownspoutNode,
|
||||
type GutterNode,
|
||||
type GutterOutlet,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { SliderControl } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Position-along-the-eave editor for a downspout. The downspout's spot
|
||||
* is owned by its outlet on the host gutter (`gutter.outlets[].offset`),
|
||||
* not by the downspout itself — so this slider reads + writes that
|
||||
* outlet on the gutter rather than patching the downspout.
|
||||
*
|
||||
* Mesh-first commit (same shape as the in-world handle drags):
|
||||
* - `onChange` (live, every drag tick) publishes the new outlets to the
|
||||
* gutter's `useLiveNodeOverrides`. The gutter renderer rebuilds its
|
||||
* mesh from that override and the downspout renderer re-reads its
|
||||
* outlet — both move immediately, with NO write to the scene store /
|
||||
* history. The slider also reads the override back so its number
|
||||
* tracks during the drag.
|
||||
* - `onCommit` (on release) writes the final outlets to the store once
|
||||
* (the single undoable change — `SliderControl` resumes history
|
||||
* first) and drops the override so the renderers read the store again.
|
||||
*
|
||||
* Wired via `parametrics.fields[].kind: 'custom'`; hidden when the
|
||||
* downspout isn't linked to an outlet.
|
||||
*/
|
||||
export function DownspoutPositionEditor({ node }: { node: DownspoutNode }) {
|
||||
const gutter = useScene((s) =>
|
||||
node.gutterId ? (s.nodes[node.gutterId as AnyNodeId] as GutterNode | undefined) : undefined,
|
||||
)
|
||||
// Live override on the gutter, so the readout tracks the in-flight drag.
|
||||
const override = useLiveNodeOverrides((s) =>
|
||||
node.gutterId
|
||||
? (s.get(node.gutterId as AnyNodeId) as Partial<GutterNode> | undefined)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
if (!gutter || gutter.type !== 'gutter') return null
|
||||
const storeOutlets = gutter.outlets ?? []
|
||||
const effectiveOutlets = (override?.outlets as GutterOutlet[] | undefined) ?? storeOutlets
|
||||
const outlet = effectiveOutlets.find((o) => o.id === node.outletId)
|
||||
if (!outlet) return null
|
||||
|
||||
// Usable half-span — keep the outlet a hair inside each end so the
|
||||
// collar never lands on a cap. The geometry clamps too; this just
|
||||
// keeps the slider honest.
|
||||
const bound = Math.max(0.05, Math.max(0.05, gutter.length) / 2 - 0.1)
|
||||
const gutterId = gutter.id as AnyNodeId
|
||||
|
||||
// Set the dragged outlet's offset on a copy of the STORE outlets (the
|
||||
// canonical base — the slider hands an absolute value each tick).
|
||||
const withOffset = (offset: number): GutterOutlet[] =>
|
||||
storeOutlets.map((o) => (o.id === node.outletId ? { ...o, offset } : o))
|
||||
|
||||
const handleChange = (offset: number) => {
|
||||
// Mesh-first: publish to the gutter override, no store write.
|
||||
useLiveNodeOverrides.getState().set(gutterId, { outlets: withOffset(offset) })
|
||||
}
|
||||
|
||||
const handleCommit = (offset: number) => {
|
||||
// Commit once to the store, then drop the override.
|
||||
const state = useScene.getState()
|
||||
state.updateNode(gutterId, { outlets: withOffset(offset) })
|
||||
useLiveNodeOverrides.getState().clear(gutterId)
|
||||
state.markDirty(gutterId)
|
||||
}
|
||||
|
||||
return (
|
||||
<SliderControl
|
||||
label="Position"
|
||||
max={bound}
|
||||
min={-bound}
|
||||
onChange={handleChange}
|
||||
onCommit={handleCommit}
|
||||
precision={2}
|
||||
// onChange only touches the override, so there's nothing in the
|
||||
// store to restore on release — skip the restore-then-reapply dance.
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.max(-bound, Math.min(bound, outlet.offset ?? 0))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import { DownspoutPositionEditor } from './inspector-editors'
|
||||
import type { DownspoutNode } from './schema'
|
||||
|
||||
export const downspoutParametrics: ParametricDescriptor<DownspoutNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Dimensions',
|
||||
fields: [
|
||||
{ key: 'length', kind: 'number', unit: 'm', min: 0.1, max: 8, step: 0.05 },
|
||||
{ key: 'diameter', kind: 'number', unit: 'm', min: 0.02, max: 0.15, step: 0.005 },
|
||||
// Cross-section: follow the gutter profile, or force round / rect.
|
||||
{
|
||||
key: 'shape',
|
||||
kind: 'enum',
|
||||
options: ['auto', 'round', 'rect'],
|
||||
display: 'segmented',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Hardware',
|
||||
fields: [
|
||||
// Wall straps clamping the run, like the gutter's hangers.
|
||||
{
|
||||
key: 'strapStyle',
|
||||
kind: 'enum',
|
||||
options: ['band', 'none'],
|
||||
display: 'segmented',
|
||||
},
|
||||
{
|
||||
key: 'strapSpacing',
|
||||
kind: 'number',
|
||||
unit: 'm',
|
||||
min: 0.3,
|
||||
max: 3,
|
||||
step: 0.1,
|
||||
visibleIf: (n) => (n.strapStyle ?? 'band') !== 'none',
|
||||
},
|
||||
// Bottom treatment: splash block, kickout only, or straight to grade.
|
||||
{
|
||||
key: 'terminal',
|
||||
kind: 'enum',
|
||||
options: ['splash', 'kickout', 'straight'],
|
||||
display: 'segmented',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Placement',
|
||||
fields: [
|
||||
// Slide the outlet (and so this downspout) along the eave. Edits
|
||||
// the linked outlet's offset on the host gutter — the only way to
|
||||
// reposition a drop after placing it. Hidden when unlinked.
|
||||
{
|
||||
key: 'outletPosition',
|
||||
kind: 'custom',
|
||||
component: DownspoutPositionEditor,
|
||||
visibleIf: (n) => Boolean(n.outletId),
|
||||
},
|
||||
// How far proud of the wall the pipe sits. Crank it up if the
|
||||
// auto-routed run buries into the wall (the wall isn't where the
|
||||
// roof overhang implies); 0 puts the pipe surface on the wall
|
||||
// face; large values pull the run back out toward the eave.
|
||||
{ key: 'standoff', kind: 'number', unit: 'm', min: 0, max: 0.6, step: 0.01 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { buildDownspoutGeometry } from './geometry'
|
||||
import type { DownspoutRouting } from './routing'
|
||||
import type { DownspoutNode } from './schema'
|
||||
|
||||
/**
|
||||
* Translucent ghost of a downspout — same geometry as the committed
|
||||
* pipe so the placement ghost matches what lands on click. No
|
||||
* internal transform wrapper; the placement tool nests this under
|
||||
* the gutter / outlet chain so the position math stays in one place.
|
||||
*
|
||||
* `routing` mirrors the renderer's — when the tool resolves the host
|
||||
* gutter it feeds the same wall-jog so the ghost already shows the
|
||||
* elbowed path, not a straight drop.
|
||||
*/
|
||||
const DownspoutPreview = ({
|
||||
node,
|
||||
routing,
|
||||
}: {
|
||||
node: DownspoutNode
|
||||
routing?: DownspoutRouting | null
|
||||
}) => {
|
||||
const geometry = useMemo(
|
||||
() => buildDownspoutGeometry(node, routing),
|
||||
[
|
||||
node.length,
|
||||
node.diameter,
|
||||
node.standoff,
|
||||
node.shape,
|
||||
node.strapStyle,
|
||||
node.strapSpacing,
|
||||
node.terminal,
|
||||
routing,
|
||||
],
|
||||
)
|
||||
|
||||
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} raycast={() => {}} />
|
||||
<lineSegments geometry={edgesGeometry} renderOrder={1000}>
|
||||
<lineBasicMaterial color={0x6c_a3_ff} depthTest={false} opacity={0.9} transparent />
|
||||
</lineSegments>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DownspoutPreview
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DownspoutNode,
|
||||
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 { computeEaveY } from '../gutter/eave-snap'
|
||||
import { resolveGutterOutletById } from '../gutter/outlet-lookup'
|
||||
import { buildDownspoutGeometry } from './geometry'
|
||||
import { computeDownspoutRouting } from './routing'
|
||||
|
||||
const defaultMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 0.7,
|
||||
metalness: 0.25,
|
||||
})
|
||||
|
||||
/**
|
||||
* Downspout renderer. Mount chain mirrors the gutter's, then nests
|
||||
* one level deeper into the outlet position in gutter-mesh-local:
|
||||
*
|
||||
* segment.position → segment.rotation (Y)
|
||||
* → [gutter.position[0], computeEaveY(segment), gutter.position[2]]
|
||||
* → gutter.rotation (Y)
|
||||
* → [outlet.x, outlet.y, outlet.z]
|
||||
* → mesh (pipe descends from Y = 0)
|
||||
*
|
||||
* Pulling the gutter's eave Y from `computeEaveY(effectiveSegment)`
|
||||
* means the downspout follows wallHeight / overhang / pitch changes
|
||||
* live, on the same frame as the gutter. The gutter and segment also
|
||||
* subscribe to `useLiveNodeOverrides` so drag-in-flight changes flow
|
||||
* through too.
|
||||
*/
|
||||
const DownspoutRenderer = ({ node: storeNode }: { node: DownspoutNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
useRegistry(storeNode.id, 'downspout', ref)
|
||||
const handlers = useNodeEvents(storeNode, 'downspout')
|
||||
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<DownspoutNode> | undefined,
|
||||
)
|
||||
const node: DownspoutNode = overrides
|
||||
? ({ ...storeNode, ...overrides } as DownspoutNode)
|
||||
: storeNode
|
||||
|
||||
// Host gutter — both scene + live overrides so drag-in-flight gutter
|
||||
// moves (length / position) reposition the downspout immediately.
|
||||
const gutter = useScene((s) =>
|
||||
node.gutterId ? (s.nodes[node.gutterId as AnyNodeId] as GutterNode | undefined) : undefined,
|
||||
)
|
||||
const gutterOverrides = useLiveNodeOverrides((s) =>
|
||||
node.gutterId
|
||||
? (s.get(node.gutterId as AnyNodeId) as Partial<GutterNode> | undefined)
|
||||
: undefined,
|
||||
)
|
||||
const effectiveGutter: GutterNode | undefined = gutter
|
||||
? gutterOverrides
|
||||
? ({ ...gutter, ...gutterOverrides } as GutterNode)
|
||||
: gutter
|
||||
: undefined
|
||||
|
||||
// Segment of the host gutter (the downspout's own scene-graph parent
|
||||
// is the same segment — same as roof accessories — so the chain
|
||||
// segment → gutter-mesh-local is what we need to reach the outlet).
|
||||
const segment = useScene((s) =>
|
||||
effectiveGutter?.roofSegmentId
|
||||
? (s.nodes[effectiveGutter.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
: undefined,
|
||||
)
|
||||
const segmentOverrides = useLiveNodeOverrides((s) =>
|
||||
effectiveGutter?.roofSegmentId
|
||||
? (s.get(effectiveGutter.roofSegmentId as AnyNodeId) as Partial<RoofSegmentNode> | undefined)
|
||||
: undefined,
|
||||
)
|
||||
const effectiveSegment: RoofSegmentNode | undefined = segment
|
||||
? segmentOverrides
|
||||
? ({ ...segment, ...segmentOverrides } as RoofSegmentNode)
|
||||
: segment
|
||||
: undefined
|
||||
|
||||
// Routing back to the wall — memoised on the gutter/segment values
|
||||
// that actually move the jog or the collar bore, so the pipe geometry
|
||||
// only rebuilds when one of those changes (not on every override-merge
|
||||
// render). Resolves to null when the gutter has no outlet.
|
||||
const routing = useMemo(
|
||||
() =>
|
||||
effectiveGutter && effectiveSegment
|
||||
? computeDownspoutRouting(effectiveGutter, effectiveSegment, node.outletId)
|
||||
: null,
|
||||
[
|
||||
effectiveGutter?.profile,
|
||||
effectiveGutter?.size,
|
||||
// The outlets array — its referenced entry's diameter / offset
|
||||
// drives the collar bore + nesting.
|
||||
effectiveGutter ? JSON.stringify(effectiveGutter.outlets) : undefined,
|
||||
effectiveSegment?.overhang,
|
||||
node.outletId,
|
||||
],
|
||||
)
|
||||
|
||||
const geometry = useMemo(
|
||||
() => buildDownspoutGeometry(node, routing),
|
||||
[
|
||||
node.length,
|
||||
node.diameter,
|
||||
node.standoff,
|
||||
node.shape,
|
||||
node.strapStyle,
|
||||
node.strapSpacing,
|
||||
node.terminal,
|
||||
routing,
|
||||
],
|
||||
)
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
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 (!effectiveGutter || !effectiveSegment) return null
|
||||
const outlet = resolveGutterOutletById(effectiveGutter, node.outletId)
|
||||
if (!outlet) return null
|
||||
|
||||
const segPos = effectiveSegment.position ?? [0, 0, 0]
|
||||
const segRotY = effectiveSegment.rotation ?? 0
|
||||
const liveEaveY = computeEaveY(effectiveSegment)
|
||||
const gutterRotY = effectiveGutter.rotation ?? 0
|
||||
|
||||
// Bake the gutter's position + Y-rotation into the registered ref so it
|
||||
// sits as a DIRECT child of the segment-transform group — its local
|
||||
// pose is the outlet's full segment-local placement. `NodeArrowHandles`
|
||||
// copies the registered object's LOCAL transform into the segment's
|
||||
// object (it assumes a flat node → scene-parent chain); with the old
|
||||
// nested segment → gutter → outlet groups it only saw the innermost
|
||||
// `[outlet.x …]` offset and the handles landed at the roof centre.
|
||||
const gutterX = effectiveGutter.position[0] ?? 0
|
||||
const gutterZ = effectiveGutter.position[2] ?? 0
|
||||
const cos = Math.cos(gutterRotY)
|
||||
const sin = Math.sin(gutterRotY)
|
||||
const outletSegX = gutterX + (outlet.x * cos + outlet.z * sin)
|
||||
const outletSegZ = gutterZ + (-outlet.x * sin + outlet.z * cos)
|
||||
const outletSegY = liveEaveY + outlet.y
|
||||
|
||||
return (
|
||||
<group position={segPos} rotation-y={segRotY}>
|
||||
<group
|
||||
position={[outletSegX, outletSegY, outletSegZ]}
|
||||
ref={ref}
|
||||
rotation-y={gutterRotY}
|
||||
visible={node.visible}
|
||||
>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={geometry}
|
||||
material={material}
|
||||
name="downspout-surface"
|
||||
receiveShadow
|
||||
{...handlers}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default DownspoutRenderer
|
||||
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
DownspoutNode,
|
||||
GutterNode,
|
||||
RoofSegmentNode,
|
||||
SceneApi,
|
||||
} from '@pascal-app/core'
|
||||
import { EAVE_TUCK_INWARD } from '../gutter/eave-snap'
|
||||
import { resolveGutterOutletById } from '../gutter/outlet-lookup'
|
||||
import {
|
||||
type OutletDims,
|
||||
type OutletShape,
|
||||
outletDims,
|
||||
profileFloorMidZ,
|
||||
} from '../gutter/profile-geometry'
|
||||
|
||||
/**
|
||||
* Routing parameters that turn the downspout from a straight drop into
|
||||
* a pipe that actually returns to the wall. Derived from the host
|
||||
* gutter + its roof segment — the geometry builder stays pure (takes
|
||||
* this as data) so the renderer, the placement preview, and the handle
|
||||
* descriptors can all feed it the same numbers.
|
||||
*/
|
||||
export type DownspoutRouting = {
|
||||
/**
|
||||
* Distance the pipe must travel toward the wall (downspout-local −Z)
|
||||
* to leave the eave overhang and sit flat against the fascia. The
|
||||
* outlet hangs `overhang − tuck` outboard of the wall face, plus the
|
||||
* `floorMidZ` offset of the outlet within the trough — so the offset
|
||||
* elbow at the top steps the pipe back by exactly this much.
|
||||
*/
|
||||
wallJog: number
|
||||
/** Cross-section the pipe takes — round on half-round, rect on k-style / box. */
|
||||
shape: OutletShape
|
||||
/**
|
||||
* Inner half-extents of the gutter's drilled collar (along-length X,
|
||||
* outward Z). The pipe's cross-section is clamped just under these so
|
||||
* it slip-fits up inside the collar instead of sharing a coincident
|
||||
* surface with it (the old pipe sat at exactly the bore and z-fought
|
||||
* the collar wall).
|
||||
*/
|
||||
collarHalfX: number
|
||||
collarHalfZ: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure routing from resolved nodes — used by the renderer / preview /
|
||||
* tool, which already hold the effective gutter + segment.
|
||||
*/
|
||||
export function computeDownspoutRouting(
|
||||
gutter: GutterNode,
|
||||
segment: Pick<RoofSegmentNode, 'overhang'>,
|
||||
outletId: string | undefined,
|
||||
): DownspoutRouting | null {
|
||||
const outlet = resolveGutterOutletById(gutter, outletId)
|
||||
if (!outlet) return null
|
||||
|
||||
const overhang = segment.overhang ?? 0
|
||||
const floorMidZ = profileFloorMidZ(gutter.profile ?? 'k-style', Math.max(0.04, gutter.size))
|
||||
// The gutter rim is tucked `EAVE_TUCK_INWARD` back from the very tip
|
||||
// of the overhang, so the real outboard distance is `overhang − tuck`
|
||||
// (never negative — a flush eave still leaves the floorMidZ offset).
|
||||
const wallJog = Math.max(0, overhang - EAVE_TUCK_INWARD) + floorMidZ
|
||||
|
||||
return {
|
||||
wallJog,
|
||||
shape: outlet.shape,
|
||||
collarHalfX: outlet.innerHalfX,
|
||||
collarHalfZ: outlet.innerHalfZ,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing for the handle descriptors, which only get `(node, sceneApi)`.
|
||||
* Walks downspout → gutter → segment through the scene snapshot.
|
||||
*/
|
||||
export function resolveDownspoutRouting(
|
||||
node: DownspoutNode,
|
||||
sceneApi: SceneApi,
|
||||
): DownspoutRouting | null {
|
||||
if (!node.gutterId) return null
|
||||
const gutter = sceneApi.get<GutterNode>(node.gutterId as AnyNodeId)
|
||||
if (!gutter || gutter.type !== 'gutter') return null
|
||||
const segment = gutter.roofSegmentId
|
||||
? sceneApi.get<RoofSegmentNode>(gutter.roofSegmentId as AnyNodeId)
|
||||
: undefined
|
||||
return computeDownspoutRouting(gutter, segment ?? { overhang: 0 }, node.outletId)
|
||||
}
|
||||
|
||||
// ─── Pipe cross-section + effective jog ──────────────────────────────
|
||||
|
||||
// Slip-fit clearance — when the pipe lands within this of the collar
|
||||
// bore (the placement default, where pipe == hole), nudge it just inside
|
||||
// so it doesn't share a coincident wall (the old z-fighting).
|
||||
const NEAR_BORE = 0.002
|
||||
const SLIP_CLEARANCE = 0.0005
|
||||
|
||||
function nestUnder(half: number, collar: number | undefined): number {
|
||||
if (collar !== undefined && Math.abs(half - collar) < NEAR_BORE) {
|
||||
return Math.max(0.005, collar - SLIP_CLEARANCE)
|
||||
}
|
||||
return half
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered pipe cross-section — round (halfX = halfZ = radius) or rect,
|
||||
* following the host gutter's profile. Defaults to `diameter`-sized, but
|
||||
* each half-extent that lands within a hair of the collar's matching
|
||||
* bore is nudged just inside so the pipe slip-fits the collar instead of
|
||||
* sharing a coincident wall. A deliberately larger / smaller pipe is
|
||||
* left alone, so the diameter field stays honest.
|
||||
*/
|
||||
export function downspoutPipeDims(
|
||||
node: Pick<DownspoutNode, 'diameter' | 'shape'>,
|
||||
routing?: DownspoutRouting | null,
|
||||
): OutletDims {
|
||||
// 'auto' follows the gutter profile; 'round' / 'rect' override it.
|
||||
const shape: OutletShape =
|
||||
node.shape && node.shape !== 'auto' ? node.shape : (routing?.shape ?? 'round')
|
||||
const dims = outletDims(shape, node.diameter)
|
||||
if (!routing) return dims
|
||||
return {
|
||||
shape,
|
||||
halfX: nestUnder(dims.halfX, routing.collarHalfX),
|
||||
halfZ: nestUnder(dims.halfZ, routing.collarHalfZ),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The −Z distance the wall run actually sits at. The raw `wallJog`
|
||||
* reaches the wall *face*; we pull back by the pipe's outward half-depth
|
||||
* (so the pipe's surface — not its centerline — meets the wall) plus the
|
||||
* `standoff` (bracket gap / overshoot escape hatch), so the pipe sits
|
||||
* proud of the wall instead of burying into it.
|
||||
*/
|
||||
export function effectiveWallJog(
|
||||
node: Pick<DownspoutNode, 'diameter' | 'standoff' | 'shape'>,
|
||||
routing?: DownspoutRouting | null,
|
||||
): number {
|
||||
if (!routing) return 0
|
||||
const dims = downspoutPipeDims(node, routing)
|
||||
return Math.max(0, routing.wallJog - dims.halfZ - (node.standoff ?? 0))
|
||||
}
|
||||
|
||||
// ─── Centerline path ─────────────────────────────────────────────────
|
||||
|
||||
// Vertical drop straight out of the collar before the offset elbow.
|
||||
const TOP_DROP = 0.05
|
||||
// Bottom kickout: how far the mouth throws outward (+Z) and how tall
|
||||
// the kicked section is. Skipped when the pipe is too short to fit it.
|
||||
const KICK_OUT = 0.08
|
||||
const KICK_RISE = 0.1
|
||||
|
||||
export type DownspoutPath = {
|
||||
/** Centerline points, top → bottom, in downspout-local space. */
|
||||
points: [number, number, number][]
|
||||
/** Bottom mouth — the kicked-out pipe end. */
|
||||
bottom: [number, number, number]
|
||||
/** Z of the vertical wall run (downspout-local; −jog). */
|
||||
wallRunZ: number
|
||||
/** Y at the top of the straight wall run (just below the offset elbow). */
|
||||
wallRunTopY: number
|
||||
/** Y at the bottom of the straight wall run (just above the kickout). */
|
||||
wallRunBottomY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure centerline of the routed pipe. Shared by the geometry builder
|
||||
* (sweeps a cylinder along it) and the handle descriptors (place the
|
||||
* length cube at `bottom`), so the dimension chrome can never drift
|
||||
* from the mesh.
|
||||
*
|
||||
* Local frame: Y = 0 at the gutter floor, −Y down, −Z toward the wall.
|
||||
* The four legs are the vertical drop out of the collar, the offset
|
||||
* elbow stepping back to the wall, the wall run, and the kickout.
|
||||
*/
|
||||
export function computeDownspoutPath(length: number, jog: number, allowKick = true): DownspoutPath {
|
||||
const len = Math.max(0.1, length)
|
||||
const j = Math.max(0, jog)
|
||||
const drop = Math.min(TOP_DROP, len * 0.15)
|
||||
// Offset elbow runs at ~45° (vertical travel == horizontal jog) but
|
||||
// never eats more than what's left after the drop + a minimum run.
|
||||
const elbowVert = Math.min(j, Math.max(0, len - drop - 0.1))
|
||||
const afterElbow = len - drop - elbowVert
|
||||
// `allowKick` off (terminal 'straight') runs the pipe straight to the
|
||||
// bottom — no kickout leg.
|
||||
const kick = allowKick && afterElbow > KICK_RISE * 1.5
|
||||
const kickRise = kick ? Math.min(KICK_RISE, afterElbow * 0.3) : 0
|
||||
const kickOut = kick ? KICK_OUT : 0
|
||||
|
||||
const wallRunTopY = -drop - elbowVert
|
||||
const wallRunBottomY = -len + kickRise
|
||||
const bottom: [number, number, number] = [0, -len, -j + kickOut]
|
||||
return {
|
||||
points: [
|
||||
[0, 0, 0], // collar mouth
|
||||
[0, -drop, 0], // bottom of the first drop
|
||||
[0, wallRunTopY, -j], // offset elbow, now at the wall
|
||||
[0, wallRunBottomY, -j], // bottom of the wall run
|
||||
bottom, // kicked mouth
|
||||
],
|
||||
bottom,
|
||||
wallRunZ: -j,
|
||||
wallRunTopY,
|
||||
wallRunBottomY,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Schema lives in core (referenced by the AnyNode union). Re-export so
|
||||
// every downspout-related import stays inside @pascal-app/nodes/downspout.
|
||||
export { DownspoutNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DownspoutNode,
|
||||
emitter,
|
||||
type GutterEvent,
|
||||
type GutterNode,
|
||||
generateId,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
import { computeEaveY } from '../gutter/eave-snap'
|
||||
import { resolveGutterOutletById } from '../gutter/outlet-lookup'
|
||||
import { downspoutDefinition } from './definition'
|
||||
import DownspoutPreview from './preview'
|
||||
import { computeDownspoutRouting, type DownspoutRouting } from './routing'
|
||||
|
||||
const DEFAULT_OUTLET_DIAMETER = 0.07
|
||||
|
||||
type PreviewTarget = {
|
||||
segment: { position: [number, number, number]; rotation: number; eaveY: number }
|
||||
gutter: { position: [number, number, number]; rotation: number }
|
||||
outlet: { x: number; y: number; z: number; bore: number }
|
||||
routing: DownspoutRouting | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Downspout placement tool. Hovering a gutter previews a downspout at
|
||||
* the cursor's position ALONG the gutter; clicking drills a NEW outlet
|
||||
* there (appended to the gutter's `outlets`) and drops a downspout
|
||||
* linked to it. So multiple downspouts on one gutter land where you
|
||||
* click instead of stacking on a single outlet.
|
||||
*
|
||||
* The cursor's along-length offset is read by projecting the world hit
|
||||
* into the gutter's registered mesh frame (worldToLocal → local X). A
|
||||
* throwaway gutter with a single `preview` outlet feeds the same outlet
|
||||
* lookup + routing the committed pipe uses, so the ghost matches.
|
||||
*/
|
||||
const _hit = new Vector3()
|
||||
|
||||
const DownspoutTool = () => {
|
||||
const activeBuildingId = useViewer((s) => s.selection.buildingId)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
const [target, setTarget] = useState<PreviewTarget | null>(null)
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
DownspoutNode.parse({
|
||||
...downspoutDefinition.defaults(),
|
||||
name: 'Downspout',
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeBuildingId) return
|
||||
|
||||
// Cursor's offset along the gutter length, from the world hit.
|
||||
const cursorOffset = (gutter: GutterNode, world: [number, number, number]): number | null => {
|
||||
const obj = sceneRegistry.nodes.get(gutter.id as AnyNodeId)
|
||||
if (!obj) return null
|
||||
obj.updateWorldMatrix(true, false)
|
||||
return obj.worldToLocal(_hit.set(world[0], world[1], world[2])).x
|
||||
}
|
||||
|
||||
const computeTarget = (event: GutterEvent): PreviewTarget | null => {
|
||||
const gutter = event.node
|
||||
const segmentId = gutter.roofSegmentId as AnyNodeId | undefined
|
||||
if (!segmentId) return null
|
||||
const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined
|
||||
if (!segment) return null
|
||||
const offset = cursorOffset(gutter, event.position)
|
||||
if (offset === null) return null
|
||||
|
||||
// Throwaway single-outlet gutter at the cursor so the lookup +
|
||||
// routing produce the exact pose the commit will store.
|
||||
const ghost: GutterNode = {
|
||||
...gutter,
|
||||
outlets: [{ id: 'preview', offset, diameter: DEFAULT_OUTLET_DIAMETER }],
|
||||
}
|
||||
const outlet = resolveGutterOutletById(ghost, 'preview')
|
||||
if (!outlet) return null
|
||||
|
||||
return {
|
||||
segment: {
|
||||
position: (segment.position ?? [0, 0, 0]) as [number, number, number],
|
||||
rotation: segment.rotation ?? 0,
|
||||
eaveY: computeEaveY(segment),
|
||||
},
|
||||
gutter: {
|
||||
position: (gutter.position ?? [0, 0, 0]) as [number, number, number],
|
||||
rotation: gutter.rotation ?? 0,
|
||||
},
|
||||
outlet,
|
||||
routing: computeDownspoutRouting(ghost, segment, 'preview'),
|
||||
}
|
||||
}
|
||||
|
||||
const updatePreview = (event: GutterEvent) => {
|
||||
const next = computeTarget(event)
|
||||
if (next) {
|
||||
setTarget(next)
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
const onClick = (event: GutterEvent) => {
|
||||
const gutter = event.node
|
||||
const segmentId = gutter.roofSegmentId as AnyNodeId | undefined
|
||||
if (!segmentId) return
|
||||
const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined
|
||||
if (!segment) return
|
||||
const offset = cursorOffset(gutter, event.position)
|
||||
if (offset === null) return
|
||||
|
||||
// Drill a new outlet at the clicked offset, then drop a downspout
|
||||
// linked to it. Both land in one undoable step.
|
||||
const outletId = generateId('outlet')
|
||||
const outlets = [
|
||||
...(gutter.outlets ?? []),
|
||||
{ id: outletId, offset, 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)
|
||||
if (!outlet) return
|
||||
// Drop from the gutter outlet (at eaveY − size) down to segment Y = 0.
|
||||
const dropLength = Math.max(0.1, computeEaveY(segment) + outlet.y)
|
||||
|
||||
const downspout = DownspoutNode.parse({
|
||||
...downspoutDefinition.defaults(),
|
||||
name: 'Downspout',
|
||||
gutterId: gutter.id,
|
||||
outletId,
|
||||
length: dropLength,
|
||||
diameter: outlet.bore * 2,
|
||||
})
|
||||
state.createNode(downspout, segmentId)
|
||||
state.dirtyNodes.add(segmentId)
|
||||
setSelection({ selectedIds: [downspout.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
emitter.on('gutter:move', updatePreview)
|
||||
emitter.on('gutter:enter', updatePreview)
|
||||
emitter.on('gutter:click', onClick)
|
||||
|
||||
return () => {
|
||||
emitter.off('gutter:move', updatePreview)
|
||||
emitter.off('gutter:enter', updatePreview)
|
||||
emitter.off('gutter:click', onClick)
|
||||
}
|
||||
}, [activeBuildingId, setSelection])
|
||||
|
||||
if (!activeBuildingId || !target) return null
|
||||
|
||||
return (
|
||||
<group position={target.segment.position} rotation-y={target.segment.rotation}>
|
||||
<group
|
||||
position={[target.gutter.position[0], target.segment.eaveY, target.gutter.position[2]]}
|
||||
rotation-y={target.gutter.rotation}
|
||||
>
|
||||
<group position={[target.outlet.x, target.outlet.y, target.outlet.z]}>
|
||||
<DownspoutPreview
|
||||
node={previewNodeWithDefaults(previewNode, target)}
|
||||
routing={target.routing}
|
||||
/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function previewNodeWithDefaults(
|
||||
base: ReturnType<typeof DownspoutNode.parse>,
|
||||
target: PreviewTarget,
|
||||
): typeof base {
|
||||
// Snap preview to the same dimensions a commit would use — bore
|
||||
// diameter from the gutter, drop length to the segment Y=0 plane.
|
||||
return {
|
||||
...base,
|
||||
diameter: target.outlet.bore * 2,
|
||||
length: Math.max(0.1, target.segment.eaveY + target.outlet.y),
|
||||
} as typeof base
|
||||
}
|
||||
|
||||
export default DownspoutTool
|
||||
@@ -357,6 +357,7 @@ export function buildElevatorFloorplan(
|
||||
point: [cx + cornerX, cz + cornerZ],
|
||||
angle: Math.atan2(radialZ, radialX),
|
||||
affordance: 'elevator-rotate',
|
||||
pivot: [cx, cz],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { buildEyebrowVentGeometry } from '../geometry'
|
||||
import { EyebrowVentNode } from '../schema'
|
||||
|
||||
function allFinite(geo: { getAttribute: (n: string) => { array: ArrayLike<number> } }): boolean {
|
||||
const arr = geo.getAttribute('position').array
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (!Number.isFinite(arr[i])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
describe('buildEyebrowVentGeometry', () => {
|
||||
test('returns a non-empty BufferGeometry with matching attributes', () => {
|
||||
const geo = buildEyebrowVentGeometry(EyebrowVentNode.parse({}))
|
||||
const p = geo.getAttribute('position')
|
||||
expect(p.count).toBeGreaterThan(0)
|
||||
expect(geo.getAttribute('normal').count).toBe(p.count)
|
||||
expect(geo.getAttribute('uv').count).toBe(p.count)
|
||||
expect(allFinite(geo)).toBe(true)
|
||||
})
|
||||
|
||||
test('all three styles build finite geometry', () => {
|
||||
for (const style of ['scoop', 'half-round', 'slant-box'] as const) {
|
||||
const geo = buildEyebrowVentGeometry(EyebrowVentNode.parse({ style }))
|
||||
expect(geo.getAttribute('position').count).toBeGreaterThan(0)
|
||||
expect(allFinite(geo)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('louvers add vertices', () => {
|
||||
const withLouvers = buildEyebrowVentGeometry(
|
||||
EyebrowVentNode.parse({ louverCount: 4 }),
|
||||
).getAttribute('position').count
|
||||
const without = buildEyebrowVentGeometry(
|
||||
EyebrowVentNode.parse({ louverCount: 0 }),
|
||||
).getAttribute('position').count
|
||||
expect(withLouvers).toBeGreaterThan(without)
|
||||
})
|
||||
|
||||
test('extreme dimensions never go NaN', () => {
|
||||
for (const style of ['scoop', 'half-round', 'slant-box'] as const) {
|
||||
const geo = buildEyebrowVentGeometry(
|
||||
EyebrowVentNode.parse({ style, width: 0.01, depth: 5, height: 0.01 }),
|
||||
)
|
||||
expect(geo.getAttribute('position').count).toBeGreaterThan(0)
|
||||
expect(allFinite(geo)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user