Skip to content

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

Merged
wass08 merged 39 commits into
pascalorg:mainfrom
sudhir9297:roof-system-v2
May 22, 2026
Merged

feat(roof-system): six roof-accessory kinds (chimney, dormer, skylight, solar-panel, ridge-vent, box-vent) on the registry model#330
wass08 merged 39 commits into
pascalorg:mainfrom
sudhir9297:roof-system-v2

Conversation

@sudhir9297
Copy link
Copy Markdown
Contributor

What does this PR do?

Ports the six roof-mounted accessories — chimney, dormer, skylight, solar-panel, ridge-vent, box-vent — to the registry-driven node model under packages/nodes/src/<kind>/, and extends the registry contract so the framework code never has to name them by kind.

Beyond the kind ports, this branch contributes:

  • Three new NodeDefinition slots that took the per-kind branches out of framework code:
    • capabilities.roofAccessory — hosts the dirty-cascade to the parent roof + an optional buildCut so dormer / skylight contribute their CSG-cut geometry through the registry instead of being named inside RoofSystem.
    • capabilities.paintresolveRole / buildPatch / applyPreview / getEffectiveMaterial. Chimney, dormer, and wall now route paint hover / click / preview / picker through this; per-kind arms deleted from selection-manager.tsx + material-paint.ts.
    • keyboardActionsR / T handlers contributed by the kind. Skylight's open/close moves from editor/src/lib/skylight-interaction.ts to packages/nodes/src/skylight/interaction.ts.
  • Roof-segment per-surface materials (top / edge / wall paint, with parent-roof fallback) and the registry-driven floor-plan + Add-Element flow in the roof inspector.
  • Schema migrations: roof-segment gains pitch + multi-slope shape ratios (and getActiveRoofHeight / getPitchFromActiveRoofHeight helpers in core); existing scenes are migrated in useScene's loader.
  • Viewer cleanup: the merged-roof CSG loop is now registry-driven (no more if (childElem.type === 'skylight' || ...)); the dormer-specific helpers (generateDormerGeometry, buildDormerCutBrush, etc.) moved out of packages/viewer/src/systems/roof/roof-system.tsx into the dormer kind, leaving only roof-segment-generic primitives (getRoofSegmentBrushes, getRoofOuterSurfaceFrameAtPoint, roofCsgDummyMats, mapRoofGroupMaterialIndex, remapRoofShellFaces) in viewer.

Follow-ups left intentionally

  • Roof / stair paint still use editor-side arms (segment→parent fan-out needs buildPatch to return { targetId, patch } — separate contract change).
  • Door / window interaction (R / T open/close) still lives in editor/src/lib/ because first-person-controls.tsx invokes the helpers with { persist: false }, which KeyboardAction.run(node) doesn't expose yet.

How to test

  1. Place each accessory. From the roof inspector's "Add Element" section, drop one chimney, one dormer, one skylight, one solar-panel, one ridge-vent, and one box-vent onto a selected roof. Move them in 3D and confirm the host roof re-CSGs cleanly (no stale cut after the drag commits).
  2. Roof-mounted CSG. Resize a dormer or skylight with the inspector sliders; confirm the merged roof shell rebuilds the cut on commit and the dormer body sits flush against the slope.
  3. Per-segment paint. Enter segment-edit mode on the roof, open the Material picker, and paint a single segment's top / edge / wall — only that segment changes. Exit segment-edit and paint the merged roof — the unpainted segments inherit, painted segments keep their override.
  4. Chimney / dormer paint. Click body vs. cap on a chimney with paint mode active; both write to the right field. Same for dormer wall / side / top.
  5. Wall paint via registry. Paint an interior wall surface, then an exterior one. Both should preview correctly and commit. (This change routes wall through the new capabilities.paint dispatch — regression-test against main's behaviour.)
  6. Skylight R / T. Select an opening / sliding skylight and press R (toggle) then T (close); animation runs through useInteractive.skylightAnimations.
  7. Existing-scene migration. Open a scene saved on main containing a legacy single-segment roof — the loader rewrites it into the segment-with-pitch shape, no visual regression.
bun typecheck             # all five packages clean
cd packages/nodes && bun test src/{box-vent,chimney,dormer,ridge-vent,skylight,solar-panel,wall}
# 84 pass / 0 fail

Screenshots / screen recording

N/A — to be added before merge. The visual surface is the six accessory kinds and the roof inspector's Add-Element section; behaviour should be a strict superset of main.

Checklist

  • I've tested this locally with bun dev
  • My code follows the existing code style (run bun check to verify)
  • I've updated relevant documentation (if applicable)
  • This PR targets the main branch

sudhir9297 and others added 30 commits May 19, 2026 02:59
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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 275af8f), 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>
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>
…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>
…derers

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>
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>
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>
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>
…t, 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 9fd42e3
and 65eec68.

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>
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>
…shes

- 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>
…ted 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>
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>
… 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>
sudhir9297 and others added 6 commits May 22, 2026 00:00
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>
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>
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>
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>
- 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>
…int, 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>
Copy link
Copy Markdown
Contributor

@Aymericr Aymericr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — six roof accessories on the registry model

CI: ✅ | Tests: 84 pass / 0 fail

What's solid

The architecture is on point. All six kinds land under packages/nodes/src/<kind>/ following the three-checkbox model, the registry wiring is correct (schema → bus → AnyNode union → builtinPlugin), and the viewer's merged-roof CSG loop is genuinely kind-agnostic now — no more if (childElem.type === 'skylight' || ...) checks. The three new NodeDefinition slots (capabilities.roofAccessory, capabilities.paint, keyboardActions) are clean abstractions that let future kinds contribute behaviour without touching framework code.

A few things to address before this is merge-ready:


🚫 Remove .claude/PORT-CHEATSHEET.md

.claude/PORT-CHEATSHEET.md — 263 lines

This is an AI-authoring reference for porting kinds — not useful to community contributors and doesn't belong in the public repo. Please drop it from the commit. If the canonical how-to is worth keeping, the right home is wiki/architecture/ or a page in the contributing docs.


📄 Document the three new NodeDefinition capabilities

registry/types.ts adds roofAccessory, paint, and keyboardActions to NodeDefinition. The PR description explains each well, but wiki/architecture/node-definitions.md (referenced by the cheatsheet as the authority on the three-checkbox model) should be updated to reflect them — otherwise the next contributor porting a kind won't find them.


🖼️ Screenshots before merge

The checklist has "N/A — to be added before merge." For a PR that lands six visual 3D components, even a short screen recording of place → move → rotate → paint on each kind would help reviewers validate behaviour without needing to run the branch locally.


Minor: double registry lookup in use-keyboard.ts

} else if (node && nodeRegistry.get(node.type)?.keyboardActions?.r?.appliesTo(node)) {
  e.preventDefault()
  nodeRegistry.get(node.type)!.keyboardActions!.r!.run(node)

The nodeRegistry.get() call is duplicated. Worth caching the ref:

const action = node ? nodeRegistry.get(node.type)?.keyboardActions?.r : undefined
if (action?.appliesTo(node!)) {
  e.preventDefault()
  action.run(node!)
  ...
}

Not a blocker, but the non-null assertions are noisy when the lookup can just be stored once.


FYI: dormer geometry is a stub

I noticed the dormer definition comment says "variant-specific dormer roof shapes, window opening + frame, sill, and the CSG trim where the dormer meets the host roof are deferred." That's fine to ship — just worth tracking as a follow-up issue so it doesn't get lost. Is there an existing issue for this?


Summary: The structural work here is good and the registry pattern is applied correctly. Main asks: drop the Claude cheatsheet file, add docs for the three new capabilities, and get screenshots in before merge.

open-pascal and others added 3 commits May 22, 2026 12:38
…Actions, 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
…ement 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>
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>
@wass08 wass08 merged commit 87384cf into pascalorg:main May 22, 2026
1 check passed
wass08 added a commit that referenced this pull request May 22, 2026
…ours

PR #330's new kinds (chimney, dormer, skylight, solar-panel, ridge-vent,
box-vent) use custom renderers, so the generic textures-off recolour path never
reached them — they fell back to hardcoded colours. Wire each renderer into the
render-modes system: read shading/textures/colorPreset/sceneTheme and resolve
untextured surfaces via createSurfaceRoleMaterial (and force the role colour when
textures are off), matching column/ceiling.

Roles: chimney body→wall / cap→roof; dormer wall→wall, roof→roof, glass→glazing,
frame→joinery; skylight glass→glazing / frame→joinery; ridge-vent + box-vent→roof;
solar-panel frame→roof (the dark product-specific cell face is left as-is). Each
definition also gets its dominant `surfaceRole` token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wass08 added a commit that referenced this pull request May 22, 2026
The render-modes/#330 merge left the legacy roof→roof-segment migration writing
`wallHeight: 0`. With #330's pitch model that builds a flat, zero-volume wall
CSG brush, which three-bvh-csg can't clip ("Coplanar clip not handled") and
yields NaN positions — so the migrated old roof never renders. Use the schema
default 0.5 (what new roofs use), giving a valid wall. The eave clamp + merged-
geometry NaN guard added earlier stay as defense-in-depth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wass08 added a commit that referenced this pull request May 22, 2026
…scene themes + edges (#332)

* viewer: add Phase 1 render-modes foundation (shading/textures/colorPreset state, defaultRender prop, SSGI gating)

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

* viewer: Phase 2 render-modes material-class switch (Lambert in solid, Standard in rendered)

Shading-aware material factories (cached per class), reactive selection in
renderers via the useViewer(shading) pattern, and dirty-rebuild on toggle for
geometry/door systems. Rendered mode output unchanged.

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

* viewer: Phase 3a render-modes surface roles + clay palette foundation

Adds surfaceRole token to core NodeDefinition, per-kind default roles,
ColorPreset palettes + resolveSurfaceColor/createSurfaceRoleMaterial (glazing
stays translucent), and the textures-off recolor path for def.geometry kinds
(slab/fence/shelf via GeometrySystem.applyDefaultSurfaceRole) + wall. Renderer-
based kinds (roof/window/stair/item/column/door/ceiling/elevator) wired in 3b.

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

* viewer: Phase 3b render-modes textures-off recoloring for renderer/system kinds

Wires clay role coloring (textures=off) for roof/roof-segment, window, stair/
stair-segment, door, item, column, ceiling, elevator via createSurfaceRoleMaterial,
reactive on textures/colorPreset. Per-surface roles: roof top+edge=roof /
underside=ceiling; window frame=joinery / glass=glazing; stair+door+elevator=
joinery; ceiling=ceiling; column=wall; item=furnishing. textures=on unchanged.

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

* editor: Phase 4a render-modes UI — Solid/Rendered toggle + per-context persistence

Per-context shading via renderContext discriminator + shadingByContext (persisted);
<Viewer> renderContext prop seeds per-context on mount. Solid/Rendered toggle in the
editor action bar + standalone toolbar + command palette. Editor mounts default to
renderContext=editor / shading=solid.

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

* viewer: fix window-system glassMaterial type to allow clay glazing reassignment

The let was inferred as MeshLambertNodeMaterial from the imported glass constant,
so reassigning createSurfaceRoleMaterial('glazing') (returns THREE.Material) failed
under tsc --build. Widen the annotation to THREE.Material. Surfaced by the build
(check-types had replayed a stale turbo cache).

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

* viewer: Phase 5 lighting — add theme-driven hemisphere light, trim fill directionals 3->2

Adds a sky/ground hemisphere fill (theme-lerped) and drops the second fill
directional; the hemisphere covers the shadow-side fill it provided, at one
fewer per-fragment directional term (shared by Solid + Rendered). Ambient lowered
since the hemisphere now carries soft fill. Intensities are a starting point —
tune visually on the gpu-perf overlay.

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

* viewer: tune Solid lighting for more form — stronger hemisphere sky/ground contrast, lower ambient

Darker hemisphere ground (#d8d6cf -> #aaa49a) + higher hemisphere intensity and
lower ambient so directional shading reads as form and undersides ground without
AO. Keeps Solid free of any post-processing pass.

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

* chore: biome format render-mode files (lefthook pre-commit)

Formatting-only — import wrapping, dep-array wrapping, single-line ternaries —
across Phase 2-4a files that weren't biome-clean. No logic changes.

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

* viewer: scene-theme system — named environment themes (studio/paper/sunset/night/...)

New SceneTheme registry (lib/scene-themes.ts) drives lights, background, and tone
mapping; lights.tsx refactored data-driven (N directionals + hemisphere + ambient).
sceneTheme state (persisted) + cycle-button picker in editor bar + standalone
toolbar, importing the registry from the viewer barrel (single source). Default
'studio' reproduces the prior look exactly; app light/dark 'theme' untouched.

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

* editor: Phase 7 — popover-dropdown pickers for render mode + scene theme (editor bar)

Replace the shading + scene-theme cycle buttons in viewer-overlay.tsx with
DropdownMenu pickers: render mode shows 2 rows (Solid/Rendered) with one-line
detail; scene theme lists all themes with a derived color-swatch strip + active
check. Imports the registry from the viewer barrel (single source).

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

* editor: Phase 7 — dropdown pickers in standalone toolbar + export DropdownMenu from barrel

Replicate the render-mode + scene-theme dropdown pickers (with swatch strip +
active check) to apps/editor's compact toolbar, matching viewer-overlay.tsx.
Export DropdownMenu* from the @pascal-app/editor barrel for the standalone app.

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

* viewer+editor: Phase 8 — crisp geometry edge overlay (off/soft/strong/sketchy)

EdgeOverlaySystem draws EdgesGeometry LineSegments over node-backed building
meshes (scoped via sceneRegistry, skips zone-layer/hitbox/overlay meshes),
rebuilt on geometry-uuid or mode change, line color follows scene-theme
background luminance; sketchy = static TSL vertex jitter. New 'edges' state
(persisted, default off) + Edges dropdown in editor bar + standalone toolbar.

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

* viewer+editor: edge overlay — thick lines via Line2, drop sketchy mode

Switch EdgeOverlaySystem from LineBasicNodeMaterial (1px hardware cap) to
LineSegments2 + Line2NodeMaterial so edges have real screen-space width
(soft 1.5px / strong 3px); resolution tracks viewport. EdgeMode is now
off/soft/strong (sketchy removed).

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

* viewer: fix edge overlay crash — webgpu Line2NodeMaterial has no settable resolution

material.resolution is undefined under WebGPU (the node material reads the
viewport internally); optional-chain the .set() call so it no-ops there instead
of throwing. Thickness still applies via linewidth.

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

* viewer: edge overlay — weld vertices to fix CSG spiderweb edges + lighter strong

Position-only mergeVertices before EdgesGeometry so coplanar triangles from
CSG-cut walls (doors/windows) share vertices and their interior edges are
suppressed — only opening outlines + silhouettes remain. Strong linewidth
3 -> 2px (was too heavy).

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

* viewer: edge overlay — crease-only extractor to fix CSG spiderweb

Replace EdgesGeometry (which always draws unpaired boundary/T-junction edges)
with buildCreaseEdges: weld positions, keep only edges shared by exactly two
faces whose dihedral exceeds the threshold, drop everything unpaired. CSG-cut
walls/slabs are watertight so real corners + opening outlines survive while the
interior triangulation fans (coplanar or T-junction) are removed. Open meshes
(bare ground plane, billboard leaves) shed their boundary clutter too.

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

* viewer: crease edges — coarser weld (0.1mm->1mm) to recover CSG/extrude seam edges

The cap<->side-wall top edge of ExtrudeGeometry walls drifts past 0.1mm after
CSG, so it stayed unpaired and was dropped. Weld at ~1mm to pair it into a real
crease while staying far below feature size (wall thickness, openings).

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

* viewer: replace geometry edge overlay with screen-space ink (SketchUp look)

Port the prototype's screen-space ink into the post-processing pipeline: depth +
normal Sobel reading the scene-pass MRT. Crease term (normalized normals,
center-vs-neighbour) + distance-independent depth-step term (raw Laplacian /
(1-d)² with a noise gate so flat ground stays clean). Topology-agnostic, so it
finally handles CSG-cut walls/openings without the spiderweb or missing-edge
problems of EdgesGeometry. Driven by the existing edges off/soft/strong mode;
MRT now builds when SSGI OR ink is on; ink colour tracks scene-theme luminance.

Removes EdgeOverlaySystem + crease-edges (geometry approach).

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

* viewer: soft/strong ink modes + keep editor overlays out of the ink

Two adjustments to the screen-space ink pass:

1. Soft vs strong now visibly differ. The edge masks saturate, so the old
   `intensity` gain did nothing once a line was detected. Replace it with a
   sample radius (line thickness) + opacity: soft = 1px / 50%, strong = 2px /
   100%. `inkedEdges` takes `radius` + `opacity` instead of `intensity`.

2. Editor overlays (gizmos, move handles, tool previews, grid) no longer get
   inked. The scene pass that feeds the depth/normal MRT now renders only
   SCENE_LAYER; overlays render in a dedicated pass on OVERLAY_LAYER and are
   composited on top after the ink + outlines, so they read as crisp UI and
   never get inked or AO'd.

   New OVERLAY_LAYER constant in viewer; editor's EDITOR_LAYER re-exports it so
   the two stay in lockstep. Also moves WallMoveSideHandles (the wall/fence move
   arrows) onto EDITOR_LAYER — it was the one overlay still on SCENE_LAYER.

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

* viewer: render the grid in the scene pass so geometry occludes it

The depth-gate fix couldn't help the grid: its material is depthWrite:false,
so it never wrote overlay-pass depth and the "didn't write depth -> keep on
top" term forced it on top — hence the floor grid bleeding through walls and
objects.

A full-floor plane can only be occluded correctly by living in the same depth
context as the scene, so move the grid onto its own GRID_LAYER which the scene
pass renders (alongside SCENE_LAYER). It's flat and depth-non-writing, so the
screen-space ink still ignores it; gizmos/handles stay on OVERLAY_LAYER. The
grid camera layer is enabled in custom-camera-controls and disabled on the
thumbnail camera so thumbnails stay grid-free, matching EDITOR_LAYER.

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

* viewer: strong ink — match soft's 1px thickness, differ by darkness only

Strong at radius 2 read too thick. Soft's 1px line is the nice one, so use it
for both modes and let strong distinguish itself purely by being fully solid
(opacity 1) vs soft's lighter 50%.

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

* viewer: shadow frustum follows the view + a shadows on/off setting

The directional light's ortho shadow camera only covers ±50 around the light
target, which was pinned at the origin — so zones far from origin received no
shadows no matter where the camera moved. Recentre each shadow-casting light
(position + target together, preserving direction) on the view focus every
frame: the orbit-controls target when available, else the camera's ground
projection. The shadow area now tracks wherever the user looks.

Also add a persisted `shadows` toggle (default on) to the viewer store and a
"Shadows" switch in the editor settings panel — the dedicated shadows control
the render-modes plan deferred. Lights gate castShadow on it.

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

* editor: shadows toggle in the standalone toolbar + a touch more shadow strength

The shadows switch I added only lived in the cloud settings panel's Visibility
section, which is hidden in the local/standalone editor (no projectId). Add a
ShadowsToggle button next to the grid toggle in the standalone toolbar so it's
reachable there, matching how Show Grid is exposed in both places.

Also push shadow strength partway toward the aesthetic prototype (which runs
near-black, no blur): bump the bright-key shadow-intensity cap 0.4 -> 0.55 and
tighten shadow-radius 2 -> 1.5. Still softer than aesthetic by design.

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

* nodes: site ground receives shadows (lit material instead of unlit Basic)

The site ground fill used MeshBasicMaterial — unlit, so it could never show
the directional shadow, and shadows visibly truncated at the slab edge. Swap it
for a lit MeshLambertNodeMaterial with receiveShadow on the mesh; the geometry
is the site polygon (slab footprints punched out), so shadows now extend across
the whole site and stop at its boundary, which is the desired bound.

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

* themes: per-theme clay palettes + 2x2 swatch in the theme pickers

Each scene theme now carries a clayTints map (wall/floor/ceiling/roof/glazing)
giving it a per-surface-role palette — e.g. Mediterranean's blue roof + warm
walls. The theme pickers (standalone toolbar + community overlay) now render the
aesthetic-style 2x2 swatch of those role tints over the theme background instead
of the old 3-colour strip.

Data + UI only; wiring the tints into the textures-off surface materials is a
separate change.

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

* editor: slimmer theme switcher + cloud-sun icon

Shrink the scene-theme toolbar button (w-[8.5rem] -> w-28) so it stops reserving
space for "Mediterranean"; the label truncates when it overflows. Swap the
palette icon for cloud-sun (atmosphere/lighting, distinct from the app light/dark
Sun-Moon toggle) in both the standalone toolbar and the community overlay.

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

* editor: theme switcher icon -> swatch-book

Swap the scene-theme icon from cloud-sun to swatch-book in both the standalone
toolbar and the community overlay.

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

* themes: colour untextured building surfaces by the active scene theme

Untextured walls/roof/slab/ceiling now take the active theme's per-role colour
(theme.clayTints[role], falling back to the colour preset) in BOTH textures
modes. The textures toggle only governs surfaces that actually have an explicit
material/preset — those still show their texture when textures are on. This is
what makes e.g. Mediterranean read as a blue roof + warm walls instead of the
old hardcoded white/grey defaults.

- materials.ts: resolveSurfaceColor / createSurfaceRoleMaterial take an optional
  sceneThemeId (theme tint ?? preset palette); theme folded into the cache key.
- wall-materials, roof-materials, slab/geometry, ceiling/renderer: the untextured
  fallback now resolves to the themed role colour instead of white/grey, in both
  modes; theme threaded into each builder + material cache key.
- wall-cutout: now reads textures/colorPreset/sceneTheme and re-applies wall
  materials when any change (previously it ignored textures/colorPreset entirely).
- geometry-system: threads sceneTheme into the generic surface-role path + rebuild
  effect. Renderers/preview call sites thread sceneTheme through.

Doors/windows/stairs/columns/items still use their existing defaults — a
follow-up pass.

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

* fix(slab): recessed (negative-elevation) slabs extrude downward again

The registry geometry builder created the slab mesh at Y=0 without applying the
negative-elevation offset, so recessed slabs rendered above the floor plane
(pool geometry is built locally with its floor cap at Y=0 and walls rising to
Y=|elevation|, so the mesh must be shifted down by `elevation` to recess). The
runtime slab-system already did this; the static builder path didn't. Mirror it:
shift mesh.position.y by elevation when negative. Positive elevation unchanged.

Unrelated to render modes — bundled into this branch's PR.

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

* fix(roof): legacy roofs render again — migration wrote invalid wallHeight 0

Legacy roof nodes (old format, no `children`) were migrated with a hardcoded
`wallHeight: 0`. With zero wall height the eave height (`wallHeight - autoDrop`)
went negative in getRoofSegmentBrushes, producing geometrically invalid brushes;
three-bvh-csg then spammed "TriangleClipper: Coplanar clip not handled" every
frame and emitted NaN positions, so the merged roof geometry failed
computeBoundingSphere and never rendered.

- core/use-scene migration: wallHeight 0 -> 0.5 (the RoofSegmentNode schema
  default), so migrated segments have a valid wall height.
- roof-system: clamp eave height to >= 0.01 so an intentional wallHeight 0 can
  never yield a negative eave, and guard updateMergedRoofGeometry so a CSG result
  with NaN positions is discarded (keep the last good mesh, warn once per roof)
  instead of poisoning the buffer + spamming the console.

Unrelated to render modes — bundled into this branch's PR.

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

* fix(column): columns cast and receive shadows

Column meshes (box, beam, cylinder, sphere, torus) rendered without castShadow/
receiveShadow, so columns neither dropped a shadow nor caught one — unlike walls,
slabs and roofs. Set both on all column shape meshes.

Unrelated to render modes — bundled into this branch's PR.

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

* viewer: default edges to soft

Editor defaults are now solid shading / studio theme / soft edges / shadows on.
Shading (solid, via EDITOR_DEFAULT_RENDER), theme (studio) and shadows (on) were
already the defaults; edges was 'off' — make 'soft' the default.

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

* themes: fold light/dark into the scene theme (remove the separate toggle)

The viewer had two overlapping appearance controls: a light/dark `theme` toggle
AND scene themes (which already drive the 3D background + lights). They
conflicted — e.g. Night/Twilight are dark themes, but the light/dark toggle was
an independent axis still tinting the 2D scene chrome.

Unify on the scene theme: add an explicit `appearance: 'light' | 'dark'` to each
SceneTheme (twilight/night = dark, the rest = light) and drive everything the
old toggle drove off it — canvas backdrop, grid line colours, measurement-label/
cursor/site-edge contrast, the site ground fill, the ground occluder, and the
mobile viewer bg. The editor UI chrome is unaffected (always dark via a fixed
body class).

Removes the `theme`/`setTheme` store state (+ persistence) and every light/dark
toggle UI: the standalone toolbar Sun/Moon button, the community overlay theme
switch, the command-palette command, and the ifc-converter preview toolbar
button.

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

* themes: per-theme ground colour + add the "Verdant" nature theme

- Add a `ground` colour to every SceneTheme and drive the site ground fill +
  the infinite ground-occluder off it (instead of the binary
  isDark ? #1f2433 : #fafafa). Dark themes now get a lit mid-tone ground
  (twilight #4a4566, night #2b3247) so the ground reads as ground rather than
  going near-black.
- Add a new green/nature scene theme "Verdant": soft green sky + lit, with a
  green roof clay tint and mossy ground.

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

* wiki: document the surface-colour / theme system

The colour-per-node/renderer/system model from the render-modes work was
undocumented. Add wiki/architecture/materials-and-themes.md covering surface
roles, colour presets, the textures axis, scene themes (appearance / ground /
clay tints), and the "untextured surfaces are theme-coloured in both modes"
invariant + where each kind wires it.

Also fix two pages that the same work made stale:
- node-definitions: geometry builders receive (shading, textures, colorPreset,
  sceneTheme); document the `surfaceRole` token + applyDefaultSurfaceRole.
- layers: OVERLAY_LAYER (1, viewer) with EDITOR_LAYER now its alias, the new
  GRID_LAYER (3, rendered in the scene pass for depth occlusion), and the
  overlay pass.

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

* nodes: wire #330 roof-accessory kinds into the surface-role/theme colours

PR #330's new kinds (chimney, dormer, skylight, solar-panel, ridge-vent,
box-vent) use custom renderers, so the generic textures-off recolour path never
reached them — they fell back to hardcoded colours. Wire each renderer into the
render-modes system: read shading/textures/colorPreset/sceneTheme and resolve
untextured surfaces via createSurfaceRoleMaterial (and force the role colour when
textures are off), matching column/ceiling.

Roles: chimney body→wall / cap→roof; dormer wall→wall, roof→roof, glass→glazing,
frame→joinery; skylight glass→glazing / frame→joinery; ridge-vent + box-vent→roof;
solar-panel frame→roof (the dark product-specific cell face is left as-is). Each
definition also gets its dominant `surfaceRole` token.

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

* fix(roof): legacy-roof migration must use a non-zero wallHeight

The render-modes/#330 merge left the legacy roof→roof-segment migration writing
`wallHeight: 0`. With #330's pitch model that builds a flat, zero-volume wall
CSG brush, which three-bvh-csg can't clip ("Coplanar clip not handled") and
yields NaN positions — so the migrated old roof never renders. Use the schema
default 0.5 (what new roofs use), giving a valid wall. The eave clamp + merged-
geometry NaN guard added earlier stay as defense-in-depth.

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

* fix(roof): guard slope frame against missing/NaN pitch (no more NaN geometry)

getSegmentSlopeFrame used `pitch <= 0` to detect flat/zero-pitch, but an
undefined or NaN pitch (a segment from an older migration that set `roofHeight`
instead of `pitch`, or stale persisted data) slips past that check and computes
Math.tan(NaN) → NaN tanTheta/activeRh → NaN segment geometry → the merged-roof
CSG spews "Coplanar clip not handled" and NaN positions, so the roof never
renders. Use `!(pitch > 0)` so any non-positive/non-finite pitch resolves to the
flat frame. Self-heals bad data regardless of how the segment was produced.

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

* fix(roof): migration guarantees a positive pitch for every roof-segment

Segments saved with neither a valid pitch nor a roofHeight (older/partial
saves, e.g. landing home-graph) fell through the legacy roofHeight->pitch
branch, leaving pitch undefined. The slope-frame guard then resolved them
to a flat frame, so the roof rendered as a slab instead of pitched. Branch
2b now normalises any segment lacking a valid pitch: derive from roofHeight
when present, else fall back to the schema default (40deg). The migration
result is cast (not zod-parsed), so this is the only place the default lands.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gsigrupa added a commit to gsigrupa/editor that referenced this pull request May 23, 2026
…g#330, pascalorg#332)

Sync z pascalorg/editor main po 2 commitach upstream:

- bb5ce68 Viewer render modes (Solid/Rendered + textures + surface-role
  clay + scene themes + edges) — PR pascalorg#332
- 87384cf feat(roof-system): 6 roof-accessory kinds (chimney, dormer,
  skylight, solar-panel, ridge-vent, box-vent) — PR pascalorg#330

Konflikty (4) rozwiazane manualnie z zachowaniem naszych GSI customizations:

1. use-placement-coordinator.tsx — upstream sam naprawil bug (PR pascalorg#332
   zawiera identyczny useCallback fix jak nasz 7102269); wezmy upstream

2. site-edge-labels.tsx — zachowane: lengthUnit z useViewer (nasze
   formatMeasurement obsluguje m/cm/mm); dodane: isNight z nowego
   getSceneTheme(state.sceneTheme).appearance

3. wall-measurement-label.tsx — analogicznie do pascalorg#2

4. structure-tools.tsx — zachowane PL labels (Ściana/Drzwi/Okno/Schody/
   Dach dwuspadowy/Ogrodzenie/Słup/Winda/Płyta podłogowa/Sufit/Strefa/
   Punkt startowy/Półka); dodany upstream'owy komentarz o roof accessories
   (chimney/dormer/skylight/solar-panel/ridge-vent/box-vent intentionally
   NOT w top palette — wejscie przez roof inspector)

Migracja theme API (upstream rename `theme` → `sceneTheme` w ViewerState):
- editor/index.tsx body.dark sync przelaczony na getSceneTheme(state.sceneTheme).appearance === 'dark'

PL translations nowe nodes (6):
- chimney → Komin
- ridge-vent → Wywietrznik kalenicowy
- box-vent → Wywietrznik kostkowy
- solar-panel → Panel słoneczny
- skylight → Świetlik
- dormer → Lukarna
+ shortcuts: 'Left click' → 'Lewy klik', 'Place X on roof' → 'Umieść X
  na dachu', 'Cancel' → 'Anuluj' (10 plików)

TS: 0 errors w core/viewer/editor/nodes/mcp i apps/editor.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants