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
+24 -4
View File
@@ -2,9 +2,12 @@ import type {
AnyNodeId,
HandleDescriptor,
NodeDefinition,
RoofSegmentNode,
WallNode,
WindowNode as WindowNodeType,
} from '@pascal-app/core'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { buildWindowFloorplan } from './floorplan'
import { windowWidthAffordance } from './floorplan-affordances'
import { windowFloorplanMoveTarget } from './floorplan-move'
@@ -36,7 +39,13 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor<WindowNodeT
axis: 'x',
anchor: side === 'right' ? 'min' : 'max',
min: MIN_WINDOW_WIDTH,
max: (n, scene) => readWallLength(n, scene),
max: (n, scene) => {
// Roof-hosted windows clamp against the face profile (the
// wall-based limits read Infinity when wallId is unset).
const roofMax = readRoofFaceWidthMax(n, scene, sign)
if (roofMax !== null) return Math.max(MIN_WINDOW_WIDTH, roofMax)
return readWallLength(n, scene)
},
currentValue: (n) => n.width,
apply: (initial, newWidth) => {
const rotY = initial.rotation[1]
@@ -73,6 +82,8 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
anchor: edge === 'top' ? 'min' : 'max',
min: MIN_WINDOW_HEIGHT,
max: (n, scene) => {
const roofMax = readRoofFaceHeightMax(n, scene, sign)
if (roofMax !== null) return Math.max(MIN_WINDOW_HEIGHT, roofMax)
// Maximum: distance from the anchored edge to the wall's allowed Y
// bounds. Top arrow caps at wall.height - bottom; bottom arrow caps
// at top (positive Y room above the floor).
@@ -139,9 +150,18 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
duplicable: true,
deletable: true,
wallOpeningPlacement: true,
// `wallId` is re-derived from the wall under the cursor at preset
// placement time — see the door capability for the same pattern.
hostRefFields: ['wallId'],
// Windows also host on roof-segment wall faces (base walls under the
// roof, gable ends) — same wiring as door; see the door capability
// for why `cascadesViaHostSegment` is required.
roofAccessory: {
buildCut: (node, hostSegment) =>
buildRoofWallOpeningCut(node as WindowNodeType, hostSegment as RoofSegmentNode),
cutScope: 'wall',
cascadesViaHostSegment: true,
},
// `wallId` / `roofSegmentId` are re-derived from the surface under
// the cursor at preset placement time — see door for the pattern.
hostRefFields: ['wallId', 'roofSegmentId'],
},
parametrics: windowParametrics,