feat(roof-system): six roof-accessory kinds (chimney, dormer, skylight, solar-panel, ridge-vent, box-vent) on the registry model (#330)

* 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

* feat(box-vent): port to packages/nodes registry shape

Move box-vent from the legacy scattered layout (core schema +
viewer/systems/renderers + editor/tools/panels/sidebar) into a single
`packages/nodes/src/box-vent/` folder following the Phase 5 Stage E
pattern. The kind now self-registers via the built-in plugin.

- schema lives in `core/schema/nodes/box-vent.ts` (referenced by the
  hand-maintained AnyNode union) and re-exports from the kind folder.
- `def.renderer` reads the parent roof-segment from useScene, applies
  the slope tilt + segment yaw + node rotation stack, and follows the
  segment's useLiveTransforms override during a parent drag.
- geometry builder is pure and shared by renderer / preview / tool /
  unit tests. `computeBoxVentSlopeTilt` is lifted as a helper for
  future reuse by other roof-mounted kinds (skylight / solar-panel).
- placement tool listens to `roof:*` events, snaps to the segment
  under the cursor, creates a new BoxVentNode parented to that
  segment.
- BoxVentEvent + `NodeEvents<'box-vent', BoxVentEvent>` added to the
  event bus so `useNodeEvents(node, 'box-vent')` type-checks.

Verified: workspace `bun run build` + `bun run check-types` pass; 13
new unit tests in `__tests__/{schema,geometry}.test.ts` pass.

Worked example for porting the remaining roof-system kinds (ridge-vent,
chimney, solar-panel, skylight, dormer) — see `.claude/PORT-CHEATSHEET.md`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ridge-vent): port to packages/nodes registry shape

Same pattern as box-vent (`752ace83`): one folder under
`packages/nodes/src/ridge-vent/`, schema in core, registration via the
built-in plugin. No outside-the-folder edits beyond core schema/types,
the event bus, and the plugin index.

- pure geometry builder shared by renderer / preview / tool / tests,
  covering all three styles (curved cap / shingled / metal) and the
  optional end caps.
- custom `def.renderer` reads the parent roof-segment, follows
  useLiveTransforms during a parent drag. No slope tilt — the ridge
  IS the high line of the segment so the transform stack is one
  level shallower than box-vent.
- placement tool snaps the cursor to the ridge (segment-local Z=0)
  wherever the cursor lands on a segment, then commits on click with
  Z=0 baked into the new node's position.
- RidgeVentEvent + NodeEvents<'ridge-vent', ...> added to the event bus.

Verified: workspace build green, 9 new tests pass alongside the
13 box-vent tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(chimney): port to packages/nodes registry shape (Option C)

Same shape as box-vent (`752ace83`) and ridge-vent (`10d489d6`).

**Scope — Option C.** Chimney lands in the registry with solid
geometry; the CSG-driven decoration (cap flue holes, body cavity,
panels, bands, and the roof-trim that hides the chimney bottom inside
the deck) is preserved in the schema but NOT rendered yet. These
re-light when roof-segment migrates to Stage B and introduces a
`roofCutout` capability the parent segment can read.

Visual consequence: a placed chimney intersects the roof at the deck
line instead of having a clean CSG-cut hole around it. Placement,
move (via the legacy floating-vent-actions until the affordance tool
is ported), paint, inspector edits, undo, and delete all work
correctly.

- pure builder returns `{ body, cap, flues, cricket }` so each piece
  carries its own material (body/top split matches the schema's
  `material` vs `topMaterial`). Body height derived from the parent
  segment's `wallHeight + (flat ? 0 : roofHeight) + heightAboveRidge`.
- custom `def.renderer` reads the parent segment via `useScene`,
  follows `useLiveTransforms` during a parent drag.
- placement tool listens to `roof:*` events, creates a new
  ChimneyNode parented to the targeted segment with segment-local
  coordinates.
- ChimneyEvent + NodeEvents<'chimney', ChimneyEvent> added to the
  event bus.
- ChimneyMaterialRole helper re-exported from core (used by the
  paint-mode picker — keeps the legacy multi-surface signature).

Verified: workspace build green, 11 new tests pass (36 total across
box-vent / ridge-vent / chimney).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(solar-panel): port to packages/nodes registry shape (Option C)

Fourth roof-mounted kind, same shape as box-vent (`752ace83`),
ridge-vent (`10d489d6`), and chimney (`45038713`).

- pure builder generates the rows × columns cell grid as a single
  merged BufferGeometry with two render groups (frame + glass) so
  one mesh can take a `[frameMaterial, panelMaterial]` array.
- analytical roof-surface helpers (`getSurfaceY`, `getAnalyticalNormal`,
  `surfaceQuatFromNormal`) live alongside the geometry builder and
  drive both the renderer (when `surfaceNormal` is absent from the
  node) and the placement preview/commit.
- placement tool stores the analytical surfaceNormal on the new node
  so the runtime renderer and the placement preview produce the same
  orientation.
- `solar-panel-presets.ts` moved into core (it was already imported
  from the schema there) and re-exported through `@pascal-app/core`.
- inspector parametrics cover preset, grid, panel dims, mounting
  (flush/tilted with `tiltAngle` shown only when tilted), standoff,
  and frame.
- SolarPanelEvent + NodeEvents<'solar-panel', ...> on the bus.

**Option C still applies**: panels visually sit on the roof surface
but the roof is NOT cut beneath them; the legacy renderer's
useFrame-driven quaternion smoothing is replaced by a static
quaternion computed once per render. Surface tracking under live
parent rotation comes back when roof-segment migrates to Stage B.

Verified: workspace build green, 16 new tests (52 total across the
four ported kinds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skylight): port to packages/nodes registry shape (stub)

Fifth roof-mounted kind. Schema is complete, but the geometry and
animation surfaces are intentionally stubbed — this commit lands the
registration so the kind is present in palette / inspector / sidebar
/ undo, and follow-up commits flesh out the type-specific geometry
and the animation system.

**Scope.**
- Schema: every field from the archive ports verbatim (25 fields,
  five `skylightType` variants, opening/sliding state, lantern
  proportions, curb).
- Geometry: frame + glass rendered as plain boxes regardless of
  `skylightType`. Lantern slope, opening swing tilt, and sliding
  panel offset from the archive are not yet rebuilt.
- Animation: `operationState` and `slideFraction` round-trip via the
  inspector but don't drive geometry yet and don't interpolate over
  time. The legacy animation lived in `useInteractive.skylight
  Animations`, which doesn't exist on main — re-introducing that
  surface is a focused follow-up.
- Inherits Option C from chimney: no CSG cutout into the roof; no
  frame CSG (4 box rails instead).

**Why ship the stub now**: the framework wiring (schema in core,
event bus entry, plugin registration, inspector descriptor, custom
renderer with parent-segment lookup, placement tool) is the part
that's reusable across all five `skylightType` variants. Wiring
+ box geometry takes the kind from "doesn't exist" to "place / move
/ paint / delete / undo all work" without committing to the harder
type-specific geometry decisions.

Follow-up commits:
- type-specific geometry (lantern slope, opening tilt, sliding offset)
- animation system + `useInteractive.skylightAnimations` extension

Verified: workspace build green, 7 new tests (59 total across the
five ported kinds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dormer): port to packages/nodes registry shape (stub)

Sixth and final roof-mounted kind. Schema complete; geometry stubbed
as a house silhouette (box body + triangular gable). Same Option C
inheritance as chimney, solar-panel, and skylight.

**Key call: window is inlined, not a hosted child.** The archive's
dormer carries its window opening as parametric fields on its own
schema (`windowWidth`, `windowColumns`, `windowSill`, etc.) — not a
hosted `WindowNode` child. So `relations.hosts` stays unset and the
kind doesn't need a `children` field. The 17 window-* fields stay in
the schema; geometry beyond the silhouette stub picks them up later.

- per-surface material resolution (`getEffectiveDormerSurfaceMaterial`)
  ports verbatim into core with the same cross-fallback semantics
  (top → material, side ↔ wall, then legacy `material`).
- placement tool follows the established pattern (`roof:*` events,
  segment-local commit, analytical surfaceNormal stored).
- `RoofType` import resolved from the existing `roof-segment` schema
  on main (the archive's `./roof-type` file is consolidated there).
- DormerEvent + NodeEvents<'dormer', DormerEvent> on the bus.

**Stub scope.** Geometry renders gable-only regardless of `roofType`;
no window opening cutout, no window frame, no sill, no roof trim
where the dormer meets the host segment. The archive's geometry
relies on `getDormerExposedFaces` + `generateDormerGeometry` from
the legacy roof-system, neither of which exists in `packages/nodes`.
Follow-up commits add per-roofType dormer roofs, the window
opening+frame+sill, and the trim/CSG against the parent segment.

Verified: workspace build green, 12 new tests pass (71 total across
all six ported kinds; pre-existing spawn parity failures unrelated).

All six roof-system kinds now live in the registry shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: porting cheat-sheet for the roof-system kind migrations

Reference doc kept alongside the six kind ports (box-vent, ridge-vent,
chimney, solar-panel, skylight, dormer) so future kind authors can
follow the same shape. Captures:

- the per-kind folder layout (13 files, what each one owns)
- the three-checkbox composition model (`geometry` / `renderer` / `system`)
- every `NodeDefinition` field with usage notes
- the wiring touch-points outside the kind folder
  (`packages/nodes/src/index.ts`, `packages/core/src/events/bus.ts`,
  the AnyNode union, the core schema exports)
- per-kind decisions for the six roof-system kinds (which checkboxes
  each one ticks, what gets stubbed, what's deferred)
- pitfalls hit while porting (material-cache leaks, group-transform
  mutation, host-kind children fields, Path 1 vs Path 2 floorplan
  move)
- a pre-PR checklist

Kept under `.claude/` (not `wiki/`) since it's a working note for the
in-flight migration, not authoritative project documentation. Move
into `wiki/architecture/` later if it earns its keep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: wire paint targets + skylight animation surface for new kinds

Two small additions cherry-picked from roof-system-archive that the
six kind ports depend on. Both are mechanical and unblock follow-up
work without changing existing behavior.

**Paint targets.** Add `chimney`, `skylight`, `dormer` to
`MaterialTarget` enum so the paint picker surfaces these kinds. Wire
`chimney` and `dormer` into the relevant material-library target
arrays (WALL_TARGETS, SLAB_TARGETS, WALL_AND_SLAB_TARGETS,
ROOF_TARGETS) so wall / slab / roof material catalog entries are
offered when painting a chimney or dormer. Without this the new
kinds' `material` / `materialPreset` fields can be set
programmatically but the user-facing paint flow has nothing to
target.

**Skylight animation surface.** Port `SkylightInteractiveState` +
`SkylightAnimationState` types, `skylights` / `skylightAnimations`
store fields, and four actions (`setSkylightOpenState`,
`removeSkylightOpenState`, `startSkylightAnimation`,
`cancelSkylightAnimation`) onto `useInteractive`. Mirrors the
existing door / window animation surfaces one-for-one. This is the
prerequisite the skylight stub commit (`6dcee1ee`) called out — the
follow-up commit that adds the skylight animation system component
+ wires `operationState` into the renderer's geometry now has
something to consume.

Neither change touches the six kind folders or their definitions —
the kinds will pick up the new paint targets automatically and the
skylight animation surface is dormant until a consumer ports
forward.

Verified: workspace build green, 71/71 kind tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(palette): surface the six roof accessories in StructureTools

Closes the UX gap from the kind ports: box-vent / ridge-vent /
chimney / solar-panel / skylight / dormer are registered in the
registry with `def.tool` and `presentation`, but the top palette
(`StructureTools`) is currently driven by a hand-coded `tools`
array, not by the registry. So the new kinds existed in the
codebase but had no entry point in the running editor — the user
had no way to add them.

- Extend `StructureTool` union in `use-editor.tsx` with the six new
  kind IDs so `setTool('chimney')` typechecks.
- Add six entries to the `tools` array in `structure-tools.tsx`.
  All use the existing `/icons/roof.png` (a kind-specific icon set
  is a follow-up).

The ToolManager already dispatches `nodeRegistry.get(tool)?.tool`
(`tool-manager.tsx:28`), so clicking a new palette button activates
the kind's registered `def.tool` automatically — no further wiring
needed.

Follow-up: a `parametrics.customPanel` on `roofDefinition` that
surfaces inline "Add Chimney / Skylight / Dormer / ..." buttons in
the roof inspector (matching the legacy `roof-panel.tsx` UX). For
now, top palette is the entry point.

Verified: workspace build green, 71/71 kind tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(roof): add inline "Add Element" section to the roof inspector

When a roof is selected, the inspector now shows six quick-add buttons
(Chimney, Dormer, Skylight, Solar Panel, Box Vent, Ridge Vent) in an
"Add element" section between Position and Actions. Closes the
discoverability gap from the kind ports — the user no longer has to
hunt for the kind in the top palette.

- Lives in `packages/nodes/src/roof/panel.tsx` (the roof's existing
  customPanel — it already escapes the auto-derived inspector to render
  Segments + Position + Actions).
- Each button calls `useEditor.getState().setTool(kind)` to activate
  the kind's registered `def.tool`. The ToolManager dispatches via
  `nodeRegistry.get(tool)?.tool` (`tool-manager.tsx:28`), so this
  reuses the same code path as clicking the kind in the top palette.
- Tools listen for `roof:*` events — after clicking "Add Chimney" the
  user clicks anywhere on a roof segment to commit the new node
  parented to that segment.

Mirrors the legacy `roof-panel.tsx` UX (which had inline Add buttons
that created hidden nodes + entered move mode); the registry-shaped
equivalent activates the placement tool instead so the user sees a
preview that follows the cursor.

Verified: workspace build green, 71/71 kind tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ux(palette): remove roof accessories from the top palette

Six kinds (box-vent / ridge-vent / chimney / solar-panel / skylight /
dormer) only make sense in context of a selected roof segment — putting
them in the top palette clutters it for users not actively editing a
roof. They're entered through the roof inspector's "Add element"
section instead (added in 275af8f4), which routes to the same
registry-driven placement tools.

- Remove the six entries from the `tools` array in `structure-tools.tsx`.
- Keep `StructureTool` union additions in `use-editor.tsx` since
  `setTool('chimney')` etc. still need to typecheck from the roof
  panel's `activateTool` callback.

Verified: workspace build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(placement): always resolve a roof segment on click so add commits

Likely root cause of "Add Element clicks not adding anything." The
six roof-mounted placement tools each had a private
`resolveSegmentFromWorldPoint` that returned null when the click's
segment-local (x, z) fell outside `width/2 × depth/2` — but the
visible merged-roof mesh extends past those bounds by the segment's
overhang. Clicks landing anywhere in the eave band, or beyond every
segment's nominal footprint, silently no-op'd: `onClick` early-returned
on `if (!hit) return` and no node was created.

- Extract a shared `resolveRoofSegmentHit` into
  `packages/nodes/src/roof/segment-hit.ts`.
- Bounds check now includes `seg.overhang` on each side, matching the
  visible roof mesh.
- If no segment passes the exact check, fall back to the FIRST
  segment with the click point projected into its local frame. Same
  policy the legacy `roof-panel.tsx` used (it parented all add
  operations to `segments[0]` and let the user move afterward).
- Rewire box-vent, ridge-vent, chimney, solar-panel, skylight, and
  dormer placement tools to use the shared helper. Drop the per-tool
  copies (and the now-unused `RoofSegmentNode` import in 5 of them).

After this, clicking "Add Chimney" / etc. in the roof inspector
followed by a click anywhere on the visible roof commits the new
node every time.

Verified: workspace build green, 71/71 kind tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(roof): mount accessory children of roof-segments so Add actually adds

Closes "Add Element click adds nothing to the scene." After the click,
the new chimney/skylight/dormer/box-vent/ridge-vent/solar-panel node
was being created in `useScene.nodes` with `parentId: <segmentId>` —
but nothing mounted it visually. Two missing pieces:

1. RoofSegmentNode had no `children` array. `createNodesAction`
   appends `newNode.id` to `parent.children` only when the parent
   declares the field (`node-actions.ts:355`). Without it the
   parent-side write was a no-op, so the accessory existed in the
   store but nothing ever fired its `<NodeRenderer>` mount.

2. Even with the schema field, `roof-segment/renderer.tsx` was a leaf
   `<mesh>` — no recursive `<NodeRenderer>` mount of `node.children`.

Fix:
- `core/src/schema/nodes/roof-segment.ts`: add
  `children: z.array(z.string()).default([])`.
- `nodes/src/roof-segment/renderer.tsx`: emit a `<group>` alongside
  the placeholder mesh that iterates `node.children` and mounts each
  via `<NodeRenderer>`. The group carries the same transform as the
  mesh so accessories inherit the segment's local frame — matching
  the segment-local coordinates each accessory renderer assumes.
- `nodes/src/roof/renderer.tsx`: drop the `visible={false}` segments
  wrapper. `RoofSystem` only fills the parent roof's `merged-roof`
  mesh (`viewer/systems/roof/roof-system.tsx:172` via
  `getObjectByName('merged-roof')`), so segment placeholder meshes
  stay empty and don't z-fight with the visible roof. Mounting
  segments inside a visible wrapper is what lets accessory grand-
  children render at all.

Also unblocks the user's `roof/panel.tsx` accessory-list selectors
(which loop `seg.children` for chimneys/dormers/skylights/etc.) by
giving the schema the field they expect.

Verified: workspace build green, 71/71 kind tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(roof-accessories): drop double-applied segment transform from renderers

After the previous fix (segments host accessories via recursive
NodeRenderer), each accessory was being positioned at *twice* the
segment offset — the renderer's outer group still applied
`segment.position` and `segment.rotation`, and the React parent (the
segment's group) was already at that transform too. Result: chimneys,
skylights, dormers, etc. landed in the scene graph but rendered far
off-screen — invisible from any normal camera view.

Fix the six accessory renderers (box-vent, ridge-vent, chimney,
solar-panel, skylight, dormer) to assume the segment's transform is
inherited from the React tree:

- Drop the outer `<group position={segmentPosition} rotation-y={...}>`
  wrapper.
- Apply `node.position` (segment-local) directly to the ref'd outer
  group, with the kind-specific tilt / quaternion / yaw on inner
  groups.
- Drop `useLiveTransforms` lookup for the segment — React tree
  re-renders propagate parent transform changes automatically.
- Keep the `useScene` segment lookup; it's still needed for kind-
  specific math (slope tilt, analytical surface normal, base Y from
  wallHeight) that reads segment fields beyond just the transform.

Chimney's outer group sits at `[0, 0, 0]` because `applyNodeTransform`
in `geometry.ts` already bakes `node.position` and `node.rotation`
into the chimney's vertex positions (which also bake `baseY` from
`segment.wallHeight`). No double application there either.

After this, Add Element → click → place puts the accessory at the
clicked spot on the roof, visible and selectable.

Verified: workspace build green, 71/71 kind tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(scene): migrate existing roof-segments to add `children: []`

Closes "Add Element click adds to side panel but not to scene graph."
The previous fix added `children: z.array(z.string()).default([])`
to RoofSegmentNode, but that default only applies when zod parses
the segment fresh. Every roof-segment already in a loaded scene
(saved before the schema change) carries no `children` field at
runtime.

`createNodesAction` (`node-actions.ts:355`) appends the new child id
to `parent.children` only when `'children' in parent && Array.isArray(...)`
is true. For un-migrated segments that check fails — the chimney /
skylight / dormer / etc. is added to `useScene.nodes` (so it shows
up in the sidebar tree) but the parent-side write is a no-op, so
the segment's children array remains undefined, the segment
renderer's recursive `<NodeRenderer>` finds nothing to mount, and
nothing appears in the 3D scene.

Mirror the existing shelf migration (`use-scene.ts:351`): when the
scene loads, patch every roof-segment whose `children` isn't an
array to `children: []`. Existing scenes get the field on next
load; new segments get it from the schema default. After this the
"Add Element" click commits visibly.

Verified: workspace build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(chimney): port archive's bespoke tabbed panel as customPanel

The auto-derived inspector worked but couldn't reproduce the
archive's tabbed UI (Cap / Flues / Shoulder / Bands / Cricket /
Panels) where each sub-section gets its own dedicated controls
laid out as a grid of selectable cards. Users selecting a chimney
got a flat parametric form instead of the dense bespoke editor.

- Drop the legacy `packages/editor/src/components/ui/panels/chimney-panel.tsx`
  into `packages/nodes/src/chimney/panel.tsx` (the kind's customPanel
  slot). Rewrites:
  * Helper imports collapsed to one barrel from `@pascal-app/editor`
    (`ActionButton`, `ActionGroup`, `PanelSection`, `PanelWrapper`,
    `SegmentedControl`, `SliderControl`, `triggerSFX`).
  * `sfxEmitter.emit(...)` → `triggerSFX(...)` (same SFX, registry-
    safe export from `@pascal-app/editor`).
  * Inline a 3-line `cn` helper since editor doesn't re-export the
    legacy `lib/utils` one.
  * `ChimneyPanel` becomes `default export` so customPanel's lazy
    loader can pick it up.
- Wire `chimneyParametrics.customPanel = () => import('./panel')` so
  the registry's parametric inspector defers to the bespoke component.
- Keep `groups` in `chimneyParametrics` for MCP / fallback consumers
  (the parametric data is still authoritative).

User-visible: clicking a chimney now opens the tabbed inspector with
the exact category layout from the archive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(chimney): CSG-trim the body against the roof so it carves cleanly

Closes "chimney is not getting trimmed just like in roof-system."
Previously the chimney mesh was rendered as solid geometry that
intersected the roof shell visually at the deck line — Option C
debt called out in `45038713`. Now the body is CSG-cut against the
parent segment so only the portion above the shingles is visible,
matching the archive's UX.

- `packages/viewer/src/lib/csg-utils.ts`: port `csgEvaluator`,
  `csgGeometry`, `csgMaterials`, `computeGeometryBoundsTree`,
  `prepareBrushForCSG`, and the `Brush` / `SUBTRACTION` re-exports
  from `roof-system-archive`. Lives in viewer because
  `three-bvh-csg` + `three-mesh-bvh` are viewer-only deps.
- `packages/viewer/src/index.ts`: expose the CSG primitives + the
  existing `getRoofSegmentBrushes` (which was already defined on
  main but not in the package surface). Adding `getRoofSegmentBrushes`
  to the export — internal already; this just opens it for kinds
  living in `@pascal-app/nodes`.
- `packages/nodes/src/chimney/roof-trim.ts`: new helper
  `trimChimneyBodyAgainstRoof(body, segment, node)`. Wraps the body
  in a `Brush`, runs a two-pass `SUBTRACTION` (chimney - wallBrush -
  shinSlab), returns the trimmed `BufferGeometry`. Returns the input
  unchanged on any CSG failure so the chimney still renders.
- `packages/nodes/src/chimney/renderer.tsx`: memoize a `trimmedBody`
  alongside the existing geo memo (keyed on the segment shape fields
  that drive the roof brushes) and pass it to the body mesh instead
  of `geo.body`. Disposal updated to release whichever buffer is
  actually live.

Deferred (Option C still): bands and panels CSG. They were the same
flow but operate on additional pieces; they re-light in a follow-up
once the chimney's bands / panels geometry comes back online.

Verified: workspace build green, chimney unit tests pass (14/14).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skylight): port full v1 implementation (panel, 5-type 3D, CSG cut, animations)

Brings the skylight node from a box-only stub up to feature parity with the
roof-system branch. Mirrors the chimney port pattern established in 9fd42e33
and 65eec685.

UI
- packages/nodes/src/skylight/panel.tsx — bespoke tabbed inspector
  (type card picker + per-variant controls: lantern height/scale,
  opening angle/side/motor, sliding direction/track width, curb, frame,
  position, rotation). Wired via parametrics.customPanel.

3D
- packages/nodes/src/skylight/{renderer,geometry,frame-csg}.tsx —
  full 5-variant geometry (flat / walk-on / lantern / opening / sliding)
  with frame ring CSG and type-specific glass (lantern pyramid +
  cylindrical frame bars; opening hinged glass with optional motor
  housing; sliding two-pane on tracks).
- packages/nodes/src/skylight/preview.tsx — uses the real frame-csg
  builder so placement ghost matches the committed mesh.

Placement / move
- packages/nodes/src/skylight/tool.tsx — commits hit.localY so the
  skylight lands on the outer shingle surface, not the bare-rafter
  analytical Y (was sinking into the deck).
- packages/nodes/src/skylight/move-tool.tsx — kind-owned drag wired
  via def.affordanceTools.move. Uses SkylightPreview as the ghost so
  drag and duplicate both show the real frame following the roof
  raycast. Reparents across segments and dirties old+new for CSG
  re-cut.

CSG cutout
- packages/viewer/src/systems/roof/roof-system.tsx — buildSkylightCutBrush
  added; the per-child loop in updateMergedRoofGeometry subtracts every
  skylight from shin/deck/wall in segment-local before the segment
  transform stacks on (matches v1).
- Ported v1's getRoofOuterSurfaceFrameAtPoint helper (raycast against the
  actual outer-shingle module mesh) and made both the cut and the
  renderer read surface point + normal from it — keeps frame and cut
  aligned on every roof type incl. hip 4-faces, gambrel, mansard, dutch.
- mergeVertices on the cut box before computeBoundsTree — without it
  three-bvh-csg silently no-ops on the BoxGeometry after applyQuaternion
  tilts the cut ~90° about the surface normal (hip short faces).
- Renderer wraps content in an outer <group position={segment.position}
  rotation-y={segment.rotation}> so the frame inherits the same segment
  transform that applyTransform bakes into the cut brush (skylight is
  rendered under <group name="roof-elements"> at the roof level, not
  under the segment, so the renderer has to apply it explicitly).
- Skylight dirty propagation in RoofSystem: edits/moves dirty the host
  segment so the parent roof rebuilds.
- packages/viewer/src/index.ts — exposes getRoofOuterSurfaceFrameAtPoint,
  SurfaceFrame, getRoofSegmentBrushes, csg primitives so @pascal-app/nodes
  can compose roof-aware cuts without a layer violation.

Animations
- packages/editor/src/lib/skylight-interaction.ts — verbatim port of v1
  (toggleSkylightOpenState, closeSkylightOpenState, isOperableSkylightType,
  SKYLIGHT_TOGGLE_ANIMATION_MS = 520).
- packages/editor/src/hooks/use-keyboard.ts — R toggles, T closes
  operable skylights, mirroring door/window.
- packages/nodes/src/skylight/system.tsx — SkylightAnimationSystem
  ported as def.system; advances skylightAnimations and writes
  operationState back to useInteractive.skylights.
- Dropped the per-tick markSkylightDirty in the animation system. The
  renderer subscribes to useInteractive directly, so the glass swings/
  slides via Zustand re-renders without dirtying the scene — the cut
  geometry doesn't depend on operationState, so re-CSG'ing the merged
  roof on every animation frame was pure waste (caused visible lag).
- packages/core/src/index.ts — exports SkylightInteractiveState and
  SkylightAnimationState (interaction lib uses them).

Drag / duplicate ghost
- Floating action menu's setMovingNode → MoveTool → registry affordance
  now resolves to the kind-owned move tool. Duplicate already worked
  through structuredClone + def.schema.parse + setMovingNode; the new
  move-tool provides the ghost both flows use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Persist roof segment materials across refresh

* feat(solar-panel): full port from archive + placement/orientation fixes

Solar panel — ported from roof-system archive to registry shape:

**Inspector & presets**
- Custom panel (panel.tsx) with preset card grid (Residential / Residential
  Large / Compact / Frameless); picking a preset now writes all four dim
  fields (panelWidth / panelHeight / frameThickness / frameDepth) so the
  inspector immediately reflects the selection.
- Auto-fit to roof, Flip orientation, Custom label when no preset matches.
- Live preview: renderer subscribes to useLiveNodeOverrides so slider drags
  update the mesh before the value is committed to the Zustand store.
- Registered via parametrics.customPanel (same pattern as chimney).

**Texture / materials**
- Procedural cell texture (createSolarPanelTexture): chamfered cell shape,
  dark blue gradient, finger-line and busbar detail drawn on a 256×256
  canvas, tiled per cell via stretched UVs in buildSolarPanelGeometry.
- getDefaultPanelMaterial singleton uses MeshStandardNodeMaterial (WebGPU-
  native) so the material integrates with the MRT pass without triggering
  "Color target has no corresponding fragment stage output / writeMask not
  zero" GPU validation errors on segment reparent.
- defaultFrameMaterial and move-tool previewMaterial also switched to
  WebGPU-safe types (MeshStandardNodeMaterial / MeshBasicMaterial).

**Default grid size**
- Schema defaults changed from 4 rows × 5 columns → 2 rows × 3 columns.
- Placement ghost and move-tool ghost use a compact 2×3 footprint;
  committed panels also default to 2×3.

**Placement tool**
- Commit position uses raycast hit Y (hit.localY from segObj.worldToLocal)
  instead of analytical getSurfaceY so the panel lands exactly where the
  ghost was shown rather than sinking into the deck/shingle layers.
- Ghost orientation uses the same analytical-normal + explicit-yaw pattern
  as the placement tool for correctness on rotated segments.

**Move tool**
- Rewrote ghost to use resolveRoofSegmentHit + getAnalyticalNormal
  (segment-local) + explicit rotation-y group, matching the placement
  tool's ghost layout exactly. Dropped unreliable event.normal / world-
  space quat path that gave wrong tilt on any segment with rotation ≠ 0.
- Committed surfaceNormal is now segment-local (not world-space) so the
  renderer's surfaceQuat + outer segment.rotation group compose correctly
  without double-rotating the panel.
- Uses shared resolveRoofSegmentHit (with surface-Y disambiguation) instead
  of the private copy, so segment hopping respects the correct face.
- Reparents children arrays on segment hop.

**Renderer**
- Applies segment.position + segment.rotation explicitly (roof accessories
  are mounted under roof-elements group which has no transform, not under
  segment subtree).
- Merges useLiveNodeOverrides so slider drags update the 3D mesh in real
  time (same pattern as elevator/skylight renderers).

**Scene graph**
- SolarPanelTreeNode added; registered in tree-node.tsx type map so panels
  appear under their parent roof-segment in the sidebar.

**Segment-hit disambiguation**
- resolveRoofSegmentHit now scores all bbox-passing candidates by
  |localY − analyticalSurfaceY(localX, localZ)| and picks the smallest,
  fixing the long-standing bug where hip/gable segments at the same roof
  origin all pass the axis-aligned bbox test and the first-match (always
  segments[0]) was returned regardless of which slope was clicked.
  Benefits all roof-accessory placement tools (chimney, box-vent, skylight,
  dormer, solar-panel).

**Hip-roof normal fix**
- getAnalyticalNormal for hip now uses slopeReach = min(w,d)/2 for the Y
  component on all four faces. The old code used depth/2 for front/back and
  width/2 for sides, which was only correct for square (w==d) hips; for any
  other aspect ratio the long-axis faces tilted the panel at the wrong angle.

**Dormer, dormer move-tool, window-frame, ridge-vent, box-vent, skylight**
- Assorted in-progress work: dormer window-frame geometry, move-tool port,
  panel refinements, ridge-vent / box-vent panel additions, skylight CSG
  frame refinements, roof-system geometry improvements, material-paint
  support, post-processing cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* perf(chimney): fix hook order + share materials + memoise segment brushes

- renderer.tsx: hoist `surfaceArray` useMemo above the `!segment || !geo`
  early return so hook call order stays stable across renders (the
  previous order would have crashed React the first time segment or geo
  flipped to null mid-session).
- renderer.tsx: replace the module-scoped `bodyMaterial` / `topMaterial`
  singletons with per-instance fallback materials so a paint-mode or
  debug mutation on one chimney can't bleed into every other unpainted
  chimney on the scene; dispose them on unmount.
- renderer.tsx: collapse the 36-field hand-maintained dep array on the
  `geo` useMemo (and the 10-field one on `trimmedBody`) down to the
  memoised `node` / `segment` references — adding a new schema field
  no longer risks stale geometry from a forgotten dep, and the
  `eslint-disable react-hooks/exhaustive-deps` lines are gone.
- renderer.tsx + roof-trim.ts: memoise `getRoofSegmentBrushes(segment)`
  per-segment-shape in the renderer instead of rebuilding the four
  CSG-ready brushes inside `trimChimneyBodyAgainstRoof` on every call.
  A chimney slider drag changes `node.*` but not the segment, so the
  brushes now survive the entire drag instead of being rebuilt and
  disposed every frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(scene): pin reparent behavior for roof-mounted kinds under repeated A→B→A→B

Investigating a reported crash where moving a vent across roof
segments three times in a row crashes the scene. The hypothesis was
duplicate IDs in the host segments' `children` arrays. These tests
prove that's NOT the cause: at the store level, the auto-reparent
inside `updateNodesAction` leaves children lists clean under repeated
hopping for every roof-mounted kind (box-vent, chimney, skylight,
dormer, solar-panel, ridge-vent), and even the redundant manual-then-
auto pattern the vent move-tools use converges to the same correct
state.

Crash root cause still under investigation, but these pins prevent
the obvious-and-tempting regression where someone "fixes" reparent
by hand and accidentally lets duplicates through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(chimney): give each sub-mesh its own name

Body, cap, flues, cricket, and bands were all named `chimney-surface`,
so hover/selection couldn't distinguish them and panel breadcrumbs
couldn't say "Chimney cap" vs "Chimney body". Rename to
`chimney-{body,cap,flues,cricket,bands}`. No code looked up the old
literal, so this is a pure naming improvement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(chimney): smooth round shading, radial cap UVs, cap reveal, flue pots

Four visual upgrades to the chimney builder, all in `geometry.ts`:

- **Round chimneys now render smooth, not faceted.** The previous
  `pushCylinderFaces` emitted unindexed triangles, so
  `computeVertexNormals()` baked per-triangle face normals into every
  vertex — the 24 polygon segments of a round body / cap / band were
  visible as flats. Round paths now build per-tier
  `THREE.CylinderGeometry` (indexed, side vertices shared across
  radial segments) and merge via `mergeGeometries`. Crisp rim edges
  are preserved because CylinderGeometry uses separate cap vertices.

- **Radial cap UVs.** Old `pushCylinderFaces` pushed `(0,0)` for every
  vertex on the top/bottom fan, so any texture on a round chimney
  smeared to a point at the caps. CylinderGeometry gives proper
  radial UVs (0.5 ± 0.5·cos/sin) for free.

- **Cap reveal.** The cap used to sit flush on the body, reading as
  glued on. New `CAP_REVEAL = 0.003` (3 mm) air gap above the body
  catches a shadow line and sells the cap as a separate stone / metal
  piece. `capTopY` (used for flue placement) updates so flues still
  sit on the actual cap top.

- **Flue pots, not drainpipes.** Each flue was a single straight
  cylinder / box — visually a "drainpipe", not a chimney. New
  two-tier silhouette: a tall straight shaft topped by a short
  overhanging rim (12 % of height, capped at 4 cm; rim radius
  flares 12 %). Reads as a terracotta pot. Total height still
  equals `flueHeight`, so the bore cutter in `holes.ts` covers the
  whole envelope unchanged.

Removed the now-unused `pushCylinderFaces` helper. Slab path
unchanged — square chimneys keep their crisp 90° corners.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(chimney): cornerBevel for square bodies / caps / bands

Square chimneys read as plastic boxes at any distance because every
vertical edge is a perfect 90° corner that catches no light. New
`cornerBevel` field (default 0 → existing scenes unchanged) replaces
each corner with a 45° chamfer face. Real masonry chimneys often
ship the same detail — a small bevel (~1-2 cm) breaks up the
silhouette and reads as stone or chamfered brick.

- Schema: add `cornerBevel: z.number().default(0)` to ChimneyNode.
- Geometry: extend `pushSlabFaces` with an optional `bevel` param.
  When > 0, dispatch to a new `pushOctagonalSlabFaces` that emits
  an 8-vertex ring per y-level (axis-aligned faces + 45° chamfer
  faces) plus fan-triangulated octagonal caps. UVs follow the same
  physical-meter convention as the unchamfered path so a brick
  texture tiles at a consistent rate with and without bevel.
- Thread `node.cornerBevel` through `buildBodyGeometry`,
  `buildCapGeometry`, and `buildBandsGeometry` (square paths only —
  round bodies have no corners to bevel).
- Parametrics: expose under the Body group with `visibleIf` gating
  on square body for the MCP / fallback inspector.
- Panel: add a "Corner Bevel" SliderControl in the Footprint section,
  same conditional visibility, clamped at `min(width, depth) / 2`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(chimney): style presets — Brick / Stone / Modern / Round

The chimney panel exposes 30+ sliders; landing on a coherent
silhouette (corbeled stone with a sloped cap and a cricket vs. a
straight brick with a double band) takes a dozen edits even when
you know what you want. New "Style" segmented control at the top
of the panel applies a curated bundle of fields in one click.

Presets only touch shape / silhouette / accessory fields:
`bodyShape`, `shoulderStyle*`, `cap*`, `band*`, `cricket*`,
`cornerBevel`, `panel*`, `flue*`. Dimensions (`width`, `depth`,
`heightAboveRidge`), placement (`position`, `rotation`,
`roofSegmentId`), and paint (`material*`, `topMaterial*`) are
deliberately left alone — applying a preset to an already-sized,
already-painted chimney resizes nothing and doesn't overwrite the
user's material choices.

- `presets.ts`: four preset bundles + `detectActiveChimneyPreset`
  helper for highlighting the matching preset in the segmented
  control.
- `panel.tsx`: new "Style" PanelSection above Footprint, segmented
  control wired to `commitProp(chimneyPresets[key])`. Renders with
  no segment highlighted ("custom") when the current node doesn't
  match any preset exactly.
- `__tests__/presets.test.ts`: round-trip each preset, confirm
  fresh-default chimneys are NOT detected as any preset, and
  confirm non-preset fields (dims / materials / placement) don't
  knock a chimney out of a preset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(chimney): drop the Stone preset

Leaves Brick / Modern / Round. The parameterised round-trip test
auto-adjusts via `CHIMNEY_PRESET_KEYS`; no test code change needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(dormer): bugs, perf, UX, refactor + bundled pitch WIP

Dormer review-backlog batch across five passes. Plus the previously
unstaged pitch/roofHeight migration on RoofSegmentNode bundled in per
session continuity.

Dormer — bugs / dead code:
- implement the windowSill (toggle was UI-only before)
- ghost preview reads wallSkirtHeight and branches on roofType=flat
- live-override slider drag swaps the heavy CSG for the fallback
- consolidate arch/rounded shape builders between viewer CSG and frame
- drop unused surfaceNormal field
- collapse getEffectiveDormerSurfaceMaterial fall-through
- confine the panel's updateWorldMatrix into a single useMemo
- preserve position Y on panel commits (was being zeroed)

Dormer — schema hygiene:
- DORMER_DEFAULTS named constants replace inline magic numbers
- collapse windowCornerRadius + windowRadiusMode + windowCornerRadii
  into the tuple alone; "All vs Individual" is derived UI state
- drop the `as never` id casts; rely on objectId default factory

Dormer — tactile UX:
- R / Shift+R rotates the placement ghost by ±15°
- auto-number new dormer names ("Dormer N", smallest free integer)
- DORMER_PLACEMENT_SNAP_M + ROTATION_STEP constants extracted

Dormer — code shape:
- new use-dormer-placement hook dedupes tool + move-tool (~90% shared)
- new <DormerWindowAssembly> isolates the frame/glass/sill JSX
- panel.tsx 788 -> 295 lines; Position / Window / Actions sections
  extracted into per-file components

Bundled pitch WIP (pre-existing, unrelated to dormer):
- RoofSegmentNode.roofHeight removed; pitch (degrees) added
- new helpers in roof-segment: getActiveRoofHeight,
  getPitchFromActiveRoofHeight, getSegmentSlopeFrame,
  ROOF_SHAPE_DEFAULTS
- migration in use-scene.ts converts legacy roofHeight to pitch
- consumers updated: chimney, box-vent, ridge-vent, solar-panel,
  roof-segment, roof, segment-hit, roof-tool, mcp construction-tools

Verification: 12/12 dormer tests pass; targeted tsc on dormer files
clean. Workspace bun build of nodes is also affected by the pitch WIP,
which is included here per request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(roof): per-segment top/edge/wall materials + restore legacy UV flow

- RoofSegmentNode gains optional topMaterial / edgeMaterial / wallMaterial
  fields mirroring the parent roof. getEffectiveSegmentSurfaceMaterial
  resolves through segment-role → segment-legacy → parent fallback.
- Segment renderer builds the 4-slot array per role with the parent's
  array as a fallback so paint at any level reaches the right surface.
- Painting a segment directly (segment edit mode hover) writes to the
  segment's role fields via buildRoofSegmentSurfaceMaterialPatch — the
  parent roof and other segments are untouched.
- Segments with any material override render as their own per-segment
  mesh inside a new always-visible 'painted-segments' group; the merged-
  roof CSG skips them (hasSegmentMaterialOverride) so we don't double-
  paint with the roof's default array.
- Paint preview now dispatches to a segment-aware path
  (applyRoofSegmentPaintPreview) so hover effects land on the visible
  per-segment mesh instead of the hidden merged-roof.
- Drop the createRoofUvGeometry post-CSG re-projection. UVs now flow
  through CSG (csgEvaluator.attributes includes 'uv') exactly as in the
  legacy roof-system branch.
- Drop the stray [skylight-cut] console.log left in the hot path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(roof-system): registry capabilities for roof accessories, paint, and keyboard

Replace kind-name branches in framework code with registry-driven dispatch.
Three new NodeDefinition slots back the migration:

- capabilities.roofAccessory — host roof cascade + optional CSG cut.
  Lets viewer's RoofSystem iterate dirty children and call buildCut on
  any kind that declares it, instead of switching on node.type. Dormer
  + skylight cut builders moved into packages/nodes/<kind>/.
- capabilities.paint — resolveRole / buildPatch / applyPreview /
  getEffectiveMaterial. Chimney, dormer, and wall now route through it;
  per-kind arms deleted from selection-manager + material-paint.
- keyboardActions — R / T handlers contributed by the kind. Skylight's
  open/close logic moved from editor/lib to nodes/skylight/interaction.

Dormer + skylight kind code (geometry, fallback shape, exposed-face math,
window-dim resolver, CSG cut builders) now lives under packages/nodes/
src/<kind>/ instead of packages/viewer/src/systems/roof/roof-system.tsx.
The viewer keeps only roof-generic primitives (roof-segment brushes,
surface-frame query, CSG dummy mats, material-slot remap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* review: remove PORT-CHEATSHEET, document roofAccessory/paint/keyboardActions, fix double registry lookup

- Remove .claude/PORT-CHEATSHEET.md (AI authoring aid, not for public repo)
- Document three new NodeDefinition capabilities in wiki/architecture/node-definitions.md:
  roofAccessory, paint, keyboardActions
- Fix double nodeRegistry.get() lookup in use-keyboard.ts: replace !.keyboardActions!.r!.run()
  with ?.keyboardActions?.r?.run() for both R and T arms

* fix(item-placement): memoize preview/dimension callbacks to stop placement loop

updatePreviewGeometry and updateDimensionGuides were declared as plain
functions in the component body, so they got a fresh identity every
render. Both sit in the placement setup effect's dependency array, which
made React tear the effect down and re-run it on every commit — its
teardown deletes the draft node while the setup re-creates it, producing
an infinite delete/recreate loop ("Maximum update depth exceeded") when
opening furnish mode.

Wrap both in useCallback with empty deps (they only close over stable
refs, module-level helpers, and the setDimensionBounds setter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(item-placement): correct preview box dimensions and floor rotation

Two issues surfaced after the placement-loop memoization removed the
accidental every-render recompute that was masking them:

1. Preview box used stale (asset-default) dimensions at draft creation
   because nothing recomputed it once the imperative draft was made.
   Recompute the box from the freshly-created draft in `ensureDraft` and
   the chained next-draft path in `onGridClick`.

2. The green/red box (and the live transform the 2D floorplan mirrors)
   ignored the floor item's rotation:
   - `floorStrategy.move` returned a hardcoded `cursorRotationY: 0`; now
     returns the draft's rotation (`rotY`).
   - `onGridMove` never applied `result.cursorRotationY` to the cursor
     group; now it does, so box + floorplan track the draft on every move.
   - the init seed used the mesh world quaternion, which double-counts
     building rotation for floor items; floor now seeds from the node's
     local Y rotation (wall/ceiling keep the world-quaternion path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: open-pascal <open@pascal.app>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
Sudhir Yadav
2026-05-22 12:14:22 -04:00
committed by GitHub
co-authored by Claude Opus 4.7 open-pascal Wassim SAMAD
parent 3cb318e445
commit 87384cfbab
147 changed files with 17913 additions and 234 deletions
@@ -0,0 +1,110 @@
import { describe, expect, test } from 'bun:test'
import type { RoofSegmentNode } from '@pascal-app/core'
import { buildChimneyGeometry, flueXPositions } from '../geometry'
import { ChimneyNode } from '../schema'
const fixtureSegment = (): RoofSegmentNode =>
({
object: 'node',
id: 'rseg_fixture',
type: 'roof-segment',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 2.5,
// atan(2 / 3)° — gives getActiveRoofHeight ≈ 2.0 on this 8×6 gable.
pitch: (Math.atan2(2, 3) * 180) / Math.PI,
wallThickness: 0.1,
deckThickness: 0.1,
overhang: 0.3,
shingleThickness: 0.05,
}) as RoofSegmentNode
describe('buildChimneyGeometry', () => {
test('returns body for default chimney with a non-empty position attribute', () => {
const { body, cap, flues, cricket } = buildChimneyGeometry(ChimneyNode.parse({}), fixtureSegment())
expect(body.getAttribute('position').count).toBeGreaterThan(0)
expect(cap?.getAttribute('position').count).toBeGreaterThan(0)
expect(flues?.getAttribute('position').count).toBeGreaterThan(0)
expect(cricket).toBeNull()
})
test('cap omitted when capShape=none', () => {
const { cap } = buildChimneyGeometry(
ChimneyNode.parse({ cap: true, capShape: 'none' }),
fixtureSegment(),
)
expect(cap).toBeNull()
})
test('cap omitted when cap=false', () => {
const { cap } = buildChimneyGeometry(
ChimneyNode.parse({ cap: false }),
fixtureSegment(),
)
expect(cap).toBeNull()
})
test('flues omitted when flueCount=0', () => {
const { flues } = buildChimneyGeometry(
ChimneyNode.parse({ flueCount: 0 }),
fixtureSegment(),
)
expect(flues).toBeNull()
})
test('cricket only emitted for square body with non-none style', () => {
const square = buildChimneyGeometry(
ChimneyNode.parse({ cricketStyle: 'simple', bodyShape: 'square' }),
fixtureSegment(),
)
expect(square.cricket?.getAttribute('position').count).toBeGreaterThan(0)
const round = buildChimneyGeometry(
ChimneyNode.parse({ cricketStyle: 'simple', bodyShape: 'round' }),
fixtureSegment(),
)
expect(round.cricket).toBeNull()
})
test('shoulder style materially increases body vertex count for tapered/corbeled', () => {
const none = buildChimneyGeometry(
ChimneyNode.parse({ shoulderStyle: 'none' }),
fixtureSegment(),
).body.getAttribute('position').count
const tapered = buildChimneyGeometry(
ChimneyNode.parse({ shoulderStyle: 'tapered' }),
fixtureSegment(),
).body.getAttribute('position').count
const corbeled = buildChimneyGeometry(
ChimneyNode.parse({ shoulderStyle: 'corbeled' }),
fixtureSegment(),
).body.getAttribute('position').count
expect(tapered).toBeGreaterThan(none)
expect(corbeled).toBeGreaterThan(tapered)
})
})
describe('flueXPositions', () => {
test('count=0 returns []', () => {
expect(flueXPositions(0, 0.6, 0.22)).toEqual([])
})
test('count=1 returns [0]', () => {
expect(flueXPositions(1, 0.6, 0.22)).toEqual([0])
})
test('count=4 spans the available width at spacing=1', () => {
const xs = flueXPositions(4, 0.6, 0.1, 1)
expect(xs.length).toBe(4)
expect(xs[0]).toBeCloseTo(-(0.6 - 0.1) / 2)
expect(xs[3]).toBeCloseTo((0.6 - 0.1) / 2)
})
test('spacing=0 collapses all to center', () => {
const xs = flueXPositions(3, 0.6, 0.1, 0)
expect(xs.every((x) => Math.abs(x) < 1e-6)).toBe(true)
})
})
@@ -0,0 +1,62 @@
import { describe, expect, test } from 'bun:test'
import { ChimneyNode } from '../schema'
import {
CHIMNEY_PRESET_KEYS,
chimneyPresets,
detectActiveChimneyPreset,
} from '../presets'
// Build a fully-formed chimney by parsing an empty object (schema fills
// every default) and merging the preset over the top — mirrors what the
// panel does when it calls `commitProp(chimneyPresets[key])`.
const applyPreset = (key: keyof typeof chimneyPresets) =>
({ ...ChimneyNode.parse({}), ...chimneyPresets[key] }) as Parameters<
typeof detectActiveChimneyPreset
>[0]
describe('detectActiveChimneyPreset', () => {
test('returns null when no node is supplied', () => {
expect(detectActiveChimneyPreset(null)).toBeNull()
expect(detectActiveChimneyPreset(undefined)).toBeNull()
})
test('returns null for a freshly-parsed default chimney (no preset applied)', () => {
// The schema's defaults are deliberately neutral — they should NOT
// accidentally match one of the curated presets. If they do, the
// panel will show a preset as active on every fresh chimney and the
// user has no "custom starting state".
expect(detectActiveChimneyPreset(ChimneyNode.parse({}))).toBeNull()
})
test.each(CHIMNEY_PRESET_KEYS)('round-trips %s preset', (key) => {
expect(detectActiveChimneyPreset(applyPreset(key))).toBe(key)
})
test('returns null after the user tweaks a field away from the preset', () => {
const node = applyPreset('brick')
// Brick preset sets bandStyle=double; flip it to confirm the
// detection narrows.
expect(
detectActiveChimneyPreset({ ...node, bandStyle: 'single' as const }),
).toBeNull()
})
test('ignores non-preset fields (dimensions, materials, placement)', () => {
// The whole point of the preset model: applying brick to an
// already-sized chimney doesn't reset its width/depth/material, and
// varying those alone shouldn't kick it out of "Brick".
const node = applyPreset('brick')
expect(
detectActiveChimneyPreset({
...node,
width: 1.2,
depth: 0.8,
heightAboveRidge: 2.5,
position: [3, 0, -1] as [number, number, number],
rotation: 0.7,
materialPreset: 'preset-brick-redbrown',
topMaterialPreset: 'preset-concrete',
}),
).toBe('brick')
})
})
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test'
import { ChimneyNode } from '../schema'
describe('ChimneyNode schema', () => {
test('parses with defaults', () => {
const parsed = ChimneyNode.parse({})
expect(parsed.type).toBe('chimney')
expect(parsed.id).toMatch(/^chimney_/)
expect(parsed.width).toBe(0.6)
expect(parsed.depth).toBe(0.6)
expect(parsed.heightAboveRidge).toBe(1.0)
expect(parsed.bodyShape).toBe('square')
expect(parsed.cap).toBe(true)
expect(parsed.capShape).toBe('sloped')
expect(parsed.flueCount).toBe(1)
expect(parsed.shoulderStyle).toBe('none')
expect(parsed.cricketStyle).toBe('none')
})
test('accepts every body shape and cap shape', () => {
for (const bodyShape of ['square', 'round'] as const) {
expect(ChimneyNode.parse({ bodyShape }).bodyShape).toBe(bodyShape)
}
for (const capShape of ['none', 'sloped', 'flat', 'stepped'] as const) {
expect(ChimneyNode.parse({ capShape }).capShape).toBe(capShape)
}
})
test('rejects flueCount out of [0,4]', () => {
expect(() => ChimneyNode.parse({ flueCount: -1 })).toThrow()
expect(() => ChimneyNode.parse({ flueCount: 5 })).toThrow()
expect(() => ChimneyNode.parse({ flueCount: 1.5 })).toThrow()
})
test('rejects unknown enums', () => {
expect(() => ChimneyNode.parse({ shoulderStyle: 'bogus' })).toThrow()
expect(() => ChimneyNode.parse({ cricketSide: 'side' })).toThrow()
})
})
+99
View File
@@ -0,0 +1,99 @@
import { type NodeDefinition, ChimneyNode as ChimneyNodeSchema } from '@pascal-app/core'
import { chimneyPaint } from './paint'
import { chimneyParametrics } from './parametrics'
import { ChimneyNode } from './schema'
// 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`.
const WHITE_MATERIAL = {
properties: {
color: '#ffffff',
roughness: 0.85,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
},
}
/**
* Chimney — a vertical masonry stack hosted on a roof segment.
*
* Three-checkbox model: `def.renderer` (custom — segment-aware
* geometry from `useScene`, body height derived from
* `segment.wallHeight + roofHeight + heightAboveRidge`), no `geometry`,
* no `system`.
*
* **Option C scope**: chimney ships in the registry shape with solid
* geometry. CSG-driven decoration (cap flue holes, body cavity,
* panels, bands) is preserved in the schema but not rendered yet —
* those re-light when roof-segment migrates to Stage B and introduces
* a `roofCutout` capability the parent segment can read.
*/
export const chimneyDefinition: NodeDefinition<typeof ChimneyNode> = {
kind: 'chimney',
schemaVersion: 1,
schema: ChimneyNode,
category: 'structure',
defaults: () => {
const stub = ChimneyNodeSchema.parse({
id: 'chimney_default' as never,
type: 'chimney',
material: WHITE_MATERIAL,
topMaterial: WHITE_MATERIAL,
})
const { id: _id, type: _type, ...rest } = stub
return rest
},
capabilities: {
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
// Mounts on a roof segment via `roofSegmentId`. Dirty marks
// cascade to the host segment's parent roof so its merged shell
// re-renders. No `buildCut` — the chimney does its own self-trim
// via `trimChimneyBodyAgainstRoof`; the host roof shell stays solid
// underneath.
roofAccessory: {},
// Paint dispatch for the body / top surface split. The editor's
// selection-manager routes paint hover / click / preview through
// this entry rather than carrying a kind-name arm.
paint: chimneyPaint,
},
affordanceTools: {
// Drag-to-place tool for duplicate + move. Reuses the placement
// ghost preview but seeds it from the moving (cloned) node so the
// duplicate keeps the source's body shape, materials, panels, etc.
move: () => import('./move-tool'),
},
parametrics: chimneyParametrics,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Place chimney on roof' },
{ key: 'Esc', label: 'Cancel' },
],
presentation: {
label: 'Chimney',
description: 'Vertical masonry stack on a roof segment.',
icon: { kind: 'url', src: '/icons/roof.png' },
paletteSection: 'structure',
paletteOrder: 122,
},
mcp: {
description:
'A chimney on a roof segment. Square or round body; optional shoulder taper; sloped/flat/stepped cap; up to 4 protruding flues; optional cricket on the up-slope face.',
},
}
+629
View File
@@ -0,0 +1,629 @@
import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
/**
* Pure chimney geometry builder. Returns body, cap, flues, and cricket
* as separate BufferGeometries so each can carry its own material
* (body/top split mirrors the schema's `material` vs `topMaterial`).
*
* **Option C scope** (see commit message): no CSG. The chimney body
* intersects the roof at the deck line; the cap is solid (no flue
* holes carved); the body has no hollow shaft cavity; flues are solid
* cylinders/boxes protruding from the cap. Decorative bands and inset
* panels are no-op on this builder until roof-segment migrates to
* Stage B and a `roofCutout` capability lets the parent segment own
* its own boolean operations.
*
* Pure: no React, no scene access, no store mutation. Takes the
* segment as a second argument so the body height can be derived from
* the segment's pitch — analogous to `door`'s `ctx.parent` access.
*/
export type ChimneyGeometry = {
body: THREE.BufferGeometry
cap: THREE.BufferGeometry | null
flues: THREE.BufferGeometry | null
cricket: THREE.BufferGeometry | null
bands: THREE.BufferGeometry | null
}
// Small air gap between the body top and the cap bottom — without it
// the cap reads as glued onto the body; this slot catches a shadow
// line and sells the cap as a separate stone/metal piece.
const CAP_REVEAL = 0.003
/**
* Smooth-shaded indexed cylinder. Used for every round body / cap /
* band section. `THREE.CylinderGeometry` gives us:
* - shared side vertices across adjacent radial segments → smooth
* cylindrical shading (the previous unindexed pusher made every
* 24-segment chimney visibly faceted),
* - separate cap-rim vertices → crisp top/bottom edges,
* - radial UV projection on the caps (vs. the previous (0,0) smear).
*/
function buildSmoothCylinder(
yBot: number,
yTop: number,
rBot: number,
rTop: number,
segments = 24,
): THREE.BufferGeometry {
const h = Math.max(1e-4, yTop - yBot)
const cy = (yTop + yBot) / 2
// CylinderGeometry params: radiusTop, radiusBottom, height, radialSegments,
// heightSegments, openEnded.
const geo = new THREE.CylinderGeometry(rTop, rBot, h, segments, 1, false)
geo.translate(0, cy, 0)
return geo
}
function mergeAndDispose(parts: THREE.BufferGeometry[]): THREE.BufferGeometry {
if (parts.length === 1) return parts[0]!
const merged = mergeGeometries(parts, false)
if (!merged) return parts[0]!
for (const p of parts) p.dispose()
return merged
}
export function buildChimneyGeometry(
node: ChimneyNode,
segment: RoofSegmentNode,
): ChimneyGeometry {
const peakY = segment.wallHeight + getActiveRoofHeight(segment)
const topY = peakY + node.heightAboveRidge
// Embed the body 0.2m below the eave so the bottom isn't visible
// above the roof when the chimney sits over a low-slope segment.
const baseY = Math.max(0, segment.wallHeight - 0.2)
const body = buildBodyGeometry(node, baseY, topY)
let cap: THREE.BufferGeometry | null = null
let capTopY = topY
if (node.cap && node.capShape !== 'none') {
// Inset the cap by `CAP_REVEAL` above the body top so a shadow
// line separates them.
const capBaseY = topY + CAP_REVEAL
cap = buildCapGeometry(node, capBaseY)
capTopY = capBaseY + node.capThickness
}
let flues: THREE.BufferGeometry | null = null
if (node.flueCount > 0) {
flues = buildFluesGeometry(node, capTopY)
}
let cricket: THREE.BufferGeometry | null = null
if (node.cricketStyle !== 'none' && node.bodyShape !== 'round') {
cricket = buildCricketGeometry(node, baseY)
}
let bands: THREE.BufferGeometry | null = null
if (node.bandStyle !== 'none') {
bands = buildBandsGeometry(node, baseY, topY)
}
return { body, cap, flues, cricket, bands }
}
// ─── Body ────────────────────────────────────────────────────────────
function buildBodyGeometry(
node: ChimneyNode,
baseY: number,
topY: number,
): THREE.BufferGeometry {
const isRound = node.bodyShape === 'round'
const w = node.width
const d = isRound ? node.width : node.depth
const r = w / 2
const style = node.shoulderStyle
const ext = Math.max(0, node.shoulderExtent)
const sh = Math.max(0.05, Math.min(node.shoulderHeight, topY - baseY - 0.05))
if (isRound) {
// Round body — assemble from smooth-shaded indexed cylinder pieces.
// Each shoulder tier is its own cylinder so corbeled steps stay
// crisp; the merge below preserves indices.
const parts: THREE.BufferGeometry[] = []
if (style === 'none') {
parts.push(buildSmoothCylinder(baseY, topY, r, r))
} else if (style === 'tapered') {
parts.push(buildSmoothCylinder(baseY, baseY + sh, r + ext, r))
parts.push(buildSmoothCylinder(baseY + sh, topY, r, r))
} else {
// corbeled — three stepped tiers, then the straight shaft above.
const tiers = 3
const tierH = sh / tiers
for (let i = 0; i < tiers; i++) {
const f = i / tiers
const yBot = baseY + i * tierH
const yTop = baseY + (i + 1) * tierH
const rr = r + ext * (1 - f)
parts.push(buildSmoothCylinder(yBot, yTop, rr, rr))
}
parts.push(buildSmoothCylinder(baseY + sh, topY, r, r))
}
const merged = mergeAndDispose(parts)
applyNodeTransform(merged, node)
return merged
}
// Square body — keep the unindexed face emitter; pass cornerBevel
// so each slab section's vertical corners are chamfered into 45°
// faces. The chamfer catches a highlight on every edge and reads as
// a masonry chimney instead of a plastic box.
const positions: number[] = []
const uvs: number[] = []
const bevel = Math.max(0, node.cornerBevel ?? 0)
if (style === 'none') {
pushSlabFaces(positions, uvs, baseY, topY, w / 2, d / 2, w / 2, d / 2, bevel)
} else if (style === 'tapered') {
pushSlabFaces(positions, uvs, baseY, baseY + sh, w / 2 + ext, d / 2 + ext, w / 2, d / 2, bevel)
pushSlabFaces(positions, uvs, baseY + sh, topY, w / 2, d / 2, w / 2, d / 2, bevel)
} else {
const tiers = 3
const tierH = sh / tiers
for (let i = 0; i < tiers; i++) {
const f = i / tiers
const yBot = baseY + i * tierH
const yTop = baseY + (i + 1) * tierH
const hw = w / 2 + ext * (1 - f)
const hd = d / 2 + ext * (1 - f)
pushSlabFaces(positions, uvs, yBot, yTop, hw, hd, hw, hd, bevel)
}
pushSlabFaces(positions, uvs, baseY + sh, topY, w / 2, d / 2, w / 2, d / 2, bevel)
}
const geo = buildBufferGeometry(positions, uvs)
applyNodeTransform(geo, node)
geo.computeVertexNormals()
return geo
}
// ─── Cap ─────────────────────────────────────────────────────────────
function buildCapGeometry(node: ChimneyNode, capBaseY: number): THREE.BufferGeometry {
const overhang = Math.max(0, node.capOverhang)
const t = node.capThickness
const isRound = node.bodyShape === 'round'
const halfW = node.width / 2 + overhang
const halfD = (isRound ? node.width : node.depth) / 2 + overhang
const halfWInner = node.width / 2
const halfDInner = (isRound ? node.width : node.depth) / 2
const y0 = capBaseY
const y1 = capBaseY + t
if (isRound) {
const parts: THREE.BufferGeometry[] = []
switch (node.capShape) {
case 'flat':
parts.push(buildSmoothCylinder(y0, y1, halfW, halfW))
break
case 'stepped': {
const tiers = 3
const tT = t / tiers
for (let i = 0; i < tiers; i++) {
const f = i / tiers
const yBot = y0 + i * tT
const yTop = y0 + (i + 1) * tT
const rr = halfW + (halfWInner - halfW) * f
parts.push(buildSmoothCylinder(yBot, yTop, rr, rr))
}
break
}
default:
// 'sloped' — taper from overhang base to chimney footprint at top
parts.push(buildSmoothCylinder(y0, y1, halfW, halfWInner))
break
}
const merged = mergeAndDispose(parts)
applyNodeTransform(merged, node)
return merged
}
// Square cap — unindexed slabs, optional corner chamfer.
const positions: number[] = []
const uvs: number[] = []
const bevel = Math.max(0, node.cornerBevel ?? 0)
switch (node.capShape) {
case 'flat':
pushSlabFaces(positions, uvs, y0, y1, halfW, halfD, halfW, halfD, bevel)
break
case 'stepped': {
const tiers = 3
const tT = t / tiers
for (let i = 0; i < tiers; i++) {
const f = i / tiers
const yBot = y0 + i * tT
const yTop = y0 + (i + 1) * tT
const hw = halfW + (halfWInner - halfW) * f
const hd = halfD + (halfDInner - halfD) * f
pushSlabFaces(positions, uvs, yBot, yTop, hw, hd, hw, hd, bevel)
}
break
}
default:
pushSlabFaces(positions, uvs, y0, y1, halfW, halfD, halfWInner, halfDInner, bevel)
break
}
const geo = buildBufferGeometry(positions, uvs)
applyNodeTransform(geo, node)
geo.computeVertexNormals()
return geo
}
// ─── Flues ───────────────────────────────────────────────────────────
export function flueXPositions(
count: number,
chimneyWidth: number,
flueDiameter: number,
spacing = 1,
): number[] {
if (count <= 0) return []
if (count === 1) return [0]
const fullAvailable = Math.max(0, chimneyWidth - flueDiameter)
const available = fullAvailable * Math.max(0, Math.min(1, spacing))
const xs: number[] = []
for (let i = 0; i < count; i++) {
xs.push(-available / 2 + (i * available) / (count - 1))
}
return xs
}
// Flue-pot proportions. The previous renderer drew each flue as a
// single straight cylinder/box — visually a "drainpipe", not a chimney
// pot. Real terracotta pots have a tall shaft topped by a short
// overhanging rim; this two-tier silhouette is the cheapest geometry
// that reads as a pot. Total height still equals `flueHeight`, so the
// bore cutter in `holes.ts` covers the whole envelope unchanged.
const FLUE_RIM_HEIGHT_RATIO = 0.12 // 12 % of total height, capped below
const FLUE_RIM_HEIGHT_MAX = 0.04 // 4 cm — bigger than this looks chunky
const FLUE_RIM_OVERHANG_RATIO = 0.12 // 12 % of flue diameter, radially
function buildFluesGeometry(node: ChimneyNode, capTopY: number): THREE.BufferGeometry | null {
const count = Math.max(0, Math.min(4, node.flueCount))
if (count === 0) return null
const d = Math.max(0.02, node.flueDiameter)
const h = Math.max(0.02, node.flueHeight)
const xs = flueXPositions(count, node.width, d, node.flueSpacing)
const parts: THREE.BufferGeometry[] = []
const rimHeight = Math.min(h * FLUE_RIM_HEIGHT_RATIO, FLUE_RIM_HEIGHT_MAX)
const shaftHeight = h - rimHeight
const rimOverhang = d * FLUE_RIM_OVERHANG_RATIO
for (const x of xs) {
const yBot = capTopY
const yShaftTop = capTopY + shaftHeight
if (node.flueShape === 'square') {
const shaft = new THREE.BoxGeometry(d, shaftHeight, d)
shaft.translate(x, yBot + shaftHeight / 2, 0)
parts.push(shaft)
const rimSide = d + 2 * rimOverhang
const rim = new THREE.BoxGeometry(rimSide, rimHeight, rimSide)
rim.translate(x, yShaftTop + rimHeight / 2, 0)
parts.push(rim)
} else {
// Round flues: indexed CylinderGeometry — smooth shafts, crisp
// rim edges, radial cap UVs (same #1/#2 fixes already applied to
// the body / cap / bands).
const shaft = buildSmoothCylinder(yBot, yShaftTop, d / 2, d / 2)
shaft.translate(x, 0, 0)
parts.push(shaft)
const rimR = d / 2 + rimOverhang
const rim = buildSmoothCylinder(yShaftTop, yShaftTop + rimHeight, rimR, rimR)
rim.translate(x, 0, 0)
parts.push(rim)
}
}
if (parts.length === 0) return null
const merged = mergeAndDispose(parts)
applyNodeTransform(merged, node)
return merged
}
// ─── Cricket ─────────────────────────────────────────────────────────
// Water-shedding wedge on the up-slope side of the chimney.
function buildCricketGeometry(
node: ChimneyNode,
baseY: number,
): THREE.BufferGeometry {
const w = node.width
const d = node.depth
const cL = Math.max(0.1, node.cricketLength)
const cH = Math.max(0.05, node.cricketHeight)
const slopeSign = node.cricketSide === 'back' ? -1 : 1
const sZ = slopeSign * (d / 2)
const sZFar = sZ + slopeSign * cL
const peakY = baseY + cH
const slopeLen = Math.hypot(cL, cH)
const positions: number[] = []
const uvs: number[] = []
// Vertex layout (back = against the chimney face):
// v0/v1 back-bottom (left/right) v4/v5 back-top (left/right)
// v3/v2 front-bottom (left/right)
const v0: [number, number, number] = [-w / 2, baseY, sZ]
const v1: [number, number, number] = [w / 2, baseY, sZ]
const v2: [number, number, number] = [w / 2, baseY, sZFar]
const v3: [number, number, number] = [-w / 2, baseY, sZFar]
const v4: [number, number, number] = [-w / 2, peakY, sZ]
const v5: [number, number, number] = [w / 2, peakY, sZ]
// Planar UVs per face — each face mapped to its own 2D extent so the
// texture tiles correctly (u along width, v along the in-face axis).
const u0_: [number, number] = [0, 0]
const u1_: [number, number] = [w, 0]
const uvBottom: Record<'v0' | 'v1' | 'v2' | 'v3', [number, number]> = {
v0: u0_, v1: u1_, v2: [w, cL], v3: [0, cL],
}
const uvSlope: Record<'v3' | 'v2' | 'v5' | 'v4', [number, number]> = {
v3: [0, 0], v2: [w, 0], v5: [w, slopeLen], v4: [0, slopeLen],
}
const uvBack: Record<'v0' | 'v1' | 'v5' | 'v4', [number, number]> = {
v0: [0, 0], v1: [w, 0], v5: [w, cH], v4: [0, cH],
}
const uvLeft: Record<'v0' | 'v3' | 'v4', [number, number]> = {
v0: [0, 0], v3: [cL, 0], v4: [0, cH],
}
const uvRight: Record<'v1' | 'v5' | 'v2', [number, number]> = {
v1: [0, 0], v5: [0, cH], v2: [cL, 0],
}
const pushTri = (
a: [number, number, number],
b: [number, number, number],
c: [number, number, number],
ua: [number, number],
ub: [number, number],
uc: [number, number],
) => {
if (slopeSign > 0) {
positions.push(...a, ...b, ...c)
uvs.push(...ua, ...ub, ...uc)
} else {
positions.push(...a, ...c, ...b)
uvs.push(...ua, ...uc, ...ub)
}
}
// Bottom (quad split into 2 tris)
pushTri(v0, v1, v2, uvBottom.v0, uvBottom.v1, uvBottom.v2)
pushTri(v0, v2, v3, uvBottom.v0, uvBottom.v2, uvBottom.v3)
// Sloped top (v3 v2 v5 v4)
pushTri(v3, v2, v5, uvSlope.v3, uvSlope.v2, uvSlope.v5)
pushTri(v3, v5, v4, uvSlope.v3, uvSlope.v5, uvSlope.v4)
// Back face against the chimney (v0 v1 v5 v4)
pushTri(v0, v4, v5, uvBack.v0, uvBack.v4, uvBack.v5)
pushTri(v0, v5, v1, uvBack.v0, uvBack.v5, uvBack.v1)
// Left side triangle
pushTri(v0, v3, v4, uvLeft.v0, uvLeft.v3, uvLeft.v4)
// Right side triangle
pushTri(v1, v5, v2, uvRight.v1, uvRight.v5, uvRight.v2)
const geo = buildBufferGeometry(positions, uvs)
applyNodeTransform(geo, node)
geo.computeVertexNormals()
return geo
}
// ─── Bands ───────────────────────────────────────────────────────────
// Decorative horizontal stripes around the chimney (soldier-course
// brick / stone band). Single or double; each band protrudes outward
// by `bandExtent` per side.
function buildBandsGeometry(
node: ChimneyNode,
baseY: number,
topY: number,
): THREE.BufferGeometry | null {
const isRound = node.bodyShape === 'round'
const w = node.width
const d = isRound ? node.width : node.depth
const r = w / 2
const bandExt = Math.max(0, node.bandExtent)
const bandH = Math.max(0.02, node.bandHeight)
const bandOffset = Math.max(0, node.bandOffset)
const count = node.bandStyle === 'double' ? 2 : 1
const gap = bandH * 0.6
if (isRound) {
const parts: THREE.BufferGeometry[] = []
for (let i = 0; i < count; i++) {
const bandTop = topY - bandOffset - i * (bandH + gap)
const bandBot = bandTop - bandH
if (bandBot <= baseY + 0.01) break
parts.push(buildSmoothCylinder(bandBot, bandTop, r + bandExt, r + bandExt))
}
if (parts.length === 0) return null
const merged = mergeAndDispose(parts)
applyNodeTransform(merged, node)
return merged
}
const positions: number[] = []
const uvs: number[] = []
const bevel = Math.max(0, node.cornerBevel ?? 0)
for (let i = 0; i < count; i++) {
const bandTop = topY - bandOffset - i * (bandH + gap)
const bandBot = bandTop - bandH
if (bandBot <= baseY + 0.01) break
pushSlabFaces(
positions,
uvs,
bandBot,
bandTop,
w / 2 + bandExt,
d / 2 + bandExt,
w / 2 + bandExt,
d / 2 + bandExt,
bevel,
)
}
if (positions.length === 0) return null
const geo = buildBufferGeometry(positions, uvs)
applyNodeTransform(geo, node)
geo.computeVertexNormals()
return geo
}
// ─── 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)
}
function buildBufferGeometry(positions: number[], uvs: number[]): THREE.BufferGeometry {
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
return geo
}
function pushSlabFaces(
positions: number[],
uvs: number[],
y0: number,
y1: number,
halfWB: number,
halfDB: number,
halfWT: number,
halfDT: number,
bevel = 0,
) {
// Clamp bevel so it never eats more than the slab can spare on
// either ring (a wider bottom plus a narrower top, e.g. an inverted
// taper, has different limits per ring).
const cB = Math.max(0, Math.min(bevel, halfWB - 0.001, halfDB - 0.001))
const cT = Math.max(0, Math.min(bevel, halfWT - 0.001, halfDT - 0.001))
if (cB > 0.001 || cT > 0.001) {
pushOctagonalSlabFaces(
positions, uvs, y0, y1, halfWB, halfDB, halfWT, halfDT, cB, cT,
)
return
}
const t = y1 - y0
const bBL: [number, number, number] = [-halfWB, y0, -halfDB]
const bBR: [number, number, number] = [halfWB, y0, -halfDB]
const bTR: [number, number, number] = [halfWB, y0, halfDB]
const bTL: [number, number, number] = [-halfWB, y0, halfDB]
const tBL: [number, number, number] = [-halfWT, y1, -halfDT]
const tBR: [number, number, number] = [halfWT, y1, -halfDT]
const tTR: [number, number, number] = [halfWT, y1, halfDT]
const tTL: [number, number, number] = [-halfWT, y1, halfDT]
const pushQuad = (
a: [number, number, number],
b: [number, number, number],
c: [number, number, number],
d: [number, number, number],
ua: [number, number],
ub: [number, number],
uc: [number, number],
ud: [number, number],
) => {
positions.push(...a, ...c, ...b, ...a, ...d, ...c)
uvs.push(...ua, ...uc, ...ub, ...ua, ...ud, ...uc)
}
// Bottom
pushQuad(bBL, bTL, bTR, bBR,
[-halfWB, -halfDB], [-halfWB, halfDB], [halfWB, halfDB], [halfWB, -halfDB])
// Top
pushQuad(tBL, tBR, tTR, tTL,
[-halfWT, -halfDT], [halfWT, -halfDT], [halfWT, halfDT], [-halfWT, halfDT])
// Sides
pushQuad(bBL, bBR, tBR, tBL, [-halfWB, 0], [halfWB, 0], [halfWT, t], [-halfWT, t])
pushQuad(bBR, bTR, tTR, tBR, [-halfDB, 0], [halfDB, 0], [halfDT, t], [-halfDT, t])
pushQuad(bTR, bTL, tTL, tTR, [halfWB, 0], [-halfWB, 0], [-halfWT, t], [halfWT, t])
pushQuad(bTL, bBL, tBL, tTL, [halfDB, 0], [-halfDB, 0], [-halfDT, t], [halfDT, t])
}
/**
* Octagonal-footprint variant of `pushSlabFaces`. Each corner of the
* usual 4-corner slab is replaced by a 45° chamfer, giving an
* 8-vertex ring at each y-level. Eight side faces (four axis-aligned
* + four chamfer) plus two fan-triangulated octagonal caps. UVs use
* the same physical-meter convention as the unchamfered path so
* textures (brick, stone) tile at a consistent rate either way.
*/
function pushOctagonalSlabFaces(
positions: number[],
uvs: number[],
y0: number,
y1: number,
halfWB: number,
halfDB: number,
halfWT: number,
halfDT: number,
cB: number,
cT: number,
) {
// Eight ring vertices per y-level, traced so consecutive entries
// share an outward-facing wall edge. Order (looking down +Y):
// p0 (+x, -z+c) p1 (+x, +z-c) p2 (+x-c, +z) p3 (-x+c, +z)
// p4 (-x, +z-c) p5 (-x, -z+c) p6 (-x+c, -z) p7 (+x-c, -z)
const ring = (hw: number, hd: number, c: number, y: number) =>
[
[hw, y, -hd + c],
[hw, y, hd - c],
[hw - c, y, hd],
[-hw + c, y, hd],
[-hw, y, hd - c],
[-hw, y, -hd + c],
[-hw + c, y, -hd],
[hw - c, y, -hd],
] as Array<[number, number, number]>
const bot = ring(halfWB, halfDB, cB, y0)
const top = ring(halfWT, halfDT, cT, y1)
const t = y1 - y0
// Eight walls. UVs: u = signed perimeter offset (in meters) from
// the start of each wall, v = height.
for (let i = 0; i < 8; i++) {
const j = (i + 1) % 8
const bA = bot[i]!
const bB = bot[j]!
const tA = top[i]!
const tB = top[j]!
const wallLen = Math.hypot(bB[0] - bA[0], bB[2] - bA[2])
// Two CCW-from-outside triangles per quad: (bA, bB, tB) + (bA, tB, tA).
positions.push(...bA, ...bB, ...tB, ...bA, ...tB, ...tA)
uvs.push(0, 0, wallLen, 0, wallLen, t, 0, 0, wallLen, t, 0, t)
}
// Top cap: fan from centre. CCW from above → +Y normal.
const cTop: [number, number, number] = [0, y1, 0]
for (let i = 0; i < 8; i++) {
const j = (i + 1) % 8
const a = top[i]!
const b = top[j]!
positions.push(...cTop, ...b, ...a)
uvs.push(0, 0, b[0], b[2], a[0], a[2])
}
// Bottom cap: reverse winding → -Y normal.
const cBot: [number, number, number] = [0, y0, 0]
for (let i = 0; i < 8; i++) {
const j = (i + 1) % 8
const a = bot[i]!
const b = bot[j]!
positions.push(...cBot, ...a, ...b)
uvs.push(0, 0, a[0], a[2], b[0], b[2])
}
}
+362
View File
@@ -0,0 +1,362 @@
import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core'
import {
Brush,
csgEvaluator,
csgGeometry,
prepareBrushForCSG,
SUBTRACTION,
} from '@pascal-app/viewer'
import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { flueXPositions } from './geometry'
const dummyMat = new THREE.MeshBasicMaterial()
/**
* Carve the top openings the chimney needs:
* - smoke shaft cavity in the body (one chimney-wide hole, or one
* bore per flue when the flues are hollow)
* - matching holes punched through the cap
* - inner bore subtracted from each flue tube
*
* Mirrors the v1 roof-system pipeline. Lives next to `roof-trim.ts` so
* the CSG deps (three-bvh-csg / three-mesh-bvh) stay inside the
* chimney folder; `geometry.ts` itself stays pure.
*/
export function carveChimneyHoles(
body: THREE.BufferGeometry,
cap: THREE.BufferGeometry | null,
flues: THREE.BufferGeometry | null,
node: ChimneyNode,
segment: RoofSegmentNode,
): {
body: THREE.BufferGeometry
cap: THREE.BufferGeometry | null
flues: THREE.BufferGeometry | null
} {
const peakY = segment.wallHeight + getActiveRoofHeight(segment)
const topY = peakY + node.heightAboveRidge
const capPresent = !!cap && node.cap && node.capShape !== 'none'
const capTopY = topY + (capPresent ? node.capThickness : 0)
const flueCount = Math.max(0, Math.min(4, node.flueCount ?? 0))
const flueDiameter = Math.max(0.02, node.flueDiameter ?? 0.22)
const flueWallT = Math.max(0, node.flueWallThickness ?? 0.02)
const flueInner = flueDiameter - 2 * flueWallT
const useFlueHoles = flueCount > 0 && flueWallT > 0 && flueInner > 0.02
const cavityDepth = Math.max(0, node.bodyHollowDepth ?? 0.6)
const hollowMargin = Math.max(0, node.bodyHollowMargin ?? 0.08)
const isRound = (node.bodyShape ?? 'square') === 'round'
type CutterSpec = {
shape: 'round' | 'square'
sizeX: number
sizeZ: number
xCenter: number
}
const specs: CutterSpec[] = []
if (cavityDepth > 0.01) {
if (useFlueHoles) {
const xs = flueXPositions(flueCount, node.width, flueDiameter, node.flueSpacing)
const flueShape = node.flueShape ?? 'round'
for (const x of xs) {
specs.push({ shape: flueShape, sizeX: flueInner, sizeZ: flueInner, xCenter: x })
}
} else if (hollowMargin > 0) {
if (isRound) {
const r = node.width / 2 - hollowMargin
if (r > 0.02) {
specs.push({ shape: 'round', sizeX: 2 * r, sizeZ: 2 * r, xCenter: 0 })
}
} else {
const cw = node.width - 2 * hollowMargin
const cd = node.depth - 2 * hollowMargin
if (cw > 0.04 && cd > 0.04) {
specs.push({ shape: 'square', sizeX: cw, sizeZ: cd, xCenter: 0 })
}
}
}
}
const flueHeight = Math.max(0.02, node.flueHeight ?? 0.3)
const yCavityBot = topY - cavityDepth
const yCapTop = capTopY + 0.02
const subtractFrom = (
base: THREE.BufferGeometry,
yBot: number,
yTop: number,
): THREE.BufferGeometry => {
if (specs.length === 0) return base
const cutters = specs.map((spec) => buildCutter(node, spec, yBot, yTop))
const result = subtractCutters(base, cutters)
for (const cutter of cutters) cutter.geometry.dispose()
return result
}
let newBody = subtractFrom(body, yCavityBot, yCapTop)
const newCap = cap ? subtractFrom(cap, yCavityBot, yCapTop) : null
// Decorative inset panels — carve a shallow rectangle out of each
// vertical face. Square bodies only (round bodies have no flat
// faces). Same CSG pipeline as the cavity cutters above.
const wantPanels =
node.panelStyle !== 'none' && !isRound && node.panelDepth > 0 && node.panelHeight > 0.01
if (wantPanels) {
const panelCutters = buildPanelCutters(node, topY)
if (panelCutters.length > 0) {
newBody = subtractCutters(newBody, panelCutters)
for (const cutter of panelCutters) cutter.geometry.dispose()
}
}
// Hollow each flue tube by punching its inner bore through.
let newFlues = flues
if (flues && useFlueHoles) {
const xs = flueXPositions(flueCount, node.width, flueDiameter, node.flueSpacing)
const flueShape = node.flueShape ?? 'round'
const cutters = xs.map((x) =>
buildCutter(
node,
{ shape: flueShape, sizeX: flueInner, sizeZ: flueInner, xCenter: x },
capTopY - 0.02,
capTopY + flueHeight + 0.02,
),
)
newFlues = subtractCutters(flues, cutters)
for (const cutter of cutters) cutter.geometry.dispose()
}
// Partition each surface so the very top face becomes its own material
// group (index 1 → top material, index 0 → body material). Matches v1's
// surface-array assignment.
partitionTopFaceGroups(newBody, topY - 0.05)
if (newCap) partitionTopFaceGroups(newCap, capTopY - 0.005)
if (newFlues) partitionTopFaceGroups(newFlues, capTopY + flueHeight - 0.005)
return { body: newBody, cap: newCap, flues: newFlues }
}
/**
* Split the index buffer into two groups:
* - group 0: every triangle whose normal is NOT roughly up, or that
* sits below `topYMin`. Receives the body material.
* - group 1: the top face triangles. Receives the top material.
*
* Mirrors the v1 `partitionTopFaceGroups` in
* `packages/viewer/src/systems/chimney/chimney-geometry.ts`. Operates in
* place — the geometry is re-indexed and its `groups` array rewritten.
*/
export function partitionTopFaceGroups(geo: THREE.BufferGeometry, topYMin: number) {
// CSG paths return indexed geometry; the pure-builder path doesn't. If
// we don't have an index, build one so the partitioning logic has
// something to reorder.
if (!geo.getIndex()) {
const merged = mergeVertices(geo, 1e-4)
if (merged.getIndex()) {
const idx = merged.getIndex()!
geo.setIndex(idx)
geo.setAttribute('position', merged.getAttribute('position'))
if (merged.getAttribute('uv')) geo.setAttribute('uv', merged.getAttribute('uv'))
if (merged.getAttribute('normal')) geo.setAttribute('normal', merged.getAttribute('normal'))
}
}
const positions = geo.getAttribute('position')
let normals = geo.getAttribute('normal')
if (!normals) {
geo.computeVertexNormals()
normals = geo.getAttribute('normal')
}
const index = geo.getIndex()
if (!(positions && normals && index)) {
geo.clearGroups()
geo.addGroup(0, index?.count ?? positions.count, 0)
return
}
const idxArr = index.array as ArrayLike<number>
const topTris: number[] = []
const otherTris: number[] = []
const yEps = 0.02
for (let i = 0; i < idxArr.length; i += 3) {
const a = idxArr[i] as number
const b = idxArr[i + 1] as number
const c = idxArr[i + 2] as number
const ny = (normals.getY(a) + normals.getY(b) + normals.getY(c)) / 3
const py = (positions.getY(a) + positions.getY(b) + positions.getY(c)) / 3
if (ny > 0.95 && py >= topYMin - yEps) {
topTris.push(a, b, c)
} else {
otherTris.push(a, b, c)
}
}
const total = otherTris.length + topTris.length
const useUint32 = (positions.count ?? 0) > 0xff_ff
const newArr = useUint32 ? new Uint32Array(total) : new Uint16Array(total)
for (let i = 0; i < otherTris.length; i++) newArr[i] = otherTris[i] as number
for (let i = 0; i < topTris.length; i++) newArr[otherTris.length + i] = topTris[i] as number
geo.setIndex(new THREE.BufferAttribute(newArr, 1))
geo.clearGroups()
if (otherTris.length > 0) geo.addGroup(0, otherTris.length, 0)
if (topTris.length > 0) geo.addGroup(otherTris.length, topTris.length, 1)
}
/**
* Build one cutter brush per vertical face for the inset-panel feature.
* Each cutter is a thin box flush against the body face; CSG-subtracted
* from the body it leaves a recessed rectangular panel — same shape as
* v1's `buildPanelCutterBrush`.
*/
function buildPanelCutters(node: ChimneyNode, topY: number): Brush[] {
const w = node.width
const d = node.depth
const margin = Math.max(0, node.panelMargin)
const recess = Math.max(0.005, node.panelDepth)
const panelHeight = Math.max(0.05, node.panelHeight)
const offsetTop = Math.max(0, node.panelOffsetTop)
const yTop = topY - offsetTop
const yBot = yTop - panelHeight
const eps = 0.002
const faces: Array<{
sizeX: number
sizeZ: number
cx: number
cz: number
}> = []
const panelW = w - 2 * margin
const panelD = d - 2 * margin
if (panelW > 0.05) {
// frontZ
faces.push({
sizeX: panelW,
sizeZ: recess + 2 * eps,
cx: 0,
cz: d / 2 - recess / 2 + eps,
})
// backZ
faces.push({
sizeX: panelW,
sizeZ: recess + 2 * eps,
cx: 0,
cz: -d / 2 + recess / 2 - eps,
})
}
if (panelD > 0.05) {
// rightX
faces.push({
sizeX: recess + 2 * eps,
sizeZ: panelD,
cx: w / 2 - recess / 2 + eps,
cz: 0,
})
// leftX
faces.push({
sizeX: recess + 2 * eps,
sizeZ: panelD,
cx: -w / 2 + recess / 2 - eps,
cz: 0,
})
}
const h = Math.max(0.02, yTop - yBot)
const midY = (yTop + yBot) / 2
const brushes: 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)
const idx = geo.getIndex()?.count ?? 0
geo.clearGroups()
if (idx > 0) geo.addGroup(0, idx, 0)
const brush = new Brush(geo, dummyMat as unknown as THREE.MeshStandardMaterial)
brush.updateMatrixWorld()
prepareBrushForCSG(brush)
brushes.push(brush)
}
return brushes
}
function buildCutter(
node: ChimneyNode,
spec: { shape: 'round' | 'square'; sizeX: number; sizeZ: number; xCenter: number },
yBot: number,
yTop: number,
): Brush {
const h = Math.max(0.02, yTop - yBot)
const midY = (yTop + yBot) / 2
const geo: THREE.BufferGeometry =
spec.shape === 'round'
? 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)
const idx = geo.getIndex()?.count ?? 0
geo.clearGroups()
if (idx > 0) geo.addGroup(0, idx, 0)
const brush = new Brush(geo, dummyMat as unknown as THREE.MeshStandardMaterial)
brush.updateMatrixWorld()
prepareBrushForCSG(brush)
return brush
}
function subtractCutters(
base: THREE.BufferGeometry,
cutters: Brush[],
): THREE.BufferGeometry {
if (cutters.length === 0) return base
const indexed = mergeVertices(base, 1e-4)
if (!indexed.getAttribute('normal')) indexed.computeVertexNormals()
const ic = indexed.getIndex()?.count ?? 0
indexed.clearGroups()
if (ic > 0) indexed.addGroup(0, ic, 0)
const baseBrush = new Brush(indexed, dummyMat as unknown as THREE.MeshStandardMaterial)
baseBrush.updateMatrixWorld()
prepareBrushForCSG(baseBrush)
let current: Brush = baseBrush
const intermediates: Brush[] = []
try {
for (const cutter of cutters) {
const next = csgEvaluator.evaluate(current, cutter, SUBTRACTION) as Brush
prepareBrushForCSG(next)
if (current !== baseBrush) intermediates.push(current)
current = next
}
const out = csgGeometry(current).clone()
const idx = out.getIndex()?.count ?? 0
out.clearGroups()
if (idx > 0) out.addGroup(0, idx, 0)
else out.addGroup(0, out.getAttribute('position').count, 0)
out.computeVertexNormals()
base.dispose()
indexed.dispose()
for (const b of intermediates) b.geometry.dispose()
if (current !== baseBrush) current.geometry.dispose()
return out
} catch (e) {
// eslint-disable-next-line no-console
console.error('[chimney] hole carve CSG failed:', e)
indexed.dispose()
for (const b of intermediates) b.geometry.dispose()
if (current !== baseBrush) current.geometry.dispose()
return base
}
}
+3
View File
@@ -0,0 +1,3 @@
export { chimneyDefinition } from './definition'
export { buildChimneyGeometry, flueXPositions } from './geometry'
export { ChimneyNode } from './schema'
+184
View File
@@ -0,0 +1,184 @@
'use client'
import {
type AnyNodeId,
ChimneyNode as ChimneyNodeSchema,
type ChimneyNode,
emitter,
type RoofEvent,
type RoofNode,
type RoofSegmentNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
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 ChimneyPreview from './preview'
const tmpMatrix = new THREE.Matrix4()
const tmpInv = new THREE.Matrix4()
const tmpPos = new THREE.Vector3()
const tmpQuat = new THREE.Quaternion()
const tmpScale = new THREE.Vector3()
type SegmentTransform = {
position: [number, number, number]
quaternion: [number, number, number, number]
}
/**
* Drag-to-place tool for chimney duplicate / move. Receives the moving
* node (a clone with `id` stripped + `metadata.isNew = true` after a
* Duplicate action) via `node` prop, shows the same ghost preview as
* placement, and on click commits the cloned chimney to the hit
* segment with that segment's local coords.
*
* Mirrors `tool.tsx`'s placement preview — the only differences are
* (a) the ghost is built from the moving node so the duplicate
* preserves the original's body shape/material/etc., and (b) on click
* we keep all of the clone's fields and only overwrite host segment +
* position. Mounted via `def.affordanceTools.move`.
*/
const MoveChimneyTool = ({ node }: { node: ChimneyNode }) => {
const activeBuildingId = useViewer((s) => s.selection.buildingId)
const setSelection = useViewer((s) => s.setSelection)
const setMovingNode = useEditor((s) => s.setMovingNode)
const [segmentXform, setSegmentXform] = useState<SegmentTransform | null>(null)
const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null)
const [previewSegment, setPreviewSegment] = useState<RoofSegmentNode | null>(null)
const lastSnapRef = useRef<[number, number] | null>(null)
// Ghost data — same as the moving clone but pinned to position[0,0,0]
// (the inner group does the cursor offset). Reparse so Zod fills any
// defaults missing from the clone.
const previewNode = useMemo(
() =>
ChimneyNodeSchema.parse({
...node,
id: 'chimney_preview' as never,
position: [0, 0, 0],
rotation: 0,
}),
[node],
)
useEffect(() => {
if (!activeBuildingId) return
const computeSegmentXform = (segmentId: string): SegmentTransform | null => {
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
const segObj = sceneRegistry.nodes.get(segmentId as AnyNodeId)
if (!(buildingObj && segObj)) return null
buildingObj.updateWorldMatrix(true, false)
segObj.updateWorldMatrix(true, false)
tmpInv.copy(buildingObj.matrixWorld).invert()
tmpMatrix.multiplyMatrices(tmpInv, segObj.matrixWorld)
tmpMatrix.decompose(tmpPos, tmpQuat, tmpScale)
return {
position: [tmpPos.x, tmpPos.y, tmpPos.z],
quaternion: [tmpQuat.x, tmpQuat.y, tmpQuat.z, tmpQuat.w],
}
}
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 xform = computeSegmentXform(hit.segment.id)
if (!xform) return
setSegmentXform(xform)
setHitLocal([hit.localX, hit.localY, hit.localZ])
setPreviewSegment(hit.segment)
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()
// Strip the `isNew` flag — only used to mark a duplicate clone
// that hasn't been committed yet.
const meta =
node.metadata && typeof node.metadata === 'object' && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
: {}
const { isNew, ...restMeta } = meta as { isNew?: boolean }
const cleanedMeta = Object.keys(restMeta).length > 0 ? restMeta : undefined
// Duplicate (clone with no committed id yet) → create a fresh
// chimney parented to the hit segment. Plain move (existing id,
// no `isNew` flag) → update host + position in place. Either way
// every other field from the clone is preserved.
if (isNew || !node.id) {
const committed = ChimneyNodeSchema.parse({
...node,
id: undefined as never,
roofSegmentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ],
metadata: cleanedMeta,
})
state.createNode(committed, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [committed.id] })
} else {
const prevSegmentId = node.roofSegmentId as AnyNodeId | undefined
state.updateNode(node.id as AnyNodeId, {
roofSegmentId: hit.segment.id,
parentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ],
metadata: cleanedMeta,
})
if (prevSegmentId) state.dirtyNodes.add(prevSegmentId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [node.id] })
}
setMovingNode(null)
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, node, setMovingNode, setSelection])
if (!activeBuildingId || !segmentXform || !hitLocal || !previewSegment) return null
return (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={[hitLocal[0], 0, hitLocal[2]]}>
<ChimneyPreview node={previewNode} segment={previewSegment} />
</group>
</group>
)
}
export default MoveChimneyTool
+119
View File
@@ -0,0 +1,119 @@
import type {
ChimneyMaterialRole,
ChimneyNode,
MaterialSchema,
PaintCapability,
} from '@pascal-app/core'
import { createMaterial, createMaterialFromPresetRef } from '@pascal-app/viewer'
import type { Material, Mesh } from 'three'
/**
* Resolve a chimney face click to its logical surface role.
*
* `holes.ts:partitionTopFaceGroups` partitions the chimney body
* mesh's material slots so:
* 0 = body
* 1 = top (the cap face)
* Faces outside any group (cricket mesh, which renders with a single
* material) fall back to 'body'.
*/
export function resolveChimneyRole(materialIndex: number | null): ChimneyMaterialRole {
return materialIndex === 1 ? 'top' : 'body'
}
export function buildChimneyMaterialPatch(
role: ChimneyMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<ChimneyNode> {
if (role === 'top') {
return { topMaterial: material, topMaterialPreset: materialPreset }
}
return { material, materialPreset }
}
export function getEffectiveChimneyMaterial(
node: ChimneyNode,
role: ChimneyMaterialRole,
): { material: MaterialSchema | undefined; materialPreset: string | undefined } {
if (role === 'top') {
const hasTop = node.topMaterial !== undefined || node.topMaterialPreset !== undefined
if (hasTop) {
return { material: node.topMaterial, materialPreset: node.topMaterialPreset }
}
}
return { material: node.material, materialPreset: node.materialPreset }
}
function buildPreviewMaterial(
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Material | null {
if (materialPreset) {
return createMaterialFromPresetRef(materialPreset)
}
if (material) {
return createMaterial(material)
}
return null
}
/**
* Apply a preview material to the chimney's mesh subtree for the
* given role. The body mesh uses a 2-slot material array (body =
* slot 0, top = slot 1) so paint-target → slot index is a single
* lookup. The cricket mesh uses a single material, which only the
* 'body' role paints.
*/
function applyChimneyPreview(
role: ChimneyMaterialRole,
previewMaterial: Material,
root: import('three').Object3D,
): (() => void) | null {
const restores: Array<() => void> = []
root.traverse((object) => {
const mesh = object as Mesh
if (!mesh.isMesh) return
const current = mesh.material as Material | Material[]
if (Array.isArray(current)) {
const idx = role === 'top' ? 1 : 0
const previousAtIdx = current[idx]
if (!previousAtIdx) return
const previousArray = [...current]
const nextArray = [...current]
nextArray[idx] = previewMaterial
mesh.material = nextArray
restores.push(() => {
mesh.material = previousArray
})
} else if (role === 'body') {
const previous = mesh.material
mesh.material = previewMaterial
restores.push(() => {
mesh.material = previous
})
}
})
if (restores.length === 0) return null
return () => {
for (let i = restores.length - 1; i >= 0; i -= 1) restores[i]?.()
}
}
/**
* Capability binding for the chimney kind. The editor's
* selection-manager invokes these in place of the legacy
* `if (node.type === 'chimney') { ... }` arm.
*/
export const chimneyPaint: PaintCapability = {
resolveRole: ({ materialIndex }) => resolveChimneyRole(materialIndex),
buildPatch: ({ role, material, materialPreset }) =>
buildChimneyMaterialPatch(role as ChimneyMaterialRole, material, materialPreset),
applyPreview: ({ role, material, materialPreset, root }) => {
const previewMaterial = buildPreviewMaterial(material, materialPreset)
if (!previewMaterial) return null
return applyChimneyPreview(role as ChimneyMaterialRole, previewMaterial, root)
},
getEffectiveMaterial: ({ node, role }) =>
getEffectiveChimneyMaterial(node as ChimneyNode, role as ChimneyMaterialRole),
}
+904
View File
@@ -0,0 +1,904 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type ChimneyNode,
getActiveRoofHeight,
type RoofNode,
type RoofSegmentNode,
sceneRegistry,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { Vector3 } from 'three'
import { useViewer } from '@pascal-app/viewer'
import { Trash2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import {
ActionButton,
ActionGroup,
PanelSection,
PanelWrapper,
SegmentedControl,
SliderControl,
triggerSFX,
} from '@pascal-app/editor'
import {
CHIMNEY_PRESET_KEYS,
CHIMNEY_PRESET_LABELS,
type ChimneyPresetKey,
chimneyPresets,
detectActiveChimneyPreset,
} from './presets'
// Tiny clsx-equivalent. The editor package doesn't re-export the
// legacy `cn` helper; inlining keeps this panel self-contained.
const cn = (...classes: Array<string | false | undefined | null>): string =>
classes.filter(Boolean).join(' ')
type ChimneyType = 'cap' | 'flues' | 'shoulder' | 'bands' | 'cricket' | 'panels'
const CHIMNEY_TYPE_OPTIONS: Array<{ label: string; value: ChimneyType }> = [
{ label: 'Cap', value: 'cap' },
{ label: 'Flues', value: 'flues' },
{ label: 'Shoulder', value: 'shoulder' },
{ label: 'Bands', value: 'bands' },
{ label: 'Cricket', value: 'cricket' },
{ label: 'Panels', value: 'panels' },
]
export default function ChimneyPanel() {
const [chimneyType, setChimneyType] = useState<ChimneyType>('cap')
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 storeNode = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ChimneyNode | undefined) : undefined,
)
// Merge live overrides so slider displays the value the user is actively
// dragging, even though the store hasn't been written to yet.
const overrides = useLiveNodeOverrides((s) =>
selectedId ? (s.get(selectedId as AnyNodeId) as Partial<ChimneyNode> | undefined) : undefined,
)
const node = storeNode && overrides ? ({ ...storeNode, ...overrides } as ChimneyNode) : storeNode
const handleUpdate = useCallback(
(updates: Partial<ChimneyNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
},
[selectedId, updateNode],
)
// Slider drag → write live override (mesh updates, store untouched).
// Slider release → commit to store + clear override.
const previewProp = useCallback(
(updates: Partial<ChimneyNode>) => {
if (!selectedId) return
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, updates)
},
[selectedId],
)
const commitProp = useCallback(
(updates: Partial<ChimneyNode>) => {
if (!selectedId) return
updateNode(selectedId as AnyNode['id'], updates)
// If reparenting was part of the patch, flag both segments dirty so the
// roof system rebuilds them.
if (updates.roofSegmentId !== undefined) {
const state = useScene.getState()
const prev = node?.roofSegmentId
if (prev) state.dirtyNodes.add(prev as AnyNodeId)
state.dirtyNodes.add(updates.roofSegmentId as AnyNodeId)
state.dirtyNodes.add(selectedId as AnyNodeId)
}
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
},
[node, selectedId, updateNode],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleBack = useCallback(() => {
if (node?.roofSegmentId) {
setSelection({ selectedIds: [node.roofSegmentId as AnyNode['id']] })
}
}, [node?.roofSegmentId, setSelection])
const handleDelete = useCallback(() => {
if (!(selectedId && node)) return
triggerSFX('sfx:item-delete')
const segmentId = node.roofSegmentId
// Unlist from host segment's children before deleting the node.
if (segmentId) {
const state = useScene.getState()
const segment = state.nodes[segmentId as AnyNodeId] as RoofSegmentNode | undefined
if (segment) {
state.updateNode(segmentId as AnyNode['id'], {
children: (segment.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 === 'chimney' && selectedId)) return null
const scenestate = useScene.getState()
const segment = node.roofSegmentId
? (scenestate.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined
const roof = segment?.parentId
? (scenestate.nodes[segment.parentId as AnyNodeId] as RoofNode | undefined)
: undefined
// ---- True world-space helpers -------------------------------------------
// We use the registered THREE.js Object3D matrices so the conversion picks
// up EVERY ancestor transform (building rotation, level, etc.) and not
// just roof + segment. Falls back to identity if a matrix isn't available
// yet (rare timing edge case).
const chimneyObj = sceneRegistry.nodes.get(selectedId)
if (chimneyObj) chimneyObj.updateWorldMatrix(true, false)
// World pose of the chimney's group origin (after segment.position +
// segment.rotation + roof + building, etc.). Used as the basis for the
// chimney's actual world position (which is groupOrigin + chimney.position
// rotated through the chain).
const computeChimneyWorldPos = () => {
if (!chimneyObj) return { x: 0, z: 0 }
// The chimney's outer group is at segment.position + segment.rotation
// already; chimney.position is applied inside the geometry. To get the
// world position of the chimney's center we transform its local center
// (chimney.position[0], 0, chimney.position[2]) through the outer group.
const localPt = new Vector3(node.position[0] ?? 0, 0, node.position[2] ?? 0)
const worldPt = localPt.applyMatrix4(chimneyObj.matrixWorld)
return { x: worldPt.x, z: worldPt.z }
}
const computeChimneyWorldRotation = () => {
if (!chimneyObj) return node.rotation ?? 0
// Extract Y rotation from the outer group's world matrix. Assumes only
// Y-axis ancestor rotations (true for our scene — buildings can rotate
// around Y; levels/roofs/segments all rotate around Y).
const m = chimneyObj.matrixWorld.elements
// 3x3 rotation portion (column-major): m[0]=cos, m[2]=-sin for pure Y rot.
const ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
return ancestorWorldY + (node.rotation ?? 0)
}
const { x: worldX_now, z: worldZ_now } = computeChimneyWorldPos()
const worldRotation_now = computeChimneyWorldRotation()
// Find any roof-segment whose footprint contains a given world (x, z).
const findSegmentForWorldPoint = (
wx: number,
wz: number,
): { segment: RoofSegmentNode; localX: number; localZ: number } | null => {
const state = useScene.getState()
const worldPt = new Vector3(wx, 0, wz)
for (const candidate of Object.values(state.nodes)) {
if (!candidate || candidate.type !== 'roof-segment') continue
const seg = candidate as RoofSegmentNode
const segObj = sceneRegistry.nodes.get(seg.id)
if (!segObj) continue
segObj.updateWorldMatrix(true, false)
const local = segObj.worldToLocal(worldPt.clone())
if (Math.abs(local.x) <= seg.width / 2 && Math.abs(local.z) <= seg.depth / 2) {
return { segment: seg, localX: local.x, localZ: local.z }
}
}
return null
}
// World→segment-local for a given segment. Uses the segment's registered
// mesh (whose world matrix already walks every ancestor transform).
const worldToSegLocal = (
wx: number,
wz: number,
seg: RoofSegmentNode,
): { localX: number; localZ: number } => {
const segObj = sceneRegistry.nodes.get(seg.id)
if (!segObj) return { localX: wx, localZ: wz }
segObj.updateWorldMatrix(true, false)
const local = segObj.worldToLocal(new Vector3(wx, 0, wz))
return { localX: local.x, localZ: local.z }
}
// World-space slider range = bounding box of the chimney's parent roof
// segments. Computed from each segment mesh's world matrix so the range
// is in TRUE world coords too.
let worldMinX = worldX_now - 20
let worldMaxX = worldX_now + 20
let worldMinZ = worldZ_now - 20
let worldMaxZ = worldZ_now + 20
if (roof) {
let lo_x = Number.POSITIVE_INFINITY
let hi_x = Number.NEGATIVE_INFINITY
let lo_z = Number.POSITIVE_INFINITY
let hi_z = Number.NEGATIVE_INFINITY
for (const childId of roof.children ?? []) {
const seg = scenestate.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
if (!seg) continue
const segObj = sceneRegistry.nodes.get(seg.id)
if (!segObj) continue
segObj.updateWorldMatrix(true, false)
const segWorldCenter = new Vector3().applyMatrix4(segObj.matrixWorld)
// Use a circular bound (sqrt(w^2 + d^2)/2) — rotation-agnostic.
const r = Math.hypot(seg.width, seg.depth) / 2
lo_x = Math.min(lo_x, segWorldCenter.x - r)
hi_x = Math.max(hi_x, segWorldCenter.x + r)
lo_z = Math.min(lo_z, segWorldCenter.z - r)
hi_z = Math.max(hi_z, segWorldCenter.z + r)
}
if (Number.isFinite(lo_x)) {
worldMinX = lo_x
worldMaxX = hi_x
worldMinZ = lo_z
worldMaxZ = hi_z
}
}
// Commit a new world (x, z). Finds whichever segment contains the point;
// if it differs from the current segment, reparents while preserving the
// chimney's world rotation and the world Y of the chimney top (via
// heightAboveRidge).
const commitWorldPosition = (newWorldX: number, newWorldZ: number) => {
if (!segment) return
const oldWorldRotation = worldRotation_now
const oldHeightAboveRidge = node.heightAboveRidge ?? 1
const oldPeakY = segment.wallHeight + getActiveRoofHeight(segment)
// Compute the chimney's current world top Y by transforming the segment-
// local top point through the chimney's matrix.
let oldWorldTopY = 0
if (chimneyObj) {
const localTop = new Vector3(
node.position[0] ?? 0,
oldPeakY + oldHeightAboveRidge,
node.position[2] ?? 0,
)
oldWorldTopY = localTop.applyMatrix4(chimneyObj.matrixWorld).y
}
const target = findSegmentForWorldPoint(newWorldX, newWorldZ)
if (target && target.segment.id !== segment.id) {
const newSegObj = sceneRegistry.nodes.get(target.segment.id)
const newPeakY = target.segment.wallHeight + getActiveRoofHeight(target.segment)
// World Y of the new chimney's group origin (at target localX,Z, y=0).
let newOriginWorldY = 0
if (newSegObj) {
newSegObj.updateWorldMatrix(true, false)
newOriginWorldY = new Vector3(target.localX, 0, target.localZ)
.applyMatrix4(newSegObj.matrixWorld).y
}
const newHeightAboveRidge = Math.max(0.1, oldWorldTopY - newOriginWorldY - newPeakY)
// Preserve world rotation: extract the new segment's ancestor world
// Y-rotation from its matrix, then compute the chimney-local rotation
// that yields the same world rotation.
let newAncestorWorldY = 0
if (newSegObj) {
const m = newSegObj.matrixWorld.elements
newAncestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
}
const newSegLocalRot = oldWorldRotation - newAncestorWorldY
commitProp({
roofSegmentId: target.segment.id,
parentId: target.segment.id,
position: [target.localX, 0, target.localZ],
rotation: newSegLocalRot,
heightAboveRidge: newHeightAboveRidge,
} as Partial<ChimneyNode>)
} else {
// Same segment, just convert world → segment-local.
const local = worldToSegLocal(newWorldX, newWorldZ, segment)
commitProp({ position: [local.localX, 0, local.localZ] })
}
}
// Commit a new world rotation. Stays parented to the current segment.
const commitWorldRotation = (newWorldRot: number) => {
if (!segment) return
let ancestorWorldY = 0
const segObj = sceneRegistry.nodes.get(segment.id)
if (segObj) {
segObj.updateWorldMatrix(true, false)
const m = segObj.matrixWorld.elements
ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
}
commitProp({ rotation: newWorldRot - ancestorWorldY })
}
// Match the current store node against the preset table so the
// segmented control highlights "the preset you'd land on if you
// applied X again". Compare against the store node, not the live-
// override-merged `node`, so the highlight is stable across slider
// drags. Null means the user has tweaked away from any preset; the
// segmented control will then render with no segment selected.
const activePreset = useMemo(() => detectActiveChimneyPreset(storeNode), [storeNode])
const applyPreset = useCallback(
(key: ChimneyPresetKey) => {
commitProp(chimneyPresets[key] as Partial<ChimneyNode>)
triggerSFX('sfx:item-pick')
},
[commitProp],
)
return (
<PanelWrapper
icon="/icons/roof.png"
onBack={node.roofSegmentId ? handleBack : undefined}
onClose={handleClose}
title={node.name || 'Chimney'}
width={300}
>
<PanelSection title="Style">
<SegmentedControl
onChange={(v) => applyPreset(v as ChimneyPresetKey)}
options={CHIMNEY_PRESET_KEYS.map((k) => ({
label: CHIMNEY_PRESET_LABELS[k],
value: k,
}))}
// Empty string when no preset matches — nothing highlighted,
// which reads correctly as "custom".
value={activePreset ?? ''}
/>
</PanelSection>
<PanelSection title="Footprint">
<SegmentedControl
onChange={(v) => handleUpdate({ bodyShape: v })}
options={[
{ label: 'Square', value: 'square' },
{ label: 'Round', value: 'round' },
]}
value={node.bodyShape ?? 'square'}
/>
<SliderControl
label={(node.bodyShape ?? 'square') === 'round' ? 'Diameter' : 'Width'}
max={3}
min={0.2}
onChange={(v) => previewProp({ width: v })}
onCommit={(v) => commitProp({ width: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(node.width * 100) / 100}
/>
{(node.bodyShape ?? 'square') !== 'round' && (
<SliderControl
label="Depth"
max={3}
min={0.2}
onChange={(v) => previewProp({ depth: v })}
onCommit={(v) => commitProp({ depth: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(node.depth * 100) / 100}
/>
)}
<SliderControl
label="Hollow Depth"
max={3}
min={0}
onChange={(v) => previewProp({ bodyHollowDepth: v })}
onCommit={(v) => commitProp({ bodyHollowDepth: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round((node.bodyHollowDepth ?? 0.6) * 100) / 100}
/>
<SliderControl
label="Wall Thickness"
max={0.3}
min={0}
onChange={(v) => previewProp({ bodyHollowMargin: v })}
onCommit={(v) => commitProp({ bodyHollowMargin: v })}
precision={3}
restoreOnCommit={false}
step={0.005}
unit="m"
value={Math.round((node.bodyHollowMargin ?? 0.08) * 1000) / 1000}
/>
{(node.bodyShape ?? 'square') !== 'round' && (
<SliderControl
label="Corner Bevel"
max={Math.max(0, Math.min(node.width, node.depth) / 2 - 0.005)}
min={0}
onChange={(v) => previewProp({ cornerBevel: v })}
onCommit={(v) => commitProp({ cornerBevel: v })}
precision={3}
restoreOnCommit={false}
step={0.005}
unit="m"
value={Math.round((node.cornerBevel ?? 0) * 1000) / 1000}
/>
)}
</PanelSection>
<PanelSection title="Height">
<SliderControl
label="Above Ridge"
max={5}
min={0.1}
onChange={(v) => previewProp({ heightAboveRidge: v })}
onCommit={(v) => commitProp({ heightAboveRidge: v })}
precision={2}
restoreOnCommit={false}
step={0.1}
unit="m"
value={Math.round(node.heightAboveRidge * 100) / 100}
/>
<SliderControl
label="Cutout Offset"
max={0.5}
min={0}
onChange={(v) => previewProp({ cutoutOffset: v })}
onCommit={(v) => commitProp({ cutoutOffset: v })}
precision={3}
restoreOnCommit={false}
step={0.005}
unit="m"
value={Math.round((node.cutoutOffset ?? 0) * 1000) / 1000}
/>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label="X"
max={Math.round(worldMaxX * 10) / 10}
min={Math.round(worldMinX * 10) / 10}
onChange={(newWorldX) => {
// Live preview: keep the chimney parented to its current segment
// and update its segment-local position so the visual matches the
// dragged world X. Reparenting (if any) happens on commit.
if (!segment) return
const local = worldToSegLocal(newWorldX, worldZ_now, segment)
previewProp({ position: [local.localX, 0, local.localZ] })
}}
onCommit={(newWorldX) => commitWorldPosition(newWorldX, worldZ_now)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(worldX_now * 100) / 100}
/>
<SliderControl
label="Z"
max={Math.round(worldMaxZ * 10) / 10}
min={Math.round(worldMinZ * 10) / 10}
onChange={(newWorldZ) => {
if (!segment) return
const local = worldToSegLocal(worldX_now, newWorldZ, segment)
previewProp({ position: [local.localX, 0, local.localZ] })
}}
onCommit={(newWorldZ) => commitWorldPosition(worldX_now, newWorldZ)}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(worldZ_now * 100) / 100}
/>
<SliderControl
label="Rotation"
max={180}
min={-180}
onChange={(degrees) => {
// World rotation → segment-local rotation for the current segment.
const newWorldRot = (degrees * Math.PI) / 180
let ancestorWorldY = 0
if (segment) {
const segObj = sceneRegistry.nodes.get(segment.id)
if (segObj) {
segObj.updateWorldMatrix(true, false)
const m = segObj.matrixWorld.elements
ancestorWorldY = Math.atan2(-(m[2] ?? 0), m[0] ?? 1)
}
}
previewProp({ rotation: newWorldRot - ancestorWorldY })
}}
onCommit={(degrees) => commitWorldRotation((degrees * Math.PI) / 180)}
precision={0}
restoreOnCommit={false}
step={1}
unit="°"
value={Math.round((worldRotation_now * 180) / Math.PI)}
/>
</PanelSection>
<PanelSection title="Chimney Type">
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
{CHIMNEY_TYPE_OPTIONS.filter((option) => {
// Cricket and Panels both rely on a flat face — hide them for
// round bodies.
if ((node.bodyShape ?? 'square') === 'round') {
return option.value !== 'cricket' && option.value !== 'panels'
}
return true
}).map((option) => {
const isSelected = chimneyType === option.value
return (
<button
className={cn(
'flex min-h-12 items-center rounded-lg border px-3 py-2.5 text-left text-xs transition-colors',
isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
)}
key={option.value}
onClick={() => setChimneyType(option.value)}
type="button"
>
<span className="truncate font-medium">{option.label}</span>
</button>
)
})}
</div>
{chimneyType === 'cap' && (
<>
<SegmentedControl
className="mt-2"
onChange={(v) => handleUpdate({ cap: v !== 'none', capShape: v })}
options={[
{ label: 'None', value: 'none' },
{ label: 'Sloped', value: 'sloped' },
{ label: 'Flat', value: 'flat' },
{ label: 'Stepped', value: 'stepped' },
]}
value={node.capShape ?? 'sloped'}
/>
{(node.capShape ?? 'sloped') !== 'none' && (
<>
<SliderControl
label="Overhang"
max={0.2}
min={0}
onChange={(v) => previewProp({ capOverhang: v })}
onCommit={(v) => commitProp({ capOverhang: v })}
precision={3}
restoreOnCommit={false}
step={0.005}
unit="m"
value={Math.round((node.capOverhang ?? 0.04) * 1000) / 1000}
/>
<SliderControl
label="Thickness"
max={0.3}
min={0.02}
onChange={(v) => previewProp({ capThickness: v })}
onCommit={(v) => commitProp({ capThickness: v })}
precision={3}
restoreOnCommit={false}
step={0.005}
unit="m"
value={Math.round((node.capThickness ?? 0.08) * 1000) / 1000}
/>
</>
)}
</>
)}
{chimneyType === 'shoulder' && (
<>
<SegmentedControl
className="mt-2"
onChange={(v) => handleUpdate({ shoulderStyle: v })}
options={[
{ label: 'None', value: 'none' },
{ label: 'Tapered', value: 'tapered' },
{ label: 'Corbeled', value: 'corbeled' },
]}
value={node.shoulderStyle ?? 'none'}
/>
{(node.shoulderStyle ?? 'none') !== 'none' && (
<>
<SliderControl
label="Height"
max={3}
min={0.1}
onChange={(v) => previewProp({ shoulderHeight: v })}
onCommit={(v) => commitProp({ shoulderHeight: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round((node.shoulderHeight ?? 0.5) * 100) / 100}
/>
<SliderControl
label="Extent"
max={0.5}
min={0}
onChange={(v) => previewProp({ shoulderExtent: v })}
onCommit={(v) => commitProp({ shoulderExtent: v })}
precision={2}
restoreOnCommit={false}
step={0.01}
unit="m"
value={Math.round((node.shoulderExtent ?? 0.1) * 100) / 100}
/>
</>
)}
</>
)}
{chimneyType === 'flues' && (
<>
<SliderControl
label="Count"
max={4}
min={0}
onChange={(v) => previewProp({ flueCount: Math.round(v) })}
onCommit={(v) => commitProp({ flueCount: Math.round(v) })}
precision={0}
restoreOnCommit={false}
step={1}
unit=""
value={node.flueCount ?? 1}
/>
{(node.flueCount ?? 1) > 0 && (
<>
<SegmentedControl
onChange={(v) => handleUpdate({ flueShape: v })}
options={[
{ label: 'Round', value: 'round' },
{ label: 'Square', value: 'square' },
]}
value={node.flueShape ?? 'round'}
/>
<SliderControl
label="Diameter"
max={Math.max(0.4, node.width)}
min={0.05}
onChange={(v) => previewProp({ flueDiameter: v })}
onCommit={(v) => commitProp({ flueDiameter: v })}
precision={2}
restoreOnCommit={false}
step={0.01}
unit="m"
value={Math.round((node.flueDiameter ?? 0.22) * 100) / 100}
/>
<SliderControl
label="Height"
max={1.5}
min={0.05}
onChange={(v) => previewProp({ flueHeight: v })}
onCommit={(v) => commitProp({ flueHeight: v })}
precision={2}
restoreOnCommit={false}
step={0.01}
unit="m"
value={Math.round((node.flueHeight ?? 0.3) * 100) / 100}
/>
{(node.flueCount ?? 1) > 1 && (
<SliderControl
label="Spacing"
max={1}
min={0}
onChange={(v) => previewProp({ flueSpacing: v })}
onCommit={(v) => commitProp({ flueSpacing: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
value={Math.round((node.flueSpacing ?? 1) * 100) / 100}
/>
)}
<SliderControl
label="Wall Thickness"
max={Math.max(0.1, (node.flueDiameter ?? 0.22) / 2 - 0.01)}
min={0}
onChange={(v) => previewProp({ flueWallThickness: v })}
onCommit={(v) => commitProp({ flueWallThickness: v })}
precision={3}
restoreOnCommit={false}
step={0.005}
unit="m"
value={Math.round((node.flueWallThickness ?? 0.02) * 1000) / 1000}
/>
</>
)}
</>
)}
{chimneyType === 'bands' && (
<>
<SegmentedControl
className="mt-2"
onChange={(v) => handleUpdate({ bandStyle: v })}
options={[
{ label: 'None', value: 'none' },
{ label: 'Single', value: 'single' },
{ label: 'Double', value: 'double' },
]}
value={node.bandStyle ?? 'none'}
/>
{(node.bandStyle ?? 'none') !== 'none' && (
<>
<SliderControl
label="Thickness"
max={0.4}
min={0.02}
onChange={(v) => previewProp({ bandHeight: v })}
onCommit={(v) => commitProp({ bandHeight: v })}
precision={2}
restoreOnCommit={false}
step={0.01}
unit="m"
value={Math.round((node.bandHeight ?? 0.1) * 100) / 100}
/>
<SliderControl
label="Extent"
max={0.2}
min={0}
onChange={(v) => previewProp({ bandExtent: v })}
onCommit={(v) => commitProp({ bandExtent: v })}
precision={3}
restoreOnCommit={false}
step={0.005}
unit="m"
value={Math.round((node.bandExtent ?? 0.04) * 1000) / 1000}
/>
<SliderControl
label="Offset"
max={3}
min={0}
onChange={(v) => previewProp({ bandOffset: v })}
onCommit={(v) => commitProp({ bandOffset: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round((node.bandOffset ?? 0.4) * 100) / 100}
/>
</>
)}
</>
)}
{chimneyType === 'cricket' && (
<>
<SegmentedControl
className="mt-2"
onChange={(v) => handleUpdate({ cricketStyle: v })}
options={[
{ label: 'None', value: 'none' },
{ label: 'Simple', value: 'simple' },
]}
value={node.cricketStyle ?? 'none'}
/>
{(node.cricketStyle ?? 'none') !== 'none' && (
<>
<SegmentedControl
className="mt-2"
onChange={(v) => handleUpdate({ cricketSide: v })}
options={[
{ label: 'Front', value: 'front' },
{ label: 'Back', value: 'back' },
]}
value={node.cricketSide ?? 'front'}
/>
<SliderControl
label="Length"
max={2}
min={0.1}
onChange={(v) => previewProp({ cricketLength: v })}
onCommit={(v) => commitProp({ cricketLength: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round((node.cricketLength ?? 0.6) * 100) / 100}
/>
<SliderControl
label="Height"
max={1.5}
min={0.05}
onChange={(v) => previewProp({ cricketHeight: v })}
onCommit={(v) => commitProp({ cricketHeight: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round((node.cricketHeight ?? 0.4) * 100) / 100}
/>
</>
)}
</>
)}
{chimneyType === 'panels' && (
<>
<SegmentedControl
className="mt-2"
onChange={(v) => handleUpdate({ panelStyle: v })}
options={[
{ label: 'None', value: 'none' },
{ label: 'Rectangular', value: 'rectangular' },
]}
value={node.panelStyle ?? 'none'}
/>
{(node.panelStyle ?? 'none') !== 'none' && (
<>
<SliderControl
label="Depth"
max={0.15}
min={0.005}
onChange={(v) => previewProp({ panelDepth: v })}
onCommit={(v) => commitProp({ panelDepth: v })}
precision={3}
restoreOnCommit={false}
step={0.005}
unit="m"
value={Math.round((node.panelDepth ?? 0.03) * 1000) / 1000}
/>
<SliderControl
label="Height"
max={3}
min={0.1}
onChange={(v) => previewProp({ panelHeight: v })}
onCommit={(v) => commitProp({ panelHeight: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round((node.panelHeight ?? 0.8) * 100) / 100}
/>
<SliderControl
label="Top Offset"
max={2}
min={0}
onChange={(v) => previewProp({ panelOffsetTop: v })}
onCommit={(v) => commitProp({ panelOffsetTop: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round((node.panelOffsetTop ?? 0.15) * 100) / 100}
/>
<SliderControl
label="Side Margin"
max={Math.max(0.5, node.width / 2 - 0.05)}
min={0.02}
onChange={(v) => previewProp({ panelMargin: v })}
onCommit={(v) => commitProp({ panelMargin: v })}
precision={2}
restoreOnCommit={false}
step={0.01}
unit="m"
value={Math.round((node.panelMargin ?? 0.1) * 100) / 100}
/>
</>
)}
</>
)}
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<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>
)
}
+180
View File
@@ -0,0 +1,180 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { ChimneyNode } from './schema'
export const chimneyParametrics: ParametricDescriptor<ChimneyNode> = {
// The chimney panel is a bespoke tabbed UI (Cap / Flues / Shoulder /
// Bands / Cricket / Panels) ported from the archive — auto-derived
// groups can't reproduce its layout. `groups` stays declared for the
// MCP path and for any future fallback consumer, but the inspector
// mounts the custom panel.
customPanel: () => import('./panel'),
groups: [
{
label: 'Body',
fields: [
{
key: 'bodyShape',
kind: 'enum',
options: ['square', 'round'],
display: 'segmented',
},
{ key: 'width', kind: 'number', unit: 'm', min: 0.2, max: 2, step: 0.05 },
{
key: 'depth',
kind: 'number',
unit: 'm',
min: 0.2,
max: 2,
step: 0.05,
visibleIf: (n) => n.bodyShape === 'square',
},
{ key: 'heightAboveRidge', kind: 'number', unit: 'm', min: 0.2, max: 3, step: 0.05 },
{
key: 'cornerBevel',
kind: 'number',
unit: 'm',
min: 0,
max: 0.1,
step: 0.005,
visibleIf: (n) => n.bodyShape === 'square',
},
],
},
{
label: 'Shoulder',
fields: [
{
key: 'shoulderStyle',
kind: 'enum',
options: ['none', 'tapered', 'corbeled'],
display: 'segmented',
},
{
key: 'shoulderHeight',
kind: 'number',
unit: 'm',
min: 0.1,
max: 1.5,
step: 0.05,
visibleIf: (n) => n.shoulderStyle !== 'none',
},
{
key: 'shoulderExtent',
kind: 'number',
unit: 'm',
min: 0,
max: 0.5,
step: 0.01,
visibleIf: (n) => n.shoulderStyle !== 'none',
},
],
},
{
label: 'Cap',
fields: [
{ key: 'cap', kind: 'boolean' },
{
key: 'capShape',
kind: 'enum',
options: ['none', 'sloped', 'flat', 'stepped'],
display: 'segmented',
visibleIf: (n) => n.cap === true,
},
{
key: 'capOverhang',
kind: 'number',
unit: 'm',
min: 0,
max: 0.2,
step: 0.01,
visibleIf: (n) => n.cap === true && n.capShape !== 'none',
},
{
key: 'capThickness',
kind: 'number',
unit: 'm',
min: 0.02,
max: 0.2,
step: 0.005,
visibleIf: (n) => n.cap === true && n.capShape !== 'none',
},
],
},
{
label: 'Flues',
fields: [
{ key: 'flueCount', kind: 'number', min: 0, max: 4, step: 1 },
{
key: 'flueShape',
kind: 'enum',
options: ['round', 'square'],
display: 'segmented',
visibleIf: (n) => n.flueCount > 0,
},
{
key: 'flueHeight',
kind: 'number',
unit: 'm',
min: 0.05,
max: 0.8,
step: 0.01,
visibleIf: (n) => n.flueCount > 0,
},
{
key: 'flueDiameter',
kind: 'number',
unit: 'm',
min: 0.05,
max: 0.4,
step: 0.01,
visibleIf: (n) => n.flueCount > 0,
},
{
key: 'flueSpacing',
kind: 'number',
min: 0,
max: 1,
step: 0.05,
visibleIf: (n) => n.flueCount > 1,
},
],
},
{
label: 'Cricket',
fields: [
{
key: 'cricketStyle',
kind: 'enum',
options: ['none', 'simple'],
display: 'segmented',
visibleIf: (n) => n.bodyShape === 'square',
},
{
key: 'cricketSide',
kind: 'enum',
options: ['front', 'back'],
display: 'segmented',
visibleIf: (n) => n.bodyShape === 'square' && n.cricketStyle !== 'none',
},
{
key: 'cricketLength',
kind: 'number',
unit: 'm',
min: 0.2,
max: 2,
step: 0.05,
visibleIf: (n) => n.bodyShape === 'square' && n.cricketStyle !== 'none',
},
{
key: 'cricketHeight',
kind: 'number',
unit: 'm',
min: 0.1,
max: 1,
step: 0.05,
visibleIf: (n) => n.bodyShape === 'square' && n.cricketStyle !== 'none',
},
],
},
],
}
+114
View File
@@ -0,0 +1,114 @@
import type { ChimneyNode } from './schema'
/**
* Style presets — opinionated starting points the user can pick from
* the panel header. Each preset sets shape / silhouette / accessory
* fields only; dimensions (`width` / `depth` / `heightAboveRidge`),
* placement (`position` / `rotation` / `roofSegmentId`), and paint
* (`material*` / `topMaterial*`) are left untouched so applying a
* preset to an already-sized chimney resizes nothing and doesn't
* overwrite the user's paint choices.
*
* Presets are intentionally distinct silhouettes:
* - `brick` — straight body, double band, flat overhanging cap
* - `modern` — minimal flat cap, recessed decorative panels
* - `round` — cylindrical body, single band, industrial look
*/
export type ChimneyPresetKey = 'brick' | 'modern' | 'round'
export const CHIMNEY_PRESET_KEYS: ChimneyPresetKey[] = ['brick', 'modern', 'round']
export const CHIMNEY_PRESET_LABELS: Record<ChimneyPresetKey, string> = {
brick: 'Brick',
modern: 'Modern',
round: 'Round',
}
export const chimneyPresets: Record<ChimneyPresetKey, Partial<ChimneyNode>> = {
brick: {
bodyShape: 'square',
shoulderStyle: 'none',
cap: true,
capShape: 'flat',
capOverhang: 0.04,
capThickness: 0.06,
bandStyle: 'double',
bandHeight: 0.05,
bandExtent: 0.025,
bandOffset: 0.4,
cricketStyle: 'none',
cornerBevel: 0,
panelStyle: 'none',
flueCount: 1,
flueShape: 'round',
flueDiameter: 0.2,
flueHeight: 0.25,
flueSpacing: 1,
},
modern: {
bodyShape: 'square',
shoulderStyle: 'none',
cap: true,
capShape: 'flat',
capOverhang: 0.02,
capThickness: 0.04,
bandStyle: 'none',
cricketStyle: 'none',
cornerBevel: 0,
panelStyle: 'rectangular',
panelDepth: 0.015,
panelHeight: 1.0,
panelOffsetTop: 0.2,
panelMargin: 0.12,
flueCount: 1,
flueShape: 'round',
flueDiameter: 0.16,
flueHeight: 0.18,
flueSpacing: 1,
},
round: {
bodyShape: 'round',
shoulderStyle: 'none',
cap: true,
capShape: 'flat',
capOverhang: 0.05,
capThickness: 0.05,
bandStyle: 'single',
bandHeight: 0.04,
bandExtent: 0.02,
bandOffset: 0.4,
cricketStyle: 'none',
cornerBevel: 0,
panelStyle: 'none',
flueCount: 1,
flueShape: 'round',
flueDiameter: 0.16,
flueHeight: 0.2,
flueSpacing: 1,
},
}
/**
* Returns the preset key whose every field matches the supplied node,
* or `null` if no preset is an exact match (i.e. the user has tweaked
* fields after applying a preset). Used by the panel to highlight the
* current preset in the segmented control.
*/
export function detectActiveChimneyPreset(
node: Partial<ChimneyNode> | undefined | null,
): ChimneyPresetKey | null {
if (!node) return null
for (const key of CHIMNEY_PRESET_KEYS) {
const preset = chimneyPresets[key] as Record<string, unknown>
const n = node as Record<string, unknown>
let matches = true
for (const k of Object.keys(preset)) {
if (n[k] !== preset[k]) {
matches = false
break
}
}
if (matches) return key
}
return null
}
+88
View File
@@ -0,0 +1,88 @@
'use client'
import type { ChimneyNode, RoofSegmentNode } from '@pascal-app/core'
import { useEffect, useMemo } from 'react'
import * as THREE from 'three'
import { buildChimneyGeometry } from './geometry'
const ghostMaterial = new THREE.MeshStandardMaterial({
color: 0xff_ff_ff,
emissive: 0xff_ff_ff,
emissiveIntensity: 0.12,
roughness: 0.85,
transparent: true,
opacity: 0.55,
depthWrite: false,
})
/**
* The preview needs a segment fixture to build the body height. The
* placement tool passes the segment under the cursor; before any
* segment is hit, the preview isn't shown at all (the tool guards on
* `previewPos`).
*/
const ChimneyPreview = ({
node,
segment,
}: {
node: ChimneyNode
segment: RoofSegmentNode
}) => {
const geo = useMemo(() => buildChimneyGeometry(node, segment), [
segment.wallHeight,
segment.pitch,
segment.roofType,
segment.width,
segment.depth,
node.width,
node.depth,
node.heightAboveRidge,
node.bodyShape,
node.shoulderStyle,
node.shoulderHeight,
node.shoulderExtent,
node.cap,
node.capShape,
node.capOverhang,
node.capThickness,
node.flueCount,
node.flueShape,
node.flueHeight,
node.flueDiameter,
node.flueSpacing,
node.cricketStyle,
node.cricketSide,
node.cricketLength,
node.cricketHeight,
node.position[0],
node.position[2],
node.rotation,
])
useEffect(
() => () => {
geo.body.dispose()
geo.cap?.dispose()
geo.flues?.dispose()
geo.cricket?.dispose()
},
[geo],
)
return (
<group>
<mesh
geometry={geo.body}
material={ghostMaterial}
raycast={() => {
/* preview should not intercept the cursor */
}}
/>
{geo.cap && <mesh geometry={geo.cap} material={ghostMaterial} raycast={() => {}} />}
{geo.flues && <mesh geometry={geo.flues} material={ghostMaterial} raycast={() => {}} />}
{geo.cricket && <mesh geometry={geo.cricket} material={ghostMaterial} raycast={() => {}} />}
</group>
)
}
export default ChimneyPreview
+259
View File
@@ -0,0 +1,259 @@
'use client'
import {
type AnyNodeId,
type ChimneyNode,
type RoofSegmentNode,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
import {
createMaterial,
createMaterialFromPresetRef,
getRoofSegmentBrushes,
useNodeEvents,
} from '@pascal-app/viewer'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { buildChimneyGeometry } from './geometry'
import { carveChimneyHoles } from './holes'
import { trimChimneyBodyAgainstRoof } from './roof-trim'
/**
* Chimney renderer. Reads the parent roof-segment so the body height
* is derived from `segment.wallHeight + roofHeight + node.heightAboveRidge`.
*
* **Option C scope**: chimney is rendered as solid geometry that
* intersects the roof at the deck line. The decorative CSG-driven
* features (cap flue holes, body cavity, panels, bands) are not
* rendered in this port — they remain as no-op fields in the schema
* until the roof-segment Stage B migration introduces a `roofCutout`
* capability the parent can read.
*/
const ChimneyRenderer = ({ node: storeNode }: { node: ChimneyNode }) => {
const ref = useRef<THREE.Group>(null!)
useRegistry(storeNode.id, 'chimney', ref)
const handlers = useNodeEvents(storeNode, 'chimney')
// Merge in-flight slider drags from `useLiveNodeOverrides` so the mesh
// updates while the user is still holding the slider. On release the
// panel commits to the store and clears the override.
const overrides = useLiveNodeOverrides((state) =>
state.get(storeNode.id as AnyNodeId) as Partial<ChimneyNode> | undefined,
)
const node = useMemo<ChimneyNode>(
() => (overrides ? { ...storeNode, ...overrides } : storeNode),
[storeNode, overrides],
)
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
// Geometry + carved CSG depend on the chimney's full schema and the
// host segment's shape. Both come in as memoised references — `node`
// only re-references when the store node or a live-override actually
// changes, `segment` only when the segment's own data changes — so a
// two-entry dep array is equivalent to enumerating every field, and
// adding a new schema field doesn't risk stale geometry from a
// forgotten dep.
const geo = useMemo(() => {
if (!segment) return null
const raw = buildChimneyGeometry(node, segment)
// Carve the smoke shaft (body cavity), cap holes, and hollow flue
// bores. Matches the v1 roof-system visual.
const carved = carveChimneyHoles(raw.body, raw.cap, raw.flues, node, segment)
return { ...raw, body: carved.body, cap: carved.cap, flues: carved.flues }
}, [node, segment])
// Segment brushes for the body trim. Building these is non-trivial
// (4 CSG-ready Brush instances per segment), so memoise by the shape
// fields that drive their geometry. A chimney slider drag changes
// `node.*` but not these, so the cached brushes survive the drag —
// previously each frame rebuilt all four.
const segmentBrushes = useMemo(
() => (segment ? getRoofSegmentBrushes(segment) : null),
[
segment?.roofType,
segment?.width,
segment?.depth,
segment?.wallHeight,
segment?.pitch,
segment?.wallThickness,
segment?.deckThickness,
segment?.overhang,
segment?.shingleThickness,
],
)
useEffect(
() => () => {
if (segmentBrushes) {
segmentBrushes.deckSlab.geometry.dispose()
segmentBrushes.shinSlab.geometry.dispose()
segmentBrushes.wallBrush.geometry.dispose()
segmentBrushes.innerBrush.geometry.dispose()
}
},
[segmentBrushes],
)
// CSG-trim the body against the parent roof segment so the portion
// passing through the wall and shingles is hidden. Returns the
// original body geometry on any CSG failure (logged via console.error).
const trimmedBody = useMemo(() => {
if (!geo || !segment || !segmentBrushes) return null
return trimChimneyBodyAgainstRoof(geo.body, segment, node, segmentBrushes)
}, [geo, segment, node, segmentBrushes])
useEffect(
() => () => {
if (geo) {
// The body may have been replaced by the trimmed version —
// `trimChimneyBodyAgainstRoof` disposes the original on
// success. Dispose `trimmedBody` if present, else the
// original body.
;(trimmedBody ?? geo.body).dispose()
geo.cap?.dispose()
geo.flues?.dispose()
geo.cricket?.dispose()
geo.bands?.dispose()
}
},
[geo, trimmedBody],
)
// Per-instance fallback materials. Were previously module-scoped
// singletons shared across every chimney — a paint-mode or debug
// system that mutates `surfaceMaterial` would have flipped the look
// of every unpainted chimney on the scene. Owning them here also
// lets us dispose them on unmount.
const fallbackBodyMaterial = useMemo(
() =>
new THREE.MeshStandardMaterial({
color: 0xb8_88_72,
roughness: 0.85,
metalness: 0,
}),
[],
)
const fallbackTopMaterial = useMemo(
() =>
new THREE.MeshStandardMaterial({
color: 0xa0_a0_a0,
roughness: 0.75,
metalness: 0,
}),
[],
)
useEffect(
() => () => {
fallbackBodyMaterial.dispose()
fallbackTopMaterial.dispose()
},
[fallbackBodyMaterial, fallbackTopMaterial],
)
const surfaceMaterial = useMemo(() => {
if (node.material) return createMaterial(node.material)
const preset = createMaterialFromPresetRef(node.materialPreset)
return preset ?? fallbackBodyMaterial
}, [node.material, node.materialPreset, fallbackBodyMaterial])
const capSurfaceMaterial = useMemo(() => {
if (node.topMaterial) return createMaterial(node.topMaterial)
const preset = createMaterialFromPresetRef(node.topMaterialPreset)
if (preset) return preset
if (node.material) return createMaterial(node.material)
const bodyPreset = createMaterialFromPresetRef(node.materialPreset)
return bodyPreset ?? fallbackTopMaterial
}, [
node.topMaterial,
node.topMaterialPreset,
node.material,
node.materialPreset,
fallbackTopMaterial,
])
// Two-material array: index 0 = body/surface, index 1 = top. The
// geometry buffers are partitioned in `holes.ts:partitionTopFaceGroups`
// so the very top face of body/cap/flues lands in group 1 and picks up
// the top material — matching the v1 roof-system visual.
// Must be declared above the early-return below: hooks can't be
// called conditionally without changing the hook-call order between
// renders.
const surfaceArray = useMemo(
() => [surfaceMaterial, capSurfaceMaterial],
[surfaceMaterial, capSurfaceMaterial],
)
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.
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 && (
<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>
)
}
export default ChimneyRenderer
+101
View File
@@ -0,0 +1,101 @@
import { type ChimneyNode, getActiveRoofHeight, type RoofSegmentNode } from '@pascal-app/core'
import {
Brush,
csgEvaluator,
csgGeometry,
type getRoofSegmentBrushes,
prepareBrushForCSG,
SUBTRACTION,
} from '@pascal-app/viewer'
import * as THREE from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { partitionTopFaceGroups } from './holes'
const visibleMat = new THREE.MeshBasicMaterial()
export type SegmentTrimBrushes = NonNullable<ReturnType<typeof getRoofSegmentBrushes>>
/**
* CSG-trim the chimney body against the parent roof segment so the
* portion of the chimney that passes through the wall and shingles
* is hidden — gives the clean "chimney emerges from the roof" look
* the archive shipped. Lives in the chimney folder (not the geometry
* builder) because three-bvh-csg + three-mesh-bvh are viewer-only
* deps; the renderer is the natural seam since it already imports
* from `@pascal-app/viewer`.
*
* Segment brushes are passed in (built once per segment shape in the
* renderer and reused across slider drags); the function does NOT
* dispose them. CSG `evaluate` returns a new brush, so the input
* brushes survive the call unmutated.
*
* Returns the input geometry untouched on any CSG failure so the
* chimney still renders (just not trimmed).
*/
export function trimChimneyBodyAgainstRoof(
body: THREE.BufferGeometry,
segment: RoofSegmentNode,
node: ChimneyNode,
segBrushes: SegmentTrimBrushes,
): 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`.
const indexed = mergeVertices(body, 1e-4)
if (!indexed.getAttribute('normal')) indexed.computeVertexNormals()
const indexCount = indexed.getIndex()?.count ?? 0
indexed.clearGroups()
if (indexCount > 0) indexed.addGroup(0, indexCount, 0)
;(indexed as unknown as { computeBoundsTree?: (opts: { maxLeafSize: number }) => void }).computeBoundsTree?.(
{ maxLeafSize: 10 },
)
const chimneyBrush = new Brush(indexed, visibleMat as unknown as THREE.MeshStandardMaterial)
chimneyBrush.updateMatrixWorld()
prepareBrushForCSG(chimneyBrush)
let result: THREE.BufferGeometry = body
try {
// Two-pass subtraction: trim the chimney shaft below the eave with
// `wallBrush`, then trim the section above the wall but below the
// shingles with `shinSlab`. Together these hide the chimney's body
// wherever it passes through the roof shell, leaving only the
// visible portion above the shingles.
const step1 = csgEvaluator.evaluate(chimneyBrush, wallBrush, SUBTRACTION) as Brush
prepareBrushForCSG(step1)
const step2 = csgEvaluator.evaluate(step1, shinSlab, SUBTRACTION) as Brush
const out = csgGeometry(step2).clone()
const ic = out.getIndex()?.count ?? 0
out.clearGroups()
if (ic > 0) out.addGroup(0, ic, 0)
out.computeVertexNormals()
// Re-partition the top rim face into group 1 so the body mesh's
// `[bodyMaterial, topMaterial]` array routes the rim to the top
// material — the CSG step above wiped the partition we set inside
// `holes.ts:carveChimneyHoles`. Same threshold as the carve step
// (top rim is at `topY`, just below it for safety).
const peakY = segment.wallHeight + getActiveRoofHeight(segment)
const topY = peakY + node.heightAboveRidge
partitionTopFaceGroups(out, topY - 0.05)
body.dispose()
step1.geometry.dispose()
step2.geometry.dispose()
indexed.dispose()
result = out
} catch (e) {
// eslint-disable-next-line no-console
console.error('[chimney] roof-trim CSG failed:', e)
indexed.dispose()
result = body
}
return result
}
+1
View File
@@ -0,0 +1 @@
export { ChimneyNode } from '@pascal-app/core'
+158
View File
@@ -0,0 +1,158 @@
'use client'
import {
type AnyNodeId,
ChimneyNode,
emitter,
type RoofEvent,
type RoofNode,
type RoofSegmentNode,
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 '../roof/segment-hit'
import { chimneyDefinition } from './definition'
import ChimneyPreview from './preview'
/**
* Chimney placement tool. Listens to `roof:*` events; the preview
* follows the cursor across the segment with the segment's yaw applied
* (chimney itself stays world-vertical, so no slope tilt wrap). Click
* creates a new ChimneyNode parented to that segment with
* segment-local position.
*/
const tmpMatrix = new THREE.Matrix4()
const tmpInv = new THREE.Matrix4()
const tmpPos = new THREE.Vector3()
const tmpQuat = new THREE.Quaternion()
const tmpScale = new THREE.Vector3()
type SegmentTransform = {
position: [number, number, number]
quaternion: [number, number, number, number]
}
const ChimneyTool = () => {
const activeBuildingId = useViewer((s) => s.selection.buildingId)
const setSelection = useViewer((s) => s.setSelection)
// Building-local matrix of the host segment — drives the ghost's
// outer-group transform so the preview lands inside the actual
// segment's frame (matches the real renderer in `renderer.tsx`).
const [segmentXform, setSegmentXform] = useState<SegmentTransform | null>(null)
// Cursor position expressed in segment-local coords. Layered inside
// the segment frame so the ghost slides with the cursor across the
// segment's footprint.
const [hitLocal, setHitLocal] = useState<[number, number, number] | null>(null)
const [previewSegment, setPreviewSegment] = useState<RoofSegmentNode | null>(null)
const lastSnapRef = useRef<[number, number] | null>(null)
const previewNode = useMemo(
() =>
ChimneyNode.parse({
...chimneyDefinition.defaults(),
name: 'Chimney',
position: [0, 0, 0],
rotation: 0,
}),
[],
)
useEffect(() => {
if (!activeBuildingId) return
const computeSegmentXform = (segmentId: string): SegmentTransform | null => {
const buildingObj = sceneRegistry.nodes.get(activeBuildingId as AnyNodeId)
const segObj = sceneRegistry.nodes.get(segmentId as AnyNodeId)
if (!(buildingObj && segObj)) return null
buildingObj.updateWorldMatrix(true, false)
segObj.updateWorldMatrix(true, false)
tmpInv.copy(buildingObj.matrixWorld).invert()
tmpMatrix.multiplyMatrices(tmpInv, segObj.matrixWorld)
tmpMatrix.decompose(tmpPos, tmpQuat, tmpScale)
return {
position: [tmpPos.x, tmpPos.y, tmpPos.z],
quaternion: [tmpQuat.x, tmpQuat.y, tmpQuat.z, tmpQuat.w],
}
}
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 xform = computeSegmentXform(hit.segment.id)
if (!xform) return
setSegmentXform(xform)
setHitLocal([hit.localX, hit.localY, hit.localZ])
setPreviewSegment(hit.segment)
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 chimney = ChimneyNode.parse({
...chimneyDefinition.defaults(),
name: 'Chimney',
roofSegmentId: hit.segment.id,
position: [hit.localX, hit.localY, hit.localZ],
rotation: 0,
})
state.createNode(chimney, hit.segment.id as AnyNodeId)
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [chimney.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 || !segmentXform || !hitLocal || !previewSegment) return null
// Outer group mirrors the real renderer's `position={segment.position}
// rotation-y={segment.rotation}` chain by composing the segment's
// building-local matrix (which walks roof + level + segment). Inner
// group offsets by the cursor's segment-local x/z so the chimney
// geometry (built with `position[0,2] = 0`) lands under the cursor.
return (
<group position={segmentXform.position} quaternion={segmentXform.quaternion}>
<group position={[hitLocal[0], 0, hitLocal[2]]}>
<ChimneyPreview node={previewNode} segment={previewSegment} />
</group>
</group>
)
}
export default ChimneyTool