* viewer: add Phase 1 render-modes foundation (shading/textures/colorPreset state, defaultRender prop, SSGI gating) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 2 render-modes material-class switch (Lambert in solid, Standard in rendered) Shading-aware material factories (cached per class), reactive selection in renderers via the useViewer(shading) pattern, and dirty-rebuild on toggle for geometry/door systems. Rendered mode output unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 3a render-modes surface roles + clay palette foundation Adds surfaceRole token to core NodeDefinition, per-kind default roles, ColorPreset palettes + resolveSurfaceColor/createSurfaceRoleMaterial (glazing stays translucent), and the textures-off recolor path for def.geometry kinds (slab/fence/shelf via GeometrySystem.applyDefaultSurfaceRole) + wall. Renderer- based kinds (roof/window/stair/item/column/door/ceiling/elevator) wired in 3b. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 3b render-modes textures-off recoloring for renderer/system kinds Wires clay role coloring (textures=off) for roof/roof-segment, window, stair/ stair-segment, door, item, column, ceiling, elevator via createSurfaceRoleMaterial, reactive on textures/colorPreset. Per-surface roles: roof top+edge=roof / underside=ceiling; window frame=joinery / glass=glazing; stair+door+elevator= joinery; ceiling=ceiling; column=wall; item=furnishing. textures=on unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: Phase 4a render-modes UI — Solid/Rendered toggle + per-context persistence Per-context shading via renderContext discriminator + shadingByContext (persisted); <Viewer> renderContext prop seeds per-context on mount. Solid/Rendered toggle in the editor action bar + standalone toolbar + command palette. Editor mounts default to renderContext=editor / shading=solid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: fix window-system glassMaterial type to allow clay glazing reassignment The let was inferred as MeshLambertNodeMaterial from the imported glass constant, so reassigning createSurfaceRoleMaterial('glazing') (returns THREE.Material) failed under tsc --build. Widen the annotation to THREE.Material. Surfaced by the build (check-types had replayed a stale turbo cache). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 5 lighting — add theme-driven hemisphere light, trim fill directionals 3->2 Adds a sky/ground hemisphere fill (theme-lerped) and drops the second fill directional; the hemisphere covers the shadow-side fill it provided, at one fewer per-fragment directional term (shared by Solid + Rendered). Ambient lowered since the hemisphere now carries soft fill. Intensities are a starting point — tune visually on the gpu-perf overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: tune Solid lighting for more form — stronger hemisphere sky/ground contrast, lower ambient Darker hemisphere ground (#d8d6cf -> #aaa49a) + higher hemisphere intensity and lower ambient so directional shading reads as form and undersides ground without AO. Keeps Solid free of any post-processing pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: biome format render-mode files (lefthook pre-commit) Formatting-only — import wrapping, dep-array wrapping, single-line ternaries — across Phase 2-4a files that weren't biome-clean. No logic changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: scene-theme system — named environment themes (studio/paper/sunset/night/...) New SceneTheme registry (lib/scene-themes.ts) drives lights, background, and tone mapping; lights.tsx refactored data-driven (N directionals + hemisphere + ambient). sceneTheme state (persisted) + cycle-button picker in editor bar + standalone toolbar, importing the registry from the viewer barrel (single source). Default 'studio' reproduces the prior look exactly; app light/dark 'theme' untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: Phase 7 — popover-dropdown pickers for render mode + scene theme (editor bar) Replace the shading + scene-theme cycle buttons in viewer-overlay.tsx with DropdownMenu pickers: render mode shows 2 rows (Solid/Rendered) with one-line detail; scene theme lists all themes with a derived color-swatch strip + active check. Imports the registry from the viewer barrel (single source). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: Phase 7 — dropdown pickers in standalone toolbar + export DropdownMenu from barrel Replicate the render-mode + scene-theme dropdown pickers (with swatch strip + active check) to apps/editor's compact toolbar, matching viewer-overlay.tsx. Export DropdownMenu* from the @pascal-app/editor barrel for the standalone app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer+editor: Phase 8 — crisp geometry edge overlay (off/soft/strong/sketchy) EdgeOverlaySystem draws EdgesGeometry LineSegments over node-backed building meshes (scoped via sceneRegistry, skips zone-layer/hitbox/overlay meshes), rebuilt on geometry-uuid or mode change, line color follows scene-theme background luminance; sketchy = static TSL vertex jitter. New 'edges' state (persisted, default off) + Edges dropdown in editor bar + standalone toolbar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer+editor: edge overlay — thick lines via Line2, drop sketchy mode Switch EdgeOverlaySystem from LineBasicNodeMaterial (1px hardware cap) to LineSegments2 + Line2NodeMaterial so edges have real screen-space width (soft 1.5px / strong 3px); resolution tracks viewport. EdgeMode is now off/soft/strong (sketchy removed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: fix edge overlay crash — webgpu Line2NodeMaterial has no settable resolution material.resolution is undefined under WebGPU (the node material reads the viewport internally); optional-chain the .set() call so it no-ops there instead of throwing. Thickness still applies via linewidth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: edge overlay — weld vertices to fix CSG spiderweb edges + lighter strong Position-only mergeVertices before EdgesGeometry so coplanar triangles from CSG-cut walls (doors/windows) share vertices and their interior edges are suppressed — only opening outlines + silhouettes remain. Strong linewidth 3 -> 2px (was too heavy). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: edge overlay — crease-only extractor to fix CSG spiderweb Replace EdgesGeometry (which always draws unpaired boundary/T-junction edges) with buildCreaseEdges: weld positions, keep only edges shared by exactly two faces whose dihedral exceeds the threshold, drop everything unpaired. CSG-cut walls/slabs are watertight so real corners + opening outlines survive while the interior triangulation fans (coplanar or T-junction) are removed. Open meshes (bare ground plane, billboard leaves) shed their boundary clutter too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: crease edges — coarser weld (0.1mm->1mm) to recover CSG/extrude seam edges The cap<->side-wall top edge of ExtrudeGeometry walls drifts past 0.1mm after CSG, so it stayed unpaired and was dropped. Weld at ~1mm to pair it into a real crease while staying far below feature size (wall thickness, openings). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: replace geometry edge overlay with screen-space ink (SketchUp look) Port the prototype's screen-space ink into the post-processing pipeline: depth + normal Sobel reading the scene-pass MRT. Crease term (normalized normals, center-vs-neighbour) + distance-independent depth-step term (raw Laplacian / (1-d)² with a noise gate so flat ground stays clean). Topology-agnostic, so it finally handles CSG-cut walls/openings without the spiderweb or missing-edge problems of EdgesGeometry. Driven by the existing edges off/soft/strong mode; MRT now builds when SSGI OR ink is on; ink colour tracks scene-theme luminance. Removes EdgeOverlaySystem + crease-edges (geometry approach). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: soft/strong ink modes + keep editor overlays out of the ink Two adjustments to the screen-space ink pass: 1. Soft vs strong now visibly differ. The edge masks saturate, so the old `intensity` gain did nothing once a line was detected. Replace it with a sample radius (line thickness) + opacity: soft = 1px / 50%, strong = 2px / 100%. `inkedEdges` takes `radius` + `opacity` instead of `intensity`. 2. Editor overlays (gizmos, move handles, tool previews, grid) no longer get inked. The scene pass that feeds the depth/normal MRT now renders only SCENE_LAYER; overlays render in a dedicated pass on OVERLAY_LAYER and are composited on top after the ink + outlines, so they read as crisp UI and never get inked or AO'd. New OVERLAY_LAYER constant in viewer; editor's EDITOR_LAYER re-exports it so the two stay in lockstep. Also moves WallMoveSideHandles (the wall/fence move arrows) onto EDITOR_LAYER — it was the one overlay still on SCENE_LAYER. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: render the grid in the scene pass so geometry occludes it The depth-gate fix couldn't help the grid: its material is depthWrite:false, so it never wrote overlay-pass depth and the "didn't write depth -> keep on top" term forced it on top — hence the floor grid bleeding through walls and objects. A full-floor plane can only be occluded correctly by living in the same depth context as the scene, so move the grid onto its own GRID_LAYER which the scene pass renders (alongside SCENE_LAYER). It's flat and depth-non-writing, so the screen-space ink still ignores it; gizmos/handles stay on OVERLAY_LAYER. The grid camera layer is enabled in custom-camera-controls and disabled on the thumbnail camera so thumbnails stay grid-free, matching EDITOR_LAYER. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: strong ink — match soft's 1px thickness, differ by darkness only Strong at radius 2 read too thick. Soft's 1px line is the nice one, so use it for both modes and let strong distinguish itself purely by being fully solid (opacity 1) vs soft's lighter 50%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: shadow frustum follows the view + a shadows on/off setting The directional light's ortho shadow camera only covers ±50 around the light target, which was pinned at the origin — so zones far from origin received no shadows no matter where the camera moved. Recentre each shadow-casting light (position + target together, preserving direction) on the view focus every frame: the orbit-controls target when available, else the camera's ground projection. The shadow area now tracks wherever the user looks. Also add a persisted `shadows` toggle (default on) to the viewer store and a "Shadows" switch in the editor settings panel — the dedicated shadows control the render-modes plan deferred. Lights gate castShadow on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: shadows toggle in the standalone toolbar + a touch more shadow strength The shadows switch I added only lived in the cloud settings panel's Visibility section, which is hidden in the local/standalone editor (no projectId). Add a ShadowsToggle button next to the grid toggle in the standalone toolbar so it's reachable there, matching how Show Grid is exposed in both places. Also push shadow strength partway toward the aesthetic prototype (which runs near-black, no blur): bump the bright-key shadow-intensity cap 0.4 -> 0.55 and tighten shadow-radius 2 -> 1.5. Still softer than aesthetic by design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nodes: site ground receives shadows (lit material instead of unlit Basic) The site ground fill used MeshBasicMaterial — unlit, so it could never show the directional shadow, and shadows visibly truncated at the slab edge. Swap it for a lit MeshLambertNodeMaterial with receiveShadow on the mesh; the geometry is the site polygon (slab footprints punched out), so shadows now extend across the whole site and stop at its boundary, which is the desired bound. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: per-theme clay palettes + 2x2 swatch in the theme pickers Each scene theme now carries a clayTints map (wall/floor/ceiling/roof/glazing) giving it a per-surface-role palette — e.g. Mediterranean's blue roof + warm walls. The theme pickers (standalone toolbar + community overlay) now render the aesthetic-style 2x2 swatch of those role tints over the theme background instead of the old 3-colour strip. Data + UI only; wiring the tints into the textures-off surface materials is a separate change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: slimmer theme switcher + cloud-sun icon Shrink the scene-theme toolbar button (w-[8.5rem] -> w-28) so it stops reserving space for "Mediterranean"; the label truncates when it overflows. Swap the palette icon for cloud-sun (atmosphere/lighting, distinct from the app light/dark Sun-Moon toggle) in both the standalone toolbar and the community overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: theme switcher icon -> swatch-book Swap the scene-theme icon from cloud-sun to swatch-book in both the standalone toolbar and the community overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: colour untextured building surfaces by the active scene theme Untextured walls/roof/slab/ceiling now take the active theme's per-role colour (theme.clayTints[role], falling back to the colour preset) in BOTH textures modes. The textures toggle only governs surfaces that actually have an explicit material/preset — those still show their texture when textures are on. This is what makes e.g. Mediterranean read as a blue roof + warm walls instead of the old hardcoded white/grey defaults. - materials.ts: resolveSurfaceColor / createSurfaceRoleMaterial take an optional sceneThemeId (theme tint ?? preset palette); theme folded into the cache key. - wall-materials, roof-materials, slab/geometry, ceiling/renderer: the untextured fallback now resolves to the themed role colour instead of white/grey, in both modes; theme threaded into each builder + material cache key. - wall-cutout: now reads textures/colorPreset/sceneTheme and re-applies wall materials when any change (previously it ignored textures/colorPreset entirely). - geometry-system: threads sceneTheme into the generic surface-role path + rebuild effect. Renderers/preview call sites thread sceneTheme through. Doors/windows/stairs/columns/items still use their existing defaults — a follow-up pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(slab): recessed (negative-elevation) slabs extrude downward again The registry geometry builder created the slab mesh at Y=0 without applying the negative-elevation offset, so recessed slabs rendered above the floor plane (pool geometry is built locally with its floor cap at Y=0 and walls rising to Y=|elevation|, so the mesh must be shifted down by `elevation` to recess). The runtime slab-system already did this; the static builder path didn't. Mirror it: shift mesh.position.y by elevation when negative. Positive elevation unchanged. Unrelated to render modes — bundled into this branch's PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): legacy roofs render again — migration wrote invalid wallHeight 0 Legacy roof nodes (old format, no `children`) were migrated with a hardcoded `wallHeight: 0`. With zero wall height the eave height (`wallHeight - autoDrop`) went negative in getRoofSegmentBrushes, producing geometrically invalid brushes; three-bvh-csg then spammed "TriangleClipper: Coplanar clip not handled" every frame and emitted NaN positions, so the merged roof geometry failed computeBoundingSphere and never rendered. - core/use-scene migration: wallHeight 0 -> 0.5 (the RoofSegmentNode schema default), so migrated segments have a valid wall height. - roof-system: clamp eave height to >= 0.01 so an intentional wallHeight 0 can never yield a negative eave, and guard updateMergedRoofGeometry so a CSG result with NaN positions is discarded (keep the last good mesh, warn once per roof) instead of poisoning the buffer + spamming the console. Unrelated to render modes — bundled into this branch's PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(column): columns cast and receive shadows Column meshes (box, beam, cylinder, sphere, torus) rendered without castShadow/ receiveShadow, so columns neither dropped a shadow nor caught one — unlike walls, slabs and roofs. Set both on all column shape meshes. Unrelated to render modes — bundled into this branch's PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: default edges to soft Editor defaults are now solid shading / studio theme / soft edges / shadows on. Shading (solid, via EDITOR_DEFAULT_RENDER), theme (studio) and shadows (on) were already the defaults; edges was 'off' — make 'soft' the default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: fold light/dark into the scene theme (remove the separate toggle) The viewer had two overlapping appearance controls: a light/dark `theme` toggle AND scene themes (which already drive the 3D background + lights). They conflicted — e.g. Night/Twilight are dark themes, but the light/dark toggle was an independent axis still tinting the 2D scene chrome. Unify on the scene theme: add an explicit `appearance: 'light' | 'dark'` to each SceneTheme (twilight/night = dark, the rest = light) and drive everything the old toggle drove off it — canvas backdrop, grid line colours, measurement-label/ cursor/site-edge contrast, the site ground fill, the ground occluder, and the mobile viewer bg. The editor UI chrome is unaffected (always dark via a fixed body class). Removes the `theme`/`setTheme` store state (+ persistence) and every light/dark toggle UI: the standalone toolbar Sun/Moon button, the community overlay theme switch, the command-palette command, and the ifc-converter preview toolbar button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: per-theme ground colour + add the "Verdant" nature theme - Add a `ground` colour to every SceneTheme and drive the site ground fill + the infinite ground-occluder off it (instead of the binary isDark ? #1f2433 : #fafafa). Dark themes now get a lit mid-tone ground (twilight #4a4566, night #2b3247) so the ground reads as ground rather than going near-black. - Add a new green/nature scene theme "Verdant": soft green sky + lit, with a green roof clay tint and mossy ground. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wiki: document the surface-colour / theme system The colour-per-node/renderer/system model from the render-modes work was undocumented. Add wiki/architecture/materials-and-themes.md covering surface roles, colour presets, the textures axis, scene themes (appearance / ground / clay tints), and the "untextured surfaces are theme-coloured in both modes" invariant + where each kind wires it. Also fix two pages that the same work made stale: - node-definitions: geometry builders receive (shading, textures, colorPreset, sceneTheme); document the `surfaceRole` token + applyDefaultSurfaceRole. - layers: OVERLAY_LAYER (1, viewer) with EDITOR_LAYER now its alias, the new GRID_LAYER (3, rendered in the scene pass for depth occlusion), and the overlay pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nodes: wire #330 roof-accessory kinds into the surface-role/theme colours PR #330's new kinds (chimney, dormer, skylight, solar-panel, ridge-vent, box-vent) use custom renderers, so the generic textures-off recolour path never reached them — they fell back to hardcoded colours. Wire each renderer into the render-modes system: read shading/textures/colorPreset/sceneTheme and resolve untextured surfaces via createSurfaceRoleMaterial (and force the role colour when textures are off), matching column/ceiling. Roles: chimney body→wall / cap→roof; dormer wall→wall, roof→roof, glass→glazing, frame→joinery; skylight glass→glazing / frame→joinery; ridge-vent + box-vent→roof; solar-panel frame→roof (the dark product-specific cell face is left as-is). Each definition also gets its dominant `surfaceRole` token. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): legacy-roof migration must use a non-zero wallHeight The render-modes/#330 merge left the legacy roof→roof-segment migration writing `wallHeight: 0`. With #330's pitch model that builds a flat, zero-volume wall CSG brush, which three-bvh-csg can't clip ("Coplanar clip not handled") and yields NaN positions — so the migrated old roof never renders. Use the schema default 0.5 (what new roofs use), giving a valid wall. The eave clamp + merged- geometry NaN guard added earlier stay as defense-in-depth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): guard slope frame against missing/NaN pitch (no more NaN geometry) getSegmentSlopeFrame used `pitch <= 0` to detect flat/zero-pitch, but an undefined or NaN pitch (a segment from an older migration that set `roofHeight` instead of `pitch`, or stale persisted data) slips past that check and computes Math.tan(NaN) → NaN tanTheta/activeRh → NaN segment geometry → the merged-roof CSG spews "Coplanar clip not handled" and NaN positions, so the roof never renders. Use `!(pitch > 0)` so any non-positive/non-finite pitch resolves to the flat frame. Self-heals bad data regardless of how the segment was produced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): migration guarantees a positive pitch for every roof-segment Segments saved with neither a valid pitch nor a roofHeight (older/partial saves, e.g. landing home-graph) fell through the legacy roofHeight->pitch branch, leaving pitch undefined. The slope-frame guard then resolved them to a flat frame, so the roof rendered as a slab instead of pitched. Branch 2b now normalises any segment lacking a valid pitch: derive from roofHeight when present, else fall back to the schema default (40deg). The migration result is cast (not zod-parsed), so this is the only place the default lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
862 lines
31 KiB
TypeScript
862 lines
31 KiB
TypeScript
import {
|
||
type DormerNode,
|
||
getActiveRoofHeight,
|
||
getPitchFromActiveRoofHeight,
|
||
ROOF_SHAPE_DEFAULTS,
|
||
type RoofSegmentNode,
|
||
} from '@pascal-app/core'
|
||
import {
|
||
ADDITION,
|
||
Brush,
|
||
computeGeometryBoundsTree,
|
||
csgEvaluator,
|
||
csgGeometry,
|
||
csgMaterials,
|
||
getRoofSegmentBrushes,
|
||
mapRoofGroupMaterialIndex,
|
||
prepareBrushForCSG,
|
||
remapRoofShellFaces,
|
||
roofCsgDummyMats,
|
||
SUBTRACTION,
|
||
} from '@pascal-app/viewer'
|
||
import * as THREE from 'three'
|
||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||
|
||
// Legacy default for the hung-wall (skirt) height. Used as a fallback
|
||
// when `dormer.wallSkirtHeight` is undefined (e.g. old saved scenes).
|
||
const DORMER_DROP_BELOW = 2
|
||
|
||
function dormerSkirtHeight(dormer: DormerNode): number {
|
||
return Math.max(0.05, dormer.wallSkirtHeight ?? DORMER_DROP_BELOW)
|
||
}
|
||
|
||
export const DORMER_GABLE_MATERIAL_INDEX = 4
|
||
|
||
const _yAxis = new THREE.Vector3(0, 1, 0)
|
||
const _scale = new THREE.Vector3(1, 1, 1)
|
||
|
||
/**
|
||
* Cheap silhouette geometry. Used as a fallback when CSG cannot run
|
||
* (missing host brushes, thrown exception, degenerate inputs) and as
|
||
* the live preview during slider drags so we don't re-run CSG on every
|
||
* pointer move. Also used by the placement / move-tool ghost.
|
||
*
|
||
* Builds a rectangular body + simple roof in dormer-mesh-local. For
|
||
* `flat` dormers the roof triangle is skipped. Other roof types use
|
||
* the gable approximation — it's a rough silhouette by design.
|
||
*
|
||
* The wall sits at material slot 0 and the roof at slot 3 so it picks
|
||
* up the same material array the renderer passes for the CSG output.
|
||
*/
|
||
export function buildDormerFallbackGeometry(dormer: DormerNode): THREE.BufferGeometry {
|
||
const w = Math.max(0.05, dormer.width)
|
||
const d = Math.max(0.05, dormer.depth)
|
||
const wallH = Math.max(0.05, dormer.height)
|
||
const roofH = Math.max(0, dormer.roofHeight)
|
||
const skirt = dormerSkirtHeight(dormer)
|
||
const isFlat = dormer.roofType === 'flat' || roofH === 0
|
||
|
||
// Body box: foot at y = -skirt, top at y = wallH.
|
||
const body = new THREE.BoxGeometry(w, wallH + skirt, d)
|
||
body.translate(0, (wallH - skirt) / 2, 0)
|
||
const bIdx = body.getIndex()?.count ?? 0
|
||
body.clearGroups()
|
||
body.addGroup(0, bIdx, 0)
|
||
|
||
if (isFlat) {
|
||
if (!body.getAttribute('normal')) body.computeVertexNormals()
|
||
return body
|
||
}
|
||
|
||
// Roof: extruded triangle from eave (y = wallH) to peak (y = wallH + roofH).
|
||
// Apex points along +Y, base spans the width. Extrude along Z (depth).
|
||
const roofShape = new THREE.Shape()
|
||
roofShape.moveTo(-w / 2, 0)
|
||
roofShape.lineTo(w / 2, 0)
|
||
roofShape.lineTo(0, roofH)
|
||
roofShape.lineTo(-w / 2, 0)
|
||
const roof = new THREE.ExtrudeGeometry(roofShape, { depth: d, bevelEnabled: false })
|
||
roof.translate(0, wallH, -d / 2)
|
||
|
||
const rIdx = roof.getIndex()?.count ?? 0
|
||
roof.clearGroups()
|
||
roof.addGroup(0, rIdx, 3)
|
||
|
||
const merged = mergeGeometries([body, roof], true) ?? body
|
||
body.dispose()
|
||
roof.dispose()
|
||
if (!merged.getAttribute('normal')) merged.computeVertexNormals()
|
||
return merged
|
||
}
|
||
|
||
export function createDormerArchShape(w: number, h: number, archHeight: number): THREE.Shape {
|
||
const hw = w / 2
|
||
const hh = h / 2
|
||
const clampedArch = Math.min(Math.max(archHeight, 0.01), Math.max(h, 0.01))
|
||
const springY = hh - clampedArch
|
||
const segments = 32
|
||
|
||
const shape = new THREE.Shape()
|
||
shape.moveTo(-hw, -hh)
|
||
shape.lineTo(hw, -hh)
|
||
shape.lineTo(hw, springY)
|
||
for (let i = 1; i <= segments; i++) {
|
||
const x = hw + (-hw - hw) * (i / segments)
|
||
const t = Math.min(Math.abs(x) / hw, 1)
|
||
const y = springY + clampedArch * Math.sqrt(Math.max(1 - t * t, 0))
|
||
shape.lineTo(x, y)
|
||
}
|
||
shape.lineTo(-hw, -hh)
|
||
shape.closePath()
|
||
return shape
|
||
}
|
||
|
||
export function normalizeDormerCornerRadii(
|
||
radii: [number, number, number, number],
|
||
w: number,
|
||
h: number,
|
||
): [number, number, number, number] {
|
||
const r = radii.map((v) => Math.max(v, 0)) as [number, number, number, number]
|
||
const scale = Math.min(
|
||
1,
|
||
Math.max(w, 0) / Math.max(r[0] + r[1], 1e-6),
|
||
Math.max(w, 0) / Math.max(r[3] + r[2], 1e-6),
|
||
Math.max(h, 0) / Math.max(r[0] + r[3], 1e-6),
|
||
Math.max(h, 0) / Math.max(r[1] + r[2], 1e-6),
|
||
)
|
||
if (scale >= 1) return r
|
||
return r.map((v) => v * scale) as [number, number, number, number]
|
||
}
|
||
|
||
export function createDormerRoundedShape(
|
||
w: number,
|
||
h: number,
|
||
radii: [number, number, number, number],
|
||
): THREE.Shape {
|
||
const hw = w / 2
|
||
const hh = h / 2
|
||
const [tl, tr, br, bl] = normalizeDormerCornerRadii(radii, w, h)
|
||
|
||
const shape = new THREE.Shape()
|
||
shape.moveTo(-hw + bl, -hh)
|
||
shape.lineTo(hw - br, -hh)
|
||
if (br > 0) shape.absarc(hw - br, -hh + br, br, -Math.PI / 2, 0, false)
|
||
else shape.lineTo(hw, -hh)
|
||
shape.lineTo(hw, hh - tr)
|
||
if (tr > 0) shape.absarc(hw - tr, hh - tr, tr, 0, Math.PI / 2, false)
|
||
else shape.lineTo(hw, hh)
|
||
shape.lineTo(-hw + tl, hh)
|
||
if (tl > 0) shape.absarc(-hw + tl, hh - tl, tl, Math.PI / 2, Math.PI, false)
|
||
else shape.lineTo(-hw, hh)
|
||
shape.lineTo(-hw, -hh + bl)
|
||
if (bl > 0) shape.absarc(-hw + bl, -hh + bl, bl, Math.PI, (3 * Math.PI) / 2, false)
|
||
else shape.lineTo(-hw, -hh)
|
||
shape.closePath()
|
||
return shape
|
||
}
|
||
|
||
function resolveDormerRadii(
|
||
dormer: DormerNode,
|
||
w: number,
|
||
h: number,
|
||
): [number, number, number, number] {
|
||
return normalizeDormerCornerRadii(dormer.windowCornerRadii, w, h)
|
||
}
|
||
|
||
function createDormerWindowCutGeometry(
|
||
dormer: DormerNode,
|
||
w: number,
|
||
h: number,
|
||
depth: number,
|
||
): THREE.BufferGeometry {
|
||
const shape = dormer.windowShape ?? 'rectangle'
|
||
if (shape === 'arch') {
|
||
const s = createDormerArchShape(w, h, dormer.windowArchHeight ?? 0.35)
|
||
const geo = new THREE.ExtrudeGeometry(s, { depth, bevelEnabled: false, curveSegments: 24 })
|
||
geo.translate(0, 0, -depth / 2)
|
||
return geo
|
||
}
|
||
if (shape === 'rounded') {
|
||
const radii = resolveDormerRadii(dormer, w, h)
|
||
const s = createDormerRoundedShape(w, h, radii)
|
||
const geo = new THREE.ExtrudeGeometry(s, { depth, bevelEnabled: false, curveSegments: 24 })
|
||
geo.translate(0, 0, -depth / 2)
|
||
return geo
|
||
}
|
||
return new THREE.BoxGeometry(w, h, depth)
|
||
}
|
||
|
||
/**
|
||
* Which faces of a dormer are exposed (not fully buried in the host
|
||
* roof). "front" = mesh-local +Z, "back" = mesh-local −Z (after the
|
||
* +π/2 yaw bake for non-shed roofs). A face is exposed when the
|
||
* dormer's total wall top exceeds the host roof surface at that face's
|
||
* Z position.
|
||
*/
|
||
export function getDormerExposedFaces(
|
||
dormer: DormerNode,
|
||
hostSegment: RoofSegmentNode,
|
||
): { front: boolean; back: boolean } {
|
||
const halfDepth = dormer.depth / 2
|
||
const dormerZ = dormer.position[2] ?? 0
|
||
const dormerY = dormer.position[1] ?? 0
|
||
const rot = dormer.rotation ?? 0
|
||
|
||
// Gable-face centres in segment-local Z (accounts for dormer yaw).
|
||
const frontZ = dormerZ + halfDepth * Math.cos(rot)
|
||
const backZ = dormerZ - halfDepth * Math.cos(rot)
|
||
|
||
const dormerWallTop = dormerY + dormer.height
|
||
|
||
const hostWh = hostSegment.wallHeight ?? 0.5
|
||
const hostRh = getActiveRoofHeight(hostSegment)
|
||
const hostDepth = hostSegment.depth ?? 4
|
||
|
||
const roofHeightAtZ = (segZ: number): number => {
|
||
const hostType = hostSegment.roofType ?? 'gable'
|
||
if (hostType === 'flat') return hostWh
|
||
if (hostType === 'shed') {
|
||
const t = Math.max(0, Math.min(1, (segZ + hostDepth / 2) / Math.max(hostDepth, 0.01)))
|
||
return hostWh + hostRh * (1 - t)
|
||
}
|
||
const halfD = Math.max(hostDepth / 2, 0.01)
|
||
const t = Math.max(0, Math.min(1, Math.abs(segZ) / halfD))
|
||
return hostWh + hostRh * (1 - t)
|
||
}
|
||
|
||
// A face is "exposed" only if the dormer's wall actually pokes above
|
||
// the host roof there by a meaningful amount — otherwise the wall is
|
||
// CSG-buried and any window we render at that face will hover with
|
||
// no wall behind it. A 5cm threshold suppresses the borderline-cases
|
||
// where the wall top is essentially level with the slope.
|
||
const minPokeOut = 0.05
|
||
return {
|
||
front: dormerWallTop - roofHeightAtZ(frontZ) > minPokeOut,
|
||
back: dormerWallTop - roofHeightAtZ(backZ) > minPokeOut,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Computed dimensions for the window opening on a dormer's gable face.
|
||
* The skirt (the wall extension below the eave used for CSG-trim) is
|
||
* `DORMER_DROP_BELOW` tall, so the window sits within that band.
|
||
*/
|
||
export function getDormerSkirtWindowDims(dormer: DormerNode): {
|
||
width: number
|
||
height: number
|
||
centerY: number
|
||
offsetX: number
|
||
} {
|
||
const skirtH = dormerSkirtHeight(dormer)
|
||
const maxW = Math.max(dormer.width - 0.1, 0.1)
|
||
const maxH = Math.max(skirtH - 0.1, 0.1)
|
||
const width = Math.min(Math.max(dormer.windowWidth ?? 1.2, 0.1), maxW)
|
||
const height = Math.min(Math.max(dormer.windowHeight ?? 1.2, 0.1), maxH)
|
||
const offsetX = dormer.windowOffsetX ?? 0
|
||
const offsetY = dormer.windowOffsetY ?? 0
|
||
const centerY = -(skirtH / 2) + offsetY
|
||
return { width, height, centerY, offsetX }
|
||
}
|
||
|
||
/**
|
||
* Build the trimmed dormer geometry hosted on a roof segment. The
|
||
* dormer's own walls+roof are generated via `getRoofSegmentBrushes`
|
||
* on a virtual segment, then the host segment's filled solid is
|
||
* CSG-subtracted in dormer-mesh-local space. Window openings are then
|
||
* subtracted on each exposed gable face.
|
||
*/
|
||
export function generateDormerGeometry(
|
||
dormer: DormerNode,
|
||
hostSegment: RoofSegmentNode,
|
||
): THREE.BufferGeometry {
|
||
const isShed = dormer.roofType === 'shed'
|
||
const yawBake = isShed ? 0 : Math.PI / 2
|
||
const segWidth = isShed ? dormer.width : dormer.depth
|
||
const segDepth = isShed ? dormer.depth : dormer.width
|
||
const skirt = dormerSkirtHeight(dormer)
|
||
|
||
const vsWidth = Math.max(0.05, segWidth)
|
||
const vsDepth = Math.max(0.05, segDepth)
|
||
const vsActiveRh = Math.max(0, dormer.roofHeight)
|
||
const virtualSegment: RoofSegmentNode = {
|
||
object: 'node',
|
||
id: `rseg_dormer_${dormer.id}` as RoofSegmentNode['id'],
|
||
type: 'roof-segment',
|
||
parentId: null,
|
||
visible: true,
|
||
metadata: null,
|
||
children: [],
|
||
position: [0, 0, 0],
|
||
rotation: 0,
|
||
roofType: dormer.roofType,
|
||
width: vsWidth,
|
||
depth: vsDepth,
|
||
wallHeight: Math.max(0.05, dormer.height) + skirt,
|
||
// The dormer schema still expresses its roof as a height; translate
|
||
// to the pitch the segment math now expects so the virtual segment
|
||
// produces an identical peak.
|
||
pitch: getPitchFromActiveRoofHeight({
|
||
roofType: dormer.roofType,
|
||
width: vsWidth,
|
||
depth: vsDepth,
|
||
roofHeight: vsActiveRh,
|
||
}),
|
||
// Dormers don't expose multi-slope shape tuning; bake the schema
|
||
// defaults so the virtualSegment renders the canonical kink positions.
|
||
...ROOF_SHAPE_DEFAULTS,
|
||
wallThickness: 0.05,
|
||
deckThickness: 0.04,
|
||
overhang: 0.08,
|
||
shingleThickness: 0.02,
|
||
}
|
||
|
||
const dormerBrushes = getRoofSegmentBrushes(virtualSegment)
|
||
if (!dormerBrushes) {
|
||
// biome-ignore lint/suspicious/noConsole: keep diagnostic — fallback path.
|
||
console.warn('[dormer] getRoofSegmentBrushes returned null; using fallback silhouette.')
|
||
return buildDormerFallbackGeometry(dormer)
|
||
}
|
||
|
||
let resultGeo = new THREE.BufferGeometry()
|
||
let dormerSolid: Brush | null = null
|
||
let hostSolid: Brush | null = null
|
||
|
||
try {
|
||
const hollowWall = csgEvaluator.evaluate(
|
||
dormerBrushes.wallBrush,
|
||
dormerBrushes.innerBrush,
|
||
SUBTRACTION,
|
||
) as Brush
|
||
const shinDeck = csgEvaluator.evaluate(
|
||
dormerBrushes.shinSlab,
|
||
dormerBrushes.deckSlab,
|
||
ADDITION,
|
||
) as Brush
|
||
dormerSolid = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION) as Brush
|
||
hollowWall.geometry.dispose()
|
||
shinDeck.geometry.dispose()
|
||
|
||
const bakeMatrix = new THREE.Matrix4().compose(
|
||
new THREE.Vector3(0, -skirt, 0),
|
||
new THREE.Quaternion().setFromAxisAngle(_yAxis, yawBake),
|
||
_scale,
|
||
)
|
||
csgGeometry(dormerSolid).applyMatrix4(bakeMatrix)
|
||
prepareBrushForCSG(dormerSolid)
|
||
|
||
const hostBrushes = getRoofSegmentBrushes(hostSegment)
|
||
if (hostBrushes) {
|
||
const wallPlusDeck = csgEvaluator.evaluate(
|
||
hostBrushes.wallBrush,
|
||
hostBrushes.deckSlab,
|
||
ADDITION,
|
||
) as Brush
|
||
hostSolid = csgEvaluator.evaluate(wallPlusDeck, hostBrushes.shinSlab, ADDITION) as Brush
|
||
wallPlusDeck.geometry.dispose()
|
||
hostBrushes.deckSlab.geometry.dispose()
|
||
hostBrushes.shinSlab.geometry.dispose()
|
||
hostBrushes.wallBrush.geometry.dispose()
|
||
hostBrushes.innerBrush.geometry.dispose()
|
||
|
||
// Union a deep ground box covering the host footprint so the
|
||
// dormer's skirt (extending below y=0) has something to subtract.
|
||
const groundMargin = Math.max(hostSegment.width, hostSegment.depth) * 2 + 4
|
||
const groundBoxGeo = new THREE.BoxGeometry(groundMargin, 100, groundMargin)
|
||
groundBoxGeo.translate(0, -50, 0)
|
||
const indexCount = groundBoxGeo.getIndex()?.count ?? 0
|
||
groundBoxGeo.clearGroups()
|
||
groundBoxGeo.addGroup(0, indexCount, 0)
|
||
computeGeometryBoundsTree(groundBoxGeo)
|
||
const groundBrush = new Brush(groundBoxGeo, roofCsgDummyMats[0])
|
||
groundBrush.updateMatrixWorld()
|
||
const fullTrim = csgEvaluator.evaluate(hostSolid, groundBrush, ADDITION) as Brush
|
||
hostSolid.geometry.dispose()
|
||
groundBrush.geometry.dispose()
|
||
hostSolid = fullTrim
|
||
|
||
// Host brushes live in segment-local. Bring them into
|
||
// dormer-mesh-local by inverting T(node.position) · R_y(node.rotation).
|
||
const segToMesh = new THREE.Matrix4()
|
||
.compose(
|
||
new THREE.Vector3(
|
||
dormer.position[0] ?? 0,
|
||
dormer.position[1] ?? 0,
|
||
dormer.position[2] ?? 0,
|
||
),
|
||
new THREE.Quaternion().setFromAxisAngle(_yAxis, dormer.rotation),
|
||
_scale,
|
||
)
|
||
.invert()
|
||
csgGeometry(hostSolid).applyMatrix4(segToMesh)
|
||
prepareBrushForCSG(hostSolid)
|
||
|
||
const trimmed = csgEvaluator.evaluate(dormerSolid, hostSolid, SUBTRACTION) as Brush
|
||
dormerSolid.geometry.dispose()
|
||
hostSolid.geometry.dispose()
|
||
hostSolid = null
|
||
dormerSolid = trimmed
|
||
}
|
||
|
||
// Cut window openings on exposed gable faces.
|
||
const exposed = getDormerExposedFaces(dormer, hostSegment)
|
||
const skirtWin = getDormerSkirtWindowDims(dormer)
|
||
const gableHalfZ = dormer.depth / 2
|
||
const cutDepth = 0.4
|
||
|
||
const cutFace = (zSign: number) => {
|
||
const cutGeo = createDormerWindowCutGeometry(
|
||
dormer,
|
||
skirtWin.width,
|
||
skirtWin.height,
|
||
cutDepth,
|
||
)
|
||
cutGeo.translate(skirtWin.offsetX, skirtWin.centerY, zSign * gableHalfZ)
|
||
if (!cutGeo.getIndex()) {
|
||
const posCount = cutGeo.getAttribute('position').count
|
||
const idx = new Uint32Array(posCount)
|
||
for (let i = 0; i < posCount; i++) idx[i] = i
|
||
cutGeo.setIndex(new THREE.BufferAttribute(idx, 1))
|
||
}
|
||
const idxCount = cutGeo.getIndex()!.count
|
||
cutGeo.clearGroups()
|
||
cutGeo.addGroup(0, idxCount, 0)
|
||
computeGeometryBoundsTree(cutGeo)
|
||
const brush = new Brush(cutGeo, roofCsgDummyMats[0])
|
||
brush.updateMatrixWorld()
|
||
const result = csgEvaluator.evaluate(dormerSolid!, brush, SUBTRACTION) as Brush
|
||
dormerSolid!.geometry.dispose()
|
||
brush.geometry.dispose()
|
||
dormerSolid = result
|
||
}
|
||
|
||
if (exposed.front) cutFace(+1)
|
||
if (exposed.back) cutFace(-1)
|
||
|
||
resultGeo = csgGeometry(dormerSolid)
|
||
const resultMaterials = csgMaterials(dormerSolid)
|
||
|
||
const matToIndex = new Map<THREE.Material, number>([
|
||
[roofCsgDummyMats[0], 0],
|
||
[roofCsgDummyMats[1], 1],
|
||
[roofCsgDummyMats[2], 2],
|
||
[roofCsgDummyMats[3], 3],
|
||
])
|
||
for (const group of resultGeo.groups) {
|
||
group.materialIndex = mapRoofGroupMaterialIndex(
|
||
group.materialIndex,
|
||
resultMaterials,
|
||
matToIndex,
|
||
)
|
||
}
|
||
remapRoofShellFaces(resultGeo, virtualSegment)
|
||
splitDormerGableMaterial(resultGeo, dormer.height, DORMER_GABLE_MATERIAL_INDEX)
|
||
} catch (e) {
|
||
// biome-ignore lint/suspicious/noConsole: dormer CSG can throw; keep diagnostic.
|
||
console.error('[dormer] CSG failed, falling back to silhouette:', e)
|
||
if (dormerSolid) {
|
||
try {
|
||
dormerSolid.geometry.dispose()
|
||
} catch {}
|
||
}
|
||
if (hostSolid) {
|
||
try {
|
||
hostSolid.geometry.dispose()
|
||
} catch {}
|
||
}
|
||
return buildDormerFallbackGeometry(dormer)
|
||
}
|
||
|
||
// If CSG produced zero triangles (host fully buried it, or one of the
|
||
// boolean ops collapsed to empty), fall back to the silhouette so the
|
||
// dormer is at least visible.
|
||
const triCount = resultGeo.getIndex()?.count ?? resultGeo.getAttribute('position')?.count ?? 0
|
||
if (triCount === 0) {
|
||
// biome-ignore lint/suspicious/noConsole: keep diagnostic — empty CSG.
|
||
console.warn('[dormer] CSG produced empty geometry; using fallback silhouette.')
|
||
return buildDormerFallbackGeometry(dormer)
|
||
}
|
||
|
||
resultGeo.computeVertexNormals()
|
||
ensureUv2Attribute(resultGeo)
|
||
return resultGeo
|
||
}
|
||
|
||
/**
|
||
* Build the dormer cut shape in dormer-mesh-local coordinates. The
|
||
* returned geometry is centered at X=Z=0 and spans Y ∈ [-skirt, peak]
|
||
* — the caller layers on the dormer's yaw + position to bring it into
|
||
* segment-local space.
|
||
*
|
||
* Shapes per roof type:
|
||
* - **flat**: a plain box (top flush with the eave; the
|
||
* dormer body has no roof above wallH).
|
||
* - **shed**: trapezoid in YZ, extruded along X. Eave
|
||
* at z=+d/2 (y=wallH), peak at z=-d/2
|
||
* (y=wallH+roofH) — matches the slope
|
||
* direction the dormer body uses.
|
||
* - **gable / gambrel**: pentagon (rectangle + symmetric triangle)
|
||
* in XY, extruded along Z. Ridge runs
|
||
* along Z (mesh-Z = virtualSegment-X after
|
||
* the yaw bake).
|
||
* - **hip / dutch / mansard**: pyramid — rectangular base, single
|
||
* apex at the peak. Narrows on all four
|
||
* sides.
|
||
*
|
||
* Gambrel / dutch / mansard fall back to gable / hip rather than the
|
||
* legacy CSG-derived geometry, because three-bvh-csg's three-way
|
||
* subtraction in the merged-roof loop can't accept CSG-derived
|
||
* brushes without corrupting the result. The dormer body itself still
|
||
* carries the precise per-type shape; the cut just needs to clear
|
||
* enough of the host shell for the body to sit cleanly.
|
||
*/
|
||
export function buildDormerCutShape(
|
||
roofType: DormerNode['roofType'],
|
||
innerW: number,
|
||
innerD: number,
|
||
skirt: number,
|
||
wallH: number,
|
||
roofH: number,
|
||
): THREE.BufferGeometry {
|
||
const hw = innerW / 2
|
||
const hd = innerD / 2
|
||
|
||
if (roofType === 'flat') {
|
||
const geo = new THREE.BoxGeometry(innerW, skirt + wallH, innerD)
|
||
geo.translate(0, (wallH - skirt) / 2, 0)
|
||
return geo
|
||
}
|
||
|
||
if (roofType === 'shed') {
|
||
// Trapezoid in shape XY → extruded along Z (shape's natural
|
||
// extrude axis) → rotated +π/2 around Y so the shape's X axis
|
||
// ends up along mesh-(-Z) and the extrusion ends up along mesh-X.
|
||
//
|
||
// `getRoofSegmentBrushes`'s shed slope puts the peak at z=-d/2
|
||
// and the eave at z=+d/2 (matching the `roofHeightAtZ` helper).
|
||
// After the +π/2 rotation, shape-X=+hd → mesh-Z=-hd, so place the
|
||
// PEAK at shape-X=+hd and the EAVE at shape-X=-hd to keep the cut
|
||
// aligned with the dormer body's actual slope direction.
|
||
const shape = new THREE.Shape()
|
||
shape.moveTo(-hd, -skirt)
|
||
shape.lineTo(hd, -skirt)
|
||
shape.lineTo(hd, wallH + roofH) // peak (lands at mesh-Z = -d/2)
|
||
shape.lineTo(-hd, wallH) // eave (lands at mesh-Z = +d/2)
|
||
shape.closePath()
|
||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||
depth: innerW,
|
||
bevelEnabled: false,
|
||
})
|
||
geo.rotateY(Math.PI / 2)
|
||
geo.translate(-innerW / 2, 0, 0) // centre along X
|
||
return geo
|
||
}
|
||
|
||
if (roofType === 'hip' || roofType === 'dutch' || roofType === 'mansard') {
|
||
// Truncated pyramid: rectangular base + eave rect + a top ridge
|
||
// along the longer axis. Mirrors `getRoofSegmentBrushes`'s hip:
|
||
// run = min(w, d) / 2
|
||
// ridge length = |w - d| (zero when w == d → degenerates to a
|
||
// single apex point)
|
||
//
|
||
// For non-shed dormers, `virtualSegment.width = dormer.depth` runs
|
||
// along mesh-Z, so the longer-axis ridge direction follows the
|
||
// larger of innerD vs. innerW.
|
||
//
|
||
// Triangle windings below are CCW from outside (verified
|
||
// case-by-case via cross-product test); three-bvh-csg uses the
|
||
// normals to determine inside/outside for SUBTRACTION, so an
|
||
// inverted winding here would make the cut subtract the
|
||
// complement of the dormer footprint — a hand-built pyramid is
|
||
// the only shape in this file that does NOT get its windings from
|
||
// Three.js geometry primitives, so we have to wind it carefully.
|
||
const longerIsZ = innerD >= innerW
|
||
const ridgeHalfLen = Math.max(0, (Math.max(innerW, innerD) - Math.min(innerW, innerD)) / 2)
|
||
const peakY = wallH + roofH
|
||
|
||
// Ridge endpoints in mesh frame.
|
||
const ridgeA = longerIsZ
|
||
? ([0, peakY, -ridgeHalfLen] as const)
|
||
: ([-ridgeHalfLen, peakY, 0] as const)
|
||
const ridgeB = longerIsZ
|
||
? ([0, peakY, ridgeHalfLen] as const)
|
||
: ([ridgeHalfLen, peakY, 0] as const)
|
||
|
||
const positions = new Float32Array([
|
||
// 0..3 = bottom rect (y = -skirt) — NW, NE, SE, SW
|
||
-hw,
|
||
-skirt,
|
||
-hd,
|
||
hw,
|
||
-skirt,
|
||
-hd,
|
||
hw,
|
||
-skirt,
|
||
hd,
|
||
-hw,
|
||
-skirt,
|
||
hd,
|
||
// 4..7 = eave rect (y = wallH) — NW, NE, SE, SW
|
||
-hw,
|
||
wallH,
|
||
-hd,
|
||
hw,
|
||
wallH,
|
||
-hd,
|
||
hw,
|
||
wallH,
|
||
hd,
|
||
-hw,
|
||
wallH,
|
||
hd,
|
||
// 8 = ridge endpoint A (- end along the ridge axis)
|
||
ridgeA[0],
|
||
ridgeA[1],
|
||
ridgeA[2],
|
||
// 9 = ridge endpoint B (+ end along the ridge axis)
|
||
ridgeB[0],
|
||
ridgeB[1],
|
||
ridgeB[2],
|
||
])
|
||
|
||
// Triangles (CCW from outside). Windings verified by computing
|
||
// `(v1-v0) × (v2-v0)` for each triangle and checking the normal
|
||
// points along the expected outward direction.
|
||
const indices: number[] = [
|
||
// Bottom (normal -Y).
|
||
0, 1, 2, 0, 2, 3,
|
||
// -Z wall (normal -Z) — eave 4,5 on top, base 0,1 below.
|
||
1, 0, 4, 1, 4, 5,
|
||
// +X wall (normal +X) — eave 5,6 on top, base 1,2 below.
|
||
2, 1, 5, 2, 5, 6,
|
||
// +Z wall (normal +Z) — eave 6,7 on top, base 2,3 below.
|
||
3, 2, 6, 3, 6, 7,
|
||
// -X wall (normal -X) — eave 7,4 on top, base 3,0 below.
|
||
0, 3, 7, 0, 7, 4,
|
||
]
|
||
|
||
if (longerIsZ) {
|
||
// Ridge along Z. A=8 at -Z end, B=9 at +Z end.
|
||
// -Z end face (triangle, normal -Z/+Y): 4, 8, 5
|
||
// +X side face (quad, normal +X/+Y): 5, 9, 6 + 5, 8, 9
|
||
// +Z end face (triangle, normal +Z/+Y): 6, 9, 7
|
||
// -X side face (quad, normal -X/+Y): 7, 8, 4 + 7, 9, 8
|
||
indices.push(4, 8, 5)
|
||
indices.push(5, 9, 6, 5, 8, 9)
|
||
indices.push(6, 9, 7)
|
||
indices.push(7, 8, 4, 7, 9, 8)
|
||
} else {
|
||
// Ridge along X. A=8 at -X end, B=9 at +X end.
|
||
// -X end face (triangle, normal -X/+Y): 4, 7, 8
|
||
// -Z side face (quad, normal -Z/+Y): 4, 9, 5 + 4, 8, 9
|
||
// +X end face (triangle, normal +X/+Y): 5, 9, 6
|
||
// +Z side face (quad, normal +Z/+Y): 6, 8, 7 + 6, 9, 8
|
||
indices.push(4, 7, 8)
|
||
indices.push(4, 9, 5, 4, 8, 9)
|
||
indices.push(5, 9, 6)
|
||
indices.push(6, 8, 7, 6, 9, 8)
|
||
}
|
||
|
||
const geo = new THREE.BufferGeometry()
|
||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||
geo.setIndex(new THREE.BufferAttribute(new Uint16Array(indices), 1))
|
||
// CSG evaluator requires 'uv'; cut brushes are never rendered so zeros are fine.
|
||
geo.setAttribute(
|
||
'uv',
|
||
new THREE.BufferAttribute(new Float32Array((positions.length / 3) * 2), 2),
|
||
)
|
||
geo.computeVertexNormals()
|
||
return geo
|
||
}
|
||
|
||
if (roofType === 'gambrel') {
|
||
// Gambrel: two-segment slope per side. `getRoofSegmentBrushes`
|
||
// uses `run = depth / 4` and `rise = activeRh * 0.6` for the
|
||
// outer (steeper) portion. The cut profile in XY mirrors that —
|
||
// straight from eave up to a kink at (±hw/2, wallH + 0.6*roofH),
|
||
// then up to the ridge at (0, wallH + roofH). Extruded along Z.
|
||
const kinkX = hw / 2
|
||
const kinkY = wallH + roofH * 0.6
|
||
const shape = new THREE.Shape()
|
||
shape.moveTo(-hw, -skirt)
|
||
shape.lineTo(hw, -skirt)
|
||
shape.lineTo(hw, wallH)
|
||
shape.lineTo(kinkX, kinkY)
|
||
shape.lineTo(0, wallH + roofH)
|
||
shape.lineTo(-kinkX, kinkY)
|
||
shape.lineTo(-hw, wallH)
|
||
shape.closePath()
|
||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||
depth: innerD,
|
||
bevelEnabled: false,
|
||
})
|
||
geo.translate(0, 0, -innerD / 2)
|
||
return geo
|
||
}
|
||
|
||
// gable (and any unrecognised type) — pentagon (rectangle +
|
||
// symmetric triangle peak), extruded along Z. Ridge runs along Z,
|
||
// matching mesh-Z which (for non-shed types) corresponds to the
|
||
// virtualSegment-X gable ridge direction after the +π/2 yaw bake
|
||
// the body geometry uses.
|
||
const shape = new THREE.Shape()
|
||
shape.moveTo(-hw, -skirt)
|
||
shape.lineTo(hw, -skirt)
|
||
shape.lineTo(hw, wallH)
|
||
shape.lineTo(0, wallH + roofH)
|
||
shape.lineTo(-hw, wallH)
|
||
shape.closePath()
|
||
const geo = new THREE.ExtrudeGeometry(shape, {
|
||
depth: innerD,
|
||
bevelEnabled: false,
|
||
})
|
||
geo.translate(0, 0, -innerD / 2)
|
||
return geo
|
||
}
|
||
|
||
/**
|
||
* Build the segment-local cut geometry the host roof's merge loop
|
||
* subtracts from its shin / deck / wall brushes so the dormer has a
|
||
* clean hole to poke through. Mirrors `generateDormerGeometry`'s
|
||
* virtual-segment + bake: build the inner shape in
|
||
* virtual-segment-local, apply the dormer's yaw + drop-below bake,
|
||
* then the dormer's segment-local position + rotation, so the
|
||
* geometry lives in host-segment-local — the same frame the
|
||
* merged-roof CSG loop operates in.
|
||
*
|
||
* Returns null on degenerate input so the caller can skip the cut.
|
||
*
|
||
* Coordinates are SEGMENT-LOCAL. The viewer welds vertices, attaches
|
||
* a single material group, and wraps the result in a Brush — see
|
||
* `wiki/architecture/node-definitions.md` (`capabilities.roofAccessory.buildCut`).
|
||
*/
|
||
export function buildDormerRoofCut(dormer: DormerNode): THREE.BufferGeometry | null {
|
||
// Defensive: bail on any non-finite or sub-millimeter dimension. A
|
||
// degenerate cut brush passed to three-bvh-csg can produce a result
|
||
// buffer with NaN positions / invalid indices, which the WebGPU
|
||
// renderer then refuses to submit ("Invalid CommandBuffer") and the
|
||
// error cascades to every subsequent submit.
|
||
const dims = [
|
||
dormer.width,
|
||
dormer.depth,
|
||
dormer.height,
|
||
dormer.roofHeight,
|
||
dormer.wallSkirtHeight,
|
||
dormer.position[0],
|
||
dormer.position[1],
|
||
dormer.position[2],
|
||
dormer.rotation,
|
||
]
|
||
for (const v of dims) {
|
||
if (!Number.isFinite(v)) return null
|
||
}
|
||
if (dormer.width < 0.01 || dormer.depth < 0.01) return null
|
||
|
||
const skirt = dormerSkirtHeight(dormer)
|
||
const wallThickness = 0.05
|
||
const innerW = Math.max(0.05, dormer.width - 2 * wallThickness)
|
||
const innerD = Math.max(0.05, dormer.depth - 2 * wallThickness)
|
||
const wallH = Math.max(0.05, dormer.height)
|
||
const roofH = Math.max(0, dormer.roofHeight)
|
||
|
||
// Cut footprint matches the dormer's INNER cavity (outer dim minus
|
||
// the 0.05m wall thickness on each side); the dormer's own outer
|
||
// wall sits over the resulting 5cm strip of host roof, hiding it
|
||
// and preventing the sub-pixel gap an exact outer-footprint cut
|
||
// would expose where dormer wall meets host roof.
|
||
//
|
||
// The shape ABOVE the eave varies per roof type so the host hole
|
||
// matches the dormer body's outline:
|
||
// - flat: box (no peak above the eave)
|
||
// - shed: trapezoid with one sloped top edge
|
||
// - hip / dutch / mansard: pyramid (narrows on all 4 sides)
|
||
// - gable / gambrel: pentagon (narrows along width axis)
|
||
const geo = buildDormerCutShape(dormer.roofType, innerW, innerD, skirt, wallH, roofH)
|
||
|
||
// Yaw in the geometry's own (un-translated) frame so the cut aligns
|
||
// with the dormer's footprint after rotation.
|
||
if (Math.abs(dormer.rotation) > 1e-4) {
|
||
geo.rotateY(dormer.rotation)
|
||
}
|
||
|
||
// Translate into segment-local. position[1] becomes the dormer's
|
||
// local Y = 0 (the wall foot / eave line); the shape's foot at
|
||
// local Y = -skirt then sits at world Y = position[1] - skirt.
|
||
geo.translate(dormer.position[0], dormer.position[1], dormer.position[2])
|
||
|
||
// The viewer's merge loop welds vertices, attaches a single material
|
||
// group, and wraps in a Brush before subtracting from the host
|
||
// segment's shin / deck / wall. Kinds only emit the raw shape.
|
||
return geo
|
||
}
|
||
|
||
/**
|
||
* Reassign slot-0 (wall) triangles whose entire footprint sits above
|
||
* `wallHeight` to a separate material slot — lets the renderer colour
|
||
* the rectangular wall and the gable triangle differently.
|
||
*/
|
||
function splitDormerGableMaterial(
|
||
geometry: THREE.BufferGeometry,
|
||
wallHeight: number,
|
||
gableMatIndex: number,
|
||
): void {
|
||
const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined
|
||
const index = geometry.getIndex()
|
||
if (!(position && index) || index.count === 0 || geometry.groups.length === 0) return
|
||
|
||
const triangleCount = index.count / 3
|
||
if (triangleCount === 0) return
|
||
|
||
const triangleMats = new Array<number>(triangleCount).fill(0)
|
||
for (const g of geometry.groups) {
|
||
const startTri = Math.floor(g.start / 3)
|
||
const endTri = Math.floor((g.start + g.count) / 3)
|
||
const mat = g.materialIndex ?? 0
|
||
for (let i = startTri; i < endTri; i++) triangleMats[i] = mat
|
||
}
|
||
|
||
const epsilon = 0.001
|
||
for (let i = 0; i < triangleCount; i++) {
|
||
if (triangleMats[i] !== 0) continue
|
||
const a = index.getX(i * 3)
|
||
const b = index.getX(i * 3 + 1)
|
||
const c = index.getX(i * 3 + 2)
|
||
const ya = position.getY(a)
|
||
const yb = position.getY(b)
|
||
const yc = position.getY(c)
|
||
if (ya > wallHeight + epsilon && yb > wallHeight + epsilon && yc > wallHeight + epsilon) {
|
||
triangleMats[i] = gableMatIndex
|
||
}
|
||
}
|
||
|
||
const sortedTri = Array.from({ length: triangleCount }, (_, i) => i)
|
||
sortedTri.sort((a, b) => (triangleMats[a] ?? 0) - (triangleMats[b] ?? 0))
|
||
|
||
const newIdx = new Uint32Array(index.count)
|
||
for (let i = 0; i < sortedTri.length; i++) {
|
||
const ti = sortedTri[i] as number
|
||
newIdx[i * 3] = index.getX(ti * 3)
|
||
newIdx[i * 3 + 1] = index.getX(ti * 3 + 1)
|
||
newIdx[i * 3 + 2] = index.getX(ti * 3 + 2)
|
||
}
|
||
geometry.setIndex(new THREE.BufferAttribute(newIdx, 1))
|
||
|
||
geometry.clearGroups()
|
||
let groupStart = 0
|
||
let curMat = triangleMats[sortedTri[0] as number] as number
|
||
for (let i = 1; i < sortedTri.length; i++) {
|
||
const mat = triangleMats[sortedTri[i] as number] as number
|
||
if (mat !== curMat) {
|
||
geometry.addGroup(groupStart, i * 3 - groupStart, curMat)
|
||
groupStart = i * 3
|
||
curMat = mat
|
||
}
|
||
}
|
||
geometry.addGroup(groupStart, sortedTri.length * 3 - groupStart, curMat)
|
||
}
|
||
|
||
function ensureUv2Attribute(geometry: THREE.BufferGeometry) {
|
||
const uv = geometry.getAttribute('uv')
|
||
if (!uv) return
|
||
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2))
|
||
}
|