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:
co-authored by
Claude Opus 4.7
parent
7b946ce8b7
commit
0a723fa1f2
@@ -27,7 +27,18 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
relations?: Relations
|
relations?: Relations
|
||||||
parametrics?: ParametricDescriptor<z.infer<S>>
|
parametrics?: ParametricDescriptor<z.infer<S>>
|
||||||
|
|
||||||
renderer: RendererSource<z.infer<S>>
|
/**
|
||||||
|
* 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 (`<NodeRenderer>`, `RegisteredSystems`)
|
||||||
|
* already null-guard on `def.renderer` so omitting it is safe.
|
||||||
|
*/
|
||||||
|
renderer?: RendererSource<z.infer<S>>
|
||||||
system?: SystemContribution
|
system?: SystemContribution
|
||||||
tool?: LazyComponent
|
tool?: LazyComponent
|
||||||
affordances?: Affordance<z.infer<S>>[]
|
affordances?: Affordance<z.infer<S>>[]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { wallDefinition } from './definition'
|
||||||
|
export { isWallRegistryEnabled } from './feature-flag'
|
||||||
|
export { WallNode } from './schema'
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -43,6 +43,11 @@ function getRegistryRenderer(
|
|||||||
function RegistryRenderer({ node }: { node: AnyNode }) {
|
function RegistryRenderer({ node }: { node: AnyNode }) {
|
||||||
const def = nodeRegistry.get(node.type)
|
const def = nodeRegistry.get(node.type)
|
||||||
if (!def) return null
|
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 <NodeRenderer> 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<AnyNode>)
|
const Renderer = getRegistryRenderer(def.renderer as RendererSource<AnyNode>)
|
||||||
if (!Renderer) return null
|
if (!Renderer) return null
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user