From 0a723fa1f27bba9c802ae2198ad854f707c23b21 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 08:48:58 -0400 Subject: [PATCH] Wall Phase 3 milestone A: registry skeleton (metadata only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/registry/types.ts | 13 +- packages/nodes/src/wall/definition.ts | 111 ++++++++++++++++++ packages/nodes/src/wall/feature-flag.ts | 23 ++++ packages/nodes/src/wall/index.ts | 3 + packages/nodes/src/wall/parametrics.ts | 27 +++++ packages/nodes/src/wall/schema.ts | 12 ++ .../components/renderers/node-renderer.tsx | 5 + 7 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 packages/nodes/src/wall/definition.ts create mode 100644 packages/nodes/src/wall/feature-flag.ts create mode 100644 packages/nodes/src/wall/index.ts create mode 100644 packages/nodes/src/wall/parametrics.ts create mode 100644 packages/nodes/src/wall/schema.ts diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 4b4ebb97..8c860980 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -27,7 +27,18 @@ export type NodeDefinition> = { relations?: Relations parametrics?: ParametricDescriptor> - renderer: RendererSource> + /** + * Renderer for this kind. Optional under the three-checkbox composition + * model (see `wiki/architecture/node-definitions.md`): when omitted, the + * framework mounts a generic empty-group renderer that the per-kind + * geometry/system fills. Required today only because the generic + * renderer is not yet implemented — Phase 4 lands it, then this field + * becomes truly optional at runtime too. Making the type optional now so + * milestone-A skeletons (like wall) can compile before their runtime + * port; downstream consumers (``, `RegisteredSystems`) + * already null-guard on `def.renderer` so omitting it is safe. + */ + renderer?: RendererSource> system?: SystemContribution tool?: LazyComponent affordances?: Affordance>[] diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts new file mode 100644 index 00000000..d88b71c5 --- /dev/null +++ b/packages/nodes/src/wall/definition.ts @@ -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 = { + 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. + }, +} diff --git a/packages/nodes/src/wall/feature-flag.ts b/packages/nodes/src/wall/feature-flag.ts new file mode 100644 index 00000000..cab551f6 --- /dev/null +++ b/packages/nodes/src/wall/feature-flag.ts @@ -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' +} diff --git a/packages/nodes/src/wall/index.ts b/packages/nodes/src/wall/index.ts new file mode 100644 index 00000000..877fb3a8 --- /dev/null +++ b/packages/nodes/src/wall/index.ts @@ -0,0 +1,3 @@ +export { wallDefinition } from './definition' +export { isWallRegistryEnabled } from './feature-flag' +export { WallNode } from './schema' diff --git a/packages/nodes/src/wall/parametrics.ts b/packages/nodes/src/wall/parametrics.ts new file mode 100644 index 00000000..97bd7be4 --- /dev/null +++ b/packages/nodes/src/wall/parametrics.ts @@ -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 `` 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 = { + 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 }, + ], + }, + ], +} diff --git a/packages/nodes/src/wall/schema.ts b/packages/nodes/src/wall/schema.ts new file mode 100644 index 00000000..fdd964f0 --- /dev/null +++ b/packages/nodes/src/wall/schema.ts @@ -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' diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 7140c9bc..562f0c9c 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -43,6 +43,11 @@ function getRegistryRenderer( function RegistryRenderer({ node }: { node: AnyNode }) { const def = nodeRegistry.get(node.type) if (!def) return null + // A registered kind may omit `renderer` — in that flow the framework's + // generic empty-group renderer covers it (Phase 4 work). Until that ships, + // returning null here lets fall through to the legacy switch, + // which is how wall's milestone-A skeleton stays inert. + if (!def.renderer) return null const Renderer = getRegistryRenderer(def.renderer as RendererSource) if (!Renderer) return null return (