From ac36297e7e5ae6ece387f7d278676dd25e1a5bcf Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 15 May 2026 10:34:42 -0400 Subject: [PATCH] Phase 4 follow-on: registry-driven floor-plan rendering + shelf port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the floor-plan side of the three-checkbox composition model documented in wiki/architecture/node-definitions.md. Mirrors the 3D side (def.geometry → ) 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): - walks the FloorplanGeometry tree and emits the matching React-SVG elements. Pure data → DOM; no per-kind logic. - 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 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) --- packages/core/src/registry/index.ts | 3 + packages/core/src/registry/types.ts | 67 ++++++++ .../renderers/floorplan-geometry-renderer.tsx | 145 ++++++++++++++++++ .../renderers/floorplan-registry-layer.tsx | 109 +++++++++++++ .../src/components/editor/floorplan-panel.tsx | 11 +- packages/nodes/src/shelf/definition.ts | 14 +- packages/nodes/src/shelf/floorplan.ts | 43 ++++++ 7 files changed, 385 insertions(+), 7 deletions(-) create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx create mode 100644 packages/nodes/src/shelf/floorplan.ts diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index bba20cf8..e4419937 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -22,6 +22,9 @@ export type { CuttableConfig, DragAction, EditorCtx, + FloorplanGeometry, + FloorplanPoint, + FloorplanStyle, GeometryContext, HostableConfig, IconRef, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index a190d64a..bcf84039 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -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> = { * work (animations, named-mesh material poking). */ geometry?: (node: z.infer, 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, ctx: GeometryContext) => FloorplanGeometry | null system?: SystemContribution tool?: LazyComponent affordances?: Affordance>[] diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx new file mode 100644 index 00000000..6a020056 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx @@ -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 ( + + ) + + case 'polygon': + return ( + + ) + + case 'polyline': + return ( + + ) + + case 'rect': + return ( + + ) + + case 'circle': + return ( + + ) + + case 'line': + return ( + + ) + + case 'group': { + const transform = formatTransform(g.transform) + return ( + + {g.children.map((child, i) => renderNode(child, i))} + + ) + } + } +} + +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 +} diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx new file mode 100644 index 00000000..02cd7e42 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -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 ``. 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 ( + + {entries.map(({ id, geometry }) => + geometry ? : null, + )} + + ) +}) + +function buildContext(node: AnyNode, nodes: Record): GeometryContext { + const resolve = (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 } +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 6ceb9506..b2bc07e0 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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 . 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. */} + + = { parametrics: shelfParametrics, - // Three-checkbox composition: shelf needs only a pure geometry function. - // The framework's mounts an empty group + wires - // events / registry / dirty-on-mount; the global 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 + handle 3D + // mount and rebuild on dirty; the 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'), diff --git a/packages/nodes/src/shelf/floorplan.ts b/packages/nodes/src/shelf/floorplan.ts new file mode 100644 index 00000000..a9dc9f33 --- /dev/null +++ b/packages/nodes/src/shelf/floorplan.ts @@ -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, + }, + ], + } +}