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:
co-authored by
Claude Opus 4.7
parent
3f3818f3b0
commit
ac36297e7e
@@ -22,6 +22,9 @@ export type {
|
||||
CuttableConfig,
|
||||
DragAction,
|
||||
EditorCtx,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
FloorplanStyle,
|
||||
GeometryContext,
|
||||
HostableConfig,
|
||||
IconRef,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user