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