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,
|
CuttableConfig,
|
||||||
DragAction,
|
DragAction,
|
||||||
EditorCtx,
|
EditorCtx,
|
||||||
|
FloorplanGeometry,
|
||||||
|
FloorplanPoint,
|
||||||
|
FloorplanStyle,
|
||||||
GeometryContext,
|
GeometryContext,
|
||||||
HostableConfig,
|
HostableConfig,
|
||||||
IconRef,
|
IconRef,
|
||||||
|
|||||||
@@ -26,6 +26,58 @@ export type GeometryContext = {
|
|||||||
parent: AnyNode | null
|
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 ─────────────────────────────────────────────────
|
// ─── Plugin manifest ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export type Plugin = {
|
export type Plugin = {
|
||||||
@@ -76,6 +128,21 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
* work (animations, named-mesh material poking).
|
* work (animations, named-mesh material poking).
|
||||||
*/
|
*/
|
||||||
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
|
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
|
system?: SystemContribution
|
||||||
tool?: LazyComponent
|
tool?: LazyComponent
|
||||||
affordances?: Affordance<z.infer<S>>[]
|
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,
|
useLiveNodeOverrides,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
WallNode as WallNodeSchema,
|
|
||||||
type WallNode,
|
type WallNode,
|
||||||
|
WallNode as WallNodeSchema,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
ZoneNode as ZoneNodeSchema,
|
ZoneNode as ZoneNodeSchema,
|
||||||
type ZoneNode as ZoneNodeType,
|
type ZoneNode as ZoneNodeType,
|
||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
FloorplanMeasurementsLayer,
|
FloorplanMeasurementsLayer,
|
||||||
type LinearMeasurementOverlay,
|
type LinearMeasurementOverlay,
|
||||||
} from '../editor-2d/renderers/floorplan-measurements-layer'
|
} 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 { FloorplanRoofLayer } from '../editor-2d/renderers/floorplan-roof-layer'
|
||||||
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
||||||
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
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
|
<FloorplanMarqueeLayer
|
||||||
bounds={visibleSvgMarqueeBounds}
|
bounds={visibleSvgMarqueeBounds}
|
||||||
cursorColor={palette.cursor}
|
cursorColor={palette.cursor}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { NodeDefinition } from '@pascal-app/core'
|
import type { NodeDefinition } from '@pascal-app/core'
|
||||||
|
import { buildShelfFloorplan } from './floorplan'
|
||||||
import { buildShelfGeometry } from './geometry'
|
import { buildShelfGeometry } from './geometry'
|
||||||
import { shelfParametrics } from './parametrics'
|
import { shelfParametrics } from './parametrics'
|
||||||
import { ShelfNode } from './schema'
|
import { ShelfNode } from './schema'
|
||||||
@@ -43,14 +44,15 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
|||||||
|
|
||||||
parametrics: shelfParametrics,
|
parametrics: shelfParametrics,
|
||||||
|
|
||||||
// Three-checkbox composition: shelf needs only a pure geometry function.
|
// Three-checkbox composition: shelf needs only pure builder functions.
|
||||||
// The framework's <ParametricNodeRenderer> mounts an empty group + wires
|
// The framework's <ParametricNodeRenderer> + <GeometrySystem> handle 3D
|
||||||
// events / registry / dirty-on-mount; the global <GeometrySystem> calls
|
// mount and rebuild on dirty; the <FloorplanRegistryLayer> calls
|
||||||
// `buildShelfGeometry(node)` on every dirty mark and swaps the group's
|
// buildShelfFloorplan for the 2D top-down view. No renderer.tsx, no
|
||||||
// children. No `renderer.tsx`, no `system.tsx` — see
|
// system.tsx, no inline floor-plan SVG — see
|
||||||
// `wiki/architecture/node-definitions.md`. Shelf is the reference port
|
// `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,
|
geometry: buildShelfGeometry,
|
||||||
|
floorplan: buildShelfFloorplan,
|
||||||
|
|
||||||
preview: () => import('./preview'),
|
preview: () => import('./preview'),
|
||||||
tool: () => import('./tool'),
|
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