* 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
* wall: 2D floor-plan move, side arrows, drag-to-move endpoints
Bundles the in-progress wall editing work on this branch:
- Wall corner endpoint drag in 3D (`floating-action-menu.tsx`,
`wall/move-endpoint-tool.tsx`): press-and-drag on the floating
endpoint button or the new 3D corner sphere, release to commit.
Replaces the prior click-to-arm / click-to-place flow.
- New 2D move side arrows on selected walls via a new
`move-arrow` floor-plan geometry kind (core type + registry-layer
renderer + wall floor-plan builder emission), mirroring the 3D
`WallMoveSideHandles`.
- 2D wall body move: new `wallFloorplanMoveTarget` translates the
moving wall and cascades shared endpoints onto linked walls so
L-corners stay connected through the drag.
- `MoveWallTool` cleanup gains an external-commit guard so a 2D
commit doesn't get clobbered by the 3D mover's cleanup restore.
- HMR-safe `bootstrap.ts` no longer re-registers builtin kinds
whose registry entry survived the closure reset.
- Misc 2D polish: floor-plan auto-fit measures the painted scene
via `getBBox`, wall dimension offset bumped, swallow-click guard
in `handleSelect` so registry-driven selection holds through the
post-pointerdown re-render.
Floor-plan move-target / move-arrow code still carries diagnostic
console logs for the cascade flow; keeping for debug on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* wall: 2D floor-plan move adopts 3D junction planner
2D wall drag now produces the same scene topology as 3D — linked corners
cascade per `planWallMoveJunctions`, off-axis branches stay rectilinear
with a bridge wall inserted between the original and new corner, and
same-direction consumed walls collapse and delete. Previously the 2D
handler did a naive endpoint-stretch cascade with no bridges or
collapses, so dragging an L-corner in 2D vs 3D yielded different scenes.
`FloorplanMoveTargetSession` gains an optional `commit` hook. The
default overlay path snapshots affected nodes and writes a diff back on
release — fine for kinds whose commit is a pure position update, but
insufficient when commit needs to also create or delete nodes. When
`commit` is present, the overlay reverts to baseline, resumes history,
and delegates the atomic write; one Ctrl-Z rolls back the entire
operation including bridge creates and collapsed deletes.
Shared helpers (`planWallMoveJunctions` plan → updates, linked-wall
snapshots, bridge synthesis) lifted to a new `packages/nodes/src/wall/
move-shared.ts` so both the 3D `MoveWallTool` and the 2D
`wallFloorplanMoveTarget` import them. Net -163 LoC after dedup.
Auto-slab live preview and ghost bridge previews mid-drag — visible in
3D today — remain 3D-only; 2D surfaces them at commit time through the
normal scene reactions. Tracked as follow-up.
Also drops three `// temp diagnostic` console.log blocks left over from
the prior wall-move branch (2D setup, 2D canCommit, 3D cleanup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* door/window: R flips side, E toggles open/close
R previously toggled the open/closed state of operable doors and
operable windows. It now flips the opening's side (front ↔ back,
rotation += π) for both — same gesture as flipping a furniture item
that knows about handedness.
The open/close toggle moved to E, which was unbound for doors and
windows before. T is now a no-op on doors and windows so it doesn't
free-rotate a wall-bound node by π/4 (which made no architectural
sense).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* door/window: keep 2D plan in sync during placement drag
While drafting a door or window across the same host wall, the tool
was bypassing the scene store and mutating the Three.js mesh
directly. That kept 3D snappy but left the 2D floor plan reading the
last committed position — drafts froze in place on the 2D side during
a same-wall drag.
Route same-wall moves back through \`updateNode\` so 2D and 3D both
re-render from a single source. The reparent path (cross-wall drag)
still uses \`updateNode\` with \`parentId\` and \`wallId\` — we only
avoid forwarding those fields when the wall hasn't changed so the
host wall's \`children\` array doesn't churn each tick and trigger a
WebGPU "Vertex buffer slot 0 ... was not set" warning from the
briefly re-rendered placeholder geometry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* floor-plan: live wall-draft measurement + opening placement event order
Two changes to the floor-plan panel:
1. Length + angle labels render alongside the wall draft in 2D,
matching the 3D \`WallTool\` feedback. Length sits at the segment
midpoint with a plate that flips when its on-screen orientation
would read upside-down; angle arcs anchor at each endpoint that
meets an existing wall and label the deviation from that wall's
direction.
2. The pointer-move handler ran the registry catch-all
(\`isFloorplanGridInteractionActive\`) before the opening-placement
branch. Door and window are registered kinds, so during their
build mode the catch-all emitted \`grid:move\` and returned —
starving the \`wall:enter\` / \`wall:move\` events the placement
tools listen for. Reorder so opening placement runs first; the
wall-build skip in the catch-all is preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* wall: pass geometry as prop on move arrow handles
R3F's \`<primitive attach="geometry">\` path emits a \`Draw(0, 1, 0, 0)\`
on the first frame because the host \`<mesh>\` briefly renders with the
default empty \`BufferGeometry\` before the primitive child attaches.
Combined with \`frustumCulled={false}\`, WebGPU flagged "Vertex buffer
slot 0 ... was not set" every time a wall or fence was selected and
the move arrows mounted.
Pass \`arrowGeometry\` as a prop on the \`<mesh>\` so it's never
mounted with the default placeholder. Same fix applied to both the
wall and fence move-arrow handles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ifc-converter: regenerate next-env.d.ts after Next route path move
Next.js moved the generated routes typings from
\`./.next/dev/types/routes.d.ts\` to \`./.next/types/routes.d.ts\` in
the current version pinned by the workspace. Regenerated via
\`next typegen\` so the project compiles against the right path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* move: track which view finalised so split-view cleanups don't race
In split view, both the 2D move overlay and the 3D move tool mount
for the same \`movingNode\` and each captures its own pre-drag
snapshot. When one side finalises (commit or Esc), the other side
unmounts because \`setMovingNode(null)\` propagates — and its effect
cleanup had to *guess* whether the live scene was already-committed
state (skip restore) or its own drag's uncommitted state (revert).
Both cleanups did this via the same heuristic: diff snapshot fields
against current scene state. Cheap, but it conflates "the other side
committed" with "the user's apply() actually changed something" —
and fails outright if a commit happens to land on the same numeric
values as the snapshot.
Replace the heuristic with an explicit \`movingNodeOrigin\` state
field: '2d' | '3d' | null. The finalising side sets its origin
before \`setMovingNode(null)\` runs; the other side's cleanup reads
it. \`movingNodeOrigin\` is preserved across \`setMovingNode(null)\`
(so it's still observable when the cleanup fires) and reset the
next time a non-null \`setMovingNode\` starts a fresh drag.
Wired on the wall move-tool (3D) and \`FloorplanRegistryMoveOverlay\`
(2D) — the two real call sites today. Other 3D move tools can adopt
the same flag incrementally as their own split-view races surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* wall: 2D drag publishes to useLiveNodeOverrides, zustand only on commit
Side-arrow / corner-dot / curve-handle drags in the 2D floor plan now
publish `{ start, end, curveOffset }` to `useLiveNodeOverrides` each
tick instead of writing to `useScene`. WallSystem, the 2D registry
layer, and the wall sidebar all merge the overrides in when reading
endpoints, so the visual + slider preview tracks the cursor while
zustand stays at the pre-drag values until pointer-up. Commit writes
one tracked `applyNodeChanges` (junction-aware) and clears the
overrides; Esc / pointercancel / mid-drag unmount also clear them.
Also bundles the in-progress branch work this depends on:
- FloorplanAffordanceSession gains optional `commit?()` mirror of the
move-target hook; the dispatcher reverts → resumes → calls it
when present (vs. its default snapshot-diff dance).
- Selected wall body is now pointer-events-inert (polygon
`pointerEvents: 'none'` + hit-line skipped) so only the arrows /
endpoint dots / curve dot start a drag.
- Move button removed from the 2D floating action menu and the wall
sidebar inspector for walls — redundant with the side-arrows.
- `useWallMoveGhosts` store + `FloorplanWallMoveGhostLayer` for the
dashed bridge previews painted mid-drag.
- WebGPU "Vertex buffer slot 0 ... was not set" fixes on grid +
guide renderer + wall draft preview by passing geometry as a
prop (same pattern as wall-move-side-handles).
- Floor-plan wall-tool fallback: when the 3D wall tool's
`grid:click` already committed the wall, treat
`createWallOnCurrentLevel` returning null as "the 3D side handled
it" and chain the next draft segment instead of clearing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* wall: new in-world selection UI — side arrows, height handle, corner pickers, ground menu
3D affordances for a selected wall, replacing the HTML floating pill:
- side move arrows: thinner chevron+shaft silhouette (extruded, beveled);
press-hold-drag-release commits on pointerup (MoveWallTool no longer
uses grid:click)
- height arrow above the wall midpoint, drags vertically against a
camera-facing plane and updates wall.height live; new resizingWallHeight
state gates camera orbit; commit plays sfx:item-place
- corner picker per endpoint: billboarded hex disc at floor + dashed
vertical leader cylinder; pointerdown routes to the existing
movingWallEndpoint flow (works for 2D and 3D)
- ground action menu (curve / duplicate / delete): three Lucide SVGs
rendered as canvas-textured planes lying flat on the floor, anchored
one wall thickness + clearance outside the camera-facing face; one
rigid container moves them as a unit (auto-flips sides + rotates with
the wall, on curved walls uses the t=0.5 curve frame)
- floating action menu hidden for walls (replaced by the above)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* wall: smooth ground-menu side-flip with hysteresis + lerp
The three floor icons appeared to "move one at a time" when orbiting:
binary side decision flickered on grazing orbits, and the 180° rotation
flip swapped curve/delete across each other while duplicate (offset 0)
stayed put. Now lerps position+rotation toward target with a hysteresis
dead-zone, so the menu swings around the wall as one unit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* wall: raise ground-action menu to 10 cm so icons clear floor textures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* wall: anchor 2D action menu and 3D height arrow at curve apex
2D menu centres on getWallMidpointHandlePoint and stays horizontal 32 px
above the wall; 3D height arrow uses getWallCurveFrameAt(0.5) so the
apex+tangent match the side handles on curved walls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* door: in-world selection UI — side width arrows, height arrow, ground menu
Side arrows resize width anchored at the opposite edge; top arrow drags
height anchored at the floor. Ground menu mirrors the wall pattern with
move + duplicate + delete icons that flip to the camera side. Handles
portal into the level (not the wall mesh) and wrap in a per-frame
transform mirror so wall hover outline doesn't pick them up.
New viewer flag handleDragging gates node pointer events during in-world
drags; pointerup also swallows the follow-up synthetic click so the
PointerMissedHandler doesn't deselect the active item on commit. Wall
height arrow, wall move arrow, and fence move arrow all opt in.
Scale chevron arrows down to 65 % across wall + door so the family reads
as one. Panel type grids (door, window, column, skylight) get matched
breathing room (px-3 py-2.5, gap-2) so labels stop hugging the borders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* door: 2D width arrows + world-relative move dot, deterministic commits
Side-arrow width drag in the 2D floor plan: doors now emit two width
arrows at the wall-tangent edges when selected, routed through a new
`resize-width` affordance that anchors at the opposite edge, clamps to
wall bounds, and previews per-tick via scene writes so both the floor
plan and the 3D viewer track the drag in real time.
`move-arrow` kind gains optional `affordance` + `payload` so the same
chevron primitive can route to either the move flow (walls) or an
arbitrary affordance (door width-resize) without forking the renderer.
Move-dot for the door is now world-anchored — it scales with zoom in
place of the previous screen-constant size, matching the rest of the
door's chrome.
Both `doorWidthAffordance.commit()` and `doorFloorplanMoveTarget.commit()`
own their atomic final write so the dispatchers take the deterministic
revert → resume → commit path. The diff path was silently reverting
when the post-apply state happened to match the snapshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* window: in-world selection UI, 2D width arrows, deterministic commits
Bring window 3D + 2D selection chrome to parity with door. Selecting a
window in 3D now emits two side width arrows, top + bottom height arrows
(top anchors at the sill, bottom anchors at the lintel and clamps to the
wall floor), and an in-world action menu that rides just below the bottom
arrow's tip so the column moves with the sill.
2D plan adds two `resize-width` arrows at the start / end edges, routed
through the new `windowWidthAffordance` — same anchored-edge + wall-bounds
clamp + per-tick scene-write preview the door uses.
`windowFloorplanMoveTarget.commit()` is now self-owned: `apply()` snapshots
the last valid placement and `commit()` re-applies it, so the dispatcher
takes the deterministic revert → resume → commit path instead of the diff
path that silently reverts when the post-apply state happens to match
the snapshot. Mirrors the door fix.
The HTML floating-action-menu skips windows now that the in-world ground
menu owns those actions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* stair: in-world selection UI — side/length/height arrows, ground menu
Bring stair-segment selection chrome to parity with wall / door / window.
Selecting a stair segment in 3D now emits two side width arrows (each
slides the opposite edge anchor under the user), a length arrow at the
back face that extends the run, and — for stair-type segments — a height
arrow on top. A ground action menu (duplicate / delete) sits beside the
segment and flips sides as the camera orbits, with hysteresis + lerp so
it doesn't dither.
The handles portal into the stair's PARENT object (level / building / scene
root) rather than the stair group itself: StairRenderer attaches
`useNodeEvents` to the stair group, so any descendant pointer-over would
bubble up and set `hoveredId = stairId`, which then makes the post-processing
outline traverse the entire stair group and stroke our icons. Mirrors the
door fix. A two-layer transform mirror (`stairPoseRef` + `segmentPoseRef`)
keeps the handles aligned with the chained per-segment pose that
StairSystem writes imperatively each frame.
Duplicate forces `attachmentSide: 'front'` on the clone so it continues the
chain end cleanly instead of inheriting the original's side and U-turning.
New `resizingStairSegment{Width,Length,Height}` editor state lets
`CustomCameraControls` suppress orbit/zoom while an arrow is dragging,
matching the wall/door/window handle pattern.
The HTML floating-action-menu skips stair-segments now that the in-world
ground menu owns those actions.
Stair-segment panel swaps its bespoke fill-to-floor toggle for the shared
`ToggleControl` so it looks like the other panels and groups with the
thickness slider under one `space-y-3` block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* stair: 2D move-revert fix, parent ground menu, curved/spiral in-world arrows
- Route stair 2D moves through `floorplanMoveTarget` and honor
`movingNodeOrigin === '2d'` in `MoveRoofTool` cleanup so the 3D
tool's restore-from-snapshot no longer stomps the 2D commit.
- Parent stair selection shows an in-world ground action menu
(move / duplicate / delete) anchored beside the stair; the
screen-space floating menu is suppressed for `type === 'stair'`
to match door / window / segment.
- Curved & spiral stairs gain in-world resize arrows: rise (centered
on the pillar for spirals), width, inner radius, and two sweep
handles (one per arc end) clustered beside the width arrow.
- Camera controls pause during curved-stair drags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* editor: reuse measurement-bar geometry; fix two post-merge dangling refs
MeasurementBar was building a fresh BoxGeometry per render for every wall
measurement bar, which the WebGPU backend flagged ("Vertex buffer slot N
... was not set") when walls moved. Hoist a unit cube and scale it instead.
Two refs left dangling after the main-branch merge resolved its conflicts
on GitHub:
- floorplan-panel.tsx referenced a `theme` variable that no longer
exists; the file already derives `isDark` from `getSceneTheme(state.
sceneTheme).appearance === 'dark'` higher up. Use that.
- grid.tsx applied `EDITOR_LAYER` but only imports `GRID_LAYER` (the new
dedicated grid layer). Use the imported one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* editor: pin building bbox center to cursor during move
The floating drag button anchors at the building's bbox center, but the
move tool was teleporting the building's origin to the cursor — so the
moment a drag started the building jumped by `bbox_center - origin`.
Capture the local-space offset from origin to bbox center at mount and
apply it on every grid move, grid click, and R/T rotation, so the bbox
center stays pinned to the cursor through the whole drag. Also seed the
cursor sphere at the bbox center instead of the origin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* stair: 2D resize affordances + dispose curved/spiral geometry on swap
Floorplan view: emit `move-arrow` children alongside the existing chrome,
mirroring the in-world arrows on selected stairs:
- straight: per-segment side (left/right width) and front (length)
- curved & spiral: width, inner-radius, and two sweep-end arrows
Hidden during placement so they don't fight the cursor follow.
Stroke widths on curved/spiral chrome converted to screen pixels (paired
with `non-scaling-stroke`); the old world-metre values rendered as
sub-pixel at every zoom. First step line is now also emphasised on
curved stairs to match legacy chrome. Skip the straight-only
direction-arrow polyline for curved/spiral — the arc-aligned arrow above
already conveys "up" and `buildFloorplanStairArrow` produces a malformed
polyline once the chain is wrapped around an arc.
Renderer: extract `SpiralColumnMesh` and `SpiralStepSupportMesh` and add
the same prop-+-dispose pattern used by `CurvedStepMesh` /
guide/renderer.tsx. Without disposing the prior BufferGeometry on each
resize tick, WebGPU keeps a stale pipeline reference and flags
"Vertex buffer slot 0 ... was not set" mid-drag on Lambert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* editor: revert in-world ground menus to HTML floating; align grid to building
- Wall/door/window/stair/stair-segment selection menus return to the
shared HTML floating menu; remove the in-world ground icons, SVG
textures, hysteresis/lerp constants, and unused imports across
wall/door/window/stair-segment handle files.
- Drop Move from the floating menu (the in-world side arrows cover it);
delete the now-unused handleMove.
- Floating menu scales with camera zoom (ortho.zoom or 1/distance),
clamped at MIN 0.5 / MAX 1 so zoom-in keeps the default pixel size
and zoom-out shrinks to a readable floor.
- Per-type y-offsets tuned: wall 0.5, opening 0.6, landing 0.5,
flight 0.75, parent stair 0.2, structural 0.4, default 0.05.
- Align wall/fence arrow materials with the door/window pattern
(depthTest/depthWrite false, transparent: true) so they render on
top of geometry consistently.
- Grid cellSize now follows `gridSnapStep` via a small `SnapAwareGrid`
wrapper, and the grid mesh anchors its world XZ to the active
building's mesh — snapped wall endpoints (in building-local coords)
now fall on visible grid lines instead of mid-cell.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* handles: registry-driven in-world resize arrows
Adds a `handles?: HandleDescriptor[] | (node) => HandleDescriptor[]`
field to `NodeDefinition` so each kind declares its in-world resize
affordances as pure data instead of shipping a bespoke React component.
- New `packages/core/src/registry/handles.ts` exposes a discriminated
union: `linear-resize` (axis + center/min/max anchor), `radial-resize`
(1:1 outward growth), plus stubs for `arc-resize` and `endpoint-move`
for follow-up migrations.
- New `packages/editor/src/components/editor/node-arrow-handles.tsx`
reads `def.handles`, mounts arrows with shared drag plumbing (raycast
plane, NDC, pointer listeners, SFX, history pause, handle-dragging
guard). Portal modes: `'parent'` (column-like, single wrapper rides
self pose) and `'grandparent'` (door/window-like, outer wrapper rides
parent pose + inner group rides self pose so handles escape the
parent's selection-outline traversal). `apply` receives the
node-at-drag-start so edge-anchored resizes (door width re-centers
position) compute their fixed anchor from pre-drag state.
- Migrate column, door, window, stair-segment. Old per-kind handle
files (`column-side-handles.tsx`, `door-side-handles.tsx`,
`window-side-handles.tsx`) removed; `stair-segment-handles.tsx`
retains `StairHandles` (parent stair curved/spiral arrows) pending
the `arc-resize` migration.
- Column: height + crossSection-aware footprint (radius / uniform
width=depth / independent width+depth / brace width+depth for
non-vertical supports).
- Door / window: edge-anchored width (left + right) with wall-length
max bound; bottom-anchored height (door) / top + bottom edges
(window).
- Stair-segment: width (chain auto-centers), length anchored at chain
start, height for step flights only (landings skip it).
Wall and parent-stair curved/spiral arrows stay on legacy components
for now — they need `endpoint-move` + `arc-resize` descriptor variants
and rotated-axis projection, which are their own focused sessions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* handles: migrate wall + parent stair to registry; add arc-resize
Closes the wall + parent-stair gap on the registry-driven handle
migration. Net −1177 lines (the per-kind handle files were 1500+
lines of duplicated drag plumbing; their replacements are ~50-line
config blocks on each NodeDefinition).
- `arc-resize` reworked to take a raw `delta` (radians) instead of
`newValue` so two-field writes like curved-stair sweep (which
updates `sweepAngle` AND `rotation` together to keep the
non-dragged edge world-fixed) stay in the descriptor without
awkward inverse-currentValue gymnastics. `currentValue` removed
from arc-resize for the same reason — applies own their math.
- New `ArcArrow` renderer in `node-arrow-handles.tsx`: raycasts a
horizontal drag plane at the arrow's Y, measures the signed angle
delta around the node's local origin (atan2 in world XZ,
normalised to [-π, π] so wraparound doesn't flip mid-gesture),
hands the delta to `descriptor.apply` along with the initial node.
- Wall: height arrow migrated (linear-resize axis='y' anchor='min',
placement uses curve apex for curved walls, chord midpoint for
straight). Side-move arrows + corner pickers stay on the legacy
`wall-move-side-handles.tsx` because they're tap-to-engage-mode
affordances (move whole wall / move endpoint), not drag-resize —
modelling them in the registry needs an editor-action descriptor
variant which is a follow-up.
- Parent stair: curved + spiral stairs declare 5 handles
— rise (linear-resize axis='y' anchor='min'),
width (linear-resize axis='x' anchor='min'),
inner-radius (linear-resize that also writes width to keep outer
rim fixed), and sweep start / end (arc-resize variants writing
sweepAngle + rotation). Straight stairs declare nothing — their
segment children own resize.
- Old `stair-segment-handles.tsx` (1405 lines) deleted; all its
arrows now flow through the registry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* handles: tap-action descriptor + EditorApi; finish wall + fence migration
Closes the final gap in the registry-driven handle migration. Wall side-
move + corner pickers + fence side-move were the last legacy handles
because they're click-to-engage-mode affordances (hand the node to its
move tool / start an endpoint drag), not drag-resize — `apply(node,
value, sceneApi)` had no path to editor state.
- New `EditorApi` interface in core (alongside `SceneApi`) exposes
`engageMove(node)` + `engageEndpointMove(node, endpoint)`. Concrete
implementation in `packages/editor/src/lib/editor-api.ts` casts
through `useEditor`'s setters so the descriptor layer never imports
editor internals.
- New `TapActionHandle` descriptor variant: `placement` + `onActivate
(node, sceneApi, editorApi)`. `shape` field picks the visual —
defaults to the chevron arrow; `'corner-picker'` renders the dashed
vertical leader + billboarded hex disc + ring (sized to
`nodeHeight(node)`).
- `TapActionArrow` renderer in `node-arrow-handles.tsx` wires up
pointer-down → descriptor.onActivate. Pulled the chevron and corner
visuals into `ArrowShape` / `CornerPickerShape` building blocks so
future shapes can be added without touching the descriptor union.
- Wall: front/back side-move (engageMove) + start/end corner pickers
(engageEndpointMove). Joined by the existing height arrow on the
same `def.handles` list. Old `wall-move-side-handles.tsx` (600
lines) deleted — wall now has zero per-kind handle component.
- Fence: front/back side-move. The bespoke endpoint move buttons in
the floating menu stay until they migrate to a tap-action too.
Net for this commit: -620 +513. Combined with the prior two
migration commits: -2287 +912 across the full registry migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* wall: revert side-move arrows + corner pickers to legacy component
The registry-driven tap-action path didn't render the four non-height
wall handles, even with descriptors resolved and the wall mesh in
sceneRegistry. Fence uses the same descriptor shape and renders fine,
so the bug is wall-specific and not in the descriptor layer itself —
left for a real diagnosis later.
Restored the pre-5756f241 wall-move-side-handles.tsx (height arrow +
front/back side-move + start/end corner leaders, 753 lines) and
mounted it next to NodeArrowHandles in editor/index.tsx. Dropped the
def.handles field on wallDefinition so the two paths don't race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* editor: level naming helper + ambient floorplan render during building moves
Two unrelated WIP fixes bundled:
- Level names: extract \`getDefaultLevelName(n)\` /
\`getLevelDisplayName(level)\` into \`packages/editor/src/lib/level-name.ts\`
and swap in across rename inputs, command palette, floating selector,
site panel, level-tree node, level-duplicate dialog, view toggles, and
viewer-overlay breadcrumb. Default labels now read "Ground Floor" /
"Floor N" / "Basement N" instead of the bare "Level N" string each
caller was concatenating itself.
- Building-move ambient floorplan: when a building is selected (or
mid-move) without an explicit level, FloorplanRegistryLayer falls
back to that building's level 0 (or lowest level) and renders it
dimmed + non-interactive so the floor stays visible as context
instead of disappearing. FloorplanPanel allows the SVG to mount in
that case. MoveBuildingTool publishes per-frame pose to
useLiveTransforms so the floor-plan follows the drag without
reading from the Three.js mesh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* handles: 3D resize arrows publish to useLiveNodeOverrides, commit on release
Door / window / wall height-arrow drags now stage the patch in
\`useLiveNodeOverrides\` each frame and write to zustand exactly once on
pointerup. The kind's system reads via \`getEffectiveNode\` and rebuilds
the mesh imperatively, so the React tree never re-renders mid-drag and
undo isn't polluted by per-frame writes.
- \`packages/core\`: shared \`getEffectiveNode<T>(node)\` helper exported
from \`@pascal-app/core\`; spreads any override fields onto the input,
returns it unchanged when none. Replaces the inline merge wall-system
had as \`getEffectiveWall\`.
- \`DoorSystem\` / \`WindowSystem\`: subscribe to
\`useLiveNodeOverrides.overrides\` (so override-only ticks re-run the
component and pick up the latest dirtyNodes), merge via
\`getEffectiveNode\` before \`updateXMesh\`. Parent-wall dirty cascade
uses the effective node's parentId.
- \`WallSystem.updateWallGeometry\`: door / window children are merged
through \`getEffectiveNode\` before being passed to
\`generateExtrudedWall\`, so cutouts track the in-flight resize.
- \`LinearArrow\` (registry handle): onMove → override + markDirty;
onUp → one tracked \`sceneApi.update(lastPatch)\` + clear; onCancel →
clear + markDirty to revert geometry.
- Legacy \`WallHeightArrowHandle\` in wall-move-side-handles.tsx
switched to the same pattern (was the only inline-drag handle in
that file — side-move + corner pickers hand off to other tools).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* floating menu: enable Move icon for wall / door / window
Widens the \`onMove\` gate on \`NodeActionMenu\` so wall, door, and
window join column in showing the Move chevron. \`handleMove\` calls
\`setMovingNode(node)\` which dispatches through the existing
\`affordanceTools.move\` path on each kind's definition (already
present for all three).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* handles: live drag, guide rings, dimension chips; floating-menu Move on stairs
- Window bottom height arrow: flip Z rotation so chevron points down
when placement Y < 0. Door / column height arrows unaffected (still
above the node).
- Floating menu: raise stair-segment offsets (segmentType is 'stair' |
'landing', so the legacy 'stair-flight' key was dead); enable the
Move icon for parent stair and stair-segment.
- HandleDecoration on LinearResizeHandle + RadialResizeHandle. Generic
GuideRing renders at node-local (0, y, 0) in the XZ plane when the
arrow is hovered or dragging. Curved/spiral stair width arrow gets
an outer rim ring, inner-radius arrow gets an inner pillar ring,
and column radius arrow gets a footprint ring on round / octagonal /
sixteen-sided shafts.
- ArcArrow migrated to the live-override pattern (sweepAngle +
rotation). NodeArrowHandles subscribes to useLiveNodeOverrides for
the selected node and merges into the effective node, so arrow
positions, decorations, and dimension chips all track the in-flight
drag instead of freezing at pre-drag values.
- StairRenderer and ColumnRenderer subscribe narrowly to their own
override entry and render against the merged effective node, so the
curved/spiral mesh and the column body update per pointer move
without zustand churn.
- DimensionLabel chip (<Html>) rendered next to every linear-resize /
radial-resize arrow on hover or drag. Format follows the wall /
fence label recipe (metric / imperial via useViewer.unit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fence + column: registry handles, brace spread arrows, per-style defaults
- Fence: side-move arrows already on the registry path; add the height
arrow (axis 'y' linear-resize, anchor min) + start/end corner pickers
(tap-action, shape 'corner-picker' with dashed leader + billboarded
hex). Move icon enabled on the floating menu; the menu floats above
the height arrow via a fence-specific MENU_Y_OFFSET. Endpoint move
buttons + Alt-detach plumbing removed from the floating menu — corner
pickers cover that flow. Legacy wall-move-side-handles.tsx no longer
branches into fence (dedupes the side-move arrows that were stacked).
- Column: bottom + top spread arrows for non-vertical supports — anchor
'center' so dragging the right leg outward grows the full leg-to-leg
span symmetrically. Conditionally added per supportStyle:
- a-frame: both bottom and top spreads
- y-frame / v-frame: top spread only
Per-style preset map applied on supportStyle switch (panel.tsx) so
every style snaps to its renderer's natural proportions (defaults
lifted from each support's fall-through expressions); a customised
A-frame switched to Y-frame no longer carries its 1.4 m bottom into
state, and an X-brace gets equal parallel legs rather than inheriting
A-frame's pinched 0.12 m top.
- GeometrySystem: merge `getEffectiveNode(node)` before calling
`def.geometry`. Smooths drags for every kind on the parametric path
(fence, shelf, item, anything that ships `def.geometry`): live
override mutates the mesh per pointer move, zustand only hears the
commit. Mirrors WallSystem / DoorSystem / WindowSystem / StairRenderer
/ ColumnRenderer hookups.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* elevator + column: registry handles, rotation gizmo with curved arrow
- Elevator: width / depth / cab-height arrows on the registry path
(anchor='center' for width/depth so dragging outward grows the full
span symmetrically; anchor='min' for cab-height with shaftTopY
resolved through `resolveElevatorLevels` so the arrow lands above
the full shaft on multi-level elevators, not just the cab top).
Floating-menu Move icon enabled + a fence-style MENU_Y_OFFSET so
the menu floats above the height arrow.
- Whole-node rotation gizmo for both elevator and column. Uses
arc-resize with `shape: 'rotate'` + a new `decoration` ring on
ArcResizeHandle. Curved-arrow geometry is a two-headed icon (arc
ribbon with chevron wings + tangential tip at each end), rendered
in node-local XZ plane at mid-height. Guide ring traces the rotation
circle (footprint-diagonal + small offset) on hover or drag.
Position offsets along +Z only — sticks out the front of the node
instead of diagonally at the corner. apply() negates the cursor
angular delta (atan2(z,x) is opposite-handed from three.js Ry) so
dragging CCW around the node rotates the node CCW.
- ArcArrow renderer extended: tracks `isDragging` like LinearArrow,
renders the optional ring decoration, and swaps geometry between
the chevron (default, used by stair-sweep handles) and the new
curved-arrow shape when `shape: 'rotate'` is set.
- HandlePlacement.position / .rotationY now optionally take a
`sceneApi` so descriptors that depend on cross-node state (the
elevator's level-chain resolution) can compute placement against
the live scene. SceneApi gains a `nodes()` accessor returning the
full record. Test stubs in core (relations-resolver, drag-session,
hosting) updated to satisfy the new shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* slab + ceiling + shelf: registry handles, live polygon preview, cursor polish
- slab: per-edge resize chevrons in PolygonEditor (gated on
`allowEdgeMove`, so site / zone editors are unaffected), height arrow
via `def.handles`, and a floating-menu Move icon. Polygon drags now
publish the in-flight polygon to `useLiveNodeOverrides` through a new
`onPolygonPreview` prop; GeometrySystem rebuilds the slab mesh at
pointer rate while the store stays untouched until the single commit
on release. Hole editor wired the same way. Handle materials switched
meshStandard → meshBasic so the blue corner / green midpoint cylinders
read true colour instead of dimming in scene lighting.
- ceiling: same Move icon, per-edge arrows, height arrow, and live
preview through the boundary + hole editors. CeilingSystem now merges
via `getEffectiveNode`, so polygon and height overrides flow through
on every dirty tick. Height arrow placement is mesh-local (not
`height + offset`) because CeilingSystem parks `mesh.position.y`
on the height value.
- shelf: width / depth / height arrows + a curved rotation gizmo with
ring decoration. Move icon on the floating menu. Shelf stores
rotation as a tuple, so the rotate `apply` reads back `[x, y, z]` and
only mutates `y`.
- LinearArrow: snapshot `rideObject.matrixWorld.invert()` at drag-start
and reuse it in `onMove`. Kinds that park `mesh.position` on the
field being dragged (ceiling `height`) used to chase a moving ride
frame, so the local-Y delta collapsed and the value stalled / jittered.
- ArcArrow: cursor is `'grab'` on hover and `'grabbing'` during the
drag (was the misleading `'ew-resize'`); the `Cursor` type gains
those two members.
- ParametricNodeRenderer: merge `useLiveNodeOverrides` for position +
rotation so the rotation gizmo shows live motion through the outer
group — GeometrySystem already covered geometry-affecting fields.
- floating menu offsets: slab 0.4 → 0.7, ceiling 0.4 → 1.0, shelf 0.6,
so the menu floats above each kind's new height arrow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* roof-segment: registry handles, live override path, analytical-normal fix
- roof-segment: width / depth / wall-height / pitch / rotation arrows; pitch
drag back-solves the angle from peak-height via the slope frame
- roof-system: getEffectiveNode + useLiveNodeOverrides so drags rebuild
the segment + merged shell live, commit-on-release stays a single write
- floating menu: Move icon for roof-segment; uniform EXTRA_MENU_LIFT
- skylight / solar-panel / box-vent ghost: fix analytical normal — shed
sign flip, mansard / dutch +X face direction, gambrel + mansard tier
awareness; one (dx·tan, 1, dz·tan) formula across all roof types
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* floorplan: resize/rotate arrows for column, shelf, elevator, fence; rotation handedness fix
- Column / shelf / elevator: per-cross-section resize arrows in the 2D
floor plan, matching the 3D handle set (width / depth / uniform /
radius / brace dims), plus a corner rotate-arrow. Body move stays on
the move-handle dot via the registry overlay's generic translate.
- Fence: floor-plan curve sagitta handle + side move-arrows + a
body-move target (`fenceFloorplanMoveTarget`) with linked-fence
endpoint cascade and ALT-detach. Commit strips `isNew` metadata and
re-selects so the chrome stays visible at the new position.
- Roof-segment: floor-plan resize + rotate arrows wired through new
affordances; `resolveSegmentFrame` aligns with the builder's
transform so handles stay glued to the rendered footprint.
- Stair: in-world rotate gizmo bow orientation derived from the
gizmo's position (was a stray `-π/4` that read as "pointing outward"
on the spiral). StairSystem now merges the live override before the
slab-elevation spatial query, so dragging the rotate gizmo no longer
drops the group's Y when a segment swings off its pre-drag footprint.
- Rotation handedness: floor-plan now plots column / shelf /
roof-segment at `-rotation` so SVG's CW-with-y-down `rotate`
visually matches Three.js Y-rotation (CCW from top-down). Same
`rotation` value rotates the same direction in both views, and the
same cursor gesture writes the same sign — `- delta` in every rotate
affordance, lined up with the 3D handles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* registry: floorplanScope + movable capability; cursor focus under mouse
- Registry: duplicate-kind throws in production, warns in dev (HMR).
New `kindsWithFloorplanScope('building')` and `isRegistryMovable`
helpers; `resolveBuildingForLevel` extracted into spatial-grid-sync.
- Floorplan registry layer: building-scoped kinds (elevator today) now
dispatched via `def.floorplanScope === 'building'` instead of a
hardcoded `node.type === 'elevator'` arm.
- FloatingActionMenu: Move button gated by `isRegistryMovable(kind)`,
replacing the 13-arm `node?.type === '…'` chain so adding a movable
kind no longer touches this file.
- 2D cursor indicator: render at the raw mouse position in all modes
(drop the snapped `cursorAnchorPosition` machinery) so the badge
always sits under the cursor.
- 3D grid reveal ring: the shader's `positionLocal.xy` is in
grid-mesh-local space, but the cursor uniform was in world coords —
so the ring drifted by the building's world XZ. Store the last world
cursor and re-derive the local uniform every frame after the mesh's
XZ lerp, so the ring stays locked under the mouse including during
the catch-up frames after a building rotation commits.
- Roof system: tighten the merged-shell filter's type predicate so TS
narrows `n` before `hasSegmentMaterialOverride(n)`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* wall: corner billboard, perpendicular grid snap, drop 45° from move/draft
- Corner picker discs (3D move + wall corner leader) now solve
`parentWorld⁻¹ · cameraWorld` so they face the camera even when an
ancestor building/level has a rotation; the old `camera.quaternion`
copy silently broke under any parent rotation.
- Side-handle wall move snaps the wall centre's *absolute* perpendicular
projection to grid lines, so axis-aligned walls land on real grid
positions regardless of where they started.
- Wall draft + endpoint move (3D and 2D) drop the 45°-from-start angle
snap. It was useful for picking a direction during the very first
draft, but during a perpendicular endpoint drag it pulls the cursor
onto a 45° ray from the fixed corner instead of tracking the grid.
- Shift now selects the fine grid step (`WALL_FINE_GRID_STEP = 0.05`)
for precision placement in every wall snap call site, replacing the
former "Shift = bypass angle snap" semantics with a consistent
"Shift = finer snap" convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fence: drop 45° angle snap from draft + endpoint move, Shift = fine step
Mirrors the wall convention shipped in e89a8228:
- `snapFenceDraftPoint` gains an optional `step` override.
- Fence draft (3D `tool.tsx` and 2D `floorplan-panel.tsx`,
`use-floorplan-background-placement.ts`) snaps to the active grid
step only — no 45°-from-start snap. Shift switches to
`WALL_FINE_GRID_STEP` for precision placement.
- Fence endpoint move (3D `actions/move-endpoint.ts` and 2D
`floorplan-affordances.ts`) drops `start`/`angleSnap` so a
perpendicular drag tracks the grid instead of pulling onto a 45°
ray from the fixed endpoint. Shift switches to the fine step.
Also fixes the matching wall click path in
`use-floorplan-background-placement.ts:215` that was missed in
e89a8228, plus its locally-injected `snapWallDraftPoint` signature.
Side-handle perpendicular slide (`fence/move-tool.tsx`) was already
grid-snap-only without 45°, so it's untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* floorplan menu: show Move button for selected walls
Removes the `node.type !== 'wall'` exclusion that hid the Move icon
on the 2D floor-plan floating menu for walls. The dispatcher already
has a working path for walls — `def.affordanceTools.move` routes to
`MoveWallTool` (perpendicular slide + linked-wall cascade) — so the
menu just needs to expose the button.
The original opt-out called the menu entry "redundant" because walls
also have side-arrow handles, but the user wants the same icon walls
get the same affordance as every other selected element in the
floating menu.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* floorplan wall move: axis-lock to wall normal, match 3D MoveWallTool
The 2D `wallFloorplanMoveTarget` was applying the raw cursor delta in
XZ, so dragging a selected wall in the floor plan let it free-float
sideways and lengthwise. The 3D `MoveWallTool` constrains the same
drag to the wall's perpendicular axis (sideways slide only) — this
brings the 2D path into parity.
- Captures the wall's centre and the `getPerpendicularWallMoveAxis`
normal at session start.
- Each tick, projects `originalCentre + rawDelta` onto the axis,
snaps that absolute scalar to the active grid step, and translates
the wall by `axis * perpDelta`. Same math as the 3D tool.
- Shift bypasses snap (raw projection), matching the 3D convention.
- Degenerate zero-length walls fall back to free XZ motion (rare;
they're already destined for deletion via the junction planner).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* floorplan move overlay: commit at last pointermove, not pointer-up
The overlay used to re-run \`session.apply\` with the pointer-up
coordinates before committing, on the assumption that pointer-up
might fire without a preceding pointermove. Side effect: when the
pointer-up coord crossed a grid-snap boundary relative to the last
pointermove, the snap flipped to a different cell and the moved node
visibly jumped at release from where the drag had painted it.
Trust the last pointermove instead — modern browsers reliably emit a
final pointermove right before pointerup, and "what you saw is what
gets committed" is the UX users expect. The previous sub-pixel
drift fix loses to the visible boundary-jump it caused.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* floorplan: door wall-hit placement, fence/stair move fixes, wall auto-ceiling sync
- Door / window placement: registry layer entries no longer swallow
pointer events while a door / window tool is active, so clicking
ON a wall now triggers placement (previously only clicks NEAR a
wall worked — the wall's registry-entry `<g>` was stopping the
pointer event before it reached the SVG background handler that
emits `wall:click`).
- Fence floor-plan move: 3D `MoveFenceTool` now respects
`movingNodeOrigin === '2d'` on unmount. Without the guard, the 2D
overlay's commit would call `setMovingNode(null)`, unmounting the
3D tool, whose cleanup then ran `restoreOriginal()` and reverted
the just-committed positions — the "fence reverts on commit"
symptom. Mirrors the wall move-tool's existing guard.
- Stair floor-plan move: anchored, delta-based motion (was
position-jumps-to-snapped-cursor), and reads `getWallGridStep()`
instead of hard-coded 0.5 so the stair snaps to the editor's
current grid step in real time. Matches the 3D `MoveRegistryNode`
commit position.
- Stair segment length arrow: drop the placement `rotationY` —
`axis: 'z'` already auto-rotates the chevron by `-π/2`, stacking
another `-π/2` spun the tip to `-X` (sideways) instead of `+Z`
(forward off the run). Matches shelf / roof-segment.
- Stair segment system: merge `useLiveNodeOverrides` when rebuilding
geometry, chain transforms, merged mesh, and slab elevation, so
width / length / height drags show the live value on the mesh and
the store only gets the final tracked write on commit.
- Stair length arrow position: offset 0.06 m past the front edge so
the head clears the stair fill and reads as pointing forward off
the run rather than lying across the edge.
- Stair default railing mode: `'both'` for new placements (was
`'right'`).
- Wall floor-plan move: anchored at first cursor sample (was raw
centre) so the floating-menu drag-icon offset doesn't jump the
wall to a different snap cell on grab.
- Wall move: live auto-slab + auto-ceiling preview via
`useLiveNodeOverrides`. The store stays at pre-drag values during
the drag; commit writes the final plan in one atomic
`applyNodeChanges` (creates / updates / deletes deferred from
per-tick to commit so undo rolls the whole topology change back
in one step). Adds `planAutoCeilingsForLevel` + `AutoCeilingSyncPlan`
exports mirroring the existing auto-slab planner.
- Wall draft: expose `WALL_ENDPOINT_SNAP_RADIUS` (0.7 m) for
endpoint snap intent — strongest user intent (closing polygons,
attaching to corners) wants a more generous radius than the
generic join snap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* open-pr skill: refresh existing PR description instead of bailing
Adds a 3b branch to the open-pr skill: when gh pr view finds an
existing PR, regenerate the body from current branch commits/diff
while preserving Screenshots verbatim and the user's checklist
tick state, then apply via gh pr edit. Previously the skill would
print the URL and exit, leaving stale descriptions on long-lived
feature branches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* floorplan: capability/hook dispatch, generic resize + endpoint state
Removes per-kind `node.type ===` arms from the floorplan layer
(door/window opening placement, wall live-override merge, building
ambient context) in favour of new NodeDefinition capabilities and
hooks. Collapses 12 `resizing*` editor-store fields into one
`activeHandleDrag`, the wall/fence endpoint-move dispatch into a
kind-keyed table, and renames now-shared wall utilities to
segment-generic names.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 3d wall measurement: hide label for selected walls
Item measurements still render; the wall branch is left in place so
re-enabling is a one-line gate flip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* editor: set EDITOR_LAYER on arrow handles to exclude from thumbnails
Arrow meshes portaled into the 3D scene were missing EDITOR_LAYER, so
ThumbnailGenerator's camera (which calls cam.layers.disable(EDITOR_LAYER))
would render selection handles into captures.
Add a useEffect in NodeArrowHandlesForNode that traverses the portal root
group and sets EDITOR_LAYER on every child. The effect re-runs whenever
descriptors change so newly created meshes (e.g. when handle count changes
for the same selected node) get the layer tag immediately.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: open-pascal <open@pascal.app>
* 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>