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>
This commit is contained in:
Aymeric Rabot
2026-06-10 14:21:14 -04:00
co-authored by Claude Fable 5
parent 5fd9be05df
commit 7879b83df3
35 changed files with 1131 additions and 315 deletions
+2 -2
View File
@@ -115,8 +115,8 @@ export {
getMaxRoofRectWidthFromAnchor,
getRoofSegmentWallFace,
getRoofSegmentWallFaces,
getRoofWallFaceIdFromYaw,
roofWallFaceLocalToSegment,
getRoofWallFaceFrame,
roofFacePointToSegment,
segmentPointToRoofWallFace,
} from './nodes/roof-segment-walls'
export { ScanNode } from './nodes/scan'
+6 -3
View File
@@ -47,10 +47,13 @@ export const DoorNode = BaseNode.extend({
side: z.enum(['front', 'back']).optional(),
wallId: z.string().optional(),
// Alternative host: a roof-segment's generated wall face (base wall
// under the roof or a coplanar gable end). When set, `position` is the
// opening center in SEGMENT-LOCAL coords on the outer wall plane and
// `rotation[1]` is the face yaw — see `roof-segment-walls.ts`.
// under the roof or a coplanar gable end). When set, `position` is
// FACE-LOCAL — [u along the face, v height, z from the wall mid-plane]
// — exactly the wall-child convention; the renderer mounts the node
// inside the face frame (`getRoofWallFaceFrame`), which is what makes
// hosted children track segment resizes live.
roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Overall dimensions
width: z.number().default(0.9),
+7
View File
@@ -135,6 +135,13 @@ export const ItemNode = BaseNode.extend({
// Wall attachment properties (only used when asset.attachTo is "wall" or "wall-side")
wallId: z.string().optional(),
wallT: z.number().optional(), // 0-1 parametric position along wall
// Alternative wall host: a roof-segment's generated wall face. When
// set, `position` is FACE-LOCAL — [u along the face, v = bottom edge,
// z from the wall mid-plane] — exactly the wall-child convention
// (ItemSystem's wall-side push applies the same way); the renderer
// mounts the node inside the face frame (`getRoofWallFaceFrame`).
roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Denormalized references to collections this node belongs to
collectionIds: z.array(z.custom<CollectionId>()).optional(),
@@ -0,0 +1,78 @@
import { describe, expect, test } from 'bun:test'
import { RoofSegmentNode } from './roof-segment'
import {
getRoofSegmentWallFace,
getRoofWallFaceFrame,
roofFacePointToSegment,
segmentPointToRoofWallFace,
} from './roof-segment-walls'
function segment(overrides: Partial<RoofSegmentNode> = {}): RoofSegmentNode {
return RoofSegmentNode.parse({
id: 'rseg_test',
type: 'roof-segment',
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 2.6,
wallThickness: 0.1,
pitch: 40,
...overrides,
})
}
describe('roof wall face frames', () => {
test('frame z = 0 lands on the nominal footprint (wall mid-plane)', () => {
const seg = segment()
// front face, u at the face middle, v = 1, mid-plane.
const point = roofFacePointToSegment(seg, 'front', [(8 + 0.1) / 2, 1, 0])
expect(point[0]).toBeCloseTo(0)
expect(point[1]).toBeCloseTo(1)
expect(point[2]).toBeCloseTo(3) // depth / 2 — the footprint plane
})
test('frame +z is the outward normal on every face', () => {
const seg = segment()
for (const [faceId, axis, sign] of [
['front', 2, 1],
['back', 2, -1],
['right', 0, 1],
['left', 0, -1],
] as const) {
const onPlane = roofFacePointToSegment(seg, faceId, [1, 1, 0])
const pushed = roofFacePointToSegment(seg, faceId, [1, 1, 0.5])
expect(pushed[axis] - onPlane[axis]).toBeCloseTo(0.5 * sign)
// The other horizontal axis is unaffected by the push.
const other = axis === 2 ? 0 : 2
expect(pushed[other] - onPlane[other]).toBeCloseTo(0)
}
})
test('face frame agrees with the hit resolver coordinates', () => {
const seg = segment()
// A point on the outer surface (z = +thickness/2 off the mid-plane)
// must read back with the same u/v and dist ≈ 0 off the outer plane.
const segLocal = roofFacePointToSegment(seg, 'right', [2.5, 1.25, 0.05])
const { u, v, dist } = segmentPointToRoofWallFace(seg, 'right', segLocal)
expect(u).toBeCloseTo(2.5)
expect(v).toBeCloseTo(1.25)
expect(dist).toBeCloseTo(0)
})
test('resizing the segment moves the frame, not the stored coords', () => {
// The core live-tracking property: the same face-local point maps to
// the new plane after a depth change — children follow by re-render.
const before = roofFacePointToSegment(segment(), 'front', [2, 1, 0])
const after = roofFacePointToSegment(segment({ depth: 8 }), 'front', [2, 1, 0])
expect(before[2]).toBeCloseTo(3)
expect(after[2]).toBeCloseTo(4)
expect(after[1]).toBeCloseTo(before[1])
})
test('frame yaw matches the face descriptor yaw', () => {
const seg = segment()
for (const faceId of ['front', 'back', 'right', 'left'] as const) {
expect(getRoofWallFaceFrame(seg, faceId).yaw).toBe(getRoofSegmentWallFace(seg, faceId).yaw)
}
})
})
@@ -206,32 +206,6 @@ export function getRoofSegmentWallFaces(node: SegmentWallInputs): RoofSegmentWal
}))
}
/**
* Face coords → segment-local point on the outer wall plane. `inset`
* pushes the point inward along the face normal — openings store their
* center at the wall mid-plane (`inset = wallThickness / 2`) so the
* frame assembly centers inside the wall like on a regular wall host.
*/
export function roofWallFaceLocalToSegment(
node: SegmentWallInputs,
id: RoofWallFaceId,
u: number,
v: number,
inset = 0,
): [number, number, number] {
const { wV, dV } = getWallVolumeFrame(node)
switch (id) {
case 'front':
return [u - wV / 2, v, dV / 2 - inset]
case 'back':
return [wV / 2 - u, v, -dV / 2 + inset]
case 'right':
return [wV / 2 - inset, v, dV / 2 - u]
case 'left':
return [-wV / 2 + inset, v, u - dV / 2]
}
}
/**
* Segment-local point → face coords. `dist` is the signed offset off the
* outer wall plane along the face normal (0 = on the plane, positive =
@@ -301,16 +275,47 @@ function getRectCenterConstraints(
}))
}
/** Face id for an opening's stored yaw (`rotation[1]`), or null. */
export function getRoofWallFaceIdFromYaw(yaw: number): RoofWallFaceId | null {
const tau = Math.PI * 2
const normalized = ((yaw % tau) + tau) % tau
const eps = 1e-3
if (normalized < eps || tau - normalized < eps) return 'front'
if (Math.abs(normalized - Math.PI) < eps) return 'back'
if (Math.abs(normalized - Math.PI / 2) < eps) return 'right'
if (Math.abs(normalized - (3 * Math.PI) / 2) < eps) return 'left'
return null
/**
* The face's render frame in segment-local space: a group placed at
* `origin` and yawed by `yaw` maps face coords to segment space —
* frame X = U (along the face), frame Y = V (height), frame Z = the
* outward normal, with z = 0 on the WALL MID-PLANE. The mid-plane of
* the generated wall volume lands exactly on the nominal footprint
* (`±width/2` / `±depth/2`), so hosted children use the same position
* conventions as wall children (openings at z = 0, wall-side items
* pushed +thickness/2 at render time). Renderers derive this from the
* live-override-merged segment, which is what makes hosted children
* track segment edits live instead of jumping on commit.
*/
export function getRoofWallFaceFrame(
node: SegmentWallInputs,
id: RoofWallFaceId,
): { origin: [number, number, number]; yaw: number } {
const { wV, dV } = getWallVolumeFrame(node)
switch (id) {
case 'front':
return { origin: [-wV / 2, 0, node.depth / 2], yaw: FACE_YAWS.front }
case 'back':
return { origin: [wV / 2, 0, -node.depth / 2], yaw: FACE_YAWS.back }
case 'right':
return { origin: [node.width / 2, 0, dV / 2], yaw: FACE_YAWS.right }
case 'left':
return { origin: [-node.width / 2, 0, -dV / 2], yaw: FACE_YAWS.left }
}
}
/** Face-frame point ([u, v, z-from-mid-plane]) → segment-local point. */
export function roofFacePointToSegment(
node: SegmentWallInputs,
id: RoofWallFaceId,
point: [number, number, number],
): [number, number, number] {
const { origin, yaw } = getRoofWallFaceFrame(node, id)
const cos = Math.cos(yaw)
const sin = Math.sin(yaw)
const [u, v, z] = point
// rotation-y: +x → (cos, 0, -sin), +z → (sin, 0, cos)
return [origin[0] + u * cos + z * sin, origin[1] + v, origin[2] - u * sin + z * cos]
}
/**
+6 -3
View File
@@ -29,10 +29,13 @@ export const WindowNode = BaseNode.extend({
// Wall reference
wallId: z.string().optional(),
// Alternative host: a roof-segment's generated wall face (base wall
// under the roof or a coplanar gable end). When set, `position` is the
// opening center in SEGMENT-LOCAL coords on the outer wall plane and
// `rotation[1]` is the face yaw — see `roof-segment-walls.ts`.
// under the roof or a coplanar gable end). When set, `position` is
// FACE-LOCAL — [u along the face, v height, z from the wall mid-plane]
// — exactly the wall-child convention; the renderer mounts the node
// inside the face frame (`getRoofWallFaceFrame`), which is what makes
// hosted children track segment resizes live.
roofSegmentId: z.string().optional(),
roofFace: z.enum(['front', 'back', 'right', 'left']).optional(),
// Overall dimensions
width: z.number().default(1.5),
+50
View File
@@ -14,6 +14,7 @@ import {
type RoofSegmentNode,
type RoofType,
} from '../schema/nodes/roof-segment'
import { segmentPointToRoofWallFace } from '../schema/nodes/roof-segment-walls'
import { ShelfNode as ShelfNodeSchema } from '../schema/nodes/shelf'
import { SiteNode } from '../schema/nodes/site'
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
@@ -539,6 +540,55 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
patchedNodes[id] = { ...node, children: [] } as AnyNode
}
// Roof-hosted wall children (door / window / item) originally stored
// SEGMENT-LOCAL positions with the face yaw in rotation[1]; the
// format moved to explicit `roofFace` + FACE-LOCAL coords so the
// renderer's face frame can track segment edits live. Convert in
// place: face from the old cardinal yaw, u/v from the outer-plane
// projection, z re-based from the outer plane to the wall mid-plane.
if (
(node.type === 'door' || node.type === 'window' || node.type === 'item') &&
typeof (node as { roofSegmentId?: unknown }).roofSegmentId === 'string' &&
(node as { roofFace?: unknown }).roofFace === undefined
) {
const current = patchedNodes[id] as AnyNode & {
roofSegmentId: string
position: [number, number, number]
rotation: [number, number, number]
}
const segment = patchedNodes[current.roofSegmentId] as
| (AnyNode & { wallThickness?: number })
| undefined
if (segment?.type === 'roof-segment') {
const tau = Math.PI * 2
const yaw = (((current.rotation?.[1] ?? 0) % tau) + tau) % tau
const eps = 1e-3
const face =
yaw < eps || tau - yaw < eps
? ('front' as const)
: Math.abs(yaw - Math.PI) < eps
? ('back' as const)
: Math.abs(yaw - Math.PI / 2) < eps
? ('right' as const)
: Math.abs(yaw - (3 * Math.PI) / 2) < eps
? ('left' as const)
: null
if (face) {
const { u, v, dist } = segmentPointToRoofWallFace(
segment as never,
face,
current.position,
)
patchedNodes[id] = {
...current,
roofFace: face,
position: [u, v, dist + (segment.wallThickness ?? 0.1) / 2],
rotation: [0, 0, 0],
} as AnyNode
}
}
}
if (node.type === 'roof') {
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
}