feat: doors and windows on roof-segment wall faces

Openings now host on the walls a roof segment generates — the base
walls under the roof and the coplanar gable/shed/gambrel end faces, so
a window can sit in a gable pediment.

- core: roof-segment-walls.ts models the four vertical faces as 2D
  frames (u along face, v height) with convex profile polygons that
  mirror the wall volume getRoofSegmentBrushes builds; rect-in-profile
  clamping and anchored resize limits via half-plane algebra.
- schemas: optional roofSegmentId on door/window; position is the
  segment-local wall mid-plane center, rotation[1] the face yaw.
- cut: reuses capabilities.roofAccessory.buildCut; new cutScope: 'wall'
  subtracts from the wall brush only. cascadesViaHostSegment keeps the
  roof-merge loop from consuming door/window dirty marks (their own
  systems cascade via parentId).
- tools: roof:* handlers in door/window tool + move-tool (the Build-tab
  preset path), with roofSegmentId cleared/restored across every
  roof<->wall re-anchor and revert; shared hit resolver normalizes
  normals through world space (merged mesh vs painted segment frames).
- fix: RoofSystem no longer rebuilds per-segment CSG in accessory-reveal
  mode — the uncut rebuild used to draw over the merged shell's fresh
  opening until deselect.
- fix: the walkthrough collider world now prunes by renderer-effective
  visibility; stale uncut segment CSG inside the hidden segments-wrapper
  blocked the player at openings the merged shell had cut through.

Known gap: painted segments render per-segment CSG without accessory
cuts (pre-existing, also affects skylight/dormer). Twice Codex-reviewed;
details in 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 00:39:13 -04:00
co-authored by Claude Fable 5
parent 1487328ec7
commit bdfee058bd
22 changed files with 1771 additions and 48 deletions
+202 -1
View File
@@ -1,9 +1,13 @@
import {
type AnyNodeId,
clampRectToRoofWallFace,
collectAlignmentAnchors,
DoorNode,
emitter,
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
@@ -23,8 +27,9 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
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'
@@ -35,6 +40,8 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false,
})
const roofCursorPoint = new Vector3()
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
const cursorGroupRef = useRef<Group>(null!)
@@ -57,6 +64,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: movingDoorNode.side,
parentId: movingDoorNode.parentId,
wallId: movingDoorNode.wallId,
// Doors can be hosted on a roof-segment wall face. Moving onto a
// wall re-anchors as wall-hosted (roofSegmentId cleared); reverts
// must restore the roof host.
roofSegmentId: movingDoorNode.roofSegmentId,
metadata: movingDoorNode.metadata,
}
@@ -207,6 +218,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: target.side,
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
})
markWallDirty(currentWallId)
currentWallId = target.wallId
@@ -284,6 +296,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: target.side,
wallId: target.wallId,
parentId: target.wallId,
roofSegmentId: undefined,
})
useScene.getState().createNode(node, target.wallId as AnyNodeId)
placedId = node.id
@@ -294,6 +307,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
@@ -304,6 +318,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: target.side,
parentId: target.wallId,
wallId: target.wallId,
roofSegmentId: undefined,
metadata: {},
})
@@ -340,6 +355,182 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
})
if (original.parentId) markWallDirty(original.parentId)
}
// ── Roof-segment wall faces ─────────────────────────────────────
// Mirrors the wall flow for the segments' vertical wall faces (base
// walls under the roof + coplanar gable ends). This is also the
// placement path preset tiles take (`metadata.isNew` clones).
const worldToBuildingLocal = (point: Vector3): [number, number, number] => {
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 resolveRoofMoveTarget = (event: RoofEvent) => {
const hit = resolveRoofWallHit(
event.node as RoofNode,
event.position,
event.normal,
event.object,
)
if (!hit) return null
// Doors sit on the segment base: v locked to height/2, only u slides.
const clamped = clampRectToRoofWallFace(
hit.face,
hit.u,
movingDoorNode.height / 2,
movingDoorNode.width,
movingDoorNode.height,
{ lockV: true },
)
if (!clamped) return null
const position = roofWallFaceLocalToSegment(
hit.segment,
hit.face.id,
clamped.u,
clamped.v,
(hit.segment.wallThickness ?? 0.1) / 2,
)
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face,
clamped.u,
clamped.v,
movingDoorNode.width,
movingDoorNode.height,
movingDoorNode.id,
)
return { hit, position, yaw: hit.face.yaw, 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])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(target.roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
target.valid,
)
}
const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event)
if (!target) return
// Wall-frame drag anchor / live transform don't apply on a roof face.
dragAnchor = null
lastTarget = null
useLiveTransforms.getState().clear(movingDoorNode.id)
if (currentWallId !== target.hit.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
side: 'front',
parentId: target.hit.segment.id,
wallId: undefined,
roofSegmentId: target.hit.segment.id,
})
markWallDirty(currentWallId)
currentWallId = target.hit.segment.id
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
})
}
updateRoofCursor(target)
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
const target = resolveRoofMoveTarget(event)
if (!target?.valid) return
const segmentId = target.hit.segment.id
let placedId: string
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
useScene.temporal.getState().resume()
const cloned = structuredClone(movingDoorNode) as any
delete cloned.id
cloned.metadata = stripPlacementMetadataFlags(cloned.metadata)
const node = DoorNode.parse({
...cloned,
position: target.position,
rotation: [0, target.yaw, 0],
side: 'front',
wallId: undefined,
roofSegmentId: segmentId,
parentId: segmentId,
})
useScene.getState().createNode(node, segmentId as AnyNodeId)
placedId = node.id
} else {
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
metadata: original.metadata,
})
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
rotation: [0, target.yaw, 0],
side: 'front',
parentId: segmentId,
wallId: undefined,
roofSegmentId: segmentId,
metadata: {},
})
if (original.parentId && original.parentId !== segmentId) {
markWallDirty(original.parentId)
}
placedId = movingDoorNode.id
}
markWallDirty(segmentId)
useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
hideCursor()
useViewer.getState().setSelection({ selectedIds: [placedId] })
exitMoveMode()
event.stopPropagation()
}
const onRoofLeave = () => {
hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id)
dragAnchor = null
lastTarget = null
if (isNew) return
if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId)
}
currentWallId = original.parentId
useScene.getState().updateNode(movingDoorNode.id, {
position: original.position,
rotation: original.rotation,
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
})
if (original.parentId) markWallDirty(original.parentId)
}
@@ -356,6 +547,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
@@ -369,6 +561,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.on('wall:move', onWallMove)
emitter.on('wall:click', onWallClick)
emitter.on('wall:leave', onWallLeave)
emitter.on('roof:enter', onRoofHover)
emitter.on('roof:move', onRoofHover)
emitter.on('roof:click', onRoofClick)
emitter.on('roof:leave', onRoofLeave)
emitter.on('tool:cancel', onCancel)
return () => {
@@ -387,6 +583,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
side: original.side,
parentId: original.parentId,
wallId: original.wallId,
roofSegmentId: original.roofSegmentId,
metadata: original.metadata,
})
if (original.parentId) markWallDirty(original.parentId)
@@ -399,6 +596,10 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
emitter.off('wall:move', onWallMove)
emitter.off('wall:click', onWallClick)
emitter.off('wall:leave', onWallLeave)
emitter.off('roof:enter', onRoofHover)
emitter.off('roof:move', onRoofHover)
emitter.off('roof:click', onRoofClick)
emitter.off('roof:leave', onRoofLeave)
emitter.off('tool:cancel', onCancel)
}
}, [movingDoorNode, exitMoveMode])