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
@@ -1,28 +1,29 @@
import type { RoofSegmentNode } from '@pascal-app/core'
import type { RoofSegmentNode, RoofWallFaceId } from '@pascal-app/core'
import { getRoofWallFaceFrame, roofFacePointToSegment } from '@pascal-app/core'
import * as THREE from 'three'
type RoofWallOpening = {
roofSegmentId?: string
roofFace?: RoofWallFaceId
position: [number, number, number]
rotation: [number, number, number]
width: number
height: number
}
/**
* CSG cut for a door / window hosted on a roof-segment wall face
* (`capabilities.roofAccessory.buildCut`). A box through the wall plane,
* oriented by the opening's face yaw, in segment-local coords — the
* roof-merge loop subtracts it from the segment's wall brush.
* (`capabilities.roofAccessory.buildCut`). A box through the wall
* mid-plane, derived from the CURRENT host geometry (the opening stores
* face-local coords), so the hole follows segment resizes for free.
*
* Returns null for wall-hosted openings (no `roofSegmentId`): their cut
* is handled by the wall system's own cutout pipeline.
* Returns null for wall-hosted openings: their cut is handled by the
* wall system's own cutout pipeline.
*/
export function buildRoofWallOpeningCut(
node: RoofWallOpening,
hostSegment: RoofSegmentNode,
): THREE.BufferGeometry | null {
if (!node.roofSegmentId) return null
if (!node.roofSegmentId || !node.roofFace) return null
const wallThickness = hostSegment.wallThickness ?? 0.1
// Through the wall both ways, but well short of the rake/eave overhang
@@ -34,9 +35,16 @@ export function buildRoofWallOpeningCut(
const bottom = node.position[1] - node.height / 2
const bottomPad = bottom < 0.005 ? 0.02 : 0
const center = roofFacePointToSegment(hostSegment, node.roofFace, [
node.position[0],
node.position[1],
0,
])
const { yaw } = getRoofWallFaceFrame(hostSegment, node.roofFace)
const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth)
geo.translate(0, -bottomPad / 2, 0)
geo.rotateY(node.rotation[1] ?? 0)
geo.translate(node.position[0], node.position[1], node.position[2])
geo.rotateY(yaw)
geo.translate(center[0], center[1], center[2])
return geo
}