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
+28 -5
View File
@@ -3,8 +3,11 @@ import type {
DoorNode as DoorNodeType,
HandleDescriptor,
NodeDefinition,
RoofSegmentNode,
WallNode,
} from '@pascal-app/core'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { scaleHandleHeight } from './door-math'
import { buildDoorFloorplan } from './floorplan'
import { doorWidthAffordance } from './floorplan-affordances'
@@ -42,7 +45,13 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor<DoorNodeType>
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
anchor: side === 'right' ? 'min' : 'max',
min: MIN_DOOR_WIDTH,
max: (n, scene) => readWallLength(n, scene),
max: (n, scene) => {
// Roof-hosted doors 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_DOOR_WIDTH, roofMax)
return readWallLength(n, scene)
},
currentValue: (n) => n.width,
apply: (initial, newWidth) => {
// Anchored edge stays fixed in wall-local coords. Door rotation is
@@ -80,6 +89,8 @@ function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
anchor: 'min', // bottom anchored at wall-local Y = position[1] - height/2
min: MIN_DOOR_HEIGHT,
max: (n, scene) => {
const roofMax = readRoofFaceHeightMax(n, scene, 1)
if (roofMax !== null) return Math.max(MIN_DOOR_HEIGHT, roofMax)
const bottom = n.position[1] - n.height / 2
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
},
@@ -147,10 +158,22 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
duplicable: true,
deletable: true,
wallOpeningPlacement: true,
// `wallId` ties the door to its host wall and is re-derived from
// the wall under the cursor when a preset is placed. Host apps
// strip this at preset-save time via `getHostRefFields(def)`.
hostRefFields: ['wallId'],
// Doors also host on roof-segment wall faces (base walls under the
// roof, gable ends). `buildCut` punches the opening into the
// segment's wall brush; `cascadesViaHostSegment` keeps the roof-merge
// loop from consuming door dirty marks (DoorSystem owns them and
// already cascades to the host via parentId).
roofAccessory: {
buildCut: (node, hostSegment) =>
buildRoofWallOpeningCut(node as DoorNodeType, hostSegment as RoofSegmentNode),
cutScope: 'wall',
cascadesViaHostSegment: true,
},
// `wallId` / `roofSegmentId` tie the door to its host and are
// 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'],
},
parametrics: doorParametrics,