diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 70771733..8aa61671 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -8843,6 +8843,11 @@ export function FloorplanPanel() { }) }, [movingFloorplanNodeRevision, spawns]) const floorplanItemEntries = useMemo(() => { + // Item migrated to def.floorplan (Phase 5 Stage C). When registered, + // FloorplanRegistryLayer renders the item rectangle via the + // parent-chain transform walker; this legacy path short-circuits. + // Removed entirely in Phase 6 cleanup. + if (nodeRegistry.has('item')) return [] const transformCache = new Map() return floorplanItems.flatMap((item) => { diff --git a/packages/nodes/src/item/definition.ts b/packages/nodes/src/item/definition.ts index 0f9217f7..291fc20f 100644 --- a/packages/nodes/src/item/definition.ts +++ b/packages/nodes/src/item/definition.ts @@ -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 = { kind: 'item', @@ -86,6 +79,9 @@ export const itemDefinition: NodeDefinition = { // 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' }, diff --git a/packages/nodes/src/item/floorplan.ts b/packages/nodes/src/item/floorplan.ts new file mode 100644 index 00000000..1d9568b8 --- /dev/null +++ b/packages/nodes/src/item/floorplan.ts @@ -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(), +): 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, + } +}