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