@pascal-app/viewer 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +54 -21
  2. package/dist/components/viewer/daylight-atmosphere.d.ts +9 -0
  3. package/dist/components/viewer/daylight-atmosphere.d.ts.map +1 -0
  4. package/dist/components/viewer/daylight-atmosphere.js +315 -0
  5. package/dist/components/viewer/drone-controller.d.ts +15 -0
  6. package/dist/components/viewer/drone-controller.d.ts.map +1 -0
  7. package/dist/components/viewer/drone-controller.js +194 -0
  8. package/dist/components/viewer/glb-interactive.d.ts +4 -18
  9. package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
  10. package/dist/components/viewer/glb-interactive.js +32 -268
  11. package/dist/components/viewer/glb-replace-instances.d.ts +6 -3
  12. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -1
  13. package/dist/components/viewer/glb-replace-instances.js +5 -4
  14. package/dist/components/viewer/glb-scene-motion.d.ts +8 -0
  15. package/dist/components/viewer/glb-scene-motion.d.ts.map +1 -0
  16. package/dist/components/viewer/glb-scene-motion.js +57 -0
  17. package/dist/components/viewer/glb-scene.d.ts +5 -1
  18. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  19. package/dist/components/viewer/glb-scene.js +290 -49
  20. package/dist/components/viewer/glb-selection-callout.d.ts +17 -0
  21. package/dist/components/viewer/glb-selection-callout.d.ts.map +1 -0
  22. package/dist/components/viewer/glb-selection-callout.js +54 -0
  23. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  24. package/dist/components/viewer/glb-walkthrough-controller.js +6 -3
  25. package/dist/components/viewer/index.d.ts +5 -0
  26. package/dist/components/viewer/index.d.ts.map +1 -1
  27. package/dist/components/viewer/index.js +38 -10
  28. package/dist/components/viewer/lights.d.ts.map +1 -1
  29. package/dist/components/viewer/lights.js +192 -5
  30. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  31. package/dist/components/viewer/post-processing.js +150 -94
  32. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  33. package/dist/components/viewer/registered-systems.js +2 -2
  34. package/dist/components/viewer/scene-environment.js +4 -5
  35. package/dist/components/viewer/selection-callout.d.ts +51 -0
  36. package/dist/components/viewer/selection-callout.d.ts.map +1 -0
  37. package/dist/components/viewer/selection-callout.js +197 -0
  38. package/dist/components/viewer/unsupported-gpu-fallback.d.ts.map +1 -1
  39. package/dist/components/viewer/unsupported-gpu-fallback.js +21 -1
  40. package/dist/components/viewer/use-daylight-rooms.d.ts +13 -0
  41. package/dist/components/viewer/use-daylight-rooms.d.ts.map +1 -0
  42. package/dist/components/viewer/use-daylight-rooms.js +278 -0
  43. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  44. package/dist/components/viewer/viewer-camera.js +12 -1
  45. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  46. package/dist/hooks/use-gltf-ktx2.js +2 -1
  47. package/dist/index.d.ts +26 -8
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +28 -8
  50. package/dist/lib/asset-url.d.ts +0 -1
  51. package/dist/lib/asset-url.d.ts.map +1 -1
  52. package/dist/lib/asset-url.js +3 -3
  53. package/dist/lib/daylight/ground.d.ts +21 -0
  54. package/dist/lib/daylight/ground.d.ts.map +1 -0
  55. package/dist/lib/daylight/ground.js +53 -0
  56. package/dist/lib/daylight/location.d.ts +32 -0
  57. package/dist/lib/daylight/location.d.ts.map +1 -0
  58. package/dist/lib/daylight/location.js +74 -0
  59. package/dist/lib/daylight/look.d.ts +33 -0
  60. package/dist/lib/daylight/look.d.ts.map +1 -0
  61. package/dist/lib/daylight/look.js +13 -0
  62. package/dist/lib/daylight/material-hooks.d.ts +2 -0
  63. package/dist/lib/daylight/material-hooks.d.ts.map +1 -0
  64. package/dist/lib/daylight/material-hooks.js +150 -0
  65. package/dist/lib/daylight/room-context.d.ts +12 -0
  66. package/dist/lib/daylight/room-context.d.ts.map +1 -0
  67. package/dist/lib/daylight/room-context.js +54 -0
  68. package/dist/lib/daylight/room-map.d.ts +124 -0
  69. package/dist/lib/daylight/room-map.d.ts.map +1 -0
  70. package/dist/lib/daylight/room-map.js +630 -0
  71. package/dist/lib/daylight/room-scene.d.ts +56 -0
  72. package/dist/lib/daylight/room-scene.d.ts.map +1 -0
  73. package/dist/lib/daylight/room-scene.js +391 -0
  74. package/dist/lib/daylight/rooms.d.ts +292 -0
  75. package/dist/lib/daylight/rooms.d.ts.map +1 -0
  76. package/dist/lib/daylight/rooms.js +1268 -0
  77. package/dist/lib/daylight/runtime.d.ts +33 -0
  78. package/dist/lib/daylight/runtime.d.ts.map +1 -0
  79. package/dist/lib/daylight/runtime.js +42 -0
  80. package/dist/lib/daylight/sky.d.ts +26 -0
  81. package/dist/lib/daylight/sky.d.ts.map +1 -0
  82. package/dist/lib/daylight/sky.js +217 -0
  83. package/dist/lib/daylight/solar.d.ts +46 -0
  84. package/dist/lib/daylight/solar.d.ts.map +1 -0
  85. package/dist/lib/daylight/solar.js +156 -0
  86. package/dist/lib/device-tier.d.ts +29 -0
  87. package/dist/lib/device-tier.d.ts.map +1 -0
  88. package/dist/lib/device-tier.js +71 -0
  89. package/dist/lib/dispose-object3d.d.ts.map +1 -1
  90. package/dist/lib/dispose-object3d.js +4 -2
  91. package/dist/lib/drone-flight.d.ts +44 -0
  92. package/dist/lib/drone-flight.d.ts.map +1 -0
  93. package/dist/lib/drone-flight.js +121 -0
  94. package/dist/lib/editor-highlight.d.ts +16 -0
  95. package/dist/lib/editor-highlight.d.ts.map +1 -0
  96. package/dist/lib/editor-highlight.js +33 -0
  97. package/dist/lib/foundation-materials.d.ts.map +1 -1
  98. package/dist/lib/foundation-materials.js +2 -4
  99. package/dist/lib/glb-merge.d.ts +23 -0
  100. package/dist/lib/glb-merge.d.ts.map +1 -0
  101. package/dist/lib/glb-merge.js +184 -0
  102. package/dist/lib/hardware-geometry.d.ts +21 -0
  103. package/dist/lib/hardware-geometry.d.ts.map +1 -0
  104. package/dist/lib/hardware-geometry.js +283 -0
  105. package/dist/lib/ktx2-loader.d.ts.map +1 -1
  106. package/dist/lib/ktx2-loader.js +2 -1
  107. package/dist/lib/layer-pass.d.ts +2 -0
  108. package/dist/lib/layer-pass.d.ts.map +1 -1
  109. package/dist/lib/layer-pass.js +20 -0
  110. package/dist/lib/materials.d.ts.map +1 -1
  111. package/dist/lib/materials.js +50 -23
  112. package/dist/lib/measurement-label.d.ts +4 -0
  113. package/dist/lib/measurement-label.d.ts.map +1 -0
  114. package/dist/lib/measurement-label.js +54 -0
  115. package/dist/lib/measurement-presentation.d.ts +5 -0
  116. package/dist/lib/measurement-presentation.d.ts.map +1 -0
  117. package/dist/lib/measurement-presentation.js +8 -0
  118. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  119. package/dist/lib/merged-outline-node.js +7 -1
  120. package/dist/lib/occlusion-culler.d.ts +34 -0
  121. package/dist/lib/occlusion-culler.d.ts.map +1 -0
  122. package/dist/lib/occlusion-culler.js +698 -0
  123. package/dist/lib/pointer-events.d.ts.map +1 -1
  124. package/dist/lib/pointer-events.js +3 -3
  125. package/dist/lib/post-processing-resources.d.ts +4 -0
  126. package/dist/lib/post-processing-resources.d.ts.map +1 -1
  127. package/dist/lib/post-processing-resources.js +5 -0
  128. package/dist/lib/readback-rows.d.ts +14 -0
  129. package/dist/lib/readback-rows.d.ts.map +1 -0
  130. package/dist/lib/readback-rows.js +31 -0
  131. package/dist/lib/reveal-pose.d.ts.map +1 -1
  132. package/dist/lib/reveal-pose.js +3 -0
  133. package/dist/lib/scene-matrices.d.ts +12 -0
  134. package/dist/lib/scene-matrices.d.ts.map +1 -0
  135. package/dist/lib/scene-matrices.js +287 -0
  136. package/dist/lib/scene-pass-mrt.d.ts +5 -2
  137. package/dist/lib/scene-pass-mrt.d.ts.map +1 -1
  138. package/dist/lib/scene-pass-mrt.js +7 -2
  139. package/dist/lib/scene-themes.d.ts +0 -2
  140. package/dist/lib/scene-themes.d.ts.map +1 -1
  141. package/dist/lib/scene-themes.js +0 -2
  142. package/dist/lib/scene-visibility.d.ts +2 -0
  143. package/dist/lib/scene-visibility.d.ts.map +1 -1
  144. package/dist/lib/scene-visibility.js +12 -3
  145. package/dist/lib/site-surfaces.d.ts +1 -8
  146. package/dist/lib/site-surfaces.d.ts.map +1 -1
  147. package/dist/lib/site-surfaces.js +0 -19
  148. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  149. package/dist/lib/snapshot-pipeline.js +24 -40
  150. package/dist/lib/texture-reference.d.ts.map +1 -1
  151. package/dist/lib/texture-reference.js +13 -10
  152. package/dist/lib/underground-ambiance.d.ts +0 -2
  153. package/dist/lib/underground-ambiance.d.ts.map +1 -1
  154. package/dist/lib/underground-ambiance.js +0 -2
  155. package/dist/lib/viewer-config.d.ts +21 -0
  156. package/dist/lib/viewer-config.d.ts.map +1 -0
  157. package/dist/lib/viewer-config.js +21 -0
  158. package/dist/lib/viewer-preference-storage.d.ts +7 -0
  159. package/dist/lib/viewer-preference-storage.d.ts.map +1 -0
  160. package/dist/lib/viewer-preference-storage.js +23 -0
  161. package/dist/store/use-item-light-pool.d.ts +3 -6
  162. package/dist/store/use-item-light-pool.d.ts.map +1 -1
  163. package/dist/store/use-item-light-pool.js +12 -0
  164. package/dist/store/use-viewer.d.ts +62 -59
  165. package/dist/store/use-viewer.d.ts.map +1 -1
  166. package/dist/store/use-viewer.js +106 -19
  167. package/dist/systems/ceiling/ceiling-system.d.ts +5 -0
  168. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  169. package/dist/systems/ceiling/ceiling-system.js +11 -1
  170. package/dist/systems/door/door-system.d.ts.map +1 -1
  171. package/dist/systems/door/door-system.js +501 -145
  172. package/dist/systems/door/leaf-profile.d.ts +11 -0
  173. package/dist/systems/door/leaf-profile.d.ts.map +1 -0
  174. package/dist/systems/door/leaf-profile.js +140 -0
  175. package/dist/systems/elevator/elevator-interaction-system.d.ts +16 -2
  176. package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -1
  177. package/dist/systems/elevator/elevator-interaction-system.js +41 -8
  178. package/dist/systems/geometry/geometry-status.d.ts +8 -0
  179. package/dist/systems/geometry/geometry-status.d.ts.map +1 -0
  180. package/dist/systems/geometry/geometry-status.js +24 -0
  181. package/dist/systems/interactive/callout-actions.d.ts +72 -0
  182. package/dist/systems/interactive/callout-actions.d.ts.map +1 -0
  183. package/dist/systems/interactive/callout-actions.js +212 -0
  184. package/dist/systems/interactive/interactive-system.d.ts +5 -1
  185. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  186. package/dist/systems/interactive/interactive-system.js +44 -121
  187. package/dist/systems/interactive/opening-settled.d.ts +16 -0
  188. package/dist/systems/interactive/opening-settled.d.ts.map +1 -0
  189. package/dist/systems/interactive/opening-settled.js +53 -0
  190. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  191. package/dist/systems/item-light/item-light-system.js +22 -214
  192. package/dist/systems/item-light/pooled-lights.d.ts +70 -0
  193. package/dist/systems/item-light/pooled-lights.d.ts.map +1 -0
  194. package/dist/systems/item-light/pooled-lights.js +449 -0
  195. package/dist/systems/level/level-system.d.ts.map +1 -1
  196. package/dist/systems/level/level-system.js +19 -1
  197. package/dist/systems/roof/roof-material-slots.d.ts +14 -0
  198. package/dist/systems/roof/roof-material-slots.d.ts.map +1 -0
  199. package/dist/systems/roof/roof-material-slots.js +13 -0
  200. package/dist/systems/roof/roof-materials.d.ts +25 -2
  201. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  202. package/dist/systems/roof/roof-materials.js +85 -26
  203. package/dist/systems/roof/roof-system.d.ts +34 -1
  204. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  205. package/dist/systems/roof/roof-system.js +582 -168
  206. package/dist/systems/wall/opening-casing-geometry.d.ts +14 -0
  207. package/dist/systems/wall/opening-casing-geometry.d.ts.map +1 -0
  208. package/dist/systems/wall/opening-casing-geometry.js +102 -0
  209. package/dist/systems/wall/opening-cutout-geometry.d.ts +9 -0
  210. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -1
  211. package/dist/systems/wall/opening-cutout-geometry.js +7 -3
  212. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  213. package/dist/systems/wall/wall-materials.js +28 -35
  214. package/dist/systems/wall/wall-system.d.ts +11 -0
  215. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  216. package/dist/systems/wall/wall-system.js +86 -25
  217. package/dist/systems/wall/wall-t-junctions.d.ts +15 -0
  218. package/dist/systems/wall/wall-t-junctions.d.ts.map +1 -0
  219. package/dist/systems/wall/wall-t-junctions.js +149 -0
  220. package/dist/systems/window/window-animation-system.d.ts +3 -12
  221. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  222. package/dist/systems/window/window-animation-system.js +37 -122
  223. package/dist/systems/window/window-pose.d.ts +31 -0
  224. package/dist/systems/window/window-pose.d.ts.map +1 -0
  225. package/dist/systems/window/window-pose.js +152 -0
  226. package/dist/systems/window/window-roller-shutter.d.ts +8 -0
  227. package/dist/systems/window/window-roller-shutter.d.ts.map +1 -0
  228. package/dist/systems/window/window-roller-shutter.js +64 -0
  229. package/dist/systems/window/window-system.d.ts +2 -11
  230. package/dist/systems/window/window-system.d.ts.map +1 -1
  231. package/dist/systems/window/window-system.js +470 -303
  232. package/package.json +6 -6
  233. package/dist/systems/interactive/control-widget.d.ts +0 -10
  234. package/dist/systems/interactive/control-widget.d.ts.map +0 -1
  235. package/dist/systems/interactive/control-widget.js +0 -35
  236. package/dist/systems/interactive/procedural-controls.d.ts +0 -13
  237. package/dist/systems/interactive/procedural-controls.d.ts.map +0 -1
  238. package/dist/systems/interactive/procedural-controls.js +0 -17
@@ -1,12 +1,16 @@
1
- import { getBarrelRoofFaces, getBarrelRoofSolidPieces, getConicalRoofCoverage, getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getEffectiveNode, getGlazedRoofFaces, getGlazedRoofPieceFaces, getGlazedRoofPieces, getRoofModuleFaces, getRoofPlanBounds, getRoofSegmentDeckReach, getRoofSegmentSurfaceY, getRoofShapeInsets, getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, isBandedShedSegment, isGlazedRoofSegment, isIndependentRoofSegment, nodeRegistry, normalizeRoofSegmentTrim, pointInPolygon2D, ROOF_SHAPE_DEFAULTS, roofOverlapEntryOwns, roofPlanBoundsOverlap, sceneRegistry, unionPolygons, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
1
+ import { getBarrelRoofFaces, getBarrelRoofSolidPieces, getConicalRoofCoverage, getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getEffectiveNode, getGlazedRoofFaces, getGlazedRoofPieceFaces, getGlazedRoofPieces, getRoofModuleFaces, getRoofPlanBounds, getRoofSegmentDeckReach, getRoofSegmentSurfaceY, getRoofShapeInsets, getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, isBandedShedSegment, isGlazedRoofSegment, isIndependentRoofSegment, LUMBER_DEPTH, nodeRegistry, normalizeRoofSegmentTrim, pointInPolygon2D, ROOF_SHAPE_DEFAULTS, roofOverlapEntryOwns, roofPlanBoundsOverlap, roofSegmentEaveProfile, roofStructureDepth, sceneRegistry, unionPolygons, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
+ import { useEffect } from 'react';
3
4
  import * as THREE from 'three';
4
5
  import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
5
6
  import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
6
7
  import { computeBoundsTree } from 'three-mesh-bvh';
7
8
  import { applyWorldScaleBoxUVs } from '../../lib/box-uv';
8
9
  import { ensureRenderableGeometryAttributes, subtractCsgBrush } from '../../lib/csg-utils';
10
+ import useViewer from '../../store/use-viewer';
9
11
  import { isRevealAssembling } from '../construction-reveal/construction-reveal';
12
+ import { setGeometryFailure, useGeometryStatus } from '../geometry/geometry-status';
13
+ import { ROOF_GABLE_LEFT_MATERIAL_INDEX, ROOF_GABLE_RIGHT_MATERIAL_INDEX, ROOF_HEEL_MATERIAL_INDEX, } from './roof-material-slots';
10
14
  function csgGeometry(brush) {
11
15
  return brush.geometry;
12
16
  }
@@ -233,6 +237,16 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
233
237
  // set from the render pass that received the override-publishing
234
238
  // `markDirty` call. Mirrors WallSystem / DoorSystem.
235
239
  useLiveNodeOverrides((s) => s.overrides);
240
+ // "Framing" opens the decks' structure band (`roofFramingDepth`): every segment, and so every
241
+ // roof shell, rebuilds when it flips.
242
+ useEffect(() => useViewer.subscribe((state, previous) => {
243
+ if (state.framingShown === previous.framingShown)
244
+ return;
245
+ const scene = useScene.getState();
246
+ for (const node of Object.values(scene.nodes))
247
+ if (node.type === 'roof-segment')
248
+ scene.markDirty(node.id);
249
+ }), []);
236
250
  useFrame(() => {
237
251
  // Clear stale pending updates when the scene is unloaded
238
252
  if (rootNodeIds.length === 0) {
@@ -252,6 +266,7 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
252
266
  pendingIndependentSegments.size === 0)
253
267
  return;
254
268
  const nodes = useScene.getState().nodes;
269
+ const framing = useViewer.getState().framingShown;
255
270
  // --- Pass 1: Process dirty roof-segments (throttled) ---
256
271
  let segmentsProcessed = 0;
257
272
  new Set([...dirtyNodes, ...pendingIndependentSegments]).forEach((id) => {
@@ -302,7 +317,7 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
302
317
  const revealOnly = mesh.parent?.name === 'segments-wrapper' &&
303
318
  mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true;
304
319
  if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
305
- updateRoofSegmentGeometry(effectiveSegment, mesh, nodes, geometryAdapter);
320
+ updateRoofSegmentGeometry(effectiveSegment, mesh, nodes, geometryAdapter, framing);
306
321
  segmentsProcessed++;
307
322
  }
308
323
  else if (isVisible && !revealOnly) {
@@ -358,7 +373,13 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
358
373
  // Built even while hidden: a roof assembling from its segments (construction
359
374
  // reveal) hides its shell, and must show it whole the moment it is captured.
360
375
  rememberMergedInputs(node, nodes);
361
- updateMergedRoofGeometry(node, group, nodes);
376
+ try {
377
+ updateMergedRoofGeometry(node, group, nodes, framing);
378
+ }
379
+ catch (error) {
380
+ setGeometryFailure(node.id, 'build-failed');
381
+ console.error('Roof rebuild failed:', error);
382
+ }
362
383
  roofsProcessed++;
363
384
  pendingRoofUpdates.delete(id);
364
385
  }
@@ -368,8 +389,20 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
368
389
  // ============================================================================
369
390
  // GEOMETRY GENERATION
370
391
  // ============================================================================
371
- function updateRoofSegmentGeometry(node, mesh, nodes, adapter) {
372
- const newGeo = generateRoofSegmentGeometry(node, nodes);
392
+ function updateRoofSegmentGeometry(node, mesh, nodes, adapter, framing = false) {
393
+ let newGeo;
394
+ try {
395
+ newGeo = generateRoofSegmentGeometry(node, nodes, { framing });
396
+ }
397
+ catch (error) {
398
+ setGeometryFailure(node.id, 'build-failed');
399
+ console.error('Roof segment rebuild failed:', error);
400
+ return;
401
+ }
402
+ if (useGeometryStatus.getState().failures.has(node.id)) {
403
+ newGeo.dispose();
404
+ return;
405
+ }
373
406
  mesh.geometry.dispose();
374
407
  mesh.geometry = newGeo;
375
408
  computeGeometryBoundsTree(newGeo);
@@ -391,84 +424,95 @@ function subtractAccessoryCuts(brushes, segment, nodes) {
391
424
  let workingShin = brushes.shinSlab;
392
425
  let workingDeck = brushes.deckSlab;
393
426
  let workingWall = brushes.wallBrush;
394
- for (const childElemId of segment.children ?? []) {
395
- const storedChild = nodes[childElemId];
396
- if (!storedChild)
397
- continue;
398
- const childElem = getEffectiveNode(storedChild);
399
- const meta = typeof childElem.metadata === 'object' && childElem.metadata !== null
400
- ? childElem.metadata
401
- : undefined;
402
- if (meta?.isTransient)
403
- continue;
404
- const childDef = nodeRegistry.get(childElem.type);
405
- const buildCut = childDef?.capabilities?.roofAccessory?.buildCut;
406
- if (!buildCut)
407
- continue;
408
- const cutGeo = buildCut(childElem, segment);
409
- if (!cutGeo)
410
- continue;
411
- // Wrap the kind-emitted geometry in a Brush. Kinds return raw
412
- // shapes; the viewer welds (mandatory after rotations leave
413
- // duplicated verts), attaches a single material group, and
414
- // builds the bounds tree — keeping kind code free of
415
- // three-bvh-csg / three-mesh-bvh imports.
416
- const welded = mergeVertices(cutGeo, 1e-4);
417
- cutGeo.dispose();
418
- const idxCount = welded.getIndex()?.count ?? 0;
419
- if (idxCount === 0) {
420
- welded.dispose();
421
- continue;
422
- }
423
- welded.clearGroups();
424
- welded.addGroup(0, idxCount, 0);
425
- welded.computeVertexNormals();
426
- ensureRenderableGeometryAttributes(welded);
427
- computeGeometryBoundsTree(welded);
428
- const cut = new Brush(welded, dummyMats[0]);
429
- cut.updateMatrixWorld();
430
- const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all';
431
- try {
432
- if (cutScope !== 'wall') {
433
- const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION);
434
- workingShin.geometry.dispose();
435
- prepareBrushForCSG(nextShin);
436
- workingShin = nextShin;
437
- const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION);
438
- workingDeck.geometry.dispose();
439
- prepareBrushForCSG(nextDeck);
440
- workingDeck = nextDeck;
427
+ try {
428
+ for (const childElemId of segment.children ?? []) {
429
+ const storedChild = nodes[childElemId];
430
+ if (!storedChild)
431
+ continue;
432
+ const childElem = getEffectiveNode(storedChild);
433
+ const meta = typeof childElem.metadata === 'object' && childElem.metadata !== null
434
+ ? childElem.metadata
435
+ : undefined;
436
+ if (meta?.isTransient)
437
+ continue;
438
+ const childDef = nodeRegistry.get(childElem.type);
439
+ const buildCut = childDef?.capabilities?.roofAccessory?.buildCut;
440
+ if (!buildCut)
441
+ continue;
442
+ const cutGeo = buildCut(childElem, segment);
443
+ if (!cutGeo)
444
+ continue;
445
+ // Wrap the kind-emitted geometry in a Brush. Kinds return raw
446
+ // shapes; the viewer welds (mandatory after rotations leave
447
+ // duplicated verts), attaches a single material group, and
448
+ // builds the bounds tree — keeping kind code free of
449
+ // three-bvh-csg / three-mesh-bvh imports.
450
+ const welded = mergeVertices(cutGeo, 1e-4);
451
+ cutGeo.dispose();
452
+ const idxCount = welded.getIndex()?.count ?? 0;
453
+ if (idxCount === 0) {
454
+ welded.dispose();
455
+ continue;
456
+ }
457
+ welded.clearGroups();
458
+ welded.addGroup(0, idxCount, 0);
459
+ welded.computeVertexNormals();
460
+ ensureRenderableGeometryAttributes(welded);
461
+ computeGeometryBoundsTree(welded);
462
+ const cut = new Brush(welded, dummyMats[0]);
463
+ cut.updateMatrixWorld();
464
+ const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all';
465
+ try {
466
+ if (cutScope !== 'wall') {
467
+ const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION);
468
+ workingShin.geometry.dispose();
469
+ prepareBrushForCSG(nextShin);
470
+ workingShin = nextShin;
471
+ const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION);
472
+ workingDeck.geometry.dispose();
473
+ prepareBrushForCSG(nextDeck);
474
+ workingDeck = nextDeck;
475
+ }
476
+ const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION);
477
+ workingWall.geometry.dispose();
478
+ prepareBrushForCSG(nextWall);
479
+ workingWall = nextWall;
480
+ }
481
+ finally {
482
+ cut.geometry.dispose();
441
483
  }
442
- const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION);
443
- workingWall.geometry.dispose();
444
- prepareBrushForCSG(nextWall);
445
- workingWall = nextWall;
446
- }
447
- catch (e) {
448
- console.error(`[${childElem.type}] cut CSG failed:`, e);
449
- }
450
- finally {
451
- cut.geometry.dispose();
452
484
  }
453
485
  }
454
- brushes.shinSlab = workingShin;
455
- brushes.deckSlab = workingDeck;
456
- brushes.wallBrush = workingWall;
486
+ finally {
487
+ brushes.shinSlab = workingShin;
488
+ brushes.deckSlab = workingDeck;
489
+ brushes.wallBrush = workingWall;
490
+ }
457
491
  }
458
- function getMergedRoofSegmentBrushes(roofNode, segment, nodes) {
492
+ function getMergedRoofSegmentBrushes(roofNode, segment, nodes, framing) {
459
493
  const segmentId = segment.id;
460
- const cacheKey = getMergedRoofSegmentCacheKey(roofNode, segment, nodes);
494
+ const framingDepth = framing ? roofFramingDepth(segment) : 0;
495
+ const cacheKey = getMergedRoofSegmentCacheKey(roofNode, segment, nodes, framingDepth);
461
496
  const cached = mergedRoofSegmentGeometryCache.get(segmentId);
462
497
  if (cached?.key === cacheKey) {
463
498
  return cloneCachedMergedRoofSegmentBrushes(cached);
464
499
  }
465
- const brushes = withSegmentUvMatrix(composeSegmentWorldMatrix(roofNode.position, roofNode.rotation ?? 0, segment.position, segment.rotation ?? 0), () => getRoofSegmentBrushes(segment, { fascia: wearsFascia(segment, roofNode) }));
500
+ const brushes = withSegmentUvMatrix(composeSegmentWorldMatrix(roofNode.position, roofNode.rotation ?? 0, segment.position, segment.rotation ?? 0), () => getRoofSegmentBrushes(segment, {
501
+ fascia: wearsFascia(segment, roofNode),
502
+ framing: framingDepth,
503
+ }));
466
504
  if (!brushes) {
467
505
  disposeCachedMergedRoofSegmentGeometrySet(cached);
468
506
  mergedRoofSegmentGeometryCache.delete(segmentId);
469
507
  return null;
470
508
  }
471
- subtractAccessoryCuts(brushes, segment, nodes);
509
+ try {
510
+ subtractAccessoryCuts(brushes, segment, nodes);
511
+ }
512
+ catch (error) {
513
+ disposeRoofSegmentBrushSet(brushes);
514
+ throw error;
515
+ }
472
516
  _matrix.compose(_position.set(segment.position[0], segment.position[1], segment.position[2]), _quaternion.setFromAxisAngle(_yAxis, segment.rotation), _scale);
473
517
  const applyTransform = (brush) => {
474
518
  csgGeometry(brush).applyMatrix4(_matrix);
@@ -505,7 +549,7 @@ function getMergedRoofSegmentBrushes(roofNode, segment, nodes) {
505
549
  disposeRoofSegmentBrushSet(brushes);
506
550
  return cloned;
507
551
  }
508
- function updateMergedRoofGeometry(roofNode, group, nodes) {
552
+ function updateMergedRoofGeometry(roofNode, group, nodes, framing) {
509
553
  const mergedMesh = group.getObjectByName('merged-roof');
510
554
  if (!mergedMesh)
511
555
  return;
@@ -529,6 +573,7 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
529
573
  // Not BoxGeometry: its 6 groups against the merged mesh's 4-material array
530
574
  // crash GLTFExporter (materials[4] → undefined) when the roof bakes.
531
575
  mergedMesh.geometry = createDegenerateRoofPlaceholder();
576
+ setGeometryFailure(roofNode.id, null);
532
577
  return;
533
578
  }
534
579
  let totalShinSlab = null;
@@ -548,7 +593,7 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
548
593
  continue;
549
594
  }
550
595
  csgChildren.push(child);
551
- const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes);
596
+ const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes, framing);
552
597
  if (!brushes)
553
598
  continue;
554
599
  if (brushes.rakeBoards) {
@@ -625,6 +670,7 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
625
670
  console.warn('[RoofSystem] Skipping merged roof geometry with invalid attributes', roofNode.id);
626
671
  warnedMergedRoofNaNIds.add(roofNode.id);
627
672
  }
673
+ setGeometryFailure(roofNode.id, 'invalid-geometry');
628
674
  resultGeo.dispose();
629
675
  if (combined !== shinDeck)
630
676
  shinDeck.geometry.dispose();
@@ -648,6 +694,14 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
648
694
  g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex);
649
695
  }
650
696
  let finalGeo = addShedInsetEndPanels(resultGeo, csgChildren, true);
697
+ splitRoofWallBandFaces(finalGeo, csgChildren
698
+ .filter((child) => shouldIncludeRoofSegmentWallShell(child, roofNode))
699
+ .map((child) => ({
700
+ node: child,
701
+ inverse: new THREE.Matrix4()
702
+ .compose(_position.set(child.position[0], child.position[1], child.position[2]), _quaternion.setFromAxisAngle(_yAxis, child.rotation), _scale)
703
+ .invert(),
704
+ })), false);
651
705
  const appendedGeometries = [...rakeBoardGeometries, ...directSegmentGeometries];
652
706
  if (appendedGeometries.length > 0) {
653
707
  const merged = mergeGeometriesPreservingGroups([finalGeo, ...appendedGeometries]);
@@ -665,10 +719,12 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
665
719
  ensureRenderableGeometryAttributes(finalGeo);
666
720
  mergedMesh.geometry.dispose();
667
721
  mergedMesh.geometry = finalGeo;
722
+ setGeometryFailure(roofNode.id, null);
668
723
  if (combined !== shinDeck)
669
724
  shinDeck.geometry.dispose();
670
725
  }
671
726
  catch (e) {
727
+ setGeometryFailure(roofNode.id, 'build-failed');
672
728
  console.error('Merged roof CSG failed:', e);
673
729
  }
674
730
  totalShinSlab.geometry.dispose();
@@ -686,6 +742,7 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
686
742
  ensureRenderableGeometryAttributes(finalGeo);
687
743
  mergedMesh.geometry.dispose();
688
744
  mergedMesh.geometry = finalGeo;
745
+ setGeometryFailure(roofNode.id, null);
689
746
  }
690
747
  for (const geometry of directSegmentGeometries) {
691
748
  if (geometry !== finalGeo)
@@ -792,7 +849,7 @@ function getMergedRoofAccessoryCachePayload(segment, nodes) {
792
849
  }
793
850
  return payload;
794
851
  }
795
- function getMergedRoofSegmentCacheKey(roofNode, segment, nodes) {
852
+ function getMergedRoofSegmentCacheKey(roofNode, segment, nodes, framing) {
796
853
  return JSON.stringify({
797
854
  roofPosition: roofNode.position ?? [0, 0, 0],
798
855
  roofRotation: roofNode.rotation ?? 0,
@@ -800,6 +857,7 @@ function getMergedRoofSegmentCacheKey(roofNode, segment, nodes) {
800
857
  composite: (roofNode.children?.length ?? 0) > 1,
801
858
  segment,
802
859
  accessories: getMergedRoofAccessoryCachePayload(segment, nodes),
860
+ framing,
803
861
  });
804
862
  }
805
863
  export function mapRoofGroupMaterialIndex(groupMaterialIndex, csgMaterials, matToIndex) {
@@ -1181,103 +1239,215 @@ export function clipGeometryBySegmentTrim(geometry, segment) {
1181
1239
  * Generate complete hollow-shell geometry for a roof segment.
1182
1240
  * Ports the prototype's CSG approach using three-bvh-csg.
1183
1241
  */
1184
- /** A 1x8 fascia / rake board: actual 3/4 × 7-1/4 in. */
1185
- const FASCIA_BOARD = { thickness: 0.019, height: 0.184 };
1186
1242
  /** A board's back sits this far inside the deck edge, so the union is one solid with no coplanar faces. */
1187
1243
  const FASCIA_EMBED = 0.002;
1244
+ /**
1245
+ * Boards that meet leave this between them: drawn as one solid, overlapping or touching boards would
1246
+ * leave it no inside to tell, and the union with the deck tears.
1247
+ */
1248
+ const BOARD_GAP = 0.0001;
1188
1249
  /**
1189
1250
  * Slot 2 takes the trim finish like the rake faces, and the shell remap
1190
1251
  * leaves it alone: the remap would send a plumb eave board to the wall band
1191
- * and its top edge to the shingles.
1252
+ * and its top edge to the shingles. A boxed eave's soffit and returns take it too.
1192
1253
  */
1193
1254
  const FASCIA_MATERIAL_INDEX = 2;
1255
+ /** The forms whose eaves core's `roofEaveProfile` cuts and boards: the ones Bones frames with tails. */
1256
+ const EAVE_ROOF_TYPES = new Set(['gable', 'hip', 'shed', 'flat']);
1257
+ const EDGES_Z = [
1258
+ { axis: 'z', sign: 1 },
1259
+ { axis: 'z', sign: -1 },
1260
+ ];
1261
+ const EDGES_X = [
1262
+ { axis: 'x', sign: 1 },
1263
+ { axis: 'x', sign: -1 },
1264
+ ];
1265
+ /** The point of an edge's section (`s` out past its plate line, `y` over `plateY`) at `u` along it. */
1266
+ function edgePoint(node, edge, plateY, [s, y], u) {
1267
+ const out = edge.sign * ((edge.axis === 'z' ? node.depth : node.width) / 2 + s);
1268
+ return edge.axis === 'z'
1269
+ ? new THREE.Vector3(u, plateY + y, out)
1270
+ : new THREE.Vector3(out, plateY + y, u);
1271
+ }
1272
+ /** A convex solid's faces, each turned to face away from its centre. */
1273
+ function outwardFaces(faces) {
1274
+ const points = faces.flat();
1275
+ const centre = points
1276
+ .reduce((sum, v) => sum.add(v), new THREE.Vector3())
1277
+ .multiplyScalar(1 / Math.max(1, points.length));
1278
+ return faces.map((face) => {
1279
+ const normal = new THREE.Vector3()
1280
+ .subVectors(face[1], face[0])
1281
+ .cross(new THREE.Vector3().subVectors(face[2], face[0]));
1282
+ const faceCentre = face
1283
+ .reduce((sum, v) => sum.add(v), new THREE.Vector3())
1284
+ .multiplyScalar(1 / face.length);
1285
+ return normal.dot(faceCentre.sub(centre)) < 0 ? [...face].reverse() : face;
1286
+ });
1287
+ }
1194
1288
  /**
1195
- * The fascia and rake boards a segment wears, in its own frame: a plumb 1x8
1196
- * on every eave with its top on the deck's top edge, and one up every gable
1197
- * or shed rake, from the eave line the deck itself is built on. Being part
1198
- * of the segment, they follow its pitch, size, overhang and type, and the
1199
- * trims, cuts and sibling roofs clip them like the deck. Gable, hip, shed
1200
- * and flat; null for the other forms.
1289
+ * A prism over a convex `section` of an edge, run along it from `from` to `to`: each end where its
1290
+ * function puts it at each section point (affine in it), so an end can butt a sloped board's face.
1201
1291
  */
1202
- function buildFasciaBoards(node, reach, eaveTopY, tanTheta) {
1292
+ function edgePrism(node, edge, plateY, section, from, to) {
1293
+ const a = section.map((point) => edgePoint(node, edge, plateY, point, from(point)));
1294
+ const b = section.map((point) => edgePoint(node, edge, plateY, point, to(point)));
1295
+ const faces = [a, b];
1296
+ for (let i = 0; i < section.length; i++) {
1297
+ const j = (i + 1) % section.length;
1298
+ faces.push([a[i], a[j], b[j], b[i]]);
1299
+ }
1300
+ return outwardFaces(faces);
1301
+ }
1302
+ /** Where a line through two section points is at height `y`, out from the plate line. */
1303
+ function sAtHeight([s0, y0], [s1, y1], y) {
1304
+ return Math.abs(y1 - y0) < 1e-9 ? s0 : s0 + ((y - y0) * (s1 - s0)) / (y1 - y0);
1305
+ }
1306
+ /** A fascia's section with its back pulled `FASCIA_EMBED` into the deck, along its thickness. */
1307
+ function embeddedFascia(profile) {
1308
+ const [backTop, frontTop, frontBottom, backBottom] = profile.fascia.corners;
1309
+ const [nx, ny] = profile.fascia.square ? [profile.cos, -profile.sin] : [1, 0];
1310
+ const back = ([s, y]) => [s - nx * FASCIA_EMBED, y - ny * FASCIA_EMBED];
1311
+ return [back(backTop), frontTop, frontBottom, back(backBottom)];
1312
+ }
1313
+ /**
1314
+ * A rake board's section in the eave's frame (`profile`): plumb, its top on the sheathing's top, as
1315
+ * deep as the profile's rake board, from the fascia's back at the eave (square to the slope with a
1316
+ * square cut; held level at the fascia's bottom with a plumb-and-level one) up to `inner`, plumb.
1317
+ */
1318
+ function rakeSection(profile, inner) {
1319
+ const { top, cos, sin, tan } = profile;
1320
+ const depth = profile.rake.height;
1321
+ const bottom = (s) => top(s) - depth;
1322
+ // short of the fascia's back, which is pulled into the deck
1323
+ const pull = FASCIA_EMBED + BOARD_GAP;
1324
+ const [s0, y0] = profile.end[0];
1325
+ const corner = profile.fascia.square
1326
+ ? [s0 - pull * cos, y0 + pull * sin]
1327
+ : [s0 - pull, top(s0 - pull)];
1328
+ const section = [corner];
1329
+ if (profile.cut === 'square') {
1330
+ const along = depth * cos;
1331
+ section.push([corner[0] - along * sin, corner[1] - along * cos]);
1332
+ }
1333
+ else if (profile.cut === 'plumb-level') {
1334
+ const level = profile.fascia.corners[3][1];
1335
+ const s = tan > 1e-9 ? (top(0) - depth - level) / tan : corner[0];
1336
+ if (bottom(corner[0]) < level && s < corner[0])
1337
+ section.push([corner[0], level], [s, level]);
1338
+ else
1339
+ section.push([corner[0], bottom(corner[0])]);
1340
+ }
1341
+ else
1342
+ section.push([corner[0], bottom(corner[0])]);
1343
+ section.push([inner, bottom(inner)], [inner, top(inner)]);
1344
+ return section;
1345
+ }
1346
+ /**
1347
+ * The fascia and rake boards a segment wears, in its own frame, as its eaves are cut
1348
+ * (`roofSegmentEaveProfile`): a 1x on every eave covering the tails' end, its top flush with the
1349
+ * sheathing's, and a plumb one up every gable or shed rake over the barge rafter, its top on the
1350
+ * sheathing's. The eave boards run through to the rake boards' faces, which butt their backs; a hip's
1351
+ * or flat roof's end boards butt the side boards' backs. Being part of the segment, they follow its
1352
+ * pitch, size, overhang, layers and type, and the trims, cuts and sibling roofs clip them like the
1353
+ * deck. Gable, hip, shed and flat; null for the other forms.
1354
+ */
1355
+ function buildFasciaBoards(node, reach, eave, high) {
1203
1356
  const { roofType, width, depth } = node;
1204
- const bt = node.fasciaDepth ?? FASCIA_BOARD.thickness;
1205
- const bh = node.fasciaHeight ?? FASCIA_BOARD.height;
1206
- // A flat roof's boards sized on purpose reach its top, over the membrane's edge: a coping band.
1207
- const flatTopY = node.fasciaHeight !== undefined ? eaveTopY + node.shingleThickness : eaveTopY;
1208
- const out = bt / 2 - FASCIA_EMBED;
1209
- const halfD = depth / 2 + reach.z;
1210
- const zE = halfD + out;
1211
- const xE = width / 2 + reach.x + out;
1212
- // the eave boards run through the corners to the rake / side boards'
1213
- // outer faces (a lapped return, no notch at the corner)
1214
- const xL = xE + bt / 2;
1357
+ if (!EAVE_ROOF_TYPES.has(roofType))
1358
+ return null;
1359
+ const plateY = node.wallHeight;
1360
+ const thickness = eave.fascia.thickness;
1361
+ const board = embeddedFascia(eave);
1215
1362
  const faces = [];
1216
- const board = (a, b, across) => {
1217
- const ax = (across[0] * bt) / 2;
1218
- const az = (across[1] * bt) / 2;
1219
- const p = [
1220
- [a[0] - ax, a[1] - bh, a[2] - az],
1221
- [b[0] - ax, b[1] - bh, b[2] - az],
1222
- [b[0] + ax, b[1] - bh, b[2] + az],
1223
- [a[0] + ax, a[1] - bh, a[2] + az],
1224
- [a[0] - ax, a[1], a[2] - az],
1225
- [b[0] - ax, b[1], b[2] - az],
1226
- [b[0] + ax, b[1], b[2] + az],
1227
- [a[0] + ax, a[1], a[2] + az],
1228
- ].map(([x, y, z]) => new THREE.Vector3(x, y, z));
1229
- const centre = p.reduce((sum, v) => sum.add(v), new THREE.Vector3()).multiplyScalar(1 / 8);
1230
- const quads = [
1231
- [0, 1, 2, 3],
1232
- [4, 5, 6, 7],
1233
- [0, 4, 5, 1],
1234
- [1, 5, 6, 2],
1235
- [2, 6, 7, 3],
1236
- [3, 7, 4, 0],
1237
- ];
1238
- for (const quad of quads) {
1239
- const face = quad.map((k) => p[k].clone());
1240
- const normal = new THREE.Vector3()
1241
- .subVectors(face[1], face[0])
1242
- .cross(new THREE.Vector3().subVectors(face[2], face[0]));
1243
- const faceCentre = face
1244
- .reduce((sum, v) => sum.add(v), new THREE.Vector3())
1245
- .multiplyScalar(1 / 4);
1246
- if (normal.dot(faceCentre.sub(centre)) < 0)
1247
- face.reverse();
1248
- faces.push(face);
1249
- }
1250
- };
1251
- if (roofType === 'gable') {
1252
- const ridgeY = eaveTopY + halfD * tanTheta;
1253
- board([-xL, eaveTopY, zE], [xL, eaveTopY, zE], [0, 1]);
1254
- board([-xL, eaveTopY, -zE], [xL, eaveTopY, -zE], [0, 1]);
1255
- for (const sx of [-1, 1]) {
1256
- board([sx * xE, eaveTopY, zE], [sx * xE, ridgeY, 0], [1, 0]);
1257
- board([sx * xE, ridgeY, 0], [sx * xE, eaveTopY, -zE], [1, 0]);
1258
- }
1259
- }
1260
- else if (roofType === 'hip' || roofType === 'flat') {
1261
- const y = roofType === 'flat' ? flatTopY : eaveTopY;
1262
- for (const sz of [-1, 1])
1263
- board([-xL, y, sz * zE], [xL, y, sz * zE], [0, 1]);
1264
- // the side boards butt the eave boards' backs
1265
- for (const sx of [-1, 1])
1266
- board([sx * xE, y, -zE + bt / 2], [sx * xE, y, zE - bt / 2], [1, 0]);
1363
+ const rakeFace = width / 2 + reach.x + thickness;
1364
+ if (roofType === 'gable' || roofType === 'shed') {
1365
+ const sides = roofType === 'gable' ? EDGES_Z : [EDGES_Z[0]];
1366
+ for (const edge of sides)
1367
+ faces.push(...edgePrism(node, edge, plateY, board, () => -rakeFace, () => rakeFace));
1368
+ const highY = plateY + getSegmentSlopeFrame(node).activeRh;
1369
+ if (roofType === 'shed' && high && (node.fasciaHighEdge ?? true))
1370
+ faces.push(...edgePrism(node, EDGES_Z[1], highY, embeddedFascia(high), () => -rakeFace, () => rakeFace));
1371
+ // the rakes: a gable's up each slope to the ridge, a shed's from its low eave to its high edge
1372
+ const inner = roofType === 'gable' ? -depth / 2 + BOARD_GAP : -depth - reach.z + FASCIA_EMBED + BOARD_GAP;
1373
+ const section = rakeSection(eave, inner);
1374
+ const back = width / 2 + reach.x - FASCIA_EMBED;
1375
+ for (const edge of sides)
1376
+ for (const sx of [-1, 1]) {
1377
+ const [x0, x1] = sx === 1 ? [back, rakeFace] : [-rakeFace, -back];
1378
+ faces.push(...edgePrism(node, edge, plateY, section, () => x0, () => x1));
1379
+ }
1267
1380
  }
1268
- else if (roofType === 'shed') {
1269
- // the low eave at +z, the slope rising to −z
1270
- const highY = eaveTopY + 2 * halfD * tanTheta;
1271
- board([-xL, eaveTopY, zE], [xL, eaveTopY, zE], [0, 1]);
1272
- if (node.fasciaHighEdge ?? true)
1273
- board([-xL, highY, -zE], [xL, highY, -zE], [0, 1]);
1274
- for (const sx of [-1, 1])
1275
- board([sx * xE, eaveTopY, zE], [sx * xE, highY, -zE], [1, 0]);
1381
+ else {
1382
+ // hip and flat: the boards along ±z run through to the end boards' faces, which butt their backs
1383
+ const [, frontTop, frontBottom] = eave.fascia.corners;
1384
+ const front = (y) => sAtHeight(frontTop, frontBottom, y);
1385
+ const back = (y) => sAtHeight(board[0], board[3], y) - BOARD_GAP;
1386
+ for (const edge of EDGES_Z)
1387
+ faces.push(...edgePrism(node, edge, plateY, board, ([, y]) => -(width / 2 + front(y)), ([, y]) => width / 2 + front(y)));
1388
+ for (const edge of EDGES_X)
1389
+ faces.push(...edgePrism(node, edge, plateY, board, ([, y]) => -(depth / 2 + back(y)), ([, y]) => depth / 2 + back(y)));
1276
1390
  }
1277
- else
1278
- return null;
1279
1391
  return createGeometryFromFaces(faces, FASCIA_MATERIAL_INDEX);
1280
1392
  }
1393
+ /**
1394
+ * What a segment's eave cut takes off its deck slab out past the walls (`roofSegmentEaveProfile`):
1395
+ * the wedge outside a square cut; under a truss's top chords the heel's depth, the tails riding
1396
+ * that far over the deck's underside (`seal` under them, as the deck is); under a boxed eave's
1397
+ * soffit everything, the box standing on it. Each a convex solid, a hip's bounded by its hip lines
1398
+ * (a hair past them, so the two at a corner overlap). None for a plumb cut on rafters.
1399
+ */
1400
+ function buildEaveCuts(node, reach, eave, seal) {
1401
+ const { roofType, width, depth } = node;
1402
+ if (roofType === 'flat')
1403
+ return [];
1404
+ const plateY = node.wallHeight;
1405
+ const sections = [];
1406
+ if (eave.cut === 'square') {
1407
+ const [corner] = eave.end;
1408
+ const down = (point, d) => [
1409
+ point[0] - d * eave.sin,
1410
+ point[1] - d * eave.cos,
1411
+ ];
1412
+ const out = (point, d) => [
1413
+ point[0] + d * eave.cos,
1414
+ point[1] - d * eave.sin,
1415
+ ];
1416
+ const reachDown = node.deckThickness + 0.1;
1417
+ const above = down(corner, -0.2);
1418
+ const below = down(corner, reachDown);
1419
+ sections.push([above, out(above, 0.5), out(below, 0.5), below]);
1420
+ }
1421
+ const lift = eave.under(0);
1422
+ const far = eave.reach + 0.3;
1423
+ if (lift > 1e-6) {
1424
+ const at = (s, d) => [s, eave.under(s) - seal - d];
1425
+ sections.push([at(eave.wallOut, 0), at(far, 0), at(far, 1), at(eave.wallOut, 1)]);
1426
+ }
1427
+ if (eave.soffit) {
1428
+ const level = eave.soffit.y - eave.soffit.thickness;
1429
+ sections.push([
1430
+ [eave.wallOut, level],
1431
+ [far, level],
1432
+ [far, level - 1],
1433
+ [eave.wallOut, level - 1],
1434
+ ]);
1435
+ }
1436
+ if (sections.length === 0)
1437
+ return [];
1438
+ const solids = [];
1439
+ const edges = roofType === 'hip' ? [...EDGES_Z, ...EDGES_X] : roofType === 'gable' ? EDGES_Z : [EDGES_Z[0]];
1440
+ for (const edge of edges)
1441
+ for (const section of sections) {
1442
+ // a hip's eave runs out to its hip lines; a gable's or shed's past its rakes
1443
+ const half = (edge.axis === 'z' ? width : depth) / 2;
1444
+ const end = roofType === 'hip'
1445
+ ? ([s]) => half + Math.max(0, s) + 0.01
1446
+ : () => half + reach.x + 1;
1447
+ solids.push(edgePrism(node, edge, plateY, section, (point) => -end(point), end));
1448
+ }
1449
+ return solids;
1450
+ }
1281
1451
  /**
1282
1452
  * Whether a drawn segment wears fascia boards. A lean-to carries its own
1283
1453
  * trim, and a legacy composite shed roof stacks overlapping shed pieces as
@@ -1293,9 +1463,44 @@ function wearsFascia(node, parentRoof) {
1293
1463
  return false;
1294
1464
  return !parentRoof || (parentRoof.children?.length ?? 0) <= 1;
1295
1465
  }
1466
+ /** What a framed roof's deck keeps over its open structure band at least: a 7/16 in sheet. */
1467
+ const ROOF_DECK_KEPT = 0.0111;
1468
+ /**
1469
+ * Where the deck stays whole while its structure band is open (over the walls, in the overhangs),
1470
+ * its underside sits this far under the rafter plane, so the rafter tails stay inside it rather
1471
+ * than sharing its face.
1472
+ */
1473
+ const ROOF_DECK_SEAL = 0.005;
1474
+ /**
1475
+ * The forms whose open band is the deck's own planes raised: their planes stay put as the
1476
+ * footprint shrinks to the walls' inner faces. A gambrel's or a mansard's break and a dutch
1477
+ * gablet move with it, so those decks stay closed.
1478
+ */
1479
+ const FRAMING_OPEN_ROOF_TYPES = new Set(['gable', 'hip', 'shed', 'flat']);
1480
+ /**
1481
+ * How deep a roof plane's deck opens under its sheathing while the viewer shows the frame
1482
+ * (`useViewer.framingShown`), square to the slope: the structure its layers name
1483
+ * (`roofStructureDepth`: its rafters, or its trusses' top chords on their heel), else Bones' default
1484
+ * 2x6. A deck no deeper keeps its top 7/16 in; a plane finished underneath (a cathedral ceiling)
1485
+ * stays closed, the frame hidden behind its finish, and so does a form `FRAMING_OPEN_ROOF_TYPES`
1486
+ * leaves out. 0 when nothing opens.
1487
+ */
1488
+ export function roofFramingDepth(node) {
1489
+ if (!FRAMING_OPEN_ROOF_TYPES.has(node.roofType) || node.buildUp?.finish)
1490
+ return 0;
1491
+ const structure = node.buildUp
1492
+ ? roofStructureDepth(node.buildUp.structure) || LUMBER_DEPTH['2x6']
1493
+ : LUMBER_DEPTH['2x6'];
1494
+ const depth = Math.min(structure, node.deckThickness - ROOF_DECK_KEPT);
1495
+ return depth > 0.005 ? depth : 0;
1496
+ }
1296
1497
  /**
1297
1498
  * `fascia`: build the segment's fascia boards into its deck. Only the roof
1298
1499
  * renderer asks; a dormer, a chimney or a sibling's clip takes the bare shell.
1500
+ * `framing`: the structure band to open (`roofFramingDepth`), the roof
1501
+ * renderer's while the frame is shown. Inside the walls the deck keeps only the
1502
+ * band over it, so the rafters show from the attic; over the walls and in the
1503
+ * overhangs it stays whole, closing the band at the walls' inner faces.
1299
1504
  */
1300
1505
  export function getRoofSegmentBrushes(node, options = {}) {
1301
1506
  const { roofType, width, depth, wallHeight, wallThickness, deckThickness, shingleThickness } = node;
@@ -1316,7 +1521,7 @@ export function getRoofSegmentBrushes(node, options = {}) {
1316
1521
  const baseI = Math.min(width, depth) * node.dutchHipWidthRatio;
1317
1522
  const plateSeated = wallHeight <= 0;
1318
1523
  // `xExt`: how far the volume reaches along x, where a gable's rakes are; the eaves' `wExt` else.
1319
- const getVol = (wExt, vOffset, baseY, matIndex, isVoid, materialRule, onPlate = false, xExt = wExt) => {
1524
+ const getVol = (wExt, vOffset, baseY, matIndex, isVoid, materialRule, onPlate = false, xExt = wExt, keepBase = false) => {
1320
1525
  const wV = Math.max(0.01, width + 2 * xExt);
1321
1526
  const dV = Math.max(0.01, depth + 2 * wExt);
1322
1527
  const autoDrop = wExt * tanTheta;
@@ -1325,7 +1530,11 @@ export function getRoofSegmentBrushes(node, options = {}) {
1325
1530
  // One floor for all volumes keeps each cutter level with the shell it carves.
1326
1531
  // A plate-seated roof (wallHeight 0) is the exception for its sloped deck
1327
1532
  // volumes: their eave hangs below the plate, and raising it lifted the
1328
- // whole roof off its walls, so their base sinks instead.
1533
+ // whole roof off its walls, so their base sinks instead: under the plate's
1534
+ // plane at their edge too, so a deck's top volume keeps the deck's whole
1535
+ // depth out to the eave (a base 5 cm under its top cut the eave to a 5 cm
1536
+ // edge with the tails hanging under it). `keepBase`: a volume whose base
1537
+ // is its own (a boxed eave's soffit).
1329
1538
  const eaveY = wallHeight - autoDrop + vOffset;
1330
1539
  const sinkBase = plateSeated && !onPlate && autoDrop !== 0;
1331
1540
  const whV = sinkBase ? eaveY : Math.max(0.05, eaveY);
@@ -1335,7 +1544,9 @@ export function getRoofSegmentBrushes(node, options = {}) {
1335
1544
  if (roofType === 'shed')
1336
1545
  rhV = activeRh + 2 * autoDrop;
1337
1546
  }
1338
- const safeBaseY = sinkBase ? Math.min(baseY, whV - 0.05) : baseY;
1547
+ const safeBaseY = sinkBase && !keepBase
1548
+ ? Math.min(baseY, Math.min(whV, wallHeight - autoDrop - ROOF_DECK_SEAL) - 0.05)
1549
+ : baseY;
1339
1550
  let structuralI = baseI;
1340
1551
  if (isVoid) {
1341
1552
  structuralI += deckThickness;
@@ -1369,13 +1580,36 @@ export function getRoofSegmentBrushes(node, options = {}) {
1369
1580
  ? (normal) => (normal.y > SHINGLE_SURFACE_EPSILON ? 3 : ROOF_EDGE_MATERIAL_INDEX)
1370
1581
  : undefined;
1371
1582
  const deckTopGeo = getVol(deckExt, verticalRt, 0, 1, false, shedRoofSideMaterialRule, false, reach.x);
1372
- const deckBotGeo = getVol(deckExt, 0, -5, 0, true, undefined, false, reach.x);
1373
- // the deck's top edge at its outer plane, as `getVol` puts it
1374
- const deckEaveDrop = deckExt * tanTheta;
1375
- const deckEaveTopY = plateSeated && deckEaveDrop > 0
1376
- ? wallHeight - deckEaveDrop + verticalRt
1377
- : Math.max(0.05, wallHeight - deckEaveDrop + verticalRt);
1378
- const fasciaGeo = options.fascia ? buildFasciaBoards(node, reach, deckEaveTopY, tanTheta) : null;
1583
+ const framing = Math.max(0, options.framing ?? 0);
1584
+ const deckBotGeo = getVol(deckExt, framing > 0 ? -ROOF_DECK_SEAL : 0, -5, 0, true, undefined, false, reach.x);
1585
+ // the structure band inside the walls' inner faces, open while the frame is shown
1586
+ const framingRt = activeRh > 0 ? framing / cosTheta : framing;
1587
+ const half = -wallThickness / 2;
1588
+ const framingGeo = framing > 0 ? getVol(half, framingRt, -5, 0, true, undefined, false, half) : null;
1589
+ // The eaves, cut and boarded by the plane's rule, the one Bones cuts its tails by (core's
1590
+ // `roofSegmentEaveProfile`). Only the roof renderer asks (`options.fascia` given); a dormer, a
1591
+ // chimney or a sibling's clip takes the bare shell.
1592
+ const eave = options.fascia !== undefined && EAVE_ROOF_TYPES.has(roofType)
1593
+ ? roofSegmentEaveProfile(node)
1594
+ : null;
1595
+ const highEave = eave && roofType === 'shed' ? roofSegmentEaveProfile(node, 'high') : null;
1596
+ const fasciaGeo = eave && options.fascia ? buildFasciaBoards(node, reach, eave, highEave) : null;
1597
+ const eaveCutGeos = eave
1598
+ ? buildEaveCuts(node, reach, eave, framing > 0 ? ROOF_DECK_SEAL : 0).map((faces) => createGeometryFromFaces(faces, 1))
1599
+ : [];
1600
+ // A boxed eave (plumb and level): solid from the soffit, level under the tails, up into the deck,
1601
+ // from the walls (a hair into them) out to the fascia's back; its ends are the returns under the rakes.
1602
+ let boxGeo = null;
1603
+ let boxCut = null;
1604
+ if (eave?.soffit) {
1605
+ const soffitBottom = wallHeight + eave.soffit.y - eave.soffit.thickness;
1606
+ const ext = reach.z - FASCIA_EMBED;
1607
+ boxGeo = getVol(ext, verticalRt - 0.003, soffitBottom, FASCIA_MATERIAL_INDEX, false, undefined, false, roofType === 'hip' ? ext : reach.x - FASCIA_EMBED, true);
1608
+ const inside = eave.wallOut - FASCIA_EMBED;
1609
+ const far = width / 2 + depth / 2 + reach.x + reach.z + 2;
1610
+ const top = wallHeight + activeRh + deckThickness + shingleThickness + 2;
1611
+ boxCut = buildTrimCutBrush(roofType === 'hip' ? -(width / 2 + inside) : -far, roofType === 'hip' ? width / 2 + inside : far, roofType === 'shed' ? -far : -(depth / 2 + inside), depth / 2 + inside, soffitBottom - 1, top);
1612
+ }
1379
1613
  const stSin = shingleThickness * sinTheta;
1380
1614
  const stCos = shingleThickness * cosTheta;
1381
1615
  const shinBotW = Math.max(0.01, width + 2 * reach.x);
@@ -1522,6 +1756,18 @@ export function getRoofSegmentBrushes(node, options = {}) {
1522
1756
  const fasciaBrush = fasciaGeo ? toBrush(fasciaGeo) : null;
1523
1757
  if (fasciaGeo && !fasciaBrush)
1524
1758
  fasciaGeo.dispose();
1759
+ const eaveCutBrushes = eaveCutGeos.flatMap((geometry) => {
1760
+ const brush = toBrush(geometry);
1761
+ if (!brush)
1762
+ geometry.dispose();
1763
+ return brush ? [brush] : [];
1764
+ });
1765
+ const boxBrush = boxGeo ? toBrush(boxGeo) : null;
1766
+ if (boxGeo && !boxBrush)
1767
+ boxGeo.dispose();
1768
+ const framingBrush = framingGeo ? toBrush(framingGeo) : null;
1769
+ if (framingGeo && !framingBrush)
1770
+ framingGeo.dispose();
1525
1771
  if (shinBotBrush) {
1526
1772
  const wV = shinBotW;
1527
1773
  const dV = shinBotD;
@@ -1538,6 +1784,31 @@ export function getRoofSegmentBrushes(node, options = {}) {
1538
1784
  try {
1539
1785
  let deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION);
1540
1786
  prepareBrushForCSG(deckSlab);
1787
+ if (framingBrush) {
1788
+ const open = csgEvaluator.evaluate(deckSlab, framingBrush, SUBTRACTION);
1789
+ prepareBrushForCSG(open);
1790
+ deckSlab.geometry.dispose();
1791
+ framingBrush.geometry.dispose();
1792
+ deckSlab = open;
1793
+ }
1794
+ for (const cut of eaveCutBrushes) {
1795
+ const next = csgEvaluator.evaluate(deckSlab, cut, SUBTRACTION);
1796
+ prepareBrushForCSG(next);
1797
+ deckSlab.geometry.dispose();
1798
+ cut.geometry.dispose();
1799
+ deckSlab = next;
1800
+ }
1801
+ if (boxBrush && boxCut) {
1802
+ const box = csgEvaluator.evaluate(boxBrush, boxCut, SUBTRACTION);
1803
+ prepareBrushForCSG(box);
1804
+ const withBox = csgEvaluator.evaluate(deckSlab, box, ADDITION);
1805
+ prepareBrushForCSG(withBox);
1806
+ deckSlab.geometry.dispose();
1807
+ box.geometry.dispose();
1808
+ boxBrush.geometry.dispose();
1809
+ boxCut.geometry.dispose();
1810
+ deckSlab = withBox;
1811
+ }
1541
1812
  if (fasciaBrush) {
1542
1813
  const withFascia = csgEvaluator.evaluate(deckSlab, fasciaBrush, ADDITION);
1543
1814
  prepareBrushForCSG(withFascia);
@@ -1588,6 +1859,11 @@ export function getRoofSegmentBrushes(node, options = {}) {
1588
1859
  if (innerBrush)
1589
1860
  innerBrush.geometry.dispose();
1590
1861
  fasciaBrush?.geometry.dispose();
1862
+ framingBrush?.geometry.dispose();
1863
+ for (const cut of eaveCutBrushes)
1864
+ cut.geometry.dispose();
1865
+ boxBrush?.geometry.dispose();
1866
+ boxCut?.geometry.dispose();
1591
1867
  rakeBoards?.dispose();
1592
1868
  return null;
1593
1869
  }
@@ -1663,7 +1939,12 @@ function buildIndependentRoofSegmentGeometry(node, nodes) {
1663
1939
  ensureRenderableGeometryAttributes(geometry);
1664
1940
  return geometry;
1665
1941
  }
1666
- export function generateRoofSegmentGeometry(node, nodes) {
1942
+ /**
1943
+ * `framing`: the frame is shown (`useViewer.framingShown`), so the deck opens its structure band
1944
+ * (`roofFramingDepth`). Only the roof system asks; a picture of the plan or a section takes the
1945
+ * closed deck.
1946
+ */
1947
+ export function generateRoofSegmentGeometry(node, nodes, options = {}) {
1667
1948
  const parentNode = node.parentId ? nodes?.[node.parentId] : undefined;
1668
1949
  const parentRoof = parentNode?.type === 'roof' ? parentNode : undefined;
1669
1950
  const parentRoofPosition = parentRoof?.position;
@@ -1682,15 +1963,25 @@ export function generateRoofSegmentGeometry(node, nodes) {
1682
1963
  }
1683
1964
  result.computeVertexNormals();
1684
1965
  ensureRenderableGeometryAttributes(result);
1966
+ setGeometryFailure(node.id, null);
1685
1967
  return result;
1686
1968
  }
1687
- const brushes = withSegmentUvMatrix(segmentWorldMatrix, () => getRoofSegmentBrushes(node, { fascia: wearsFascia(node, parentRoof) }));
1969
+ const brushes = withSegmentUvMatrix(segmentWorldMatrix, () => getRoofSegmentBrushes(node, {
1970
+ fascia: wearsFascia(node, parentRoof),
1971
+ framing: options.framing ? roofFramingDepth(node) : 0,
1972
+ }));
1688
1973
  if (!brushes) {
1689
- // Fallback: simple box
1974
+ setGeometryFailure(node.id, 'build-failed');
1690
1975
  return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth);
1691
1976
  }
1692
1977
  if (nodes) {
1693
- subtractAccessoryCuts(brushes, node, nodes);
1978
+ try {
1979
+ subtractAccessoryCuts(brushes, node, nodes);
1980
+ }
1981
+ catch (error) {
1982
+ disposeRoofSegmentBrushSet(brushes);
1983
+ throw error;
1984
+ }
1694
1985
  }
1695
1986
  const { deckSlab, shinSlab, wallBrush, innerBrush, rakeBoards } = brushes;
1696
1987
  let resultGeo = new THREE.BufferGeometry();
@@ -1715,8 +2006,11 @@ export function generateRoofSegmentGeometry(node, nodes) {
1715
2006
  resultGeo = csgGeometry(combined);
1716
2007
  if (geometryHasInvalidAttributes(resultGeo)) {
1717
2008
  resultGeo.dispose();
2009
+ setGeometryFailure(node.id, 'invalid-geometry');
1718
2010
  resultGeo = csgGeometry(wallBrush).clone();
1719
2011
  }
2012
+ else
2013
+ setGeometryFailure(node.id, null);
1720
2014
  const resultMaterials = csgMaterials(combined);
1721
2015
  const matToIndex = new Map([
1722
2016
  [dummyMats[0], 0],
@@ -1729,11 +2023,14 @@ export function generateRoofSegmentGeometry(node, nodes) {
1729
2023
  }
1730
2024
  remapRoofShellFaces(resultGeo, node);
1731
2025
  resultGeo = addShedInsetEndPanels(resultGeo, [node], false);
2026
+ if (hollowWall)
2027
+ splitRoofWallBandFaces(resultGeo, [{ node, inverse: null }], true);
1732
2028
  hollowWall?.geometry.dispose();
1733
2029
  if (combined !== shinDeck)
1734
2030
  shinDeck.geometry.dispose();
1735
2031
  }
1736
2032
  catch (e) {
2033
+ setGeometryFailure(node.id, 'build-failed');
1737
2034
  console.error('Roof CSG failed:', e);
1738
2035
  resultGeo = csgGeometry(wallBrush).clone();
1739
2036
  }
@@ -2730,6 +3027,123 @@ function splitShellByFacePlanes(geometry, planes, materialIndex) {
2730
3027
  geometry.dispose();
2731
3028
  return createGeometryFromRawAttributes(keptPositions, keptNormals, keptUvs, keptGroups);
2732
3029
  }
3030
+ /** Off a wall shell's outer plane by more than this, a face is not the wall band. */
3031
+ const WALL_BAND_PLANE_TOLERANCE = 0.01;
3032
+ const _bandA = new THREE.Vector3();
3033
+ const _bandB = new THREE.Vector3();
3034
+ const _bandC = new THREE.Vector3();
3035
+ const _bandCentroid = new THREE.Vector3();
3036
+ const _bandNormal = new THREE.Vector3();
3037
+ const _bandLocalCentroid = new THREE.Vector3();
3038
+ const _bandLocalNormal = new THREE.Vector3();
3039
+ const _bandNormalMatrix = new THREE.Matrix3();
3040
+ /**
3041
+ * Which wall band a slot-0 triangle is, in segment-local space, or null when
3042
+ * it is not on the wall shell's outer face (soffits, deck and shingle edges).
3043
+ * The shell's outer faces stand half the wall thickness outside the footprint;
3044
+ * on the ±x ends, above the eave, they are the gables, and below it, like the
3045
+ * ±z sides, the heel band.
3046
+ */
3047
+ function classifyWallBandFace(node, centroid, normal) {
3048
+ if (Math.abs(normal.y) > 0.2)
3049
+ return null;
3050
+ const half = Math.max(0, node.wallThickness) / 2;
3051
+ const outerX = node.width / 2 + half;
3052
+ const outerZ = node.depth / 2 + half;
3053
+ const tol = WALL_BAND_PLANE_TOLERANCE;
3054
+ if (Math.abs(normal.x) > 0.9) {
3055
+ if (Math.sign(normal.x) !== Math.sign(centroid.x))
3056
+ return null;
3057
+ if (Math.abs(Math.abs(centroid.x) - outerX) > tol || Math.abs(centroid.z) > outerZ + tol)
3058
+ return null;
3059
+ // the shell's eave line, as `getRoofSegmentBrushes` raises it (5 cm at the least)
3060
+ const { tanTheta } = getSegmentSlopeFrame(node);
3061
+ const eaveY = Math.max(0.05, node.wallHeight - half * tanTheta);
3062
+ if (centroid.y <= eaveY + 1e-5)
3063
+ return ROOF_HEEL_MATERIAL_INDEX;
3064
+ return normal.x < 0 ? ROOF_GABLE_LEFT_MATERIAL_INDEX : ROOF_GABLE_RIGHT_MATERIAL_INDEX;
3065
+ }
3066
+ if (Math.abs(normal.z) > 0.9) {
3067
+ if (Math.sign(normal.z) !== Math.sign(centroid.z))
3068
+ return null;
3069
+ if (Math.abs(Math.abs(centroid.z) - outerZ) > tol || Math.abs(centroid.x) > outerX + tol)
3070
+ return null;
3071
+ return ROOF_HEEL_MATERIAL_INDEX;
3072
+ }
3073
+ return null;
3074
+ }
3075
+ /**
3076
+ * Split the wall bands off slot 0 (see `ROOF_HEEL_MATERIAL_INDEX`): the heel
3077
+ * band always, each gable end when `gables` (a segment's own mesh, where they
3078
+ * paint apart; on the merged shell they stay the edge role's). Runs on the
3079
+ * finished shell, after every slot remap that folds indices back into 0–3.
3080
+ */
3081
+ export function splitRoofWallBandFaces(geometry, segments, gables) {
3082
+ const position = geometry.getAttribute('position');
3083
+ if (!position || geometry.groups.length === 0 || segments.length === 0)
3084
+ return;
3085
+ const index = geometry.getIndex();
3086
+ const triangleCount = Math.floor((index ? index.count : position.count) / 3);
3087
+ if (triangleCount === 0)
3088
+ return;
3089
+ const vertexAt = (corner) => (index ? index.getX(corner) : corner);
3090
+ const triangleMaterials = new Array(triangleCount).fill(0);
3091
+ let changed = false;
3092
+ for (const group of geometry.groups) {
3093
+ const start = Math.floor(group.start / 3);
3094
+ const end = Math.min(triangleCount, Math.floor((group.start + group.count) / 3));
3095
+ for (let triangle = start; triangle < end; triangle++) {
3096
+ const materialIndex = group.materialIndex ?? 0;
3097
+ triangleMaterials[triangle] = materialIndex;
3098
+ if (materialIndex !== ROOF_EDGE_MATERIAL_INDEX)
3099
+ continue;
3100
+ _bandA.fromBufferAttribute(position, vertexAt(triangle * 3));
3101
+ _bandB.fromBufferAttribute(position, vertexAt(triangle * 3 + 1));
3102
+ _bandC.fromBufferAttribute(position, vertexAt(triangle * 3 + 2));
3103
+ _bandCentroid
3104
+ .copy(_bandA)
3105
+ .add(_bandB)
3106
+ .add(_bandC)
3107
+ .multiplyScalar(1 / 3);
3108
+ _bandNormal.subVectors(_bandB, _bandA).cross(_bandC.sub(_bandA));
3109
+ if (_bandNormal.lengthSq() < 1e-14)
3110
+ continue;
3111
+ _bandNormal.normalize();
3112
+ for (const segment of segments) {
3113
+ _bandLocalCentroid.copy(_bandCentroid);
3114
+ _bandLocalNormal.copy(_bandNormal);
3115
+ if (segment.inverse) {
3116
+ _bandLocalCentroid.applyMatrix4(segment.inverse);
3117
+ _bandLocalNormal.applyMatrix3(_bandNormalMatrix.setFromMatrix4(segment.inverse));
3118
+ _bandLocalNormal.normalize();
3119
+ }
3120
+ const band = classifyWallBandFace(segment.node, _bandLocalCentroid, _bandLocalNormal);
3121
+ if (band === null)
3122
+ continue;
3123
+ const resolved = band === ROOF_HEEL_MATERIAL_INDEX || gables ? band : ROOF_EDGE_MATERIAL_INDEX;
3124
+ if (resolved !== ROOF_EDGE_MATERIAL_INDEX) {
3125
+ triangleMaterials[triangle] = resolved;
3126
+ changed = true;
3127
+ }
3128
+ break;
3129
+ }
3130
+ }
3131
+ }
3132
+ if (!changed)
3133
+ return;
3134
+ geometry.clearGroups();
3135
+ let currentMaterial = triangleMaterials[0] ?? 0;
3136
+ let groupStart = 0;
3137
+ for (let triangle = 1; triangle < triangleCount; triangle++) {
3138
+ const materialIndex = triangleMaterials[triangle] ?? 0;
3139
+ if (materialIndex === currentMaterial)
3140
+ continue;
3141
+ geometry.addGroup(groupStart * 3, (triangle - groupStart) * 3, currentMaterial);
3142
+ groupStart = triangle;
3143
+ currentMaterial = materialIndex;
3144
+ }
3145
+ geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial);
3146
+ }
2733
3147
  export function remapRoofShellFaces(geometry, node) {
2734
3148
  const position = geometry.getAttribute('position');
2735
3149
  const index = geometry.getIndex();