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>
This commit is contained in:
Wassim SAMAD
2026-05-15 08:48:58 -04:00
co-authored by Claude Opus 4.7
parent 7b946ce8b7
commit 0a723fa1f2
7 changed files with 193 additions and 1 deletions
+111
View File
@@ -0,0 +1,111 @@
import type { NodeDefinition } from '@pascal-app/core'
import { wallParametrics } from './parametrics'
import { WallNode } from './schema'
/**
* Wall — the Phase 3 stress test of the registry-driven node model.
*
* What this definition encodes today:
* - **Capabilities**: cuttable (doors/windows punch holes), snappable
* (other walls, doors, windows snap to wall geometry), surfaces (front
* + back faces host items), selectable, duplicable, deletable.
* - **Relations**: hosts doors/windows/items; affects spatial slabs +
* ceilings + zones when moved; descendants cascade-delete; linked walls
* follow corners via endpoint-match (consumed by the affordances in a
* later milestone — the relations resolver already understands the
* declaration).
* - **Parametrics**: thickness / height / curveOffset for the inspector.
*
* What this definition does *not* yet encode:
* - `geometry` / `renderer` / `system` runtime — the existing
* `wall-renderer.tsx` + `wall-system.tsx` keep serving wall until
* Milestone B ports them into this folder. Until then, this definition
* is metadata-only and *intentionally not registered* in
* `builtinPlugin.nodes` — the Phase 0 shims only flip behavior when a
* kind is registered, so wall stays on its legacy path.
* - `tool` — wall's placement + endpoint drag + curve drag tools port in
* a follow-up milestone, expressed via the `DragAction` primitive so
* the affordances declared in `relations` get real handles.
*
* Migration is gated by `feature-flag.ts` (env: `NEXT_PUBLIC_USE_REGISTRY_FOR_WALL`).
* See `plans/editor-node-registry.md#phase-3` for the milestone breakdown.
*/
export const wallDefinition: NodeDefinition<typeof WallNode> = {
kind: 'wall',
schemaVersion: 1,
schema: WallNode,
category: 'structure',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
start: [0, 0],
end: [3, 0],
frontSide: 'unknown',
backSide: 'unknown',
}),
capabilities: {
// Wall move is bespoke today (endpoint drag, linked-wall corner cascade,
// ALT-detach). `MoveRegistryNodeTool`'s "translate on X/Z plane" shape
// doesn't apply — wall stays on its own move tool until the affordance
// port. Leaving `movable` omitted keeps that dispatch.
selectable: { hitVolume: 'bbox' },
// Front + back faces host items (paintings, shelves, switches).
// `height` callback resolves per-instance so taller walls expose taller
// hosting surface — same shape used by shelf.top.
surfaces: {
// Sides config — wall has two faces; concrete face-selection logic
// stays in the renderer/system for now. Phase 4 will derive snap
// targets from this.
sides: { faces: 'all' },
},
duplicable: true,
deletable: true,
},
relations: {
// Doors / windows / items mount on walls. The host-resolver consumes
// this to validate `parentId` on creation and to re-anchor children
// when a wall moves (a milestone-B concern; the declaration lives here
// so the wiring exists ahead of time).
hosts: ['door', 'window', 'item'],
// Moving a wall dirties the slabs / ceilings / zones that border it.
// Today this is *not* wired (the existing `wall-system` doesn't cascade
// to slab / zone) — the registry resolver gains this behavior for free
// once wall is registered. This is the "slab reflow on wall move"
// behavior gain called out in Phase 3 acceptance.
affectsSpatial: ['slab', 'ceiling', 'zone'],
// Walls sharing an endpoint move together when a corner is dragged.
// The endpoint affordance (milestone C) uses this declaration via the
// shared cascade resolver — no hand-rolled `getLinkedWallSnapshots`.
linkedBy: 'endpoint-match',
// Deleting a wall deletes its hosted doors/windows/items (today's
// implicit behavior, now declarative).
cascadeDelete: 'descendants',
},
parametrics: wallParametrics,
// No `geometry` / `renderer` / `system` / `tool` fields yet — see file
// header. Adding them registers wall via `builtinPlugin.nodes` and flips
// the dispatch shims; do that in milestone B once the runtime port lands.
presentation: {
label: 'Wall',
description: 'A straight or curved wall segment. Hosts doors, windows, and wall-mounted items.',
icon: { kind: 'iconify', name: 'lucide:wall' },
paletteSection: 'structure',
paletteOrder: 10,
},
mcp: {
description: 'A wall segment defined by start + end points, with optional curve sagitta.',
// Wall has hand-written semantic MCP tooling (`create_wall` builds full
// rooms from polygons; this entry is for the auto-derived single-wall
// primitive). Stays auto-derived until Phase 4 says otherwise.
},
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Feature flag for the registry-driven wall.
*
* Wall is the Phase 3 stress test — its scope is large enough (5 affordances,
* miter cascade, host re-anchor, slab/zone dirty propagation, undo
* correctness) that flipping it on uncontrolled would risk silently
* regressing every wall scene in production.
*
* Pattern mirrors `NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN` used during the
* Phase 2 spawn migration: when ON, `wallDefinition` is appended to
* `builtinPlugin.nodes` and the Phase 0 dispatch shims take over wall
* rendering / tooling. When OFF, the legacy wall paths run unchanged.
*
* Literal `process.env.NEXT_PUBLIC_USE_REGISTRY_FOR_WALL` access is required
* — Next.js only inline-substitutes literal env reads, so `process.env[name]`
* with a variable would always read undefined in client bundles.
*
* Drop this file the moment Phase 3 parity is signed off and wall is
* registered unconditionally.
*/
export const isWallRegistryEnabled = (): boolean => {
return process.env.NEXT_PUBLIC_USE_REGISTRY_FOR_WALL === 'true'
}
+3
View File
@@ -0,0 +1,3 @@
export { wallDefinition } from './definition'
export { isWallRegistryEnabled } from './feature-flag'
export { WallNode } from './schema'
+27
View File
@@ -0,0 +1,27 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { WallNode } from './schema'
/**
* Inspector descriptor for wall.
*
* Wall has a few "structural" knobs (thickness, height, curve sagitta) and
* a few "presentation" knobs (front / back / interior / exterior material
* presets). Phase 4's `<ParametricInspector>` renders these directly.
*
* Endpoints (`start`, `end`) and host children are *not* exposed here —
* those are edited via affordances (endpoint drag handles) and child
* placement tools, not number inputs. `parametrics` is for "type a value
* and see it apply"; spatial manipulation belongs to tools/affordances.
*/
export const wallParametrics: ParametricDescriptor<WallNode> = {
groups: [
{
label: 'Dimensions',
fields: [
{ key: 'thickness', kind: 'number', unit: 'm', min: 0.05, max: 0.6, step: 0.01 },
{ key: 'height', kind: 'number', unit: 'm', min: 1.5, max: 6, step: 0.05 },
{ key: 'curveOffset', kind: 'number', unit: 'm', min: -3, max: 3, step: 0.05 },
],
},
],
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Wall schema re-export.
*
* Wall's Zod schema lives in `@pascal-app/core` because doors, windows, and
* items still need to type-check their `parentId` against `WallNode.shape.id`
* before the migration to a `relations.hosts`-driven model is complete. The
* registry definition consumes it from here so the rest of the bundle
* imports a single canonical type.
*/
export type { WallNode as WallNodeType } from '@pascal-app/core'
export { WallNode } from '@pascal-app/core'