Files
editor/packages/nodes/src/item/floorplan.ts
T
Aymeric RabotandClaude Fable 5 7879b83df3 feat: face-frame hosting for roof wall children + items on roof walls
Wall-mounted items join doors/windows on roof-segment wall faces, and
the storage model moves to FACE-LOCAL coordinates so hosted children
track segment edits live.

- Children store roofFace + position [u, v, z-from-mid-plane] with
  rotation 0 in-frame — the exact wall-child conventions (the wall
  volume's mid-plane lands on the nominal footprint). A shared
  <RoofFaceHostFrame> derives segment pose + face frame from the
  live-override-merged segment: children follow resize handle drags in
  real time and never jump on commit. No re-anchor cascade needed —
  position is authoritative, the frame is derived. migrateNodes
  converts branch-era segment-local data.
- Items: roofWallStrategy + roof:* handlers in the placement
  coordinator (surface 'roof-wall'), Shift free-place normalized with
  walls, ItemSystem wall-side push extended to segment hosts, correct
  2D plan glyphs via face→segment→roof pose composition. The roof
  hit resolver + overlap guard moved to @pascal-app/editor (the
  coordinator lives there; nodes already depends on editor).
- Cuts: subtractAccessoryCuts extracted and applied in BOTH the
  merged-shell and per-segment CSG paths (full edit mode / painted
  segments used to lose every hole), built from the CURRENT host
  geometry and live-effective children so holes follow segment and
  opening drags.
- Handle rig: the grandparent portal now maps the node's world pose
  into the portal frame instead of composing parent+node registry
  poses — correct for any nesting (the face-frame group broke the
  old assumption), identical for walls.
- Host-field hygiene: useDraftNode.commit/adopt and the window panel
  duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall
  re-anchor clears and every revert restores them.

Codex-reviewed (design consultation, adversarial rounds on the
replaced cascade and on this refactor); frame conventions locked by
unit tests. Record: private-editor plans/editor-roof-wall-openings.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:21:14 -04:00

226 lines
8.5 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,
getRoofWallFaceFrame,
getScaledDimensions,
type ItemNode,
type RoofSegmentNode,
roofFacePointToSegment,
useLiveTransforms,
} 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 }
// Plan-space rotation convention used by the legacy `rotatePlanVector`
// in `editor/src/lib/floorplan/geometry.ts`. This is a CLOCKWISE rotation
// — the registry-side equivalent of the canonical floor-plan transform
// math. Don't switch to a standard counter-clockwise rotation; the wall
// items math (wallRotation = -atan2(dy, dx)) is calibrated against this
// convention, and the legacy item floor-plan stack reads from these
// offsets across many sites.
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 if (parentNode?.type === 'shelf') {
// Shelf-hosted item: `item.position` is in shelf-local coords. The
// shelf has its own `position` + `rotation[1]` in its parent (level)
// frame, so the item's plan-space position composes the shelf's
// pose with the item's local offset. Without this branch the
// `else` below would treat shelf-local coords as level-local and
// the item would render at the wrong spot whenever the shelf is
// anywhere other than (0, 0, 0).
//
// We also check `useLiveTransforms` for the shelf — if the shelf is
// mid-move (3D or 2D), its scene-state `position` is still at the
// pre-move spot but the live transform carries the cursor-tracked
// position. Reading the live value here keeps the hosted item
// following the shelf in 2D throughout the drag, mirroring how the
// shelf's own entry follows via the layer's effectiveNode override.
const shelf = parentNode as AnyNode & {
position: [number, number, number]
rotation: [number, number, number]
}
const live = useLiveTransforms.getState().get(shelf.id as AnyNodeId)
const shelfX = live?.position[0] ?? shelf.position[0]
const shelfZ = live?.position[2] ?? shelf.position[2]
const shelfRotationY = live?.rotation ?? shelf.rotation[1] ?? 0
const [offsetX, offsetY] = rotateVec(item.position[0], item.position[2], shelfRotationY)
result = {
x: shelfX + offsetX,
y: shelfZ + offsetY,
rotation: shelfRotationY + localRotation,
}
} else if (parentNode?.type === 'roof-segment') {
// Roof-hosted wall item: FACE-LOCAL position mapped through the face
// frame, then composed through the segment's and parent roof's poses
// into level-local plan coords.
const segment = parentNode as RoofSegmentNode
const roof = segment.parentId
? (ctx.resolve(segment.parentId as AnyNodeId) as
| (AnyNode & { position: [number, number, number]; rotation: number })
| undefined)
: undefined
if (roof?.type === 'roof' && item.roofFace) {
const frame = getRoofWallFaceFrame(segment, item.roofFace)
const segLocal = roofFacePointToSegment(segment, item.roofFace, item.position)
const [sx, sz] = rotateVec(segLocal[0], segLocal[2], segment.rotation ?? 0)
const [rx, rz] = rotateVec(
sx + segment.position[0],
sz + segment.position[2],
roof.rotation ?? 0,
)
result = {
x: rx + roof.position[0],
y: rz + roof.position[2],
rotation: (roof.rotation ?? 0) + (segment.rotation ?? 0) + frame.yaw + 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
})
const isSelected = ctx.viewState?.selected ?? false
const floorPlanUrl = node.asset.floorPlanUrl
const children: FloorplanGeometry[] = [
{
kind: 'polygon',
points,
// When an asset thumbnail is present, the polygon is a transparent
// hit-target — the image carries the visual weight. Without a
// thumbnail the polygon needs a light fill to read at all.
//
// `transparent` (not `none`) so the interior remains hit-testable —
// the registry layer's wrapping `<g>` only fires onPointerDown when
// the child renders a paintable surface. `fill="none"` would make
// clicks pass through to whatever's beneath, breaking selection.
fill: floorPlanUrl ? 'transparent' : '#fef3c7',
stroke: '#92400e',
strokeWidth: 0.012,
opacity: 0.85,
},
]
// Asset thumbnail — top-down PNG capture from the asset modal. Drawn
// inside the footprint with the item's rotation applied. Matches the
// legacy `FloorplanItemImage` overlay.
if (floorPlanUrl) {
children.push({
kind: 'image',
url: floorPlanUrl,
center: [cx, cy],
width,
height: depth,
rotation: transform.rotation,
})
}
// Move handle — orange dot at the item center. Only when selected.
if (isSelected) {
children.push({
kind: 'move-handle',
point: [cx, cy],
})
}
return { kind: 'group', children }
}