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 -1
View File
@@ -161,7 +161,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
},
// `wallId` / `roofSegmentId` are re-derived from the surface under
// the cursor at preset placement time — see door for the pattern.
hostRefFields: ['wallId', 'roofSegmentId'],
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
},
parametrics: windowParametrics,
+3 -4
View File
@@ -48,10 +48,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
original:
originalWall?.type === 'wall'
? projectWallLocalPointToPlan(originalWall, node.position[0])
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [
node.position[0],
0,
]),
: (getRoofHostedOpeningPlanPoint(node, useScene.getState().nodes) ?? [node.position[0], 0]),
metadata: node.metadata,
})
@@ -69,6 +66,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
parentId: string
wallId: string
roofSegmentId: undefined
roofFace: undefined
} | null = null
const session: FloorplanMoveTargetSession = {
@@ -109,6 +107,7 @@ export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ nod
// Re-anchoring to a wall ends any roof-segment hosting; the
// overlay's snapshot restores it if the move is reverted.
roofSegmentId: undefined,
roofFace: undefined,
}
useScene.getState().updateNodes([
+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)
+2
View File
@@ -215,6 +215,8 @@ export default function WindowPanel() {
rotation: [...node.rotation] as [number, number, number],
side: node.side,
wallId: node.wallId,
roofSegmentId: node.roofSegmentId,
roofFace: node.roofFace,
parentId: node.parentId,
width: node.width,
height: node.height,
+5 -20
View File
@@ -1,12 +1,6 @@
'use client'
import {
type AnyNodeId,
type RoofSegmentNode,
useRegistry,
useScene,
type WindowNode,
} from '@pascal-app/core'
import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
import {
createMaterial,
DEFAULT_WINDOW_MATERIAL,
@@ -15,6 +9,7 @@ import {
} from '@pascal-app/viewer'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
const ref = useRef<Mesh>(null!)
@@ -39,16 +34,6 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
node.material?.texture,
])
// Roof-hosted windows mount under the roof's `roof-elements` group (roof
// frame), so the host segment's transform is applied here — wall-hosted
// windows get it for free from the wall mesh they're nested in.
const segment = useScene((state) =>
node.roofSegmentId
? (state.nodes[node.roofSegmentId as AnyNodeId] as RoofSegmentNode | undefined)
: undefined,
)
if (node.roofSegmentId && segment?.type !== 'roof-segment') return null
const mesh = (
<mesh
material={material}
@@ -62,11 +47,11 @@ export const WindowRenderer = ({ node }: { node: WindowNode }) => {
</mesh>
)
if (!segment) return mesh
if (!node.roofSegmentId) return mesh
return (
<group position={segment.position} rotation-y={segment.rotation}>
<RoofFaceHostFrame roofFace={node.roofFace} roofSegmentId={node.roofSegmentId}>
{mesh}
</group>
</RoofFaceHostFrame>
)
}
+25 -18
View File
@@ -6,7 +6,7 @@ import {
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
roofFacePointToSegment,
sceneRegistry,
spatialGridManager,
useScene,
@@ -18,7 +18,9 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace,
resolveRoofWallHit,
snapToHalf,
triggerSFX,
useAlignmentGuides,
@@ -27,7 +29,6 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, 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'
@@ -234,6 +235,7 @@ const WindowTool: React.FC = () => {
wallId: event.node.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
}
@@ -384,23 +386,20 @@ const WindowTool: React.FC = () => {
// it down under the gable slopes when needed.
const clamped = clampRectToRoofWallFace(hit.face, hit.u, snapToHalf(hit.v), width, 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,
width,
height,
draftRef.current?.id,
)
return { hit, position, yaw: hit.face.yaw, valid }
return { hit, position, valid }
}
const updateRoofCursor = (
@@ -410,11 +409,16 @@ const WindowTool: React.FC = () => {
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),
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.hit.face.yaw,
target.valid,
)
}
@@ -430,20 +434,22 @@ const WindowTool: React.FC = () => {
}
return
}
const { hit, position, yaw } = target
const { hit, position } = target
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft()
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position,
rotation: [0, yaw, 0],
rotation: [0, 0, 0],
roofFace: hit.face.id,
})
} else {
const node = WindowNode.parse({
position,
rotation: [0, yaw, 0],
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id,
metadata: { isTransient: true },
})
@@ -458,7 +464,7 @@ const WindowTool: React.FC = () => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
const { hit, position, yaw } = target
const { hit, position } = target
const draft = draftRef.current
draftRef.current = null
@@ -474,9 +480,10 @@ const WindowTool: React.FC = () => {
const node = WindowNode.parse({
name: `Window ${windowCount + 1}`,
position,
rotation: [0, yaw, 0],
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id,
width: draft.width,
height: draft.height,