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>
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>
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>
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>
Two concerns from the spike:
1) Selection / floating-action-menu had hardcoded kind lists scattered
across 4 files. Adding 'shelf' to each one per migration was the
wrong abstraction — the user's question "did you make it generic
from the noderegistry?" was the right one. Done now.
Added to @pascal-app/core/registry:
- getSelectableKinds(): string[] — returns all registered kinds
whose definition declares `capabilities.selectable`.
- isRegistrySelectable(kind): boolean — predicate for OR-chains.
Refactored hardcoded sites to merge registry kinds at runtime,
keeping legacy hardcoded lists intact so existing kinds keep
working unchanged:
- editor SelectionManager: 4 subscription loops (enter/leave/click)
+ structure.isValid + getSelectionTarget — all augment with
registry kinds. Phase 6 deletes the hardcoded lists.
- viewer SelectionManager: subscription loop + SelectableNodeType
broadened with `(string & {})` to accept registry kinds.
- floating-action-menu: ALLOWED_TYPES OR'd with isRegistrySelectable.
- Removed the manually-added 'shelf' entries from previous commit
857ddd4; they were redundant once the registry-driven path landed.
Future built-in nodes that declare `capabilities.selectable` get
click-selection + hover + the floating action menu (move/delete
icons) for free, no editing of these 4 files.
2) Spawn parity is signed off. Drop the
NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag entirely; spawn registers
unconditionally in builtinPlugin.nodes. Restored SPAWN_COLOR to
the original #22c55e green (was #ef4444 red as a Phase 2
verification marker).
Pre-existing typecheck errors in editor (ceiling/fence/slab tree-node,
scene.ts buildingId) are unchanged.
630 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shelf renderer was emitting `shelf:click` / `shelf:enter` / `shelf:leave`
via useNodeEvents from the previous commit, but no listener subscribed
— the SelectionManager components (one in editor, one in viewer) each
maintain hardcoded allTypes arrays that didn't include 'shelf'.
Adds 'shelf' to:
- editor/selection-manager: 5 allTypes arrays (one per selection strategy
— structure, structure-hover, furnish, site, deselect-also-listens-to).
- viewer/selection-manager: the SelectableNodeType union + allTypes
array.
Shelves can now be clicked / hovered in the 3D canvas and the
selection state updates correctly.
The hardcoded arrays are exactly the kind of cross-cutting friction
the registry is supposed to eliminate. Phase 4 should derive these
lists from `nodeRegistry.entries().filter(d => d.capabilities.selectable)`
so adding a new kind doesn't require editing two files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-visible follow-ups after first running the Phase 2 spike.
Spawn tool now matches legacy UX:
- CursorSphere from @pascal-app/editor for the placement indicator
(ring + line + tool-icon tooltip) — was a plain sphere mesh.
- Emits sfx:structure-build on commit + setTool(null) + setMode
('select') to exit build mode, matching legacy spawn-tool.
Shelf tool placement:
- Emits sfx:structure-build on commit.
- Cursor preview now shows top board + brackets (was just the top),
matching what gets placed.
Shelf selectable from the 3D canvas:
- ShelfEvent type added to @pascal-app/core/events/bus.
- 'shelf' added to NodeConfig in useNodeEvents.
- ShelfRenderer wires `useNodeEvents(node, 'shelf')` handlers onto
every mesh. Clicks/hovers now bubble through the editor's selection
manager and update useViewer.selection.
Shelf appears in the sidebar:
- ShelfTreeNode component (mirrors spawn-tree-node's shape +
selection/hover/rename wiring; lucide Layers icon).
- TreeNode dispatcher adds a `case 'shelf':` arm.
Framework changes:
- @pascal-app/editor exports CursorSphere alongside triggerSFX.
- @pascal-app/nodes now declares @pascal-app/editor as peer/dev dep.
Pre-existing typecheck errors in @pascal-app/editor (ceiling-tree-node,
fence-tree-node, slab-tree-node, scene.ts) are unchanged — present on
main and not introduced by this commit.
630 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first time registry-driven nodes actually run in the editor.
Spawn migration (under NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag):
- New packages/nodes/src/spawn/ folder with renderer, tool, schema
(re-exported from core), parametrics, definition, index.
- Spawn definition appended to builtinPlugin.nodes only when the flag
is set. With the flag off, the Phase 0 shims fall through and the
legacy SpawnRenderer / SpawnTool keep ownership.
- New no-props SpawnTool reads activeLevelId from useViewer directly,
matches legacy placement behavior (half-meter snap, singleton-per-
level, replace-on-reclick).
- Structural parity test (9 cases) validates definition shape +
schema identity. Pixel-diff defers to Phase 4 alongside more nodes.
New shelf node (no legacy — registered unconditionally):
- ShelfNode schema in core/schema/nodes/shelf.ts (hand-maintained
AnyNode union for now; Phase 6 derives the union from the registry
and moves the schema fully into nodes/shelf/).
- packages/nodes/src/shelf/ folder: pure geometry builder
(buildShelfGeometry returns a Three.js Group of top board +
brackets), R3F renderer that mounts the built group, no-props
placement tool, parametrics descriptor (width/depth/thickness/
height/bracketStyle/color), definition with surfaces.top stackable
surface for future stacking, and presentation metadata for the
palette.
- 13 unit tests across schema bounds and geometry behavior.
- Palette wiring: 'shelf' added to StructureTool union + an entry in
the structure-tools array (placeholder icon, replaced in Phase 4
when palette is registry-driven).
Framework changes:
- @pascal-app/viewer now exports useNodeEvents from its public barrel
so node bundles in @pascal-app/nodes can subscribe to node-specific
pointer events. (Used by spawn renderer; shelf renderer skips it
for now since useNodeEvents has a hardcoded kind list — Phase 4
generalizes it via the registry.)
- @pascal-app/nodes gains @pascal-app/viewer as a peer + dev dep so
node bundles can import from it.
630 tests pass across 76 files (22 new this phase). Editor app
continues to ship green with both legacy spawn and the new shelf
node co-existing through the Phase 0 dispatch shims.
To validate end-to-end in dev:
- bun dev:community → open editor → click 'Shelf' in structure
toolbar → click to place. Confirms full registry path
(NodeRenderer dispatch + ToolManager dispatch + sceneRegistry
byType Proxy).
- Set NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN=1, restart dev, place spawn
→ visually identical to legacy. Confirms parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additions plus a viewer JSX rewire:
- legacy-system.tsx: <LegacySystem kind="..."> wrapper that renders its
children only when nodeRegistry.has(kind) is false. Lets one wrapper
cover all legacy systems for a kind (door has DoorSystem and
DoorAnimationSystem — both belong to 'door' so they yield together).
- registered-systems.tsx: <RegisteredSystems /> iterates the registry,
filters entries that contribute a system, sorts by system.priority
(default 5; e.g. wall mitering at 8 runs after door cuts at 3),
mounts each via React.lazy. Today empty registry = renders nothing.
- viewer/index.tsx: every existing per-kind system is wrapped in
LegacySystem. RegisteredSystems is mounted alongside.
With the registry empty (Phase 0), every LegacySystem passes through
unchanged and RegisteredSystems is a no-op — zero behavior change.
Once a kind registers in Phase 2+, its legacy systems yield and its
registry-contributed system runs in their place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NodeRenderer now checks the registry first. Registered kinds load
their renderer module via React.lazy (cached by RendererSource so the
Suspense boundary is stable across re-renders). Unregistered kinds
fall through to the legacy chain below.
Today the registry is empty (Phase 0 builtinPlugin.nodes is []), so
every node still hits the legacy chain — no behavior change. The
moment a kind registers in @pascal-app/nodes (Phase 2 onward), the
registry path takes over without further edits here.
GLB / instanced-GLB RendererSource kinds are typed but not yet
honored — they get their built-in renderers in Phase 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs in one fix:
1. The 0.8.0 release commit only bumped .version, not the inter-package
peerDependencies / devDependencies. So the published packages still
declared peer constraints like "@pascal-app/viewer": "^0.7.0". Caret
in semver 0.x doesn't allow 0.8.0 to satisfy ^0.7.0, so bun resolves
workspace consumers (and apps/editor's deep import paths) to the
stale npm-published 0.7.0 instead of the workspace 0.8.0 — meaning
local edits never show up in apps/editor or any linked consumer.
2. The release.yml sync step was using $GITHUB_ENV to read back the new
versions in the same step, which doesn't work — env-file writes only
surface in subsequent steps. Switched to a bash associative array
(NEW_VERSIONS) for in-step lookup, kept the $GITHUB_ENV write for the
downstream publish/commit/tag steps. Also added a final "refs after
sync" debug print so this is visible in the workflow log.
After this lands and you bun install, packages/editor/node_modules/@pascal-app/viewer
should symlink to the workspace packages/viewer/, not to .bun/@pascal-app+viewer@0.7.0.
Future releases will sync peerDeps correctly on their own.
Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.
Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
@types/react, @types/react-dom, @types/three to prevent JSX namespace
fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
match the new versions on every bump (so viewer/editor/mcp tarballs always
reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts
Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
what private-editor currently reports
Open PRs against editor-v2 will need rebasing/conflict resolution.
- Replace runtime mesh-based bounding-box computation with static dimension-based polygons for item footprints
- Add snapUpToGridStep() and getGridAlignedDimensions() to placement-math for grid-cell-aligned placement wireframes
- Add expandBoundsToGrid() to use-placement-coordinator for consistent wireframe snapping
- Add currentCursorRotationY to PlacementContext; preserve world orientation across item-surface transitions
- Fix item detach from surface: use worldToBuildingLocal() instead of event.localPosition to avoid coordinate-space jump
- Subscribe to useLiveTransforms in FloorplanPanel during placement so R/T keyboard rotation refreshes the 2D overlay immediately
- Fix FloorplanItemImage rotation (+180° to account for top-down camera capture orientation)
- Simplify spatial-grid-manager: single dimension-based getItemLocalBounds(), removes runtime mesh-metadata path
- Remove item-mesh-metadata system (compute-item-mesh-metadata, item-mesh-metadata-system, sync-request)