820c5d9e3e7800ad22fbfc89666c00ff9790c75e
841
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3c37ff009b |
Phase 5 Stage E: fence / slab / ceiling drop their legacy panels
Three flavors of Stage E in one PR: - **Fence** — fully auto-derived. Adds `kind: 'boolean'` to ParamField (rendered as ToggleControl), wires `showInfill` through `def.parametrics`. Legacy `FencePanel` deleted; the auto-derived `<ParametricInspector>` now drives fence editing entirely. - **Slab / Ceiling** — kind-owned via `parametrics.customPanel`. The legacy panels have shape-specific bits (elevation/height presets, area display, holes list with auto-vs-manual provenance) that don't fit the auto-derived field model yet. `<ParametricInspector>` learns to lazy-load and mount `parametrics.customPanel` when present; legacy `SlabPanel` + `CeilingPanel` files relocate to `nodes/src/<kind>/panel.tsx` and the legacy copies delete. When `list` / `computed` / `action` field kinds eventually graduate to auto-derived support, these custom panels collapse back into `parametrics.groups`. The plan calls this out under "Custom-behavior escape hatch" and Foot-gun 5 of the recipe. Public-surface additions in `@pascal-app/editor`: - `ActionButton`, `ActionGroup`, `PanelSection`, `SegmentedControl`, `ToggleControl`, `PanelWrapper` — needed by the kind-owned panels. Per-kind progress table: fence/slab/ceiling all flip to E ✅. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c3e255b58a |
wiki(tools): clarify useLiveTransforms.position semantics for polygon kinds
Two regressions in the Stage D port surfaced the same gap: the wiki already said "the mesh offset must mirror the useLiveTransforms entry" but didn't spell out that for polygon-based kinds (slab / fence / ceiling / wall), the value to mirror is the DELTA (the group's local position) — not the world location of the polygon's center. Expanded the live-drag rule with a position-based vs polygon-based split and an explicit note that ParametricNodeRenderer reconciles the group's position via React on every Zustand notification, so the two writes must agree to the exact value or they fight every frame. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
82c9b5e1df |
Phase 5 Stage D moves: align useLiveTransforms with the direct mesh.position delta
User reported the slab/fence mesh jittered/teleported continuously
while dragging (not on commit — during the drag). Root cause: the
move tool wrote TWO conflicting values per grid:move tick:
1. `mesh.position.set(deltaX, 0, deltaZ)` — relative offset, direct
Three.js mutation.
2. `useLiveTransforms.set(id, { position: [originalCenter + deltaX,
0, originalCenter + deltaZ], rotation: 0 })` — absolute world
position of the translated polygon center.
`ParametricNodeRenderer` reads `useLiveTransforms` and binds it via
React: `<group position={liveTransform.position}>`. So every Zustand
notification re-rendered the renderer and reconciled the group's
position back to "originalCenter + delta" (the absolute), overriding
the "delta" the direct mutation had just written. The two systems
fought every frame → visible jitter.
Fix: `useLiveTransforms.position` now holds the SAME delta the direct
mutation uses (`[deltaX, 0, deltaZ]`). React reconciles to the same
value the direct mutation already set — no conflict.
The cursor sphere position stays as the translated polygon center
(it's tracked separately via React state, not `useLiveTransforms`).
Ceiling aligned for consistency, though CeilingRenderer doesn't read
`useLiveTransforms` so the value there has no rendering effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
f4ea07e05b |
Phase 5 Stage D moves: live-drag mesh.position for slab / ceiling / fence
User-reported regressions on the 1:1 legacy ports: slab/ceiling moves were slow (polygon CSG rebuilds per scene.update tick), fence moves teleported briefly on commit (residual mesh.position offset survived the geometry rebuild). All three now use the same live-drag pattern the legacy fence move was designed for: - During drag, write only to `sceneRegistry.nodes.get(id).position` + `useLiveTransforms`. No `scene.update`, no polygon rebuild, no React re-render of geometry. - History stays UNPAUSED — scene state isn't changing. - On commit, a single `scene.update` writes the translated polygon (or fence start/end + linked-fence cascade). Recorded as one natural undo step. - Tools leave `mesh.position` at the drag delta on commit; GeometrySystem / CeilingSystem reset it to (0,0,0) when they rebuild the geometry on the next frame. By the time position clears, the new geometry is in place — no teleport. Two framework changes enable this: - `GeometrySystem` (viewer/systems/geometry) now resets `group.position` + `group.rotation` after every rebuild, matching the legacy `FenceSystem.updateFenceGeometry` behavior. Tools that translate the group during live-drag can rely on the reset. - Legacy `CeilingSystem.updateCeilingGeometry` extends its existing `position.y` reset to cover X/Z too — previously it left X/Z at the drag delta after rebuild, double-translating the visual. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1e15e10185 |
Phase 5 Stage D: re-port curve + whole-item move tools 1:1 from legacy
Each tool is now a direct copy of the legacy implementation, relocated
under @pascal-app/nodes/<kind>/ and dispatched via the registry's
def.affordanceTools. No DragAction abstraction, no clever live-drag
exception, no novel snap pipeline — same code, same UX, same
performance, same history dance.
Ports:
- fence/curve-tool.tsx (legacy CurveFenceTool, 1:1)
- fence/move-tool.tsx (legacy MoveFenceTool, 1:1 — including
the mesh.position + useLiveTransforms
exception that the legacy uses for fence
specifically)
- wall/curve-tool.tsx (legacy CurveWallTool, 1:1)
- slab/move-tool.tsx (legacy MoveSlabTool, 1:1)
- ceiling/move-tool.tsx (legacy MoveCeilingTool, 1:1 — preview
fill + outline overlay preserved)
Drops the obsolete DragAction-based action files
(packages/nodes/src/{fence,wall,slab,ceiling}/actions/{curve,move}.ts)
and their now-empty actions/ directories where applicable. Fence
keeps actions/move-endpoint.ts since that port works.
Editor public surface gains `getWallGridStep` + `snapScalarToGrid`
(transitional exports — Stage F moves them into @pascal-app/nodes).
ToolManager + MoveTool dispatch unchanged: the same legacy-fallback
branches now mount the registry component because the affordances are
declared, but the rendered behavior matches the legacy because the
implementations are copies.
Per-kind progress: fence D ✅ (curve / move-endpoint / move / placement
all kind-owned), slab D ✅, ceiling D ✅, wall D 🟡 (curve only,
endpoint/move/placement still legacy).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
da63081f73 |
Phase 5 Stage D: revert curve + whole-item move affordances to legacy
User-reported regressions made it clear that my D-ports of the curve
and whole-item move tools introduced more friction than they removed.
The legacy CurveFenceTool / CurveWallTool / MoveFenceTool /
MoveSlabTool / MoveCeilingTool ship more polish than the new ports do
right now:
- Legacy curve tools pre-snap the pointer position to the 0.5m grid
BEFORE projecting onto the chord normal, and they Shift-toggle to
a free-place mode. The ports skipped both — finer math, but the
user-visible UX regressed (laggy because the cascade resolver fires
per move, history feels broken near the no-op threshold).
- Legacy whole-item moves use scene.update per tick which keeps
hosted children visually aligned. The live-drag mesh.position port
cleared the offset before the GeometrySystem could rebuild,
producing a one-frame teleport on commit.
Drop the affordance registrations for those tools — the ToolManager /
MoveTool dispatch falls back to the legacy per-kind tools when the
registry doesn't declare the affordance. Stage D progress preserved
for: fence move-endpoint (linked cascade + alt-detach), slab/ceiling
boundary + hole editors, fence placement, slab placement, ceiling
placement.
The kind-owned files (curve-tool.tsx, move-tool.tsx, actions/curve.ts,
actions/move.ts) stay on disk for the next iteration — when they
reach parity with legacy UX, re-add the affordance entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
d1231b4901 |
Phase 5 Stage D moves: live-drag for slab/ceiling + cursor follows pointer
User-reported regressions after the previous Stage D move ports: 1. Slab/ceiling moves were sluggish because the actions wrote scene.update(polygon) every grid:move tick → React re-render + CSG-with-holes geometry rebuild per frame. Apply the live-drag exception (same recipe fence move already used): visual translation via `sceneRegistry.nodes.get(id).position` + `useLiveTransforms`; scene.polygon is only written on commit. Polygon center precomputed in ctx, mesh-offset clears on commit/cancel. 2. Cursor sphere sat at the polygon center (offset from the user's actual cursor by `originalCenter - first_cursor`). Move wrappers now subscribe to `grid:move` and set `cursorRef.current.position` directly — no React state, no per-tick reconcile. Cursor lands on the user's pointer. 3. Fence move had the same React-reconcile-per-tick cost via its `useLiveTransforms` subscription. Switched to the same direct ref-mutation pattern. Adds a "REAL bend" test pinning that one Ctrl-Z after a real curve drag undoes only the bend, not the create. The previously reported "first undo does nothing" outcome reproduces only for no-op bends (drags within `normalizeWallCurveOffset`'s straight-snap threshold, ≈1.5cm on a 3m fence). For visible bends the dance pushes a real pastState entry and one undo step rolls back the bend. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c2c2f66427 |
Phase 5 Stage D: fix bend history + move-tool loop + grid-snap sfx
Three user-reported regressions, all in the Stage D move/curve ports: 1. **Fence/wall bend cancelled the fence creation on Ctrl-Z.** The action.commit's `return false` shortcut on "no offset change" bypassed the dance entirely — pastStates wasn't touched, so the next Ctrl-Z fell through to whatever preceded activation (typically the create step). Fix: always run the dance, even on no-op commits. First Ctrl-Z then absorbs a silent no-op entry, subsequent presses roll back real prior actions. Same fix applied to fence move-endpoint, fence move, slab move, ceiling move. 2. **Slab/ceiling move 'maximum update depth exceeded' loop.** The `useScene` selector in `SlabMoveTool` returned a freshly-allocated `[sx, sz]` tuple on every call. Zustand's `Object.is` equality failed each comparison → re-subscribe → re-render → loop. Fix: subscribe to the stable live-node reference and derive the center via `useMemo`. Same recipe for fence/ceiling move-tools, including memoizing the `originalCenter` fallback that was getting a new array per render. 3. **No grid-snap sfx during move drag.** Action `preview` now tracks the last snapped pointer on a mutable `lastSnapped` ctx field and emits `sfx:grid-snap` when it changes between ticks. Matches the legacy MoveFenceTool's per-tick sound. Locking test for foot-gun 1 lives in `packages/core/src/services/single-undo-dance.test.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d43cd569d6 |
drag-session: tests pinning single-undo-dance behavior
Includes a regression test for the "no-op bend" case that reproduces the user-reported 'fence bend cancels fence creation' bug: when action.commit returns false (draft === original), no pastState push happens during the drag's commit path. The very next Ctrl-Z falls through to whatever was on the stack before activation — typically the fence creation itself. The actual dance with real changes (delta !=0) works correctly — the test exercising the full createDragSession flow with action.commit returning true pins one undo step covering only the drag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7f2f9c685b |
Phase 5 Stage D wall: port CurveWallTool to DragAction (first wall D)
Direct copy of the fence curve recipe — pure `curveWallDragAction` (chord-perpendicular projection + clamp + normalize + single-undo dance) plus a thin wrapper feeding `useDragAction`. Mounted via `def.affordanceTools.curve`. Slight precision difference vs legacy: the legacy CurveWallTool snapped the pointer position to `getWallGridStep()` before projecting onto the chord normal; the ported action skips that pre-snap and relies on `normalizeWallCurveOffset` to settle the final value. The user-visible result is the same magnitude of step, just with the snap applied at the offset level instead of the position level. Remaining wall D affordances (endpoint move, whole-wall move, placement) are larger and queued for future sessions — wall's move tool alone is 804 LoC with the linked-wall corner-cascade logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
de3efa1b53 |
Phase 5 Stage D ceiling: port placement + move + boundary/hole editors
Replicates the slab Stage D recipe for ceiling: four affordances routed through the registry — def.tool for the placement flow, def.affordanceTools for boundary edit / hole edit / whole-ceiling move. Ceiling-specific bits preserved: - Placement tool keeps the dual-cursor + vertical TSL-gradient connector + ground-shadow lines (1:1 with legacy). - Move tool wrapper renders the translucent preview fill + outline overlay so the user sees the destination before clicking. ToolManager mount sites for CeilingBoundaryEditor / CeilingHoleEditor now route through `getRegistryAffordanceTool` with legacy fallback. Per-kind progress: ceiling A ✅ B (n/a, def.renderer escape hatch preserved) C ✅ D ✅; E + F pending. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
b2e5d84986 |
Phase 5 Stage D slab: port placement + move + boundary/hole editors
Replicates the fence Stage D recipe for slab — three drag affordances
+ one placement tool, all routed through the registry:
affordanceTools:
'boundary-edit' → thin <PolygonEditor> wrapper (vertex/edge drag)
'hole-edit' → same for a single hole polygon
move → DragAction with single-undo dance
tool: () => placement (multi-click polygon with axis/45° snap)
`PolygonEditor` + `PolygonEditorProps` exported from
`@pascal-app/editor` as Stage D transitional surface (Stage F cleanup
moves them into `@pascal-app/nodes`).
ToolManager mount sites for SlabBoundaryEditor / SlabHoleEditor now
route through `getRegistryAffordanceTool` with legacy fallback.
Slab move action does not use the live-drag exception (polygon CSG
rebuild every tick) — matches legacy behavior; optimization is a
separate task once we measure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7b5a1c607a |
Phase 5 Stage D fence: port FenceTool placement to def.tool
Fourth and final Stage D fence affordance — the placement tool itself.
Unlike the drag affordances (curve / move / endpoint), placement is a
two-click flow with state across grid events, not a single drag-down →
drag-up lifecycle. `DragAction` doesn't fit; the component owns its
own emitter subscriptions directly. The kind exposes it via
`def.tool: () => import('./tool')` and ToolManager's existing
`getRegistryTool()` lookup mounts it (legacy `tools.structure.fence =
FenceTool` falls through when the registry entry is missing).
Adds transitional exports from `@pascal-app/editor` for the helpers
the kind-owned tool needs at module scope: `createFenceOnCurrentLevel`,
`markToolCancelConsumed`, `EDITOR_LAYER`. Stage F cleanup moves these
into `@pascal-app/nodes` once every consumer is registry-driven.
Fence Stage D is now complete. Per-kind progress: A ✅ B ✅ C ✅ D ✅
(four affordances ported — curve, move-endpoint, move, placement).
E (drop legacy panel) and F (cleanup) pending across all kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
c16878c876 |
Phase 5 Stage D fence: port MoveFenceTool to DragAction (whole-fence move)
Third Stage D affordance port (302 LoC legacy → action + thin wrapper). The action (`actions/move.ts`) uses the live-drag exception documented in editor/wiki/architecture/tools.md: visual-only updates via `sceneRegistry.nodes.get(id).position` + `useLiveTransforms` during the drag, no scene mutations. Avoids re-rebuilding the fence geometry (many posts + infill panels) every pointer tick. Commit performs the single-undo dance — writes final start/end to scene, geometry rebuilds once, Ctrl-Z reverses the whole drag. Linked-fence cascade follows the same shape as MoveFenceEndpoint — any fence in the same parent that shared an endpoint at activation moves with the drag. `getRegistryAffordanceTool` extracted to `tools/shared/affordance-dispatch.ts` so move-tool.tsx and tool-manager.tsx share the lazy-load helper (no duplicate caches). MoveTool dispatch gains a generic `affordanceTools.move` check after the capability-driven `movable` shortcut — fence routes through here; wall / slab / etc. fall through to their legacy per-kind chain until their D ports land. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
36c48b7fc9 |
chore: biome auto-format pass (resolve persistent dirty-tree noise)
These files had been showing as modified in every dev session — biome's canonical formatting (line-length collapses, import sort, type-modifier placement) didn't match the committed state. No semantic changes. Committing now so the working tree stays clean across sessions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5e67ffd4e8 |
fence curve: single-undo dance in commit — fix undo skipping past drag
The previous commit() just returned true, relying on createDragSession.terminate() to resumeHistory. But that alone never captures the drag in zundo's pastStates — the pause window's mutations are skipped entirely. After commit, Ctrl-Z jumped past the curve *and* past the prior fence creation. Adds the same dance now used by move-endpoint: restoreAll → resumeHistory → re-apply the final draft. Zundo records one undo step for the whole drag. cancel() becomes a no-op (orchestrator's restoreAll covers it). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
05efa25edb |
Phase 5 Stage D fence: port MoveFenceEndpointTool to DragAction
Second Stage D affordance port (425 LoC legacy → ~430 split across
action + wrapper). All math (snap, linked-fence cascade, alt-detach,
min-length gate, single-undo dance) lives in the pure
`moveFenceEndpointDragAction`. The React wrapper handles the UI
overlays (cursor sphere, Drag/Detach badge, angle label) and the live
state subscriptions.
The action introduces the single-undo dance pattern for multi-write
commits: `commit()` calls `scene.restoreAll()` → `resumeHistory()` →
re-applies the final draft so zundo records the entire drag as one
undo step. Reusable shape for slab/wall/door endpoint ports.
Transitional exports added to `@pascal-app/editor`'s public surface
(`snapFenceDraftPoint`, `isWallLongEnough`, the segment-angle helpers,
`MovingFenceEndpoint`). Stage F cleanup moves these into
`@pascal-app/nodes` once every consumer is registry-driven.
`getRegistryAffordanceTool` is now generic (`ComponentType<any>`) so
affordances with different prop shapes (`{ node }`, `{ target }`, …)
all dispatch through the same helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ee309ece6f |
drag-session: dispose() is now silent — does not fire onCancel
The previous dispose() was documented as "equivalent to cancel()" and fired onCancel. That breaks React StrictMode's mount → cleanup → mount cycle for useDragAction consumers: the first cleanup's onCancel resets the parent state machine (e.g. setCurvingFence(null)), which unmounts the component before the second mount runs. Net result: the tool blinks in and out instantly. dispose() now restores scene state + resumes history but skips onCancel. Explicit cancel() still fires onCancel (Esc / external aborts). New test locks this in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1082b62552 |
useDragAction: activation-click grace + curve fence commit sfx
The legacy CurveFenceTool ignored grid:click for 150ms after mount — otherwise the very click that activates a tool (e.g. the floating menu "curve" button) cascades through the R3F drei <Html> portal into the grid, fires grid:click on the just-mounted tool, and commits the drag before any preview move runs. The new useDragAction was missing this guard, so the Stage D fence curve port "click → place sfx → exit" without ever letting the user adjust. Adds `activationGraceMs` (default 150) on useDragAction; ports the sfx:item-place commit emission into FenceCurveTool so the kind-owned tool matches legacy UX. Same guard will cover the upcoming Stage D ports (endpoint move, whole-fence move, placement, plus slab/ceiling/wall D). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8ca9686b27 |
Phase 5 Stage D: fence curve affordance → registry DragAction
First Stage D port — `CurveFenceTool` (178 LoC legacy) split into a pure `DragAction` primitive (`packages/nodes/src/fence/actions/curve.ts`) plus a thin React wrapper (`packages/nodes/src/fence/curve-tool.tsx`) that feeds it through `useDragAction`. The kind declares the affordance via `def.affordanceTools.curve`; ToolManager lazy-loads it at runtime when `useEditor.curvingFence` activates. Falls back to the legacy `CurveFenceTool` for any kind that hasn't been ported. The lazy-load dispatch dodges the editor→nodes circular dep (nodes already depends on editor for `useDragAction` + `CursorSphere`). Establishes the pattern for the remaining fence affordances (`MoveFenceEndpoint`, `MoveFence`, placement) and for slab/ceiling/wall D ports. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
95645d8e37 |
SelectionManager: route furnish-category registry kinds through furnish phase
Shelf clicks in 3D weren't selecting: getSelectionTarget routed shelf (category='furnish') to the furnish phase, but furnish.isValid hard- coded `node.type !== 'item'` and rejected shelf. Click switched phase, nothing selected. Fix: extend furnish.isValid to also accept registry-driven kinds whose def.category === 'furnish' AND def.capabilities.selectable. Item's asset.category door/window special-case stays first. Future furnish-category kinds (tables, lamps, etc.) are selectable in furnish phase without further changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9aec943740 |
Phase 5 Stage C: item floor plan — all 9 kinds now at Stage C
Item is the last Stage A-only kind to gain def.floorplan. Closes Stage C
across every registered kind.
Files added:
- nodes/src/item/floorplan.ts: buildItemFloorplan inlines a self-
contained parent-chain transform walker using `ctx.resolve`. Mirrors
the legacy `getItemFloorplanTransform` math from editor/lib/floorplan/
items.ts:
* Wall parent: rotate item.position by wall's angle, anchor at
wall.start, handle wall-side attachTo via wall.thickness offset.
* Item parent (nested): recurse for parent's transform.
* Level / slab / ceiling parent: item.position is level-local.
Returns a rotated width × depth rectangle. asset.floorPlanUrl image
overlay deferred for Phase 5 follow-up.
Files changed:
- nodes/src/item/definition.ts: wires `floorplan: buildItemFloorplan`.
- floorplan-panel.tsx: floorplanItemEntries useMemo short-circuits to
[] when nodeRegistry.has('item'). Phase 6 deletes the entire useMemo.
Stage C coverage (all 9 registered kinds):
shelf ✅ spawn ✅ fence ✅ slab ✅ ceiling ✅ wall ✅ door ✅ window ✅ item ✅
Next sessions: Stage B for door / window / wall (each large geometry
extraction), Stage D per kind (DragAction affordance ports), Stage E
(drop legacy panels), Phase 6 Stage F cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9bcb25d0aa |
Phase 5 Stage C continued: wall, door, window now in registry floor plan
Three remaining kinds at Stage C this session: wall → C - buildWallFloorplan: uses ctx.siblings to gather other walls in the level, runs calculateLevelMiters, computes plan footprint via getWallPlanFootprint. Same visual output as legacy. - getFloorplanWall thickness exaggeration inlined (~25 lines from editor/lib/floorplan/walls.ts) to keep nodes/wall self-contained. - floorplan-panel.tsx's wallPolygons short-circuits to [] when wall is registered. - Performance note: recomputes level miter data per wall (O(N²) for N walls in a level). Acceptable for typical scenes; ctx.levelData?. miters optimization deferred to Stage B's wall design pass. door → C - buildDoorFloorplan: inlines getOpeningFootprint math from floorplan-panel.tsx (40 lines, pure math). Uses ctx.parent as the wall to compute direction + perpendicular for the cutout footprint. - Returns null when parent isn't a wall (orphaned doors during placement). window → C - buildWindowFloorplan: same shape as door, glass-blue tint to distinguish visually. Both share the legacy openingsPolygons gating: - floorplan-panel.tsx's openingsPolygons useMemo filters per kind so a partial migration still works (e.g., if only door registers, only doors get skipped). When both registered, returns [] entirely. Item C intentionally deferred — needs parent-chain transform helpers (buildFloorplanItemEntry / getItemFloorplanTransform from editor/lib/ floorplan/items.ts) exposed publicly or moved into core. A focused session is the right place to design that boundary. Stage B for door / window / wall still pending — each is a focused session per kind (large geometry math extractions, wall needs ctx. levelData design). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
969b154b08 |
Phase 5 depth-first: spawn C, fence B+C, slab B+C, ceiling C
Depth-first session: drive registered kinds through Stage B (pure
def.geometry, drop system re-export) and Stage C (def.floorplan,
short-circuit legacy inline rendering in floorplan-panel.tsx).
spawn → C
- buildSpawnFloorplan wired on definition (was written but deferred
to avoid double-render).
- floorplan-panel.tsx's floorplanSpawnEntries useMemo short-circuits
to [] when nodeRegistry.has('spawn').
fence → B
- generateFenceGeometry exported from viewer; buildFenceGeometry
wraps it in a Group+Mesh with DEFAULT_STAIR_MATERIAL.
- def.geometry set; renderer + system fields dropped.
- Deleted nodes/src/fence/{renderer.tsx,system.tsx}.
fence → C
- buildFenceFloorplan: polyline along centerline (sampled for curved
fences via sampleWallCenterline from core). Stroke width = node.thickness.
- floorplan-panel.tsx's floorplanFenceEntries short-circuits.
slab → B
- generateSlabGeometry exported from viewer; buildSlabGeometry wraps
it in a Group+Mesh + cached material (preset / custom / default
pattern preserved from legacy renderer).
- def.geometry set; renderer + system fields dropped.
- Deleted nodes/src/slab/{renderer.tsx,system.tsx}.
slab → C
- buildSlabFloorplan: SVG path with outer polygon + hole subpaths
(uses getRenderableSlabPolygon from core for wall-clipping parity).
- floorplan-panel.tsx's slabPolygons short-circuits.
ceiling → B INTENTIONALLY SKIPPED
- Ceiling renderer renders React children (hosted items) + uses TSL
shader materials + named meshes that other systems poke
(getObjectByName('ceiling-grid')). Pure def.geometry can't preserve
that. Ceiling keeps def.renderer (the custom escape hatch) — same
pattern item uses. Documented in ceiling/definition.ts.
ceiling → C
- buildCeilingFloorplan: dashed-outline path with hole subpaths
(visually distinct from slab since ceilings are above).
- floorplan-panel.tsx's ceilingPolygons short-circuits.
Per-kind progress after this session:
- shelf: B ✅ C ✅ (Stage E since brand-new)
- spawn: A ✅ C ✅
- wall: A ✅ (B blocked on ctx.levelData design)
- fence: A ✅ B ✅ C ✅
- slab: A ✅ B ✅ C ✅
- ceiling: A ✅ C ✅ (B intentionally not applicable)
- door / window / item: A ✅ (B+C pending in future sessions)
Known test issue: `bun test` in packages/nodes fails to load
`three-bvh-csg` through the viewer's transitive imports (UMD/ESM
mismatch in Bun's test runner). The Next.js editor build works fine
because it bundles differently. Fix requires either dynamic imports
(breaks sync def.geometry contract) or test env config — deferred.
Other tests (schema, geometry, parity) pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
df07f7bcb2 |
SelectionManager: route item to furnish phase before registry fallback
User report: items needed a double click to select after the item kind
registered (Phase 5). Root cause: `getSelectionTarget` checked
`isRegistrySelectable(node.type)` as part of the FIRST branch (which
routes to structure phase), matching `item` before the item-specific
branch below could route door/window-category items to structure +
everything else to furnish.
Effect: clicking an item triggered phase switch (structure ← furnish),
then the next click selected. Hence the double click.
Fix:
1. Item-specific case moved to the TOP of getSelectionTarget. Its
asset.category-driven routing (door/window items → structure;
everything else → furnish) beats any generic registry fallback.
2. Generic registry fallback at the bottom now reads `def.category`
to pick the phase — `category: 'furnish'` → furnish phase,
everything else → structure/elements. Future furnish-category
kinds (only shelf right now) route correctly without a special
case.
3. `isRegistrySelectable(node.type)` clause removed from the
structure branch — replaced by the def.category check at the
bottom.
Net: single-click selection works for items again, and the routing
logic is now cleanly capability/category-driven instead of "all
registered kinds → structure" which was a Stage A simplification
that broke as soon as a furnish-category kind registered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
8d65be17fa |
Phase 5 batch kind: item migrates to registry (always-on)
Item is the first kind to use the `def.renderer` escape hatch (custom React component with `useGLTF` + drei + interactive widgets) — not expressible as a pure `def.geometry`. Catalog-backed + multi-host (free / wall / wall-side / ceiling). Files added (packages/nodes/src/item/): - schema.ts: re-exports ItemNode from core. - parametrics.ts: empty groups[]. Item parametrics come from the asset's catalog-defined interactive controls (toggles / sliders / temperature) — too dynamic for the auto-inspector at Stage A. Legacy ItemPanel renders the catalog-driven controls; Phase 5 Stage E will likely use parametrics.customPanel. - definition.ts: capabilities (no `movable` — item move is bespoke MoveItemContent that handles attachTo transitions floor↔wall↔ ceiling mid-drag; capability-driven dispatch keeps legacy mover), parametrics, renderer (wrap-export of ItemRenderer), system bundling ItemSystem + ItemLightSystem, toolHints matching the user's screenshot (Place item / R rotate ccw / T rotate cw / Shift free place / Esc cancel). defaults() casts an object literal with a stub asset since asset is required by the schema; createNode re-parses through ItemNode at runtime. - renderer.tsx: wrap-export of legacy ItemRenderer (~280 lines with useGLTF + interactive widgets — too much to duplicate at Stage A). - system.tsx: bundles ItemSystem + ItemLightSystem. - index.ts: barrel. Files changed: - packages/viewer/src/index.ts: new public exports for ItemRenderer, ItemSystem, ItemLightSystem. - packages/nodes/src/index.ts: appends itemDefinition. - packages/editor/src/components/ui/panels/item-panel.tsx: panel slider-drag fix recipe applied (nodeRef pattern, drop subscribed updateNode dep, drop node from useCallback deps). Item panel has scale + position + rotation sliders all subject to the cascade. Item is the registry's stress test for `def.renderer` escape hatch. GLB loading via useGLTF + drei works as-is; nothing in the registry forces a pure-geometry shape on kinds that don't fit. Phase 5 progress: shelf ✅ spawn ✅ wall ✅ fence ✅ slab ✅ ceiling ✅ door ✅ window ✅ item ✅. Nine kinds on the registry. Stair / roof / zone / containers remain. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9eced06f32 |
Phase 5 batch: door + window migrate to registry (always-on)
Both kinds share traits — hosted on walls, cuttable, animated open/
close state via a geometry system + animation system. Stage A
migration: register + wrap-export the legacy renderer + bundle both
per-kind systems. Pure geometry + floor-plan ports are later
milestones.
Files added (packages/nodes/src/door/, packages/nodes/src/window/):
- schema.ts: re-export from core.
- parametrics.ts: minimal — dimensions only. Door has 29 sliders +
segmented controls + presets in its legacy panel; window has 15+
sliders. Auto-inspector can't cover them at Stage A — legacy
panel keeps rendering via panel-manager.tsx case fall-through.
Stage E may extend parametrics or use parametrics.customPanel
escape hatch.
- definition.ts: capabilities (no `movable` — wall-bound drag is
bespoke; capability-driven dispatch keeps legacy MoveDoorTool /
MoveWindowTool), parametrics, renderer, system. defaults() uses
`DoorNode.parse({...stub})` to leverage zod's schema-level
`.default()` annotations — door has 40+ fields, window has 20+;
listing them inline duplicates the schema.
- renderer.tsx: wrap-export of legacy DoorRenderer / WindowRenderer
(thin 33-36 lines each).
- system.tsx: bundles each kind's TWO systems — DoorSystem +
DoorAnimationSystem, WindowSystem + WindowAnimationSystem. Both
per-kind systems mount via RegisteredSystems when the kind is
registry-driven; `<LegacySystem kind="door|window">` wrappers
around each individual system short-circuit.
- index.ts: barrel.
Files changed:
- packages/viewer/src/index.ts: new public exports for DoorRenderer,
DoorSystem, DoorAnimationSystem, WindowRenderer, WindowSystem,
WindowAnimationSystem.
- packages/nodes/src/index.ts: appends doorDefinition + windowDefinition.
- packages/editor/src/components/ui/panels/door-panel.tsx + window-
panel.tsx: panel slider-drag fix recipe applied. Drop the
subscribed `updateNode` action, drop the `node` dep from
handleUpdate / previewDoorUpdate / commitDoorPreview useCallbacks.
Use useScene.getState() inside. Door panel has 29 SliderControls,
window 15+ — both at high risk of the Maximum update depth
cascade without the fix.
Phase 5 progress: shelf ✅, spawn ✅, wall ✅, fence ✅, slab ✅, ceiling ✅,
door ✅, window ✅. Eight kinds on the registry. Item / stair / roof /
zone / containers remain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
2dd50fa5be |
Phase 5 batch kind: ceiling migrates to registry (always-on)
Structurally identical to slab. Stage A migration: registers the kind, wraps the legacy renderer + system, applies the panel slider-drag fix recipe. Files added (packages/nodes/src/ceiling/): - schema.ts: re-exports CeilingNode from core. - parametrics.ts: height slider only. Polygon + holes via floor-plan editors. - definition.ts: capabilities (no `movable`, `surfaces.top` mapped to `height`), relations (hosts: ['item'] for ceiling-mounted lights / fans, cascadeDelete: 'descendants'), toolHints, parametrics. - renderer.tsx: wrap-export of legacy CeilingRenderer. The legacy renderer uses TSL shader code for grid-line patterns (~100 lines); not worth duplicating at Stage A. Per-stage migration plan in plans/editor-node-registry.md moves the renderer body into this folder at Stage B/F. - system.tsx: re-exports legacy CeilingSystem. - index.ts: barrel. Files changed: - packages/viewer/src/index.ts: new public exports for CeilingRenderer + CeilingSystem. - packages/nodes/src/index.ts: appends ceilingDefinition. - packages/editor/src/components/ui/panels/ceiling-panel.tsx: panel slider-drag fix recipe applied (nodeRef pattern, useScene.getState() inside handler, drop subscribed updateNode dep) so the height slider doesn't trigger the same cascade fence + wall + slab fixed. Phase 5 progress: shelf ✅, spawn ✅, wall ✅, fence ✅, slab ✅, ceiling ✅. Six kinds on the registry. Door / window / item / stair / roof / zone follow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4891f681f3 |
Phase 5 batch kind: slab migrates to registry (always-on)
Same shape as fence — thin renderer + system re-export, capabilities declared, panel slider-drag fix recipe applied. Pure geometry + floor-plan ports are later milestones. Files added (packages/nodes/src/slab/): - schema.ts: re-exports SlabNode from core. - parametrics.ts: elevation slider only. Polygon + holes edited via floor-plan boundary / hole editors, not number inputs. - definition.ts: capabilities (no `movable` — slab move is bespoke whole-translation through MoveSlabTool that integrates with the boundary editor; capability-driven dispatch keeps the legacy mover), surfaces.top with elevation-as-height for stacked items, relations (hosts: ['item'], cascadeDelete: 'descendants'), toolHints (trace / finish / cancel for the placement tool). - renderer.tsx: thin placeholder mesh + markDirty on mount + node events + cached material via the same getSlabMaterial pattern as the legacy renderer (preset apply on shared material instance). - system.tsx: re-exports the legacy SlabSystem from viewer. - index.ts: barrel. Files changed: - packages/viewer/src/index.ts: exports SlabSystem (already had DEFAULT_SLAB_MATERIAL, applyMaterialPresetToMaterials, createMaterial from earlier exports). - packages/nodes/src/index.ts: appends slabDefinition unconditionally to builtinPlugin.nodes. - packages/editor/src/components/ui/panels/slab-panel.tsx: applied the panel slider-drag fix recipe from plans/editor-node-registry.md prophylactically (nodeRef pattern, useScene.getState().updateNode inside handler, drop subscribed updateNode dep). Slab's elevation slider is the only drag-driven control in the panel — would have triggered the same Maximum update depth cascade as wall/fence. No behavior change. Slab now mounts via the registry path, but the legacy SlabSystem still does the actual polygon triangulation + hole CSG work (re-exported, not duplicated). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fc9a5d02a0 |
MoveTool: dispatch by capabilities.movable, not nodeRegistry.has
User report: after wall registered, the move tool's smart sims-style
arrow UX (endpoint handles + linked-wall corner cascade + ALT-detach)
was replaced by a generic whole-wall-translate. The dispatch was
unconditionally routing every registered kind through
MoveRegistryNodeTool — but MoveRegistryNodeTool is for kinds whose
move semantics are "translate position on X/Z plane" (shelf, spawn,
single-position items). Wall / fence / slab / stair endpoint drags
are bespoke and need their legacy movers until each gets a proper
DragAction-based affordance port.
Fix: gate the registry-mover dispatch on `def.capabilities.movable`.
When a kind opts in (`movable: { axes, gridSnap }`), use the generic
mover; when a kind omits the capability deliberately (wall and fence
do), fall through to the legacy per-kind branch below.
This is the registry-aware analogue of "the registry doesn't limit
custom behavior — it lets kinds opt in to generic dispatch". Adding
`movable` is an opt-in; omitting it is an opt-out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
71b211de97 |
Fence + wall panels: stable handler refs via nodeRef to fix slider-drag loop
User report: dragging the Length slider on a fence triggered "Maximum update depth exceeded" in updateNodesAction. Root cause: the panels' `handleUpdate` / `handleUpdateLength` useCallback deps included the subscribed `node` and `updateNode` references. On every store tick during slider drag (one per pointermove), Zustand notified subscribers → panel re-rendered → new `node` ref → new handler refs → SliderControl re-rendered with new onChange prop → its `useCallback([..., onChange])` for handleLabelPointerMove rebuilt while pointer capture was active. Combined with float drift in `getWallCurveLength` recomputing per render, React eventually flagged the cascade as a componentWillUpdate / componentDidUpdate loop. Fix: - Mirror `node` into a `nodeRef` updated on every render. Handlers read from `nodeRef.current` instead of closing over `node`. - Drop the subscribed `updateNode` dep: use `useScene.getState(). updateNode(...)` inside the handler. Same pattern ParametricInspector already uses for its registry-driven inspector. - Drop the now-redundant `useScene.getState().dirtyNodes.add(id)` call — updateNode's RAF markDirty already covers it. Net effect: handler refs are stable across slider drags (only change when `selectedId` changes). SliderControl's pointer listeners no longer churn mid-drag. Cycle broken. Same fix applied to wall-panel.tsx prophylactically — it has the identical pattern and would exhibit the same loop under the right float-drift / drag conditions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6a4de8cff5 |
Drop wall + fence feature flags: register unconditionally; remove verification logs
Parity comparison against deployed prod is now cleaner than juggling
env-var flag toggles locally. Both kinds enter builtinPlugin.nodes
unconditionally; the Phase 0 dispatch shims (<LegacySystem kind="X">
wrappers + NodeRenderer's registry-first branch) handle the cutover.
Files deleted:
- packages/nodes/src/wall/feature-flag.ts
- packages/nodes/src/fence/feature-flag.ts
Files changed:
- packages/nodes/src/index.ts: drops isWallRegistryEnabled /
isFenceRegistryEnabled gates; wallDefinition + fenceDefinition
land directly in builtinPlugin.nodes.
- packages/nodes/src/{wall,fence}/index.ts: drop the flag re-export.
- packages/nodes/src/{wall,fence}/renderer.tsx: drop the one-shot
verification console.info. Same for the system.tsx wrappers.
- packages/viewer/src/components/renderers/{wall,fence}/{wall,fence}-
renderer.tsx: drop the paired [X:legacy] verification logs (no
longer comparing flag-toggled paths).
Net DX: no env var to remember when starting `bun dev:community`. To
A/B test, compare against editor.pascal.app deployed prod.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9883f1cdc1 |
Phase 5 first batch kind: fence migrates to registry behind feature flag
Same shape as wall milestone B — thin renderer + system re-export, no geometry / floor-plan / tool ports yet (later milestones). Feature flag NEXT_PUBLIC_USE_REGISTRY_FOR_FENCE gates the dispatch flip. Files added (packages/nodes/src/fence/): - schema.ts: re-exports FenceNode from core. - parametrics.ts: dimensions / posts / style fields for the auto- inspector. Endpoints + curveOffset edited via tools, not in parametrics. - feature-flag.ts: mirrors the wall flag pattern. - definition.ts: capabilities (snappable + surfaces sides + selectable + duplicable + deletable), relations (linkedBy endpoint-match, no hosts, no affectsSpatial — matches legacy), parametrics, renderer, system, toolHints (Left click / Shift / Esc — fence has no helper file today so this adds a panel where there wasn't one). Tool field absent: fence has 4 tools (build, curve, move, move-endpoint) wired through editor state, not the registry dispatch — they keep running unchanged. - renderer.tsx: thin placeholder mesh + markDirty on mount + node events + DEFAULT_STAIR_MATERIAL (matches legacy material reuse). Verification log fires once on first mount. - system.tsx: re-exports the legacy FenceSystem from viewer. Verification log on mount/unmount confirms the bundle activates. - index.ts: barrel. Files changed: - packages/viewer/src/index.ts: new exports for FenceSystem and DEFAULT_STAIR_MATERIAL so the @pascal-app/nodes bundle can compose them without reaching into viewer internals. - packages/viewer/src/components/renderers/fence/fence-renderer.tsx: paired one-shot legacy verification log so the dispatch path is unambiguous from the browser console. - packages/nodes/src/index.ts: conditional fenceEntries appended to builtinPlugin.nodes based on isFenceRegistryEnabled. With the flag off (default), behavior is unchanged; with it on, Phase 0 shims switch fence to the registry path — legacy <FenceRenderer> and <LegacySystem kind="fence"><FenceSystem /></LegacySystem> short- circuit, the bundled system.tsx re-mounts FenceSystem via RegisteredSystems, and the new renderer takes over the dispatch. No behavior change with the flag off. With it on, behavior should be byte-identical (same FenceSystem code, same priority, same geometry path). Phase 5 batch order continues with slab / ceiling / door / window / item / etc. as flagged migrations after fence parity signs off. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
713ef5009e |
Floor-plan registry: snap, real-SVG move, commit on pointerup, placement events
Four issues in one pass:
1. Move snaps to grid
MoveOverlay's cursor is now snapped via snapPointToGrid([m.x, m.y],
GRID_STEP=0.5). Matches the 3D shelf tool's placement step so 2D
and 3D placement feel identical.
2. Move translates the actual rendered SVG, not a ghost
MoveOverlay no longer portals a 50%-opacity ghost. Instead it finds
the rendered [data-node-id] <g> inside the floor-plan scene and sets
its `transform` attribute imperatively each pointermove. The inner
group's translate(px pz) rotate(deg) stays untouched — the outer
transform composes as a pure delta. Same "smooth move" pattern as
the 3D MoveRegistryNodeTool: no React re-renders, no zundo bloat,
the actual shape follows the cursor with full fidelity.
3. Click commits the position (previously did nothing)
Switched from `window click` (with capture + composedPath check)
to `window pointerup`. Pointerup fires reliably regardless of
click-vs-drag semantics in the floor-plan panel's pointer-down
handlers (which can preventDefault on certain modes and suppress
the synthesized click). Target check uses
`target.closest('[data-floorplan-scene]')` instead of composedPath
for cross-browser SVG reliability.
4. Clicking in floor plan with shelf tool active creates a shelf
Root cause: `isFloorplanGridInteractionActive` is a hardcoded OR of
build/move modes that doesn't include registry kinds, so the panel
never emits `grid:click` / `grid:move` for them. Shelf tool listens
on those events; without them, clicks were silently dropped.
Fix: new `isRegistryToolBuildActive` derived from
`mode === 'build' && tool != null && nodeRegistry.has(tool)` — added
to the OR chain. Future Phase 5 kinds (fence, item, etc.) inherit
floor-plan placement automatically the moment they register a tool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
773b58ccd4 |
Floor-plan: registry-driven action menu + cursor-driven move overlay
Two new files in packages/editor/src/components/editor-2d/ keep the
work out of the 18k-line floorplan-panel.tsx monolith. The panel
itself gets only four tiny additions (two imports, two component
mounts, one data attribute).
<FloorplanRegistryActionMenu>
- Reads useViewer.selection — when a registered kind is selected and
we're not in a move state, queries the rendered [data-node-id] <g>
for its bounding rect (polled via rAF for pan/zoom/move reactivity).
- Portals an HTML overlay above the bounding box with the existing
<NodeActionMenu>. Buttons gated by def.capabilities:
* Move → setMovingNode(node)
* Duplicate → structuredClone + schema.parse + createNode + set
movingNode (placement cursor) — matches 3D duplicate UX.
* Delete → deleteNode(id) + clear selection.
- Same visual styling as the legacy <FloorplanActionMenuLayer> per
kind, but driven by registry data.
<FloorplanRegistryMoveOverlay>
- Activates when useEditor.movingNode is a kind with def.floorplan.
- Listens on window for pointermove (to track cursor in floor plan
meters via the scene <g>'s getScreenCTM — matches the legacy
getSvgPointFromClientPoint coordinate path so cursor → meters
accounts for pan/zoom/building rotation).
- Renders a 50%-opacity ghost via portal into the floor-plan scene
<g>. Builder reused from def.floorplan — no per-kind ghost code.
- Click commits via updateNode({ position: [cx, oldY, cz] }) and
clears movingNode. Clears `isNew` metadata on duplicates so they
don't loop. Esc cancels.
floorplan-panel.tsx touches:
- Two imports (action menu + move overlay).
- data-floorplan-scene="" attribute on the floorplanSceneRef <g>
so the overlay can find the scene without sharing a ref.
- <FloorplanRegistryActionMenu /> mounted alongside the legacy
action menu layer.
- <FloorplanRegistryMoveOverlay /> mounted inside the SVG tree
alongside the registry render layer.
FloorplanRegistryLayer: also stopPropagation on click events so the
outer SVG's onClick={handleBackgroundClick} doesn't deselect right
after our pointerDown sets selection. Fixes "click-in-2D doesn't
select" bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a3fb5bd622 |
FloorplanRegistryLayer: strip drag-to-move, keep click-to-select
The drag-with-grab-cursor model was wrong design — 3D doesn't drag, it uses select → inspector "Move" button → click-to-place. Floor plan should match. Drag also had a coord-conversion bug (used outer <svg> CTM instead of the floorplanSceneRef <g>, so screen→meter conversion didn't account for the floor plan's pan/zoom/building- rotation transforms). Strips the drag pointerdown/move/up handlers, the imperative transform override, the temporal pause bracketing, and the global window listeners. Cursor goes back to 'pointer'. Only click-to- select remains. The right pattern (move via inspector / action menu + cursor-driven placement) needs: - Registry-aware FloorplanActionMenuLayer path - Generic movingNode handler in floor-plan-panel for any registered kind with capabilities.movable - Shared floorplanSceneRef for accurate coord conversion Both flagged in the plan as Phase 4 follow-on gaps with their acceptance criteria. 3D-realtime-sync-while-moving is documented as deferred (legacy doesn't do it for any kind either; design + ship in a dedicated PR later). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d10d7f8deb |
Phase 4 follow-on: floor-plan interaction + def.toolHints + spawn floorplan builder
Three additions on top of the floor-plan registry contract:
1. def.toolHints + RegisteredToolHelper (registry contract for the
shortcut hint panel)
- New ToolHint type in core: { key, label } static array.
- Added `def.toolHints?: ToolHint[]` to NodeDefinition.
- New <RegisteredToolHelper hints={...}> in editor — same visual
styling as WallHelper / ItemHelper but data-driven.
- HelperManager: registry-first check before falling through to the
hand-written per-tool switch. Per-tool helper files get deleted
as their kind migrates `toolHints` in.
- Shelf + spawn definitions ship toolHints today; wall ports in
Phase 3 Milestone C alongside its tool/affordance port.
2. Floor-plan interaction layer (selection + drag-to-move)
- <FloorplanRegistryLayer> now wraps each entry in an interactive
<g>:
* Click → useViewer.setSelection({ selectedIds: [id] }).
Selection visual is a thicker accent-colored stroke applied
via withSelectionStyle() recursion through the FloorplanGeometry
tree — kinds don't author selection decoration.
* Drag → imperative SVG transform during the gesture, single
updateNode commit on pointerup. Same "smooth move" pattern as
MoveRegistryNodeTool for 3D drag: no per-tick store update,
no React re-render storm, no zundo bloat. Coordinate
conversion via svg.getScreenCTM().inverse().
* useScene.temporal.pause/resume brackets the gesture so one
drag = one undo step.
- Global pointermove / pointerup listeners so the gesture survives
the cursor leaving the entry's bounding box (matches the legacy
elevator-resize-drag and item-drag patterns in floorplan-panel).
3. Spawn floor-plan builder (deferred wiring)
- buildSpawnFloorplan written but NOT wired on the definition —
spawn already renders in the legacy floorplan-panel.tsx via
`floorplanSpawnEntries`, and wiring def.floorplan now would
double-render. The pure builder lives in nodes/src/spawn/
floorplan.ts ready to wire when the legacy inline branch is
removed (Phase 5 spawn-floorplan migration PR — same shape as
wall's feature flag, but per kind inside the legacy panel).
Plan updated: floor-plan interaction section locks the click/drag
contract in, wall-floor-plan-as-legacy note flags everything advanced
the user sees today as legacy that ports alongside Milestone C.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ac36297e7e |
Phase 4 follow-on: registry-driven floor-plan rendering + shelf port
Adds the floor-plan side of the three-checkbox composition model documented in wiki/architecture/node-definitions.md. Mirrors the 3D side (def.geometry → <GeometrySystem> → <ParametricNodeRenderer>) but emits SVG primitives instead of three.js Object3Ds, and runs inside the floor-plan panel. Type-side (packages/core/src/registry/types.ts): - New FloorplanGeometry tagged union covering path / polygon / polyline / rect / circle / line / group. FloorplanStyle props map straight to SVG attributes. Coordinates are level-local meters; rotations are radians (three.js convention). - New `def.floorplan?: (node, ctx) => FloorplanGeometry | null` field on NodeDefinition, independent of `geometry` and `renderer`. Re- exported via packages/core/src/registry/index.ts. Runtime (packages/editor): - <FloorplanGeometryRenderer> walks the FloorplanGeometry tree and emits the matching React-SVG elements. Pure data → DOM; no per-kind logic. - <FloorplanRegistryLayer> reads the active levelId, walks the level subtree, looks up each node's def.floorplan, builds a GeometryContext, calls the builder, and renders the output via FloorplanGeometryRenderer. - Mounted in floorplan-panel.tsx just before <FloorplanMarqueeLayer> so registry-driven kinds layer above legacy inline content. Shelf migration (proof port): - New nodes/src/shelf/floorplan.ts — buildShelfFloorplan(node) emits a group with the rotation/translation transform and a width × depth rectangle in the shelf's color. Brackets omitted (hidden under top board from above). - Wired to shelfDefinition.floorplan. Shelf now appears in the floor plan view for the first time (was missing from the legacy panel's inline switch). Pattern proven; every future kind migrating in Phase 5 follows the same shape: a pure (node, ctx) => FloorplanGeometry function. As kinds register their floor-plan builders, the corresponding inline branches in floorplan-panel.tsx become dead code and can be deleted in the same PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3f3818f3b0 |
Phase 4: generic GeometrySystem + ParametricNodeRenderer; shelf ports off renderer/system files
Lands the three-checkbox composition runtime documented in wiki/architecture/node-definitions.md. A kind with only a pure geometry function now needs zero per-kind React or system code. Type-side additions (packages/core/src/registry/types.ts): - New `GeometryContext` (resolve / children / siblings / parent) — read- only scene access for builders that reference other nodes by ID (wall miters, door cutouts). Most kinds ignore it. - New `geometry?: (node, ctx) => Object3D` field on NodeDefinition, independent of renderer/system. Three orthogonal opt-ins replace the v0 RendererSource union. - Re-exported via packages/core/src/registry/index.ts (consumed by nodes packages through `export * from './registry'`). Runtime (packages/viewer): - New <GeometrySystem> (systems/geometry/geometry-system.tsx) walks dirtyNodes, builds a GeometryContext per dirty node, calls def.geometry, disposes old children, attaches new ones, clearDirty. Frame priority 2 (matches the priority shelf's per-kind system had). Mounted in viewer/index.tsx alongside <RegisteredSystems>. - New <ParametricNodeRenderer> (components/renderers/parametric-node- renderer.tsx) — empty <group> + useRegistry + useNodeEvents + markDirty-on-mount + useLiveTransforms. Mounts hosted children via <NodeRenderer> recursively. The default renderer for any registered kind without a custom def.renderer. - <NodeRenderer> dispatch updated: custom renderer wins, else geometry-only kinds fall through to ParametricNodeRenderer, else null (legacy switch fallback). Documented inline. Shelf migration (proof of the boilerplate collapse): - Deleted nodes/src/shelf/renderer.tsx (was 45 lines of registry + handler boilerplate). - Deleted nodes/src/shelf/system.tsx (was 60 lines of dirty-loop + dispose plumbing). - shelfDefinition now: `geometry: buildShelfGeometry`. One line. buildShelfGeometry is the pure function from geometry.ts that already existed. End-to-end effect: registry-driven shelf now mounts via the framework's generic renderer + system. Parametric edits flow through the same dirty-driven rebuild path, but the kind ships ~100 fewer lines of boilerplate. Every future kind that fits the same shape (item, fence segment, column, etc. as they migrate in Phase 5) follows the same "one line, one pure function" pattern. Wall stays on its dedicated def.renderer + def.system — its mitering needs level-batch context (`ctx.levelData?.miters`, future extension) that the generic system doesn't yet provide. Decided at Phase 3+, not blocking Phase 4 acceptance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
60117e848b |
WallSystem: throttle adjacent-wall rebuild during drag
Endpoint drags fire markDirty(wallId) on every pointermove tick. The old behavior rebuilt the dragged wall AND every wall sharing a junction on every tick — in a 4-corner room with doors, that's 4× the CSG +miter pass per tick. Visible as drag lag. New behavior: the dragged wall rebuilds every tick (so the drag tracks the cursor with full fidelity, cutouts and all). Adjacent walls are queued in pendingAdjacentByLevel and rebuilt on the trailing edge — 80ms after the dirty stream stops. The corners snap into their correct miter joins ~80ms after release, which is the standard CAD-app "rubber-band the dragged element, fix neighbors on commit" pattern. Module-level singleton state for the queue + timestamp — WallSystem is mounted exactly once globally, so module state is the right scope. Expected speedup: - t-junction drag: ~3× (was 3 walls/tick, now 1) - 4-corner room with door per wall: ~4× The trailing flush condition (!hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS) means single edits (non-drag) pay an 80ms latency before neighbors miter correctly. Acceptable for now; the real fix is the affordance/tool port (Milestone C) which will explicitly signal "drag in progress" so we can drop the heuristic. Until then this is a substantial drag-perf win for zero risk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
375914a07c |
Wall: paired verification logs for registry vs legacy dispatch
Three one-shot console.info calls so the Phase 3 milestone-B parity check is unambiguous from the browser console alone: - [wall:registry] system bundle mounted — fires when RegisteredSystems lazy-loads nodes/src/wall/system.tsx (exactly once per viewer mount when the flag is on). - [wall:registry] first WallRenderer mounted — fires once when the first registry-driven WallRenderer mounts. - [wall:legacy] first legacy WallRenderer mounted — fires once if the legacy path is active (flag off, or kind not registered). Module-level booleans gate the renderer logs so they don't spam in scenes with many walls. Drop all three alongside the feature flag at Phase 3 sign-off. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
02aeca8439 |
Wall Phase 3 milestone B: runtime port behind feature flag
Brings the wall kind onto the registry path when
NEXT_PUBLIC_USE_REGISTRY_FOR_WALL=true; default-off keeps wall on its
legacy path unchanged.
Files added:
- nodes/src/wall/renderer.tsx — thin placeholder-mesh mount point.
Identical pattern to the legacy WallRenderer: registers ref via
useRegistry, marks dirty on mount, renders hosted children
recursively via NodeRenderer. The legacy WallSystem fills geometry
on the next frame regardless of which mount path is active.
- nodes/src/wall/system.tsx — a bundle component that renders
<WallSystem /> + <WallCutout /> (both re-exported from viewer).
Registered via def.system with priority 4 to mirror the legacy
WallSystem's useFrame priority. Zero logic duplication — the
~970 lines of CSG/mitering/cutaway code stays in viewer.
Files changed:
- packages/viewer/src/index.ts — new exports for WallSystem, WallCutout,
and NodeRenderer. The first two so the registry-driven system bundle
can compose them; NodeRenderer so any parent kind (wall, slab,
ceiling, building) can recursively render hosted children without
reaching into viewer internals.
- nodes/src/wall/definition.ts — adds renderer + system fields. Tool
field stays absent (wall placement / endpoint drag remain bespoke
for now; the affordance port is a later milestone).
- nodes/src/index.ts — conditionally appends wallDefinition to
builtinPlugin.nodes based on isWallRegistryEnabled(). With the flag
off, the array is identical to before this commit; with it on,
Phase 0 dispatch shims switch wall to the registry path:
* <LegacySystem kind="wall"> around WallSystem returns null
* <LegacySystem kind="wall"> around WallCutout returns null
* <NodeRenderer> takes the registry-first branch and mounts the
new renderer instead of the legacy switch case for 'wall'
* RegisteredSystems mounts the new system bundle, which re-mounts
the same WallSystem + WallCutout components from viewer
No behavior change with the flag off. With the flag on, behavior should
be byte-identical (same components, same priority, same geometry path).
Manual verification next: place walls, t-junctions, walls-with-doors
with the flag toggled both ways; confirm visual + interactive parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0a723fa1f2 |
Wall Phase 3 milestone A: registry skeleton (metadata only)
Lays down the wall folder under @pascal-app/nodes with everything needed to register the kind, but intentionally without runtime wiring: - schema.ts re-exports WallNode from core (door/window/item still type their parentId against WallNode.shape.id, so the schema stays canonical there for now). - parametrics.ts declares thickness / height / curveOffset for the Phase 4 inspector. Endpoints and host children are edited via affordances, not number inputs, so they're not in parametrics. - definition.ts encodes capabilities (surfaces, selectable, duplicable, deletable — no movable since wall's move is bespoke endpoint-drag), relations (hosts doors/windows/items, affectsSpatial slabs/ceilings/ zones, linkedBy endpoint-match, cascadeDelete descendants), and the presentation metadata for the palette. Renderer / system / tool fields are deliberately absent — the existing wall-renderer.tsx and wall-system.tsx keep serving wall until milestone B. - feature-flag.ts gates the eventual registration via NEXT_PUBLIC_USE_REGISTRY_FOR_WALL (same pattern Phase 2 used for spawn). - wallDefinition is NOT yet appended to builtinPlugin.nodes — registration is what flips the Phase 0 dispatch shims, and we don't want that until the runtime port lands. Until then this file is metadata-only. Two type-side changes pulled forward from Phase 4 to make a metadata-only definition compile: - NodeDefinition.renderer becomes optional (the three-checkbox model documented in wiki/architecture/node-definitions.md already promises this). RegistryRenderer in node-renderer.tsx gains a null-guard so an undefined renderer cleanly falls through to the legacy switch. No runtime behavior change. Walls render and behave exactly as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7b946ce8b7 |
wiki: add node-definitions doc for three-checkbox composition model
New page covering the geometry/renderer/system trio that registry-driven kinds opt into. Documents: - The three optional fields on NodeDefinition and when each applies - Generic <GeometrySystem> + <ParametricNodeRenderer> runtime - GeometryContext shape (resolve / children / siblings / parent) - Combination matrix for shelf / spawn / zone / door / window / GLB items - Migration recipe from custom renderer+system files to def.geometry - Rules around purity, dispose-on-rebuild, register-once renderers.md and systems.md gain "prefer registry-driven" banners and link out to the new page. Architecture README adds the page to the index so review-architecture skill picks it up. The pattern was validated by the shelf spike: inline-JSX geometry was visibly laggy on parametric edits; moving to a per-kind system reading dirtyNodes (mirroring door/wall/item) restored smoothness. The three- checkbox model generalises that win so most future kinds need only a pure geometry function. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7f24593041 |
Shelf: move geometry build into a system, slim renderer
Follows the renderer/system split documented in wiki/architecture/ renderers.md and systems.md: the renderer must not run geometry generation. Mirrors the door-renderer/door-system pattern. - New ShelfSystem reads dirtyNodes in useFrame, retrieves the shelf's registered Group from sceneRegistry, swaps its children with the output of buildShelfGeometry(node), then clears the dirty flag. Geometry rebuild is fully imperative — no React work involved. - ShelfRenderer is now a thin empty <group> that registers with sceneRegistry, marks the node dirty on mount, and carries the pointer-event handlers + live transform overrides at the root. - Wired system into shelfDefinition so RegisteredSystems mounts it alongside the renderer. Net effect: dragging shelf parametric sliders no longer re-renders the renderer per tick — the system rebuilds meshes at frame cadence based on dirtyNodes, the inspector's per-field subscription only re-renders the dragged field, and the rest of the React tree stays quiet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
f874cecf8f |
ParametricInspector: fine-grained per-field subscriptions
Subscribe to node.type at the top and to each field's value individually inside FieldRenderer. Slider drags previously re-rendered the entire inspector + every field every tick because the panel subscribed to the whole node object (which gets a new reference on every updateNode). Primitive field values stay === equal across unrelated mutations, so now only the dragged field re-renders. Handlers (move/delete/update) use useScene.getState() inside callbacks instead of subscribing — they only need the current value, not a reactive read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9d17683feb |
Add ParametricInspector — auto-derive right-panel UI from definition.parametrics
First slice of Phase 4 work pulled in: an inspector that reads any registered node's `parametrics` descriptor and renders the right-side panel automatically. Verifies the parametric descriptor design end-to-end on shelf and proves the loop (descriptor → UI → store update → render). ParametricInspector component: - Reads `nodeRegistry.get(node.type)?.parametrics`. - Renders one `<PanelSection>` per group, one control per field. - Field kinds supported in v1: * `number` → SliderControl with min/max/step/unit from descriptor * `enum` → dark-themed <select> with prettified labels * `color` → native color picker + hex input pair * `vec3` → 3× SliderControl (X / Y / Z) - Honors `field.visibleIf(node)` to gate conditional fields. - Generic Actions footer with Move / Delete, gated on `capabilities.movable` / `capabilities.deletable !== false`. - Title from `presentation.label`, defaults to `node.type`. Wired as the `default:` arm of panel-manager.tsx's switch — registered kinds without a hardcoded case (shelf, future kinds) get the auto-derived panel. Spawn keeps its hand-written panel (the legacy switch catches it first); we'll switch to registry-first dispatch when Phase 4 finishes and the hand-written panels can be deleted. What you can verify after this: - Click a shelf → right panel shows Dimensions (width/depth/thickness/ height sliders with units + bounds from the schema) + Style (bracket style select + color picker) + Actions (Move / Delete). - Drag a slider → mesh updates live (store update → renderer re-renders). - Try to set width > 3.0 — schema rejects, no update fires (the parametric bounds are enforced by Zod, same source of truth as MCP bound generation in Phase 4's MCP work). Not in scope for this commit: - `parametrics.customPanel?` escape hatch. - `material` / `ref` field kinds. - `invariants` validation feedback in the UI. - Migration of legacy panels (spawn/column/etc.) to the auto-generated path — those keep working unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d254582597 |
MoveTool: dispatch registry-first so spawn move uses MoveRegistryNodeTool
Shim ordering bug: the `nodeRegistry.has(movingNode.type)` check sat at the END of the dispatch chain, AFTER the per-kind `if (movingNode.type === 'spawn') return <MoveSpawnTool>` branches. Spawn (now registered in builtinPlugin) was therefore still routing to the legacy MoveSpawnTool — which uses the broken useLiveTransforms pattern and makes the spawn mesh disappear during drag. Moved the registry check to the TOP, matching the registry-first dispatch model the Phase 0 shims use everywhere else (NodeRenderer, ToolManager, system guards). Now any kind registered via @pascal-app/nodes routes to MoveRegistryNodeTool — same smooth imperative drag for shelf, spawn, and every future kind. Legacy per-kind movers below run only for kinds not yet in the registry. This is exactly how the Phase 5 progressive consolidation works: as kinds migrate to the registry, their legacy movers stop being reached and can be deleted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
166e860bfe |
Move: pure imperative via sceneRegistry (smooth, zero re-renders); drop dev logs
User feedback: updating the store per tick caused tons of React
re-renders → laggy drag. Switched to pure imperative.
MoveRegistryNodeTool now:
- Mutates `sceneRegistry.nodes.get(id).position` directly per
grid:move tick. No useScene.updateNode during drag. No store
change → no renderer re-render → R3F doesn't reapply
`position={node.position}` → the imperative mutation sticks.
- On commit: single tracked `useScene.updateNode(id, { position })`.
Undo replays one step (original → final), no per-tick spam.
- On cancel / unmount: imperatively snap the mesh back to original.
Store was never touched so no data revert needed.
Trade-off vs the items pattern (which does update the store per tick
and re-renders per tick): our approach is faster but assumes the
renderer doesn't re-render mid-drag. Items get away with constant
re-renders because their renderer is heavily optimized; for parametric
shelves (and future kinds) the imperative path is simpler and faster.
Cleanup: removed the dev `[shelf] rendered` and `[shelf] placed`
console.info logs from the shelf renderer and tool. They were Phase 2
verification scaffolding — no longer needed now that everything works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7bd34e5ff0 |
Move: update node.position directly per tick (matches item pattern)
The useLiveTransforms + sceneRegistry.position.set approach used by
MoveColumnTool is broken — column ALSO disappears during move, per
user observation. The mesh doesn't visibly follow the cursor.
Items work because their move tool directly updates the scene store's
node.position on every grid:move tick (with history paused), and the
renderer reads node.position. Matching that pattern here.
MoveRegistryNodeTool now:
- Snapshots the original position at mount (for cancel / commit
revert path).
- Pauses scene history so per-tick updateNode calls don't fill undo.
- On grid:move: `useScene.updateNode(id, { position })`. The kind's
registered renderer reads node.position and re-renders, so the
actual mesh visibly follows the cursor.
- On commit: revert to original while still paused → resume → final
update (single tracked action) → re-pause. Undo replays one step,
not the per-tick spam.
- On cancel / unmount-without-commit: restore original position with
history still paused (won't enter undo), then resume.
The cursor sphere stays as the aim indicator alongside the moving
mesh. No translucent ghost — the actual mesh IS the preview now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|