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:
Sudhir Yadav
2026-06-02 10:57:37 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 96d6e0afdb
commit eb1f2d12c2
184 changed files with 17244 additions and 1416 deletions
+150 -1
View File
@@ -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',
+207
View File
@@ -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 }
}
+143 -170
View File
@@ -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(
+2 -2
View File
@@ -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'
/**
+26 -12
View File
@@ -321,18 +321,32 @@ export default function BoxVentPanel() {
</>
)}
{node.style === 'dome' && (
<SliderControl
label="Dome Curvature"
max={1.2}
min={0.3}
onChange={(v) => previewProp({ domeCurvature: v })}
onCommit={(v) => handleUpdate({ domeCurvature: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit=""
value={Math.round((node.domeCurvature ?? 0.65) * 100) / 100}
/>
<>
<SliderControl
label="Dome Curvature"
max={1.5}
min={0.3}
onChange={(v) => previewProp({ domeCurvature: v })}
onCommit={(v) => handleUpdate({ domeCurvature: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit=""
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>
+29 -27
View File
@@ -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,21 +151,18 @@ 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}
material={material}
name="box-vent-surface"
receiveShadow
{...handlers}
/>
</group>
</group>
<mesh
castShadow
geometry={geometry}
material={material}
name="box-vent-surface"
receiveShadow
{...handlers}
/>
</group>
</group>
)
+2 -2
View File
@@ -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: {
+208
View File
@@ -0,0 +1,208 @@
'use client'
import {
type BuildingNode,
emitter,
type GridEvent,
sceneRegistry,
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'
const Y_AXIS = new THREE.Vector3(0, 1, 0)
export function MoveBuildingContent({ node }: { node: BuildingNode }) {
const previousGridPosRef = useRef<[number, number] | null>(null)
// Stable refs so the effect never needs node in its dependency array
const nodeIdRef = useRef(node.id)
const originalPositionRef = useRef<[number, number, number]>([...node.position] as [
number,
number,
number,
])
const originalRotationRef = useRef<number>(node.rotation[1] ?? 0)
const pendingRotationRef = useRef<number>(node.rotation[1] ?? 0)
// Local-space offset from the building's origin to its bbox center. The
// floating drag button anchors at the bbox center, so we pin that point to
// the cursor during the drag — otherwise the raw origin (often nowhere near
// the visual center) would snap to the cursor and the building would jump.
const centerOffsetLocalRef = useRef<THREE.Vector3>(new THREE.Vector3())
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(node.id)
if (obj) {
const box = new THREE.Box3().setFromObject(obj)
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3())
const originWorld = new THREE.Vector3()
obj.getWorldPosition(originWorld)
const originalRotation = node.rotation[1] ?? 0
centerOffsetLocalRef.current = center
.clone()
.sub(originWorld)
.applyAxisAngle(Y_AXIS, -originalRotation)
return [center.x, 0, center.z]
}
const pos = new THREE.Vector3()
obj.getWorldPosition(pos)
return [pos.x, pos.y, pos.z]
}
return [node.position[0], node.position[1], node.position[2]]
})
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalPosition = originalPositionRef.current
const offsetWork = new THREE.Vector3()
const offsetAt = (rotationY: number) =>
offsetWork.copy(centerOffsetLocalRef.current).applyAxisAngle(Y_AXIS, rotationY)
useScene.temporal.getState().pause()
// Publish the building's current pose to useLiveTransforms so the
// floor-plan (and any other live consumers) can follow per-frame
// without peeking into the Three.js mesh.
const publishLive = (posX: number, posZ: number, rotY: number) => {
useLiveTransforms.getState().set(nodeId, {
position: [posX, originalPosition[1], posZ],
rotation: rotY,
})
}
let wasCommitted = false
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const ROTATION_STEP = Math.PI / 2
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
triggerSFX('sfx:item-rotate')
pendingRotationRef.current += rotationDelta
const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) {
mesh.rotation.y = pendingRotationRef.current
// Keep the bbox center pinned to the cursor through rotation.
if (previousGridPosRef.current) {
const [gridX, gridZ] = previousGridPosRef.current
const off = offsetAt(pendingRotationRef.current)
mesh.position.x = gridX - off.x
mesh.position.z = gridZ - off.z
publishLive(mesh.position.x, mesh.position.z, pendingRotationRef.current)
} else {
publishLive(mesh.position.x, mesh.position.z, pendingRotationRef.current)
}
}
}
}
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
setCursorWorldPos([gridX, 0, gridZ])
// Directly update the Three.js group — no store update during drag
const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) {
const off = offsetAt(pendingRotationRef.current)
mesh.position.x = gridX - off.x
mesh.position.z = gridZ - off.z
publishLive(mesh.position.x, mesh.position.z, pendingRotationRef.current)
}
}
const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
wasCommitted = true
const off = offsetAt(pendingRotationRef.current)
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, {
position: [gridX - off.x, originalPosition[1], gridZ - off.z],
rotation: [0, pendingRotationRef.current, 0],
})
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
// Revert mesh position and rotation immediately
const mesh = sceneRegistry.nodes.get(nodeId)
if (mesh) {
mesh.position.x = originalPosition[0]
mesh.position.z = originalPosition[2]
mesh.rotation.y = originalRotationRef.current
}
pendingRotationRef.current = originalRotationRef.current
// Restore building selection
useViewer.getState().setSelection({ buildingId: nodeId as BuildingNode['id'] })
useScene.temporal.getState().resume()
// Tell the keyboard handler we handled this, so it doesn't also clear the selection
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
return () => {
if (!wasCommitted) {
useScene.getState().updateNode(nodeId, {
position: originalPosition,
rotation: [0, originalRotationRef.current, 0],
})
}
// Drop the live transform — committed positions are now in the scene
// store, so the floor-plan should read those instead of the stale
// drag overlay.
useLiveTransforms.getState().clear(nodeId)
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [exitMoveMode]) // stable — node values captured via refs at mount
return (
<group>
<CursorSphere position={cursorWorldPos} showTooltip={false} />
</group>
)
}
export default MoveBuildingContent
+11 -1
View File
@@ -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),
}
}
+322 -1
View File
@@ -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',
+215
View File
@@ -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 }
}
+8 -7
View File
@@ -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()
+6 -6
View File
@@ -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()
+1 -1
View File
@@ -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()
+51 -47
View File
@@ -223,67 +223,71 @@ 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}
>
<mesh
castShadow
geometry={trimmedBody ?? geo.body}
material={surfaceArray}
name="chimney-body"
receiveShadow
/>
{geo.cap && (
<group
position={[node.position[0] ?? 0, 0, node.position[2] ?? 0]}
ref={ref}
rotation-y={node.rotation ?? 0}
>
<mesh
castShadow
geometry={geo.cap}
geometry={trimmedBody ?? geo.body}
material={surfaceArray}
name="chimney-cap"
name="chimney-body"
receiveShadow
/>
)}
{geo.flues && (
<mesh
castShadow
geometry={geo.flues}
material={surfaceArray}
name="chimney-flues"
receiveShadow
/>
)}
{geo.cricket && (
<mesh
castShadow
geometry={geo.cricket}
material={surfaceMaterial}
name="chimney-cricket"
receiveShadow
/>
)}
{geo.bands && (
<mesh
castShadow
geometry={geo.bands}
material={surfaceMaterial}
name="chimney-bands"
receiveShadow
/>
)}
{geo.cap && (
<mesh
castShadow
geometry={geo.cap}
material={surfaceArray}
name="chimney-cap"
receiveShadow
/>
)}
{geo.flues && (
<mesh
castShadow
geometry={geo.flues}
material={surfaceArray}
name="chimney-flues"
receiveShadow
/>
)}
{geo.cricket && (
<mesh
castShadow
geometry={geo.cricket}
material={surfaceMaterial}
name="chimney-cricket"
receiveShadow
/>
)}
{geo.bands && (
<mesh
castShadow
geometry={geo.bands}
material={surfaceMaterial}
name="chimney-bands"
receiveShadow
/>
)}
</group>
</group>
)
}
+18 -4
View File
@@ -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)
+1 -1
View File
@@ -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'
+1
View File
@@ -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)
})
})
+165
View File
@@ -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.',
},
}
+111
View File
@@ -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 }
}
+354
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
export { cupolaDefinition } from './definition'
export { buildCupolaGeometry } from './geometry'
export { CupolaNode } from './schema'
+203
View File
@@ -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>
)
}
+288
View File
@@ -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>
)
}
+33
View File
@@ -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 },
],
},
],
}
+63
View File
@@ -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
+109
View File
@@ -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
+3
View File
@@ -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'
+130
View File
@@ -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
+44 -19
View File
@@ -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,
}
}
+368 -1
View File
@@ -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',
+213
View File
@@ -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 }
}
+24 -17
View File
@@ -152,29 +152,36 @@ 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]}>
<group rotation-y={node.rotation ?? 0} {...handlers}>
<mesh
castShadow
geometry={geometry}
material={material}
name="dormer-body"
receiveShadow
/>
<DormerWindowAssembly
frameMaterial={frameSideMat}
glassMaterial={glassMat}
node={node}
segment={segment}
/>
</group>
<group
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
ref={ref}
rotation-y={node.rotation ?? 0}
>
<mesh
castShadow
geometry={geometry}
material={material}
name="dormer-body"
receiveShadow
/>
<DormerWindowAssembly
frameMaterial={frameSideMat}
glassMaterial={glassMat}
node={node}
segment={segment}
/>
</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()
+28 -5
View File
@@ -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')}
</>
)
}
+209
View File
@@ -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.',
},
}
+241
View File
@@ -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
}
+3
View File
@@ -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 },
],
},
],
}
+77
View File
@@ -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
+189
View File
@@ -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
+207
View File
@@ -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,
}
}
+3
View File
@@ -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'
+197
View File
@@ -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
+1
View File
@@ -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)
}
})
})
@@ -0,0 +1,172 @@
import {
EyebrowVentNode as EyebrowVentNodeSchema,
type EyebrowVentNode as EyebrowVentNodeType,
type HandleDescriptor,
type NodeDefinition,
} from '@pascal-app/core'
import { surfacePaintCapability } from '../shared/surface-paint'
import { buildEyebrowVentFloorplan } from './floorplan'
import { eyebrowVentParametrics } from './parametrics'
import { EyebrowVentNode } from './schema'
const SIDE_HANDLE_OFFSET = 0.3
const HEIGHT_HANDLE_OFFSET = 0.2
// Snug to the vent corner so the rotate icon stays close to the item.
const ROTATE_CORNER_OFFSET = 0.12
const MIN_WIDTH = 0.4
const MIN_DEPTH = 0.2
const MIN_HEIGHT = 0.08
// Mid-Y of the arch (its tallest point is `height`), used to seat the side /
// rotate chevrons against the body and centre the rotate ring.
function getBodyMidY(n: EyebrowVentNodeType): number {
return Math.max(0.001, n.height) / 2
}
// Width (span) and depth grow symmetrically from the centre — eyebrow vents
// are placed by their centre — so a single centred chevron per axis.
function eyebrowWidthHandle(): HandleDescriptor<EyebrowVentNodeType> {
return {
kind: 'linear-resize',
axis: 'x',
anchor: 'center',
min: MIN_WIDTH,
currentValue: (n) => n.width,
apply: (_n, newValue) => ({ width: Math.max(MIN_WIDTH, newValue) }),
placement: {
position: (n) => [n.width / 2 + SIDE_HANDLE_OFFSET, getBodyMidY(n), 0],
},
}
}
function eyebrowDepthHandle(): HandleDescriptor<EyebrowVentNodeType> {
return {
kind: 'linear-resize',
axis: 'z',
anchor: 'center',
min: MIN_DEPTH,
currentValue: (n) => n.depth,
apply: (_n, newValue) => ({ depth: Math.max(MIN_DEPTH, newValue) }),
placement: {
position: (n) => [0, getBodyMidY(n), n.depth / 2 + SIDE_HANDLE_OFFSET],
},
}
}
function eyebrowHeightHandle(): HandleDescriptor<EyebrowVentNodeType> {
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 eyebrowRotateHandle(): HandleDescriptor<EyebrowVentNodeType> {
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 vent, sized to pass through the corner icon so
// the icon rides the ring — matches cupola / 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 — see box-vent's note. The vent
// rides the roof→segment→node frame chain, so the handle rig must too, or the
// handles (and rotate arc) render offset from the vent.
const eyebrowVentHandles: HandleDescriptor<EyebrowVentNodeType>[] = [
eyebrowWidthHandle(),
eyebrowDepthHandle(),
eyebrowHeightHandle(),
eyebrowRotateHandle(),
].map((h): HandleDescriptor<EyebrowVentNodeType> => ({ ...h, portal: 'grandparent' }))
/**
* Eyebrow vent — a low, curved lens-shaped hood with a louvered front that
* sweeps out of a roof slope. 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 / cupola (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 eyebrowVentDefinition: NodeDefinition<typeof EyebrowVentNode> = {
kind: 'eyebrow-vent',
schemaVersion: 1,
schema: EyebrowVentNode,
category: 'structure',
surfaceRole: 'roof',
defaults: () => {
const stub = EyebrowVentNodeSchema.parse({
id: 'eyebrow-vent_default' as never,
type: 'eyebrow-vent',
})
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 vent moves / resizes.
roofAccessory: {},
},
parametrics: eyebrowVentParametrics,
handles: eyebrowVentHandles,
floorplan: buildEyebrowVentFloorplan,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
preview: () => import('./preview'),
tool: () => import('./tool'),
affordanceTools: {
move: () => import('./move-tool'),
},
toolHints: [
{ key: 'Left click', label: 'Place eyebrow vent on roof' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Eyebrow Vent',
description: 'Low curved lens-shaped roof vent with a louvered front.',
icon: { kind: 'url', src: '/icons/roof.png' },
paletteSection: 'structure',
paletteOrder: 123,
},
mcp: {
description:
'A low curved eyebrow vent on a roof segment — a lens-shaped hood with an optional louvered front. Parametric width (span) / depth / height.',
},
}
@@ -0,0 +1,108 @@
import type {
AnyNodeId,
EyebrowVentNode,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
RoofNode,
RoofSegmentNode,
} from '@pascal-app/core'
/**
* Floor-plan builder for an eyebrow vent — seen from above it reads as the
* lens (eye-shaped) footprint of the hood, with a faint centre seam. The
* coordinate frame mirrors the 3D transform stack (roof → roof-segment →
* vent), same as the box-vent / cupola builders.
*/
export function buildEyebrowVentFloorplan(
node: EyebrowVentNode,
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 rect = (halfX: number, halfZ: number): FloorplanPoint[] => [
toPlan(-halfX, -halfZ),
toPlan(halfX, -halfZ),
toPlan(halfX, halfZ),
toPlan(-halfX, halfZ),
]
const children: FloorplanGeometry[] = []
// Hood footprint outline + hit target (no flashing plate).
const hood = rect(hw, hd)
children.push({
kind: 'polygon',
points: hood,
fill: stroke,
fillOpacity: 0,
stroke: 'none',
strokeWidth: 0,
pointerEvents: 'all',
})
children.push({
kind: 'polygon',
points: hood,
fill,
fillOpacity,
stroke,
strokeWidth: lineWidth,
strokeLinejoin: 'round',
pointerEvents: 'none',
})
// Front edge (the louvered opening faces +Z).
children.push({
kind: 'polyline',
points: [toPlan(-hw, hd), toPlan(hw, hd)],
stroke,
strokeWidth: lineWidth,
strokeOpacity: 0.9,
pointerEvents: 'none',
})
return { kind: 'group', children }
}
+475
View File
@@ -0,0 +1,475 @@
import type { EyebrowVentNode } from '@pascal-app/core'
import * as THREE from 'three'
/**
* Pure builder for the eyebrow-vent mesh. Three styles, all seated directly on
* the roof at y=0 and facing +Z (downslope):
*
* - `scoop` — a rounded louvered opening at the front that sweeps back
* and tapers to nothing (the classic dormer "eyebrow"). Front
* = a half-ellipse of horizontal louvers; the body is a lofted
* half-cone with a rounded nose.
* - `half-round` — a D-shaped half-round vent: a constant half-ellipse cross
* section extruded a short depth, flat louvered front face,
* capped back, curved top.
* - `slant-box` — a low box with a slanted top (tall front, lower back) and a
* framed front face holding recessed louvers + a screen.
*
* Every face is emitted through a winding-safe oriented quad/tri, the louvers
* are extruded into solid slabs, and the whole mesh is double-sided at the end
* (see `doubleSide`) so it reads correctly from any angle.
*
* Pure: no React, no scene access, no store mutation. Safe for unit tests, the
* placement preview, and the move-tool ghost.
*/
export function buildEyebrowVentGeometry(node: EyebrowVentNode): THREE.BufferGeometry {
const w = Math.max(0.15, node.width)
const d = Math.max(0.15, node.depth)
const h = Math.max(0.06, node.height)
const slats = Math.max(0, Math.min(8, Math.round(node.louverCount ?? 3)))
// slant-box: the low rear edge as a fraction of the tall front edge.
const backRatio = Math.max(0.15, Math.min(1, node.backRatio ?? 0.5))
const p: number[] = []
const n: number[] = []
const uv: number[] = []
// The hood seats directly on the roof at y=0 — no flashing plate.
if (node.style === 'half-round') {
addHalfRound(p, n, uv, w, d, h, 0, slats)
} else if (node.style === 'slant-box') {
addSlantBox(p, n, uv, w, d, h, 0, slats, backRatio)
} else {
addScoop(p, n, uv, w, d, h, 0, slats)
}
// Double-side the whole mesh at the geometry level: append a back-facing
// copy of every triangle (reversed winding + negated normals). The open
// shells (scoop hood, half-round dome, slant-box pocket) then read as solid
// from inside too, lit correctly from both sides — without a `DoubleSide`
// material, which poisons the MRT scene pass (see the ridge-vent renderer
// note). Only one of each coplanar pair front-faces any camera, so there's
// no z-fighting.
doubleSide(p, n, uv)
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
}
// ─── Style: scoop (the eyebrow) ───────────────────────────────────────────
function addScoop(
p: number[],
n: number[],
uv: number[],
w: number,
d: number,
h: number,
yB: number,
slats: number,
): void {
const a = w / 2
const b = h
const zF = d / 2
const NZ = 20
const NF = 18
// Lofted half-ellipse, full at the front (z = zF) tapering to a rounded
// nose at the back. `cos(v·π/2)` gives a smooth falloff to zero.
const rings: number[][][] = []
for (let i = 0; i <= NZ; i++) {
const v = i / NZ
const scale = Math.cos((v * Math.PI) / 2)
const z = zF - v * d
rings.push(halfRing(a, b, yB, z, scale, NF))
}
for (let i = 0; i < NZ; i++) {
addBand(p, n, uv, rings[i + 1]!, rings[i]!, NF, (qa, qb, qc, qd) => {
const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4
const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4
return [mx, my - yB, 0] // radial-out from the spine
})
}
// Horizontal louvers filling the front half-ellipse opening.
addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats)
}
// ─── Style: half-round (D-shaped louver vent) ─────────────────────────────
function addHalfRound(
p: number[],
n: number[],
uv: number[],
w: number,
d: number,
h: number,
yB: number,
slats: number,
): void {
const a = w / 2
// Cap the crown at a true half-round — never bulge past a semicircle, so the
// top reads as a clean, smaller-radius arch. `height` flattens it further.
const b = Math.min(h, a)
const zF = d / 2
const zB = -d / 2
const NF = 20
const ringF = halfRing(a, b, yB, zF, 1, NF)
const ringB = halfRing(a, b, yB, zB, 1, NF)
// Curved top shell (constant cross section).
addBand(p, n, uv, ringB, ringF, NF, (qa, qb, qc, qd) => {
const mx = (qa[0]! + qb[0]! + qc[0]! + qd[0]!) / 4
const my = (qa[1]! + qb[1]! + qc[1]! + qd[1]!) / 4
return [mx, my - yB, 0]
})
// Back cap — fan the rear semicircle, facing -Z.
const backCenter = [0, yB, zB]
for (let j = 0; j < NF; j++) {
pushTri(p, n, uv, backCenter, ringB[j + 1]!, ringB[j]!, [0, 0, -1])
}
// Louvered front face (a slat count bumped up — the D-vent reads denser).
addArchLouvers(p, n, uv, a, b, yB, zF - d * 0.04, slats > 0 ? Math.max(slats, 4) : 0)
}
// ─── Style: slant-box (low hooded box) ────────────────────────────────────
function addSlantBox(
p: number[],
n: number[],
uv: number[],
w: number,
d: number,
h: number,
yB: number,
slats: number,
backRatio: number,
): void {
const hw = w / 2
const zF = d / 2
const zB = -d / 2
const yFront = yB + h // tall front — the louvered/screened opening
const yBack = yB + h * backRatio // lower at the back, top slopes down to it
// Corner shorthands.
const fbl = [-hw, yB, zF]
const fbr = [hw, yB, zF]
const ftl = [-hw, yFront, zF]
const ftr = [hw, yFront, zF]
const bbl = [-hw, yB, zB]
const bbr = [hw, yB, zB]
const btl = [-hw, yBack, zB]
const btr = [hw, yBack, zB]
// Sides (trapezoids), slanted top, back wall.
pushQuad(p, n, uv, fbr, bbr, btr, ftr, [1, 0, 0])
pushQuad(p, n, uv, bbl, fbl, ftl, btl, [-1, 0, 0])
pushQuad(p, n, uv, ftl, ftr, btr, btl, [0, 1, 0])
pushQuad(p, n, uv, bbl, bbr, btr, btl, [0, 0, -1])
// Front frame: a face plate at z = zF with a rectangular hole. The louvers
// and screen live RECESSED inside that hole, so they're fully contained by
// the box — never poking out past the front face or above the opening.
const frame = Math.min(0.04, Math.min(w, h) * 0.14)
const oL = -hw + frame
const oR = hw - frame
const oB = yB + frame
const oT = yFront - frame
// Four frame rails around the opening (front face, +Z).
pushQuad(p, n, uv, [-hw, oT, zF], [hw, oT, zF], ftr, ftl, [0, 0, 1]) // top
pushQuad(p, n, uv, fbl, fbr, [hw, oB, zF], [-hw, oB, zF], [0, 0, 1]) // bottom
pushQuad(p, n, uv, [-hw, oB, zF], [oL, oB, zF], [oL, oT, zF], [-hw, oT, zF], [0, 0, 1]) // left
pushQuad(p, n, uv, [oR, oB, zF], [hw, oB, zF], [hw, oT, zF], [oR, oT, zF], [0, 0, 1]) // right
// Recessed screen panel at the back of the pocket (blocks see-through).
const screenZ = zF - d * 0.2
pushQuad(
p,
n,
uv,
[oL, oB, screenZ],
[oR, oB, screenZ],
[oR, oT, screenZ],
[oL, oT, screenZ],
[0, 0, 1],
)
// Horizontal louvers inside the pocket — bounded by the opening in height
// and recessed in depth between the frame face and the screen.
addRectLouvers(p, n, uv, oR, oB, oT, zF - d * 0.07, slats)
}
// ─── Louver helpers ───────────────────────────────────────────────────────
// Angled horizontal slats filling a half-ellipse opening (radius a × b,
// flat side on the plate at `yB`), set just inside the face at `z`.
function addArchLouvers(
p: number[],
n: number[],
uv: number[],
a: number,
b: number,
yB: number,
z: number,
slats: number,
): void {
if (slats <= 0) return
const drop = (b / (slats + 1)) * 0.55
const back = Math.max(0.012, b * 0.12)
const thick = Math.max(0.004, drop * 0.32)
for (let k = 1; k <= slats; k++) {
const s = k / (slats + 1) // height fraction up the semicircle (sin φ)
const y = yB + b * s
const halfX = a * Math.sqrt(Math.max(0, 1 - s * s)) * 0.96
if (halfX < 1e-3) continue
addSlab(
p,
n,
uv,
[-halfX, y, z],
[halfX, y, z],
[halfX, y - drop, z - back],
[-halfX, y - drop, z - back],
thick,
)
}
}
// Angled horizontal slats across a rectangular opening (±halfX, yB..yTop) set
// just inside the face at `z`.
function addRectLouvers(
p: number[],
n: number[],
uv: number[],
halfX: number,
yB: number,
yTop: number,
z: number,
slats: number,
): void {
if (slats <= 0) return
const span = yTop - yB
const drop = (span / (slats + 1)) * 0.55
const back = Math.max(0.012, span * 0.18)
const thick = Math.max(0.004, drop * 0.32)
for (let k = 1; k <= slats; k++) {
const y = yB + (span * k) / (slats + 1)
addSlab(
p,
n,
uv,
[-halfX, y, z],
[halfX, y, z],
[halfX, y - drop, z - back],
[-halfX, y - drop, z - back],
thick,
)
}
}
// Extrude a planar quad (a→b→c→d) into a thin solid slab of `t` thickness
// along its normal — gives louver blades real depth so they don't read as
// paper-thin at a grazing angle. Each of the 6 faces is oriented outward from
// the slab centre, so winding is correct without hand-tracing.
function addSlab(
p: number[],
n: number[],
uv: number[],
a: number[],
b: number[],
c: number[],
d: number[],
t: number,
): void {
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 len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1
nx = (nx / len) * (t / 2)
ny = (ny / len) * (t / 2)
nz = (nz / len) * (t / 2)
const up = (q: number[]): number[] => [q[0]! + nx, q[1]! + ny, q[2]! + nz]
const dn = (q: number[]): number[] => [q[0]! - nx, q[1]! - ny, q[2]! - nz]
const aT = up(a)
const bT = up(b)
const cT = up(c)
const dT = up(d)
const aB = dn(a)
const bB = dn(b)
const cB = dn(c)
const dB = dn(d)
let cx = 0
let cy = 0
let cz = 0
for (const v of [aT, bT, cT, dT, aB, bB, cB, dB]) {
cx += v[0]! / 8
cy += v[1]! / 8
cz += v[2]! / 8
}
const face = (q0: number[], q1: number[], q2: number[], q3: number[]) => {
const mx = (q0[0]! + q1[0]! + q2[0]! + q3[0]!) / 4
const my = (q0[1]! + q1[1]! + q2[1]! + q3[1]!) / 4
const mz = (q0[2]! + q1[2]! + q2[2]! + q3[2]!) / 4
pushQuad(p, n, uv, q0, q1, q2, q3, [mx - cx, my - cy, mz - cz])
}
face(aT, bT, cT, dT) // top
face(aB, bB, cB, dB) // bottom
face(aT, bT, bB, aB) // leading edge
face(bT, cT, cB, bB) // right
face(cT, dT, dB, cB) // trailing edge
face(dT, aT, aB, dB) // left
}
// Half-ellipse ring (flat side down on `yB`): φ from 0 (+x) to π (x).
function halfRing(
a: number,
b: number,
yB: number,
z: number,
scale: number,
steps: number,
): number[][] {
const row: number[][] = []
for (let j = 0; j <= steps; j++) {
const phi = (Math.PI * j) / steps
row.push([a * scale * Math.cos(phi), yB + b * scale * Math.sin(phi), z])
}
return row
}
// ─── Primitives ───────────────────────────────────────────────────────────
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))
}
}
// Append a reversed-winding, negated-normal copy of every triangle already in
// the buffers, making the mesh render from both sides under a FrontSide
// material. `triCount` is snapshotted up front so we only mirror the originals.
function doubleSide(p: number[], n: number[], uv: number[]): void {
const triCount = Math.floor(p.length / 9)
for (let t = 0; t < triCount; t++) {
const o = t * 9
const u = t * 6
// verts v0, v2, v1 (reverse the last two to flip the face).
p.push(
p[o]!,
p[o + 1]!,
p[o + 2]!,
p[o + 6]!,
p[o + 7]!,
p[o + 8]!,
p[o + 3]!,
p[o + 4]!,
p[o + 5]!,
)
n.push(
-n[o]!,
-n[o + 1]!,
-n[o + 2]!,
-n[o + 6]!,
-n[o + 7]!,
-n[o + 8]!,
-n[o + 3]!,
-n[o + 4]!,
-n[o + 5]!,
)
uv.push(uv[u]!, uv[u + 1]!, uv[u + 4]!, uv[u + 5]!, uv[u + 2]!, uv[u + 3]!)
}
}
// ─── 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)
}
+3
View File
@@ -0,0 +1,3 @@
export { eyebrowVentDefinition } from './definition'
export { buildEyebrowVentGeometry } from './geometry'
export { EyebrowVentNode } from './schema'
@@ -0,0 +1,204 @@
'use client'
import {
type AnyNodeId,
type EyebrowVentNode,
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 EyebrowVentPreview from './preview'
/**
* Eyebrow-vent move tool. Mirrors the box-vent / cupola 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
* vent.
*/
export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode }) {
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 ventObj = sceneRegistry.nodes.get(node.id)
if (ventObj) ventObj.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}>
<EyebrowVentPreview node={node} />
</group>
</group>
</group>
)
}
+307
View File
@@ -0,0 +1,307 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
EyebrowVentNode as EyebrowVentSchema,
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 { EyebrowVentNode } from './schema'
/**
* Inspector panel for a placed eyebrow vent. Louvers toggle + dimensions plus
* Move / Duplicate / Delete wired into the same ghost-preview drag flow the
* placement tool uses. Mirrors the box-vent / cupola panel.
*/
export default function EyebrowVentPanel() {
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 EyebrowVentNode | undefined) : undefined,
)
const overrides = useLiveNodeOverrides((s) =>
selectedId
? (s.get(selectedId as AnyNodeId) as Partial<EyebrowVentNode> | undefined)
: undefined,
)
const node: EyebrowVentNode | undefined =
storeNode && overrides ? ({ ...storeNode, ...overrides } as EyebrowVentNode) : storeNode
const segment = useScene((s) =>
node?.roofSegmentId
? (s.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
const previewProp = useCallback(
(updates: Partial<EyebrowVentNode>) => {
if (!selectedId) return
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates)
},
[selectedId],
)
const commitProp = useCallback(
(updates: Partial<EyebrowVentNode>) => {
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 = EyebrowVentSchema.parse(cloneInput) as EyebrowVentNode
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 === 'eyebrow-vent' && selectedId)) return null
return (
<PanelWrapper
icon="/icons/roof.png"
onBack={node.roofSegmentId ? handleBack : undefined}
onClose={handleClose}
title={node.name || 'Eyebrow Vent'}
width={300}
>
<PanelSection title="Style">
<SegmentedControl
onChange={(v) => handleUpdate({ style: v as EyebrowVentNode['style'] })}
options={[
{ label: 'Scoop', value: 'scoop' },
{ label: 'Half-round', value: 'half-round' },
{ label: 'Slant-box', value: 'slant-box' },
]}
value={node.style ?? 'scoop'}
/>
<SliderControl
label="Louvers"
max={8}
min={0}
onChange={(v) => previewProp({ louverCount: Math.round(v) })}
onCommit={(v) => handleUpdate({ louverCount: Math.round(v) })}
precision={0}
restoreOnCommit={false}
step={1}
value={node.louverCount ?? 3}
/>
{node.style === 'slant-box' ? (
<SliderControl
label="Back height"
max={1}
min={0.15}
onChange={(v) => previewProp({ backRatio: v })}
onCommit={(v) => handleUpdate({ backRatio: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
value={Math.round((node.backRatio ?? 0.5) * 100) / 100}
/>
) : null}
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Width"
max={3}
min={0.4}
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={1.5}
min={0.2}
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={1}
min={0.08}
onChange={(v) => previewProp({ height: v })}
onCommit={(v) => handleUpdate({ height: v })}
precision={2}
restoreOnCommit={false}
step={0.02}
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,34 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { EyebrowVentNode } from './schema'
/**
* Inspector descriptor for the eyebrow vent. 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 / cupola.
*/
export const eyebrowVentParametrics: ParametricDescriptor<EyebrowVentNode> = {
customPanel: () => import('./panel'),
groups: [
{
label: 'Style',
fields: [
{
key: 'style',
kind: 'enum',
options: ['scoop', 'half-round', 'slant-box'],
display: 'segmented',
},
{ key: 'louverCount', kind: 'number', min: 0, max: 8, step: 1 },
{ key: 'backRatio', kind: 'number', min: 0.15, max: 1, step: 0.05 },
],
},
{
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.2, max: 1.5, step: 0.05 },
{ key: 'height', kind: 'number', unit: 'm', min: 0.08, max: 1, step: 0.02 },
],
},
],
}
@@ -0,0 +1,63 @@
'use client'
import { useEffect, useMemo } from 'react'
import * as THREE from 'three'
import { buildEyebrowVentGeometry } from './geometry'
import type { EyebrowVentNode } from './schema'
/**
* Translucent ghost of an eyebrow vent, 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 vent. Raycast disabled so
* the preview doesn't intercept the cursor ray feeding the tool.
*/
const EyebrowVentPreview = ({ node }: { node: EyebrowVentNode }) => {
const geometry = useMemo(
() => buildEyebrowVentGeometry(node),
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
)
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 EyebrowVentPreview
@@ -0,0 +1,111 @@
'use client'
import {
type AnyNodeId,
type EyebrowVentNode,
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 { buildEyebrowVentGeometry } from './geometry'
const defaultMaterial = new THREE.MeshStandardMaterial({
color: 0xff_ff_ff,
roughness: 0.7,
metalness: 0.2,
})
/**
* Eyebrow-vent renderer. Same transform stack as the box vent / cupola — the
* vent is parented to a roof-segment, so this reads the segment directly and
* reproduces the segment-local transform (segment position → rotation → vent
* position → slope tilt → vent yaw → mesh). No animation.
*/
const EyebrowVentRenderer = ({ node: storeNode }: { node: EyebrowVentNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'eyebrow-vent', ref)
const handlers = useNodeEvents(storeNode, 'eyebrow-vent')
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
const overrides = useLiveNodeOverrides(
(s) => s.get(storeNode.id as AnyNodeId) as Partial<EyebrowVentNode> | undefined,
)
const node: EyebrowVentNode = overrides
? ({ ...storeNode, ...overrides } as EyebrowVentNode)
: storeNode
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
const geometry = useMemo(
() => buildEyebrowVentGeometry(node),
[node.width, node.depth, node.height, node.style, node.louverCount, node.backRatio],
)
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="eyebrow-vent-surface"
receiveShadow
{...handlers}
/>
</group>
</group>
)
}
export default EyebrowVentRenderer
@@ -0,0 +1,3 @@
// Schema lives in core (referenced by the AnyNode union). Re-export so every
// eyebrow-vent-related import stays inside @pascal-app/nodes/eyebrow-vent.
export { EyebrowVentNode } from '@pascal-app/core'
+131
View File
@@ -0,0 +1,131 @@
'use client'
import {
type AnyNodeId,
EyebrowVentNode,
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 { eyebrowVentDefinition } from './definition'
import EyebrowVentPreview from './preview'
const worldPoint = new THREE.Vector3()
/**
* Eyebrow-vent placement tool. Mounts when the palette activates the
* eyebrow-vent kind; listens for `roof:*` events; on click commits a new
* `EyebrowVentNode` parented to the targeted segment with segment-local
* coordinates. Mirrors box-vent / cupola.
*/
const EyebrowVentTool = () => {
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(
() =>
EyebrowVentNode.parse({
...eyebrowVentDefinition.defaults(),
name: 'Eyebrow Vent',
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 vent = EyebrowVentNode.parse({
...eyebrowVentDefinition.defaults(),
name: 'Eyebrow Vent',
roofSegmentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ],
rotation: 0,
})
state.createNode(vent, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [vent.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}>
<EyebrowVentPreview node={previewNode} />
</group>
</group>
</group>
)
}
export default EyebrowVentTool
+3
View File
@@ -71,6 +71,9 @@ function fenceHeightHandle(): HandleDescriptor<FenceNodeType> {
axis: 'y',
anchor: 'min',
min: MIN_FENCE_HEIGHT,
// Drives the floating dimension pill (H · L · T) and suppresses the
// arrow's own inline chip, matching the wall height handle.
measureLabel: 'height',
currentValue: (n) => n.height ?? 1.8,
apply: (_n, newHeight) => ({ height: newHeight }),
placement: {
@@ -1,12 +1,13 @@
'use client'
import { type FenceNode, useScene, type WallNode } from '@pascal-app/core'
import { type FenceNode, getWallCurveLength, useScene, type WallNode } from '@pascal-app/core'
import {
CursorSphere,
type FencePlanPoint,
formatAngleRadians,
getAngleToSegmentReference,
getSegmentAngleReferenceAtPoint,
MeasurementPill,
type MovingFenceEndpoint,
triggerSFX,
useDragAction,
@@ -90,6 +91,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
: [target.fence.end[0], target.fence.end[1]]
const [altPressed, setAltPressed] = useState(false)
const unit = useViewer((s) => s.unit)
const exitMoveMode = (committed: boolean) => {
if (committed) triggerSFX('sfx:item-place')
@@ -174,9 +176,34 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
const cursorPos: [number, number, number] = [movingPoint[0], 0, movingPoint[1]]
// Live segment dimensions for the floating pill. Length tracks the drag;
// height + thickness are static during an endpoint move.
const liveLength = getWallCurveLength({
start: liveStart,
end: liveEnd,
curveOffset: liveFence?.curveOffset ?? target.fence.curveOffset,
})
const fenceHeight = target.fence.height ?? 1.8
const dimMidX = (liveStart[0] + liveEnd[0]) / 2
const dimMidZ = (liveStart[1] + liveEnd[1]) / 2
return (
<group>
<CursorSphere position={cursorPos} showTooltip={false} />
<Html
center
position={[dimMidX, fenceHeight + 0.3, dimMidZ]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<MeasurementPill
height={fenceHeight}
length={liveLength}
primary="length"
thickness={target.fence.thickness ?? 0.08}
unit={unit}
/>
</Html>
<Html
position={cursorPos}
style={{ pointerEvents: 'none', touchAction: 'none' }}
+264
View File
@@ -0,0 +1,264 @@
import type { GutterNode, RoofSegmentNode } from '@pascal-app/core'
/**
* Auto-mitre detector for two gutters meeting at a roof corner.
*
* When two gutters' endpoints land within `CORNER_EPSILON` of each
* other in plan (ROOF-local X/Z — see `planDistSq`), the renderer
* treats them as a single L-junction and skews each end so the back
* walls meet at the inner corner while the front rims extend outward to
* a clean mitre.
*
* Why "back wall stays at the corner": the gutter mounts against the
* fascia (gutter-local +X is the length, +Z is outward over the eave).
* Two perpendicular fascias meet at the eave corner — that's the
* fixed point. The rims hang in space past the building, so they're
* the parts that need to extend to actually touch each other.
*
* For 90° (typical hip / rectangular plan) corners the mitre is 45°
* each side; arbitrary angles use the standard mitre formula
* `(π interior) / 2`. Aligned gutters (interior ≈ π) → mitre 0 → no
* displacement, no cap suppression — they read as a straight run.
*
* Cross-segment: endpoints + length axes are lifted into the shared
* ROOF frame (each gutter's segment position + Y-rotation applied), so
* gutters on DIFFERENT roof segments (L-shaped plans, additions) mitre
* at the corner where their segments meet — not just gutters sharing a
* segment. Same-segment corners fall out of the same path (both gutters
* carry the same segment transform). The skew assumes a convex (outer)
* corner; a concave inner corner mitres approximately.
*/
export type GutterMitres = {
/**
* SIGNED mitre angle (radians) at the gutter's X end; 0 = no mitre.
* Positive = CONVEX (outer) corner — the front rim EXTENDS past the
* end to reach the outer eave intersection. Negative = CONCAVE (inner)
* corner — the rim RETRACTS to the inner intersection. The geometry
* builder feeds the value straight through `Math.tan`, so the sign
* flips the skew direction; `=== 0` still means "no mitre / keep cap".
*/
left: number
/** Signed mitre angle (radians) at the gutter's +X end; see `left`. */
right: number
}
export const NO_MITRES: GutterMitres = { left: 0, right: 0 }
// Match the length-snap's 10 cm catch radius (`length-snap.ts`): any two
// endpoints close enough for the corner snap to bind are close enough to
// read as "they meant to meet". The corner snap pulls them to the exact
// eave intersection (≈ 0 cm), so this is mostly slack for eyeballed /
// move-tool corners that never went through the length handle.
const CORNER_EPSILON = 0.1
export const CORNER_EPSILON_SQ = CORNER_EPSILON * CORNER_EPSILON
// Mitres beyond this are unphysical (an acute outer corner past 30°
// interior angle isn't a building corner, it's a CSG artefact). Capping
// keeps a misplaced gutter from producing a runaway skew that swallows
// the rest of the trough.
const MAX_MITRE = (75 * Math.PI) / 180
// Cross product below this magnitude counts as parallel length-axes — a
// straight collinear run, no corner. Matches `length-snap.ts`.
const AXIS_PARALLEL_EPSILON = 1e-3
/** A sibling gutter paired with the segment it sits on, for the frame lift. */
export type GutterWithSegment = {
gutter: GutterNode
segment: Pick<RoofSegmentNode, 'position' | 'rotation'>
}
export type Endpoint = {
pos: readonly [number, number, number]
/** Length-axis direction in segment frame, pointing from this end toward the other end. */
awayDir: readonly [number, number]
/** Outward normal (gutter-local +Z, "away from the building") in this frame. */
outDir: readonly [number, number]
}
function gutterEndpoints(g: GutterNode): { plus: Endpoint; minus: Endpoint } {
const [px, py, pz] = g.position
const r = g.rotation ?? 0
// Gutter-local +X (length axis) rotated by `r` around Y. THREE's
// rotation-y convention: local (1, 0, 0) → (cos r, 0, sin r).
const dirX = Math.cos(r)
const dirZ = -Math.sin(r)
// Gutter-local +Z (outward) rotated by `r`: (0, 0, 1) → (sin r, 0, cos r).
const outX = Math.sin(r)
const outZ = Math.cos(r)
const half = g.length / 2
return {
plus: {
pos: [px + dirX * half, py, pz + dirZ * half],
// From the +X endpoint, the rest of the gutter extends back
// toward the X end — so "away from this end" is dir.
awayDir: [-dirX, -dirZ],
outDir: [outX, outZ],
},
minus: {
pos: [px - dirX * half, py, pz - dirZ * half],
awayDir: [dirX, dirZ],
outDir: [outX, outZ],
},
}
}
/**
* Gutter endpoints lifted from segment-local into the shared ROOF frame
* by applying the host segment's position + Y-rotation. Two gutters on
* different segments can then be compared in one frame. THREE's
* rotation-y convention: a point/dir (x, z) rotates to
* (x·cos + z·sin, x·sin + z·cos).
*/
export function gutterEndpointsInFrame(
g: GutterNode,
segment: Pick<RoofSegmentNode, 'position' | 'rotation'>,
): { plus: Endpoint; minus: Endpoint } {
const local = gutterEndpoints(g)
const sx = segment.position?.[0] ?? 0
const sz = segment.position?.[2] ?? 0
const sr = segment.rotation ?? 0
const c = Math.cos(sr)
const s = Math.sin(sr)
const liftPos = (p: readonly [number, number, number]): [number, number, number] => [
sx + (p[0] * c + p[2] * s),
p[1],
sz + (-p[0] * s + p[2] * c),
]
const liftDir = (d: readonly [number, number]): [number, number] => [
d[0] * c + d[1] * s,
-d[0] * s + d[1] * c,
]
return {
plus: {
pos: liftPos(local.plus.pos),
awayDir: liftDir(local.plus.awayDir),
outDir: liftDir(local.plus.outDir),
},
minus: {
pos: liftPos(local.minus.pos),
awayDir: liftDir(local.minus.awayDir),
outDir: liftDir(local.minus.outDir),
},
}
}
// Plan-space (X/Z) distance only — deliberately ignores Y. Gutters are
// pinned to the eave line so they're coplanar in eave-Y, AND the
// renderer draws them at the LIVE `computeEaveY(segment)`, not the
// stored `position[1]` (which goes stale if the segment's wallHeight /
// pitch changed after placement). Folding Y in would reject a real
// corner whenever two gutters' stored Ys drifted apart even though they
// visibly meet. The length-snap that feeds this also works purely in
// plan, so the match must too.
export function planDistSq(
a: readonly [number, number, number],
b: readonly [number, number, number],
): number {
const dx = a[0] - b[0]
const dz = a[2] - b[2]
return dx * dx + dz * dz
}
function mitreBetween(a: Endpoint, b: Endpoint): number {
// Both `awayDir`s point from the corner toward the FAR end of their
// gutter. The interior angle of the joint is the angle between them.
// Mitre = half the supplementary angle (standard carpenter formula).
const dot = a.awayDir[0] * b.awayDir[0] + a.awayDir[1] * b.awayDir[1]
const clamped = Math.max(-1, Math.min(1, dot))
const interior = Math.acos(clamped)
const mitre = (Math.PI - interior) / 2
// Aligned-or-nearly so → straight run, no mitre needed.
if (mitre < 1e-3) return 0
// Convex vs concave. `a.outDir` is THIS gutter's outward normal; `b`'s
// body runs from the corner along `b.awayDir`. On a CONVEX (outer)
// corner the neighbour's body sits on the INWARD side of our rim, so
// `b.awayDir · a.outDir < 0` (it heads away from our outward face) —
// the rim must EXTEND (positive). On a CONCAVE (inner) corner the
// neighbour's body sits on our OUTWARD side (`· > 0`) and the rim must
// RETRACT (negative) to the inner intersection.
const concave = b.awayDir[0] * a.outDir[0] + b.awayDir[1] * a.outDir[1] > 0
const signed = concave ? -mitre : mitre
return Math.max(-MAX_MITRE, Math.min(MAX_MITRE, signed))
}
// Intersection (in plan X/Z) of two infinite length-axis lines, each
// given as a point + run direction. Returns null when the runs are
// parallel (no single crossing — a straight collinear run, not a
// corner). Mirrors the `length-snap.ts` corner solve.
function axisIntersectionXZ(
aPos: readonly [number, number, number],
aDir: readonly [number, number],
bPos: readonly [number, number, number],
bDir: readonly [number, number],
): readonly [number, number, number] | null {
const cross = aDir[0] * bDir[1] - aDir[1] * bDir[0]
if (Math.abs(cross) < AXIS_PARALLEL_EPSILON) return null
const dx = bPos[0] - aPos[0]
const dz = bPos[2] - aPos[2]
const t = (dx * bDir[1] - dz * bDir[0]) / cross
return [aPos[0] + t * aDir[0], 0, aPos[2] + t * aDir[1]]
}
/**
* Compute mitres for `subject` against every other gutter under the
* same parent.
*
* A corner is the INTERSECTION of the two gutters' length-axis lines —
* not the proximity of their endpoints. This is what makes inner
* (concave) corners work: there the two eave drip-lines meet out in the
* notch, a full overhang away from where either gutter naturally ends,
* so an endpoint-to-endpoint test never fired. Keying off the axis
* crossing treats convex and concave identically. The length-snap pulls
* both ends out to that shared point, so by the time we mitre the
* subject's end AND the sibling's end both sit on the intersection — we
* require both to be within `CORNER_EPSILON` of it, which also rejects
* runs that merely cross in the middle (a T, not an L).
*
* First match per end wins; siblings order is the caller's, so the
* result is deterministic.
*/
export function computeGutterMitres(
subject: GutterNode,
subjectSegment: Pick<RoofSegmentNode, 'position' | 'rotation'>,
siblings: readonly GutterWithSegment[],
): GutterMitres {
if (siblings.length === 0) return NO_MITRES
const subj = gutterEndpointsInFrame(subject, subjectSegment)
let leftMitre = 0
let rightMitre = 0
for (const sib of siblings) {
if (sib.gutter.id === subject.id) continue
const other = gutterEndpointsInFrame(sib.gutter, sib.segment)
// `minus.awayDir` runs from the X end toward +X, i.e. along the
// length — so it's a valid direction for either gutter's axis line.
const corner = axisIntersectionXZ(
subj.minus.pos,
subj.minus.awayDir,
other.minus.pos,
other.minus.awayDir,
)
if (!corner) continue
// The sibling end that sits on the corner is the one we mitre against.
const otherPlusAtCorner = planDistSq(other.plus.pos, corner) <= CORNER_EPSILON_SQ
const otherMinusAtCorner = planDistSq(other.minus.pos, corner) <= CORNER_EPSILON_SQ
if (!otherPlusAtCorner && !otherMinusAtCorner) continue
const otherEnd = otherPlusAtCorner ? other.plus : other.minus
if (leftMitre === 0 && planDistSq(subj.minus.pos, corner) <= CORNER_EPSILON_SQ) {
leftMitre = mitreBetween(subj.minus, otherEnd)
}
if (rightMitre === 0 && planDistSq(subj.plus.pos, corner) <= CORNER_EPSILON_SQ) {
rightMitre = mitreBetween(subj.plus, otherEnd)
}
if (leftMitre !== 0 && rightMitre !== 0) break
}
return { left: leftMitre, right: rightMitre }
}
+197
View File
@@ -0,0 +1,197 @@
import {
GutterNode as GutterNodeSchema,
type GutterNode as GutterNodeType,
type HandleDescriptor,
type NodeDefinition,
} from '@pascal-app/core'
import { buildGutterFloorplan } from './floorplan'
import { snapLengthToCorner } from './length-snap'
import { gutterParametrics } from './parametrics'
import { GutterNode } from './schema'
// Edge-to-arrow-center offset, matching the box-vent / ridge-vent
// cadence so a roof's worth of accessories all read at the same scale.
const SIDE_HANDLE_OFFSET = 0.2
// Gutter chevrons sit BELOW the gutter (the trough hangs below the
// eave); the Y handle places its arrow under the cross-section apex.
const SIZE_HANDLE_OFFSET = 0.15
// Minimums — well below the inspector defaults (2.0 m length, 0.13 m
// profile) so users can shrink freely without locking.
const MIN_LENGTH = 0.2
const MIN_SIZE = 0.05
// Centre of the gutter cross-section in vertical (Y) terms. The gutter
// hangs from the eave (Y=0 in vent-mesh-local) down to Y=-size; chevrons
// that want to read "beside the body" sit at -size/2.
function getBodyMidY(n: GutterNodeType): number {
return -Math.max(MIN_SIZE, n.size) / 2
}
// Outward Z midpoint — the gutter's back wall sits at Z=0 and the rim
// hangs out to Z≈+size (k-style) / +size (half-round / box). Side
// handles place at Z=0 so they sit ABOVE the eave's fascia line.
function getRimZ(n: GutterNodeType): number {
return Math.max(MIN_SIZE, n.size) / 2
}
// Length arrow on ±X (the eave direction). Asymmetric resize: drag one
// end outward while the opposite end stays world-fixed by recentering
// `position` along the gutter's own +X arm in segment frame. Same
// yaw-aware projection as the box-vent / ridge-vent / chimney width
// handles.
//
// Corner snap: when the dragged endpoint nears the geometric corner it
// would form with another gutter (the crossing of their length axes),
// `snapLengthToCorner` overrides the raw newLength so the endpoint lands
// EXACTLY on that corner — the corner-mitre detector then fires reliably
// without pixel-perfect dragging. Only this gutter's length changes.
function gutterLengthHandle(side: 'left' | 'right'): HandleDescriptor<GutterNodeType> {
const sign = side === 'right' ? 1 : -1
return {
kind: 'linear-resize',
axis: 'x',
anchor: side === 'right' ? 'min' : 'max',
min: MIN_LENGTH,
currentValue: (n) => n.length,
apply: (initial, newLength, sceneApi) => {
const rotY = initial.rotation ?? 0
const armX = Math.cos(rotY)
const armZ = -Math.sin(rotY)
const anchorX = initial.position[0] - sign * (initial.length / 2) * armX
const anchorZ = initial.position[2] - sign * (initial.length / 2) * armZ
const snap = snapLengthToCorner(
initial,
newLength,
sign,
anchorX,
anchorZ,
armX,
armZ,
MIN_LENGTH,
sceneApi,
)
// Only the dragged gutter's own length is snapped — `snapLengthToCorner`
// never moves the corner-mate, so dragging one gutter can't reset
// another the user placed deliberately.
const newCenterX = anchorX + sign * (snap.length / 2) * armX
const newCenterZ = anchorZ + sign * (snap.length / 2) * armZ
return {
length: snap.length,
position: [newCenterX, initial.position[1], newCenterZ],
}
},
placement: {
position: (n) => [
sign * (n.length / 2 + SIDE_HANDLE_OFFSET),
getBodyMidY(n),
getRimZ(n),
],
rotationY: () => (side === 'right' ? 0 : Math.PI),
},
}
}
// Profile-size arrow below the rim. axis='y', anchor='max' pins the
// top of the trough (Y=0, the eave line) and grows the bottom edge
// downward as the user drags toward -Y. Plain chevron — at typical
// sizes (5″–6″) a dashed tracker would clutter the eave line.
function gutterSizeHandle(): HandleDescriptor<GutterNodeType> {
return {
kind: 'linear-resize',
axis: 'y',
// 'max' = +Y edge anchored (top of the trough stays at Y=0); drag
// pulls the bottom edge further down. The base linear-resize
// factor for `max` is -1, which flips the cursor delta so dragging
// downward grows the value 1:1.
anchor: 'max',
min: MIN_SIZE,
currentValue: (n) => n.size,
apply: (_n, newValue) => ({ size: Math.max(MIN_SIZE, newValue) }),
placement: {
// Sit at the bottom of the trough, pushed a bit further down so
// the chevron clears the rim and reads as a downward indicator.
position: (n) => [0, -Math.max(n.size, MIN_SIZE) - SIZE_HANDLE_OFFSET, getRimZ(n)],
},
}
}
const gutterHandles: HandleDescriptor<GutterNodeType>[] = [
gutterLengthHandle('right'),
gutterLengthHandle('left'),
gutterSizeHandle(),
]
/**
* Gutter — a rain-water channel running along the eave of a roof
* segment. Parented to a `roof-segment`; position is segment-local.
*
* Three-checkbox model — same shape as box-vent / ridge-vent: custom
* `def.renderer` for the parent-segment transform lookup + live
* override merge, pure geometry builder in `./geometry` shared with
* the placement preview, no per-frame system (no animation, no
* cross-kind cascades).
*
* Placement tool snaps to the eave line (segment-local
* `Z = +depth/2, Y = wallHeight`) wherever the cursor lands on a
* segment. After commit, the length L/R handles cover trimming and
* the inspector covers profile + size adjustments.
*/
export const gutterDefinition: NodeDefinition<typeof GutterNode> = {
kind: 'gutter',
schemaVersion: 1,
schema: GutterNode,
category: 'structure',
surfaceRole: 'roof',
defaults: () => {
const stub = GutterNodeSchema.parse({
id: 'gutter_default' as never,
type: 'gutter',
})
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
// Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the
// eave fascia — no `buildCut`, just the dirty cascade so the
// parent roof's merged shell rebuilds when the gutter moves /
// resizes.
roofAccessory: {},
},
parametrics: gutterParametrics,
handles: gutterHandles,
floorplan: buildGutterFloorplan,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
preview: () => import('./preview'),
tool: () => import('./tool'),
affordanceTools: {
move: () => import('./move-tool'),
},
toolHints: [
{ key: 'Left click', label: 'Place gutter on roof eave' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Gutter',
description: 'Rain-water channel running along the eave of a roof segment.',
icon: { kind: 'url', src: '/icons/roof.png' },
paletteSection: 'structure',
paletteOrder: 122,
},
mcp: {
description:
'A gutter strip running along the eave of a roof segment. Three profiles (k-style ogee fascia, half-round, square box), length / size / thickness parametric.',
},
}
@@ -0,0 +1,161 @@
'use client'
import {
type AnyNodeId,
DownspoutNode,
type DownspoutNode as DownspoutNodeType,
type GutterNode,
generateId,
type RoofSegmentNode,
useScene,
} from '@pascal-app/core'
import { ActionButton, ActionGroup, PanelSection, triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useShallow } from 'zustand/react/shallow'
import { computeEaveY } from './eave-snap'
import { resolveGutterOutletById } from './outlet-lookup'
const DEFAULT_OUTLET_DIAMETER = 0.07
/**
* Pick an along-length offset for a new outlet that doesn't land on an
* existing one: the first goes near the right end, the rest drop into
* the midpoint of the widest gap (including the gaps to each end). So
* "Add Downspout" repeatedly spreads outlets along the run instead of
* stacking them.
*/
function nextOutletOffset(gutter: GutterNode): number {
const len = Math.max(0.05, gutter.length)
const margin = 0.12
const lo = -len / 2 + margin
const hi = len / 2 - margin
if (hi <= lo) return 0
const existing = (gutter.outlets ?? [])
.map((o) => Math.max(lo, Math.min(hi, o.offset ?? 0)))
.sort((a, b) => a - b)
if (existing.length === 0) return hi
const bounds = [lo, ...existing, hi]
let bestMid = (lo + hi) / 2
let bestGap = -1
for (let i = 0; i < bounds.length - 1; i++) {
const gap = bounds[i + 1]! - bounds[i]!
if (gap > bestGap) {
bestGap = gap
bestMid = (bounds[i]! + bounds[i + 1]!) / 2
}
}
return bestMid
}
/**
* Downspouts subsection at the bottom of the gutter inspector. Lists the
* downspouts attached to this gutter (one per outlet); "Add Downspout"
* drills a fresh outlet at a spread-out position and drops a downspout
* on it, and each row's ✕ removes both the downspout and its outlet.
*/
export default function DownspoutsPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const setSelection = useViewer((s) => s.setSelection)
const gutter = useScene((s) =>
selectedId ? (s.nodes[selectedId] as GutterNode | undefined) : undefined,
)
const downspouts = useScene(
useShallow((s) => {
if (!selectedId) return [] as DownspoutNodeType[]
const out: DownspoutNodeType[] = []
for (const n of Object.values(s.nodes)) {
if (n?.type === 'downspout' && n.gutterId === selectedId) {
out.push(n as DownspoutNodeType)
}
}
return out
}),
)
if (!gutter || gutter.type !== 'gutter') return null
const handleSelectDownspout = (id: AnyNodeId) => {
setSelection({ selectedIds: [id] })
}
const handleAddDownspout = () => {
const segmentId = gutter.roofSegmentId as AnyNodeId | undefined
if (!segmentId) return
const segment = useScene.getState().nodes[segmentId] as RoofSegmentNode | undefined
if (!segment) return
const outletId = generateId('outlet')
const outlets = [
...(gutter.outlets ?? []),
{ id: outletId, offset: nextOutletOffset(gutter), diameter: DEFAULT_OUTLET_DIAMETER },
]
const state = useScene.getState()
state.updateNode(gutter.id as AnyNodeId, { outlets })
state.dirtyNodes.add(gutter.id as AnyNodeId)
const outlet = resolveGutterOutletById({ ...gutter, outlets }, outletId)
const dropLength = Math.max(0.1, computeEaveY(segment) + (outlet?.y ?? -gutter.size))
const downspout = DownspoutNode.parse({
name: 'Downspout',
gutterId: gutter.id,
outletId,
length: dropLength,
diameter: (outlet?.bore ?? DEFAULT_OUTLET_DIAMETER / 2) * 2,
})
state.createNode(downspout, segmentId)
state.dirtyNodes.add(segmentId)
setSelection({ selectedIds: [downspout.id] })
triggerSFX('sfx:item-place')
}
const handleRemove = (downspout: DownspoutNodeType) => {
const state = useScene.getState()
// Drop the outlet this downspout drained so its hole closes up.
if (downspout.outletId) {
state.updateNode(gutter.id as AnyNodeId, {
outlets: (gutter.outlets ?? []).filter((o) => o.id !== downspout.outletId),
})
state.dirtyNodes.add(gutter.id as AnyNodeId)
}
state.deleteNode(downspout.id as AnyNodeId)
if (gutter.roofSegmentId) state.dirtyNodes.add(gutter.roofSegmentId as AnyNodeId)
setSelection({ selectedIds: [gutter.id as AnyNodeId] })
}
return (
<PanelSection title="Downspouts">
<div className="flex flex-col gap-1">
{downspouts.map((d, i) => (
<div
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm"
key={d.id}
>
<button
className="flex-1 truncate text-left transition-colors hover:text-white"
onClick={() => handleSelectDownspout(d.id as AnyNodeId)}
type="button"
>
{d.name || `Downspout ${i + 1}`}
</button>
<button
aria-label="Remove downspout"
className="ml-2 text-muted-foreground text-xs transition-colors hover:text-red-400"
onClick={() => handleRemove(d)}
type="button"
>
</button>
</div>
))}
<ActionGroup>
<ActionButton label="Add Downspout" onClick={handleAddDownspout} />
</ActionGroup>
</div>
</PanelSection>
)
}
+97
View File
@@ -0,0 +1,97 @@
import type { GutterNode, RoofSegmentNode } from '@pascal-app/core'
import { CORNER_EPSILON_SQ, gutterEndpointsInFrame, planDistSq } from './corner-mitre'
import { computeEaveY } from './eave-snap'
/**
* Shared eave-Y for a connected run of gutters.
*
* Each gutter normally derives its mount height independently from its
* own host segment via `computeEaveY` (wallHeight overhang·tan(pitch)
* + tuck, or wallHeight on a flat deck). Two segments that read as the
* same height in the inspector can still land at different eave Ys when
* their pitch / overhang / roofType differ — so gutters that meet at a
* corner inherit two heights and the run visibly steps at the joint.
*
* This walks the connected component of gutters that meet at corners
* (same plan-space endpoint test the mitre detector uses — Y is
* deliberately ignored, so the grouping survives the very height drift
* we're correcting) and returns ONE height for the whole run: the
* HIGHEST member eave. Aligning up means no gutter ever sinks into a
* roof surface; a lower roof gets a small fascia gap, which reads
* cleaner than a gutter clipping through its slope.
*
* Deterministic + symmetric: every gutter in the run computes the same
* component and the same max, so they all converge on the identical Y
* without any shared coordinator or store write. Isolated gutters (no
* corner neighbour) get their own eave Y unchanged.
*
* Pure: no React, no scene access, no store mutation.
*/
/** A sibling gutter paired with its FULL host segment (needs the eave-Y inputs). */
export type GutterWithSegment = {
gutter: GutterNode
segment: RoofSegmentNode
}
function guttersMeet(
a: GutterNode,
aSeg: RoofSegmentNode,
b: GutterNode,
bSeg: RoofSegmentNode,
): boolean {
const ea = gutterEndpointsInFrame(a, aSeg)
const eb = gutterEndpointsInFrame(b, bSeg)
return (
planDistSq(ea.minus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ ||
planDistSq(ea.minus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ ||
planDistSq(ea.plus.pos, eb.plus.pos) <= CORNER_EPSILON_SQ ||
planDistSq(ea.plus.pos, eb.minus.pos) <= CORNER_EPSILON_SQ
)
}
// Each gutter mounts at `segment.position[1] + computeEaveY(segment)` in
// the roof frame (the renderer adds the segment-local eave Y under the
// segment's group). Segments can sit at different Y offsets, so the run
// has to be compared — and the answer returned — in the SHARED roof
// frame, not raw segment-local eave Ys.
function worldEaveY(segment: RoofSegmentNode): number {
return (segment.position?.[1] ?? 0) + computeEaveY(segment)
}
export function computeSharedEaveY(
subject: GutterNode,
subjectSegment: RoofSegmentNode,
siblings: readonly GutterWithSegment[],
): number {
const subjectBaseY = subjectSegment.position?.[1] ?? 0
if (siblings.length === 0) return computeEaveY(subjectSegment)
// Index 0 is the subject; the rest are candidates. BFS the corner
// graph from the subject and keep the tallest eave in its component.
const nodes: GutterWithSegment[] = [{ gutter: subject, segment: subjectSegment }, ...siblings]
const visited = new Array<boolean>(nodes.length).fill(false)
visited[0] = true
const queue = [0]
let maxWorldEaveY = worldEaveY(subjectSegment)
while (queue.length > 0) {
const i = queue.pop()!
const cur = nodes[i]!
for (let j = 0; j < nodes.length; j++) {
if (visited[j]) continue
const other = nodes[j]!
if (guttersMeet(cur.gutter, cur.segment, other.gutter, other.segment)) {
visited[j] = true
queue.push(j)
const eaveY = worldEaveY(other.segment)
if (eaveY > maxWorldEaveY) maxWorldEaveY = eaveY
}
}
}
// Back to the SUBJECT's segment-local frame — the renderer applies the
// returned value under the subject segment's group, which already adds
// `subjectBaseY`.
return maxWorldEaveY - subjectBaseY
}
+167
View File
@@ -0,0 +1,167 @@
import type { RoofSegmentNode, RoofType } from '@pascal-app/core'
/**
* Shared eave-snap math for the gutter's placement + move tools.
*
* `resolveEaveSnap` finds the drip edge of the eave closest to a
* cursor hit in segment-local coords. It supports every roof type the
* segment renderer can produce; the difference vs the original
* `±Z`-only resolver is hip/flat awareness (4-way eave instead of 2)
* and shed's single low eave.
*
* Why this lives outside the tools: the two tool files used to inline
* an identical copy of the resolver + the tuck constants, with a
* "keep in sync" comment that becomes a landmine as soon as the
* resolver grows non-trivial. Hip's 4-way picker pushed it past that
* threshold.
*/
// Real gutters mount on the fascia (slightly inside the drip edge),
// with the rim at the deck-top line rather than the slope-surface-at-
// drip-edge. These tuck the snap so the gutter reads as "attached to
// the fascia" rather than "floating at the very tip of the overhang".
// Tuned by feel — bump them up if the gutter looks too low / outboard.
export const EAVE_TUCK_INWARD = 0.04
export const EAVE_TUCK_UP = 0.04
export type EaveSide = '+X' | '-X' | '+Z' | '-Z'
export type EaveSnap = {
/** Segment-local X of the snapped gutter position. */
eaveX: number
/** Segment-local Y of the snapped gutter position (drip-edge Y). */
eaveY: number
/** Segment-local Z of the snapped gutter position. */
eaveZ: number
/**
* Gutter's segment-local Y rotation: orients gutter's outward axis
* (+Z local) toward the side picked. Length axis (+X local) falls
* out along the eave direction (±X or ±Z depending on the side).
*/
rotation: number
/** Which side of the segment the snap landed on. */
side: EaveSide
}
/**
* Live eave Y from a segment's wallHeight + overhang + pitch. Pulled
* out as a shared helper because the renderer derives Y from this same
* formula on every frame (the gutter tracks the segment's height
* instead of trusting `node.position[1]` from placement time), and
* `resolveEaveSnap` uses the same formula at placement.
*/
export function computeEaveY(
segment: Pick<RoofSegmentNode, 'wallHeight' | 'overhang' | 'pitch' | 'roofType'>,
): number {
const wallHeight = segment.wallHeight ?? 0
// Flat roofs have no slope drop and no slope-surface-vs-deck-top
// offset — the deck top IS the eave line. EAVE_TUCK_UP is a
// correction that lifts a SLOPED gutter from the slope-surface up to
// the deck-top line; applying it to a flat deck floats the gutter
// above the roof and leaves a visible gap between the edge and the
// gutter. So mount flat gutters right at the deck top.
if ((segment.roofType ?? 'gable') === 'flat') return wallHeight
const overhang = segment.overhang ?? 0
const pitchRad = ((segment.pitch ?? 0) * Math.PI) / 180
return wallHeight - overhang * Math.tan(pitchRad) + EAVE_TUCK_UP
}
/**
* Pick which of the segment's eaves is closest to the cursor.
*
* - `shed`: low side only. The segment-hit's analytical surface for
* a shed has `t = (lz + depth/2)/depth`, so the eave is at +Z
* regardless of which side the cursor is on — clicking on the high
* side still rolls the gutter down to the low eave.
*
* - `hip` / `flat` / `dutch`: 4-way. The slope the user is standing
* on is determined by whichever of `|lx|/halfW` or `|lz|/halfD` is
* larger — same `max(fx, fz)` discriminator the segment-hit's
* `analyticalSurfaceY` uses for hip. Sign of the dominant axis
* picks +/-. Dutch is a hip base with a gablet on top, so its
* lower run has all four eaves at the eave line — it gets the same
* 4-way snap as hip.
*
* - `gable` / `gambrel` / `mansard`: 2-way `±Z`. Mansard has real
* 4-side eaves in plan, but the segment-hit formula approximates it
* as 2-slope (depth-only), so we stay consistent here — the user
* can re-place the gutter manually on a side eave if mansard
* becomes important.
*/
function pickEaveSide(
roofType: RoofType,
localX: number,
localZ: number,
halfW: number,
halfD: number,
): EaveSide {
if (roofType === 'shed') return '+Z'
if (roofType === 'hip' || roofType === 'flat' || roofType === 'dutch') {
const fx = halfW > 0 ? Math.abs(localX) / halfW : 0
const fz = halfD > 0 ? Math.abs(localZ) / halfD : 0
if (fx > fz) return localX < 0 ? '-X' : '+X'
return localZ < 0 ? '-Z' : '+Z'
}
return localZ < 0 ? '-Z' : '+Z'
}
export function resolveEaveSnap(
segment: RoofSegmentNode,
localX: number,
localZ: number,
): EaveSnap {
const halfW = (segment.width ?? 0) / 2
const halfD = (segment.depth ?? 0) / 2
const overhang = segment.overhang ?? 0
// The slope keeps descending past the wall edge by the overhang
// span; same drop on every eave (pitch is the segment-wide primary
// slope). EAVE_TUCK_UP raises the rim back toward the deck-top line.
// Shared formula with the renderer so placement and live tracking
// agree exactly.
const eaveY = computeEaveY(segment)
const side = pickEaveSide(segment.roofType ?? 'gable', localX, localZ, halfW, halfD)
// For `±Z` eaves the eave runs along ±X so the parallel axis stays
// free (snapped to cursor's X), and Z pins to the drip edge. `±X`
// eaves swap which axis is free vs pinned. Rotation aligns the
// gutter's outward (+Z local) with the picked side; length (+X
// local) then falls along the eave.
switch (side) {
case '+Z':
return {
eaveX: localX,
eaveY,
eaveZ: Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD),
rotation: 0,
side,
}
case '-Z':
return {
eaveX: localX,
eaveY,
eaveZ: -Math.max(halfD, halfD + overhang - EAVE_TUCK_INWARD),
rotation: Math.PI,
side,
}
case '+X':
return {
eaveX: Math.max(halfW, halfW + overhang - EAVE_TUCK_INWARD),
eaveY,
eaveZ: localZ,
rotation: Math.PI / 2,
side,
}
case '-X':
return {
eaveX: -Math.max(halfW, halfW + overhang - EAVE_TUCK_INWARD),
eaveY,
eaveZ: localZ,
rotation: -Math.PI / 2,
side,
}
}
}
+300
View File
@@ -0,0 +1,300 @@
import type {
AnyNodeId,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
GutterNode,
RoofNode,
RoofSegmentNode,
} from '@pascal-app/core'
import { computeGutterMitres, type GutterWithSegment } from './corner-mitre'
import { EAVE_TUCK_INWARD } from './eave-snap'
import { outletDims, outletShapeForProfile, profileFloorMidZ } from './profile-geometry'
/**
* Floor-plan builder for a gutter. A gutter is a thin rain-water channel
* hosted on a roof segment, running along an eave. In plan it reads as a
* narrow metal strip just outboard of the eave line: the trough (two long
* edges), end caps where the trough is closed, hanger straps across the
* run, and a downspout outlet symbol where one is fitted.
*
* The coordinate frame mirrors the 3D transform stack
* (roof → roof-segment → gutter). The gutter's `position` is
* segment-local and the segment's is roof-local, so we compose
* world = roof.pos + R(roof) · (seg.pos + R(seg) · gutter.pos)
* using the floor-plan's negated-rotation convention (see
* `buildRoofSegmentFloorplan`). Gutter-local +X is the run (along the
* eave); +Z hangs outward, away from the building.
*/
export function buildGutterFloorplan(
node: GutterNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const segment = ctx.parent as RoofSegmentNode | null
if (!segment || segment.type !== 'roof-segment') return null
const roofId = segment.parentId as AnyNodeId | null
const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined
if (!roof || roof.type !== 'roof') return null
// Compose roof → segment → gutter in plan coords. Each rotation is
// negated so SVG's y-down CW matches Three.js' top-down CCW — the same
// convention the roof-segment builder establishes.
const cosR = Math.cos(-roof.rotation)
const sinR = Math.sin(-roof.rotation)
const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR
const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR
const segRot = -(roof.rotation + segment.rotation)
const cosS = Math.cos(segRot)
const sinS = Math.sin(segRot)
const cx = segCx + node.position[0] * cosS - node.position[2] * sinS
const cz = segCz + node.position[0] * sinS + node.position[2] * cosS
const gutterRot = -(roof.rotation + segment.rotation + node.rotation)
const cos = Math.cos(gutterRot)
const sin = Math.sin(gutterRot)
const toPlan = (lx: number, lz: number): FloorplanPoint => [
cx + lx * cos - lz * sin,
cz + lx * sin + lz * cos,
]
const halfLen = Math.max(node.length, 0.1) / 2
const width = Math.max(node.size, 0.05) // outward extent of the trough
// The gutter's stored position is the eave drip edge — `halfD + overhang`
// out from the segment centre (resolveEaveSnap). The roof floor plan
// draws only the structural footprint (no overhang), so to seat the
// trough on the drawn roof edge we shift it inward by that overhang
// excess. Local Z then reads: `backZ` = back/fascia edge (on the roof
// edge), `rimZ` = outward lip.
//
// A plain inward shift would break mitred corners — the two meeting
// gutters lie on perpendicular eaves, so each shifts a different way and
// their ends part (they cross). We compensate in the corner math below
// by also retracting each MITRED end along the run by the same inset:
// the net move at a corner is then identical for both gutters, so they
// still meet — now at the structural corner. Exact for right-angle
// (hip / rectangular) corners.
const eaveInset = Math.max(0, (segment.overhang ?? 0) - EAVE_TUCK_INWARD)
const backZ = -eaveInset
const rimZ = width - eaveInset
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const isHovered = view?.hovered ?? false
const showSelectedChrome = isSelected || isHighlighted
// Gutters are a metal accessory — read them in a cooler grey than the
// roof's black structural ink, accent on select, light blue on hover.
const baseInk = '#475569'
const stroke =
showSelectedChrome && palette
? palette.selectedStroke
: isHovered && palette
? palette.wallHoverStroke
: baseInk
const fill = showSelectedChrome ? '#fed7aa' : '#cbd5e1'
const fillOpacity = showSelectedChrome ? 0.5 : 0.4
const lineWidth = showSelectedChrome ? 0.03 : 0.022
// Corner mitres — when a sibling gutter meets this one at a roof
// corner, that shared end is open: the back wall stays at the corner
// while the rim extends outward to the mitre, and the cap is
// suppressed. Mirrors the 3D builder's rule (`endCap* && mitre === 0`).
// `left` = X end, `right` = +X end.
//
// Cross-segment: collect every other gutter on the roof paired with its
// host segment (mirrors the renderer's `mitreNodes` walk) so gutters
// meeting where two segments join still mitre, not just same-segment.
const mitreSiblings: GutterWithSegment[] = []
for (const segId of roof.children ?? []) {
const sib = ctx.resolve(segId as AnyNodeId) as RoofSegmentNode | undefined
if (!sib || sib.type !== 'roof-segment') continue
for (const gid of sib.children ?? []) {
const g = ctx.resolve(gid as AnyNodeId) as GutterNode | undefined
if (g && g.type === 'gutter' && g.id !== node.id) {
mitreSiblings.push({ gutter: g, segment: sib })
}
}
}
const mitres = computeGutterMitres(node, segment, mitreSiblings)
const capRight = node.endCapRight && mitres.right === 0
const capLeft = node.endCapLeft && mitres.left === 0
// Footprint corners. Back edge sits on the roof edge (lz = backZ); the
// rim hangs outward (lz = rimZ). A mitred end (a) retracts along the run
// by `eaveInset` so its back corner lands on the structural corner, then
// (b) skews its rim corner by the SIGNED mitre (`Math.tan` carries the
// sign: convex extends, concave retracts) so adjacent gutters' rims meet
// at the corner. Non-mitred ends (mitre === 0) keep the full run.
const backRightX = halfLen - (mitres.right !== 0 ? eaveInset : 0)
const backLeftX = -(halfLen - (mitres.left !== 0 ? eaveInset : 0))
const backLeft = toPlan(backLeftX, backZ)
const backRight = toPlan(backRightX, backZ)
const rimRight = toPlan(backRightX + width * Math.tan(mitres.right), rimZ)
const rimLeft = toPlan(backLeftX - width * Math.tan(mitres.left), rimZ)
const children: FloorplanGeometry[] = [
// Transparent hit-target across the whole channel so the thin strip
// is easy to click-select in plan.
{
kind: 'polygon',
points: [backLeft, backRight, rimRight, rimLeft],
fill: stroke,
fillOpacity: 0,
stroke: 'none',
strokeWidth: 0,
pointerEvents: 'all',
},
// Channel fill.
{
kind: 'polygon',
points: [backLeft, backRight, rimRight, rimLeft],
fill,
fillOpacity,
stroke: 'none',
strokeWidth: 0,
pointerEvents: 'none',
},
// Long edges — the back (fascia) line and the front lip. These two
// parallel lines are the gutter's signature read in plan.
{
kind: 'line',
x1: backLeft[0],
y1: backLeft[1],
x2: backRight[0],
y2: backRight[1],
stroke,
strokeWidth: lineWidth,
strokeLinecap: 'round',
pointerEvents: 'none',
},
{
kind: 'line',
x1: rimLeft[0],
y1: rimLeft[1],
x2: rimRight[0],
y2: rimRight[1],
stroke,
strokeWidth: lineWidth,
strokeLinecap: 'round',
pointerEvents: 'none',
},
]
// End edges. A capped end gets a square closure line; a mitred end gets
// the slanted mitre seam (so the joint shows in plan); an open, uncapped
// end gets nothing. `cap*` already excludes mitred ends, so an end never
// draws both a cap and a seam.
if (capLeft || mitres.left !== 0) {
children.push({
kind: 'line',
x1: backLeft[0],
y1: backLeft[1],
x2: rimLeft[0],
y2: rimLeft[1],
stroke,
strokeWidth: lineWidth,
strokeLinecap: 'round',
pointerEvents: 'none',
})
}
if (capRight || mitres.right !== 0) {
children.push({
kind: 'line',
x1: backRight[0],
y1: backRight[1],
x2: rimRight[0],
y2: rimRight[1],
stroke,
strokeWidth: lineWidth,
strokeLinecap: 'round',
pointerEvents: 'none',
})
}
// Hanger straps — short ticks across the trough at the real hanger
// spacing, so the strip reads as a gutter rather than a thin wall.
if (node.hangerStyle === 'strap') {
const spacing = Math.max(node.hangerSpacing, 0.2)
const inset = width * 0.15
// Span the (possibly retracted) run between the two end corners.
const mid = (backLeftX + backRightX) / 2
const runLen = backRightX - backLeftX
const count = Math.max(1, Math.floor(runLen / spacing))
const span = count * spacing
for (let i = 0; i < count; i++) {
const x = mid - span / 2 + spacing / 2 + i * spacing
if (x <= backLeftX + 0.02 || x >= backRightX - 0.02) continue
const a = toPlan(x, backZ + inset)
const b = toPlan(x, rimZ - inset)
children.push({
kind: 'line',
x1: a[0],
y1: a[1],
x2: b[0],
y2: b[1],
stroke,
strokeWidth: lineWidth * 0.7,
strokeLinecap: 'round',
opacity: 0.6,
pointerEvents: 'none',
})
}
}
// Downspout outlets — a leader symbol per outlet (round for half-round
// gutters, rectangular for k-style / box, following the profile) at each
// outlet's along-run position. The strongest "this is a gutter" cue in a
// roof plan. `offset` is signed from the gutter centre along +X.
// `outlets` is a recent schema addition — gutters persisted before it
// existed deserialize without the field (the schema default only fills
// on a fresh parse), so guard against `undefined`.
const outlets = node.outlets ?? []
if (outlets.length > 0) {
const floorZ = Math.min(
Math.max(profileFloorMidZ(node.profile, width), width * 0.25),
width * 0.85,
)
const outletZ = backZ + floorZ
const shape = outletShapeForProfile(node.profile)
const outStroke = showSelectedChrome && palette ? palette.selectedStroke : '#1e293b'
for (const outlet of outlets) {
// Clamp inside the run so the symbol never rides out onto a cap line
// (the 3D builder clamps the drill the same way).
const outletX = Math.max(-halfLen * 0.9, Math.min(halfLen * 0.9, outlet.offset))
const dims = outletDims(shape, outlet.diameter)
if (shape === 'round') {
const center = toPlan(outletX, outletZ)
children.push({
kind: 'circle',
cx: center[0],
cy: center[1],
r: Math.max(dims.halfX, 0.03),
fill: 'none',
stroke: outStroke,
strokeWidth: lineWidth,
pointerEvents: 'none',
})
} else {
children.push({
kind: 'polygon',
points: [
toPlan(outletX - dims.halfX, outletZ - dims.halfZ),
toPlan(outletX + dims.halfX, outletZ - dims.halfZ),
toPlan(outletX + dims.halfX, outletZ + dims.halfZ),
toPlan(outletX - dims.halfX, outletZ + dims.halfZ),
],
fill: 'none',
stroke: outStroke,
strokeWidth: lineWidth,
pointerEvents: 'none',
})
}
}
}
return { kind: 'group', children }
}
+634
View File
@@ -0,0 +1,634 @@
import type { GutterNode } from '@pascal-app/core'
import {
Brush,
csgEvaluator,
csgGeometry,
prepareBrushForCSG,
SUBTRACTION,
} from '@pascal-app/viewer'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { type GutterMitres, NO_MITRES } from './corner-mitre'
import {
OUTLET_STUB_LENGTH,
OUTLET_WALL_THICKNESS,
type OutletDims,
type OutletShape,
outletDims,
outletShapeForProfile,
profileFloorMidZ,
} from './profile-geometry'
/**
* Pure builder for the gutter mesh. The gutter is a hollow trough that
* runs along the eave; we build its cross-section as a closed 2D Shape
* in the (Z, Y) plane with the channel cavity carved out as a Path
* hole, then extrude along the gutter's local +X (length direction).
*
* Three profiles share the same outer-outline-minus-cavity recipe; they
* differ only in the OUTLINE shape:
*
* - `k-style`: flat back + flat bottom + ogee (S-curve) fascia.
* Most common modern residential profile.
* - `half-round`: half-cylinder (semicircle cross-section).
* - `box`: square / rectangular u-channel; reads as commercial.
*
* The gutter mounts at the eave line (gutter-local Y=0) and drops
* downward (negative Y) by `size`. +Z is "away from the building" —
* positive Z is the outer face that hangs over the eave.
*
* End caps: when `endCapLeft` / `endCapRight` is true, the matching
* end gets a thin SOLID slice (depth = wall thickness) instead of the
* hollow U-channel. The solid slice's end face closes the trough so
* water can't run out the side. Caps subtract from the user-set
* `length` so the gutter's total span stays constant — capping doesn't
* silently grow the geometry past what the inspector reads.
*
* Corner mitres: when a sibling gutter meets this gutter at a roof
* corner, the corner-mitre detector passes a mitre angle for the
* affected end. The end-face vertices are skewed (back wall held in
* place, front rim extended outward) so two perpendicular gutters'
* front rims meet at the outer eave intersection. A mitred end's cap
* is force-suppressed — capping a corner would wall off the L.
*
* Pure: no React, no scene access, no store mutation.
*/
export function buildGutterGeometry(
node: GutterNode,
mitres: GutterMitres = NO_MITRES,
): THREE.BufferGeometry {
const len = Math.max(0.05, node.length)
const size = Math.max(0.04, node.size)
const t = Math.min(Math.max(0.001, node.thickness), size * 0.4)
const capLeft = (node.endCapLeft ?? true) && mitres.left === 0
const capRight = (node.endCapRight ?? true) && mitres.right === 0
// Reserve cap slices at each capped end. Each cap is `t` thick
// (matches the wall thickness — a real end cap is a stamped plate
// welded onto the gutter). Clamp so a tiny gutter doesn't end up
// all-cap-no-channel.
const reserved = (capLeft ? t : 0) + (capRight ? t : 0)
const channelLen = Math.max(len * 0.1, len - reserved)
const totalCap = len - channelLen
const capLeftLen = capLeft ? (capRight ? totalCap / 2 : totalCap) : 0
const capRightLen = capRight ? (capLeft ? totalCap / 2 : totalCap) : 0
let channelCross: THREE.Shape
let capCross: THREE.Shape
if (node.profile === 'half-round') {
channelCross = buildHalfRoundCross(size, t)
capCross = buildHalfRoundOuterOnly(size)
} else if (node.profile === 'box') {
channelCross = buildBoxCross(size, t)
capCross = buildBoxOuterOnly(size)
} else {
channelCross = buildKStyleCross(size, t)
capCross = buildKStyleOuterOnly(size)
}
// Each extrude below uses the same orient-and-recenter recipe:
// ExtrudeGeometry produces (mesh-X = cross-X, mesh-Y = cross-Y,
// mesh-Z = extrusion axis); we rotateY(-π/2) so the LENGTH lands
// along mesh-+X and the OUTWARD direction lands along mesh-+Z, then
// translate so the piece sits in its slot of the gutter's overall
// [-len/2, +len/2] span. Z_cs = 0 maps to mesh-+X (right end);
// Z_cs = depth maps to mesh--X (left end).
const pieces: THREE.BufferGeometry[] = []
let channel: THREE.BufferGeometry = new THREE.ExtrudeGeometry(channelCross, {
depth: channelLen,
bevelEnabled: false,
curveSegments: 16,
steps: 1,
})
// Apply the corner-mitre skew while we're still in the source frame.
// Source axes (pre-rotation): X_cs = outward, Y_cs = vertical,
// Z_cs = length (0 at right end of mesh, `channelLen` at left end).
// After rotateY(-π/2): mesh-X = -Z_cs, mesh-Z = X_cs.
//
// Corner mitre rule: the back wall (X_cs = 0) stays at the original
// end (mesh-X = ±len/2); the front rim (X_cs = +outward) is SKEWED
// along the gutter's length to reach the eave intersection of the L.
// The mitre is SIGNED — positive (convex / outer corner) extends the
// rim past the end; negative (concave / inner corner) retracts it to
// the inner intersection. `Math.tan` carries the sign, so the same
// formula handles both. In mesh coords:
// right end: Δmesh-X = +mesh-Z · tan(mitreRight)
// left end: Δmesh-X = mesh-Z · tan(mitreLeft)
// Mapped back to source coords (Δmesh-X = −ΔZ_cs, mesh-Z = X_cs):
// right end (Z_cs = 0): new Z_cs = X_cs · tan(mitreRight)
// left end (Z_cs = channelLen): new Z_cs = channelLen + X_cs · tan(mitreLeft)
if (mitres.right !== 0 || mitres.left !== 0) {
// Drop the cross-section CAP at each mitred end first. Both gutters
// at a corner skew their end faces onto the SAME mitre plane, so the
// thin metal-section caps land coincident and z-fight into the stray
// seam lines visible at the joint (and the ink-edge pass outlines
// them). With the caps gone the open troughs flow into each other and
// the side walls carry the corner — a clean seam. Done before the
// skew so the end planes are still the clean z=0 / z=channelLen.
const stripped = stripEndCaps(channel, channelLen, mitres.right !== 0, mitres.left !== 0)
channel.dispose()
channel = stripped
const tanRight = Math.tan(mitres.right)
const tanLeft = Math.tan(mitres.left)
const eps = 1e-5
const pos = channel.attributes.position!
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i)
const z = pos.getZ(i)
if (mitres.right !== 0 && Math.abs(z) < eps) {
pos.setZ(i, -x * tanRight)
} else if (mitres.left !== 0 && Math.abs(z - channelLen) < eps) {
pos.setZ(i, channelLen + x * tanLeft)
}
}
pos.needsUpdate = true
channel.computeVertexNormals()
}
channel.rotateY(-Math.PI / 2)
// Channel spans [-len/2 + capLeftLen, +len/2 - capRightLen]: shift
// the recentered extrude so its right end butts against the right cap.
channel.translate(len / 2 - capRightLen, 0, 0)
pieces.push(channel)
if (capLeft) {
const leftCap = new THREE.ExtrudeGeometry(capCross, {
depth: capLeftLen,
bevelEnabled: false,
curveSegments: 16,
steps: 1,
})
leftCap.rotateY(-Math.PI / 2)
// Left cap spans [-len/2, -len/2 + capLeftLen]: translate by
// -len/2 + capLeftLen so Z_cs=0 (mesh-+X end of the cap slice)
// sits at -len/2 + capLeftLen and Z_cs=depth at -len/2.
leftCap.translate(-len / 2 + capLeftLen, 0, 0)
pieces.push(leftCap)
}
if (capRight) {
const rightCap = new THREE.ExtrudeGeometry(capCross, {
depth: capRightLen,
bevelEnabled: false,
curveSegments: 16,
steps: 1,
})
rightCap.rotateY(-Math.PI / 2)
// Right cap spans [+len/2 - capRightLen, +len/2].
rightCap.translate(len / 2, 0, 0)
pieces.push(rightCap)
}
// Hangers: thin metal straps spanning the rim from the back wall to
// the front rim, repeated along the length. Each strap is a small
// box centered on Y=0 (the eave line, where the rim sits) with its
// top ~3mm above the rim — so it reads as a clip resting on the
// gutter rather than buried in it.
if ((node.hangerStyle ?? 'strap') !== 'none') {
for (const hanger of buildHangers(node, len, size, capLeftLen, capRightLen, mitres)) {
pieces.push(hanger)
}
}
// Downspout outlets: short drop-tube collars hanging off the gutter
// floor at each outlet's position. Each collar is solid at the outer
// (bore + wall) cross-section; the bore is then drilled through both
// the floor and the collar via CSG, so the result is a real hole in
// the trough floor and a hollow tube descending from it. CSG only
// runs when at least one outlet exists — an empty `outlets` keeps the
// fast merge path.
const placements = resolveOutletPlacements(node, len, size, capLeftLen, capRightLen)
for (const p of placements) {
pieces.push(buildOutletStub(p, size))
// Flared funnel lip at the floor so the drop reads as a real drop
// outlet rather than a bare tube; the same bore drill cuts it open.
pieces.push(buildOutletFunnel(p, size))
}
const merged = pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!)
// Free the intermediate pieces when merge returned a new geometry.
if (merged !== pieces[0]) {
for (const p of pieces) p.dispose()
}
merged.computeVertexNormals()
// CSG drill — punches each bore through the merged geometry. Runs
// last so the floor + collars are already in one mesh; each drill
// cuts both at once, subtracted sequentially.
if (placements.length > 0) {
let workingBrush = new Brush(merged)
prepareBrushForCSG(workingBrush)
for (const p of placements) {
const drill = buildOutletDrill(p, size)
const drillBrush = new Brush(drill)
prepareBrushForCSG(drillBrush)
const next = csgEvaluator.evaluate(workingBrush, drillBrush, SUBTRACTION) as Brush
// Free the previous step's intermediate result (but not `merged`,
// which is disposed once below).
if (workingBrush.geometry !== merged) workingBrush.geometry.dispose()
workingBrush = next
drill.dispose()
}
const cutGeometry = csgGeometry(workingBrush)
merged.dispose()
return cutGeometry
}
return merged
}
// Remove the extrude's cross-section CAP triangles at a mitred end so two
// gutters meeting at a corner don't leave coincident, z-fighting end
// faces (the seam lines at the joint). A cap triangle is one whose three
// vertices ALL lie on an end plane — z≈0 (right end) or z≈channelLen
// (left end). Wall triangles always span the two planes, so this cleanly
// separates caps from walls without depending on ExtrudeGeometry's
// internal vertex order. Input is non-indexed (ExtrudeGeometry), so every
// three positions is one triangle; we copy through the survivors and let
// the caller recompute normals.
function stripEndCaps(
geo: THREE.BufferGeometry,
channelLen: number,
removeRight: boolean,
removeLeft: boolean,
): THREE.BufferGeometry {
const src = geo.index ? geo.toNonIndexed() : geo
const pos = src.attributes.position!
const uv = src.attributes.uv
const eps = 1e-4
const keepPos: number[] = []
const keepUv: number[] | null = uv ? [] : null
const triCount = Math.floor(pos.count / 3)
for (let t = 0; t < triCount; t++) {
const a = t * 3
const za = pos.getZ(a)
const zb = pos.getZ(a + 1)
const zc = pos.getZ(a + 2)
const allRight = Math.abs(za) < eps && Math.abs(zb) < eps && Math.abs(zc) < eps
const allLeft =
Math.abs(za - channelLen) < eps &&
Math.abs(zb - channelLen) < eps &&
Math.abs(zc - channelLen) < eps
if ((removeRight && allRight) || (removeLeft && allLeft)) continue
for (let k = 0; k < 3; k++) {
const idx = a + k
keepPos.push(pos.getX(idx), pos.getY(idx), pos.getZ(idx))
if (keepUv && uv) keepUv.push(uv.getX(idx), uv.getY(idx))
}
}
if (src !== geo) src.dispose()
const out = new THREE.BufferGeometry()
out.setAttribute('position', new THREE.Float32BufferAttribute(keepPos, 3))
if (keepUv) out.setAttribute('uv', new THREE.Float32BufferAttribute(keepUv, 2))
return out
}
// Each cross-section below is authored as a single closed polygon that
// traces the U-channel's MATERIAL — outer wall down → bottom → outer
// wall up → rim across → inner wall down → inner bottom → inner wall
// up → closing rim. The interior of the U (where rainwater sits) is
// empty space, not a hole — so the extrude has an OPEN TOP, which is
// what makes the geometry read as a gutter rather than a sealed box
// with a tunnel through it.
//
// Cross-section authoring frame: X is the gutter's outward direction
// (X=0 against the fascia, X=+w hanging outward); Y is vertical (Y=0
// at the eave line, Y=-size at the bottom of the trough). After the
// rotateY(-π/2) in the parent builder, +X maps to segment-outward (+Z)
// and the extrude axis (length) maps to segment-+X.
function buildKStyleCross(size: number, t: number): THREE.Shape {
const wBot = size * 0.8 // bottom outer width — narrower than the rim
const wTop = size * 0.95
const ogeeY = -size * 0.65 // S-curve inflection on the fascia
const shape = new THREE.Shape()
// Outer trace — top-back → down the back → across the bottom → up
// the ogee fascia.
shape.moveTo(0, 0)
shape.lineTo(0, -size)
shape.lineTo(wBot, -size)
shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0)
// Front rim (thin top of the front wall): step inward by `t`.
shape.lineTo(wTop - t, 0)
// Inner trace — back down the ogee, across the inner bottom, up the
// inner back wall. Same bezier control points pushed inward by `t`.
shape.bezierCurveTo(
wTop - size * 0.15 - t,
ogeeY * 0.4,
wBot + size * 0.15 - t,
ogeeY,
wBot - t,
-size + t,
)
shape.lineTo(t, -size + t)
shape.lineTo(t, 0)
// closePath draws the back rim (t, 0) → (0, 0) — the thin top of
// the back wall, sealing the cross-section.
shape.closePath()
return shape
}
// Half-round trough — a semicircular cross-section with a smaller
// concentric semicircle carved out. Single closed trace: outer half
// from (0,0) sweeping down and back up to (2r, 0), front rim across by
// `t`, inner half from (2r-t, 0) sweeping back to (t, 0), back rim
// closes the loop.
function buildHalfRoundCross(size: number, t: number): THREE.Shape {
const r = size // radius == size: half-circle drops `size` below the eave
const ri = r - t // inner radius
const segs = 24
const shape = new THREE.Shape()
shape.moveTo(0, 0)
// Outer semicircle, lower half (angle π → 2π). At i=0 we'd be at
// (0,0) — already there from moveTo — so start at i=1.
for (let i = 1; i <= segs; i++) {
const angle = Math.PI + (Math.PI * i) / segs
shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle))
}
// Front rim — step inward by `t` to start the inner trace.
shape.lineTo(2 * r - t, 0)
// Inner semicircle, traced BACK toward the back wall (angle 2π → π).
for (let i = 1; i <= segs; i++) {
const angle = 2 * Math.PI - (Math.PI * i) / segs
shape.lineTo(r + ri * Math.cos(angle), ri * Math.sin(angle))
}
// closePath draws (t, 0) → (0, 0) — back rim.
shape.closePath()
return shape
}
// Square / rectangular box U-channel. Width equals size (deep-and-
// narrow ratio reads as commercial). Traced as outer rect → front rim
// → inner rect (reverse) → back rim.
function buildBoxCross(size: number, t: number): THREE.Shape {
const w = size
const shape = new THREE.Shape()
shape.moveTo(0, 0)
shape.lineTo(0, -size)
shape.lineTo(w, -size)
shape.lineTo(w, 0)
// Front rim.
shape.lineTo(w - t, 0)
// Inner rect, reversed so the polygon doesn't self-intersect.
shape.lineTo(w - t, -size + t)
shape.lineTo(t, -size + t)
shape.lineTo(t, 0)
// closePath draws the back rim (t, 0) → (0, 0).
shape.closePath()
return shape
}
// Solid-outer outlines used for the end-cap slices: same outer
// boundary as the channel cross-sections above but without the inner
// trough carved out, so the extruded slice is a solid plug that closes
// the open end of the trough.
function buildKStyleOuterOnly(size: number): THREE.Shape {
const wBot = size * 0.8
const wTop = size * 0.95
const ogeeY = -size * 0.65
const shape = new THREE.Shape()
shape.moveTo(0, 0)
shape.lineTo(0, -size)
shape.lineTo(wBot, -size)
shape.bezierCurveTo(wBot + size * 0.15, ogeeY, wTop - size * 0.15, ogeeY * 0.4, wTop, 0)
// closePath draws (wTop, 0) → (0, 0) — the cap's rim line across
// the top of the gutter cross-section.
shape.closePath()
return shape
}
function buildHalfRoundOuterOnly(size: number): THREE.Shape {
const r = size
const segs = 24
const shape = new THREE.Shape()
shape.moveTo(0, 0)
for (let i = 1; i <= segs; i++) {
const angle = Math.PI + (Math.PI * i) / segs
shape.lineTo(r + r * Math.cos(angle), r * Math.sin(angle))
}
shape.closePath()
return shape
}
function buildBoxOuterOnly(size: number): THREE.Shape {
const w = size
const shape = new THREE.Shape()
shape.moveTo(0, 0)
shape.lineTo(0, -size)
shape.lineTo(w, -size)
shape.lineTo(w, 0)
shape.closePath()
return shape
}
// ─── Hangers ───────────────────────────────────────────────────────
// Strap dimensions — a residential hidden hanger reads as a flat band
// roughly 25mm wide along the gutter, 3mm thick, sitting on the rim.
const HANGER_BAR_LEN = 0.025
const HANGER_BAR_THICKNESS = 0.003
// Extra spread past the rim's outward extent — so the strap looks like
// it "wraps over" both edges rather than ending flush.
const HANGER_OVERHANG = 0.005
// Distance from each gutter end where a strap is allowed to sit; keeps
// straps from clashing with end caps and from looking pinned to the
// very edge.
const HANGER_END_MARGIN = 0.05
/** Outward Z extent of each profile, used to size the strap. */
function profileRimWidth(profile: GutterNode['profile'], size: number): number {
if (profile === 'half-round') return 2 * size
if (profile === 'box') return size
return size * 0.95 // k-style wTop
}
function buildHangers(
node: GutterNode,
len: number,
size: number,
capLeftLen: number,
capRightLen: number,
mitres: GutterMitres,
): THREE.BufferGeometry[] {
const spacing = Math.max(0.2, node.hangerSpacing ?? 0.6)
const profile = node.profile ?? 'k-style'
const rimWidth = profileRimWidth(profile, size)
const strapDepth = rimWidth + HANGER_OVERHANG * 2
// At an INNER (concave) corner the two gutters' troughs fold into the
// same notch, so a strap sitting near that end overlaps the neighbour
// gutter's end strap — the two perpendicular straps read as an X across
// the corner. Pull the run's bound back by a full strap depth at a
// concave end so the nearest strap clears the joint. OUTER (convex)
// corners diverge outward and never overlap, so they keep the tight
// margin (`mitre > 0` and non-mitred ends → no extra inset).
const concaveInset = (mitre: number) => (mitre < 0 ? strapDepth : 0)
// Inset by margin AND any cap so straps don't punch into the cap slab,
// plus the concave-corner clearance above.
const leftBound = -len / 2 + capLeftLen + HANGER_END_MARGIN + concaveInset(mitres.left)
const rightBound = len / 2 - capRightLen - HANGER_END_MARGIN - concaveInset(mitres.right)
const usable = rightBound - leftBound
if (usable <= 0) return []
// Span the usable run with straps at `spacing` between centers, plus
// one at each end. Symmetric layout for any length, including very
// short gutters where two straps land at the bounds.
const count = Math.max(1, Math.floor(usable / spacing) + 1)
const stride = count > 1 ? usable / (count - 1) : 0
const pieces: THREE.BufferGeometry[] = []
for (let i = 0; i < count; i++) {
const x = count > 1 ? leftBound + i * stride : (leftBound + rightBound) / 2
// BoxGeometry is indexed; the channel + cap ExtrudeGeometries are
// not. `mergeGeometries` rejects mixed-index sets, so flatten the
// box to non-indexed before pushing.
const bar = new THREE.BoxGeometry(
HANGER_BAR_LEN,
HANGER_BAR_THICKNESS,
strapDepth,
).toNonIndexed()
// Center the bar at X = position, Y just above the rim line, Z
// straddling 0 so the strap covers the full back-to-front span.
bar.translate(x, HANGER_BAR_THICKNESS / 2 + 0.001, rimWidth / 2)
pieces.push(bar)
}
return pieces
}
// ─── Outlet ────────────────────────────────────────────────────────
// Radial subdivisions — 24 reads as smooth at typical outlet
// diameters; lower and the 3″ tube starts looking faceted from below.
const OUTLET_RADIAL_SEGMENTS = 24
// Drill overshoot past the floor and past the stub bottom — keeps the
// CSG cut planes from coinciding with merged-geometry surfaces
// (coplanar cuts produce degenerate output in three-bvh-csg).
const OUTLET_DRILL_OVERSHOOT = 0.01
// Funnel lip at the drop outlet — flares this much wider than the collar
// over this height, just below the trough floor. The bore drill cuts it
// open along with the collar.
const OUTLET_FLARE_SCALE = 1.6
const OUTLET_FLARE_HEIGHT = 0.02
type OutletPlacement = {
x: number
z: number
shape: OutletShape
/** Bore cross-section (the drilled hole). */
inner: OutletDims
/** Collar cross-section (bore + wall) — the solid stub body. */
outer: OutletDims
}
/**
* One placement per `node.outlets` entry that fits between the caps. The
* shape follows the gutter profile (`outletShapeForProfile`): round
* leader on half-round, rectangular on k-style / box. Each outlet's
* `offset` (signed from center) is clamped inside the trough-floor span
* using the OUTER along-length half-extent so the collar can't poke into
* a cap. Outlets that can't fit at all are dropped.
*/
function resolveOutletPlacements(
node: GutterNode,
len: number,
size: number,
capLeftLen: number,
capRightLen: number,
): OutletPlacement[] {
const outlets = node.outlets ?? []
if (outlets.length === 0) return []
const shape = outletShapeForProfile(node.profile)
const z = profileFloorMidZ(node.profile ?? 'k-style', size)
const placements: OutletPlacement[] = []
for (const outlet of outlets) {
const inner = outletDims(shape, outlet.diameter ?? 0.07)
const outer: OutletDims = {
shape,
halfX: inner.halfX + OUTLET_WALL_THICKNESS,
halfZ: inner.halfZ + OUTLET_WALL_THICKNESS,
}
const minX = -len / 2 + capLeftLen + outer.halfX
const maxX = len / 2 - capRightLen - outer.halfX
if (maxX <= minX) continue
const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0))
placements.push({ x, z, shape, inner, outer })
}
return placements
}
/** Cylinder (round) or box (rect) sized to `dims`, height `h` along Y. */
function outletSolid(dims: OutletDims, h: number): THREE.BufferGeometry {
if (dims.shape === 'round') {
return new THREE.CylinderGeometry(
dims.halfX,
dims.halfX,
h,
OUTLET_RADIAL_SEGMENTS,
).toNonIndexed()
}
return new THREE.BoxGeometry(2 * dims.halfX, h, 2 * dims.halfZ).toNonIndexed()
}
/**
* Solid collar at the OUTER cross-section; the CSG drill hollows out the
* bore and leaves a tube wall. Top sits flush with the gutter floor
* (Y = size). Flattened to match the ExtrudeGeometries.
*/
function buildOutletStub(p: OutletPlacement, size: number): THREE.BufferGeometry {
const stub = outletSolid(p.outer, OUTLET_STUB_LENGTH)
stub.translate(p.x, -size - OUTLET_STUB_LENGTH / 2, p.z)
return stub
}
/**
* Flared funnel lip just below the trough floor — wide at the floor,
* tapering to the collar below (round → a cone; rect → a stepped wider
* lip). Sits in the bore drill's span so it gets cut open too.
*/
function buildOutletFunnel(p: OutletPlacement, size: number): THREE.BufferGeometry {
const centerY = -size - OUTLET_FLARE_HEIGHT / 2
let funnel: THREE.BufferGeometry
if (p.shape === 'round') {
funnel = new THREE.CylinderGeometry(
p.outer.halfX * OUTLET_FLARE_SCALE,
p.outer.halfX,
OUTLET_FLARE_HEIGHT,
OUTLET_RADIAL_SEGMENTS,
).toNonIndexed()
} else {
funnel = new THREE.BoxGeometry(
2 * p.outer.halfX * OUTLET_FLARE_SCALE,
OUTLET_FLARE_HEIGHT,
2 * p.outer.halfZ * OUTLET_FLARE_SCALE,
).toNonIndexed()
}
funnel.translate(p.x, centerY, p.z)
return funnel
}
/**
* Bore drill — spans from slightly above the trough floor to slightly
* below the collar's bottom; the overshoots keep CSG cut planes from
* sitting coplanar with merged-geometry faces.
*/
function buildOutletDrill(p: OutletPlacement, size: number): THREE.BufferGeometry {
const top = -size + OUTLET_DRILL_OVERSHOOT
const bottom = -size - OUTLET_STUB_LENGTH - OUTLET_DRILL_OVERSHOOT
const height = top - bottom
const centerY = (top + bottom) / 2
const drill = outletSolid(p.inner, height)
drill.translate(p.x, centerY, p.z)
return drill
}
+3
View File
@@ -0,0 +1,3 @@
export { gutterDefinition } from './definition'
export { buildGutterGeometry } from './geometry'
export { GutterNode } from './schema'
+217
View File
@@ -0,0 +1,217 @@
import type { AnyNodeId, GutterNode, RoofSegmentNode, SceneApi } from '@pascal-app/core'
/**
* Length-handle snap. When the user drags a gutter's ±X length handle
* and the proposed endpoint lands within `SNAP_RADIUS` of the geometric
* CORNER it would form with another gutter — the intersection of their
* two length-axis lines — the dragged gutter's length is pulled so its
* end lands exactly on that corner. The corner-mitre detector's match
* window then fires reliably without a pixel-perfect drag.
*
* ONLY the dragged gutter moves. The corner is the axis crossing — a
* fixed point in plan, independent of where the other gutter currently
* ends — so each gutter snaps onto the SAME shared corner on its own as
* it's dragged in, and the L meets there without ever reaching over to
* reposition a gutter the user placed deliberately. (An earlier version
* adjusted both gutters at once; that yanked an already-placed gutter
* whenever its corner-mate was dragged, which is the bug this avoids.)
*
* Cross-segment: the search covers every gutter under the SAME ROOF
* (not just the dragged gutter's segment-mates), and all the geometry
* runs in the shared ROOF frame — each gutter's endpoints are lifted
* out of its own segment-local frame by that segment's position +
* Y-rotation. So an L-shaped plan whose two eaves live on different
* roof segments snaps + mitres at the corner where the segments meet,
* exactly like a same-segment hip corner (which is the degenerate case
* where both gutters share one segment transform).
*
* Pure: no React, no THREE. Reads through SceneApi; returns the snapped
* length for the caller to apply.
*/
// 10 cm catch radius — wide enough that the user doesn't need pixel-
// perfect dragging, narrow enough that unrelated gutters on the
// opposite eave don't accidentally bind.
const SNAP_RADIUS = 0.1
const SNAP_RADIUS_SQ = SNAP_RADIUS * SNAP_RADIUS
// Cross product below this counts as parallel axes — no intersection,
// fall back to snapping A onto B's current endpoint without modifying B.
const AXIS_PARALLEL_EPSILON = 1e-3
// How far a corner-mate's own nearer endpoint may sit from the corner
// and still bind the dragged gutter to it. The corner is the crossing
// of the two axis LINES, which can lie well beyond where a gutter ends —
// at an inner/concave corner the mate's end is a full eave overhang
// short of the crossing, so the bound has to clear a generous overhang.
// It also stops a far perpendicular gutter, whose infinite axis happens
// to cross near the dragged end, from binding by coincidence: a real
// corner-mate is within reach, an unrelated run is metres away.
const CORNER_MATE_REACH = 1.5
const CORNER_MATE_REACH_SQ = CORNER_MATE_REACH * CORNER_MATE_REACH
export type GutterLengthSnap = {
/** Length to apply to the dragged gutter. */
length: number
}
/**
* @param initial gutter at drag start (rotation, length, position)
* @param proposedLength length the linear-resize pipeline computed
* @param sign +1 for the gutter-local +X end being dragged, 1 for X
* @param anchorX,anchorZ the held-fixed endpoint (opposite of `sign`)
* @param armX,armZ gutter +X direction in segment frame (cos r, sin r)
* @param minLength floor — typically the descriptor's `min` value
* @param sceneApi scene access for corner-mate lookup
*/
export function snapLengthToCorner(
initial: GutterNode,
proposedLength: number,
sign: 1 | -1,
anchorX: number,
anchorZ: number,
armX: number,
armZ: number,
minLength: number,
sceneApi: SceneApi,
): GutterLengthSnap {
const segmentId = initial.roofSegmentId as AnyNodeId | undefined
if (!segmentId) return { length: proposedLength }
const seg = sceneApi.get<RoofSegmentNode>(segmentId)
if (!seg) return { length: proposedLength }
// Everything runs in the ROOF frame so gutters on different segments
// can meet. The dragged gutter's anchor/arm come in segment-local
// (the caller computes them from `initial`); lift them into the roof
// frame with the dragged segment's transform.
const selfTf = segmentTransform(seg)
const anchorR = applyTf(selfTf, anchorX, anchorZ)
const armR = applyTfDir(selfTf, armX, armZ)
const aAnchorX = anchorR.x
const aAnchorZ = anchorR.z
const aArmX = armR.x
const aArmZ = armR.z
const proposedEndX = aAnchorX + sign * proposedLength * aArmX
const proposedEndZ = aAnchorZ + sign * proposedLength * aArmZ
// Candidate gutters: every gutter under the SAME ROOF, each carrying
// its own segment's roof-frame transform.
type Cand = { gutter: GutterNode; tf: SegmentTransform }
const candidates: Cand[] = []
const roofId = seg.parentId as AnyNodeId | undefined
const roof = roofId ? sceneApi.get(roofId) : undefined
const roofChildren = (roof as { children?: readonly string[] } | undefined)?.children
for (const sid of roofChildren ?? []) {
const s = sceneApi.get<RoofSegmentNode>(sid as AnyNodeId)
if (!s || s.type !== 'roof-segment') continue
const tf = segmentTransform(s)
for (const gid of s.children ?? []) {
const g = sceneApi.get(gid as AnyNodeId)
if (g?.type === 'gutter' && g.id !== initial.id) {
candidates.push({ gutter: g as GutterNode, tf })
}
}
}
// Find the corner-mate whose CORNER with the dragged gutter lands
// closest to the proposed dragged endpoint. The corner is the
// intersection of the two length-axis LINES (roof frame) — NOT the
// proximity of the two endpoints. That distinction is what unlocks
// inner/concave corners: there the two eave drip-lines meet out in the
// notch, a full overhang away from where either gutter naturally ends,
// so the old endpoint-to-endpoint catch never fired. Keying off the
// axis crossing treats convex and concave identically. Parallel axes
// (a straight collinear run) have no crossing, so there we fall back to
// the mate's nearer endpoint (flush join). Only the dragged gutter's
// own length is snapped to the corner — the mate is never moved.
let bestTargetX = 0
let bestTargetZ = 0
let bestDistSq = SNAP_RADIUS_SQ
let found = false
for (const { gutter: mateG, tf } of candidates) {
const mateRot = mateG.rotation ?? 0
const arm = applyTfDir(tf, Math.cos(mateRot), -Math.sin(mateRot))
const mateHalf = mateG.length / 2
const center = applyTf(tf, mateG.position[0], mateG.position[2])
const plusX = center.x + arm.x * mateHalf
const plusZ = center.z + arm.z * mateHalf
const minusX = center.x - arm.x * mateHalf
const minusZ = center.z - arm.z * mateHalf
// Corner target T: axis intersection when the runs cross, else the
// mate's endpoint nearest the dragged end (collinear extension).
const crossDirs = aArmX * arm.z - aArmZ * arm.x
let targetX: number
let targetZ: number
if (Math.abs(crossDirs) < AXIS_PARALLEL_EPSILON) {
const dPlus = (plusX - proposedEndX) ** 2 + (plusZ - proposedEndZ) ** 2
const dMinus = (minusX - proposedEndX) ** 2 + (minusZ - proposedEndZ) ** 2
if (dPlus <= dMinus) {
targetX = plusX
targetZ = plusZ
} else {
targetX = minusX
targetZ = minusZ
}
} else {
const dx = center.x - aAnchorX
const dz = center.z - aAnchorZ
const t = (dx * arm.z - dz * arm.x) / crossDirs
targetX = aAnchorX + t * aArmX
targetZ = aAnchorZ + t * aArmZ
}
// Reject a mate whose own ends are nowhere near the crossing — its
// infinite axis lines up by coincidence, it's not a real corner-mate.
const dPlusT = (plusX - targetX) ** 2 + (plusZ - targetZ) ** 2
const dMinusT = (minusX - targetX) ** 2 + (minusZ - targetZ) ** 2
if (Math.min(dPlusT, dMinusT) > CORNER_MATE_REACH_SQ) continue
const score = (targetX - proposedEndX) ** 2 + (targetZ - proposedEndZ) ** 2
if (score < bestDistSq) {
bestDistSq = score
bestTargetX = targetX
bestTargetZ = targetZ
found = true
}
}
if (!found) return { length: proposedLength }
// Snap the dragged gutter's own end onto the corner: project
// (corner anchor) onto its roof-frame axis. Length is frame-invariant
// (a scalar along the run), so the projection is the same in roof or
// segment frame — no need to map back. The mate is left untouched.
const projected = sign * ((bestTargetX - aAnchorX) * aArmX + (bestTargetZ - aAnchorZ) * aArmZ)
return { length: Math.max(minLength, projected) }
}
// ─── Segment-frame ↔ roof-frame transform ────────────────────────────
//
// A segment places its children at `seg.position` rotated by
// `seg.rotation` about +Y. THREE's rotation-y convention: a point
// (x, z) maps to (x·cos + z·sin, x·sin + z·cos). These helpers lift a
// gutter's segment-local X/Z into the shared roof frame and back so two
// gutters on different segments can be compared in one frame.
type SegmentTransform = { x: number; z: number; cos: number; sin: number }
function segmentTransform(seg: Pick<RoofSegmentNode, 'position' | 'rotation'>): SegmentTransform {
const r = seg.rotation ?? 0
return {
x: seg.position?.[0] ?? 0,
z: seg.position?.[2] ?? 0,
cos: Math.cos(r),
sin: Math.sin(r),
}
}
function applyTf(tf: SegmentTransform, x: number, z: number): { x: number; z: number } {
return { x: tf.x + (x * tf.cos + z * tf.sin), z: tf.z + (-x * tf.sin + z * tf.cos) }
}
function applyTfDir(tf: SegmentTransform, x: number, z: number): { x: number; z: number } {
return { x: x * tf.cos + z * tf.sin, z: -x * tf.sin + z * tf.cos }
}
+225
View File
@@ -0,0 +1,225 @@
'use client'
import {
type AnyNodeId,
emitter,
type GutterNode,
type RoofEvent,
type RoofNode,
type RoofSegmentNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
import GutterPreview from './preview'
type PreviewTarget = {
roof: { position: [number, number, number]; rotation: number }
segment: { position: [number, number, number]; rotation: number }
snap: EaveSnap
}
/**
* Gutter move tool. Mirrors the ridge-vent move flow — ghost follows
* the cursor over any roof segment, click commits the new position +
* parent segment in one undoable step. The eave-snap math from the
* placement tool runs again on the new segment so the gutter lands on
* the correct side of the new ridge.
*
* On commit the gutter rotation may flip from 0 ↔ π if the user moves
* it from the front eave to the back eave (or vice versa). The
* pre-drag rotation is restored on cancel.
*
* Ghost transform: mirrors the GutterRenderer chain (roof → segment →
* snap), so the cursor preview lands at the exact world coords the
* commit will store. GutterPreview applies no internal rotation, so
* the gutter's CURRENT `rotation` doesn't bleed into the new snap.
*/
export default function MoveGutterTool({ node }: { node: GutterNode }) {
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
const [target, setTarget] = useState<PreviewTarget | null>(null)
useEffect(() => {
useScene.temporal.getState().pause()
const original = {
position: [...node.position] as [number, number, number],
rotation: node.rotation ?? 0,
roofSegmentId: node.roofSegmentId,
parentId: node.parentId,
metadata: node.metadata,
}
const meta =
typeof node.metadata === 'object' && node.metadata !== null
? (node.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const gutterObj = sceneRegistry.nodes.get(node.id)
if (gutterObj) gutterObj.visible = false
let lastSnap: [number, number] | null = null
const updatePreview = (event: RoofEvent) => {
const roof = event.node as RoofNode
const hit = resolveRoofSegmentHit(
roof,
event.position[0],
event.position[1],
event.position[2],
)
if (!hit) return
// Same snap math as the placement tool — picking-up and putting-
// down round-trip identically. roofType-aware: hip/flat picks
// ±X or ±Z based on which slope the cursor is on; shed always
// snaps to its low (+Z) eave; gable / gambrel / mansard / dutch
// stay on ±Z.
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
const sx = Math.round(snap.eaveX * 20) / 20
const sz = Math.round(snap.eaveZ * 20) / 20
if (!lastSnap || lastSnap[0] !== sx || lastSnap[1] !== sz) {
triggerSFX('sfx:grid-snap')
lastSnap = [sx, sz]
}
setTarget({
roof: {
position: (roof.position ?? [0, 0, 0]) as [number, number, number],
rotation: roof.rotation ?? 0,
},
segment: {
position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number],
rotation: hit.segment.rotation ?? 0,
},
snap,
})
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit(
event.node as RoofNode,
event.position[0],
event.position[1],
event.position[2],
)
if (!hit) return
const targetSegmentId = hit.segment.id as AnyNodeId
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
const st = useScene.getState()
const prevSegmentId = original.roofSegmentId as AnyNodeId | undefined
if (prevSegmentId && prevSegmentId !== targetSegmentId) {
const oldSeg = st.nodes[prevSegmentId] as RoofSegmentNode | undefined
if (oldSeg) {
st.updateNode(prevSegmentId, {
children: (oldSeg.children ?? []).filter((id) => id !== node.id),
})
}
const newSeg = st.nodes[targetSegmentId] as RoofSegmentNode | undefined
if (newSeg && !(newSeg.children ?? []).includes(node.id)) {
st.updateNode(targetSegmentId, {
children: [...(newSeg.children ?? []), node.id],
})
}
st.dirtyNodes.add(prevSegmentId)
}
useScene.temporal.getState().resume()
st.updateNode(node.id as AnyNodeId, {
roofSegmentId: targetSegmentId,
parentId: targetSegmentId,
position: [snap.eaveX, snap.eaveY, snap.eaveZ],
rotation: snap.rotation,
visible: true,
metadata: {},
})
useScene.temporal.getState().pause()
st.dirtyNodes.add(targetSegmentId)
st.dirtyNodes.add(node.id as AnyNodeId)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
triggerSFX('sfx:item-place')
exitMoveMode()
event.stopPropagation()
}
const onCancel = () => {
if (isNew) {
const parentId = original.roofSegmentId as AnyNodeId | undefined
if (parentId) {
const parent = useScene.getState().nodes[parentId] as RoofSegmentNode | undefined
if (parent) {
useScene.getState().updateNode(parentId, {
children: (parent.children ?? []).filter((id) => id !== node.id),
})
}
}
useScene.getState().deleteNode(node.id as AnyNodeId)
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
return
}
useScene.getState().updateNode(node.id as AnyNodeId, {
position: original.position,
rotation: original.rotation,
roofSegmentId: original.roofSegmentId as AnyNodeId | undefined,
parentId: original.parentId as AnyNodeId | undefined,
metadata: original.metadata,
})
if (original.roofSegmentId) {
useScene.getState().dirtyNodes.add(original.roofSegmentId as AnyNodeId)
}
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onRoofClick)
emitter.on('tool:cancel', onCancel)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onRoofClick)
emitter.off('tool:cancel', onCancel)
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
useScene.temporal.getState().resume()
}
}, [exitMoveMode, node])
if (!target) return null
return (
<group position={target.roof.position} rotation-y={target.roof.rotation}>
<group position={target.segment.position} rotation-y={target.segment.rotation}>
<group
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
rotation-y={target.snap.rotation}
>
<GutterPreview node={node} />
</group>
</group>
</group>
)
}
+107
View File
@@ -0,0 +1,107 @@
import type { GutterNode, GutterOutlet } from '@pascal-app/core'
import {
OUTLET_WALL_THICKNESS,
type OutletShape,
outletDims,
outletShapeForProfile,
profileFloorMidZ,
} from './profile-geometry'
/**
* Outlet position lookup — used by the downspout (renderer / tool /
* routing) to mount a pipe at one of the gutter's outlets without
* walking the gutter's geometry pipeline.
*
* Returns the outlet's center in GUTTER-MESH-LOCAL frame (i.e. after
* the gutter's own `position` + `rotation` have already been applied
* by the renderer chain): X is along the gutter length, Y is the
* gutter's vertical extent (size, the trough floor), Z is outward
* (the profile-dependent floor midpoint).
*
* The clamp mirrors the geometry's `resolveOutletPlacements` so the
* lookup and the drilled hole agree on X. Ignores mitres — when a
* gutter end is mitred its cap collapses, which shifts the clamp bound
* by ≤ 6 mm; the drift is below what reads visually, and the gutter's
* own CSG drill still cuts in the exact spot since it sees the full
* mitre context.
*/
export type GutterOutletPlacement = {
/** Gutter-mesh-local X — along the length axis, signed from center. */
x: number
/** Gutter-mesh-local Y — the trough floor at size. */
y: number
/** Gutter-mesh-local Z — profile-dependent floor midpoint. */
z: number
/** Nominal bore radius (= halfX); `bore * 2` is the outlet diameter. */
bore: number
/** Outlet cross-section — round on half-round, rect on k-style / box. */
shape: OutletShape
/** Bore half-extent along the gutter length (X) — the pipe nests just inside this. */
innerHalfX: number
/** Bore half-extent outward (Z) — the pipe nests just inside this. */
innerHalfZ: number
}
function placeOutlet(
gutter: GutterNode,
outlet: GutterOutlet,
len: number,
size: number,
t: number,
): GutterOutletPlacement | null {
const shape = outletShapeForProfile(gutter.profile)
const inner = outletDims(shape, outlet.diameter ?? 0.07)
const outerHalfX = inner.halfX + OUTLET_WALL_THICKNESS
// Default-cap reservation — no mitre awareness here; see header note.
const capLeftLen = (gutter.endCapLeft ?? true) ? t : 0
const capRightLen = (gutter.endCapRight ?? true) ? t : 0
const minX = -len / 2 + capLeftLen + outerHalfX
const maxX = len / 2 - capRightLen - outerHalfX
if (maxX <= minX) return null
const x = Math.max(minX, Math.min(maxX, outlet.offset ?? 0))
return {
x,
y: -size,
z: profileFloorMidZ(gutter.profile ?? 'k-style', size),
bore: inner.halfX,
shape,
innerHalfX: inner.halfX,
innerHalfZ: inner.halfZ,
}
}
function gutterDims(gutter: GutterNode): { len: number; size: number; t: number } {
const len = Math.max(0.05, gutter.length)
const size = Math.max(0.04, gutter.size)
const t = Math.min(Math.max(0.001, gutter.thickness), size * 0.4)
return { len, size, t }
}
/** Placement of the gutter's outlet with the given id, or null if absent / doesn't fit. */
export function resolveGutterOutletById(
gutter: GutterNode,
outletId: string | undefined,
): GutterOutletPlacement | null {
if (!outletId) return null
const outlet = (gutter.outlets ?? []).find((o) => o.id === outletId)
if (!outlet) return null
const { len, size, t } = gutterDims(gutter)
return placeOutlet(gutter, outlet, len, size, t)
}
/** Placements for every fitting outlet, tagged with its id. */
export function resolveGutterOutlets(
gutter: GutterNode,
): Array<GutterOutletPlacement & { id: string }> {
const { len, size, t } = gutterDims(gutter)
const out: Array<GutterOutletPlacement & { id: string }> = []
for (const outlet of gutter.outlets ?? []) {
const p = placeOutlet(gutter, outlet, len, size, t)
if (p) out.push({ ...p, id: outlet.id })
}
return out
}
+65
View File
@@ -0,0 +1,65 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { GutterNode } from './schema'
export const gutterParametrics: ParametricDescriptor<GutterNode> = {
groups: [
{
label: 'Profile',
fields: [
{
key: 'profile',
kind: 'enum',
options: ['k-style', 'half-round', 'box'],
display: 'segmented',
},
],
},
{
label: 'Dimensions',
fields: [
{ key: 'length', kind: 'number', unit: 'm', min: 0.2, max: 12, step: 0.05 },
{ key: 'size', kind: 'number', unit: 'm', min: 0.05, max: 0.3, step: 0.005 },
{
key: 'thickness',
kind: 'number',
unit: 'm',
min: 0.001,
max: 0.02,
step: 0.001,
},
],
},
{
label: 'End caps',
fields: [
{ key: 'endCapLeft', kind: 'boolean' },
{ key: 'endCapRight', kind: 'boolean' },
],
},
{
label: 'Hangers',
fields: [
{
key: 'hangerStyle',
kind: 'enum',
options: ['strap', 'none'],
display: 'segmented',
},
{
key: 'hangerSpacing',
kind: 'number',
unit: 'm',
min: 0.2,
max: 2.0,
step: 0.05,
visibleIf: (n) => (n.hangerStyle ?? 'strap') !== 'none',
},
],
},
],
// Lazy-loaded section that lists every downspout attached to this
// gutter and offers an Add button at the bottom. Outlets are created
// and removed through this panel (and the downspout placement tool) —
// one outlet per downspout — so there's no separate outlet field group.
trailingSection: () => import('./downspouts-panel'),
}
+82
View File
@@ -0,0 +1,82 @@
'use client'
import { useEffect, useMemo } from 'react'
import * as THREE from 'three'
import { buildGutterGeometry } from './geometry'
import type { GutterNode } from './schema'
/**
* Translucent ghost of a gutter — built from the same `buildGutterGeometry`
* the renderer commits, so the shape on screen during placement is the
* shape that lands on click.
*
* No internal transform wrapper. Callers (placement tool + move tool)
* mirror the GutterRenderer's transform chain around this component
* (roof → segment → snap), so the ghost shares one bulletproof chain
* with the committed mesh instead of a flattened-yaw shortcut that can
* drift in edge cases.
*
* FrontSide matches the renderer; DoubleSide would render the inside
* of the trough walls and visually thicken the ghost relative to the
* placed gutter.
*/
const GutterPreview = ({ node }: { node: GutterNode }) => {
const geometry = useMemo(
() => buildGutterGeometry(node),
[
node.length,
node.size,
node.thickness,
node.profile,
node.endCapLeft,
node.endCapRight,
node.hangerStyle,
node.hangerSpacing,
JSON.stringify(node.outlets),
],
)
const material = useMemo(
() =>
new THREE.MeshStandardMaterial({
color: 0xff_ff_ff,
emissive: 0xff_ff_ff,
emissiveIntensity: 0.12,
roughness: 0.7,
metalness: 0.2,
transparent: true,
opacity: 0.55,
depthWrite: false,
side: THREE.FrontSide,
}),
[],
)
const edgesGeometry = useMemo(() => new THREE.EdgesGeometry(geometry, 25), [geometry])
useEffect(
() => () => {
geometry.dispose()
edgesGeometry.dispose()
material.dispose()
},
[geometry, edgesGeometry, material],
)
return (
<>
<mesh
geometry={geometry}
material={material}
// See box-vent preview note — never let the preview swallow
// roof events meant for the placement tool's hit-tester.
raycast={() => {}}
/>
<lineSegments geometry={edgesGeometry} renderOrder={1000}>
<lineBasicMaterial color={0x6c_a3_ff} depthTest={false} opacity={0.9} transparent />
</lineSegments>
</>
)
}
export default GutterPreview
@@ -0,0 +1,73 @@
import type { GutterNode } from '@pascal-app/core'
/**
* Shared outlet/profile geometry constants + math used by the gutter
* mesh builder, the outlet lookup the downspout mounts against, and the
* downspout's own routing. Kept in one place so the trough-floor probe
* and the collar dimensions can't drift between the three call sites
* (they did before this file — `profileFloorMidZ` was copied verbatim
* into both `geometry.ts` and `outlet-lookup.ts`).
*/
// Wall thickness of the drop-tube collar — 3 mm matches typical
// residential gauge. After the CSG drill the stub becomes a tube with
// outer radius = bore + wall and inner radius = bore.
export const OUTLET_WALL_THICKNESS = 0.003
// Collar length — how far the drop-tube stub hangs below the trough
// floor. 6 cm reads as "drop outlet" without poking conspicuously far
// below the eave; the downspout slip-fits up into this collar.
export const OUTLET_STUB_LENGTH = 0.06
/**
* Z (outward) coordinate of the trough floor's midpoint per profile —
* where a drop outlet drills through. k-style bottom is `wBot = 0.8 ·
* size` wide so its midpoint sits at `0.4 · size`; box bottom is `size`
* wide → `size / 2`; half-round's lowest point is the centre of the
* semicircle at Z = r = size.
*/
export function profileFloorMidZ(profile: GutterNode['profile'], size: number): number {
if (profile === 'half-round') return size
if (profile === 'box') return size / 2
return size * 0.4
}
// ─── Outlet cross-section shape ──────────────────────────────────────
export type OutletShape = 'round' | 'rect'
/**
* Which cross-section a gutter's drop outlet (and the downspout that
* plugs into it) takes. Half-round gutters use a round leader; the
* flat-bottomed profiles (k-style, box) use a rectangular one — matching
* real residential hardware (round leaders on half-round, 2×3 / 3×4
* rectangular leaders on k-style / commercial box).
*/
export function outletShapeForProfile(profile: GutterNode['profile']): OutletShape {
return (profile ?? 'k-style') === 'half-round' ? 'round' : 'rect'
}
// Outward (Z) depth of a rectangular outlet as a fraction of its
// along-length (X) width — a 2×3 leader is ~0.66; 0.7 reads cleanly and
// still fits inside the k-style trough floor.
export const RECT_OUTLET_DEPTH_RATIO = 0.7
export type OutletDims = {
shape: OutletShape
/** Half-extent along the gutter length (X). Round: = radius. */
halfX: number
/** Half-extent outward (Z). Round: = radius. */
halfZ: number
}
/**
* Cross-section half-extents for a `nominalDiameter`-sized outlet of the
* given shape. Round → a circle of that diameter (halfX = halfZ =
* radius); rect → that diameter wide along the run, `RECT_OUTLET_DEPTH_
* RATIO` as deep outward.
*/
export function outletDims(shape: OutletShape, nominalDiameter: number): OutletDims {
const half = Math.max(0.01, nominalDiameter / 2)
if (shape === 'round') return { shape, halfX: half, halfZ: half }
return { shape, halfX: half, halfZ: half * RECT_OUTLET_DEPTH_RATIO }
}
+244
View File
@@ -0,0 +1,244 @@
'use client'
import {
type AnyNodeId,
type GutterNode,
type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
import {
type ColorPreset,
createMaterial,
createMaterialFromPresetRef,
createSurfaceRoleMaterial,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow'
import { computeGutterMitres, type GutterWithSegment, NO_MITRES } from './corner-mitre'
import { computeSharedEaveY } from './eave-align'
import { computeEaveY } from './eave-snap'
import { buildGutterGeometry } from './geometry'
const defaultMaterial = new THREE.MeshStandardMaterial({
color: 0xff_ff_ff,
roughness: 0.7,
metalness: 0.25,
})
/**
* Gutter renderer. Mounts at the eave of the host roof-segment — the
* gutter hangs level off the eave line (gravity wins; no slope tilt).
* Transform stack:
*
* segment.position → segment.rotation (Y) → gutter.position
* → gutter.rotation (Y) → mesh
*
* The registered ref sits on the inner group that applies position +
* rotation, so `NodeArrowHandles` reads gutter-mesh-local coords for
* its chevron placements (same pattern as ridge-vent).
*
* `useLiveNodeOverrides` merges in-flight handle drags onto the store
* node so the mesh tracks the drag without flushing zustand each
* frame.
*/
const GutterRenderer = ({ node: storeNode }: { node: GutterNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'gutter', ref)
const handlers = useNodeEvents(storeNode, 'gutter')
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
const overrides = useLiveNodeOverrides(
(s) => s.get(storeNode.id as AnyNodeId) as Partial<GutterNode> | undefined,
)
const node: GutterNode = overrides ? ({ ...storeNode, ...overrides } as GutterNode) : storeNode
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
// While the user is dragging the segment's wall-height / overhang /
// pitch handle, the drag pipeline writes to useLiveNodeOverrides
// instead of the scene store — the scene entry above stays at the
// pre-drag value until pointer-up. Subscribing to the segment's live
// overrides too lets the gutter's `computeEaveY` see the in-flight
// height and slide up/down on every frame of the drag.
const segmentOverrides = useLiveNodeOverrides((s) =>
node.roofSegmentId
? (s.get(node.roofSegmentId as AnyNodeId) as Partial<RoofSegmentNode> | undefined)
: undefined,
)
const effectiveSegment: RoofSegmentNode | undefined = segment
? segmentOverrides
? ({ ...segment, ...segmentOverrides } as RoofSegmentNode)
: segment
: undefined
// Corner-mitre inputs: every other gutter under the SAME ROOF, plus
// the roof's segments (for the frame lift). A flat array of node refs
// keeps `useShallow` stable — it only re-renders when one of those
// nodes actually changes. Cross-segment so gutters on different
// segments mitre where their segments meet (the mitres useMemo pairs
// each gutter back to its segment).
const mitreNodes = useScene(
useShallow((state) => {
const segmentId = node.roofSegmentId as AnyNodeId | undefined
const seg = segmentId ? (state.nodes[segmentId] as RoofSegmentNode | undefined) : undefined
const roofId = seg?.parentId as AnyNodeId | undefined
const roof = roofId
? (state.nodes[roofId] as { children?: readonly string[] } | undefined)
: undefined
if (!roof) return [] as (GutterNode | RoofSegmentNode)[]
const out: (GutterNode | RoofSegmentNode)[] = []
for (const sid of roof.children ?? []) {
const s = state.nodes[sid as AnyNodeId]
if (s?.type !== 'roof-segment') continue
out.push(s as RoofSegmentNode)
for (const gid of (s as RoofSegmentNode).children ?? []) {
const g = state.nodes[gid as AnyNodeId]
if (g?.type === 'gutter' && g.id !== storeNode.id) out.push(g as GutterNode)
}
}
return out
}),
)
// Mitres AND the run's shared eave height come from the same sibling
// walk: both key off which gutters meet at corners. `siblings` carries
// the FULL host segment (the alignment needs wallHeight / overhang /
// pitch / roofType to derive each eave Y), which is a superset of what
// the mitre detector reads — so one list feeds both.
const { mitres, sharedEaveY } = useMemo(() => {
if (!effectiveSegment) return { mitres: NO_MITRES, sharedEaveY: undefined }
const segById = new Map<string, RoofSegmentNode>()
for (const n of mitreNodes) {
if (n.type === 'roof-segment') segById.set(n.id, n as RoofSegmentNode)
}
const siblings: GutterWithSegment[] = []
for (const n of mitreNodes) {
if (n.type !== 'gutter') continue
const g = n as GutterNode
const seg = g.roofSegmentId ? segById.get(g.roofSegmentId) : undefined
if (seg) siblings.push({ gutter: g, segment: seg })
}
return {
mitres: computeGutterMitres(node, effectiveSegment, siblings),
// `siblings` is typed for the mitre detector (position/rotation),
// but the segment objects are the full RoofSegmentNodes from
// `mitreNodes`, so `computeSharedEaveY` gets the eave-Y inputs it
// needs at runtime.
sharedEaveY: computeSharedEaveY(
node,
effectiveSegment,
siblings as unknown as Parameters<typeof computeSharedEaveY>[2],
),
}
}, [
node.position[0],
node.position[1],
node.position[2],
node.rotation,
node.length,
effectiveSegment?.position?.[0],
effectiveSegment?.position?.[2],
effectiveSegment?.rotation,
effectiveSegment?.wallHeight,
effectiveSegment?.overhang,
effectiveSegment?.pitch,
effectiveSegment?.roofType,
mitreNodes,
])
const geometry = useMemo(
() => buildGutterGeometry(node, mitres),
[
node.length,
node.size,
node.thickness,
node.profile,
node.endCapLeft,
node.endCapRight,
node.hangerStyle,
node.hangerSpacing,
// Value-compare the outlets array so the CSG drills only rebuild
// when an outlet's offset / diameter changes or one is added.
JSON.stringify(node.outlets),
mitres.left,
mitres.right,
],
)
useEffect(() => () => geometry.dispose(), [geometry])
// Paint surface: explicit material wins, then preset, then the cached
// default. FrontSide everywhere — DoubleSide on any NodeMaterial inside
// the MRT scenePass compiles a back-face shader variant that doesn't
// declare outputs for every MRT target and poisons the render context
// (see `materials.ts` line 77, and the glazing FrontSide fix in
// 9400f1c5). The U-channel cross-section in `geometry.ts` is traced as
// a single closed polygon around the material — both the exterior shell
// and the interior trough walls are part of the same outward-wound
// boundary, so ExtrudeGeometry produces outward-facing normals on every
// visible face. FrontSide is therefore sufficient and DoubleSide is not
// needed.
const material = useMemo(() => {
if (!textures || (!node.material && !node.materialPreset)) {
return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
}
return node.material
? createMaterial(node.material, shading)
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
if (!segment || !effectiveSegment) return null
// `node.position` is segment-local — the placement tool resolves the
// eave click via `segObj.worldToLocal`. The renderer mounts under
// `roof-elements` (only the roof transform inherited), so we
// re-apply the segment's roof-local transform here. Mirrors the
// ridge-vent / box-vent pattern; without this gutters on rotated
// segments would land on the first segment instead.
//
// Y is derived live from `effectiveSegment` (scene + drag overrides)
// instead of trusting `node.position[1]` — so changing wallHeight,
// overhang, or pitch on the parent segment moves the gutter on the
// very next frame, including while a segment-height handle is
// mid-drag. Matches the chimney/box-vent pattern of pulling host-
// segment geometry at draw time rather than caching it at placement.
const segPos = segment.position ?? [0, 0, 0]
const segRotY = segment.rotation ?? 0
// Prefer the connected run's shared height (aligns gutters meeting at
// a corner whose segments derive different eave Ys); fall back to this
// segment's own eave Y for an isolated gutter.
const liveEaveY = sharedEaveY ?? computeEaveY(effectiveSegment)
return (
<group position={segPos} rotation-y={segRotY}>
<group
position={[node.position[0] ?? 0, liveEaveY, node.position[2] ?? 0]}
ref={ref}
rotation-y={node.rotation ?? 0}
visible={node.visible}
>
<mesh
castShadow
geometry={geometry}
material={material}
name="gutter-surface"
receiveShadow
{...handlers}
/>
</group>
</group>
)
}
export default GutterRenderer
+3
View File
@@ -0,0 +1,3 @@
// Schema lives in core (referenced by the AnyNode union). Re-export so
// every gutter-related import stays inside @pascal-app/nodes/gutter.
export { GutterNode } from '@pascal-app/core'
+161
View File
@@ -0,0 +1,161 @@
'use client'
import {
type AnyNodeId,
emitter,
GutterNode,
type RoofEvent,
type RoofNode,
useScene,
} from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { gutterDefinition } from './definition'
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
import GutterPreview from './preview'
type PreviewTarget = {
roof: { position: [number, number, number]; rotation: number }
segment: { position: [number, number, number]; rotation: number }
snap: EaveSnap
}
/**
* Gutter placement tool. Cursor preview snaps to the OUTER eave — the
* drip edge of the roof, NOT the wall line. The eave sits at
* `Z = ±(depth/2 + overhang)` in segment-local frame; the gutter
* mounts against the fascia there, hanging outward from the building.
*
* Which eave: `eave-snap.ts` picks the side closest to the cursor
* (roof-type aware — 4-way for hip/flat, low side only for shed, ±Z
* for the rest) and returns the segment-local snap pose. The same
* snap drives the ghost AND the commit, so picking-up + putting-down
* land at identical world coordinates.
*
* Ghost transform: we mount the GutterPreview under the exact same
* chain the GutterRenderer applies — roof.position + roof.rotation →
* segment.position + segment.rotation → snap.eave + snap.rotation.
* No `worldToBuildingLocal` + `previewYaw`-sum shortcut: that
* collapses three Y rotations into one scalar and converts world
* coords back into building-local, which is mathematically
* equivalent for pure-Y stacks but drifts under any future non-Y
* roof/segment transform. Sharing the renderer's chain means the
* ghost and the placed mesh are guaranteed pixel-identical.
*/
const GutterTool = () => {
const activeBuildingId = useViewer((s) => s.selection.buildingId)
const setSelection = useViewer((s) => s.setSelection)
const [target, setTarget] = useState<PreviewTarget | null>(null)
const lastSnapRef = useRef<[number, number] | null>(null)
const previewNode = useMemo(
() =>
GutterNode.parse({
...gutterDefinition.defaults(),
name: 'Gutter',
position: [0, 0, 0],
rotation: 0,
}),
[],
)
useEffect(() => {
if (!activeBuildingId) return
const updatePreview = (event: RoofEvent) => {
const roof = event.node as RoofNode
const hit = resolveRoofSegmentHit(
roof,
event.position[0],
event.position[1],
event.position[2],
)
if (!hit) return
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
// Grid-snap chime fires when the segment-local snap moves to a
// new 5 cm cell along the eave — keeps SFX in lockstep with what
// the commit will actually store.
const sx = Math.round(snap.eaveX * 20) / 20
const sz = Math.round(snap.eaveZ * 20) / 20
const prev = lastSnapRef.current
if (!prev || prev[0] !== sx || prev[1] !== sz) {
triggerSFX('sfx:grid-snap')
lastSnapRef.current = [sx, sz]
}
setTarget({
roof: {
position: (roof.position ?? [0, 0, 0]) as [number, number, number],
rotation: roof.rotation ?? 0,
},
segment: {
position: (hit.segment.position ?? [0, 0, 0]) as [number, number, number],
rotation: hit.segment.rotation ?? 0,
},
snap,
})
event.stopPropagation()
}
const onClick = (event: RoofEvent) => {
const hit = resolveRoofSegmentHit(
event.node as RoofNode,
event.position[0],
event.position[1],
event.position[2],
)
if (!hit) return
const state = useScene.getState()
const snap = resolveEaveSnap(hit.segment, hit.localX, hit.localZ)
const gutter = GutterNode.parse({
...gutterDefinition.defaults(),
name: 'Gutter',
roofSegmentId: hit.segment.id,
// (X, Y, Z) all come from the eave snap — on ±Z eaves X stays
// free along the cursor; on ±X eaves Z stays free instead.
// Rotation orients the gutter's outward axis away from the
// building on whichever side the click landed.
position: [snap.eaveX, snap.eaveY, snap.eaveZ],
rotation: snap.rotation,
})
state.createNode(gutter, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [gutter.id] })
triggerSFX('sfx:item-place')
event.stopPropagation()
}
emitter.on('roof:move', updatePreview)
emitter.on('roof:enter', updatePreview)
emitter.on('roof:click', onClick)
return () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
}
}, [activeBuildingId, setSelection])
if (!activeBuildingId || !target) return null
return (
<group position={target.roof.position} rotation-y={target.roof.rotation}>
<group position={target.segment.position} rotation-y={target.segment.rotation}>
<group
position={[target.snap.eaveX, target.snap.eaveY, target.snap.eaveZ]}
rotation-y={target.snap.rotation}
>
<GutterPreview node={previewNode} />
</group>
</group>
</group>
)
}
export default GutterTool
+15
View File
@@ -4,11 +4,15 @@ import { buildingDefinition } from './building'
import { ceilingDefinition } from './ceiling'
import { chimneyDefinition } from './chimney'
import { columnDefinition } from './column'
import { cupolaDefinition } from './cupola'
import { doorDefinition } from './door'
import { dormerDefinition } from './dormer'
import { downspoutDefinition } from './downspout'
import { elevatorDefinition } from './elevator'
import { eyebrowVentDefinition } from './eyebrow-vent'
import { fenceDefinition } from './fence'
import { guideDefinition } from './guide'
import { gutterDefinition } from './gutter'
import { itemDefinition } from './item'
import { levelDefinition } from './level'
import { ridgeVentDefinition } from './ridge-vent'
@@ -23,6 +27,7 @@ import { solarPanelDefinition } from './solar-panel'
import { spawnDefinition } from './spawn'
import { stairDefinition } from './stair'
import { stairSegmentDefinition } from './stair-segment'
import { turbineVentDefinition } from './turbine-vent'
import { wallDefinition } from './wall'
import { windowDefinition } from './window'
import { zoneDefinition } from './zone'
@@ -74,10 +79,15 @@ export const builtinPlugin: Plugin = {
// Roof-mounted accessories (custom renderer + bespoke roof-event tool).
boxVentDefinition as unknown as AnyNodeDefinition,
ridgeVentDefinition as unknown as AnyNodeDefinition,
turbineVentDefinition as unknown as AnyNodeDefinition,
cupolaDefinition as unknown as AnyNodeDefinition,
eyebrowVentDefinition as unknown as AnyNodeDefinition,
chimneyDefinition as unknown as AnyNodeDefinition,
solarPanelDefinition as unknown as AnyNodeDefinition,
skylightDefinition as unknown as AnyNodeDefinition,
dormerDefinition as unknown as AnyNodeDefinition,
gutterDefinition as unknown as AnyNodeDefinition,
downspoutDefinition as unknown as AnyNodeDefinition,
],
}
@@ -86,11 +96,15 @@ export { buildingDefinition } from './building'
export { ceilingDefinition } from './ceiling'
export { chimneyDefinition } from './chimney'
export { columnDefinition } from './column'
export { cupolaDefinition } from './cupola'
export { doorDefinition } from './door'
export { dormerDefinition } from './dormer'
export { downspoutDefinition } from './downspout'
export { elevatorDefinition } from './elevator'
export { eyebrowVentDefinition } from './eyebrow-vent'
export { fenceDefinition } from './fence'
export { guideDefinition } from './guide'
export { gutterDefinition } from './gutter'
export { itemDefinition } from './item'
export { levelDefinition } from './level'
export { ridgeVentDefinition } from './ridge-vent'
@@ -105,6 +119,7 @@ export { solarPanelDefinition } from './solar-panel'
export { spawnDefinition } from './spawn'
export { stairDefinition } from './stair'
export { stairSegmentDefinition } from './stair-segment'
export { turbineVentDefinition } from './turbine-vent'
export { wallDefinition } from './wall'
export { windowDefinition } from './window'
export { zoneDefinition } from './zone'
+143
View File
@@ -1,5 +1,6 @@
import {
getScaledDimensions,
type HandleDescriptor,
type ItemNode as ItemNodeType,
type NodeDefinition,
} from '@pascal-app/core'
@@ -8,6 +9,134 @@ import { itemFloorplanMoveTarget } from './floorplan-move'
import { itemParametrics } from './parametrics'
import { ItemNode } from './schema'
// Gizmo sits just past the front-right footprint corner; the guide ring
// traces a circle slightly outside the footprint's bounding circle.
const ROTATE_CORNER_OFFSET = 0.25
const ROTATE_RING_OFFSET = 0.06
// How far past the item's front edge the move cross floats.
const MOVE_FRONT_OFFSET = 0.35
// Whole-item rotation handle — the two-headed curved arrow. `arc-resize`
// does the angular drag math (raycasts a horizontal plane at the gizmo's
// Y, measures cursor bearing around the item's local origin, returns the
// delta). Holding Shift snaps to 15° increments (handled generically in
// node-arrow-handles for any `shape: 'rotate'`), matching the R/T rotate
// step for placed items. Item rotation is stored as `[x, y, z]`; only the
// Y component turns.
function itemRotateHandle(): HandleDescriptor<ItemNodeType> {
return {
kind: 'arc-resize',
axis: 'angular',
shape: 'rotate',
// Negate the cursor delta to match three.js Y-rotation handedness
// (positive Ry takes +X → Z, while atan2(z, x) increases +X → +Z).
apply: (initial, delta) => {
const [rx, ry, rz] = initial.rotation ?? [0, 0, 0]
return { rotation: [rx, ry - delta, rz] }
},
placement: {
// Front-right corner of the footprint at mid-height. The registered
// item mesh carries position + rotation only (scale lives on an
// inner mesh), so the scaled footprint maps straight to world.
position: (n) => {
const [w, h, d] = getScaledDimensions(n)
return [w / 2, h / 2, d / 2 + ROTATE_CORNER_OFFSET]
},
// Fixed 45° tilt leans the curve toward the item's front face.
rotationY: () => -Math.PI / 4,
},
decoration: {
kind: 'ring',
radius: (n) => {
const [w, , d] = getScaledDimensions(n)
return Math.hypot(w / 2, d / 2) + ROTATE_RING_OFFSET
},
y: (n) => getScaledDimensions(n)[1] / 2,
},
}
}
// Free ground-plane move gizmo — the 4-way cross just outside the front edge.
// Press-drag-release slides the item across the floor (live preview, commit
// on release). `snapExtents` aligns the item's edges to the grid the same
// way placement does, swapping width / depth at 90° turns.
function itemMoveHandle(): HandleDescriptor<ItemNodeType> {
return {
kind: 'translate',
placement: {
// Sit just outside the item's front edge (centred in X, clear of the
// model), low to the floor so it reads as a floor-move grip.
position: (n) => {
const [, , d] = getScaledDimensions(n)
return [0, 0.02, d / 2 + MOVE_FRONT_OFFSET]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const [dimX, , dimZ] = getScaledDimensions(n)
const swap = Math.abs(Math.sin(n.rotation[1] ?? 0)) > 0.9
return [swap ? dimZ : dimX, swap ? dimX : dimZ]
},
}
}
// ---- Wall-mounted items (attachTo 'wall' / 'wall-side') ----
// These live in the wall's local frame: position is [along-wall, up, depth]
// and the item faces along the wall normal (its local +Z). Both gizmos use
// `portal: 'grandparent'` so they render in the wall frame like door / window
// handles, and sit a little off the wall surface (+Z) so they're grabbable.
// How far off the wall surface (along the normal) the wall gizmos float, and
// how far to either side of the item they sit.
const WALL_GIZMO_LIFT = 0.12
const WALL_SIDE_OFFSET = 0.3
// Spin the item flat against the wall — rotation about its local +Z (the wall
// normal), written to rotation[2]. Sits just past the item's right edge.
function itemWallRotateHandle(): HandleDescriptor<ItemNodeType> {
return {
kind: 'arc-resize',
axis: 'angular',
shape: 'rotate',
rotationPlane: 'node-normal',
portal: 'grandparent',
apply: (initial, delta) => {
const [rx, ry, rz] = initial.rotation ?? [0, 0, 0]
return { rotation: [rx, ry, rz + delta] }
},
placement: {
position: (n) => {
const [w] = getScaledDimensions(n)
return [w / 2 + WALL_SIDE_OFFSET, 0, WALL_GIZMO_LIFT]
},
},
}
}
// Slide the item across the wall face — constrained to the wall plane (along
// the wall + up/down), depth pinned. Sits just past the item's left edge.
function itemWallMoveHandle(): HandleDescriptor<ItemNodeType> {
return {
kind: 'translate',
plane: 'node-normal',
portal: 'grandparent',
placement: {
position: (n) => {
const [w] = getScaledDimensions(n)
return [-(w / 2 + WALL_SIDE_OFFSET), 0, WALL_GIZMO_LIFT]
},
},
apply: (_n, pos) => ({ position: [pos[0], pos[1], pos[2]] }),
snapExtents: (n) => {
const [dimX, dimY] = getScaledDimensions(n)
// A 90° roll about the normal swaps the item's along-wall + vertical
// footprint.
const swap = Math.abs(Math.sin(n.rotation[2] ?? 0)) > 0.9
return [swap ? dimY : dimX, swap ? dimX : dimY]
},
}
}
/**
* Item — Phase 5 batch kind. Catalog-backed, GLB-rendered, multi-host.
*
@@ -99,6 +228,20 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
parametrics: itemParametrics,
// In-world rotate + move gizmos for selected items.
// - Floor items: world-Y rotate + free floor-plane move cross.
// - Wall items: wall-normal rotate (spin flat against the wall) + a move
// cross constrained to the wall face. Both ride the wall frame.
// - Ceiling items: no gizmos yet (move via the move tool).
handles: (node) => {
const attachTo = (node as ItemNodeType).asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
return [itemWallRotateHandle(), itemWallMoveHandle()]
}
if (attachTo) return []
return [itemRotateHandle(), itemMoveHandle()]
},
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
+14 -2
View File
@@ -7,6 +7,7 @@ import {
type ItemNode,
type LightEffect,
useInteractive,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
@@ -75,10 +76,21 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
)
}
export const ItemRenderer = ({ node }: { node: ItemNode }) => {
export const ItemRenderer = ({ node: storeNode }: { node: ItemNode }) => {
const ref = useRef<Group>(null!)
useRegistry(node.id, node.type, ref)
useRegistry(storeNode.id, storeNode.type, ref)
// Merge live drag overrides so the mesh transforms in real time during a
// drag (e.g. the in-world rotate gizmo). The handle writes the in-flight
// rotation to `useLiveNodeOverrides` on every pointer move and commits to
// the store only on release — without this merge the item would stay put
// until commit.
const liveOverrides = useLiveNodeOverrides((state) => state.get(storeNode.id as AnyNodeId))
const node = useMemo(
() => (liveOverrides ? ({ ...storeNode, ...liveOverrides } as ItemNode) : storeNode),
[storeNode, liveOverrides],
)
return (
<group position={node.position} ref={ref} rotation={node.rotation} visible={node.visible}>
@@ -8,7 +8,7 @@ describe('RidgeVentNode schema', () => {
expect(parsed.id).toMatch(/^rvent_/)
expect(parsed.length).toBe(2.0)
expect(parsed.width).toBe(0.3)
expect(parsed.height).toBe(0.08)
expect(parsed.height).toBe(0.1)
expect(parsed.style).toBe('standard')
expect(parsed.endCaps).toBe(true)
expect(parsed.position).toEqual([0, 0, 0])
+146 -1
View File
@@ -1,7 +1,148 @@
import { type NodeDefinition, RidgeVentNode as RidgeVentNodeSchema } from '@pascal-app/core'
import {
type HandleDescriptor,
type NodeDefinition,
RidgeVentNode as RidgeVentNodeSchema,
type RidgeVentNode as RidgeVentNodeType,
} from '@pascal-app/core'
import { buildRidgeVentFloorplan } from './floorplan'
import { surfacePaintCapability } from '../shared/surface-paint'
import { ridgeVentParametrics } from './parametrics'
import { RidgeVentNode } from './schema'
// Edge-to-arrow-center offset, matching the box-vent / chimney cadence.
const SIDE_HANDLE_OFFSET = 0.25
const HEIGHT_HANDLE_OFFSET = 0.15
// Snug to the vent corner — keeps the rotate icon close to the item.
const ROTATE_CORNER_OFFSET = 0.1
// Ridge vents are long but thin — minimums let users shrink without
// collapsing the geometry past the point where the cross-section
// degenerates. Default length is 2.0, default width 0.3, default
// height 0.08, so these are well below the defaults.
const MIN_LENGTH = 0.2
const MIN_WIDTH = 0.1
const MIN_HEIGHT = 0.02
// Mid-Y of the vent body in vent-mesh-local frame. The base sits at the
// ridge line (Y=0) and the cap peaks at Y=height — so side / rotate
// chevrons place at half-height to read as "beside the body".
function getBodyMidY(n: RidgeVentNodeType): number {
return Math.max(MIN_HEIGHT, n.height) / 2
}
// Length arrow on ±X (the ridge direction). Asymmetric: drag one end
// outward and the opposite end stays world-fixed by recentering
// `position` along the vent's own +X arm in segment frame (yaw-aware
// math, matches box-vent / chimney). The ridge vent typically straddles
// a portion of the ridge, so dragging one end is the natural extend /
// shorten gesture.
function ridgeVentLengthHandle(side: 'left' | 'right'): HandleDescriptor<RidgeVentNodeType> {
const sign = side === 'right' ? 1 : -1
return {
kind: 'linear-resize',
axis: 'x',
anchor: side === 'right' ? 'min' : 'max',
min: MIN_LENGTH,
currentValue: (n) => n.length,
apply: (initial, newLength) => {
const rotY = initial.rotation ?? 0
const armX = Math.cos(rotY)
const armZ = -Math.sin(rotY)
const anchorX = initial.position[0] - sign * (initial.length / 2) * armX
const anchorZ = initial.position[2] - sign * (initial.length / 2) * armZ
const newCenterX = anchorX + sign * (newLength / 2) * armX
const newCenterZ = anchorZ + sign * (newLength / 2) * armZ
return {
length: newLength,
position: [newCenterX, initial.position[1], newCenterZ],
}
},
placement: {
position: (n) => [sign * (n.length / 2 + SIDE_HANDLE_OFFSET), getBodyMidY(n), 0],
rotationY: () => (side === 'right' ? 0 : Math.PI),
},
}
}
// Width arrow on +Z (across the ridge). Symmetric — the vent geometry
// straddles the ridge line (Z=0) so growing the width pushes both edges
// outward by the same amount. A single chevron on +Z reads as "this is
// the width dimension"; keeping it symmetric also stays inside the same
// handle-count budget the chimney / dormer / box-vent already document.
function ridgeVentWidthHandle(): HandleDescriptor<RidgeVentNodeType> {
return {
kind: 'linear-resize',
axis: 'z',
anchor: 'center',
min: MIN_WIDTH,
currentValue: (n) => n.width,
apply: (_n, newValue) => ({ width: newValue }),
placement: {
position: (n) => [0, getBodyMidY(n), n.width / 2 + SIDE_HANDLE_OFFSET],
},
}
}
// Height arrow above the cap peak. anchor='min' so the base stays
// pinned to the ridge line (Y=0) and the peak follows the cursor. Plain
// chevron — at default 0.08 m a dashed tracker leader would be visual
// noise rather than a dimension cue.
function ridgeVentHeightHandle(): HandleDescriptor<RidgeVentNodeType> {
return {
kind: 'linear-resize',
axis: 'y',
anchor: 'min',
min: MIN_HEIGHT,
currentValue: (n) => n.height,
apply: (_n, newValue) => ({ height: Math.max(MIN_HEIGHT, newValue) }),
placement: {
position: (n) => [0, Math.max(n.height, MIN_HEIGHT) + HEIGHT_HANDLE_OFFSET, 0],
},
}
}
// Whole-vent rotation gizmo at the +X+Z corner of the body footprint.
// Negate the cursor delta to match three.js Y-rotation handedness. The
// registered group already centres on the vent and applies its yaw,
// so the default rotation pivot is correct.
function ridgeVentRotateHandle(): HandleDescriptor<RidgeVentNodeType> {
return {
kind: 'arc-resize',
axis: 'angular',
shape: 'rotate',
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
placement: {
position: (n) => [
n.length / 2 + ROTATE_CORNER_OFFSET,
getBodyMidY(n),
n.width / 2 + ROTATE_CORNER_OFFSET,
],
// Two-headed icon's natural bias is along +X; aim along the
// +X+Z corner bisector so it sits visually flush with the
// rotate gesture's swing direction.
rotationY: () => -Math.PI / 4,
},
// Guide ring centred on the vent, sized to pass through the corner icon
// so the icon rides the ring — matches solar-panel / skylight.
decoration: {
kind: 'ring',
radius: (n) =>
Math.hypot(n.length / 2 + ROTATE_CORNER_OFFSET, n.width / 2 + ROTATE_CORNER_OFFSET),
y: (n) => getBodyMidY(n),
},
}
}
// `portal: 'grandparent'` on every handle — see box-vent's note. The vent
// rides the roof→segment→node frame chain, so the handle rig must too, or
// the handles (and rotate arc) render offset from the vent.
const ridgeVentHandles: HandleDescriptor<RidgeVentNodeType>[] = [
ridgeVentLengthHandle('right'),
ridgeVentLengthHandle('left'),
ridgeVentWidthHandle(),
ridgeVentHeightHandle(),
ridgeVentRotateHandle(),
].map((h): HandleDescriptor<RidgeVentNodeType> => ({ ...h, portal: 'grandparent' }))
/**
* Ridge vent — a ventilation strip running along the ridge of a roof
* segment. Parented to a `roof-segment`; position is segment-local.
@@ -34,6 +175,8 @@ export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
// Single painted surface — registry-driven paint dispatch (see chimney).
paint: surfacePaintCapability,
// Mounts on a roof segment via `roofSegmentId`. Sits ON TOP of the
// ridge — no `buildCut`, just the dirty cascade so the parent
// roof's merged shell rebuilds when the vent moves / resizes.
@@ -41,6 +184,8 @@ export const ridgeVentDefinition: NodeDefinition<typeof RidgeVentNode> = {
},
parametrics: ridgeVentParametrics,
handles: ridgeVentHandles,
floorplan: buildRidgeVentFloorplan,
renderer: {
kind: 'parametric',
+152
View File
@@ -0,0 +1,152 @@
import type {
AnyNodeId,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
RidgeVentNode,
RoofNode,
RoofSegmentNode,
} from '@pascal-app/core'
// Tab pitch for the shingled style — matches SHINGLED_TAB_SIZE in
// geometry.ts so the plan's divider spacing reads like the 3D ridge cap.
const SHINGLED_TAB_SIZE = 0.3
/**
* Floor-plan builder for a ridge vent — a ventilation strip running along
* a roof ridge. Seen from above it's a long thin band straddling the
* ridge crest, with a centre crest line, end caps where closed, and tab
* dividers for the shingled style.
*
* Coordinate frame mirrors the 3D transform stack
* (roof → roof-segment → vent), same as the chimney builder. `position`
* is segment-local; the run (`length`) is along local +X and the small
* cross-`width` straddles the ridge along local Z (centred at Z = 0).
* `rotation` is yaw, negated for the floor plan's y-down convention.
*/
export function buildRidgeVentFloorplan(
node: RidgeVentNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const segment = ctx.parent as RoofSegmentNode | null
if (!segment || segment.type !== 'roof-segment') return null
const roofId = segment.parentId as AnyNodeId | null
const roof = roofId ? (ctx.resolve(roofId) as RoofNode | undefined) : undefined
if (!roof || roof.type !== 'roof') return null
const cosR = Math.cos(-roof.rotation)
const sinR = Math.sin(-roof.rotation)
const segCx = roof.position[0] + segment.position[0] * cosR - segment.position[2] * sinR
const segCz = roof.position[2] + segment.position[0] * sinR + segment.position[2] * cosR
const segRot = -(roof.rotation + segment.rotation)
const cosS = Math.cos(segRot)
const sinS = Math.sin(segRot)
const cx = segCx + node.position[0] * cosS - node.position[2] * sinS
const cz = segCz + node.position[0] * sinS + node.position[2] * cosS
const rot = -(roof.rotation + segment.rotation + node.rotation)
const cos = Math.cos(rot)
const sin = Math.sin(rot)
const toPlan = (lx: number, lz: number): FloorplanPoint => [
cx + lx * cos - lz * sin,
cz + lx * sin + lz * cos,
]
const view = ctx.viewState
const palette = view?.palette
const isSelected = view?.selected ?? false
const isHighlighted = view?.highlighted ?? false
const isHovered = view?.hovered ?? false
const showSelectedChrome = isSelected || isHighlighted
const baseInk = '#475569'
const stroke =
showSelectedChrome && palette
? palette.selectedStroke
: isHovered && palette
? palette.wallHoverStroke
: baseInk
const fill = showSelectedChrome ? '#fed7aa' : '#dbe1e8'
const fillOpacity = showSelectedChrome ? 0.55 : 0.6
const lineWidth = showSelectedChrome ? 0.03 : 0.02
const halfLen = Math.max(node.length, 0.1) / 2
const halfW = Math.max(node.width, 0.04) / 2
const corners: FloorplanPoint[] = [
toPlan(-halfLen, -halfW),
toPlan(halfLen, -halfW),
toPlan(halfLen, halfW),
toPlan(-halfLen, halfW),
]
const children: FloorplanGeometry[] = [
// Transparent hit-target over the whole strip.
{
kind: 'polygon',
points: corners,
fill: stroke,
fillOpacity: 0,
stroke: 'none',
strokeWidth: 0,
pointerEvents: 'all',
},
// Strip fill.
{
kind: 'polygon',
points: corners,
fill,
fillOpacity,
stroke: 'none',
strokeWidth: 0,
pointerEvents: 'none',
},
]
const seg = (
a: readonly [number, number],
b: readonly [number, number],
w: number,
opacity?: number,
) => {
const pa = toPlan(a[0], a[1])
const pb = toPlan(b[0], b[1])
children.push({
kind: 'line',
x1: pa[0],
y1: pa[1],
x2: pb[0],
y2: pb[1],
stroke,
strokeWidth: w,
strokeLinecap: 'round',
opacity,
pointerEvents: 'none',
})
}
// Long edges along the run (always) + end caps (only when closed).
seg([-halfLen, -halfW], [halfLen, -halfW], lineWidth)
seg([-halfLen, halfW], [halfLen, halfW], lineWidth)
if (node.endCaps !== false) {
seg([-halfLen, -halfW], [-halfLen, halfW], lineWidth)
seg([halfLen, -halfW], [halfLen, halfW], lineWidth)
}
// Ridge crest line down the centre.
seg([-halfLen, 0], [halfLen, 0], lineWidth * 0.8, 0.7)
// Shingled style: tab dividers across the width at the cap pitch.
if (node.style === 'shingled') {
const total = halfLen * 2
const count = Math.max(2, Math.round(total / SHINGLED_TAB_SIZE))
const step = total / count
for (let i = 1; i < count; i++) {
const x = -halfLen + i * step
seg([x, -halfW], [x, halfW], lineWidth * 0.6, 0.5)
}
}
return { kind: 'group', children }
}
+200 -462
View File
@@ -1,512 +1,238 @@
import type { RidgeVentNode } from '@pascal-app/core'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
const ARC_SEGMENTS = 8
const SHELL_THICKNESS = 0.25
const SHINGLED_PEAK_SEGS = 3
const ARC_SEGS = 16
const SHINGLED_TAB_SIZE = 0.3
/**
* Pure builder for the ridge vent mesh. Three styles share a common
* cross-section approach: extrude a 2D profile (in the Y-Z plane)
* along the segment's X axis (ridge direction), then add optional
* end caps.
* Pure builder for the ridge vent mesh. Each style is a peaked **band** of
* constant thickness `t` that drapes over the ridge like a real ridge cap:
* a shaped top surface, a parallel underside offset down by `t`, visible
* eave thickness faces along both edges, and end caps.
*
* - `standard`: smooth curved shell with offset inner surface
* - `shingled`: angular slopes meeting at a rounded peak with tab ridges
* - `metal`: angular bent-metal cap with drip-edge lips and a center bead
* This is the middle ground between the two earlier extremes — the original
* was a paper-thin shell (no perceptible thickness), then a flat-bottomed
* solid (read as a closed box). The band keeps the V / arched cap silhouette
* and the open underside (so it sits astride the ridge) while showing real
* thickness at the eaves and ends.
*
* Pure: no React, no scene access, no store mutation.
* - `standard`: smooth rounded arch
* - `shingled`: angular peak with raised shingle-course ridges across the top
* - `metal`: bent-metal cap with a wide flat seam and drip lips
*
* `endCaps` closes both ends. Pure: no React, no scene access, no mutation.
*/
export function buildRidgeVentGeometry(node: RidgeVentNode): THREE.BufferGeometry {
const halfLen = node.length / 2
const halfW = node.width / 2
const h = node.height
// Band thickness. Generous enough to read as a solid cap; the eave faces
// are `t` tall, which is the depth the user actually sees from the side.
const t = Math.max(0.02, h * 0.4)
const pieces: THREE.BufferGeometry[] = []
const top =
node.style === 'metal'
? metalTop(halfW, h, t)
: node.style === 'shingled'
? shingledTop(halfW, h, t)
: standardTop(halfW, h, t)
if (node.style === 'metal') {
pieces.push(buildMetalProfile(halfLen, halfW, h))
} else if (node.style === 'shingled') {
pieces.push(buildShingledProfile(halfLen, halfW, h))
} else {
pieces.push(buildCurvedCapProfile(halfLen, halfW, h))
}
if (node.endCaps) {
const cap =
node.style === 'metal'
? buildMetalEndCaps(halfLen, halfW, h)
: node.style === 'shingled'
? buildShingledEndCaps(halfLen, halfW, h)
: buildCurvedEndCaps(halfLen, halfW, h)
if (cap) pieces.push(cap)
}
return pieces.length === 1 ? pieces[0]! : (mergeGeometries(pieces, false) ?? pieces[0]!)
}
// ─── Standard curved cap ─────────────────────────────────────────────
function buildCurvedCapProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const t = h * SHELL_THICKNESS
const outerPts: [number, number][] = []
for (let i = 0; i <= ARC_SEGMENTS; i++) {
const frac = i / ARC_SEGMENTS
const angle = Math.PI * frac
const z = -halfW + frac * (2 * halfW)
const y = h * Math.sin(angle)
outerPts.push([z, y])
}
const innerPts = offsetProfileInward(outerPts, t)
buildBand(positions, normals, uvs, top, t, halfLen, node.endCaps)
for (let i = 0; i < ARC_SEGMENTS; i++) {
const [oz0, oy0] = outerPts[i]!
const [oz1, oy1] = outerPts[i + 1]!
const [iz0, iy0] = innerPts[i]!
const [iz1, iy1] = innerPts[i + 1]!
const dz = oz1 - oz0
const dy = oy1 - oy0
const fLen = Math.sqrt(dz * dz + dy * dy) || 1
const fnz = -dy / fLen
const fny = dz / fLen
pushQuad(
positions,
normals,
uvs,
[-halfLen, oy0, oz0],
[halfLen, oy0, oz0],
[halfLen, oy1, oz1],
[-halfLen, oy1, oz1],
[0, fny, fnz],
)
pushQuad(
positions,
normals,
uvs,
[-halfLen, iy1, iz1],
[halfLen, iy1, iz1],
[halfLen, iy0, iz0],
[-halfLen, iy0, iz0],
[0, -fny, -fnz],
)
}
// Eave bottoms
for (const idx of [0, ARC_SEGMENTS]) {
const [oz, oy] = outerPts[idx]!
const [iz, iy] = innerPts[idx]!
if (idx === 0) {
pushQuad(
positions,
normals,
uvs,
[-halfLen, iy, iz],
[halfLen, iy, iz],
[halfLen, oy, oz],
[-halfLen, oy, oz],
[0, -1, 0],
)
} else {
pushQuad(
positions,
normals,
uvs,
[-halfLen, oy, oz],
[halfLen, oy, oz],
[halfLen, iy, iz],
[-halfLen, iy, iz],
[0, -1, 0],
)
}
if (node.style === 'shingled') {
addShingledTabs(positions, normals, uvs, halfLen, top, h)
}
return buildBufferGeometry(positions, normals, uvs)
}
function buildCurvedEndCaps(
halfLen: number,
halfW: number,
h: number,
): THREE.BufferGeometry | null {
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const t = h * SHELL_THICKNESS
// ─── Top profiles (open polylines eave → peak → eave, in [z, y]) ─────────
// Eaves sit at y = t so that the underside (top t) lands on y = 0 at the
// eaves, seating the cap on the roof while leaving a peaked void beneath.
const outerPts: [number, number][] = []
for (let i = 0; i <= ARC_SEGMENTS; i++) {
const frac = i / ARC_SEGMENTS
const angle = Math.PI * frac
outerPts.push([-halfW + frac * (2 * halfW), h * Math.sin(angle)])
// Smooth rounded arch.
function standardTop(halfW: number, h: number, t: number): [number, number][] {
const pts: [number, number][] = []
for (let i = 0; i <= ARC_SEGS; i++) {
const frac = i / ARC_SEGS
const z = -halfW + frac * 2 * halfW
const y = t + (h - t) * Math.sin(frac * Math.PI)
pts.push([z, y])
}
const innerPts = offsetProfileInward(outerPts, t)
for (const sign of [-1, 1] as const) {
const x = sign * halfLen
for (let i = 0; i < ARC_SEGMENTS; i++) {
const a: [number, number, number] = [x, outerPts[i]![1], outerPts[i]![0]]
const b: [number, number, number] = [x, outerPts[i + 1]![1], outerPts[i + 1]![0]]
const c: [number, number, number] = [x, innerPts[i + 1]![1], innerPts[i + 1]![0]]
const d: [number, number, number] = [x, innerPts[i]![1], innerPts[i]![0]]
if (sign > 0) pushQuad(positions, normals, uvs, a, b, c, d, [sign, 0, 0])
else pushQuad(positions, normals, uvs, d, c, b, a, [sign, 0, 0])
}
}
return positions.length === 0 ? null : buildBufferGeometry(positions, normals, uvs)
}
// ─── Shingled profile ───────────────────────────────────────────────
function shingledOuterPts(halfW: number, h: number): [number, number][] {
const peakR = halfW * 0.1
const slopeY = (h * (halfW - peakR)) / halfW
const pts: [number, number][] = [[-halfW, 0]]
for (let i = 0; i <= SHINGLED_PEAK_SEGS; i++) {
const frac = i / SHINGLED_PEAK_SEGS
const angle = Math.PI * (1 - frac)
pts.push([peakR * Math.cos(angle), slopeY + (h - slopeY) * Math.sin(angle)])
}
pts.push([halfW, 0])
return pts
}
function buildShingledProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const t = h * SHELL_THICKNESS
// Angular peak with a narrow flat ridge at the top.
function shingledTop(halfW: number, h: number, t: number): [number, number][] {
const peakHalf = halfW * 0.12
return [
[-halfW, t],
[-peakHalf, h],
[peakHalf, h],
[halfW, t],
]
}
const outerPts = shingledOuterPts(halfW, h)
const innerPts = offsetProfileInward(outerPts, t)
// Bent-metal cap: steep folds up to a wide flat standing seam.
function metalTop(halfW: number, h: number, t: number): [number, number][] {
const seamHalf = halfW * 0.5
const shoulderY = t + (h - t) * 0.5
return [
[-halfW, t],
[-halfW * 0.82, shoulderY],
[-seamHalf, h],
[seamHalf, h],
[halfW * 0.82, shoulderY],
[halfW, t],
]
}
for (let i = 0; i < outerPts.length - 1; i++) {
const [oz0, oy0] = outerPts[i]!
const [oz1, oy1] = outerPts[i + 1]!
const [iz0, iy0] = innerPts[i]!
const [iz1, iy1] = innerPts[i + 1]!
// ─── Band assembly ───────────────────────────────────────────────────────
const dz = oz1 - oz0
const dy = oy1 - oy0
const fLen = Math.sqrt(dz * dz + dy * dy) || 1
const fnz = -dy / fLen
const fny = dz / fLen
function buildBand(
positions: number[],
normals: number[],
uvs: number[],
top: [number, number][],
t: number,
halfLen: number,
withCaps: boolean,
): void {
const n = top.length
// Underside: the same profile dropped straight down by `t` (eaves → y 0).
const inner: [number, number][] = top.map(([z, y]) => [z, y - t])
// Top surface + underside, swept along the ridge length.
for (let i = 0; i < n - 1; i++) {
const [z0, y0] = top[i]!
const [z1, y1] = top[i + 1]!
pushQuad(
positions,
normals,
uvs,
[-halfLen, oy0, oz0],
[halfLen, oy0, oz0],
[halfLen, oy1, oz1],
[-halfLen, oy1, oz1],
[0, fny, fnz],
[-halfLen, y0, z0],
[halfLen, y0, z0],
[halfLen, y1, z1],
[-halfLen, y1, z1],
[0, 1, 0],
)
const [iz0, iy0] = inner[i]!
const [iz1, iy1] = inner[i + 1]!
pushQuad(
positions,
normals,
uvs,
[-halfLen, iy1, iz1],
[halfLen, iy1, iz1],
[halfLen, iy0, iz0],
[-halfLen, iy0, iz0],
[0, -fny, -fnz],
)
}
// Eave bottoms
{
const [oz, oy] = outerPts[0]!
const [iz, iy] = innerPts[0]!
pushQuad(
positions,
normals,
uvs,
[-halfLen, iy, iz],
[halfLen, iy, iz],
[halfLen, oy, oz],
[-halfLen, oy, oz],
[0, -1, 0],
)
}
{
const last = outerPts.length - 1
const [oz, oy] = outerPts[last]!
const [iz, iy] = innerPts[last]!
pushQuad(
positions,
normals,
uvs,
[-halfLen, oy, oz],
[halfLen, oy, oz],
[halfLen, iy, iz],
[-halfLen, iy, iz],
[halfLen, iy0, iz0],
[halfLen, iy1, iz1],
[-halfLen, iy1, iz1],
[0, -1, 0],
)
}
// Tab divider ridges along the length
// Eave thickness faces (the visible depth along each long edge).
for (const idx of [0, n - 1]) {
const [z, yTop] = top[idx]!
const [, yInner] = inner[idx]!
const hint: [number, number, number] = [0, 0, z < 0 ? -1 : 1]
pushQuad(
positions,
normals,
uvs,
[-halfLen, yInner, z],
[halfLen, yInner, z],
[halfLen, yTop, z],
[-halfLen, yTop, z],
hint,
)
}
// End caps: the band's cross-section ring at each end.
if (withCaps) {
for (const sign of [-1, 1] as const) {
const x = sign * halfLen
const hint: [number, number, number] = [sign, 0, 0]
for (let i = 0; i < n - 1; i++) {
const [z0, y0] = top[i]!
const [z1, y1] = top[i + 1]!
const [iz0, iy0] = inner[i]!
const [iz1, iy1] = inner[i + 1]!
pushQuad(
positions,
normals,
uvs,
[x, y0, z0],
[x, y1, z1],
[x, iy1, iz1],
[x, iy0, iz0],
hint,
)
}
}
}
}
// ─── Shingled course ridges ──────────────────────────────────────────────
// Thin raised lines running across the cap at intervals, suggesting
// overlapping shingle courses. Sit on the top profile edges.
function addShingledTabs(
positions: number[],
normals: number[],
uvs: number[],
halfLen: number,
top: [number, number][],
h: number,
): void {
const totalLen = halfLen * 2
const numTabs = Math.max(2, Math.round(totalLen / SHINGLED_TAB_SIZE))
const tabLen = totalLen / numTabs
const ridgeH = h * 0.06
const ridgeD = 0.006
const ridgeD = Math.min(0.01, tabLen * 0.15)
for (let tab = 1; tab < numTabs; tab++) {
const x = -halfLen + tab * tabLen
for (let i = 0; i < outerPts.length - 1; i++) {
const [oz0, oy0] = outerPts[i]!
const [oz1, oy1] = outerPts[i + 1]!
const dz = oz1 - oz0
const dy = oy1 - oy0
const fLen = Math.sqrt(dz * dz + dy * dy) || 1
const fnz = -dy / fLen
const fny = dz / fLen
const r0y = oy0 + fny * ridgeH
const r0z = oz0 + fnz * ridgeH
const r1y = oy1 + fny * ridgeH
const r1z = oz1 + fnz * ridgeH
for (let i = 0; i < top.length - 1; i++) {
const [z0, y0] = top[i]!
const [z1, y1] = top[i + 1]!
const dz = z1 - z0
const dy = y1 - y0
const len = Math.sqrt(dz * dz + dy * dy) || 1
const nz = -dy / len
const ny = dz / len
const r0y = y0 + ny * ridgeH
const r0z = z0 + nz * ridgeH
const r1y = y1 + ny * ridgeH
const r1z = z1 + nz * ridgeH
pushQuad(
positions,
normals,
uvs,
[x, r0y, r0z],
[x, r1y, r1z],
[x, oy1, oz1],
[x, oy0, oz0],
[x, y1, z1],
[x, y0, z0],
[1, 0, 0],
)
pushQuad(
positions,
normals,
uvs,
[x, r0y, r0z],
[x, r1y, r1z],
[x - ridgeD, oy1, oz1],
[x - ridgeD, oy0, oz0],
[0, fny, fnz],
[x - ridgeD, r0y, r0z],
[x - ridgeD, r1y, r1z],
[x - ridgeD, y1, z1],
[x - ridgeD, y0, z0],
[-1, 0, 0],
)
}
}
return buildBufferGeometry(positions, normals, uvs)
}
function buildShingledEndCaps(
halfLen: number,
halfW: number,
h: number,
): THREE.BufferGeometry | null {
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const t = h * SHELL_THICKNESS
const outerPts = shingledOuterPts(halfW, h)
const innerPts = offsetProfileInward(outerPts, t)
for (const sign of [-1, 1] as const) {
const x = sign * halfLen
for (let i = 0; i < outerPts.length - 1; i++) {
const a: [number, number, number] = [x, outerPts[i]![1], outerPts[i]![0]]
const b: [number, number, number] = [x, outerPts[i + 1]![1], outerPts[i + 1]![0]]
const c: [number, number, number] = [x, innerPts[i + 1]![1], innerPts[i + 1]![0]]
const d: [number, number, number] = [x, innerPts[i]![1], innerPts[i]![0]]
if (sign > 0) pushQuad(positions, normals, uvs, a, b, c, d, [sign, 0, 0])
else pushQuad(positions, normals, uvs, d, c, b, a, [sign, 0, 0])
}
}
return positions.length === 0 ? null : buildBufferGeometry(positions, normals, uvs)
}
// ─── Metal profile ───────────────────────────────────────────────────
/**
* Bent-sheet-metal ridge cap cross-section. Real metal vents read as a
* smooth arched cap riding above two flat mounting flanges — not the
* old angular peak + bead, which looked like a stamped novelty. Profile:
*
* flange ─┐ ┌─ flange
* │ ◜╶───── rounded ridge cap ─────╶◝ │
* │ ╱ ╲ │
* └─╯ ╰────┘
*
* Built from outer points (Z, Y); the inner shell is offset inward so the
* cap reads as a real folded-metal thickness instead of paper-thin.
*/
function metalProfile(halfW: number, h: number, t: number) {
// Horizontal mounting flange that hugs the shingles on each side. Wide
// enough to read as a real screw-down tab, not a sliver.
const flangeW = halfW * 0.22
// Where the arched cap takes off from the flange tip — gentle rise so
// the corner reads as a soft fold instead of a hard kink.
const liftH = h * 0.12
const liftDZ = halfW * 0.04
// Span and height of the rounded cap. Span stays narrower than the
// overall width so the cap "rides" on the flanges rather than swallow-
// ing them.
const capHalfSpan = halfW * 0.7
const capPeakY = h
const capStartY = h * 0.45
const capSegs = 12
const outer: [number, number][] = []
// Left flange — flat horizontal tab.
outer.push([-halfW, 0])
outer.push([-halfW + flangeW, 0])
// Soft fold up to the cap's starting shoulder.
outer.push([-halfW + flangeW + liftDZ, liftH])
outer.push([-capHalfSpan, capStartY])
// Rounded ridge: half-sine from left shoulder over the top to right
// shoulder. Using sin() (not cos+sin sphere math) keeps the cap's
// tangents continuous with the slope below — no visible kinks.
for (let i = 1; i < capSegs; i++) {
const frac = i / capSegs
const z = -capHalfSpan + frac * (2 * capHalfSpan)
const y = capStartY + (capPeakY - capStartY) * Math.sin(frac * Math.PI)
outer.push([z, y])
}
// Mirror down the right side.
outer.push([capHalfSpan, capStartY])
outer.push([halfW - flangeW - liftDZ, liftH])
outer.push([halfW - flangeW, 0])
outer.push([halfW, 0])
const inner = offsetProfileInward(outer, t)
return { outer, inner }
}
function segNormal(z0: number, y0: number, z1: number, y1: number): number[] {
const dz = z1 - z0
const dy = y1 - y0
const len = Math.sqrt(dz * dz + dy * dy) || 1
return [0, dz / len, -dy / len]
}
function buildMetalProfile(halfLen: number, halfW: number, h: number): THREE.BufferGeometry {
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const t = h * SHELL_THICKNESS
const { outer, inner } = metalProfile(halfW, h, t)
for (let i = 0; i < outer.length - 1; i++) {
const [oz0, oy0] = outer[i]!
const [oz1, oy1] = outer[i + 1]!
const [iz0, iy0] = inner[i]!
const [iz1, iy1] = inner[i + 1]!
const outerN = segNormal(oz0, oy0, oz1, oy1)
const innerN = segNormal(iz0, iy0, iz1, iy1).map((v) => -v)
pushQuad(
positions,
normals,
uvs,
[-halfLen, oy0, oz0],
[halfLen, oy0, oz0],
[halfLen, oy1, oz1],
[-halfLen, oy1, oz1],
outerN,
)
pushQuad(
positions,
normals,
uvs,
[-halfLen, iy1, iz1],
[halfLen, iy1, iz1],
[halfLen, iy0, iz0],
[-halfLen, iy0, iz0],
innerN,
)
}
// Eave bottoms
pushQuad(
positions,
normals,
uvs,
[-halfLen, inner[0]![1], inner[0]![0]],
[halfLen, inner[0]![1], inner[0]![0]],
[halfLen, outer[0]![1], outer[0]![0]],
[-halfLen, outer[0]![1], outer[0]![0]],
[0, -1, 0],
)
const last = outer.length - 1
pushQuad(
positions,
normals,
uvs,
[-halfLen, outer[last]![1], outer[last]![0]],
[halfLen, outer[last]![1], outer[last]![0]],
[halfLen, inner[last]![1], inner[last]![0]],
[-halfLen, inner[last]![1], inner[last]![0]],
[0, -1, 0],
)
return buildBufferGeometry(positions, normals, uvs)
}
function buildMetalEndCaps(halfLen: number, halfW: number, h: number): THREE.BufferGeometry | null {
const positions: number[] = []
const normals: number[] = []
const uvs: number[] = []
const t = h * SHELL_THICKNESS
const { outer, inner } = metalProfile(halfW, h, t)
for (const sign of [-1, 1] as const) {
const x = sign * halfLen
for (let i = 0; i < outer.length - 1; i++) {
const a: [number, number, number] = [x, outer[i]![1], outer[i]![0]]
const b: [number, number, number] = [x, outer[i + 1]![1], outer[i + 1]![0]]
const c: [number, number, number] = [x, inner[i + 1]![1], inner[i + 1]![0]]
const d: [number, number, number] = [x, inner[i]![1], inner[i]![0]]
if (sign > 0) pushQuad(positions, normals, uvs, a, b, c, d, [sign, 0, 0])
else pushQuad(positions, normals, uvs, d, c, b, a, [sign, 0, 0])
}
}
return positions.length === 0 ? null : buildBufferGeometry(positions, normals, uvs)
}
// ─── Helpers ─────────────────────────────────────────────────────────
function offsetProfileInward(pts: [number, number][], t: number): [number, number][] {
const result: [number, number][] = []
for (let i = 0; i < pts.length; i++) {
const [z, y] = pts[i]!
let dz: number
let dy: number
if (i === 0) {
dz = pts[1]![0] - z
dy = pts[1]![1] - y
} else if (i === pts.length - 1) {
dz = z - pts[i - 1]![0]
dy = y - pts[i - 1]![1]
} else {
dz = pts[i + 1]![0] - pts[i - 1]![0]
dy = pts[i + 1]![1] - pts[i - 1]![1]
}
const len = Math.sqrt(dz * dz + dy * dy) || 1
const nz = dy / len
const ny = -dz / len
result.push([z + nz * t, y + ny * t])
}
return result
}
// ─── Geometry plumbing ───────────────────────────────────────────────────
function buildBufferGeometry(
positions: number[],
@@ -517,38 +243,50 @@ function buildBufferGeometry(
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geo.computeBoundingSphere()
return geo
}
// Winding-safe quad: triangulates (a,b,c,d) and orients both triangles so
// the shared flat normal points toward `hint`. UVs are dimension-based so
// painted presets tile at world scale across the ridge length and the cap.
function pushQuad(
positions: number[],
normals: number[],
uvs: number[],
a: number[] | readonly number[],
b: number[] | readonly number[],
c: number[] | readonly number[],
d: number[] | readonly number[],
n: number[] | readonly number[],
a: number[],
b: number[],
c: number[],
d: number[],
hint: number[],
) {
// Dimension-based planar UVs: U follows |b-a|, V follows |d-a|, so
// textures tile at world scale across the ridge length, the arched
// shell, and the end caps. Hardcoded 0..1 UVs stretched each face
// independently — a 2m ridge tile looked the same as a 4cm lip.
const abx = b[0]! - a[0]!
const aby = b[1]! - a[1]!
const abz = b[2]! - a[2]!
const adx = d[0]! - a[0]!
const ady = d[1]! - a[1]!
const adz = d[2]! - a[2]!
const u = Math.sqrt(abx * abx + aby * aby + abz * abz)
const v = Math.sqrt(adx * adx + ady * ady + adz * adz)
let nx = (c[1]! - a[1]!) * (b[2]! - a[2]!) - (c[2]! - a[2]!) * (b[1]! - a[1]!)
let ny = (c[2]! - a[2]!) * (b[0]! - a[0]!) - (c[0]! - a[0]!) * (b[2]! - a[2]!)
let nz = (c[0]! - a[0]!) * (b[1]! - a[1]!) - (c[1]! - a[1]!) * (b[0]! - a[0]!)
const flip = nx * hint[0]! + ny * hint[1]! + nz * hint[2]! < 0
if (flip) {
nx = -nx
ny = -ny
nz = -nz
}
const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1
nx /= len
ny /= len
nz /= len
// Winding is (a, c, b) + (a, d, c) so the triangle face direction
// matches the stored normal — same fix as box-vent's pushQuad.
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
normals.push(n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!)
uvs.push(0, 0, u, v, u, 0)
positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!)
normals.push(n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!, n[0]!, n[1]!, n[2]!)
uvs.push(0, 0, 0, v, u, v)
const u = Math.hypot(b[0]! - a[0]!, b[1]! - a[1]!, b[2]! - a[2]!)
const v = Math.hypot(d[0]! - a[0]!, d[1]! - a[1]!, d[2]! - a[2]!)
if (flip) {
positions.push(a[0]!, a[1]!, a[2]!, b[0]!, b[1]!, b[2]!, c[0]!, c[1]!, c[2]!)
uvs.push(0, 0, u, 0, u, v)
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, d[0]!, d[1]!, d[2]!)
uvs.push(0, 0, u, v, 0, v)
} else {
positions.push(a[0]!, a[1]!, a[2]!, c[0]!, c[1]!, c[2]!, b[0]!, b[1]!, b[2]!)
uvs.push(0, 0, u, v, u, 0)
positions.push(a[0]!, a[1]!, a[2]!, d[0]!, d[1]!, d[2]!, c[0]!, c[1]!, c[2]!)
uvs.push(0, 0, 0, v, u, v)
}
for (let i = 0; i < 6; i++) normals.push(nx, ny, nz)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import { markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/edito
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useState } from 'react'
import * as THREE from 'three'
import { resolveRoofSegmentHit } from '../roof/segment-hit'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import RidgeVentPreview from './preview'
/**
+34 -19
View File
@@ -4,6 +4,7 @@ import {
type AnyNodeId,
type RidgeVentNode,
type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
@@ -17,6 +18,7 @@ import {
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { getSurfaceY } from '../shared/roof-surface'
import { buildRidgeVentGeometry } from './geometry'
// Single white fallback for every style. Paint customisation comes from
@@ -28,7 +30,6 @@ const defaultMaterial = new THREE.MeshStandardMaterial({
color: 0xff_ff_ff,
roughness: 0.85,
metalness: 0.1,
side: THREE.DoubleSide,
})
/**
@@ -45,15 +46,26 @@ const defaultMaterial = new THREE.MeshStandardMaterial({
* family (matte standard / shingled grey / brushed metal) before the
* user opens the paint tray.
*/
const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
const RidgeVentRenderer = ({ node: storeNode }: { node: RidgeVentNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(node.id, 'ridge-vent', ref)
const handlers = useNodeEvents(node, 'ridge-vent')
useRegistry(storeNode.id, 'ridge-vent', ref)
const handlers = useNodeEvents(storeNode, 'ridge-vent')
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset: ColorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
// Merge live drag overrides on top of the store node so handle drags
// update the mesh in-flight without flushing to zustand on every frame.
// Same pattern as box-vent / chimney / dormer — the override is set by
// `NodeArrowHandles`' drag handler and cleared on commit.
const overrides = useLiveNodeOverrides(
(s) => s.get(storeNode.id as AnyNodeId) as Partial<RidgeVentNode> | undefined,
)
const node: RidgeVentNode = overrides
? ({ ...storeNode, ...overrides } as RidgeVentNode)
: storeNode
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
@@ -67,25 +79,20 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
useEffect(() => () => geometry.dispose(), [geometry])
// The preset cache returns materials with `side: FrontSide` (that's
// what the preset payload encodes). For a thin extruded ridge cap that
// makes the underside disappear when the camera dips below the eaves
// so clone the resolved material and force `DoubleSide` locally
// without mutating the shared cache entry.
// Paint surface: FrontSide everywhere — DoubleSide on the role
// material's NodeMaterial poisons the MRT scene pass (see `materials.ts`
// line 77 / glazing fix 9400f1c5). Earlier this path forced DoubleSide
// so the underside of the thin extruded ridge cap stayed visible from
// below; that's now a known visual tradeoff — building the cap as a
// closed solid in `geometry.ts` is the right fix if the underside-view
// becomes noticeable.
const material = useMemo(() => {
// Untextured ridge vent (and textures-off mode) takes the themed
// 'roof' role colour. Request DoubleSide directly so the cached role
// material is the right side — no clone/mutation of a shared material.
if (!textures || (!node.material && !node.materialPreset)) {
return createSurfaceRoleMaterial('roof', colorPreset, THREE.DoubleSide, sceneTheme)
return createSurfaceRoleMaterial('roof', colorPreset, THREE.FrontSide, sceneTheme)
}
const base = node.material
return node.material
? createMaterial(node.material, shading)
: (createMaterialFromPresetRef(node.materialPreset, shading) ?? defaultMaterial)
if (base.side === THREE.DoubleSide) return base
const cloned = base.clone()
cloned.side = THREE.DoubleSide
return cloned
}, [textures, colorPreset, sceneTheme, shading, node.material, node.materialPreset])
if (!segment) return null
@@ -102,10 +109,18 @@ const RidgeVentRenderer = ({ node }: { node: RidgeVentNode }) => {
const segPos = segment.position ?? [0, 0, 0]
const segRotY = segment.rotation ?? 0
// Seat the vent on the ridge by DERIVING its Y from the segment's current
// surface rather than the stored `position[1]`. The ridge height comes from
// the segment's pitch (`getActiveRoofHeight`), so when the roof is lowered
// the segment updates, this renderer re-runs, and the vent rides the ridge
// down automatically — no stale floating cap. X/Z stay as authored (the vent
// straddles the ridge line at localZ≈0).
const ridgeY = getSurfaceY(node.position[0] ?? 0, node.position[2] ?? 0, segment)
return (
<group position={segPos} rotation-y={segRotY}>
<group
position={[node.position[0] ?? 0, node.position[1] ?? 0, node.position[2] ?? 0]}
position={[node.position[0] ?? 0, ridgeY, node.position[2] ?? 0]}
ref={ref}
rotation-y={node.rotation ?? 0}
visible={node.visible}
+1 -1
View File
@@ -13,7 +13,7 @@ import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import { resolveRoofSegmentHit } from '../roof/segment-hit'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { ridgeVentDefinition } from './definition'
import RidgeVentPreview from './preview'
+107 -23
View File
@@ -35,64 +35,139 @@ function getPeakHeight(n: RoofSegmentNodeType): number {
return n.wallHeight + getActiveRoofHeight(n)
}
// Width arrow — anchor='center' so dragging the +X side grows the full
// footprint symmetrically (both edges move ±delta). Same idiom as the
// elevator / column / shelf width arrow.
function roofSegmentWidthHandle(): HandleDescriptor<RoofSegmentNodeType> {
// Width arrow on the +X (right) or -X (left) side. Asymmetric resize:
// dragging one arrow grows the segment outward from its own edge while
// the opposite edge stays world-fixed — the same pattern doors use
// (`door/definition.ts:35-73`). The arrow's chevron points outward
// (`rotationY: Math.PI` flips the left arrow's chevron to face -X) so
// you read "this edge is what moves" at a glance.
//
// `apply` recomputes `position` so the anchored edge stays at the same
// world point even when the segment is Y-rotated: project the segment's
// local +X onto world via (cos r, -sin r), find the anchored edge's
// world XZ from the pre-drag node, then place the new center half a
// new-width away from that anchor in the same direction.
function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor<RoofSegmentNodeType> {
const sign = side === 'right' ? 1 : -1
return {
kind: 'linear-resize',
axis: 'x',
anchor: 'center',
// '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',
min: MIN_ROOF_DIM,
currentValue: (n) => n.width,
apply: (_n, newValue) => ({ width: newValue }),
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) => [
n.width / 2 + SIDE_HANDLE_OFFSET,
sign * (n.width / 2 + SIDE_HANDLE_OFFSET),
Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
0,
],
// Flip the left chevron so it points outward toward -X. The
// generic LinearArrow only auto-orients for axis 'z' (rotates the
// chevron 90° to face +Z); +X / -X facing is up to the descriptor.
rotationY: () => (side === 'right' ? 0 : Math.PI),
},
}
}
// Depth arrow — symmetric on the +Z side.
function roofSegmentDepthHandle(): HandleDescriptor<RoofSegmentNodeType> {
// Depth arrow on the +Z (front) or -Z (back) side. Asymmetric: the
// dragged edge follows the pointer, the opposite edge stays world-fixed
// — mirrors the width-handle pattern (`roofSegmentWidthHandle`). Because
// segment depth feeds the pitch math via `getActiveRoofHeight`, growing
// depth at constant pitch ramps the peak up too, which reads as
// scaling. We hold the peak height constant by back-solving a new pitch
// for the new depth (same recipe the pitch handle uses, run in
// reverse). MIN/MAX_PITCH clamps cover degenerate cases where the new
// depth would demand a negative or beyond-vertical pitch.
function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor<RoofSegmentNodeType> {
const sign = side === 'front' ? 1 : -1
return {
kind: 'linear-resize',
axis: 'z',
anchor: 'center',
anchor: side === 'front' ? 'min' : 'max',
min: MIN_ROOF_DIM,
currentValue: (n) => n.depth,
apply: (_n, newValue) => ({ depth: newValue }),
apply: (initial, newDepth) => {
// Recenter so the anchored Z edge stays at the same world point.
// Same math as the width handle but along the Z arm: yaw maps
// segment-local +Z to (sin r, cos r) in world.
const rotY = initial.rotation ?? 0
const armX = Math.sin(rotY)
const armZ = Math.cos(rotY)
const anchorX = initial.position[0] - sign * (initial.depth / 2) * armX
const anchorZ = initial.position[2] - sign * (initial.depth / 2) * armZ
const newCenterX = anchorX + sign * (newDepth / 2) * armX
const newCenterZ = anchorZ + sign * (newDepth / 2) * armZ
// Preserve peak height — back-solve pitch for the new depth so
// the assembled roof height matches what it was before the drag.
const originalRoofHeight = getActiveRoofHeight(initial)
const newPitch = getPitchFromActiveRoofHeight({
roofType: initial.roofType,
width: initial.width,
depth: newDepth,
roofHeight: originalRoofHeight,
gambrelLowerWidthRatio: initial.gambrelLowerWidthRatio,
gambrelLowerHeightRatio: initial.gambrelLowerHeightRatio,
mansardSteepWidthRatio: initial.mansardSteepWidthRatio,
mansardSteepHeightRatio: initial.mansardSteepHeightRatio,
dutchHipWidthRatio: initial.dutchHipWidthRatio,
dutchHipHeightRatio: initial.dutchHipHeightRatio,
})
return {
depth: newDepth,
position: [newCenterX, initial.position[1], newCenterZ],
pitch: Math.max(MIN_PITCH, Math.min(MAX_PITCH, newPitch)),
}
},
placement: {
position: (n) => [
0,
Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
n.depth / 2 + SIDE_HANDLE_OFFSET,
sign * (n.depth / 2 + SIDE_HANDLE_OFFSET),
],
// For axis 'z', `LinearArrow` adds -π/2 around Y so the chevron
// points +Z by default. Flip the back arrow by π so it points -Z.
rotationY: () => (side === 'front' ? 0 : Math.PI),
},
}
}
// Wall-height arrow — `anchor: 'min'` keeps the base on the floor and
// grows the wall upward. Placed on the -X side at the wall's top edge
// so it doesn't stack on the centered pitch arrow when wallHeight ≈ 0
// (flat roof / no walls).
// Wall-height tracker — dashed vertical leader from the floor up to a
// draggable cube at the wall top, centred on the footprint. Replaces
// the old -X-side chevron so the wall-top control reads as "the wall is
// THIS tall" instead of "there's an arrow on the side." Drag math is
// unchanged: same linear-resize axis='y' / anchor='min' pipeline as
// every other height handle; the `shape: 'tracker'` flag only swaps the
// visual. Wall-height clamps to MIN_WALL_DISPLAY for placement so the
// cube stays grabbable on flat / wall-less segments where the real
// `wallHeight` is ~0 and the leader would collapse to nothing.
function roofSegmentWallHeightHandle(): HandleDescriptor<RoofSegmentNodeType> {
return {
kind: 'linear-resize',
axis: 'y',
anchor: 'min',
shape: 'tracker',
min: MIN_WALL_HEIGHT,
currentValue: (n) => n.wallHeight,
apply: (_n, newValue) => ({ wallHeight: newValue }),
placement: {
position: (n) => [
-(n.width / 2 + SIDE_HANDLE_OFFSET),
Math.max(n.wallHeight, MIN_WALL_DISPLAY),
0,
],
position: (n) => [0, Math.max(n.wallHeight, MIN_WALL_DISPLAY), 0],
},
}
}
@@ -169,8 +244,10 @@ function roofSegmentRotateHandle(): HandleDescriptor<RoofSegmentNodeType> {
}
const roofSegmentHandles: HandleDescriptor<RoofSegmentNodeType>[] = [
roofSegmentWidthHandle(),
roofSegmentDepthHandle(),
roofSegmentWidthHandle('right'),
roofSegmentWidthHandle('left'),
roofSegmentDepthHandle('front'),
roofSegmentDepthHandle('back'),
roofSegmentWallHeightHandle(),
roofSegmentPitchHandle(),
roofSegmentRotateHandle(),
@@ -204,6 +281,13 @@ export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
deletable: true,
},
// Bespoke move shared with roof / stair / stair-segment via
// `shared/move-roof-tool` — routed through `MoveTool`'s registry-
// affordance lookup rather than a hardcoded dispatcher arm.
affordanceTools: {
move: () => import('../shared/move-roof-tool'),
},
parametrics: roofSegmentParametrics,
handles: roofSegmentHandles,
+180 -43
View File
@@ -7,13 +7,16 @@ import type {
} from '@pascal-app/core'
/**
* Stage C floor-plan builder for roof segment. Renders the segment's
* footprint as a rotated rectangle in world coords (parent roof's
* position + rotation composed with the segment's own).
* Stage C floor-plan builder for roof segment. Renders the segment as a
* proper architectural roof plan: the footprint outline plus the
* ridge / hip / break linework (and a downslope arrow for sheds) that
* makes each roof shape — hip, gable, shed, gambrel, dutch, mansard,
* flat — read distinctly, rather than as a bare rectangle.
*
* Inlined from `getRoofSegmentPolygon` / `getRoofSegmentCenter` in
* `floorplan-panel.tsx`. Ridge line not yet rendered — adds a follow-up
* for full visual parity.
* All linework is derived in segment-local space, mirroring the faces the
* 3D builder (`getModuleFaces` in the roof system) generates per type, so
* the plan and the model agree. Everything is composed into world coords
* via the parent roof's position + rotation and the segment's own.
*/
export function buildRoofSegmentFloorplan(
node: RoofSegmentNode,
@@ -41,16 +44,21 @@ export function buildRoofSegmentFloorplan(
const halfWidth = node.width / 2
const halfDepth = node.depth / 2
// Map a segment-local point (lx = width axis, lz = depth axis) into
// world plan coords — the same rotation + translation the footprint
// corners use. Shared by the per-type ridge/hip linework below.
const toPlan = (lx: number, lz: number): FloorplanPoint => [
cx + lx * cos - lz * sin,
cz + lx * sin + lz * cos,
]
const corners: Array<[number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
const points: FloorplanPoint[] = corners.map(([x, y]) => [
cx + x * cos - y * sin,
cz + x * sin + y * cos,
])
const points: FloorplanPoint[] = corners.map(([x, y]) => toPlan(x, y))
const view = ctx.viewState
const palette = view?.palette
@@ -77,46 +85,34 @@ export function buildRoofSegmentFloorplan(
strokeWidth: 0,
pointerEvents: 'all',
},
// Visible outline.
{
kind: 'polygon',
points,
fill: showSelectedChrome ? '#fed7aa' : 'none',
fillOpacity: showSelectedChrome ? 0.55 : 0,
stroke,
strokeWidth: showSelectedChrome ? 0.035 : 0.025,
strokeLinejoin: 'miter',
},
]
// Ridge line — only for pitched segments, not flat roofs. Dashed
// black so it reads as the ridge (axis of the pitch) without
// competing with the perimeter outline.
if (node.roofType !== 'flat') {
const ridgeAxis =
node.roofType === 'gable' || node.roofType === 'gambrel'
? 'x'
: node.roofType === 'dutch'
? node.width >= node.depth
? 'x'
: 'z'
: 'z'
const axisAngle = ridgeAxis === 'x' ? rotation : rotation + Math.PI / 2
const halfSpan = ridgeAxis === 'x' ? node.width / 2 : node.depth / 2
// The segment's own rectangle outline + fill render ONLY while it's
// selected / highlighted — that highlights which sub-plane is active
// (including its interior edges shared with neighbours). When unselected
// the eaves come from the parent roof's merged outline
// (`buildRoofFloorplan`), so overlapping segments read as one combined
// shape instead of stacked rectangles. Ridges/hips below always draw.
if (showSelectedChrome) {
children.push({
kind: 'line',
x1: cx - halfSpan * Math.cos(axisAngle),
y1: cz - halfSpan * Math.sin(axisAngle),
x2: cx + halfSpan * Math.cos(axisAngle),
y2: cz + halfSpan * Math.sin(axisAngle),
kind: 'polygon',
points,
fill: '#fed7aa',
fillOpacity: 0.55,
stroke,
strokeWidth: 0.02,
strokeDasharray: '0.1 0.08',
strokeLinecap: 'butt',
opacity: 0.85,
strokeWidth: 0.035,
strokeLinejoin: 'miter',
})
}
// NOTE: the ridge / hip / break / slope linework is NOT drawn here — the
// parent roof's builder (`buildRoofFloorplan`) draws it for every segment,
// clipped against the merged-roof valleys so a segment's ridge stops at
// the junction instead of running on into a neighbour it overlaps. This
// builder owns only the per-segment interaction chrome below. The shape
// math lives in `getRoofSegmentPlanLinework` (exported for the roof
// builder to consume).
// Selection chrome — orange move-handle dot at the centre, four
// perpendicular side resize-arrows (width on X, depth on Z), and a
// rotate-arrow at the +X/+Z corner. Sister to the 3D handles in
@@ -173,8 +169,149 @@ export function buildRoofSegmentFloorplan(
point: [cx + cornerX, cz + cornerZ],
angle: Math.atan2(radialZ, radialX),
affordance: 'roof-segment-rotate',
pivot: [cx, cz],
})
}
return { kind: 'group', children }
}
export type PlanPt = readonly [number, number]
export type PlanSeg = readonly [PlanPt, PlanPt]
/**
* Ridge / hip / break linework for a roof segment in segment-local space
* (lx = width axis, lz = depth axis), mirroring the faces the 3D builder
* (`getModuleFaces`) generates for each roof type. The floor-plan builder
* maps these to world coords. `slope`, when set, is a shed roof's downhill
* fall direction (tail = high eave, head = low eave).
*
* - ridge: peak line(s) where opposite slopes meet
* - hip: diagonal from an eave corner up to a ridge end / peak
* - break: horizontal fold where the slope angle changes (gambrel kink,
* mansard/dutch waist)
*
* Exported so the roof-level builder can reuse it to terminate the valley
* diagonals it draws at merged-roof junctions against the segments' ridges.
*/
export function getRoofSegmentPlanLinework(node: RoofSegmentNode): {
ridges: PlanSeg[]
hips: PlanSeg[]
breaks: PlanSeg[]
slope: { tail: PlanPt; head: PlanPt } | null
} {
const hw = node.width / 2
const hd = node.depth / 2
const ridges: PlanSeg[] = []
const hips: PlanSeg[] = []
const breaks: PlanSeg[] = []
let slope: { tail: PlanPt; head: PlanPt } | null = null
// Eave corners, matching e1..e4 in the 3D `getModuleFaces` builder.
const e1: PlanPt = [-hw, hd]
const e2: PlanPt = [hw, hd]
const e3: PlanPt = [hw, -hd]
const e4: PlanPt = [-hw, -hd]
// Hip linework shared by `hip` and the collapsed-waist mansard/dutch
// fallbacks: ridge along the longer axis, four hips from the eave
// corners to the nearer ridge end — or a single peak when square.
const pushHip = () => {
if (Math.abs(node.width - node.depth) < 0.01) {
const peak: PlanPt = [0, 0]
hips.push([e1, peak], [e2, peak], [e3, peak], [e4, peak])
} else if (node.width >= node.depth) {
const r1: PlanPt = [-hw + hd, 0]
const r2: PlanPt = [hw - hd, 0]
ridges.push([r1, r2])
hips.push([e1, r1], [e4, r1], [e2, r2], [e3, r2])
} else {
const r1: PlanPt = [0, hd - hw]
const r2: PlanPt = [0, -hd + hw]
ridges.push([r1, r2])
hips.push([e1, r1], [e2, r1], [e3, r2], [e4, r2])
}
}
switch (node.roofType) {
case 'flat':
break
case 'gable':
// Single ridge down the middle along the width axis.
ridges.push([
[-hw, 0],
[hw, 0],
])
break
case 'shed':
// 3D builder slopes from the high eave (lz = -hd) down to lz = +hd.
slope = { tail: [0, -hd * 0.55], head: [0, hd * 0.55] }
break
case 'hip':
pushHip()
break
case 'gambrel': {
// Ridge + two kink lines parallel to it.
const mz = hd * node.gambrelLowerWidthRatio
ridges.push([
[-hw, 0],
[hw, 0],
])
breaks.push(
[
[-hw, mz],
[hw, mz],
],
[
[-hw, -mz],
[hw, -mz],
],
)
break
}
case 'mansard': {
// Inner waist rectangle + four corner hips from the eaves to it.
const i = Math.min(node.width, node.depth) * node.mansardSteepWidthRatio
if (hw - i > 0.02 && hd - i > 0.02) {
const w1: PlanPt = [-hw + i, hd - i]
const w2: PlanPt = [hw - i, hd - i]
const w3: PlanPt = [hw - i, -hd + i]
const w4: PlanPt = [-hw + i, -hd + i]
breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
} else {
pushHip()
}
break
}
case 'dutch': {
// Hipped lower skirt (eave corners → waist corners) + the gablet
// fold, then a gable-style ridge on top of the waist.
const i = Math.min(node.width, node.depth) * node.dutchHipWidthRatio
if (hw - i > 0.02 && hd - i > 0.02) {
const w1: PlanPt = [-hw + i, hd - i]
const w2: PlanPt = [hw - i, hd - i]
const w3: PlanPt = [hw - i, -hd + i]
const w4: PlanPt = [-hw + i, -hd + i]
hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
if (node.width >= node.depth) {
const r1: PlanPt = [-hw + i, 0]
const r2: PlanPt = [hw - i, 0]
ridges.push([r1, r2])
hips.push([w1, r1], [w4, r1], [w2, r2], [w3, r2])
} else {
const r1: PlanPt = [0, hd - i]
const r2: PlanPt = [0, -hd + i]
ridges.push([r1, r2])
hips.push([w1, r1], [w2, r1], [w3, r2], [w4, r2])
}
} else {
pushHip()
}
break
}
}
return { ridges, hips, breaks, slope }
}
+14 -1
View File
@@ -1,11 +1,15 @@
import { type NodeDefinition, RoofNode as RoofNodeSchema } from '@pascal-app/core'
import { buildRoofFloorplan } from './floorplan'
import { roofParametrics } from './parametrics'
import { RoofNode } from './schema'
/**
* Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
* + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` +
* CSG). Inspector / move / floorplan stay legacy until Stage B-E.
* CSG). Inspector / move stay legacy until Stage B-E. `floorplan` draws
* the merged silhouette (union of the child segments' footprints), so a
* multi-segment roof reads as one combined shape rather than stacked
* rectangles.
*
* Roof is a "composite" node — it has `roof-segment` children that
* own per-segment geometry. The parent roof handles overall framing;
@@ -30,7 +34,16 @@ export const roofDefinition: NodeDefinition<typeof RoofNode> = {
deletable: true,
},
// Bespoke free-floating move (drag-to-place with R/T rotation and
// wall/fence snapping). Routes through `MoveTool`'s registry-affordance
// lookup — no hardcoded dispatcher arm. Shared with roof-segment / stair
// / stair-segment via `shared/move-roof-tool`.
affordanceTools: {
move: () => import('../shared/move-roof-tool'),
},
parametrics: roofParametrics,
floorplan: buildRoofFloorplan,
renderer: {
kind: 'parametric',
+292
View File
@@ -0,0 +1,292 @@
import type {
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
RoofNode,
RoofSegmentNode,
} from '@pascal-app/core'
import { unionPolygons } from '@pascal-app/viewer'
import { getRoofSegmentPlanLinework } from '../roof-segment/floorplan'
type Pt = [number, number]
type Seg = [Pt, Pt]
function signedArea(ring: readonly Pt[]): number {
let a = 0
const n = ring.length
for (let i = 0; i < n; i++) {
const p = ring[i] as Pt
const q = ring[(i + 1) % n] as Pt
a += p[0] * q[1] - q[0] * p[1]
}
return a / 2
}
/** Distance `t >= 0` from `V` along unit dir `(dx,dz)` to where the ray first
* meets segment `A→B`, or null. (Used to terminate valleys at ridges.) */
function rayHitT(
vx: number,
vz: number,
dx: number,
dz: number,
ax: number,
az: number,
bx: number,
bz: number,
): number | null {
const ex = bx - ax
const ez = bz - az
const denom = dx * ez - dz * ex
if (Math.abs(denom) < 1e-9) return null
const wx = ax - vx
const wz = az - vz
const t = (wx * ez - wz * ex) / denom
const s = (wx * dz - wz * dx) / denom
if (t < 0) return null
if (s < -1e-6 || s > 1 + 1e-6) return null
return t
}
function pointInPolygon(px: number, pz: number, poly: readonly Pt[]): boolean {
let inside = false
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const pi = poly[i] as Pt
const pj = poly[j] as Pt
if (
pi[1] > pz !== pj[1] > pz &&
px < ((pj[0] - pi[0]) * (pz - pi[1])) / (pj[1] - pi[1]) + pi[0]
) {
inside = !inside
}
}
return inside
}
/** Parametric `t` in (0,1) along `p1→p2` where it crosses segment `a→b`, else null. */
function segCrossT(p1: Pt, p2: Pt, a: Pt, b: Pt): number | null {
const rx = p2[0] - p1[0]
const rz = p2[1] - p1[1]
const ex = b[0] - a[0]
const ez = b[1] - a[1]
const denom = rx * ez - rz * ex
if (Math.abs(denom) < 1e-12) return null
const wx = a[0] - p1[0]
const wz = a[1] - p1[1]
const t = (wx * ez - wz * ex) / denom
const s = (wx * rz - wz * rx) / denom
if (t <= 1e-4 || t >= 1 - 1e-9) return null
if (s < -1e-6 || s > 1 + 1e-6) return null
return t
}
type SegPlan = {
footprint: Pt[]
ridges: Seg[]
hips: Seg[]
breaks: Seg[]
slope: { tail: Pt; head: Pt } | null
}
/** A segment's footprint + ridge/hip/break/slope linework, in world plan coords. */
function buildSegPlan(roof: RoofNode, seg: RoofSegmentNode): SegPlan {
const cosRoof = Math.cos(-roof.rotation)
const sinRoof = Math.sin(-roof.rotation)
const segCx = roof.position[0] + seg.position[0] * cosRoof - seg.position[2] * sinRoof
const segCz = roof.position[2] + seg.position[0] * sinRoof + seg.position[2] * cosRoof
const rot = -(roof.rotation + seg.rotation)
const cos = Math.cos(rot)
const sin = Math.sin(rot)
const tp = (lx: number, lz: number): Pt => [segCx + lx * cos - lz * sin, segCz + lx * sin + lz * cos]
const hw = Math.max(seg.width, 0.01) / 2
const hd = Math.max(seg.depth, 0.01) / 2
const lw = getRoofSegmentPlanLinework(seg)
const mapSeg = (s: readonly [readonly [number, number], readonly [number, number]]): Seg => [
tp(s[0][0], s[0][1]),
tp(s[1][0], s[1][1]),
]
return {
footprint: [tp(-hw, -hd), tp(hw, -hd), tp(hw, hd), tp(-hw, hd)],
ridges: lw.ridges.map(mapSeg),
hips: lw.hips.map(mapSeg),
breaks: lw.breaks.map(mapSeg),
slope: lw.slope
? { tail: tp(lw.slope.tail[0], lw.slope.tail[1]), head: tp(lw.slope.head[0], lw.slope.head[1]) }
: null,
}
}
/**
* Roof-level floor-plan builder. Draws the whole merged-roof plan: the
* unioned silhouette, the valley diagonals at concave junctions, and every
* segment's ridge/hip/break linework — clipped so a line stops at the valley
* where its segment overlaps a neighbour, instead of running on at the
* segment's full length into the cut-away part.
*
* Drawing all the linework here (rather than per-segment) is what lets the
* clip work: the valleys and the neighbouring footprints are all in hand, so
* each line can be trimmed to the actual merged geometry. The segment
* builder keeps only its hit-target / selection chrome.
*
* Composition uses the floor plan's negated-rotation convention
* (segment-local → roof-local → plan). `unionPolygons` returns one ring per
* disjoint group, so non-touching segments each keep their own outline. The
* group is decorative (`pointerEvents: 'none'`) — clicks fall through to the
* segment hit-targets.
*/
export function buildRoofFloorplan(
node: RoofNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const segments = ctx.children.filter(
(c): c is RoofSegmentNode => c.type === 'roof-segment',
)
if (segments.length === 0) return null
const plans = segments.map((s) => buildSegPlan(node, s))
const rings = unionPolygons(plans.map((p) => p.footprint)) as Pt[][]
if (rings.length === 0) return null
// Valleys at concave (reflex) corners of the merged outline. Each runs
// along the interior angle bisector and terminates at the nearest segment
// ridge — the diagonal where two merged slopes meet.
const allRidges: Seg[] = plans.flatMap((p) => p.ridges)
const valleys: Seg[] = []
for (const ring of rings) {
const n = ring.length
if (n < 3) continue
const orient = signedArea(ring) > 0 ? 1 : -1
for (let i = 0; i < n; i++) {
const prev = ring[(i - 1 + n) % n] as Pt
const V = ring[i] as Pt
const next = ring[(i + 1) % n] as Pt
const ax = prev[0] - V[0]
const az = prev[1] - V[1]
const bx = next[0] - V[0]
const bz = next[1] - V[1]
if ((ax * bz - az * bx) * orient <= 0) continue // not reflex
const la = Math.hypot(ax, az) || 1
const lb = Math.hypot(bx, bz) || 1
let dx = -(ax / la + bx / lb)
let dz = -(az / la + bz / lb)
const dl = Math.hypot(dx, dz)
if (dl < 1e-6) continue
dx /= dl
dz /= dl
let bestT = Number.POSITIVE_INFINITY
for (const [A, B] of allRidges) {
const t = rayHitT(V[0], V[1], dx, dz, A[0], A[1], B[0], B[1])
if (t !== null && t > 1e-4 && t < bestT) bestT = t
}
if (!Number.isFinite(bestT)) continue
valleys.push([
[V[0], V[1]],
[V[0] + dx * bestT, V[1] + dz * bestT],
])
}
}
const view = ctx.viewState
const palette = view?.palette
const showSelectedChrome = (view?.selected ?? false) || (view?.highlighted ?? false)
const ink = showSelectedChrome && palette ? palette.selectedStroke : '#111111'
const eaveWidth = showSelectedChrome ? 0.04 : 0.03
const ridgeWidth = showSelectedChrome ? 0.05 : 0.038
const hipWidth = showSelectedChrome ? 0.04 : 0.026
const children: FloorplanGeometry[] = []
const pushLine = (a: Pt, b: Pt, width: number) => {
children.push({
kind: 'line',
x1: a[0],
y1: a[1],
x2: b[0],
y2: b[1],
stroke: ink,
strokeWidth: width,
strokeLinecap: 'round',
pointerEvents: 'none',
})
}
// Merged outline (eaves).
for (const ring of rings) {
if (ring.length < 3) continue
children.push({
kind: 'polygon',
points: ring.map(([x, z]) => [x, z] as FloorplanPoint),
fill: 'none',
stroke: ink,
strokeWidth: eaveWidth,
strokeLinejoin: 'miter',
pointerEvents: 'none',
})
}
// Valley diagonals.
for (const v of valleys) pushLine(v[0], v[1], hipWidth)
// Per-segment ridge / hip / break linework, clipped to the merged geometry:
// an endpoint that overshoots into another segment is pulled back to the
// valley it crosses (the junction), so a ridge stops at the diagonal.
const footprints = plans.map((p) => p.footprint)
const clipEnd = (pt: Pt, other: Pt, ownIndex: number): Pt => {
let inOther = false
for (let i = 0; i < footprints.length; i++) {
if (i === ownIndex) continue
if (pointInPolygon(pt[0], pt[1], footprints[i] as Pt[])) {
inOther = true
break
}
}
if (!inOther) return pt
let bestT = Number.POSITIVE_INFINITY // nearest valley crossing to the overshoot
for (const v of valleys) {
const t = segCrossT(pt, other, v[0], v[1])
if (t !== null && t < bestT) bestT = t
}
if (!Number.isFinite(bestT)) return pt // overshoots but no valley to stop at
return [pt[0] + (other[0] - pt[0]) * bestT, pt[1] + (other[1] - pt[1]) * bestT]
}
const clipPush = (line: Seg, width: number, ownIndex: number) => {
const a = clipEnd(line[0], line[1], ownIndex)
const b = clipEnd(line[1], a, ownIndex)
const dx = a[0] - b[0]
const dz = a[1] - b[1]
if (dx * dx + dz * dz < 1e-8) return
pushLine(a, b, width)
}
plans.forEach((p, idx) => {
for (const s of p.breaks) clipPush(s, hipWidth, idx)
for (const s of p.hips) clipPush(s, hipWidth, idx)
for (const s of p.ridges) clipPush(s, ridgeWidth, idx)
// Shed downslope arrow (no overshoot to clip).
if (p.slope) {
const { tail, head } = p.slope
const dx = head[0] - tail[0]
const dz = head[1] - tail[1]
const len = Math.hypot(dx, dz) || 1
const ux = dx / len
const uz = dz / len
const headLen = Math.min(0.22, len * 0.4)
const wing = headLen * 0.6
pushLine(tail, head, hipWidth)
children.push({
kind: 'polyline',
points: [
[head[0] - headLen * ux - wing * uz, head[1] - headLen * uz + wing * ux],
[head[0], head[1]],
[head[0] - headLen * ux + wing * uz, head[1] - headLen * uz - wing * ux],
],
stroke: ink,
strokeWidth: hipWidth,
strokeLinecap: 'round',
strokeLinejoin: 'round',
pointerEvents: 'none',
})
}
})
return children.length > 0 ? { kind: 'group', children } : null
}
+1 -1
View File
@@ -1,2 +1,2 @@
export { type RoofSegmentHit, resolveRoofSegmentHit } from '../shared/roof-segment-hit'
export { roofDefinition } from './definition'
export { type RoofSegmentHit, resolveRoofSegmentHit } from './segment-hit'
+86 -8
View File
@@ -6,12 +6,14 @@ import {
type BoxVentNode,
type ChimneyNode,
type DormerNode,
type GutterNode,
type RidgeVentNode,
type RoofNode,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
type SkylightNode,
type SolarPanelNode,
type TurbineVentNode,
useScene,
} from '@pascal-app/core'
import {
@@ -31,7 +33,7 @@ import { useCallback, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
export default function RoofPanel() {
const [ventType, setVentType] = useState<'box-vent' | 'ridge-vent'>('box-vent')
const [ventType, setVentType] = useState<'box-vent' | 'ridge-vent' | 'turbine-vent'>('box-vent')
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
@@ -112,20 +114,33 @@ export default function RoofPanel() {
}),
)
const gutters = useScene(
useShallow((s) => {
if (segmentIdSet.size === 0) return []
const out: GutterNode[] = []
for (const n of Object.values(s.nodes)) {
if (n?.type === 'gutter' && n.roofSegmentId && segmentIdSet.has(n.roofSegmentId)) {
out.push(n as GutterNode)
}
}
return out
}),
)
// Box vents and ridge vents share the "Vents" UI group — same list,
// type shown as the right-side label, and an `Add Vent` button with
// a Box/Ridge segmented picker.
const vents = useScene(
useShallow((s) => {
if (segmentIdSet.size === 0) return []
const out: (BoxVentNode | RidgeVentNode)[] = []
const out: (BoxVentNode | RidgeVentNode | TurbineVentNode)[] = []
for (const n of Object.values(s.nodes)) {
if (
(n?.type === 'box-vent' || n?.type === 'ridge-vent') &&
(n?.type === 'box-vent' || n?.type === 'ridge-vent' || n?.type === 'turbine-vent') &&
n.roofSegmentId &&
segmentIdSet.has(n.roofSegmentId)
) {
out.push(n as BoxVentNode | RidgeVentNode)
out.push(n as BoxVentNode | RidgeVentNode | TurbineVentNode)
}
}
return out
@@ -207,7 +222,20 @@ export default function RoofPanel() {
// Same code path as the top palette — see `tool-manager.tsx:28`'s
// `nodeRegistry.get(tool)?.tool` dispatch.
const activateTool = useCallback(
(kind: 'box-vent' | 'ridge-vent' | 'chimney' | 'solar-panel' | 'skylight' | 'dormer') => {
(
kind:
| 'box-vent'
| 'ridge-vent'
| 'turbine-vent'
| 'cupola'
| 'eyebrow-vent'
| 'chimney'
| 'solar-panel'
| 'skylight'
| 'dormer'
| 'gutter'
| 'downspout',
) => {
triggerSFX('sfx:item-pick')
useEditor.getState().setTool(kind)
if (useEditor.getState().mode !== 'build') {
@@ -418,18 +446,27 @@ export default function RoofPanel() {
>
<span className="truncate">
{vent.name ||
(vent.type === 'box-vent' ? `Box Vent ${i + 1}` : `Ridge Vent ${i + 1}`)}
(vent.type === 'box-vent'
? `Box Vent ${i + 1}`
: vent.type === 'ridge-vent'
? `Ridge Vent ${i + 1}`
: `Turbine Vent ${i + 1}`)}
</span>
<span className="text-muted-foreground text-xs">
{vent.type === 'box-vent' ? 'box vent' : 'ridge vent'}
{vent.type === 'box-vent'
? 'box vent'
: vent.type === 'ridge-vent'
? 'ridge vent'
: 'turbine vent'}
</span>
</button>
))}
<SegmentedControl<'box-vent' | 'ridge-vent'>
<SegmentedControl<'box-vent' | 'ridge-vent' | 'turbine-vent'>
onChange={setVentType}
options={[
{ label: 'Box', value: 'box-vent' },
{ label: 'Ridge', value: 'ridge-vent' },
{ label: 'Turbine', value: 'turbine-vent' },
]}
value={ventType}
/>
@@ -441,6 +478,47 @@ export default function RoofPanel() {
/>
</ActionGroup>
</div>
<div className="flex flex-col gap-1">
<ActionGroup>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Cupola"
onClick={() => activateTool('cupola')}
/>
</ActionGroup>
</div>
<div className="flex flex-col gap-1">
<ActionGroup>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Eyebrow Vent"
onClick={() => activateTool('eyebrow-vent')}
/>
</ActionGroup>
</div>
<div className="flex flex-col gap-1">
{gutters.map((gutter, i) => (
<button
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
key={gutter.id}
onClick={() => handleSelectElement(gutter.id)}
type="button"
>
<span className="truncate">{gutter.name || `Gutter ${i + 1}`}</span>
<span className="text-muted-foreground text-xs">gutter</span>
</button>
))}
<ActionGroup>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Gutter"
onClick={() => activateTool('gutter')}
/>
</ActionGroup>
</div>
</div>
</PanelSection>
@@ -0,0 +1,377 @@
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
type RoofNode,
type RoofSegmentNode,
type StairNode,
type StairSegmentNode,
sceneRegistry,
useLiveTransforms,
useScene,
type WallNode,
} from '@pascal-app/core'
import {
CursorSphere,
clearRoofDuplicateMetadata,
snapFenceDraftPoint,
triggerSFX,
useEditor,
type WallPlanPoint,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
}> = ({ node: movingNode }) => {
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null)
}, [])
const previousGridPosRef = useRef<[number, number] | null>(null)
const [cursorWorldPos, setCursorWorldPos] = useState<[number, number, number]>(() => {
const obj = sceneRegistry.nodes.get(movingNode.id)
if (obj) {
const worldPos = obj.getWorldPosition(new THREE.Vector3())
// Cursor renders inside the building-local ToolManager group, so convert
// world → building-local to honor any building rotation.
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
if (buildingObj) buildingObj.worldToLocal(worldPos)
return [worldPos.x, worldPos.y, worldPos.z]
}
// Fallback if not registered (e.g. newly created duplicate without mesh yet)
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
const parentAngle = parentNode.rotation as number
const px = parentNode.position[0] as number
const py = parentNode.position[1] as number
const pz = parentNode.position[2] as number
const lx = movingNode.position[0]
const ly = movingNode.position[1]
const lz = movingNode.position[2]
const wx = lx * Math.cos(parentAngle) - lz * Math.sin(parentAngle) + px
const wz = lx * Math.sin(parentAngle) + lz * Math.cos(parentAngle) + pz
return [wx, py + ly, wz]
}
}
return [movingNode.position[0], movingNode.position[1], movingNode.position[2]]
})
useEffect(() => {
useScene.temporal.getState().pause()
const meta =
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
? (movingNode.metadata as Record<string, unknown>)
: {}
const isNew = !!meta.isNew
const committedMeta: RoofNode['metadata'] = (() => {
if (
typeof movingNode.metadata !== 'object' ||
movingNode.metadata === null ||
Array.isArray(movingNode.metadata)
) {
return movingNode.metadata
}
const nextMeta = { ...movingNode.metadata } as Record<string, unknown>
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta as RoofNode['metadata']
})()
const original = {
position: [...movingNode.position] as [number, number, number],
rotation: movingNode.rotation,
parentId: movingNode.parentId,
metadata: movingNode.metadata,
}
// Track whether the move was committed so cleanup knows whether to revert.
// We avoid setting isTransient on the store to prevent RoofSystem from
// resetting the mesh position (it resets on dirty) and from triggering
// expensive merged-mesh CSG rebuilds on every frame.
let wasCommitted = false
let wasCancelled = false
// Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number
// For roof-segment moves: the selection was cleared before entering move mode,
// so isSelected=false on the parent roof, hiding individual segment meshes and
// showing only the merged mesh. We directly flip Three.js visibility so the
// user sees the individual segment tracking the cursor.
let segmentWrapperGroup: THREE.Object3D | null = null
let mergedRoofMesh: THREE.Object3D | null = null
if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') {
const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
if (segmentMesh?.parent) {
// segmentMesh.parent = <group visible={isSelected}> wrapper in Roof/StairRenderer
// segmentMesh.parent.parent = the registered roof/stair group
segmentWrapperGroup = segmentMesh.parent
const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof'
mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null
segmentWrapperGroup.visible = true
if (mergedRoofMesh) mergedRoofMesh.visible = false
}
}
const resolveLevelId = () => {
if (movingNode.type === 'roof' || movingNode.type === 'stair') {
return movingNode.parentId ?? null
}
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
}
return null
}
const levelId = resolveLevelId()
const levelNode =
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is WallNode => node?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is FenceNode => node?.type === 'fence')
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) {
const worldPoint = buildingObj.localToWorld(
new THREE.Vector3(localPoint[0], y, localPoint[1]),
)
return [worldPoint.x, worldPoint.y, worldPoint.z]
}
return [localPoint[0], y, localPoint[1]]
}
const computeLocal = (
gridX: number,
gridZ: number,
y: number,
buildingLocalX: number,
buildingLocalZ: number,
): [number, number] => {
// Segments have a transformed parent (stair/roof). Convert world → parent-local
// via Three.js hierarchy so the segment's stored position stays parent-relative.
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
const parentObj = sceneRegistry.nodes.get(movingNode.parentId)
if (parentObj) {
const worldVec = new THREE.Vector3(gridX, y, gridZ)
parentObj.worldToLocal(worldVec)
return [worldVec.x, worldVec.z]
}
const dx = gridX - (parentNode.position[0] as number)
const dz = gridZ - (parentNode.position[2] as number)
const angle = -(parentNode.rotation as number)
return [
dx * Math.cos(angle) - dz * Math.sin(angle),
dx * Math.sin(angle) + dz * Math.cos(angle),
]
}
}
// Stair/roof live directly in the level — their stored position is building-local.
// event.localPosition is already building-local, so using it handles building rotation.
return [buildingLocalX, buildingLocalZ]
}
const onGridMove = (event: GridEvent) => {
const y = event.position[1]
const snappedLocal = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
const [lx, lz] = snappedLocal
setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) {
mesh.position.x = localX
mesh.position.z = localZ
}
// Publish world-space position so the 2D floorplan can track the drag
useLiveTransforms.getState().set(movingNode.id, {
position: [gridX, y, gridZ],
rotation: pendingRotation,
})
}
const onGridClick = (event: GridEvent) => {
const y = event.position[1]
const snappedLocal = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
const [lx, lz] = snappedLocal
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
wasCommitted = true
// The store still holds the original values (we didn't update during drag).
// Resume temporal and apply the final state as a single undoable step.
useScene.temporal.getState().resume()
if (isNew && movingNode.type === 'roof') {
clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
} else {
useScene.getState().updateNode(movingNode.id, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
}
useScene.temporal.getState().pause()
triggerSFX('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
useLiveTransforms.getState().clear(movingNode.id)
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
wasCancelled = true
useLiveTransforms.getState().clear(movingNode.id)
if (isNew) {
useScene.getState().deleteNode(movingNode.id)
} else {
useScene.getState().updateNode(movingNode.id, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
}
useScene.temporal.getState().resume()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const ROTATION_STEP = Math.PI / 4
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
triggerSFX('sfx:item-rotate')
pendingRotation += rotationDelta
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) mesh.rotation.y = pendingRotation
// Update live transform rotation for 2D floorplan
const currentLive = useLiveTransforms.getState().get(movingNode.id)
if (currentLive) {
useLiveTransforms.getState().set(movingNode.id, {
...currentLive,
rotation: pendingRotation,
})
}
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
return () => {
// Restore segment wrapper visibility (React will re-sync on next render)
if (segmentWrapperGroup) segmentWrapperGroup.visible = false
if (mergedRoofMesh) mergedRoofMesh.visible = true
// Clear ephemeral live transform
useLiveTransforms.getState().clear(movingNode.id)
// Skip restore when the 2D floor-plan overlay claimed teardown
// ownership — same contract `FloorplanRegistryMoveOverlay` uses to
// decide whether to revert its own apply() writes. Without this,
// a stair / roof move committed in the floor plan unmounts this
// tool with `wasCommitted === false` (this tool's own grid-click
// never fired), and the restore below stomps the just-committed
// position back to the snapshot.
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
if (!(wasCommitted || wasCancelled || isNew || finalisedBy2D)) {
useScene.getState().updateNode(movingNode.id, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
}
}, [movingNode, exitMoveMode])
return (
<group>
<CursorSphere position={cursorWorldPos} showTooltip={false} />
</group>
)
}
export default MoveRoofTool
@@ -19,7 +19,7 @@ export type RoofSegmentHit = {
/**
* Analytical surface Y for `seg` at segment-local (lx, lz). Mirrors
* the per-roof-type slope math in `solar-panel/geometry.ts` so the
* the per-roof-type slope math in `shared/roof-surface.ts` so the
* disambiguator below stays free of cross-kind imports. Returns the
* roof's local surface height; the value is only used to compare
* candidates, never written to the scene.
+158
View File
@@ -0,0 +1,158 @@
import {
getActiveRoofHeight,
getSegmentSlopeFrame,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
} from '@pascal-app/core'
import * as THREE from 'three'
// ─── Roof-surface helpers ────────────────────────────────────────────
// Analytical slope geometry for a roof segment, shared by every roof
// accessory that seats itself on the slope (solar-panel, skylight,
// box-vent). Lives here rather than inside any one kind's folder so the
// accessories don't reach across into a sibling kind for it.
export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): number {
const { roofType, wallHeight, depth, width } = seg
const rh = getActiveRoofHeight(seg)
const peakY = wallHeight + rh
if (rh === 0) return wallHeight
if (roofType === 'gable') {
const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
return peakY - t * rh
}
if (roofType === 'shed') {
const t = (lz + depth / 2) / (depth || 1)
return peakY - t * rh
}
if (roofType === 'hip') {
const fx = width > 0 ? Math.abs(lx) / (width / 2) : 0
const fz = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
return peakY - Math.max(fx, fz) * rh
}
const t = depth > 0 ? Math.abs(lz) / (depth / 2) : 0
return peakY - t * rh
}
// Outward normal for a roof surface tilting at angle θ in the horizontal
// direction (dx, dz). Derivation: the surface tangent vectors are the
// ridge axis (perpendicular to the fall line, horizontal) and the
// down-slope direction (cos θ horizontal + sin θ vertical). Crossing
// them gives the outward normal ∝ (sin θ · dx, cos θ, sin θ · dz),
// equivalently (dx · tan θ, 1, dz · tan θ) un-normalised.
function buildSlopeNormal(dx: number, dz: number, tan: number): THREE.Vector3 {
return new THREE.Vector3(dx * tan, 1, dz * tan).normalize()
}
export function getAnalyticalNormal(lx: number, lz: number, seg: RoofSegmentNode): THREE.Vector3 {
const { roofType, depth, width } = seg
const slope = getSegmentSlopeFrame(seg)
if (slope.activeRh === 0 || slope.tanTheta === 0) {
return new THREE.Vector3(0, 1, 0)
}
const primaryTan = slope.tanTheta
const halfW = width / 2
const halfD = depth / 2
// Ridge runs along X — slope falls in ±Z. Gambrel shares the gable
// dispatch (its kink-to-eave/lower tier is the primary slope frame).
if (roofType === 'gable' || roofType === 'gambrel') {
if (roofType === 'gambrel') {
// Tier-aware: the upper (shallower) face spans |z| < mz; the
// lower (steep) face spans mz < |z| ≤ halfD. Using primaryTan on
// the upper tier would tilt the ghost too steeply near the ridge.
const lowerWidthRatio =
seg.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio
const lowerHeightRatio =
seg.gambrelLowerHeightRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerHeightRatio
const mz = halfD * lowerWidthRatio
if (Math.abs(lz) <= mz) {
const upperRise = slope.activeRh * (1 - lowerHeightRatio)
const upperRun = mz
const upperTan = upperRun > 0 ? upperRise / upperRun : 0
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, upperTan)
}
}
return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
}
// Single slope falling toward +Z (ridge at -Z, eave at +Z).
if (roofType === 'shed') {
return buildSlopeNormal(0, 1, primaryTan)
}
// 4-sided slopes: the dominant axis chooses which face the point sits
// on. Hip is uniform across all four faces. Mansard has a steep outer
// band (primaryTan) and a shallow top inside the waist. Dutch has hip
// ends and gable sides — both share the same primaryTan from the
// slope frame, so directional dispatch is enough.
if (roofType === 'hip') {
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
}
if (roofType === 'mansard') {
const widthRatio = seg.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio
const heightRatio = seg.mansardSteepHeightRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepHeightRatio
const inset = Math.min(width, depth) * widthRatio
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
const onZ = fz >= fx
const inSteepBand = onZ ? Math.abs(lz) > halfD - inset : Math.abs(lx) > halfW - inset
let tan = primaryTan
if (!inSteepBand) {
// Top hip (shallow) above the waist — rises from the waist
// rectangle at fraction `heightRatio` of activeRh up to the peak.
const topRise = slope.activeRh * (1 - heightRatio)
const topRun = Math.max(0, Math.min(halfW, halfD) - inset)
tan = topRun > 0 ? topRise / topRun : 0
}
if (onZ) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, tan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, tan)
}
if (roofType === 'dutch') {
// Hip on the short-axis ends, gable on the long-axis sides. Both
// share the primary pitch on their primary (eave-band) face, so the
// approximation collapses to "pick the dominant axis."
const fx = halfW > 0 ? Math.abs(lx) / halfW : 0
const fz = halfD > 0 ? Math.abs(lz) / halfD : 0
if (fz >= fx) return buildSlopeNormal(0, lz >= 0 ? 1 : -1, primaryTan)
return buildSlopeNormal(lx >= 0 ? 1 : -1, 0, primaryTan)
}
return new THREE.Vector3(0, 1, 0)
}
// ─── Quaternion helper ───────────────────────────────────────────────
// Given a normal in the panel's parent frame, build a rotation that
// aligns the panel's local +Y to that normal. Lifted out so the
// renderer and the placement preview share one source of truth.
export function surfaceQuatFromNormal(normal: THREE.Vector3, out: THREE.Quaternion) {
// Build `right` by projecting world +X onto the surface plane instead of
// using `up × normal`. The cross-product version flips sign when the
// normal's Z component flips (e.g. the two slopes of a gable roof), so
// the resulting basis has its +X axis reversed on one slope — which
// makes hosted children's local +X point in opposite world directions
// depending on which slope they sit on, and registry chevrons end up
// anchored to the wrong edge. Projecting +X keeps the basis stable
// across slope-flips that share the same X axis.
const wx = new THREE.Vector3(1, 0, 0)
const right = wx.sub(normal.clone().multiplyScalar(new THREE.Vector3(1, 0, 0).dot(normal)))
if (right.lengthSq() < 1e-6) {
// Degenerate: normal is parallel to ±X. Fall back to +Z so the basis
// is still well-defined; this is the wall-like edge case (vertical
// surface facing along X) where any in-plane convention is OK.
right.set(0, 0, 1)
} else {
right.normalize()
}
const forward = new THREE.Vector3().crossVectors(right, normal).normalize()
const m = new THREE.Matrix4().makeBasis(right, normal, forward)
return out.setFromRotationMatrix(m)
}
@@ -0,0 +1,55 @@
import type { AnyNode, MaterialSchema, PaintCapability } from '@pascal-app/core'
import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer'
import type { Material, Mesh, Object3D } from 'three'
/**
* Paint capability for kinds with a single painted surface (`role: 'surface'`)
* that register a `<group>` of meshes all sharing one material — the roof
* vents (box / ridge / turbine / cupola / eyebrow). Replaces the editor's
* hardcoded `node.type === '<vent>'` paint arms with registry-driven dispatch,
* the same way chimney / dormer / wall declare their own `paint` capability.
*/
type SurfaceNode = AnyNode & {
material?: MaterialSchema
materialPreset?: string
}
function buildPreviewMaterial(
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Material | null {
if (materialPreset) return createMaterialFromPresetRef(materialPreset)
if (material) return createMaterial(material)
return null
}
export const surfacePaintCapability: PaintCapability = {
// One paintable surface — every face resolves to it.
resolveRole: () => 'surface',
buildPatch: ({ material, materialPreset }) => ({ material, materialPreset }) as Partial<AnyNode>,
applyPreview: ({ material, materialPreset, root }) => {
const preview = buildPreviewMaterial(material, materialPreset)
if (!preview) return null
// The kinds register a group, so walk the subtree and swap every child
// mesh's material, recording a restore for each.
const restores: Array<() => void> = []
;(root as Object3D).traverse((object) => {
const mesh = object as Mesh
if (!mesh.isMesh) return
const previous = mesh.material
mesh.material = preview
restores.push(() => {
mesh.material = previous
})
})
if (restores.length === 0) return null
return () => {
for (let i = restores.length - 1; i >= 0; i -= 1) restores[i]?.()
}
},
getEffectiveMaterial: ({ node }) => {
const n = node as SurfaceNode
return { material: n.material, materialPreset: n.materialPreset }
},
}
+1
View File
@@ -134,6 +134,7 @@ export function buildShelfFloorplan(node: ShelfNode, ctx?: GeometryContext): Flo
point: [px + cornerPlanX, pz + cornerPlanY],
angle: Math.atan2(radialPlanY, radialPlanX),
affordance: 'shelf-rotate',
pivot: [px, pz],
})
return { kind: 'group', children }

Some files were not shown because too many files have changed in this diff Show More