Phase 5 Stage C: item floor plan — all 9 kinds now at Stage C

Item is the last Stage A-only kind to gain def.floorplan. Closes Stage C
across every registered kind.

Files added:
 - nodes/src/item/floorplan.ts: buildItemFloorplan inlines a self-
   contained parent-chain transform walker using `ctx.resolve`. Mirrors
   the legacy `getItemFloorplanTransform` math from editor/lib/floorplan/
   items.ts:
     * Wall parent: rotate item.position by wall's angle, anchor at
       wall.start, handle wall-side attachTo via wall.thickness offset.
     * Item parent (nested): recurse for parent's transform.
     * Level / slab / ceiling parent: item.position is level-local.
   Returns a rotated width × depth rectangle. asset.floorPlanUrl image
   overlay deferred for Phase 5 follow-up.

Files changed:
 - nodes/src/item/definition.ts: wires `floorplan: buildItemFloorplan`.
 - floorplan-panel.tsx: floorplanItemEntries useMemo short-circuits to
   [] when nodeRegistry.has('item'). Phase 6 deletes the entire useMemo.

Stage C coverage (all 9 registered kinds):
 shelf  spawn  fence  slab  ceiling  wall  door  window  item 

Next sessions: Stage B for door / window / wall (each large geometry
extraction), Stage D per kind (DragAction affordance ports), Stage E
(drop legacy panels), Phase 6 Stage F cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-15 16:50:30 -04:00
co-authored by Claude Opus 4.7
parent 9bcb25d0aa
commit 9aec943740
3 changed files with 145 additions and 17 deletions
+13 -17
View File
@@ -1,4 +1,5 @@
import type { ItemNode as ItemNodeType, NodeDefinition } from '@pascal-app/core'
import { buildItemFloorplan } from './floorplan'
import { itemParametrics } from './parametrics'
import { ItemNode } from './schema'
@@ -18,25 +19,17 @@ import { ItemNode } from './schema'
* placement. The smooth generic mover can't express that. Legacy
* mover keeps running via capability-driven dispatch.
* - `selectable`, `duplicable`, `deletable` standard.
* - Items have a catalog-defined `surface.height` (some items act as
* tables — they expose a surface other items stack on). For Stage A
* we don't surface this via `capabilities.surfaces.top` yet —
* legacy ItemSystem computes the stack y via spatial-grid lookups.
* Phase 5+ may surface it.
*
* Stages:
* - A: registered.
* - B: N/A — def.renderer escape hatch (GLB / useGLTF).
* - C: `def.floorplan` resolves parent chain via `ctx.resolve`,
* returns a rotated rectangle (width × depth). Mirrors the legacy
* `getItemFloorplanTransform` math. Legacy `floorplanItemEntries`
* short-circuits when item is registered.
*
* `toolHints`: matches the legacy ItemHelper UI (mouse / R / T / Shift /
* Esc) — same panel the user sees during placement. Once item registers,
* `HelperManager` consults `def.toolHints` and renders the
* `RegisteredToolHelper` for placement (the legacy ItemHelper still
* renders for movingNode state — that's a generic "you're moving
* something" panel, not item-specific; Phase 5+ may deprecate it).
*
* Renderer + system: wrap-export of legacy ItemRenderer + bundle of
* ItemSystem + ItemLightSystem.
*
* Tool field absent: catalog UI + item-tool placement flow stays on
* editor state. Phase 5+ may port to `DragAction` once the registry's
* catalog-aware affordances exist.
* Esc) — registry-driven placement panel.
*/
export const itemDefinition: NodeDefinition<typeof ItemNode> = {
kind: 'item',
@@ -86,6 +79,9 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
// Same priority as the legacy ItemSystem.
priority: 2,
},
// Stage C: floor-plan polygon. ctx.resolve walks the parent chain
// (wall / nested item / level) to compute the world-space transform.
floorplan: buildItemFloorplan,
toolHints: [
{ key: 'Left click', label: 'Place item' },
+127
View File
@@ -0,0 +1,127 @@
import {
type AnyNode,
type AnyNodeId,
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
getScaledDimensions,
type ItemNode,
} from '@pascal-app/core'
/**
* Stage C floor-plan builder for item.
*
* Items can be parented to a wall, ceiling, slab, or another item.
* Position is in the parent's local frame, so we walk the parent chain
* via `ctx.resolve` to compute the world-space (level-local) transform.
*
* Mirrors `getItemFloorplanTransform` from editor/lib/floorplan/items.ts
* but uses the registry's resolve callback instead of a node map. Logic
* is identical so visual output matches the legacy.
*
* Returns a rotated rectangle of width × depth at the resolved position.
* Phase 5 follow-up may render `asset.floorPlanUrl` as a custom image
* overlay when present.
*/
type Transform = { x: number; y: number; rotation: number }
function rotateVec(x: number, y: number, angle: number): [number, number] {
const c = Math.cos(angle)
const s = Math.sin(angle)
return [x * c - y * s, x * s + y * c]
}
function resolveItemTransform(
item: ItemNode,
ctx: GeometryContext,
cache = new Map<AnyNodeId, Transform | null>(),
): Transform | null {
const cached = cache.get(item.id as AnyNodeId)
if (cached !== undefined) return cached
const localRotation = item.rotation[1] ?? 0
let result: Transform | null = null
const parentNode: AnyNode | undefined = item.parentId
? ctx.resolve(item.parentId as AnyNodeId)
: undefined
if (parentNode?.type === 'wall') {
// Wall-aligned: rotate item.position by wall's angle, anchor at wall.start.
const wall = parentNode as AnyNode & {
start: [number, number]
end: [number, number]
thickness?: number
}
const wallRotation = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
const wallLocalZ =
item.asset.attachTo === 'wall-side'
? ((wall.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
: item.position[2]
const [offsetX, offsetY] = rotateVec(item.position[0], wallLocalZ, wallRotation)
result = {
x: wall.start[0] + offsetX,
y: wall.start[1] + offsetY,
rotation: wallRotation + localRotation,
}
} else if (parentNode?.type === 'item') {
// Nested item: recursively resolve parent's transform.
const parentT = resolveItemTransform(parentNode as ItemNode, ctx, cache)
if (parentT) {
const [offsetX, offsetY] = rotateVec(item.position[0], item.position[2], parentT.rotation)
result = {
x: parentT.x + offsetX,
y: parentT.y + offsetY,
rotation: parentT.rotation + localRotation,
}
}
} else {
// Level / slab / ceiling parent — item.position is level-local.
result = {
x: item.position[0],
y: item.position[2],
rotation: localRotation,
}
}
cache.set(item.id as AnyNodeId, result)
return result
}
export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): FloorplanGeometry | null {
const transform = resolveItemTransform(node, ctx)
if (!transform) return null
const [width, , depth] = getScaledDimensions(node)
if (width <= 0 || depth <= 0) return null
// Wall-side items are anchored at the front face — center their footprint
// half-a-depth back toward the wall surface.
const centerLocalZ = node.asset.attachTo === 'wall-side' ? -depth / 2 : 0
const [centerOffsetX, centerOffsetY] = rotateVec(0, centerLocalZ, transform.rotation)
const cx = transform.x + centerOffsetX
const cy = transform.y + centerOffsetY
// Rectangle corners in local space, rotated and translated.
const halfW = width / 2
const halfD = depth / 2
const corners: Array<[number, number]> = [
[-halfW, -halfD],
[halfW, -halfD],
[halfW, halfD],
[-halfW, halfD],
]
const points: readonly FloorplanPoint[] = corners.map(([x, y]) => {
const [rx, ry] = rotateVec(x, y, transform.rotation)
return [cx + rx, cy + ry] as FloorplanPoint
})
return {
kind: 'polygon',
points,
fill: '#fef3c7',
stroke: '#92400e',
strokeWidth: 0.012,
opacity: 0.85,
}
}