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
@@ -0,0 +1,53 @@
'use client'
import {
type AnyNodeId,
getRoofWallFaceFrame,
type RoofSegmentNode,
type RoofWallFaceId,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { type ReactNode, useMemo } from 'react'
/**
* Mounts a roof-hosted wall child inside its host face frame. Children
* of roof segments render under the roof's `roof-elements` group (roof
* frame); this wrapper applies the segment transform plus the face
* frame, both derived from the LIVE-override-merged segment — hosted
* nodes therefore track segment handle drags in real time instead of
* jumping to their new spot on commit. Inside the frame, children use
* plain wall-child position conventions ([u, v, z-from-mid-plane]).
*/
export function RoofFaceHostFrame({
roofSegmentId,
roofFace,
children,
}: {
roofSegmentId: string
roofFace: RoofWallFaceId | undefined
children: ReactNode
}) {
const storeSegment = useScene(
(state) => state.nodes[roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined,
)
const liveOverride = useLiveNodeOverrides((s) => s.get(roofSegmentId as AnyNodeId))
const segment = useMemo(
() =>
storeSegment && liveOverride
? ({ ...storeSegment, ...liveOverride } as RoofSegmentNode)
: storeSegment,
[storeSegment, liveOverride],
)
if (!segment || segment.type !== 'roof-segment' || !roofFace) return null
const frame = getRoofWallFaceFrame(segment, roofFace)
return (
<group position={segment.position} rotation-y={segment.rotation}>
<group position={frame.origin} rotation-y={frame.yaw}>
{children}
</group>
</group>
)
}
+33 -21
View File
@@ -1,23 +1,29 @@
import type { AnyNode, AnyNodeId, RoofNode, RoofSegmentNode } from '@pascal-app/core'
import type {
AnyNode,
AnyNodeId,
RoofNode,
RoofSegmentNode,
RoofWallFaceId,
} from '@pascal-app/core'
import {
getMaxRoofRectHeightFromAnchor,
getMaxRoofRectWidthFromAnchor,
getRoofSegmentWallFace,
getRoofWallFaceIdFromYaw,
segmentPointToRoofWallFace,
roofFacePointToSegment,
} from '@pascal-app/core'
/**
* Host-side helpers for openings (door / window) hosted on a roof-segment
* wall face: resize-handle limits derived from the face profile, and the
* plan-space anchors the 2D floor-plan move path needs.
* plan-space anchors the 2D floor-plan move path needs. Hosted children
* store FACE-LOCAL coords ([u, v, z-from-mid-plane]) + `roofFace`.
*/
type RoofHostedOpening = {
roofSegmentId?: string
roofFace?: RoofWallFaceId
parentId: string | null
position: [number, number, number]
rotation: [number, number, number]
width: number
height: number
}
@@ -25,14 +31,10 @@ type RoofHostedOpening = {
type SceneReader = { get: (id: AnyNodeId) => unknown }
function resolveHostFace(node: RoofHostedOpening, scene: SceneReader) {
if (!node.roofSegmentId) return null
if (!(node.roofSegmentId && node.roofFace)) return null
const segment = scene.get(node.roofSegmentId as AnyNodeId) as RoofSegmentNode | undefined
if (!segment || segment.type !== 'roof-segment') return null
const faceId = getRoofWallFaceIdFromYaw(node.rotation[1])
if (!faceId) return null
const face = getRoofSegmentWallFace(segment, faceId)
const { u, v } = segmentPointToRoofWallFace(segment, faceId, node.position)
return { segment, face, u, v }
return { segment, face: getRoofSegmentWallFace(segment, node.roofFace) }
}
/**
@@ -47,8 +49,8 @@ export function readRoofFaceWidthMax(
): number | null {
const host = resolveHostFace(node, scene)
if (!host) return null
const anchorU = host.u - (growSign * node.width) / 2
return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, host.v, node.height)
const anchorU = node.position[0] - (growSign * node.width) / 2
return getMaxRoofRectWidthFromAnchor(host.face, anchorU, growSign, node.position[1], node.height)
}
/**
@@ -63,8 +65,8 @@ export function readRoofFaceHeightMax(
): number | null {
const host = resolveHostFace(node, scene)
if (!host) return null
const anchorV = host.v - (growSign * node.height) / 2
return getMaxRoofRectHeightFromAnchor(host.face, host.u, node.width, anchorV, growSign)
const anchorV = node.position[1] - (growSign * node.height) / 2
return getMaxRoofRectHeightFromAnchor(host.face, node.position[0], node.width, anchorV, growSign)
}
/**
@@ -72,7 +74,7 @@ export function readRoofFaceHeightMax(
* level). Null when the parent chain isn't roof-shaped.
*/
export function getRoofHostedOpeningLevelId(
node: RoofHostedOpening,
node: { parentId: string | null },
nodes: Record<string, AnyNode | undefined>,
): AnyNodeId | null {
const segment = node.parentId ? nodes[node.parentId] : undefined
@@ -83,15 +85,20 @@ export function getRoofHostedOpeningLevelId(
}
/**
* Level-plan [x, z] of a roof-hosted opening — its segment-local center
* composed through the segment's and roof's yaw + position.
* Level-plan [x, z] of a roof-hosted node — its face-local center mapped
* through the face frame, then composed through the segment's and roof's
* yaw + position.
*/
export function getRoofHostedOpeningPlanPoint(
node: RoofHostedOpening,
node: {
parentId: string | null
roofFace?: RoofWallFaceId
position: [number, number, number]
},
nodes: Record<string, AnyNode | undefined>,
): [number, number] | null {
const segment = node.parentId ? (nodes[node.parentId] as RoofSegmentNode | undefined) : undefined
if (segment?.type !== 'roof-segment') return null
if (segment?.type !== 'roof-segment' || !node.roofFace) return null
const roof = segment.parentId ? (nodes[segment.parentId] as RoofNode | undefined) : undefined
if (roof?.type !== 'roof') return null
@@ -100,7 +107,12 @@ export function getRoofHostedOpeningPlanPoint(
-x * Math.sin(yaw) + z * Math.cos(yaw),
]
const [sx, sz] = rotate(node.position[0], node.position[2], segment.rotation ?? 0)
const segLocal = roofFacePointToSegment(segment, node.roofFace, [
node.position[0],
node.position[1],
node.position[2],
])
const [sx, sz] = rotate(segLocal[0], segLocal[2], segment.rotation ?? 0)
const segX = sx + segment.position[0]
const segZ = sz + segment.position[2]
const [rx, rz] = rotate(segX, segZ, roof.rotation ?? 0)
-152
View File
@@ -1,152 +0,0 @@
import {
type AnyNodeId,
getRoofSegmentWallFaces,
type RoofNode,
type RoofSegmentNode,
type RoofSegmentWallFace,
sceneRegistry,
segmentPointToRoofWallFace,
useScene,
} from '@pascal-app/core'
import * as THREE from 'three'
const worldPoint = new THREE.Vector3()
const worldNormal = new THREE.Vector3()
const localPoint = new THREE.Vector3()
const localNormal = new THREE.Vector3()
const inverseMatrix = new THREE.Matrix4()
export type RoofWallHit = {
segment: RoofSegmentNode
face: RoofSegmentWallFace
/** Face coords of the hit (u along the face, v above the segment base). */
u: number
v: number
}
/** Pointer hits more than this far off the wall plane are not wall hits. */
const PLANE_TOLERANCE = 0.06
/** Reject faces whose normal disagrees with the hit normal (slope / soffit). */
const NORMAL_ALIGNMENT = 0.7
/** A wall face is vertical; slope faces on low pitches have |ny| ≫ 0. */
const MAX_NORMAL_Y = 0.4
/**
* Resolve a pointer hit on a roof to one of its segments' vertical wall
* faces (base walls under the roof + the coplanar gable/shed/gambrel end
* faces). Counterpart of `resolveRoofSegmentHit`, which resolves to the
* sloped top surface instead.
*
* `normal` must be the raw `NodeEvent.normal` (hit-object-local) together
* with the `object` it came from — roof events can originate from the
* merged-roof mesh (roof-local frame) or a painted segment mesh
* (segment-local frame), so the normal is normalised through world space
* here instead of trusting the event frame.
*/
export function resolveRoofWallHit(
roof: RoofNode,
position: [number, number, number],
normal: [number, number, number] | undefined,
object: THREE.Object3D | undefined,
): RoofWallHit | null {
if (!normal || !object) return null
worldPoint.set(position[0], position[1], position[2])
worldNormal.set(normal[0], normal[1], normal[2])
object.updateWorldMatrix(true, false)
worldNormal.transformDirection(object.matrixWorld)
const state = useScene.getState()
let best: { hit: RoofWallHit; score: number } | null = null
for (const childId of roof.children ?? []) {
const segment = state.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined
if (segment?.type !== 'roof-segment') continue
const segObj = sceneRegistry.nodes.get(segment.id)
if (!segObj) continue
segObj.updateWorldMatrix(true, false)
localPoint.copy(worldPoint)
segObj.worldToLocal(localPoint)
inverseMatrix.copy(segObj.matrixWorld).invert()
localNormal.copy(worldNormal).transformDirection(inverseMatrix)
if (Math.abs(localNormal.y) > MAX_NORMAL_Y) continue
for (const face of getRoofSegmentWallFaces(segment)) {
const alignment =
localNormal.x * face.normal[0] +
localNormal.y * face.normal[1] +
localNormal.z * face.normal[2]
if (alignment < NORMAL_ALIGNMENT) continue
const { u, v, dist } = segmentPointToRoofWallFace(segment, face.id, [
localPoint.x,
localPoint.y,
localPoint.z,
])
if (Math.abs(dist) > PLANE_TOLERANCE) continue
if (u < -PLANE_TOLERANCE || u > face.length + PLANE_TOLERANCE) continue
if (v < -PLANE_TOLERANCE) continue
const score = Math.abs(dist)
if (!best || score < best.score) {
best = { hit: { segment, face, u, v }, score }
}
}
}
return best?.hit ?? null
}
/**
* Overlap guard for openings sharing a roof-segment wall face — the
* roof-host analogue of `hasWallChildOverlap`. Only door / window
* siblings on the same face are compared (other accessories live on the
* sloped surfaces).
*/
export function hasRoofFaceChildOverlap(
segment: RoofSegmentNode,
face: RoofSegmentWallFace,
u: number,
v: number,
width: number,
height: number,
ignoreId?: string,
): boolean {
const nodes = useScene.getState().nodes
const newLeft = u - width / 2
const newRight = u + width / 2
const newBottom = v - height / 2
const newTop = v + height / 2
// Sibling openings store their center at the wall mid-plane (inset by
// wallThickness / 2 from the outer plane this face measures from).
const sameFaceTolerance = (segment.wallThickness ?? 0.1) / 2 + PLANE_TOLERANCE
for (const childId of segment.children ?? []) {
if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId]
if (!child || (child.type !== 'door' && child.type !== 'window')) continue
const opening = child as {
position: [number, number, number]
rotation: [number, number, number]
width: number
height: number
}
const {
u: childU,
v: childV,
dist,
} = segmentPointToRoofWallFace(segment, face.id, [
opening.position[0],
opening.position[1],
opening.position[2],
])
// Same face = the opening's mid-plane center sits near this face.
if (Math.abs(dist) > sameFaceTolerance) continue
const xOverlap = newLeft < childU + opening.width / 2 && newRight > childU - opening.width / 2
const yOverlap = newBottom < childV + opening.height / 2 && newTop > childV - opening.height / 2
if (xOverlap && yOverlap) return true
}
return false
}
@@ -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
}