@pascal-app/viewer 1.0.5 → 1.0.7

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 (199) hide show
  1. package/dist/components/viewer/daylight-atmosphere.d.ts +9 -0
  2. package/dist/components/viewer/daylight-atmosphere.d.ts.map +1 -0
  3. package/dist/components/viewer/daylight-atmosphere.js +315 -0
  4. package/dist/components/viewer/drone-controller.d.ts +15 -0
  5. package/dist/components/viewer/drone-controller.d.ts.map +1 -0
  6. package/dist/components/viewer/drone-controller.js +194 -0
  7. package/dist/components/viewer/glb-interactive.d.ts +4 -18
  8. package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
  9. package/dist/components/viewer/glb-interactive.js +32 -268
  10. package/dist/components/viewer/glb-scene-motion.d.ts +8 -0
  11. package/dist/components/viewer/glb-scene-motion.d.ts.map +1 -0
  12. package/dist/components/viewer/glb-scene-motion.js +57 -0
  13. package/dist/components/viewer/glb-scene.d.ts +3 -1
  14. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  15. package/dist/components/viewer/glb-scene.js +289 -49
  16. package/dist/components/viewer/glb-selection-callout.d.ts +17 -0
  17. package/dist/components/viewer/glb-selection-callout.d.ts.map +1 -0
  18. package/dist/components/viewer/glb-selection-callout.js +54 -0
  19. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  20. package/dist/components/viewer/glb-walkthrough-controller.js +6 -3
  21. package/dist/components/viewer/index.d.ts +5 -0
  22. package/dist/components/viewer/index.d.ts.map +1 -1
  23. package/dist/components/viewer/index.js +30 -6
  24. package/dist/components/viewer/lights.d.ts.map +1 -1
  25. package/dist/components/viewer/lights.js +192 -5
  26. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  27. package/dist/components/viewer/post-processing.js +150 -94
  28. package/dist/components/viewer/scene-environment.js +4 -5
  29. package/dist/components/viewer/selection-callout.d.ts +51 -0
  30. package/dist/components/viewer/selection-callout.d.ts.map +1 -0
  31. package/dist/components/viewer/selection-callout.js +197 -0
  32. package/dist/components/viewer/use-daylight-rooms.d.ts +13 -0
  33. package/dist/components/viewer/use-daylight-rooms.d.ts.map +1 -0
  34. package/dist/components/viewer/use-daylight-rooms.js +236 -0
  35. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  36. package/dist/components/viewer/viewer-camera.js +12 -1
  37. package/dist/index.d.ts +21 -7
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +23 -7
  40. package/dist/lib/daylight/ground.d.ts +21 -0
  41. package/dist/lib/daylight/ground.d.ts.map +1 -0
  42. package/dist/lib/daylight/ground.js +53 -0
  43. package/dist/lib/daylight/location.d.ts +32 -0
  44. package/dist/lib/daylight/location.d.ts.map +1 -0
  45. package/dist/lib/daylight/location.js +74 -0
  46. package/dist/lib/daylight/look.d.ts +33 -0
  47. package/dist/lib/daylight/look.d.ts.map +1 -0
  48. package/dist/lib/daylight/look.js +13 -0
  49. package/dist/lib/daylight/material-hooks.d.ts +2 -0
  50. package/dist/lib/daylight/material-hooks.d.ts.map +1 -0
  51. package/dist/lib/daylight/material-hooks.js +150 -0
  52. package/dist/lib/daylight/room-context.d.ts +12 -0
  53. package/dist/lib/daylight/room-context.d.ts.map +1 -0
  54. package/dist/lib/daylight/room-context.js +53 -0
  55. package/dist/lib/daylight/room-scene.d.ts +54 -0
  56. package/dist/lib/daylight/room-scene.d.ts.map +1 -0
  57. package/dist/lib/daylight/room-scene.js +356 -0
  58. package/dist/lib/daylight/rooms.d.ts +262 -0
  59. package/dist/lib/daylight/rooms.d.ts.map +1 -0
  60. package/dist/lib/daylight/rooms.js +1223 -0
  61. package/dist/lib/daylight/runtime.d.ts +33 -0
  62. package/dist/lib/daylight/runtime.d.ts.map +1 -0
  63. package/dist/lib/daylight/runtime.js +42 -0
  64. package/dist/lib/daylight/sky.d.ts +26 -0
  65. package/dist/lib/daylight/sky.d.ts.map +1 -0
  66. package/dist/lib/daylight/sky.js +217 -0
  67. package/dist/lib/daylight/solar.d.ts +46 -0
  68. package/dist/lib/daylight/solar.d.ts.map +1 -0
  69. package/dist/lib/daylight/solar.js +156 -0
  70. package/dist/lib/device-tier.d.ts +29 -0
  71. package/dist/lib/device-tier.d.ts.map +1 -0
  72. package/dist/lib/device-tier.js +71 -0
  73. package/dist/lib/dispose-object3d.d.ts.map +1 -1
  74. package/dist/lib/dispose-object3d.js +4 -2
  75. package/dist/lib/drone-flight.d.ts +44 -0
  76. package/dist/lib/drone-flight.d.ts.map +1 -0
  77. package/dist/lib/drone-flight.js +121 -0
  78. package/dist/lib/editor-highlight.d.ts +16 -0
  79. package/dist/lib/editor-highlight.d.ts.map +1 -0
  80. package/dist/lib/editor-highlight.js +33 -0
  81. package/dist/lib/foundation-materials.d.ts.map +1 -1
  82. package/dist/lib/foundation-materials.js +2 -4
  83. package/dist/lib/glb-merge.d.ts +23 -0
  84. package/dist/lib/glb-merge.d.ts.map +1 -0
  85. package/dist/lib/glb-merge.js +184 -0
  86. package/dist/lib/hardware-geometry.d.ts +21 -0
  87. package/dist/lib/hardware-geometry.d.ts.map +1 -0
  88. package/dist/lib/hardware-geometry.js +283 -0
  89. package/dist/lib/layer-pass.d.ts +2 -0
  90. package/dist/lib/layer-pass.d.ts.map +1 -1
  91. package/dist/lib/layer-pass.js +20 -0
  92. package/dist/lib/materials.d.ts.map +1 -1
  93. package/dist/lib/materials.js +50 -23
  94. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  95. package/dist/lib/merged-outline-node.js +7 -1
  96. package/dist/lib/occlusion-culler.d.ts +34 -0
  97. package/dist/lib/occlusion-culler.d.ts.map +1 -0
  98. package/dist/lib/occlusion-culler.js +698 -0
  99. package/dist/lib/post-processing-resources.d.ts +4 -0
  100. package/dist/lib/post-processing-resources.d.ts.map +1 -1
  101. package/dist/lib/post-processing-resources.js +5 -0
  102. package/dist/lib/readback-rows.d.ts +14 -0
  103. package/dist/lib/readback-rows.d.ts.map +1 -0
  104. package/dist/lib/readback-rows.js +31 -0
  105. package/dist/lib/reveal-pose.d.ts.map +1 -1
  106. package/dist/lib/reveal-pose.js +3 -0
  107. package/dist/lib/scene-matrices.d.ts +12 -0
  108. package/dist/lib/scene-matrices.d.ts.map +1 -0
  109. package/dist/lib/scene-matrices.js +287 -0
  110. package/dist/lib/scene-pass-mrt.d.ts +5 -2
  111. package/dist/lib/scene-pass-mrt.d.ts.map +1 -1
  112. package/dist/lib/scene-pass-mrt.js +7 -2
  113. package/dist/lib/scene-themes.d.ts +0 -2
  114. package/dist/lib/scene-themes.d.ts.map +1 -1
  115. package/dist/lib/scene-themes.js +0 -2
  116. package/dist/lib/scene-visibility.d.ts +2 -0
  117. package/dist/lib/scene-visibility.d.ts.map +1 -1
  118. package/dist/lib/scene-visibility.js +12 -3
  119. package/dist/lib/site-surfaces.d.ts +1 -8
  120. package/dist/lib/site-surfaces.d.ts.map +1 -1
  121. package/dist/lib/site-surfaces.js +0 -19
  122. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  123. package/dist/lib/snapshot-pipeline.js +24 -40
  124. package/dist/lib/texture-reference.d.ts.map +1 -1
  125. package/dist/lib/texture-reference.js +10 -8
  126. package/dist/lib/underground-ambiance.d.ts +0 -2
  127. package/dist/lib/underground-ambiance.d.ts.map +1 -1
  128. package/dist/lib/underground-ambiance.js +0 -2
  129. package/dist/store/use-item-light-pool.d.ts +3 -6
  130. package/dist/store/use-item-light-pool.d.ts.map +1 -1
  131. package/dist/store/use-item-light-pool.js +12 -0
  132. package/dist/store/use-viewer.d.ts +72 -15
  133. package/dist/store/use-viewer.d.ts.map +1 -1
  134. package/dist/store/use-viewer.js +103 -18
  135. package/dist/systems/ceiling/ceiling-system.d.ts +5 -0
  136. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  137. package/dist/systems/ceiling/ceiling-system.js +11 -1
  138. package/dist/systems/door/door-system.d.ts.map +1 -1
  139. package/dist/systems/door/door-system.js +501 -145
  140. package/dist/systems/door/leaf-profile.d.ts +11 -0
  141. package/dist/systems/door/leaf-profile.d.ts.map +1 -0
  142. package/dist/systems/door/leaf-profile.js +140 -0
  143. package/dist/systems/interactive/callout-actions.d.ts +72 -0
  144. package/dist/systems/interactive/callout-actions.d.ts.map +1 -0
  145. package/dist/systems/interactive/callout-actions.js +212 -0
  146. package/dist/systems/interactive/interactive-system.d.ts +5 -1
  147. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  148. package/dist/systems/interactive/interactive-system.js +44 -121
  149. package/dist/systems/interactive/opening-settled.d.ts +16 -0
  150. package/dist/systems/interactive/opening-settled.d.ts.map +1 -0
  151. package/dist/systems/interactive/opening-settled.js +53 -0
  152. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  153. package/dist/systems/item-light/item-light-system.js +22 -214
  154. package/dist/systems/item-light/pooled-lights.d.ts +70 -0
  155. package/dist/systems/item-light/pooled-lights.d.ts.map +1 -0
  156. package/dist/systems/item-light/pooled-lights.js +449 -0
  157. package/dist/systems/level/level-system.d.ts.map +1 -1
  158. package/dist/systems/level/level-system.js +19 -1
  159. package/dist/systems/roof/roof-material-slots.d.ts +14 -0
  160. package/dist/systems/roof/roof-material-slots.d.ts.map +1 -0
  161. package/dist/systems/roof/roof-material-slots.js +13 -0
  162. package/dist/systems/roof/roof-materials.d.ts +25 -2
  163. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  164. package/dist/systems/roof/roof-materials.js +85 -26
  165. package/dist/systems/roof/roof-system.d.ts +34 -1
  166. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  167. package/dist/systems/roof/roof-system.js +477 -105
  168. package/dist/systems/wall/opening-casing-geometry.d.ts +14 -0
  169. package/dist/systems/wall/opening-casing-geometry.d.ts.map +1 -0
  170. package/dist/systems/wall/opening-casing-geometry.js +102 -0
  171. package/dist/systems/wall/opening-cutout-geometry.d.ts +9 -0
  172. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -1
  173. package/dist/systems/wall/opening-cutout-geometry.js +7 -3
  174. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  175. package/dist/systems/wall/wall-materials.js +28 -35
  176. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  177. package/dist/systems/wall/wall-system.js +34 -19
  178. package/dist/systems/wall/wall-t-junctions.d.ts +15 -0
  179. package/dist/systems/wall/wall-t-junctions.d.ts.map +1 -0
  180. package/dist/systems/wall/wall-t-junctions.js +149 -0
  181. package/dist/systems/window/window-animation-system.d.ts +3 -12
  182. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  183. package/dist/systems/window/window-animation-system.js +37 -122
  184. package/dist/systems/window/window-pose.d.ts +31 -0
  185. package/dist/systems/window/window-pose.d.ts.map +1 -0
  186. package/dist/systems/window/window-pose.js +152 -0
  187. package/dist/systems/window/window-roller-shutter.d.ts +8 -0
  188. package/dist/systems/window/window-roller-shutter.d.ts.map +1 -0
  189. package/dist/systems/window/window-roller-shutter.js +64 -0
  190. package/dist/systems/window/window-system.d.ts +2 -11
  191. package/dist/systems/window/window-system.d.ts.map +1 -1
  192. package/dist/systems/window/window-system.js +470 -303
  193. package/package.json +6 -6
  194. package/dist/systems/interactive/control-widget.d.ts +0 -10
  195. package/dist/systems/interactive/control-widget.d.ts.map +0 -1
  196. package/dist/systems/interactive/control-widget.js +0 -35
  197. package/dist/systems/interactive/procedural-controls.d.ts +0 -13
  198. package/dist/systems/interactive/procedural-controls.d.ts.map +0 -1
  199. package/dist/systems/interactive/procedural-controls.js +0 -17
@@ -1,12 +1,15 @@
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 { ROOF_GABLE_LEFT_MATERIAL_INDEX, ROOF_GABLE_RIGHT_MATERIAL_INDEX, ROOF_HEEL_MATERIAL_INDEX, } from './roof-material-slots';
10
13
  function csgGeometry(brush) {
11
14
  return brush.geometry;
12
15
  }
@@ -233,6 +236,16 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
233
236
  // set from the render pass that received the override-publishing
234
237
  // `markDirty` call. Mirrors WallSystem / DoorSystem.
235
238
  useLiveNodeOverrides((s) => s.overrides);
239
+ // "Framing" opens the decks' structure band (`roofFramingDepth`): every segment, and so every
240
+ // roof shell, rebuilds when it flips.
241
+ useEffect(() => useViewer.subscribe((state, previous) => {
242
+ if (state.framingShown === previous.framingShown)
243
+ return;
244
+ const scene = useScene.getState();
245
+ for (const node of Object.values(scene.nodes))
246
+ if (node.type === 'roof-segment')
247
+ scene.markDirty(node.id);
248
+ }), []);
236
249
  useFrame(() => {
237
250
  // Clear stale pending updates when the scene is unloaded
238
251
  if (rootNodeIds.length === 0) {
@@ -252,6 +265,7 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
252
265
  pendingIndependentSegments.size === 0)
253
266
  return;
254
267
  const nodes = useScene.getState().nodes;
268
+ const framing = useViewer.getState().framingShown;
255
269
  // --- Pass 1: Process dirty roof-segments (throttled) ---
256
270
  let segmentsProcessed = 0;
257
271
  new Set([...dirtyNodes, ...pendingIndependentSegments]).forEach((id) => {
@@ -302,7 +316,7 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
302
316
  const revealOnly = mesh.parent?.name === 'segments-wrapper' &&
303
317
  mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true;
304
318
  if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
305
- updateRoofSegmentGeometry(effectiveSegment, mesh, nodes, geometryAdapter);
319
+ updateRoofSegmentGeometry(effectiveSegment, mesh, nodes, geometryAdapter, framing);
306
320
  segmentsProcessed++;
307
321
  }
308
322
  else if (isVisible && !revealOnly) {
@@ -358,7 +372,7 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
358
372
  // Built even while hidden: a roof assembling from its segments (construction
359
373
  // reveal) hides its shell, and must show it whole the moment it is captured.
360
374
  rememberMergedInputs(node, nodes);
361
- updateMergedRoofGeometry(node, group, nodes);
375
+ updateMergedRoofGeometry(node, group, nodes, framing);
362
376
  roofsProcessed++;
363
377
  pendingRoofUpdates.delete(id);
364
378
  }
@@ -368,8 +382,8 @@ export const RoofSystem = ({ geometryAdapter } = {}) => {
368
382
  // ============================================================================
369
383
  // GEOMETRY GENERATION
370
384
  // ============================================================================
371
- function updateRoofSegmentGeometry(node, mesh, nodes, adapter) {
372
- const newGeo = generateRoofSegmentGeometry(node, nodes);
385
+ function updateRoofSegmentGeometry(node, mesh, nodes, adapter, framing = false) {
386
+ const newGeo = generateRoofSegmentGeometry(node, nodes, { framing });
373
387
  mesh.geometry.dispose();
374
388
  mesh.geometry = newGeo;
375
389
  computeGeometryBoundsTree(newGeo);
@@ -455,14 +469,18 @@ function subtractAccessoryCuts(brushes, segment, nodes) {
455
469
  brushes.deckSlab = workingDeck;
456
470
  brushes.wallBrush = workingWall;
457
471
  }
458
- function getMergedRoofSegmentBrushes(roofNode, segment, nodes) {
472
+ function getMergedRoofSegmentBrushes(roofNode, segment, nodes, framing) {
459
473
  const segmentId = segment.id;
460
- const cacheKey = getMergedRoofSegmentCacheKey(roofNode, segment, nodes);
474
+ const framingDepth = framing ? roofFramingDepth(segment) : 0;
475
+ const cacheKey = getMergedRoofSegmentCacheKey(roofNode, segment, nodes, framingDepth);
461
476
  const cached = mergedRoofSegmentGeometryCache.get(segmentId);
462
477
  if (cached?.key === cacheKey) {
463
478
  return cloneCachedMergedRoofSegmentBrushes(cached);
464
479
  }
465
- const brushes = withSegmentUvMatrix(composeSegmentWorldMatrix(roofNode.position, roofNode.rotation ?? 0, segment.position, segment.rotation ?? 0), () => getRoofSegmentBrushes(segment, { fascia: wearsFascia(segment, roofNode) }));
480
+ const brushes = withSegmentUvMatrix(composeSegmentWorldMatrix(roofNode.position, roofNode.rotation ?? 0, segment.position, segment.rotation ?? 0), () => getRoofSegmentBrushes(segment, {
481
+ fascia: wearsFascia(segment, roofNode),
482
+ framing: framingDepth,
483
+ }));
466
484
  if (!brushes) {
467
485
  disposeCachedMergedRoofSegmentGeometrySet(cached);
468
486
  mergedRoofSegmentGeometryCache.delete(segmentId);
@@ -505,7 +523,7 @@ function getMergedRoofSegmentBrushes(roofNode, segment, nodes) {
505
523
  disposeRoofSegmentBrushSet(brushes);
506
524
  return cloned;
507
525
  }
508
- function updateMergedRoofGeometry(roofNode, group, nodes) {
526
+ function updateMergedRoofGeometry(roofNode, group, nodes, framing) {
509
527
  const mergedMesh = group.getObjectByName('merged-roof');
510
528
  if (!mergedMesh)
511
529
  return;
@@ -548,7 +566,7 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
548
566
  continue;
549
567
  }
550
568
  csgChildren.push(child);
551
- const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes);
569
+ const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes, framing);
552
570
  if (!brushes)
553
571
  continue;
554
572
  if (brushes.rakeBoards) {
@@ -648,6 +666,14 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
648
666
  g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex);
649
667
  }
650
668
  let finalGeo = addShedInsetEndPanels(resultGeo, csgChildren, true);
669
+ splitRoofWallBandFaces(finalGeo, csgChildren
670
+ .filter((child) => shouldIncludeRoofSegmentWallShell(child, roofNode))
671
+ .map((child) => ({
672
+ node: child,
673
+ inverse: new THREE.Matrix4()
674
+ .compose(_position.set(child.position[0], child.position[1], child.position[2]), _quaternion.setFromAxisAngle(_yAxis, child.rotation), _scale)
675
+ .invert(),
676
+ })), false);
651
677
  const appendedGeometries = [...rakeBoardGeometries, ...directSegmentGeometries];
652
678
  if (appendedGeometries.length > 0) {
653
679
  const merged = mergeGeometriesPreservingGroups([finalGeo, ...appendedGeometries]);
@@ -792,7 +818,7 @@ function getMergedRoofAccessoryCachePayload(segment, nodes) {
792
818
  }
793
819
  return payload;
794
820
  }
795
- function getMergedRoofSegmentCacheKey(roofNode, segment, nodes) {
821
+ function getMergedRoofSegmentCacheKey(roofNode, segment, nodes, framing) {
796
822
  return JSON.stringify({
797
823
  roofPosition: roofNode.position ?? [0, 0, 0],
798
824
  roofRotation: roofNode.rotation ?? 0,
@@ -800,6 +826,7 @@ function getMergedRoofSegmentCacheKey(roofNode, segment, nodes) {
800
826
  composite: (roofNode.children?.length ?? 0) > 1,
801
827
  segment,
802
828
  accessories: getMergedRoofAccessoryCachePayload(segment, nodes),
829
+ framing,
803
830
  });
804
831
  }
805
832
  export function mapRoofGroupMaterialIndex(groupMaterialIndex, csgMaterials, matToIndex) {
@@ -1181,103 +1208,215 @@ export function clipGeometryBySegmentTrim(geometry, segment) {
1181
1208
  * Generate complete hollow-shell geometry for a roof segment.
1182
1209
  * Ports the prototype's CSG approach using three-bvh-csg.
1183
1210
  */
1184
- /** A 1x8 fascia / rake board: actual 3/4 × 7-1/4 in. */
1185
- const FASCIA_BOARD = { thickness: 0.019, height: 0.184 };
1186
1211
  /** A board's back sits this far inside the deck edge, so the union is one solid with no coplanar faces. */
1187
1212
  const FASCIA_EMBED = 0.002;
1213
+ /**
1214
+ * Boards that meet leave this between them: drawn as one solid, overlapping or touching boards would
1215
+ * leave it no inside to tell, and the union with the deck tears.
1216
+ */
1217
+ const BOARD_GAP = 0.0001;
1188
1218
  /**
1189
1219
  * Slot 2 takes the trim finish like the rake faces, and the shell remap
1190
1220
  * leaves it alone: the remap would send a plumb eave board to the wall band
1191
- * and its top edge to the shingles.
1221
+ * and its top edge to the shingles. A boxed eave's soffit and returns take it too.
1192
1222
  */
1193
1223
  const FASCIA_MATERIAL_INDEX = 2;
1224
+ /** The forms whose eaves core's `roofEaveProfile` cuts and boards: the ones Bones frames with tails. */
1225
+ const EAVE_ROOF_TYPES = new Set(['gable', 'hip', 'shed', 'flat']);
1226
+ const EDGES_Z = [
1227
+ { axis: 'z', sign: 1 },
1228
+ { axis: 'z', sign: -1 },
1229
+ ];
1230
+ const EDGES_X = [
1231
+ { axis: 'x', sign: 1 },
1232
+ { axis: 'x', sign: -1 },
1233
+ ];
1234
+ /** The point of an edge's section (`s` out past its plate line, `y` over `plateY`) at `u` along it. */
1235
+ function edgePoint(node, edge, plateY, [s, y], u) {
1236
+ const out = edge.sign * ((edge.axis === 'z' ? node.depth : node.width) / 2 + s);
1237
+ return edge.axis === 'z'
1238
+ ? new THREE.Vector3(u, plateY + y, out)
1239
+ : new THREE.Vector3(out, plateY + y, u);
1240
+ }
1241
+ /** A convex solid's faces, each turned to face away from its centre. */
1242
+ function outwardFaces(faces) {
1243
+ const points = faces.flat();
1244
+ const centre = points
1245
+ .reduce((sum, v) => sum.add(v), new THREE.Vector3())
1246
+ .multiplyScalar(1 / Math.max(1, points.length));
1247
+ return faces.map((face) => {
1248
+ const normal = new THREE.Vector3()
1249
+ .subVectors(face[1], face[0])
1250
+ .cross(new THREE.Vector3().subVectors(face[2], face[0]));
1251
+ const faceCentre = face
1252
+ .reduce((sum, v) => sum.add(v), new THREE.Vector3())
1253
+ .multiplyScalar(1 / face.length);
1254
+ return normal.dot(faceCentre.sub(centre)) < 0 ? [...face].reverse() : face;
1255
+ });
1256
+ }
1257
+ /**
1258
+ * A prism over a convex `section` of an edge, run along it from `from` to `to`: each end where its
1259
+ * function puts it at each section point (affine in it), so an end can butt a sloped board's face.
1260
+ */
1261
+ function edgePrism(node, edge, plateY, section, from, to) {
1262
+ const a = section.map((point) => edgePoint(node, edge, plateY, point, from(point)));
1263
+ const b = section.map((point) => edgePoint(node, edge, plateY, point, to(point)));
1264
+ const faces = [a, b];
1265
+ for (let i = 0; i < section.length; i++) {
1266
+ const j = (i + 1) % section.length;
1267
+ faces.push([a[i], a[j], b[j], b[i]]);
1268
+ }
1269
+ return outwardFaces(faces);
1270
+ }
1271
+ /** Where a line through two section points is at height `y`, out from the plate line. */
1272
+ function sAtHeight([s0, y0], [s1, y1], y) {
1273
+ return Math.abs(y1 - y0) < 1e-9 ? s0 : s0 + ((y - y0) * (s1 - s0)) / (y1 - y0);
1274
+ }
1275
+ /** A fascia's section with its back pulled `FASCIA_EMBED` into the deck, along its thickness. */
1276
+ function embeddedFascia(profile) {
1277
+ const [backTop, frontTop, frontBottom, backBottom] = profile.fascia.corners;
1278
+ const [nx, ny] = profile.fascia.square ? [profile.cos, -profile.sin] : [1, 0];
1279
+ const back = ([s, y]) => [s - nx * FASCIA_EMBED, y - ny * FASCIA_EMBED];
1280
+ return [back(backTop), frontTop, frontBottom, back(backBottom)];
1281
+ }
1194
1282
  /**
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.
1283
+ * A rake board's section in the eave's frame (`profile`): plumb, its top on the sheathing's top, as
1284
+ * deep as the profile's rake board, from the fascia's back at the eave (square to the slope with a
1285
+ * square cut; held level at the fascia's bottom with a plumb-and-level one) up to `inner`, plumb.
1201
1286
  */
1202
- function buildFasciaBoards(node, reach, eaveTopY, tanTheta) {
1287
+ function rakeSection(profile, inner) {
1288
+ const { top, cos, sin, tan } = profile;
1289
+ const depth = profile.rake.height;
1290
+ const bottom = (s) => top(s) - depth;
1291
+ // short of the fascia's back, which is pulled into the deck
1292
+ const pull = FASCIA_EMBED + BOARD_GAP;
1293
+ const [s0, y0] = profile.end[0];
1294
+ const corner = profile.fascia.square
1295
+ ? [s0 - pull * cos, y0 + pull * sin]
1296
+ : [s0 - pull, top(s0 - pull)];
1297
+ const section = [corner];
1298
+ if (profile.cut === 'square') {
1299
+ const along = depth * cos;
1300
+ section.push([corner[0] - along * sin, corner[1] - along * cos]);
1301
+ }
1302
+ else if (profile.cut === 'plumb-level') {
1303
+ const level = profile.fascia.corners[3][1];
1304
+ const s = tan > 1e-9 ? (top(0) - depth - level) / tan : corner[0];
1305
+ if (bottom(corner[0]) < level && s < corner[0])
1306
+ section.push([corner[0], level], [s, level]);
1307
+ else
1308
+ section.push([corner[0], bottom(corner[0])]);
1309
+ }
1310
+ else
1311
+ section.push([corner[0], bottom(corner[0])]);
1312
+ section.push([inner, bottom(inner)], [inner, top(inner)]);
1313
+ return section;
1314
+ }
1315
+ /**
1316
+ * The fascia and rake boards a segment wears, in its own frame, as its eaves are cut
1317
+ * (`roofSegmentEaveProfile`): a 1x on every eave covering the tails' end, its top flush with the
1318
+ * sheathing's, and a plumb one up every gable or shed rake over the barge rafter, its top on the
1319
+ * sheathing's. The eave boards run through to the rake boards' faces, which butt their backs; a hip's
1320
+ * or flat roof's end boards butt the side boards' backs. Being part of the segment, they follow its
1321
+ * pitch, size, overhang, layers and type, and the trims, cuts and sibling roofs clip them like the
1322
+ * deck. Gable, hip, shed and flat; null for the other forms.
1323
+ */
1324
+ function buildFasciaBoards(node, reach, eave, high) {
1203
1325
  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;
1326
+ if (!EAVE_ROOF_TYPES.has(roofType))
1327
+ return null;
1328
+ const plateY = node.wallHeight;
1329
+ const thickness = eave.fascia.thickness;
1330
+ const board = embeddedFascia(eave);
1215
1331
  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]);
1332
+ const rakeFace = width / 2 + reach.x + thickness;
1333
+ if (roofType === 'gable' || roofType === 'shed') {
1334
+ const sides = roofType === 'gable' ? EDGES_Z : [EDGES_Z[0]];
1335
+ for (const edge of sides)
1336
+ faces.push(...edgePrism(node, edge, plateY, board, () => -rakeFace, () => rakeFace));
1337
+ const highY = plateY + getSegmentSlopeFrame(node).activeRh;
1338
+ if (roofType === 'shed' && high && (node.fasciaHighEdge ?? true))
1339
+ faces.push(...edgePrism(node, EDGES_Z[1], highY, embeddedFascia(high), () => -rakeFace, () => rakeFace));
1340
+ // the rakes: a gable's up each slope to the ridge, a shed's from its low eave to its high edge
1341
+ const inner = roofType === 'gable' ? -depth / 2 + BOARD_GAP : -depth - reach.z + FASCIA_EMBED + BOARD_GAP;
1342
+ const section = rakeSection(eave, inner);
1343
+ const back = width / 2 + reach.x - FASCIA_EMBED;
1344
+ for (const edge of sides)
1345
+ for (const sx of [-1, 1]) {
1346
+ const [x0, x1] = sx === 1 ? [back, rakeFace] : [-rakeFace, -back];
1347
+ faces.push(...edgePrism(node, edge, plateY, section, () => x0, () => x1));
1348
+ }
1267
1349
  }
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]);
1350
+ else {
1351
+ // hip and flat: the boards along ±z run through to the end boards' faces, which butt their backs
1352
+ const [, frontTop, frontBottom] = eave.fascia.corners;
1353
+ const front = (y) => sAtHeight(frontTop, frontBottom, y);
1354
+ const back = (y) => sAtHeight(board[0], board[3], y) - BOARD_GAP;
1355
+ for (const edge of EDGES_Z)
1356
+ faces.push(...edgePrism(node, edge, plateY, board, ([, y]) => -(width / 2 + front(y)), ([, y]) => width / 2 + front(y)));
1357
+ for (const edge of EDGES_X)
1358
+ faces.push(...edgePrism(node, edge, plateY, board, ([, y]) => -(depth / 2 + back(y)), ([, y]) => depth / 2 + back(y)));
1276
1359
  }
1277
- else
1278
- return null;
1279
1360
  return createGeometryFromFaces(faces, FASCIA_MATERIAL_INDEX);
1280
1361
  }
1362
+ /**
1363
+ * What a segment's eave cut takes off its deck slab out past the walls (`roofSegmentEaveProfile`):
1364
+ * the wedge outside a square cut; under a truss's top chords the heel's depth, the tails riding
1365
+ * that far over the deck's underside (`seal` under them, as the deck is); under a boxed eave's
1366
+ * soffit everything, the box standing on it. Each a convex solid, a hip's bounded by its hip lines
1367
+ * (a hair past them, so the two at a corner overlap). None for a plumb cut on rafters.
1368
+ */
1369
+ function buildEaveCuts(node, reach, eave, seal) {
1370
+ const { roofType, width, depth } = node;
1371
+ if (roofType === 'flat')
1372
+ return [];
1373
+ const plateY = node.wallHeight;
1374
+ const sections = [];
1375
+ if (eave.cut === 'square') {
1376
+ const [corner] = eave.end;
1377
+ const down = (point, d) => [
1378
+ point[0] - d * eave.sin,
1379
+ point[1] - d * eave.cos,
1380
+ ];
1381
+ const out = (point, d) => [
1382
+ point[0] + d * eave.cos,
1383
+ point[1] - d * eave.sin,
1384
+ ];
1385
+ const reachDown = node.deckThickness + 0.1;
1386
+ const above = down(corner, -0.2);
1387
+ const below = down(corner, reachDown);
1388
+ sections.push([above, out(above, 0.5), out(below, 0.5), below]);
1389
+ }
1390
+ const lift = eave.under(0);
1391
+ const far = eave.reach + 0.3;
1392
+ if (lift > 1e-6) {
1393
+ const at = (s, d) => [s, eave.under(s) - seal - d];
1394
+ sections.push([at(eave.wallOut, 0), at(far, 0), at(far, 1), at(eave.wallOut, 1)]);
1395
+ }
1396
+ if (eave.soffit) {
1397
+ const level = eave.soffit.y - eave.soffit.thickness;
1398
+ sections.push([
1399
+ [eave.wallOut, level],
1400
+ [far, level],
1401
+ [far, level - 1],
1402
+ [eave.wallOut, level - 1],
1403
+ ]);
1404
+ }
1405
+ if (sections.length === 0)
1406
+ return [];
1407
+ const solids = [];
1408
+ const edges = roofType === 'hip' ? [...EDGES_Z, ...EDGES_X] : roofType === 'gable' ? EDGES_Z : [EDGES_Z[0]];
1409
+ for (const edge of edges)
1410
+ for (const section of sections) {
1411
+ // a hip's eave runs out to its hip lines; a gable's or shed's past its rakes
1412
+ const half = (edge.axis === 'z' ? width : depth) / 2;
1413
+ const end = roofType === 'hip'
1414
+ ? ([s]) => half + Math.max(0, s) + 0.01
1415
+ : () => half + reach.x + 1;
1416
+ solids.push(edgePrism(node, edge, plateY, section, (point) => -end(point), end));
1417
+ }
1418
+ return solids;
1419
+ }
1281
1420
  /**
1282
1421
  * Whether a drawn segment wears fascia boards. A lean-to carries its own
1283
1422
  * trim, and a legacy composite shed roof stacks overlapping shed pieces as
@@ -1293,9 +1432,44 @@ function wearsFascia(node, parentRoof) {
1293
1432
  return false;
1294
1433
  return !parentRoof || (parentRoof.children?.length ?? 0) <= 1;
1295
1434
  }
1435
+ /** What a framed roof's deck keeps over its open structure band at least: a 7/16 in sheet. */
1436
+ const ROOF_DECK_KEPT = 0.0111;
1437
+ /**
1438
+ * Where the deck stays whole while its structure band is open (over the walls, in the overhangs),
1439
+ * its underside sits this far under the rafter plane, so the rafter tails stay inside it rather
1440
+ * than sharing its face.
1441
+ */
1442
+ const ROOF_DECK_SEAL = 0.005;
1443
+ /**
1444
+ * The forms whose open band is the deck's own planes raised: their planes stay put as the
1445
+ * footprint shrinks to the walls' inner faces. A gambrel's or a mansard's break and a dutch
1446
+ * gablet move with it, so those decks stay closed.
1447
+ */
1448
+ const FRAMING_OPEN_ROOF_TYPES = new Set(['gable', 'hip', 'shed', 'flat']);
1449
+ /**
1450
+ * How deep a roof plane's deck opens under its sheathing while the viewer shows the frame
1451
+ * (`useViewer.framingShown`), square to the slope: the structure its layers name
1452
+ * (`roofStructureDepth`: its rafters, or its trusses' top chords on their heel), else Bones' default
1453
+ * 2x6. A deck no deeper keeps its top 7/16 in; a plane finished underneath (a cathedral ceiling)
1454
+ * stays closed, the frame hidden behind its finish, and so does a form `FRAMING_OPEN_ROOF_TYPES`
1455
+ * leaves out. 0 when nothing opens.
1456
+ */
1457
+ export function roofFramingDepth(node) {
1458
+ if (!FRAMING_OPEN_ROOF_TYPES.has(node.roofType) || node.buildUp?.finish)
1459
+ return 0;
1460
+ const structure = node.buildUp
1461
+ ? roofStructureDepth(node.buildUp.structure) || LUMBER_DEPTH['2x6']
1462
+ : LUMBER_DEPTH['2x6'];
1463
+ const depth = Math.min(structure, node.deckThickness - ROOF_DECK_KEPT);
1464
+ return depth > 0.005 ? depth : 0;
1465
+ }
1296
1466
  /**
1297
1467
  * `fascia`: build the segment's fascia boards into its deck. Only the roof
1298
1468
  * renderer asks; a dormer, a chimney or a sibling's clip takes the bare shell.
1469
+ * `framing`: the structure band to open (`roofFramingDepth`), the roof
1470
+ * renderer's while the frame is shown. Inside the walls the deck keeps only the
1471
+ * band over it, so the rafters show from the attic; over the walls and in the
1472
+ * overhangs it stays whole, closing the band at the walls' inner faces.
1299
1473
  */
1300
1474
  export function getRoofSegmentBrushes(node, options = {}) {
1301
1475
  const { roofType, width, depth, wallHeight, wallThickness, deckThickness, shingleThickness } = node;
@@ -1316,7 +1490,7 @@ export function getRoofSegmentBrushes(node, options = {}) {
1316
1490
  const baseI = Math.min(width, depth) * node.dutchHipWidthRatio;
1317
1491
  const plateSeated = wallHeight <= 0;
1318
1492
  // `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) => {
1493
+ const getVol = (wExt, vOffset, baseY, matIndex, isVoid, materialRule, onPlate = false, xExt = wExt, keepBase = false) => {
1320
1494
  const wV = Math.max(0.01, width + 2 * xExt);
1321
1495
  const dV = Math.max(0.01, depth + 2 * wExt);
1322
1496
  const autoDrop = wExt * tanTheta;
@@ -1325,7 +1499,11 @@ export function getRoofSegmentBrushes(node, options = {}) {
1325
1499
  // One floor for all volumes keeps each cutter level with the shell it carves.
1326
1500
  // A plate-seated roof (wallHeight 0) is the exception for its sloped deck
1327
1501
  // volumes: their eave hangs below the plate, and raising it lifted the
1328
- // whole roof off its walls, so their base sinks instead.
1502
+ // whole roof off its walls, so their base sinks instead: under the plate's
1503
+ // plane at their edge too, so a deck's top volume keeps the deck's whole
1504
+ // depth out to the eave (a base 5 cm under its top cut the eave to a 5 cm
1505
+ // edge with the tails hanging under it). `keepBase`: a volume whose base
1506
+ // is its own (a boxed eave's soffit).
1329
1507
  const eaveY = wallHeight - autoDrop + vOffset;
1330
1508
  const sinkBase = plateSeated && !onPlate && autoDrop !== 0;
1331
1509
  const whV = sinkBase ? eaveY : Math.max(0.05, eaveY);
@@ -1335,7 +1513,9 @@ export function getRoofSegmentBrushes(node, options = {}) {
1335
1513
  if (roofType === 'shed')
1336
1514
  rhV = activeRh + 2 * autoDrop;
1337
1515
  }
1338
- const safeBaseY = sinkBase ? Math.min(baseY, whV - 0.05) : baseY;
1516
+ const safeBaseY = sinkBase && !keepBase
1517
+ ? Math.min(baseY, Math.min(whV, wallHeight - autoDrop - ROOF_DECK_SEAL) - 0.05)
1518
+ : baseY;
1339
1519
  let structuralI = baseI;
1340
1520
  if (isVoid) {
1341
1521
  structuralI += deckThickness;
@@ -1369,13 +1549,36 @@ export function getRoofSegmentBrushes(node, options = {}) {
1369
1549
  ? (normal) => (normal.y > SHINGLE_SURFACE_EPSILON ? 3 : ROOF_EDGE_MATERIAL_INDEX)
1370
1550
  : undefined;
1371
1551
  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;
1552
+ const framing = Math.max(0, options.framing ?? 0);
1553
+ const deckBotGeo = getVol(deckExt, framing > 0 ? -ROOF_DECK_SEAL : 0, -5, 0, true, undefined, false, reach.x);
1554
+ // the structure band inside the walls' inner faces, open while the frame is shown
1555
+ const framingRt = activeRh > 0 ? framing / cosTheta : framing;
1556
+ const half = -wallThickness / 2;
1557
+ const framingGeo = framing > 0 ? getVol(half, framingRt, -5, 0, true, undefined, false, half) : null;
1558
+ // The eaves, cut and boarded by the plane's rule, the one Bones cuts its tails by (core's
1559
+ // `roofSegmentEaveProfile`). Only the roof renderer asks (`options.fascia` given); a dormer, a
1560
+ // chimney or a sibling's clip takes the bare shell.
1561
+ const eave = options.fascia !== undefined && EAVE_ROOF_TYPES.has(roofType)
1562
+ ? roofSegmentEaveProfile(node)
1563
+ : null;
1564
+ const highEave = eave && roofType === 'shed' ? roofSegmentEaveProfile(node, 'high') : null;
1565
+ const fasciaGeo = eave && options.fascia ? buildFasciaBoards(node, reach, eave, highEave) : null;
1566
+ const eaveCutGeos = eave
1567
+ ? buildEaveCuts(node, reach, eave, framing > 0 ? ROOF_DECK_SEAL : 0).map((faces) => createGeometryFromFaces(faces, 1))
1568
+ : [];
1569
+ // A boxed eave (plumb and level): solid from the soffit, level under the tails, up into the deck,
1570
+ // from the walls (a hair into them) out to the fascia's back; its ends are the returns under the rakes.
1571
+ let boxGeo = null;
1572
+ let boxCut = null;
1573
+ if (eave?.soffit) {
1574
+ const soffitBottom = wallHeight + eave.soffit.y - eave.soffit.thickness;
1575
+ const ext = reach.z - FASCIA_EMBED;
1576
+ boxGeo = getVol(ext, verticalRt - 0.003, soffitBottom, FASCIA_MATERIAL_INDEX, false, undefined, false, roofType === 'hip' ? ext : reach.x - FASCIA_EMBED, true);
1577
+ const inside = eave.wallOut - FASCIA_EMBED;
1578
+ const far = width / 2 + depth / 2 + reach.x + reach.z + 2;
1579
+ const top = wallHeight + activeRh + deckThickness + shingleThickness + 2;
1580
+ 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);
1581
+ }
1379
1582
  const stSin = shingleThickness * sinTheta;
1380
1583
  const stCos = shingleThickness * cosTheta;
1381
1584
  const shinBotW = Math.max(0.01, width + 2 * reach.x);
@@ -1522,6 +1725,18 @@ export function getRoofSegmentBrushes(node, options = {}) {
1522
1725
  const fasciaBrush = fasciaGeo ? toBrush(fasciaGeo) : null;
1523
1726
  if (fasciaGeo && !fasciaBrush)
1524
1727
  fasciaGeo.dispose();
1728
+ const eaveCutBrushes = eaveCutGeos.flatMap((geometry) => {
1729
+ const brush = toBrush(geometry);
1730
+ if (!brush)
1731
+ geometry.dispose();
1732
+ return brush ? [brush] : [];
1733
+ });
1734
+ const boxBrush = boxGeo ? toBrush(boxGeo) : null;
1735
+ if (boxGeo && !boxBrush)
1736
+ boxGeo.dispose();
1737
+ const framingBrush = framingGeo ? toBrush(framingGeo) : null;
1738
+ if (framingGeo && !framingBrush)
1739
+ framingGeo.dispose();
1525
1740
  if (shinBotBrush) {
1526
1741
  const wV = shinBotW;
1527
1742
  const dV = shinBotD;
@@ -1538,6 +1753,31 @@ export function getRoofSegmentBrushes(node, options = {}) {
1538
1753
  try {
1539
1754
  let deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION);
1540
1755
  prepareBrushForCSG(deckSlab);
1756
+ if (framingBrush) {
1757
+ const open = csgEvaluator.evaluate(deckSlab, framingBrush, SUBTRACTION);
1758
+ prepareBrushForCSG(open);
1759
+ deckSlab.geometry.dispose();
1760
+ framingBrush.geometry.dispose();
1761
+ deckSlab = open;
1762
+ }
1763
+ for (const cut of eaveCutBrushes) {
1764
+ const next = csgEvaluator.evaluate(deckSlab, cut, SUBTRACTION);
1765
+ prepareBrushForCSG(next);
1766
+ deckSlab.geometry.dispose();
1767
+ cut.geometry.dispose();
1768
+ deckSlab = next;
1769
+ }
1770
+ if (boxBrush && boxCut) {
1771
+ const box = csgEvaluator.evaluate(boxBrush, boxCut, SUBTRACTION);
1772
+ prepareBrushForCSG(box);
1773
+ const withBox = csgEvaluator.evaluate(deckSlab, box, ADDITION);
1774
+ prepareBrushForCSG(withBox);
1775
+ deckSlab.geometry.dispose();
1776
+ box.geometry.dispose();
1777
+ boxBrush.geometry.dispose();
1778
+ boxCut.geometry.dispose();
1779
+ deckSlab = withBox;
1780
+ }
1541
1781
  if (fasciaBrush) {
1542
1782
  const withFascia = csgEvaluator.evaluate(deckSlab, fasciaBrush, ADDITION);
1543
1783
  prepareBrushForCSG(withFascia);
@@ -1588,6 +1828,11 @@ export function getRoofSegmentBrushes(node, options = {}) {
1588
1828
  if (innerBrush)
1589
1829
  innerBrush.geometry.dispose();
1590
1830
  fasciaBrush?.geometry.dispose();
1831
+ framingBrush?.geometry.dispose();
1832
+ for (const cut of eaveCutBrushes)
1833
+ cut.geometry.dispose();
1834
+ boxBrush?.geometry.dispose();
1835
+ boxCut?.geometry.dispose();
1591
1836
  rakeBoards?.dispose();
1592
1837
  return null;
1593
1838
  }
@@ -1663,7 +1908,12 @@ function buildIndependentRoofSegmentGeometry(node, nodes) {
1663
1908
  ensureRenderableGeometryAttributes(geometry);
1664
1909
  return geometry;
1665
1910
  }
1666
- export function generateRoofSegmentGeometry(node, nodes) {
1911
+ /**
1912
+ * `framing`: the frame is shown (`useViewer.framingShown`), so the deck opens its structure band
1913
+ * (`roofFramingDepth`). Only the roof system asks; a picture of the plan or a section takes the
1914
+ * closed deck.
1915
+ */
1916
+ export function generateRoofSegmentGeometry(node, nodes, options = {}) {
1667
1917
  const parentNode = node.parentId ? nodes?.[node.parentId] : undefined;
1668
1918
  const parentRoof = parentNode?.type === 'roof' ? parentNode : undefined;
1669
1919
  const parentRoofPosition = parentRoof?.position;
@@ -1684,7 +1934,10 @@ export function generateRoofSegmentGeometry(node, nodes) {
1684
1934
  ensureRenderableGeometryAttributes(result);
1685
1935
  return result;
1686
1936
  }
1687
- const brushes = withSegmentUvMatrix(segmentWorldMatrix, () => getRoofSegmentBrushes(node, { fascia: wearsFascia(node, parentRoof) }));
1937
+ const brushes = withSegmentUvMatrix(segmentWorldMatrix, () => getRoofSegmentBrushes(node, {
1938
+ fascia: wearsFascia(node, parentRoof),
1939
+ framing: options.framing ? roofFramingDepth(node) : 0,
1940
+ }));
1688
1941
  if (!brushes) {
1689
1942
  // Fallback: simple box
1690
1943
  return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth);
@@ -1729,6 +1982,8 @@ export function generateRoofSegmentGeometry(node, nodes) {
1729
1982
  }
1730
1983
  remapRoofShellFaces(resultGeo, node);
1731
1984
  resultGeo = addShedInsetEndPanels(resultGeo, [node], false);
1985
+ if (hollowWall)
1986
+ splitRoofWallBandFaces(resultGeo, [{ node, inverse: null }], true);
1732
1987
  hollowWall?.geometry.dispose();
1733
1988
  if (combined !== shinDeck)
1734
1989
  shinDeck.geometry.dispose();
@@ -2730,6 +2985,123 @@ function splitShellByFacePlanes(geometry, planes, materialIndex) {
2730
2985
  geometry.dispose();
2731
2986
  return createGeometryFromRawAttributes(keptPositions, keptNormals, keptUvs, keptGroups);
2732
2987
  }
2988
+ /** Off a wall shell's outer plane by more than this, a face is not the wall band. */
2989
+ const WALL_BAND_PLANE_TOLERANCE = 0.01;
2990
+ const _bandA = new THREE.Vector3();
2991
+ const _bandB = new THREE.Vector3();
2992
+ const _bandC = new THREE.Vector3();
2993
+ const _bandCentroid = new THREE.Vector3();
2994
+ const _bandNormal = new THREE.Vector3();
2995
+ const _bandLocalCentroid = new THREE.Vector3();
2996
+ const _bandLocalNormal = new THREE.Vector3();
2997
+ const _bandNormalMatrix = new THREE.Matrix3();
2998
+ /**
2999
+ * Which wall band a slot-0 triangle is, in segment-local space, or null when
3000
+ * it is not on the wall shell's outer face (soffits, deck and shingle edges).
3001
+ * The shell's outer faces stand half the wall thickness outside the footprint;
3002
+ * on the ±x ends, above the eave, they are the gables, and below it, like the
3003
+ * ±z sides, the heel band.
3004
+ */
3005
+ function classifyWallBandFace(node, centroid, normal) {
3006
+ if (Math.abs(normal.y) > 0.2)
3007
+ return null;
3008
+ const half = Math.max(0, node.wallThickness) / 2;
3009
+ const outerX = node.width / 2 + half;
3010
+ const outerZ = node.depth / 2 + half;
3011
+ const tol = WALL_BAND_PLANE_TOLERANCE;
3012
+ if (Math.abs(normal.x) > 0.9) {
3013
+ if (Math.sign(normal.x) !== Math.sign(centroid.x))
3014
+ return null;
3015
+ if (Math.abs(Math.abs(centroid.x) - outerX) > tol || Math.abs(centroid.z) > outerZ + tol)
3016
+ return null;
3017
+ // the shell's eave line, as `getRoofSegmentBrushes` raises it (5 cm at the least)
3018
+ const { tanTheta } = getSegmentSlopeFrame(node);
3019
+ const eaveY = Math.max(0.05, node.wallHeight - half * tanTheta);
3020
+ if (centroid.y <= eaveY + 1e-5)
3021
+ return ROOF_HEEL_MATERIAL_INDEX;
3022
+ return normal.x < 0 ? ROOF_GABLE_LEFT_MATERIAL_INDEX : ROOF_GABLE_RIGHT_MATERIAL_INDEX;
3023
+ }
3024
+ if (Math.abs(normal.z) > 0.9) {
3025
+ if (Math.sign(normal.z) !== Math.sign(centroid.z))
3026
+ return null;
3027
+ if (Math.abs(Math.abs(centroid.z) - outerZ) > tol || Math.abs(centroid.x) > outerX + tol)
3028
+ return null;
3029
+ return ROOF_HEEL_MATERIAL_INDEX;
3030
+ }
3031
+ return null;
3032
+ }
3033
+ /**
3034
+ * Split the wall bands off slot 0 (see `ROOF_HEEL_MATERIAL_INDEX`): the heel
3035
+ * band always, each gable end when `gables` (a segment's own mesh, where they
3036
+ * paint apart; on the merged shell they stay the edge role's). Runs on the
3037
+ * finished shell, after every slot remap that folds indices back into 0–3.
3038
+ */
3039
+ export function splitRoofWallBandFaces(geometry, segments, gables) {
3040
+ const position = geometry.getAttribute('position');
3041
+ if (!position || geometry.groups.length === 0 || segments.length === 0)
3042
+ return;
3043
+ const index = geometry.getIndex();
3044
+ const triangleCount = Math.floor((index ? index.count : position.count) / 3);
3045
+ if (triangleCount === 0)
3046
+ return;
3047
+ const vertexAt = (corner) => (index ? index.getX(corner) : corner);
3048
+ const triangleMaterials = new Array(triangleCount).fill(0);
3049
+ let changed = false;
3050
+ for (const group of geometry.groups) {
3051
+ const start = Math.floor(group.start / 3);
3052
+ const end = Math.min(triangleCount, Math.floor((group.start + group.count) / 3));
3053
+ for (let triangle = start; triangle < end; triangle++) {
3054
+ const materialIndex = group.materialIndex ?? 0;
3055
+ triangleMaterials[triangle] = materialIndex;
3056
+ if (materialIndex !== ROOF_EDGE_MATERIAL_INDEX)
3057
+ continue;
3058
+ _bandA.fromBufferAttribute(position, vertexAt(triangle * 3));
3059
+ _bandB.fromBufferAttribute(position, vertexAt(triangle * 3 + 1));
3060
+ _bandC.fromBufferAttribute(position, vertexAt(triangle * 3 + 2));
3061
+ _bandCentroid
3062
+ .copy(_bandA)
3063
+ .add(_bandB)
3064
+ .add(_bandC)
3065
+ .multiplyScalar(1 / 3);
3066
+ _bandNormal.subVectors(_bandB, _bandA).cross(_bandC.sub(_bandA));
3067
+ if (_bandNormal.lengthSq() < 1e-14)
3068
+ continue;
3069
+ _bandNormal.normalize();
3070
+ for (const segment of segments) {
3071
+ _bandLocalCentroid.copy(_bandCentroid);
3072
+ _bandLocalNormal.copy(_bandNormal);
3073
+ if (segment.inverse) {
3074
+ _bandLocalCentroid.applyMatrix4(segment.inverse);
3075
+ _bandLocalNormal.applyMatrix3(_bandNormalMatrix.setFromMatrix4(segment.inverse));
3076
+ _bandLocalNormal.normalize();
3077
+ }
3078
+ const band = classifyWallBandFace(segment.node, _bandLocalCentroid, _bandLocalNormal);
3079
+ if (band === null)
3080
+ continue;
3081
+ const resolved = band === ROOF_HEEL_MATERIAL_INDEX || gables ? band : ROOF_EDGE_MATERIAL_INDEX;
3082
+ if (resolved !== ROOF_EDGE_MATERIAL_INDEX) {
3083
+ triangleMaterials[triangle] = resolved;
3084
+ changed = true;
3085
+ }
3086
+ break;
3087
+ }
3088
+ }
3089
+ }
3090
+ if (!changed)
3091
+ return;
3092
+ geometry.clearGroups();
3093
+ let currentMaterial = triangleMaterials[0] ?? 0;
3094
+ let groupStart = 0;
3095
+ for (let triangle = 1; triangle < triangleCount; triangle++) {
3096
+ const materialIndex = triangleMaterials[triangle] ?? 0;
3097
+ if (materialIndex === currentMaterial)
3098
+ continue;
3099
+ geometry.addGroup(groupStart * 3, (triangle - groupStart) * 3, currentMaterial);
3100
+ groupStart = triangle;
3101
+ currentMaterial = materialIndex;
3102
+ }
3103
+ geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial);
3104
+ }
2733
3105
  export function remapRoofShellFaces(geometry, node) {
2734
3106
  const position = geometry.getAttribute('position');
2735
3107
  const index = geometry.getIndex();