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
+33 -17
View File
@@ -6,7 +6,7 @@ import {
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
roofFacePointToSegment,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
@@ -19,7 +19,9 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace,
resolveRoofWallHit,
snapToHalf,
triggerSFX,
useAlignmentGuides,
@@ -29,7 +31,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../shared/roof-wall-hit'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
@@ -81,6 +82,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
// must restore the roof host.
roofSegmentId: movingWindowNode.roofSegmentId,
roofFace: movingWindowNode.roofFace,
metadata: movingWindowNode.metadata,
}
@@ -242,6 +244,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
markWallDirty(currentWallId)
currentWallId = target.wallId
@@ -328,6 +331,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
wallId: target.wallId,
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -341,6 +345,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
@@ -390,6 +395,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
}
@@ -426,34 +432,36 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
movingWindowNode.height,
)
if (!clamped) return null
const position = roofWallFaceLocalToSegment(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
// FACE-LOCAL storage (u, v, z = 0 → wall mid-plane): the renderer
// mounts the node inside the live face frame, so it tracks segment
// resizes without any re-anchoring.
const position: [number, number, number] = [clamped.u, clamped.v, 0]
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face,
hit.face.id,
clamped.u,
clamped.v,
movingWindowNode.width,
movingWindowNode.height,
movingWindowNode.id,
)
return { hit, position, yaw: hit.face.yaw, valid, roof: event.node as RoofNode }
return { hit, position, valid, roof: event.node as RoofNode }
}
const updateRoofCursor = (target: NonNullable<ReturnType<typeof resolveRoofMoveTarget>>) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
roofCursorPoint.set(target.position[0], target.position[1], target.position[2])
const segLocal = roofFacePointToSegment(
target.hit.segment,
target.hit.face.id,
target.position,
)
roofCursorPoint.set(segLocal[0], segLocal[1], segLocal[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
}
@@ -468,18 +476,20 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
if (currentWallId !== target.hit.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
side: 'front',
parentId: target.hit.segment.id,
wallId: undefined,
roofSegmentId: target.hit.segment.id,
roofFace: target.hit.face.id,
})
markWallDirty(currentWallId)
currentWallId = target.hit.segment.id
} else {
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
roofFace: target.hit.face.id,
})
}
updateRoofCursor(target)
@@ -507,10 +517,11 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const node = WindowNode.parse({
...cloned,
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
side: 'front',
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.hit.face.id,
parentId: segmentId,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
@@ -523,17 +534,19 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
side: 'front',
parentId: segmentId,
wallId: undefined,
roofSegmentId: segmentId,
roofFace: target.hit.face.id,
metadata: {},
})
@@ -571,6 +584,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
}
@@ -588,6 +602,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
@@ -625,6 +640,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)