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:
co-authored by
Claude Fable 5
parent
5fd9be05df
commit
7879b83df3
@@ -115,8 +115,8 @@ export {
|
||||
getMaxRoofRectWidthFromAnchor,
|
||||
getRoofSegmentWallFace,
|
||||
getRoofSegmentWallFaces,
|
||||
getRoofWallFaceIdFromYaw,
|
||||
roofWallFaceLocalToSegment,
|
||||
getRoofWallFaceFrame,
|
||||
roofFacePointToSegment,
|
||||
segmentPointToRoofWallFace,
|
||||
} from './nodes/roof-segment-walls'
|
||||
export { ScanNode } from './nodes/scan'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user