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
@@ -173,7 +173,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
// re-derived from the surface under the cursor when a preset is
// placed. Host apps strip these at preset-save time via
// `getHostRefFields(def)`.
hostRefFields: ['wallId', 'roofSegmentId'],
hostRefFields: ['wallId', 'roofSegmentId', 'roofFace'],
},
parametrics: doorParametrics,
+3 -4
View File
@@ -55,10 +55,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
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,
})
@@ -72,6 +69,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
parentId: string
wallId: string
roofSegmentId: undefined
roofFace: undefined
} | null = null
const session: FloorplanMoveTargetSession = {
@@ -107,6 +105,7 @@ export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node })
// 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,
}
// Build the updates atomically — position + rotation + side +
+33 -17
View File
@@ -7,7 +7,7 @@ import {
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
roofFacePointToSegment,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
@@ -19,7 +19,9 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace,
resolveRoofWallHit,
stripPlacementMetadataFlags,
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 './door-math'
@@ -68,6 +69,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
// must restore the roof host.
roofSegmentId: movingDoorNode.roofSegmentId,
roofFace: movingDoorNode.roofFace,
metadata: movingDoorNode.metadata,
}
@@ -219,6 +221,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
markWallDirty(currentWallId)
currentWallId = target.wallId
@@ -297,6 +300,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
wallId: target.wallId,
parentId: target.wallId,
roofSegmentId: undefined,
roofFace: undefined,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -308,6 +312,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
@@ -356,6 +361,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
}
@@ -390,34 +396,36 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
{ lockV: true },
)
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,
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.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,
)
}
@@ -432,18 +440,20 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
if (currentWallId !== target.hit.segment.id) {
useScene.getState().updateNode(movingDoorNode.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(movingDoorNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
rotation: [0, 0, 0],
roofFace: target.hit.face.id,
})
}
updateRoofCursor(target)
@@ -467,10 +477,11 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const node = DoorNode.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)
@@ -483,17 +494,19 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingDoorNode.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: {},
})
@@ -531,6 +544,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
})
if (original.parentId) markWallDirty(original.parentId)
}
@@ -548,6 +562,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
@@ -584,6 +599,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
roofFace: original.roofFace,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
+5 -20
View File
@@ -1,15 +1,10 @@
'use client'
import {
type AnyNodeId,
type DoorNode,
type RoofSegmentNode,
useRegistry,
useScene,
} from '@pascal-app/core'
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useNodeEvents } from '@pascal-app/viewer'
import { useLayoutEffect, useRef } from 'react'
import { type Mesh, MeshBasicMaterial } from 'three'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
@@ -23,16 +18,6 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
const handlers = useNodeEvents(node, 'door')
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
// Roof-hosted doors mount under the roof's `roof-elements` group (roof
// frame), so the host segment's transform is applied here — wall-hosted
// doors 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
castShadow
@@ -48,11 +33,11 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
</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
@@ -7,7 +7,7 @@ import {
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
roofFacePointToSegment,
sceneRegistry,
spatialGridManager,
useScene,
@@ -18,7 +18,9 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace,
resolveRoofWallHit,
triggerSFX,
useAlignmentGuides,
} from '@pascal-app/editor'
@@ -26,7 +28,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 './door-math'
@@ -226,6 +227,7 @@ const DoorTool: React.FC = () => {
wallId: event.node.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
roofFace: undefined,
})
}
}
@@ -374,23 +376,20 @@ const DoorTool: React.FC = () => {
lockV: true,
})
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 = (
@@ -400,11 +399,16 @@ const DoorTool: 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,
)
}
@@ -420,20 +424,22 @@ const DoorTool: 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 = DoorNode.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 },
})
@@ -448,7 +454,7 @@ const DoorTool: 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
@@ -464,9 +470,10 @@ const DoorTool: React.FC = () => {
const node = DoorNode.parse({
name: `Door ${doorCount + 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,