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
+179 -2
View File
@@ -1,8 +1,12 @@
import {
type AnyNodeId,
clampRectToRoofWallFace,
collectAlignmentAnchors,
emitter,
isCurvedWall,
type RoofEvent,
type RoofNode,
roofWallFaceLocalToSegment,
sceneRegistry,
spatialGridManager,
useScene,
@@ -21,8 +25,9 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
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'
@@ -34,8 +39,12 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false,
})
const roofCursorPoint = new Vector3()
/**
* Window tool — places WindowNodes on walls only.
* Window tool — places WindowNodes on walls and on roof-segment wall
* faces (the generated base walls under a roof, including coplanar gable
* ends — a window can sit in the gable pediment).
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
*/
const WindowTool: React.FC = () => {
@@ -223,6 +232,8 @@ const WindowTool: React.FC = () => {
side,
parentId: event.node.id,
wallId: event.node.id,
// The draft may arrive from a roof-segment face hover.
roofSegmentId: undefined,
})
}
}
@@ -343,6 +354,164 @@ const WindowTool: React.FC = () => {
hideCursor()
}
// ── Roof-segment wall faces ─────────────────────────────────────
// The merged roof mesh emits `roof:*`; hits are resolved against the
// segments' vertical wall faces (base walls + coplanar gable ends),
// so a window can sit anywhere inside the face profile — including
// the gable pediment triangle.
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 ?? 1.5
const height = draftRef.current?.height ?? 1.5
// Free vertical placement (snapped to the 0.5m grid like walls);
// the clamp projects the window inside the face profile, sliding
// 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,
)
const valid = !hasRoofFaceChildOverlap(
hit.segment,
hit.face,
clamped.u,
clamped.v,
width,
height,
draftRef.current?.id,
)
return { hit, position, yaw: hit.face.yaw, 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)
roofCursorPoint.set(target.position[0], target.position[1], target.position[2])
segObj.localToWorld(roofCursorPoint)
updateCursor(
worldToBuildingLocal(roofCursorPoint),
(roof.rotation ?? 0) + (target.hit.segment.rotation ?? 0) + target.yaw,
target.valid,
)
}
const onRoofHover = (event: RoofEvent) => {
const target = resolveRoofTarget(event)
if (!target) {
// On the roof but not over a placeable wall face (slope, soffit,
// or a face the window cannot fit on).
if (draftRef.current?.roofSegmentId) {
destroyDraft()
hideCursor()
}
return
}
const { hit, position, yaw } = 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],
})
} else {
const node = WindowNode.parse({
position,
rotation: [0, yaw, 0],
side: 'front',
roofSegmentId: hit.segment.id,
parentId: hit.segment.id,
metadata: { isTransient: true },
})
useScene.getState().createNode(node, hit.segment.id as AnyNodeId)
draftRef.current = node
}
updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
if (!draftRef.current?.roofSegmentId) return
const target = resolveRoofTarget(event)
if (!target?.valid) return
const { hit, position, yaw } = target
const draft = draftRef.current
draftRef.current = null
useScene.getState().deleteNode(draft.id)
useScene.temporal.getState().resume()
const state = useScene.getState()
const windowCount = Object.values(state.nodes).filter(
(n) => n.type === 'window' && (n as WindowNode).roofSegmentId !== undefined,
).length
const node = WindowNode.parse({
name: `Window ${windowCount + 1}`,
position,
rotation: [0, yaw, 0],
side: 'front',
roofSegmentId: hit.segment.id,
parentId: hit.segment.id,
width: draft.width,
height: draft.height,
windowType: draft.windowType,
operationState: draft.operationState,
awningDirection: draft.awningDirection,
casementStyle: draft.casementStyle,
hingesSide: draft.hingesSide,
frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth,
columnRatios: draft.columnRatios,
rowRatios: draft.rowRatios,
columnDividerThickness: draft.columnDividerThickness,
rowDividerThickness: draft.rowDividerThickness,
sill: draft.sill,
sillDepth: draft.sillDepth,
sillThickness: draft.sillThickness,
})
useScene.getState().createNode(node, hit.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)
useViewer.getState().setSelection({ selectedIds: [node.id] })
useScene.temporal.getState().pause()
triggerSFX('sfx:structure-build')
event.stopPropagation()
}
const onRoofLeave = () => {
if (!draftRef.current?.roofSegmentId) return
destroyDraft()
hideCursor()
}
const onCancel = () => {
destroyDraft()
hideCursor()
@@ -352,6 +521,10 @@ const WindowTool: React.FC = () => {
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 () => {
@@ -363,6 +536,10 @@ const WindowTool: React.FC = () => {
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)
}
}, [])