Files
editor/packages/nodes/src/item/floorplan.ts
T
Wassim SAMADandClaude Opus 4.7 9aec943740 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>
2026-05-15 16:50:30 -04:00

128 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}