refactor: release-review cleanup for roof wall openings

Dual review pass (Claude multi-angle + Codex release-quality). One
correctness fix and the agreed do-now cleanups:

- fix: clone-scene-graph remaps roofSegmentId like wallId in both
  clone paths — duplicated scenes/levels kept pointing roof-hosted
  children at the original segments.
- extract the settled, stateless roof target/cursor math shared by the
  four door/window tools into shared/roof-wall-opening-placement.ts
  (resolveRoofWallOpeningTarget + getRoofWallOpeningCursorPose +
  worldToSelectedBuildingLocal); tools keep the stateful lifecycle
  (drafts, undo/temporal, commit field lists). −199 net lines.
- rename host-generic state: currentWallId→currentHostId,
  markWallDirty→markHostDirty (they hold segment ids too); capability
  cascadesViaHostSegment→dirtyHandledByOwnSystem (behavior-facing,
  before the public API hardens).
- drop getRoofAccessoryKinds from core's public API — its only caller
  was the standalone Build tab, which now enumerates the registry
  inline with its app-specific filter.
- window move-tool uses the shared stripPlacementMetadataFlags; stale
  "segment-local" comment fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-10 14:33:13 -04:00
co-authored by Claude Fable 5
parent b8dfa90762
commit aa3b0ef758
16 changed files with 349 additions and 435 deletions
+33 -80
View File
@@ -1,13 +1,11 @@
import {
type AnyNodeId,
clampRectToRoofWallFace,
collectAlignmentAnchors,
DoorNode,
emitter,
isCurvedWall,
type RoofEvent,
type RoofNode,
roofFacePointToSegment,
sceneRegistry,
spatialGridManager,
useScene,
@@ -18,16 +16,19 @@ import {
calculateItemRotation,
EDITOR_LAYER,
getSideFromNormal,
hasRoofFaceChildOverlap,
isValidWallSideFace,
resolveRoofWallHit,
triggerSFX,
useAlignmentGuides,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
getRoofWallOpeningCursorPose,
resolveRoofWallOpeningTarget,
type RoofWallOpeningTarget,
} from '../shared/roof-wall-opening-placement'
import { resolveWallSlideAlignment } from '../shared/wall-opening-alignment'
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
@@ -38,7 +39,6 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false,
})
const roofCursorPoint = new Vector3()
/**
* Door tool — places DoorNodes on walls and on roof-segment wall faces
@@ -66,8 +66,8 @@ const DoorTool: React.FC = () => {
wallEvent.node.end,
)
const markWallDirty = (wallId: string) => {
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
const markHostDirty = (hostId: string) => {
useScene.getState().dirtyNodes.add(hostId as AnyNodeId)
}
const destroyDraft = () => {
@@ -75,7 +75,7 @@ const DoorTool: React.FC = () => {
const wallId = draftRef.current.parentId
useScene.getState().deleteNode(draftRef.current.id)
draftRef.current = null
if (wallId) markWallDirty(wallId)
if (wallId) markHostDirty(wallId)
}
const hideCursor = () => {
@@ -217,7 +217,7 @@ const DoorTool: React.FC = () => {
rotation: [0, itemRotation, 0],
side,
})
markWallDirty(event.node.id)
markHostDirty(event.node.id)
} else {
useScene.getState().updateNode(draftRef.current.id, {
position: [clampedX, clampedY, 0],
@@ -352,65 +352,18 @@ const DoorTool: React.FC = () => {
// The merged roof mesh emits `roof:*`; hits are resolved against the
// segments' vertical wall faces (base walls + coplanar gable ends).
const worldToBuildingLocal = (point: Vector3): [number, number, number] => {
// The tool's cursor group renders in the building's local frame —
// same conversion as the roof accessory tools (e.g. SkylightTool).
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : undefined
if (buildingObj) buildingObj.worldToLocal(point)
return [point.x, point.y, point.z]
}
const resolveRoofTarget = (event: RoofEvent) => {
const hit = resolveRoofWallHit(
event.node as RoofNode,
event.position,
event.normal,
event.object,
)
if (!hit) return null
const width = draftRef.current?.width ?? 0.9
const height = draftRef.current?.height ?? 2.1
// Doors sit on the segment base: v locked to height/2, only u slides.
const clamped = clampRectToRoofWallFace(hit.face, hit.u, height / 2, width, height, {
lockV: true,
const resolveRoofTarget = (event: RoofEvent) =>
resolveRoofWallOpeningTarget({
event,
width: draftRef.current?.width ?? 0.9,
height: draftRef.current?.height ?? 2.1,
ignoreId: draftRef.current?.id,
vertical: { kind: 'bottom-locked' },
})
if (!clamped) return null
// 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.id,
clamped.u,
clamped.v,
width,
height,
draftRef.current?.id,
)
return { hit, position, valid }
}
const updateRoofCursor = (
target: NonNullable<ReturnType<typeof resolveRoofTarget>>,
roof: RoofNode,
) => {
const segObj = sceneRegistry.nodes.get(target.hit.segment.id as AnyNodeId)
if (!segObj) return
segObj.updateWorldMatrix(true, false)
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.hit.face.yaw,
target.valid,
)
const updateRoofCursor = (target: RoofWallOpeningTarget, roof: RoofNode) => {
const pose = getRoofWallOpeningCursorPose(target, roof)
if (pose) updateCursor(pose.position, pose.rotationY, target.valid)
}
const onRoofHover = (event: RoofEvent) => {
@@ -424,26 +377,26 @@ const DoorTool: React.FC = () => {
}
return
}
const { hit, position } = target
const { segment, face, position } = target
if (draftRef.current && draftRef.current.parentId !== hit.segment.id) destroyDraft()
if (draftRef.current && draftRef.current.parentId !== segment.id) destroyDraft()
if (draftRef.current) {
useScene.getState().updateNode(draftRef.current.id, {
position,
rotation: [0, 0, 0],
roofFace: hit.face.id,
roofFace: face.id,
})
} else {
const node = DoorNode.parse({
position,
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id,
roofSegmentId: segment.id,
roofFace: face.id,
parentId: segment.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, hit.segment.id as AnyNodeId)
useScene.getState().createNode(node, segment.id as AnyNodeId)
draftRef.current = node
}
updateRoofCursor(target, event.node as RoofNode)
@@ -454,7 +407,7 @@ const DoorTool: React.FC = () => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
const { hit, position } = target
const { segment, face, position } = target
const draft = draftRef.current
draftRef.current = null
@@ -472,9 +425,9 @@ const DoorTool: React.FC = () => {
position,
rotation: [0, 0, 0],
side: 'front',
roofSegmentId: hit.segment.id,
roofFace: hit.face.id,
parentId: hit.segment.id,
roofSegmentId: segment.id,
roofFace: face.id,
parentId: segment.id,
width: draft.width,
height: draft.height,
doorCategory: draft.doorCategory,
@@ -499,10 +452,10 @@ const DoorTool: React.FC = () => {
panicBarHeight: draft.panicBarHeight,
})
useScene.getState().createNode(node, hit.segment.id as AnyNodeId)
useScene.getState().createNode(node, segment.id as AnyNodeId)
// Rebuild the segment (and the merged roof) so the wall brush
// picks up the new opening cut.
useScene.getState().dirtyNodes.add(hit.segment.id as AnyNodeId)
useScene.getState().dirtyNodes.add(segment.id as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')