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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user