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:
co-authored by
Claude Fable 5
parent
5fd9be05df
commit
7879b83df3
@@ -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,
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user