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
@@ -0,0 +1,42 @@
import type { RoofSegmentNode } from '@pascal-app/core'
import * as THREE from 'three'
type RoofWallOpening = {
roofSegmentId?: string
position: [number, number, number]
rotation: [number, number, number]
width: number
height: number
}
/**
* CSG cut for a door / window hosted on a roof-segment wall face
* (`capabilities.roofAccessory.buildCut`). A box through the wall plane,
* oriented by the opening's face yaw, in segment-local coords — the
* roof-merge loop subtracts it from the segment's wall brush.
*
* Returns null for wall-hosted openings (no `roofSegmentId`): their cut
* is handled by the wall system's own cutout pipeline.
*/
export function buildRoofWallOpeningCut(
node: RoofWallOpening,
hostSegment: RoofSegmentNode,
): THREE.BufferGeometry | null {
if (!node.roofSegmentId) return null
const wallThickness = hostSegment.wallThickness ?? 0.1
// Through the wall both ways, but well short of the rake/eave overhang
// so the cut never nicks the soffit or fascia bands.
const depth = wallThickness * 2 + 0.04
// A door's cut bottom is coplanar with the wall brush base — extend it
// slightly downward so three-bvh-csg never has to clip coplanar faces.
const bottom = node.position[1] - node.height / 2
const bottomPad = bottom < 0.005 ? 0.02 : 0
const geo = new THREE.BoxGeometry(node.width, node.height + bottomPad, depth)
geo.translate(0, -bottomPad / 2, 0)
geo.rotateY(node.rotation[1] ?? 0)
geo.translate(node.position[0], node.position[1], node.position[2])
return geo
}