Phase 4 follow-on: registry-driven floor-plan rendering + shelf port

Adds the floor-plan side of the three-checkbox composition model
documented in wiki/architecture/node-definitions.md. Mirrors the 3D
side (def.geometry → <GeometrySystem> → <ParametricNodeRenderer>) but
emits SVG primitives instead of three.js Object3Ds, and runs inside
the floor-plan panel.

Type-side (packages/core/src/registry/types.ts):
 - New FloorplanGeometry tagged union covering path / polygon /
   polyline / rect / circle / line / group. FloorplanStyle props map
   straight to SVG attributes. Coordinates are level-local meters;
   rotations are radians (three.js convention).
 - New `def.floorplan?: (node, ctx) => FloorplanGeometry | null` field
   on NodeDefinition, independent of `geometry` and `renderer`. Re-
   exported via packages/core/src/registry/index.ts.

Runtime (packages/editor):
 - <FloorplanGeometryRenderer> walks the FloorplanGeometry tree and
   emits the matching React-SVG elements. Pure data → DOM; no per-kind
   logic.
 - <FloorplanRegistryLayer> reads the active levelId, walks the level
   subtree, looks up each node's def.floorplan, builds a
   GeometryContext, calls the builder, and renders the output via
   FloorplanGeometryRenderer.
 - Mounted in floorplan-panel.tsx just before <FloorplanMarqueeLayer>
   so registry-driven kinds layer above legacy inline content.

Shelf migration (proof port):
 - New nodes/src/shelf/floorplan.ts — buildShelfFloorplan(node) emits
   a group with the rotation/translation transform and a width × depth
   rectangle in the shelf's color. Brackets omitted (hidden under top
   board from above).
 - Wired to shelfDefinition.floorplan. Shelf now appears in the floor
   plan view for the first time (was missing from the legacy panel's
   inline switch).

Pattern proven; every future kind migrating in Phase 5 follows the
same shape: a pure (node, ctx) => FloorplanGeometry function. As kinds
register their floor-plan builders, the corresponding inline branches
in floorplan-panel.tsx become dead code and can be deleted in the
same PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 10:34:42 -04:00
co-authored by Claude Opus 4.7
parent 3f3818f3b0
commit ac36297e7e
7 changed files with 385 additions and 7 deletions
+3
View File
@@ -22,6 +22,9 @@ export type {
CuttableConfig,
DragAction,
EditorCtx,
FloorplanGeometry,
FloorplanPoint,
FloorplanStyle,
GeometryContext,
HostableConfig,
IconRef,
+67
View File
@@ -26,6 +26,58 @@ export type GeometryContext = {
parent: AnyNode | null
}
// ─── FloorplanGeometry ───────────────────────────────────────────────
//
// Output shape for `def.floorplan(node, ctx)`. The floor-plan panel
// converts these primitives to React-SVG elements via a generic renderer
// — kinds never touch SVG nodes directly. Coordinates are level-local
// meters; the panel handles world→SVG transform via its viewBox.
//
// Visual styling lives in the geometry so an AI-authored kind can pick
// its own colors without needing to know about CSS / theme tokens. The
// renderer maps these directly to SVG attributes.
export type FloorplanPoint = readonly [x: number, y: number]
export type FloorplanStyle = {
stroke?: string
fill?: string
strokeWidth?: number
strokeDasharray?: string
opacity?: number
}
export type FloorplanGeometry =
| ({ kind: 'path'; d: string } & FloorplanStyle)
| ({ kind: 'polygon'; points: readonly FloorplanPoint[] } & FloorplanStyle)
| ({
kind: 'polyline'
points: readonly FloorplanPoint[]
} & FloorplanStyle)
| ({
kind: 'rect'
x: number
y: number
width: number
height: number
rx?: number
ry?: number
} & FloorplanStyle)
| ({ kind: 'circle'; cx: number; cy: number; r: number } & FloorplanStyle)
| ({
kind: 'line'
x1: number
y1: number
x2: number
y2: number
} & FloorplanStyle)
| {
kind: 'group'
children: FloorplanGeometry[]
/** Optional transform applied to all children. Rotation in radians. */
transform?: { translate?: FloorplanPoint; rotate?: number }
}
// ─── Plugin manifest ─────────────────────────────────────────────────
export type Plugin = {
@@ -76,6 +128,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* work (animations, named-mesh material poking).
*/
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
/**
* Pure 2D builder for floor-plan rendering. Mirrors `geometry` but emits
* plain `FloorplanGeometry` data (SVG-renderable) rather than three.js
* Object3D. Coordinates are level-local meters — the floor-plan panel
* applies the world→SVG transform.
*
* Returns `null` when the kind shouldn't appear in floor plan (e.g. an
* invisible utility node, or a kind that's 3D-only). Kinds that need
* floor-plan rendering but no 3D mesh set `floorplan` without `geometry`.
*
* See `wiki/architecture/node-definitions.md` ("floor-plan rendering"
* section) and Phase 5 of the registry plan for the migration plan off
* the legacy `floorplan-panel.tsx` monolith.
*/
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
system?: SystemContribution
tool?: LazyComponent
affordances?: Affordance<z.infer<S>>[]
@@ -0,0 +1,145 @@
'use client'
import type { FloorplanGeometry } from '@pascal-app/core'
import { memo } from 'react'
/**
* Pure-data → SVG converter. Walks a `FloorplanGeometry` tree returned by
* `def.floorplan(node, ctx)` and emits the matching React-SVG nodes.
*
* Coordinates are level-local meters. The wrapping floor-plan panel
* applies the world→SVG transform via its viewBox, so kinds emit
* geometry in the same units they reason about in 3D.
*
* Group transforms compose: `transform={translate(x y) rotate(deg)}`.
* Rotations are radians at the data layer (consistent with three.js
* conventions used by `def.geometry`) and converted to degrees for SVG
* here — kinds never touch units.
*
* Styling props map straight onto SVG attributes. Builders that need
* theme colors should declare them inline or expose them as registry
* tokens later (deferred until a real need surfaces — AI-authored kinds
* can pick safe defaults today).
*/
export const FloorplanGeometryRenderer = memo(function FloorplanGeometryRenderer({
geometry,
}: {
geometry: FloorplanGeometry
}) {
return renderNode(geometry, 0)
})
function renderNode(g: FloorplanGeometry, keyHint: number): React.ReactElement | null {
switch (g.kind) {
case 'path':
return (
<path
d={g.d}
fill={g.fill ?? 'none'}
key={keyHint}
opacity={g.opacity}
stroke={g.stroke}
strokeDasharray={g.strokeDasharray}
strokeWidth={g.strokeWidth}
/>
)
case 'polygon':
return (
<polygon
fill={g.fill ?? 'none'}
key={keyHint}
opacity={g.opacity}
points={pointsToAttr(g.points)}
stroke={g.stroke}
strokeDasharray={g.strokeDasharray}
strokeWidth={g.strokeWidth}
/>
)
case 'polyline':
return (
<polyline
fill={g.fill ?? 'none'}
key={keyHint}
opacity={g.opacity}
points={pointsToAttr(g.points)}
stroke={g.stroke}
strokeDasharray={g.strokeDasharray}
strokeWidth={g.strokeWidth}
/>
)
case 'rect':
return (
<rect
fill={g.fill ?? 'none'}
height={g.height}
key={keyHint}
opacity={g.opacity}
rx={g.rx}
ry={g.ry}
stroke={g.stroke}
strokeDasharray={g.strokeDasharray}
strokeWidth={g.strokeWidth}
width={g.width}
x={g.x}
y={g.y}
/>
)
case 'circle':
return (
<circle
cx={g.cx}
cy={g.cy}
fill={g.fill ?? 'none'}
key={keyHint}
opacity={g.opacity}
r={g.r}
stroke={g.stroke}
strokeDasharray={g.strokeDasharray}
strokeWidth={g.strokeWidth}
/>
)
case 'line':
return (
<line
key={keyHint}
opacity={g.opacity}
stroke={g.stroke}
strokeDasharray={g.strokeDasharray}
strokeWidth={g.strokeWidth}
x1={g.x1}
x2={g.x2}
y1={g.y1}
y2={g.y2}
/>
)
case 'group': {
const transform = formatTransform(g.transform)
return (
<g key={keyHint} transform={transform}>
{g.children.map((child, i) => renderNode(child, i))}
</g>
)
}
}
}
function pointsToAttr(points: readonly (readonly [number, number])[]): string {
return points.map(([x, y]) => `${x},${y}`).join(' ')
}
function formatTransform(t?: {
translate?: readonly [number, number]
rotate?: number
}): string | undefined {
if (!t) return undefined
const parts: string[] = []
if (t.translate) parts.push(`translate(${t.translate[0]} ${t.translate[1]})`)
if (t.rotate !== undefined) parts.push(`rotate(${(t.rotate * 180) / Math.PI})`)
return parts.length > 0 ? parts.join(' ') : undefined
}
@@ -0,0 +1,109 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type FloorplanGeometry,
type GeometryContext,
nodeRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { memo, useMemo } from 'react'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
/**
* Registry-driven floor-plan layer.
*
* Iterates registered kinds with `def.floorplan`, finds the matching nodes
* in the active level, calls each kind's builder, and emits the resulting
* SVG via `<FloorplanGeometryRenderer>`. Coexists with the legacy
* `floorplan-panel.tsx` inline rendering — the panel's hand-written
* dispatch keeps running for unmigrated kinds, this layer adds the
* registry-driven path for kinds that opt in via `def.floorplan`.
*
* Phase 5 batch migration: as each kind ports its `floorplan` field, its
* inline rendering inside `floorplan-panel.tsx` becomes redundant and
* gets deleted in the same PR.
*
* Coordinates are level-local meters; the parent SVG handles the
* world→pixel transform via its viewBox.
*/
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const levelId = useViewer((s) => s.selection.levelId)
const nodes = useScene((s) => s.nodes)
const entries = useMemo(() => {
if (!levelId) return []
const out: {
id: AnyNodeId
node: AnyNode
geometry: FloorplanGeometry
}[] = []
// Walk the level's subtree once. Most kinds live as direct or indirect
// children of the level node. For shelf today the parent is the level;
// future container kinds (slab, ceiling, wall hosting items) will
// require nested traversal — handled by the same walk below.
const visit = (id: AnyNodeId) => {
const node = nodes[id]
if (!node) return
const def = nodeRegistry.get(node.type)
const builder = def?.floorplan
if (builder) {
const ctx = buildContext(node, nodes)
// Builder is typed against the kind's specific node; at dispatch
// level we lose that refinement. Cast contained here.
const geometry = (builder as (n: AnyNode, c: GeometryContext) => unknown)(node, ctx)
if (geometry) out.push({ id, node, geometry: geometry as never })
}
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
if (Array.isArray(childIds)) {
for (const cid of childIds) visit(cid)
}
}
visit(levelId as AnyNodeId)
return out
}, [levelId, nodes])
if (entries.length === 0) return null
return (
<g className="floorplan-registry-layer" pointerEvents="none">
{entries.map(({ id, geometry }) =>
geometry ? <FloorplanGeometryRenderer geometry={geometry} key={id} /> : null,
)}
</g>
)
})
function buildContext(node: AnyNode, nodes: Record<string, AnyNode>): GeometryContext {
const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
const childIds = (node as unknown as { children?: AnyNodeId[] }).children
const children: AnyNode[] = Array.isArray(childIds)
? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
: []
const parentId = node.parentId as AnyNodeId | null
const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
let siblings: AnyNode[] = []
if (parent) {
const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children
if (Array.isArray(parentChildIds)) {
for (const sid of parentChildIds) {
if (sid === node.id) continue
const s = nodes[sid]
if (s && s.type === node.type) siblings.push(s)
}
} else {
siblings = Object.values(nodes).filter(
(n) => n !== node && n.type === node.type && n.parentId === parentId,
)
}
}
return { resolve, children, siblings, parent }
}
@@ -43,8 +43,8 @@ import {
useLiveNodeOverrides,
useLiveTransforms,
useScene,
WallNode as WallNodeSchema,
type WallNode,
WallNode as WallNodeSchema,
WindowNode,
ZoneNode as ZoneNodeSchema,
type ZoneNode as ZoneNodeType,
@@ -90,6 +90,7 @@ import {
FloorplanMeasurementsLayer,
type LinearMeasurementOverlay,
} from '../editor-2d/renderers/floorplan-measurements-layer'
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
import { FloorplanRoofLayer } from '../editor-2d/renderers/floorplan-roof-layer'
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
@@ -17672,6 +17673,14 @@ export function FloorplanPanel() {
/>
)}
{/* Registry-driven floor-plan layer. Iterates kinds whose
NodeDefinition supplies a `floorplan` builder and renders
their SVG via <FloorplanGeometryRenderer>. Sits above the
legacy inline content so newly-registered kinds (shelf
today) overlay on top until their inline equivalent is
removed in their Phase 5 migration PR. */}
<FloorplanRegistryLayer />
<FloorplanMarqueeLayer
bounds={visibleSvgMarqueeBounds}
cursorColor={palette.cursor}
+8 -6
View File
@@ -1,4 +1,5 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildShelfFloorplan } from './floorplan'
import { buildShelfGeometry } from './geometry'
import { shelfParametrics } from './parametrics'
import { ShelfNode } from './schema'
@@ -43,14 +44,15 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
parametrics: shelfParametrics,
// Three-checkbox composition: shelf needs only a pure geometry function.
// The framework's <ParametricNodeRenderer> mounts an empty group + wires
// events / registry / dirty-on-mount; the global <GeometrySystem> calls
// `buildShelfGeometry(node)` on every dirty mark and swaps the group's
// children. No `renderer.tsx`, no `system.tsx` — see
// Three-checkbox composition: shelf needs only pure builder functions.
// The framework's <ParametricNodeRenderer> + <GeometrySystem> handle 3D
// mount and rebuild on dirty; the <FloorplanRegistryLayer> calls
// buildShelfFloorplan for the 2D top-down view. No renderer.tsx, no
// system.tsx, no inline floor-plan SVG — see
// `wiki/architecture/node-definitions.md`. Shelf is the reference port
// proving Phase 4's boilerplate collapse end-to-end.
// proving Phase 4's boilerplate collapse for both 3D and 2D.
geometry: buildShelfGeometry,
floorplan: buildShelfFloorplan,
preview: () => import('./preview'),
tool: () => import('./tool'),
+43
View File
@@ -0,0 +1,43 @@
import type { FloorplanGeometry } from '@pascal-app/core'
import type { ShelfNode } from './schema'
/**
* 2D floor-plan representation of a shelf. The top board (the largest
* visible surface from above) projects to a rectangle of `width × depth`
* centered on `(position.x, position.z)`, rotated by the shelf's Y angle.
*
* Brackets are intentionally omitted — they're hidden under the top
* board from a top-down view, and adding them as separate rects clutters
* the plan without conveying useful information at typical zoom levels.
*
* Coordinates are level-local meters; the floor-plan panel applies the
* world→SVG transform via its viewBox. Rotation is radians (three.js
* convention); the renderer converts to SVG degrees.
*
* Pairs with `buildShelfGeometry(node)` — the 3D builder. Same shape,
* different output projection.
*/
export function buildShelfFloorplan(node: ShelfNode): FloorplanGeometry {
const [px, , pz] = node.position
const ry = node.rotation[1] ?? 0
const halfW = node.width / 2
const halfD = node.depth / 2
return {
kind: 'group',
transform: { translate: [px, pz], rotate: ry },
children: [
{
kind: 'rect',
x: -halfW,
y: -halfD,
width: node.width,
height: node.depth,
fill: node.color,
stroke: '#1f2937',
strokeWidth: 0.015,
opacity: 0.9,
},
],
}
}