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
@@ -36,12 +36,20 @@ export const ItemSystem = () => {
if (!mesh) return
if (item.asset.attachTo === 'wall-side') {
// Wall-attached item: offset Z by half the parent wall's thickness
const parentWall = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
if (parentWall && parentWall.type === 'wall') {
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
// Wall-attached item: offset Z by half the host wall's thickness.
// Roof-segment wall faces share the convention — the face frame's
// z = 0 is the wall mid-plane, so the same push lands the item on
// the outer surface.
const parent = item.parentId ? nodes[item.parentId as AnyNodeId] : undefined
const thickness =
parent?.type === 'wall'
? ((parent as WallNode).thickness ?? 0.1)
: parent?.type === 'roof-segment'
? (parent.wallThickness ?? 0.1)
: undefined
if (thickness !== undefined) {
const side = item.side === 'front' ? 1 : -1
mesh.position.z = (wallThickness / 2) * side
mesh.position.z = (thickness / 2) * side
}
}
+103 -76
View File
@@ -113,7 +113,10 @@ export const RoofSystem = () => {
// Kinds with `cascadesViaHostSegment` (door / window) reach the roof
// through their own geometry system's parentId cascade instead —
// their dirty marks belong to that system, not to this loop.
if (def?.capabilities?.roofAccessory && !def.capabilities.roofAccessory.cascadesViaHostSegment) {
if (
def?.capabilities?.roofAccessory &&
!def.capabilities.roofAccessory.cascadesViaHostSegment
) {
const segId = (node as { roofSegmentId?: string }).roofSegmentId
const seg = segId ? (nodes[segId as AnyNodeId] as RoofSegmentNode | undefined) : undefined
if (seg?.parentId) {
@@ -145,7 +148,7 @@ export const RoofSystem = () => {
mesh.parent?.name === 'segments-wrapper' &&
mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true
if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
updateRoofSegmentGeometry(effectiveSegment, mesh)
updateRoofSegmentGeometry(effectiveSegment, mesh, nodes)
segmentsProcessed++
} else if (isVisible && !revealOnly) {
return // Over budget — keep dirty, process next frame
@@ -231,8 +234,12 @@ export const RoofSystem = () => {
// GEOMETRY GENERATION
// ============================================================================
function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) {
const newGeo = generateRoofSegmentGeometry(node)
function updateRoofSegmentGeometry(
node: RoofSegmentNode,
mesh: THREE.Mesh,
nodes?: Record<string, AnyNode>,
) {
const newGeo = generateRoofSegmentGeometry(node, nodes)
mesh.geometry.dispose()
mesh.geometry = newGeo
@@ -242,6 +249,89 @@ function updateRoofSegmentGeometry(node: RoofSegmentNode, mesh: THREE.Mesh) {
mesh.rotation.y = node.rotation
}
/**
* Subtract every hosted accessory cut (`capabilities.roofAccessory.
* buildCut`) from a segment's brushes, in SEGMENT-LOCAL space. Shared by
* the merged-shell path AND the per-segment path (full edit mode /
* painted segments) — without the latter, selecting a segment used to
* swap the merged shell for uncut per-segment meshes and every door /
* window / skylight hole vanished until deselect. Children are read
* live-effective so an in-flight handle drag carves the live hole.
* Registry-driven so the viewer never names a kind.
*/
function subtractAccessoryCuts(
brushes: { deckSlab: Brush; shinSlab: Brush; wallBrush: Brush; innerBrush: Brush },
segment: RoofSegmentNode,
nodes: Record<string, AnyNode>,
) {
let workingShin = brushes.shinSlab
let workingDeck = brushes.deckSlab
let workingWall = brushes.wallBrush
for (const childElemId of segment.children ?? []) {
const storedChild = nodes[childElemId as AnyNodeId]
if (!storedChild) continue
const childElem = getEffectiveNode(storedChild)
const meta =
typeof childElem.metadata === 'object' && childElem.metadata !== null
? (childElem.metadata as Record<string, unknown>)
: undefined
if (meta?.isTransient) continue
const childDef = nodeRegistry.get(childElem.type)
const buildCut = childDef?.capabilities?.roofAccessory?.buildCut
if (!buildCut) continue
const cutGeo = buildCut(childElem, segment)
if (!cutGeo) continue
// Wrap the kind-emitted geometry in a Brush. Kinds return raw
// shapes; the viewer welds (mandatory after rotations leave
// duplicated verts), attaches a single material group, and
// builds the bounds tree — keeping kind code free of
// three-bvh-csg / three-mesh-bvh imports.
const welded = mergeVertices(cutGeo, 1e-4)
cutGeo.dispose()
const idxCount = welded.getIndex()?.count ?? 0
if (idxCount === 0) {
welded.dispose()
continue
}
welded.clearGroups()
welded.addGroup(0, idxCount, 0)
welded.computeVertexNormals()
computeGeometryBoundsTree(welded)
const cut = new Brush(welded, dummyMats[0])
cut.updateMatrixWorld()
const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all'
try {
if (cutScope !== 'wall') {
const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush
workingShin.geometry.dispose()
prepareBrushForCSG(nextShin)
workingShin = nextShin
const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush
workingDeck.geometry.dispose()
prepareBrushForCSG(nextDeck)
workingDeck = nextDeck
}
const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush
workingWall.geometry.dispose()
prepareBrushForCSG(nextWall)
workingWall = nextWall
} catch (e) {
console.error(`[${childElem.type}] cut CSG failed:`, e)
} finally {
cut.geometry.dispose()
}
}
brushes.shinSlab = workingShin
brushes.deckSlab = workingDeck
brushes.wallBrush = workingWall
}
function updateMergedRoofGeometry(
roofNode: RoofNode,
group: THREE.Group,
@@ -282,77 +372,7 @@ function updateMergedRoofGeometry(
const brushes = getRoofSegmentBrushes(child)
if (!brushes) continue
// Per-child cuts in SEGMENT-LOCAL space: subtract every accessory
// that contributes a cut (declares
// `capabilities.roofAccessory.buildCut`) from shin / deck / wall
// before we accumulate. Mirrors roof-system v1 — the cut is built
// in segment-local, then carved out before the segment transform
// stacks on. Registry-driven so the viewer never names a kind.
let workingShin = brushes.shinSlab
let workingDeck = brushes.deckSlab
let workingWall = brushes.wallBrush
for (const childElemId of child.children ?? []) {
const childElem = nodes[childElemId as AnyNodeId]
if (!childElem) continue
const meta =
typeof childElem.metadata === 'object' && childElem.metadata !== null
? (childElem.metadata as Record<string, unknown>)
: undefined
if (meta?.isTransient) continue
const childDef = nodeRegistry.get(childElem.type)
const buildCut = childDef?.capabilities?.roofAccessory?.buildCut
if (!buildCut) continue
const cutGeo = buildCut(childElem, child)
if (!cutGeo) continue
// Wrap the kind-emitted geometry in a Brush. Kinds return raw
// shapes; the viewer welds (mandatory after rotations leave
// duplicated verts), attaches a single material group, and
// builds the bounds tree — keeping kind code free of
// three-bvh-csg / three-mesh-bvh imports.
const welded = mergeVertices(cutGeo, 1e-4)
cutGeo.dispose()
const idxCount = welded.getIndex()?.count ?? 0
if (idxCount === 0) {
welded.dispose()
continue
}
welded.clearGroups()
welded.addGroup(0, idxCount, 0)
welded.computeVertexNormals()
computeGeometryBoundsTree(welded)
const cut = new Brush(welded, dummyMats[0])
cut.updateMatrixWorld()
const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all'
try {
if (cutScope !== 'wall') {
const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION) as Brush
workingShin.geometry.dispose()
prepareBrushForCSG(nextShin)
workingShin = nextShin
const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION) as Brush
workingDeck.geometry.dispose()
prepareBrushForCSG(nextDeck)
workingDeck = nextDeck
}
const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION) as Brush
workingWall.geometry.dispose()
prepareBrushForCSG(nextWall)
workingWall = nextWall
} catch (e) {
console.error(`[${childElem.type}] cut CSG failed:`, e)
} finally {
cut.geometry.dispose()
}
}
brushes.shinSlab = workingShin
brushes.deckSlab = workingDeck
brushes.wallBrush = workingWall
subtractAccessoryCuts(brushes, child, nodes)
_matrix.compose(
_position.set(child.position[0], child.position[1], child.position[2]),
@@ -813,13 +833,20 @@ export function getRoofSegmentBrushes(
return null
}
export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.BufferGeometry {
export function generateRoofSegmentGeometry(
node: RoofSegmentNode,
nodes?: Record<string, AnyNode>,
): THREE.BufferGeometry {
const brushes = getRoofSegmentBrushes(node)
if (!brushes) {
// Fallback: simple box
return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth)
}
if (nodes) {
subtractAccessoryCuts(brushes, node, nodes)
}
const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes
let resultGeo = new THREE.BufferGeometry()