@pascal-app/viewer 1.0.0-beta.5 → 1.0.1

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 (208) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/capture/asset-url.d.ts +2 -0
  4. package/dist/capture/asset-url.d.ts.map +1 -0
  5. package/dist/capture/asset-url.js +13 -0
  6. package/dist/capture/capture-runtime.d.ts +43 -0
  7. package/dist/capture/capture-runtime.d.ts.map +1 -0
  8. package/dist/capture/capture-runtime.js +198 -0
  9. package/dist/capture/frame.d.ts +4 -0
  10. package/dist/capture/frame.d.ts.map +1 -0
  11. package/dist/capture/frame.js +21 -0
  12. package/dist/capture/index.d.ts +12 -0
  13. package/dist/capture/index.d.ts.map +1 -0
  14. package/dist/capture/index.js +11 -0
  15. package/dist/capture/layer-visibility.d.ts +5 -0
  16. package/dist/capture/layer-visibility.d.ts.map +1 -0
  17. package/dist/capture/layer-visibility.js +11 -0
  18. package/dist/capture/layers/clay-matcap.d.ts +3 -0
  19. package/dist/capture/layers/clay-matcap.d.ts.map +1 -0
  20. package/dist/capture/layers/clay-matcap.js +26 -0
  21. package/dist/capture/layers/device-motion-layer.d.ts +7 -0
  22. package/dist/capture/layers/device-motion-layer.d.ts.map +1 -0
  23. package/dist/capture/layers/device-motion-layer.js +76 -0
  24. package/dist/capture/layers/point-cloud-layer.d.ts +15 -0
  25. package/dist/capture/layers/point-cloud-layer.d.ts.map +1 -0
  26. package/dist/capture/layers/point-cloud-layer.js +106 -0
  27. package/dist/capture/layers/room-model-layer.d.ts +9 -0
  28. package/dist/capture/layers/room-model-layer.d.ts.map +1 -0
  29. package/dist/capture/layers/room-model-layer.js +71 -0
  30. package/dist/capture/layers/surface-mesh-data.d.ts +9 -0
  31. package/dist/capture/layers/surface-mesh-data.d.ts.map +1 -0
  32. package/dist/capture/layers/surface-mesh-data.js +74 -0
  33. package/dist/capture/layers/surface-mesh-layer.d.ts +7 -0
  34. package/dist/capture/layers/surface-mesh-layer.d.ts.map +1 -0
  35. package/dist/capture/layers/surface-mesh-layer.js +16 -0
  36. package/dist/capture/preview.d.ts +3 -0
  37. package/dist/capture/preview.d.ts.map +1 -0
  38. package/dist/capture/preview.js +2 -0
  39. package/dist/capture/source-state.d.ts +22 -0
  40. package/dist/capture/source-state.d.ts.map +1 -0
  41. package/dist/capture/source-state.js +182 -0
  42. package/dist/capture/stream-rendering.d.ts +14 -0
  43. package/dist/capture/stream-rendering.d.ts.map +1 -0
  44. package/dist/capture/stream-rendering.js +70 -0
  45. package/dist/capture/trajectory.d.ts +20 -0
  46. package/dist/capture/trajectory.d.ts.map +1 -0
  47. package/dist/capture/trajectory.js +77 -0
  48. package/dist/capture/use-json-artifact.d.ts +2 -0
  49. package/dist/capture/use-json-artifact.d.ts.map +1 -0
  50. package/dist/capture/use-json-artifact.js +42 -0
  51. package/dist/components/viewer/batched-mesh-spike.d.ts +14 -0
  52. package/dist/components/viewer/batched-mesh-spike.d.ts.map +1 -0
  53. package/dist/components/viewer/batched-mesh-spike.js +64 -0
  54. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  55. package/dist/components/viewer/frame-limiter.js +4 -3
  56. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  57. package/dist/components/viewer/glb-scene.js +3 -1
  58. package/dist/components/viewer/index.d.ts.map +1 -1
  59. package/dist/components/viewer/index.js +70 -62
  60. package/dist/components/viewer/lights.d.ts.map +1 -1
  61. package/dist/components/viewer/lights.js +123 -89
  62. package/dist/components/viewer/perf-monitor.d.ts +6 -1
  63. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  64. package/dist/components/viewer/perf-monitor.js +307 -116
  65. package/dist/components/viewer/perf-panel.d.ts +3 -0
  66. package/dist/components/viewer/perf-panel.d.ts.map +1 -0
  67. package/dist/components/viewer/perf-panel.js +178 -0
  68. package/dist/components/viewer/pointer-raycast-layers.d.ts +15 -0
  69. package/dist/components/viewer/pointer-raycast-layers.d.ts.map +1 -0
  70. package/dist/components/viewer/pointer-raycast-layers.js +28 -0
  71. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  72. package/dist/components/viewer/post-processing.js +130 -83
  73. package/dist/components/viewer/registered-systems.d.ts +14 -5
  74. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  75. package/dist/components/viewer/registered-systems.js +22 -10
  76. package/dist/components/viewer/scene-atmosphere.d.ts +30 -0
  77. package/dist/components/viewer/scene-atmosphere.d.ts.map +1 -0
  78. package/dist/components/viewer/scene-atmosphere.js +89 -0
  79. package/dist/components/viewer/scene-environment.d.ts.map +1 -1
  80. package/dist/components/viewer/scene-environment.js +19 -5
  81. package/dist/components/viewer/scene-ground-replacement.d.ts +5 -0
  82. package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -0
  83. package/dist/components/viewer/scene-ground-replacement.js +35 -0
  84. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  85. package/dist/components/viewer/selection-manager.js +8 -16
  86. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  87. package/dist/components/viewer/viewer-camera.js +4 -1
  88. package/dist/components/viewer/viewer-presentations.d.ts +67 -0
  89. package/dist/components/viewer/viewer-presentations.d.ts.map +1 -0
  90. package/dist/components/viewer/viewer-presentations.js +120 -0
  91. package/dist/hooks/use-library-materials-version.d.ts +7 -0
  92. package/dist/hooks/use-library-materials-version.d.ts.map +1 -0
  93. package/dist/hooks/use-library-materials-version.js +10 -0
  94. package/dist/hooks/use-node-events.d.ts.map +1 -1
  95. package/dist/hooks/use-node-events.js +1 -0
  96. package/dist/index.d.ts +16 -7
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +16 -7
  99. package/dist/lib/csg-utils.d.ts +1 -0
  100. package/dist/lib/csg-utils.d.ts.map +1 -1
  101. package/dist/lib/csg-utils.js +6 -1
  102. package/dist/lib/gpu-perf.d.ts +0 -2
  103. package/dist/lib/gpu-perf.d.ts.map +1 -1
  104. package/dist/lib/gpu-perf.js +7 -21
  105. package/dist/lib/isolation.d.ts +14 -30
  106. package/dist/lib/isolation.d.ts.map +1 -1
  107. package/dist/lib/isolation.js +62 -66
  108. package/dist/lib/layer-pass.d.ts +28 -0
  109. package/dist/lib/layer-pass.d.ts.map +1 -0
  110. package/dist/lib/layer-pass.js +254 -0
  111. package/dist/lib/materials.d.ts +12 -0
  112. package/dist/lib/materials.d.ts.map +1 -1
  113. package/dist/lib/materials.js +52 -17
  114. package/dist/lib/merged-outline-node.d.ts +17 -8
  115. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  116. package/dist/lib/merged-outline-node.js +194 -52
  117. package/dist/lib/perf-actions.d.ts +55 -0
  118. package/dist/lib/perf-actions.d.ts.map +1 -0
  119. package/dist/lib/perf-actions.js +211 -0
  120. package/dist/lib/perf-observers.d.ts +2 -0
  121. package/dist/lib/perf-observers.d.ts.map +1 -0
  122. package/dist/lib/perf-observers.js +17 -0
  123. package/dist/lib/perf-panel-store.d.ts +71 -0
  124. package/dist/lib/perf-panel-store.d.ts.map +1 -0
  125. package/dist/lib/perf-panel-store.js +29 -0
  126. package/dist/lib/perf-tracks.d.ts +43 -0
  127. package/dist/lib/perf-tracks.d.ts.map +1 -0
  128. package/dist/lib/perf-tracks.js +113 -0
  129. package/dist/lib/pointer-events.d.ts +7 -0
  130. package/dist/lib/pointer-events.d.ts.map +1 -0
  131. package/dist/lib/pointer-events.js +680 -0
  132. package/dist/lib/post-processing-resources.d.ts +12 -0
  133. package/dist/lib/post-processing-resources.d.ts.map +1 -0
  134. package/dist/lib/post-processing-resources.js +19 -0
  135. package/dist/lib/renderer-capability.d.ts.map +1 -1
  136. package/dist/lib/renderer-capability.js +4 -2
  137. package/dist/lib/scene-visibility.d.ts +2 -1
  138. package/dist/lib/scene-visibility.d.ts.map +1 -1
  139. package/dist/lib/scene-visibility.js +25 -2
  140. package/dist/lib/snapshot-pipeline.d.ts +3 -1
  141. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  142. package/dist/lib/snapshot-pipeline.js +32 -16
  143. package/dist/store/use-viewer.d.ts +5 -3
  144. package/dist/store/use-viewer.d.ts.map +1 -1
  145. package/dist/store/use-viewer.js +2 -0
  146. package/dist/systems/ceiling/ceiling-system.js +1 -1
  147. package/dist/systems/door/door-animation-system.d.ts.map +1 -1
  148. package/dist/systems/door/door-animation-system.js +6 -9
  149. package/dist/systems/door/door-system.d.ts.map +1 -1
  150. package/dist/systems/door/door-system.js +13 -2
  151. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  152. package/dist/systems/floor-elevation/floor-elevation-system.js +7 -3
  153. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  154. package/dist/systems/geometry/geometry-system.js +7 -2
  155. package/dist/systems/level/level-system.d.ts.map +1 -1
  156. package/dist/systems/level/level-system.js +1 -1
  157. package/dist/systems/level/level-utils.d.ts +9 -0
  158. package/dist/systems/level/level-utils.d.ts.map +1 -1
  159. package/dist/systems/level/level-utils.js +14 -0
  160. package/dist/systems/perf-action-settle/perf-action-settle-system.d.ts +8 -0
  161. package/dist/systems/perf-action-settle/perf-action-settle-system.d.ts.map +1 -0
  162. package/dist/systems/perf-action-settle/perf-action-settle-system.js +32 -0
  163. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  164. package/dist/systems/roof/roof-system.js +1196 -58
  165. package/dist/systems/scan/scan-system.js +6 -5
  166. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  167. package/dist/systems/slab/slab-system.js +1 -2
  168. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  169. package/dist/systems/stair/stair-system.js +9 -8
  170. package/dist/systems/surface-hole-geometry.d.ts +1 -1
  171. package/dist/systems/surface-hole-geometry.d.ts.map +1 -1
  172. package/dist/systems/surface-hole-geometry.js +1 -1
  173. package/dist/systems/wall/level-miter-cache.d.ts.map +1 -1
  174. package/dist/systems/wall/wall-build-lifecycle.d.ts +9 -0
  175. package/dist/systems/wall/wall-build-lifecycle.d.ts.map +1 -0
  176. package/dist/systems/wall/wall-build-lifecycle.js +74 -0
  177. package/dist/systems/wall/wall-cutout-cache.d.ts +57 -0
  178. package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -0
  179. package/dist/systems/wall/wall-cutout-cache.js +304 -0
  180. package/dist/systems/wall/wall-cutout.d.ts +12 -1
  181. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  182. package/dist/systems/wall/wall-cutout.js +24 -161
  183. package/dist/systems/wall/wall-material-variant.d.ts +31 -0
  184. package/dist/systems/wall/wall-material-variant.d.ts.map +1 -0
  185. package/dist/systems/wall/wall-material-variant.js +10 -0
  186. package/dist/systems/wall/wall-materials.d.ts +6 -0
  187. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  188. package/dist/systems/wall/wall-materials.js +29 -13
  189. package/dist/systems/wall/wall-placeholder-sweep.d.ts +50 -0
  190. package/dist/systems/wall/wall-placeholder-sweep.d.ts.map +1 -0
  191. package/dist/systems/wall/wall-placeholder-sweep.js +51 -0
  192. package/dist/systems/wall/wall-rebuild-notifications.d.ts +5 -0
  193. package/dist/systems/wall/wall-rebuild-notifications.d.ts.map +1 -0
  194. package/dist/systems/wall/wall-rebuild-notifications.js +23 -0
  195. package/dist/systems/wall/wall-system.d.ts +11 -4
  196. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  197. package/dist/systems/wall/wall-system.js +405 -153
  198. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  199. package/dist/systems/window/window-animation-system.js +9 -8
  200. package/dist/systems/window/window-system.d.ts +1 -0
  201. package/dist/systems/window/window-system.d.ts.map +1 -1
  202. package/dist/systems/window/window-system.js +16 -2
  203. package/dist/systems/zone/zone-system.d.ts.map +1 -1
  204. package/dist/systems/zone/zone-system.js +28 -2
  205. package/package.json +17 -4
  206. package/dist/lib/polygon-union.d.ts +0 -4
  207. package/dist/lib/polygon-union.d.ts.map +0 -1
  208. package/dist/lib/polygon-union.js +0 -289
@@ -1,11 +1,11 @@
1
- import { getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getEffectiveNode, getRoofModuleFaces, getRoofShapeInsets, getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, nodeRegistry, normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, sceneRegistry, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
1
+ import { getConicalRoofCoverage, getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getEffectiveNode, getRoofModuleFaces, getRoofPlanBounds, getRoofSegmentSurfaceY, getRoofShapeInsets, getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, isBandedShedSegment, nodeRegistry, normalizeRoofSegmentTrim, pointInPolygon2D, ROOF_SHAPE_DEFAULTS, roofOverlapEntryOwns, roofPlanBoundsOverlap, sceneRegistry, unionPolygons, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
3
  import * as THREE from 'three';
4
4
  import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
5
5
  import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
6
6
  import { computeBoundsTree } from 'three-mesh-bvh';
7
7
  import { applyWorldScaleBoxUVs } from '../../lib/box-uv';
8
- import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils';
8
+ import { ensureRenderableGeometryAttributes, subtractCsgBrush } from '../../lib/csg-utils';
9
9
  function csgGeometry(brush) {
10
10
  return brush.geometry;
11
11
  }
@@ -108,9 +108,71 @@ function createDegenerateRoofPlaceholder() {
108
108
  }
109
109
  // Pending merged-roof updates carried across frames (for throttling)
110
110
  const pendingRoofUpdates = new Set();
111
+ const previousRoofPlanBounds = new Map();
111
112
  const warnedMergedRoofNaNIds = new Set();
112
113
  const MAX_ROOFS_PER_FRAME = 1;
113
114
  const MAX_SEGMENTS_PER_FRAME = 3;
115
+ function queueSiblingRoofUpdates(roofId, nodes) {
116
+ pendingRoofUpdates.add(roofId);
117
+ const roof = nodes[roofId]?.type === 'roof' ? getEffectiveNode(nodes[roofId]) : undefined;
118
+ if (roof?.type !== 'roof' || !roof.parentId)
119
+ return;
120
+ const currentBounds = getRoofPlanBounds({
121
+ position: roof.position,
122
+ rotation: roof.rotation,
123
+ segments: (roof.children ?? []).flatMap((id) => {
124
+ const segment = nodes[id];
125
+ if (segment?.type !== 'roof-segment')
126
+ return [];
127
+ const effective = getEffectiveNode(segment);
128
+ return [
129
+ {
130
+ position: effective.position,
131
+ rotation: effective.rotation,
132
+ width: effective.width,
133
+ depth: effective.depth,
134
+ },
135
+ ];
136
+ }),
137
+ });
138
+ const oldBounds = previousRoofPlanBounds.get(roofId);
139
+ if (currentBounds)
140
+ previousRoofPlanBounds.set(roofId, currentBounds);
141
+ const parent = nodes[roof.parentId];
142
+ if (!parent || !('children' in parent) || !Array.isArray(parent.children))
143
+ return;
144
+ for (const siblingId of parent.children) {
145
+ const sibling = nodes[siblingId];
146
+ if (sibling?.type !== 'roof' || sibling.id === roofId)
147
+ continue;
148
+ const effectiveSibling = getEffectiveNode(sibling);
149
+ const siblingBounds = getRoofPlanBounds({
150
+ position: effectiveSibling.position,
151
+ rotation: effectiveSibling.rotation,
152
+ segments: (effectiveSibling.children ?? []).flatMap((id) => {
153
+ const segment = nodes[id];
154
+ if (segment?.type !== 'roof-segment')
155
+ return [];
156
+ const effective = getEffectiveNode(segment);
157
+ return [
158
+ {
159
+ position: effective.position,
160
+ rotation: effective.rotation,
161
+ width: effective.width,
162
+ depth: effective.depth,
163
+ },
164
+ ];
165
+ }),
166
+ });
167
+ if (!siblingBounds)
168
+ continue;
169
+ previousRoofPlanBounds.set(sibling.id, siblingBounds);
170
+ if ((currentBounds && roofPlanBoundsOverlap(currentBounds, siblingBounds)) ||
171
+ (oldBounds && roofPlanBoundsOverlap(oldBounds, siblingBounds))) {
172
+ pendingRoofUpdates.add(sibling.id);
173
+ }
174
+ }
175
+ }
114
176
  // ============================================================================
115
177
  // ROOF SYSTEM
116
178
  // ============================================================================
@@ -127,6 +189,7 @@ export const RoofSystem = () => {
127
189
  // Clear stale pending updates when the scene is unloaded
128
190
  if (rootNodeIds.length === 0) {
129
191
  pendingRoofUpdates.clear();
192
+ previousRoofPlanBounds.clear();
130
193
  warnedMergedRoofNaNIds.clear();
131
194
  for (const cached of mergedRoofSegmentGeometryCache.values()) {
132
195
  disposeCachedMergedRoofSegmentGeometrySet(cached);
@@ -207,11 +270,11 @@ export const RoofSystem = () => {
207
270
  }
208
271
  // Queue the parent roof for a merged geometry update
209
272
  if (effectiveSegment.parentId) {
210
- pendingRoofUpdates.add(effectiveSegment.parentId);
273
+ queueSiblingRoofUpdates(effectiveSegment.parentId, nodes);
211
274
  }
212
275
  }
213
276
  else if (node.type === 'roof') {
214
- pendingRoofUpdates.add(id);
277
+ queueSiblingRoofUpdates(id, nodes);
215
278
  clearDirty(id);
216
279
  }
217
280
  });
@@ -408,16 +471,36 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
408
471
  }
409
472
  let totalShinSlab = null;
410
473
  let totalDeckSlab = null;
411
- let totalWall = null;
412
- let totalInner = null;
474
+ let totalWallShell = null;
413
475
  const rakeBoardGeometries = [];
476
+ const directSegmentGeometries = [];
477
+ const csgChildren = [];
414
478
  for (const child of children) {
479
+ const directGeometry = withSegmentUvMatrix(composeSegmentWorldMatrix(roofNode.position, roofNode.rotation ?? 0, child.position, child.rotation ?? 0), () => buildCustomShedGeometry(child, nodes) ?? buildDirectConicalSectorGeometry(child));
480
+ if (directGeometry) {
481
+ let withPanels = addShedInsetEndPanels(directGeometry, [child], false);
482
+ _matrix.compose(_position.set(child.position[0], child.position[1], child.position[2]), _quaternion.setFromAxisAngle(_yAxis, child.rotation), _scale);
483
+ withPanels.applyMatrix4(_matrix);
484
+ withPanels = clipDirectRoofGeometryAgainstSiblings(withPanels, child, nodes, 'roof');
485
+ directSegmentGeometries.push(withPanels);
486
+ continue;
487
+ }
488
+ csgChildren.push(child);
415
489
  const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes);
416
490
  if (!brushes)
417
491
  continue;
418
492
  if (brushes.rakeBoards) {
419
493
  rakeBoardGeometries.push(brushes.rakeBoards);
420
494
  }
495
+ const occludingInterior = buildOccludingRoofInterior(child, nodes, 'roof');
496
+ if (occludingInterior) {
497
+ const exposedShingles = subtractCsgBrush(brushes.shinSlab, occludingInterior, csgEvaluator);
498
+ brushes.shinSlab.geometry.dispose();
499
+ brushes.shinSlab = exposedShingles;
500
+ const exposedDeck = subtractCsgBrush(brushes.deckSlab, occludingInterior, csgEvaluator);
501
+ brushes.deckSlab.geometry.dispose();
502
+ brushes.deckSlab = exposedDeck;
503
+ }
421
504
  if (totalShinSlab) {
422
505
  const next = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION);
423
506
  totalShinSlab.geometry.dispose();
@@ -438,34 +521,41 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
438
521
  else {
439
522
  totalDeckSlab = brushes.deckSlab;
440
523
  }
441
- if (totalWall) {
442
- const next = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION);
443
- totalWall.geometry.dispose();
524
+ if (!shouldIncludeRoofSegmentWallShell(child, roofNode)) {
444
525
  brushes.wallBrush.geometry.dispose();
445
- prepareBrushForCSG(next);
446
- totalWall = next;
447
- }
448
- else {
449
- totalWall = brushes.wallBrush;
450
- }
451
- if (totalInner) {
452
- const next = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION);
453
- totalInner.geometry.dispose();
454
526
  brushes.innerBrush.geometry.dispose();
455
- prepareBrushForCSG(next);
456
- totalInner = next;
457
527
  }
458
528
  else {
459
- totalInner = brushes.innerBrush;
529
+ let wallShell = csgEvaluator.evaluate(brushes.wallBrush, brushes.innerBrush, SUBTRACTION);
530
+ brushes.wallBrush.geometry.dispose();
531
+ brushes.innerBrush.geometry.dispose();
532
+ prepareBrushForCSG(wallShell);
533
+ if (occludingInterior) {
534
+ const exposedWall = subtractCsgBrush(wallShell, occludingInterior, csgEvaluator);
535
+ wallShell.geometry.dispose();
536
+ wallShell = exposedWall;
537
+ }
538
+ if (totalWallShell) {
539
+ const next = csgEvaluator.evaluate(totalWallShell, wallShell, ADDITION);
540
+ totalWallShell.geometry.dispose();
541
+ wallShell.geometry.dispose();
542
+ prepareBrushForCSG(next);
543
+ totalWallShell = next;
544
+ }
545
+ else {
546
+ totalWallShell = wallShell;
547
+ }
460
548
  }
549
+ occludingInterior?.geometry.dispose();
461
550
  }
462
- if (totalShinSlab && totalDeckSlab && totalWall && totalInner) {
551
+ if (totalShinSlab && totalDeckSlab) {
463
552
  try {
464
- const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION);
465
- prepareBrushForCSG(finalWallTrimmed);
466
553
  const shinDeck = csgEvaluator.evaluate(totalShinSlab, totalDeckSlab, ADDITION);
467
554
  prepareBrushForCSG(shinDeck);
468
- const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION);
555
+ let combined = shinDeck;
556
+ if (totalWallShell) {
557
+ combined = csgEvaluator.evaluate(shinDeck, totalWallShell, ADDITION);
558
+ }
469
559
  prepareBrushForCSG(combined);
470
560
  const resultGeo = csgGeometry(combined);
471
561
  if (geometryHasInvalidAttributes(resultGeo)) {
@@ -474,14 +564,15 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
474
564
  warnedMergedRoofNaNIds.add(roofNode.id);
475
565
  }
476
566
  resultGeo.dispose();
477
- finalWallTrimmed.geometry.dispose();
478
- shinDeck.geometry.dispose();
567
+ if (combined !== shinDeck)
568
+ shinDeck.geometry.dispose();
479
569
  totalShinSlab.geometry.dispose();
480
570
  totalDeckSlab.geometry.dispose();
481
- totalWall.geometry.dispose();
482
- totalInner.geometry.dispose();
571
+ totalWallShell?.geometry.dispose();
483
572
  for (const geometry of rakeBoardGeometries)
484
573
  geometry.dispose();
574
+ for (const geometry of directSegmentGeometries)
575
+ geometry.dispose();
485
576
  return;
486
577
  }
487
578
  const resultMaterials = csgMaterials(combined);
@@ -494,9 +585,10 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
494
585
  for (const g of resultGeo.groups) {
495
586
  g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex);
496
587
  }
497
- let finalGeo = resultGeo;
498
- if (rakeBoardGeometries.length > 0) {
499
- const merged = mergeGeometriesPreservingGroups([finalGeo, ...rakeBoardGeometries]);
588
+ let finalGeo = addShedInsetEndPanels(resultGeo, csgChildren, true);
589
+ const appendedGeometries = [...rakeBoardGeometries, ...directSegmentGeometries];
590
+ if (appendedGeometries.length > 0) {
591
+ const merged = mergeGeometriesPreservingGroups([finalGeo, ...appendedGeometries]);
500
592
  if (merged) {
501
593
  finalGeo.dispose();
502
594
  finalGeo = merged;
@@ -504,23 +596,40 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
504
596
  }
505
597
  for (const geometry of rakeBoardGeometries)
506
598
  geometry.dispose();
599
+ for (const geometry of directSegmentGeometries)
600
+ geometry.dispose();
601
+ directSegmentGeometries.length = 0;
507
602
  finalGeo.computeVertexNormals();
508
603
  ensureRenderableGeometryAttributes(finalGeo);
509
604
  mergedMesh.geometry.dispose();
510
605
  mergedMesh.geometry = finalGeo;
511
- finalWallTrimmed.geometry.dispose();
512
- shinDeck.geometry.dispose();
606
+ if (combined !== shinDeck)
607
+ shinDeck.geometry.dispose();
513
608
  }
514
609
  catch (e) {
515
610
  console.error('Merged roof CSG failed:', e);
516
611
  }
517
612
  totalShinSlab.geometry.dispose();
518
613
  totalDeckSlab.geometry.dispose();
519
- totalWall.geometry.dispose();
520
- totalInner.geometry.dispose();
614
+ totalWallShell?.geometry.dispose();
521
615
  for (const geometry of rakeBoardGeometries)
522
616
  geometry.dispose();
523
617
  }
618
+ if (directSegmentGeometries.length > 0) {
619
+ const finalGeo = directSegmentGeometries.length === 1
620
+ ? directSegmentGeometries[0]
621
+ : mergeGeometriesPreservingGroups(directSegmentGeometries);
622
+ if (finalGeo) {
623
+ finalGeo.computeVertexNormals();
624
+ ensureRenderableGeometryAttributes(finalGeo);
625
+ mergedMesh.geometry.dispose();
626
+ mergedMesh.geometry = finalGeo;
627
+ }
628
+ for (const geometry of directSegmentGeometries) {
629
+ if (geometry !== finalGeo)
630
+ geometry.dispose();
631
+ }
632
+ }
524
633
  }
525
634
  function geometryHasInvalidAttributes(geometry) {
526
635
  const position = geometry.getAttribute('position');
@@ -707,6 +816,8 @@ const SHINGLE_SURFACE_EPSILON = 0.02;
707
816
  const RAKE_FACE_NORMAL_EPSILON = 0.3;
708
817
  const RAKE_FACE_ALIGNMENT_EPSILON = 0.35;
709
818
  const TRIM_CUT_EPSILON = 0.002;
819
+ const ROOF_EDGE_MATERIAL_INDEX = 0;
820
+ const ROOF_INSET_WALL_MATERIAL_INDEX = 2;
710
821
  const DUTCH_RAKE_SIDE_MATERIAL_INDEX = 1;
711
822
  const DUTCH_RAKE_TOP_MATERIAL_INDEX = 3;
712
823
  const DUTCH_RAKE_SLOPE_SEAT_OFFSET = 0.0002;
@@ -714,6 +825,41 @@ function pushDoubleSidedFace(targetFaces, face) {
714
825
  targetFaces.push(face);
715
826
  targetFaces.push(face.map((point) => point.clone()).reverse());
716
827
  }
828
+ function readShedFootprintPieces(node) {
829
+ const value = node.shedFootprintPieces;
830
+ if (!Array.isArray(value))
831
+ return [];
832
+ return value.flatMap((polygon) => {
833
+ if (!Array.isArray(polygon))
834
+ return [];
835
+ const points = polygon.flatMap((point) => {
836
+ if (!Array.isArray(point) || point.length < 2)
837
+ return [];
838
+ const x = readFiniteNumber(point[0]);
839
+ const z = readFiniteNumber(point[1]);
840
+ return x === null || z === null ? [] : [[x, z]];
841
+ });
842
+ return points.length >= 3 && points.length === polygon.length ? [points] : [];
843
+ });
844
+ }
845
+ function readShedOpenEndSides(node) {
846
+ const value = node.shedOpenEndSides;
847
+ if (!Array.isArray(value))
848
+ return new Set();
849
+ return new Set(value.filter((side) => side === 'left' || side === 'right'));
850
+ }
851
+ function shouldIncludeRoofSegmentWallShell(node, parentRoof) {
852
+ if (node.wallShell === 'include')
853
+ return true;
854
+ if (node.wallShell === 'omit')
855
+ return false;
856
+ if (node.roofType !== 'shed')
857
+ return true;
858
+ // Older composite roofs use overlapping shed segments as deck pieces. Their
859
+ // wall volumes were never part of the rendered shell and make CSG grow
860
+ // exponentially when unioned together.
861
+ return !parentRoof || (parentRoof.children?.length ?? 0) <= 1;
862
+ }
717
863
  function hasSegmentTrim(node) {
718
864
  const trim = normalizeRoofSegmentTrim(node);
719
865
  return (trim.left > 0 ||
@@ -949,6 +1095,7 @@ export function clipGeometryBySegmentTrim(geometry, segment) {
949
1095
  */
950
1096
  export function getRoofSegmentBrushes(node) {
951
1097
  const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang, shingleThickness, } = node;
1098
+ const conicalCoverage = getConicalRoofCoverage(node);
952
1099
  const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node);
953
1100
  const shapeRatios = getRoofShapeRatios({
954
1101
  gambrelLowerWidthRatio: node.gambrelLowerWidthRatio,
@@ -963,18 +1110,20 @@ export function getRoofSegmentBrushes(node) {
963
1110
  // the 2D floorplan and the slope frame (which derives activeRh from the
964
1111
  // same ratio). A hardcoded 0.25 desyncs the gablet from the parameter.
965
1112
  const baseI = Math.min(width, depth) * node.dutchHipWidthRatio;
966
- const getVol = (wExt, vOffset, baseY, matIndex, isVoid) => {
1113
+ const getVol = (wExt, vOffset, baseY, matIndex, isVoid, materialRule) => {
967
1114
  const wV = Math.max(0.01, width + 2 * wExt);
968
1115
  const dV = Math.max(0.01, depth + 2 * wExt);
969
1116
  const autoDrop = wExt * tanTheta;
970
- const whV = Math.max(0.01, wallHeight - autoDrop + vOffset);
1117
+ // Floor every prism at 5 cm so CSG never sees a degenerate volume — by
1118
+ // raising the top, never by sinking the base (the base is the wall top).
1119
+ // One floor for all volumes keeps each cutter level with the shell it carves.
1120
+ const whV = Math.max(0.05, wallHeight - autoDrop + vOffset);
971
1121
  let rhV = activeRh;
972
1122
  if (activeRh > 0) {
973
1123
  rhV = activeRh + autoDrop;
974
1124
  if (roofType === 'shed')
975
1125
  rhV = activeRh + 2 * autoDrop;
976
1126
  }
977
- const safeBaseY = Math.min(baseY, whV - 0.05);
978
1127
  let structuralI = baseI;
979
1128
  if (isVoid) {
980
1129
  structuralI += deckThickness;
@@ -985,21 +1134,26 @@ export function getRoofSegmentBrushes(node) {
985
1134
  d: dV,
986
1135
  wh: whV,
987
1136
  rh: rhV,
988
- baseY: safeBaseY,
1137
+ baseY,
989
1138
  insets: { dutchI: structuralI },
990
1139
  baseW: width,
991
1140
  baseD: depth,
992
1141
  tanTheta,
993
1142
  shapeRatios,
994
1143
  dutchTopRakeThickness: node.dutchTopRakeThickness,
1144
+ conicalStartAngle: conicalCoverage.startAngle,
1145
+ conicalSweepAngle: conicalCoverage.sweepAngle,
995
1146
  }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
996
- return createGeometryFromFaces(faces, matIndex);
1147
+ return createGeometryFromFaces(faces, materialRule ?? matIndex);
997
1148
  };
998
1149
  const wallGeo = getVol(wallThickness / 2, 0, 0, 0, false);
999
1150
  const innerGeo = getVol(-wallThickness / 2, 0, -5, 2, false);
1000
1151
  const horizontalOverhang = overhang * cosTheta;
1001
1152
  const deckExt = wallThickness / 2 + horizontalOverhang;
1002
- const deckTopGeo = getVol(deckExt, verticalRt, 0, 1, false);
1153
+ const shedRoofSideMaterialRule = roofType === 'shed'
1154
+ ? (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : ROOF_EDGE_MATERIAL_INDEX
1155
+ : undefined;
1156
+ const deckTopGeo = getVol(deckExt, verticalRt, 0, 1, false, shedRoofSideMaterialRule);
1003
1157
  const deckBotGeo = getVol(deckExt, 0, -5, 0, true);
1004
1158
  const stSin = shingleThickness * sinTheta;
1005
1159
  const stCos = shingleThickness * cosTheta;
@@ -1016,7 +1170,7 @@ export function getRoofSegmentBrushes(node) {
1016
1170
  let shinTopW = shinBotW;
1017
1171
  let shinTopD = shinBotD;
1018
1172
  let transZ = 0;
1019
- if (['hip', 'mansard', 'dutch'].includes(roofType)) {
1173
+ if (['hip', 'mansard', 'dutch', 'conical'].includes(roofType)) {
1020
1174
  shinTopW += 2 * stSin;
1021
1175
  shinTopD += 2 * stSin;
1022
1176
  }
@@ -1077,6 +1231,8 @@ export function getRoofSegmentBrushes(node) {
1077
1231
  tanTheta,
1078
1232
  shapeRatios,
1079
1233
  dutchTopRakeThickness: node.dutchTopRakeThickness,
1234
+ conicalStartAngle: conicalCoverage.startAngle,
1235
+ conicalSweepAngle: conicalCoverage.sweepAngle,
1080
1236
  }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
1081
1237
  const topFaces = getRoofModuleFaces({
1082
1238
  type: roofType,
@@ -1091,13 +1247,16 @@ export function getRoofSegmentBrushes(node) {
1091
1247
  tanTheta,
1092
1248
  shapeRatios,
1093
1249
  dutchTopRakeThickness: node.dutchTopRakeThickness,
1250
+ conicalStartAngle: conicalCoverage.startAngle,
1251
+ conicalSweepAngle: conicalCoverage.sweepAngle,
1094
1252
  }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
1095
1253
  let rakeBoards = null;
1096
1254
  if (roofType === 'dutch' && insetsTop.dutchI !== undefined) {
1097
1255
  rakeBoards = buildDutchRakeBoards(shinTopW, shinTopD, shinTopWh, shinTopRh, insetsTop.dutchI, shapeRatios, node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness);
1098
1256
  }
1099
- const shinBotGeo = createGeometryFromFaces(botFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1);
1100
- const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1);
1257
+ const shedRoofSideMaterialIndex = roofType === 'shed' ? ROOF_EDGE_MATERIAL_INDEX : 1;
1258
+ const shinBotGeo = createGeometryFromFaces(botFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : shedRoofSideMaterialIndex);
1259
+ const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : shedRoofSideMaterialIndex);
1101
1260
  if (transZ !== 0) {
1102
1261
  shinTopGeo.translate(0, 0, transZ);
1103
1262
  rakeBoards?.translate(0, 0, transZ);
@@ -1201,12 +1360,22 @@ export function getRoofSegmentBrushes(node) {
1201
1360
  return null;
1202
1361
  }
1203
1362
  export function generateRoofSegmentGeometry(node, nodes) {
1204
- const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined;
1205
- const parentRoofPosition = parentRoof && 'position' in parentRoof ? parentRoof.position : undefined;
1206
- const parentRoofRotation = parentRoof && 'rotation' in parentRoof
1207
- ? (parentRoof.rotation ?? 0)
1208
- : 0;
1209
- const brushes = withSegmentUvMatrix(composeSegmentWorldMatrix(parentRoofPosition, parentRoofRotation, node.position, node.rotation ?? 0), () => getRoofSegmentBrushes(node));
1363
+ const parentNode = node.parentId ? nodes?.[node.parentId] : undefined;
1364
+ const parentRoof = parentNode?.type === 'roof' ? parentNode : undefined;
1365
+ const parentRoofPosition = parentRoof?.position;
1366
+ const parentRoofRotation = parentRoof?.rotation ?? 0;
1367
+ const segmentWorldMatrix = composeSegmentWorldMatrix(parentRoofPosition, parentRoofRotation, node.position, node.rotation ?? 0);
1368
+ const directSegmentGeometry = withSegmentUvMatrix(segmentWorldMatrix, () => buildCustomShedGeometry(node, nodes) ?? buildDirectConicalSectorGeometry(node));
1369
+ if (directSegmentGeometry) {
1370
+ let result = addShedInsetEndPanels(directSegmentGeometry, [node], false);
1371
+ if (nodes) {
1372
+ result = clipDirectRoofGeometryAgainstSiblings(result, node, nodes, 'segment');
1373
+ }
1374
+ result.computeVertexNormals();
1375
+ ensureRenderableGeometryAttributes(result);
1376
+ return result;
1377
+ }
1378
+ const brushes = withSegmentUvMatrix(segmentWorldMatrix, () => getRoofSegmentBrushes(node));
1210
1379
  if (!brushes) {
1211
1380
  // Fallback: simple box
1212
1381
  return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth);
@@ -1217,12 +1386,23 @@ export function generateRoofSegmentGeometry(node, nodes) {
1217
1386
  const { deckSlab, shinSlab, wallBrush, innerBrush, rakeBoards } = brushes;
1218
1387
  let resultGeo = new THREE.BufferGeometry();
1219
1388
  try {
1220
- const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION);
1221
- prepareBrushForCSG(hollowWall);
1222
1389
  const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION);
1223
1390
  prepareBrushForCSG(shinDeck);
1224
- const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION);
1391
+ let combined = shinDeck;
1392
+ let hollowWall = null;
1393
+ if (shouldIncludeRoofSegmentWallShell(node, parentRoof)) {
1394
+ hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION);
1395
+ prepareBrushForCSG(hollowWall);
1396
+ combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION);
1397
+ }
1225
1398
  prepareBrushForCSG(combined);
1399
+ const siblingInterior = nodes ? buildOccludingRoofInterior(node, nodes, 'segment') : null;
1400
+ if (siblingInterior) {
1401
+ const unclipped = combined;
1402
+ combined = subtractCsgBrush(unclipped, siblingInterior, csgEvaluator);
1403
+ unclipped.geometry.dispose();
1404
+ siblingInterior.geometry.dispose();
1405
+ }
1226
1406
  resultGeo = csgGeometry(combined);
1227
1407
  if (geometryHasInvalidAttributes(resultGeo)) {
1228
1408
  resultGeo.dispose();
@@ -1239,8 +1419,10 @@ export function generateRoofSegmentGeometry(node, nodes) {
1239
1419
  group.materialIndex = mapRoofGroupMaterialIndex(group.materialIndex, resultMaterials, matToIndex);
1240
1420
  }
1241
1421
  remapRoofShellFaces(resultGeo, node);
1242
- hollowWall.geometry.dispose();
1243
- shinDeck.geometry.dispose();
1422
+ resultGeo = addShedInsetEndPanels(resultGeo, [node], false);
1423
+ hollowWall?.geometry.dispose();
1424
+ if (combined !== shinDeck)
1425
+ shinDeck.geometry.dispose();
1244
1426
  }
1245
1427
  catch (e) {
1246
1428
  console.error('Roof CSG failed:', e);
@@ -1262,6 +1444,124 @@ export function generateRoofSegmentGeometry(node, nodes) {
1262
1444
  ensureRenderableGeometryAttributes(resultGeo);
1263
1445
  return resultGeo;
1264
1446
  }
1447
+ function clipDirectRoofGeometryAgainstSiblings(geometry, node, nodes, space) {
1448
+ const siblingInterior = buildOccludingRoofInterior(node, nodes, space);
1449
+ if (!siblingInterior)
1450
+ return geometry;
1451
+ const brush = new Brush(geometry, dummyMats);
1452
+ prepareBrushForCSG(brush);
1453
+ try {
1454
+ const clipped = subtractCsgBrush(brush, siblingInterior, csgEvaluator);
1455
+ const clippedGeometry = csgGeometry(clipped);
1456
+ const clippedMaterials = csgMaterials(clipped);
1457
+ const materialIndices = new Map([
1458
+ [dummyMats[0], 0],
1459
+ [dummyMats[1], 1],
1460
+ [dummyMats[2], 2],
1461
+ [dummyMats[3], 3],
1462
+ ]);
1463
+ for (const group of clippedGeometry.groups) {
1464
+ group.materialIndex = mapRoofGroupMaterialIndex(group.materialIndex, clippedMaterials, materialIndices);
1465
+ }
1466
+ geometry.dispose();
1467
+ clippedGeometry.computeVertexNormals();
1468
+ ensureRenderableGeometryAttributes(clippedGeometry);
1469
+ return clippedGeometry;
1470
+ }
1471
+ catch (error) {
1472
+ console.error('Direct roof intersection CSG failed:', error);
1473
+ return geometry;
1474
+ }
1475
+ finally {
1476
+ siblingInterior.geometry.dispose();
1477
+ }
1478
+ }
1479
+ function buildOccludingRoofInterior(node, nodes, space) {
1480
+ if (!node.parentId)
1481
+ return null;
1482
+ const parent = nodes[node.parentId];
1483
+ if (parent?.type !== 'roof')
1484
+ return null;
1485
+ const roofEntries = collectSiblingRoofEntries(parent, nodes);
1486
+ const currentEntry = roofEntries.find(({ segment }) => segment.id === node.id);
1487
+ if (!currentEntry)
1488
+ return null;
1489
+ const targetRoofInverse = composeRoofTransform(parent).invert();
1490
+ const targetSegmentInverse = composeSegmentTransform(node).invert();
1491
+ let combinedInterior = null;
1492
+ for (let siblingIndex = 0; siblingIndex < roofEntries.length; siblingIndex++) {
1493
+ const entry = roofEntries[siblingIndex];
1494
+ const sibling = entry.segment;
1495
+ if (sibling.id === node.id)
1496
+ continue;
1497
+ if (sibling.roofType === 'shed')
1498
+ continue;
1499
+ const siblingOwnsOverlap = roofOverlapEntryOwns(roofOverlapEntry(entry.roof, sibling, nodes), roofOverlapEntry(currentEntry.roof, node, nodes));
1500
+ if (!siblingOwnsOverlap)
1501
+ continue;
1502
+ const siblingBrushes = getRoofSegmentBrushes(sibling);
1503
+ if (!siblingBrushes)
1504
+ continue;
1505
+ const siblingInTargetRoof = new THREE.Matrix4()
1506
+ .multiplyMatrices(targetRoofInverse, composeRoofTransform(entry.roof))
1507
+ .multiply(composeSegmentTransform(sibling));
1508
+ const relativeMatrix = space === 'segment'
1509
+ ? new THREE.Matrix4().multiplyMatrices(targetSegmentInverse, siblingInTargetRoof)
1510
+ : siblingInTargetRoof;
1511
+ csgGeometry(siblingBrushes.innerBrush).applyMatrix4(relativeMatrix);
1512
+ siblingBrushes.innerBrush.updateMatrixWorld();
1513
+ siblingBrushes.shinSlab.geometry.dispose();
1514
+ siblingBrushes.deckSlab.geometry.dispose();
1515
+ siblingBrushes.wallBrush.geometry.dispose();
1516
+ siblingBrushes.rakeBoards?.dispose();
1517
+ if (combinedInterior) {
1518
+ const next = csgEvaluator.evaluate(combinedInterior, siblingBrushes.innerBrush, ADDITION);
1519
+ combinedInterior.geometry.dispose();
1520
+ siblingBrushes.innerBrush.geometry.dispose();
1521
+ prepareBrushForCSG(next);
1522
+ combinedInterior = next;
1523
+ }
1524
+ else {
1525
+ combinedInterior = siblingBrushes.innerBrush;
1526
+ }
1527
+ }
1528
+ return combinedInterior;
1529
+ }
1530
+ function roofOverlapEntry(roof, segment, nodes) {
1531
+ const supportSegment = roof.support?.kind === 'roof' ? nodes[roof.support.roofSegmentId] : undefined;
1532
+ return {
1533
+ roofId: String(roof.id),
1534
+ segmentId: String(segment.id),
1535
+ supportRoofId: supportSegment?.type === 'roof-segment' && supportSegment.parentId
1536
+ ? String(supportSegment.parentId)
1537
+ : undefined,
1538
+ supportRoofSegmentId: roof.support?.kind === 'roof' ? String(roof.support.roofSegmentId) : undefined,
1539
+ roofType: segment.roofType,
1540
+ width: segment.width,
1541
+ depth: segment.depth,
1542
+ };
1543
+ }
1544
+ function collectSiblingRoofEntries(targetRoof, nodes) {
1545
+ const parent = targetRoof.parentId ? nodes[targetRoof.parentId] : undefined;
1546
+ const orderedRoofIds = parent && 'children' in parent && Array.isArray(parent.children)
1547
+ ? parent.children.filter((id) => nodes[id]?.type === 'roof')
1548
+ : [targetRoof.id];
1549
+ if (!orderedRoofIds.includes(targetRoof.id))
1550
+ orderedRoofIds.push(targetRoof.id);
1551
+ return orderedRoofIds.flatMap((roofId) => {
1552
+ const roof = getEffectiveNode(nodes[roofId]);
1553
+ return (roof.children ?? []).flatMap((segmentId) => {
1554
+ const segment = nodes[segmentId];
1555
+ return segment?.type === 'roof-segment' ? [{ roof, segment: getEffectiveNode(segment) }] : [];
1556
+ });
1557
+ });
1558
+ }
1559
+ function composeRoofTransform(roof) {
1560
+ return new THREE.Matrix4().compose(new THREE.Vector3(...roof.position), new THREE.Quaternion().setFromAxisAngle(_yAxis, roof.rotation ?? 0), new THREE.Vector3(1, 1, 1));
1561
+ }
1562
+ function composeSegmentTransform(segment) {
1563
+ return new THREE.Matrix4().compose(new THREE.Vector3(...segment.position), new THREE.Quaternion().setFromAxisAngle(_yAxis, segment.rotation ?? 0), new THREE.Vector3(1, 1, 1));
1564
+ }
1265
1565
  function createGeometryFromRawAttributes(positions, normals, uvs, groups) {
1266
1566
  const geometry = new THREE.BufferGeometry();
1267
1567
  geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
@@ -1298,6 +1598,742 @@ function mergeGeometriesPreservingGroups(geometries) {
1298
1598
  }
1299
1599
  return merged;
1300
1600
  }
1601
+ // Signed bend reference for a banded segment: the divisor that turns a flat
1602
+ // along-width coordinate into an arc angle.
1603
+ function bandSignedRef(arc) {
1604
+ return (Math.sign(arc.centerZ) || 1) * arc.radius;
1605
+ }
1606
+ // Concentric map: rotate a flat segment-local (x, z) about the stored arc center by
1607
+ // the angle its along-width coordinate subtends. The back (wall) edge lands at the
1608
+ // wall's radius, the front edge at radius ± depth — a thin annular band, never a disc.
1609
+ function bendBandPoint(arc, signedRef, x, z) {
1610
+ const phi = (x - arc.centerX) / signedRef;
1611
+ const radial = z - arc.centerZ;
1612
+ return {
1613
+ x: arc.centerX - radial * Math.sin(phi),
1614
+ z: arc.centerZ + radial * Math.cos(phi),
1615
+ };
1616
+ }
1617
+ // Remap every vertex of a flat segment-local geometry onto the concentric band,
1618
+ // keeping Y. Used for the side-infill end panels so they follow the arc ends.
1619
+ function applyBandBendToGeometry(geometry, arc) {
1620
+ const position = geometry.getAttribute('position');
1621
+ if (!position)
1622
+ return;
1623
+ const signedRef = bandSignedRef(arc);
1624
+ for (let index = 0; index < position.count; index++) {
1625
+ const bent = bendBandPoint(arc, signedRef, position.getX(index), position.getZ(index));
1626
+ position.setX(index, bent.x);
1627
+ position.setZ(index, bent.z);
1628
+ }
1629
+ position.needsUpdate = true;
1630
+ }
1631
+ // Faceted annular-band deck for a shed segment bent across its width. The slope runs
1632
+ // unchanged along depth (Z); the width axis (X) sweeps the stored concentric arc, so
1633
+ // the deck hugs the host wall as a thin band and can never balloon into a disc.
1634
+ function buildConcentricBandDeckGeometry(node) {
1635
+ const arc = node.arc;
1636
+ if (!arc)
1637
+ return null;
1638
+ const width = node.width;
1639
+ const halfWidth = width / 2;
1640
+ const halfDepth = node.depth / 2;
1641
+ const signedRef = bandSignedRef(arc);
1642
+ const { cosTheta } = getSegmentSlopeFrame(node);
1643
+ const verticalThickness = node.deckThickness / Math.max(0.1, cosTheta) + node.shingleThickness * cosTheta;
1644
+ const facetCount = Math.max(4, Math.min(32, Math.ceil(width / 0.4)));
1645
+ const bend = (localX, localZ) => {
1646
+ const bent = bendBandPoint(arc, signedRef, localX, localZ);
1647
+ return new THREE.Vector3(bent.x, getRoofSegmentSurfaceY(node, localX, localZ), bent.z);
1648
+ };
1649
+ const backBottom = [];
1650
+ const frontBottom = [];
1651
+ for (let index = 0; index <= facetCount; index++) {
1652
+ const localX = -halfWidth + (index / facetCount) * width;
1653
+ backBottom.push(bend(localX, -halfDepth));
1654
+ frontBottom.push(bend(localX, halfDepth));
1655
+ }
1656
+ const raise = (point) => new THREE.Vector3(point.x, point.y + verticalThickness, point.z);
1657
+ const backTop = backBottom.map(raise);
1658
+ const frontTop = frontBottom.map(raise);
1659
+ const faces = [];
1660
+ for (let index = 0; index < facetCount; index++) {
1661
+ const next = index + 1;
1662
+ // Each angular interval is its own convex quad. A single polygon around
1663
+ // the complete annular boundary is concave, so fan triangulation sends
1664
+ // diagonals through the open center and fills the roof as a solid sector.
1665
+ faces.push([
1666
+ backBottom[index].clone(),
1667
+ backBottom[next].clone(),
1668
+ frontBottom[next].clone(),
1669
+ frontBottom[index].clone(),
1670
+ ], [
1671
+ frontTop[index].clone(),
1672
+ frontTop[next].clone(),
1673
+ backTop[next].clone(),
1674
+ backTop[index].clone(),
1675
+ ], [
1676
+ backBottom[next].clone(),
1677
+ backBottom[index].clone(),
1678
+ backTop[index].clone(),
1679
+ backTop[next].clone(),
1680
+ ], [
1681
+ frontBottom[index].clone(),
1682
+ frontBottom[next].clone(),
1683
+ frontTop[next].clone(),
1684
+ frontTop[index].clone(),
1685
+ ]);
1686
+ }
1687
+ const last = facetCount;
1688
+ faces.push([backBottom[0].clone(), frontBottom[0].clone(), frontTop[0].clone(), backTop[0].clone()], [
1689
+ frontBottom[last].clone(),
1690
+ backBottom[last].clone(),
1691
+ backTop[last].clone(),
1692
+ frontTop[last].clone(),
1693
+ ]);
1694
+ const merged = createGeometryFromFaces(faces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : ROOF_EDGE_MATERIAL_INDEX);
1695
+ merged.computeVertexNormals();
1696
+ ensureRenderableGeometryAttributes(merged);
1697
+ return merged;
1698
+ }
1699
+ function buildDirectConicalSectorGeometry(node) {
1700
+ if (node.roofType !== 'conical' || hasSegmentTrim(node))
1701
+ return null;
1702
+ const coverage = getConicalRoofCoverage(node);
1703
+ if (coverage.fullCircle)
1704
+ return null;
1705
+ const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node);
1706
+ const deckRadius = node.width / 2 + node.wallThickness / 2 + node.overhang * Math.max(0, cosTheta);
1707
+ const deckDrop = (deckRadius - node.width / 2) * tanTheta;
1708
+ const deckVerticalThickness = node.deckThickness / Math.max(0.1, cosTheta);
1709
+ const shingleRadialThickness = node.shingleThickness * sinTheta;
1710
+ const shingleVerticalThickness = node.shingleThickness * cosTheta;
1711
+ const deckBottom = {
1712
+ radius: deckRadius,
1713
+ eaveY: node.wallHeight - deckDrop,
1714
+ peakY: node.wallHeight + activeRh,
1715
+ };
1716
+ const deckTop = {
1717
+ radius: deckRadius,
1718
+ eaveY: deckBottom.eaveY + deckVerticalThickness,
1719
+ peakY: deckBottom.peakY + deckVerticalThickness,
1720
+ };
1721
+ const shingleTop = {
1722
+ radius: deckRadius + shingleRadialThickness,
1723
+ eaveY: deckTop.eaveY + shingleVerticalThickness,
1724
+ peakY: deckTop.peakY + shingleVerticalThickness + shingleRadialThickness * tanTheta,
1725
+ };
1726
+ const radialSegments = Math.max(1, Math.ceil((48 * Math.abs(coverage.sweepAngle)) / (Math.PI * 2)));
1727
+ const angles = Array.from({ length: radialSegments + 1 }, (_, index) => coverage.startAngle + (index / radialSegments) * coverage.sweepAngle);
1728
+ const ring = (profile) => angles.map((angle) => new THREE.Vector3(Math.cos(angle) * profile.radius, profile.eaveY, Math.sin(angle) * profile.radius));
1729
+ const deckBottomRing = ring(deckBottom);
1730
+ const deckTopRing = ring(deckTop);
1731
+ const shingleTopRing = ring(shingleTop);
1732
+ const deckBottomApex = new THREE.Vector3(0, deckBottom.peakY, 0);
1733
+ const deckTopApex = new THREE.Vector3(0, deckTop.peakY, 0);
1734
+ const shingleTopApex = new THREE.Vector3(0, shingleTop.peakY, 0);
1735
+ const orient = (face, upward) => {
1736
+ const normalY = new THREE.Vector3()
1737
+ .subVectors(face[1], face[0])
1738
+ .cross(new THREE.Vector3().subVectors(face[2], face[0])).y;
1739
+ return normalY >= 0 === upward ? face : [...face].reverse();
1740
+ };
1741
+ const layerFaces = (bottomRing, bottomApex, topRing, topApex) => {
1742
+ const bottomFaces = [];
1743
+ const topFaces = [];
1744
+ const edgeFaces = [];
1745
+ for (let index = 0; index < radialSegments; index += 1) {
1746
+ const next = index + 1;
1747
+ bottomFaces.push(orient([bottomRing[index], bottomRing[next], bottomApex].map((point) => point.clone()), false));
1748
+ topFaces.push(orient([topRing[index], topRing[next], topApex].map((point) => point.clone()), true));
1749
+ edgeFaces.push([
1750
+ bottomRing[index].clone(),
1751
+ bottomRing[next].clone(),
1752
+ topRing[next].clone(),
1753
+ topRing[index].clone(),
1754
+ ]);
1755
+ }
1756
+ const last = radialSegments;
1757
+ edgeFaces.push([bottomApex.clone(), bottomRing[0].clone(), topRing[0].clone(), topApex.clone()], [bottomApex.clone(), topApex.clone(), topRing[last].clone(), bottomRing[last].clone()]);
1758
+ return { bottomFaces, topFaces, edgeFaces };
1759
+ };
1760
+ const deck = layerFaces(deckBottomRing, deckBottomApex, deckTopRing, deckTopApex);
1761
+ const shingles = layerFaces(deckTopRing, deckTopApex, shingleTopRing, shingleTopApex);
1762
+ const geometries = [
1763
+ createGeometryFromFaces([...deck.bottomFaces, ...deck.edgeFaces], ROOF_EDGE_MATERIAL_INDEX),
1764
+ createGeometryFromFaces(shingles.topFaces, 3),
1765
+ createGeometryFromFaces(shingles.edgeFaces, ROOF_EDGE_MATERIAL_INDEX),
1766
+ ];
1767
+ if (node.wallHeight > 0.001) {
1768
+ const outerRadius = node.width / 2 + node.wallThickness / 2;
1769
+ const innerRadius = Math.max(0.005, node.width / 2 - node.wallThickness / 2);
1770
+ const outerBottom = angles.map((angle) => new THREE.Vector3(Math.cos(angle) * outerRadius, 0, Math.sin(angle) * outerRadius));
1771
+ const outerTop = outerBottom.map((point) => new THREE.Vector3(point.x, node.wallHeight, point.z));
1772
+ const innerBottom = angles.map((angle) => new THREE.Vector3(Math.cos(angle) * innerRadius, 0, Math.sin(angle) * innerRadius));
1773
+ const innerTop = innerBottom.map((point) => new THREE.Vector3(point.x, node.wallHeight, point.z));
1774
+ const wallFaces = [];
1775
+ for (let index = 0; index < radialSegments; index += 1) {
1776
+ const next = index + 1;
1777
+ pushDoubleSidedFace(wallFaces, [
1778
+ outerBottom[next],
1779
+ outerBottom[index],
1780
+ outerTop[index],
1781
+ outerTop[next],
1782
+ ]);
1783
+ pushDoubleSidedFace(wallFaces, [
1784
+ innerBottom[index],
1785
+ innerBottom[next],
1786
+ innerTop[next],
1787
+ innerTop[index],
1788
+ ]);
1789
+ }
1790
+ const last = radialSegments;
1791
+ pushDoubleSidedFace(wallFaces, [outerBottom[0], innerBottom[0], innerTop[0], outerTop[0]]);
1792
+ pushDoubleSidedFace(wallFaces, [
1793
+ innerBottom[last],
1794
+ outerBottom[last],
1795
+ outerTop[last],
1796
+ innerTop[last],
1797
+ ]);
1798
+ geometries.push(createGeometryFromFaces(wallFaces, ROOF_EDGE_MATERIAL_INDEX));
1799
+ }
1800
+ const merged = mergeGeometriesPreservingGroups(geometries);
1801
+ for (const geometry of geometries)
1802
+ geometry.dispose();
1803
+ if (!merged)
1804
+ return null;
1805
+ merged.computeVertexNormals();
1806
+ ensureRenderableGeometryAttributes(merged);
1807
+ return merged;
1808
+ }
1809
+ function clipRoofPolygonAtX(polygon, boundaryX, keepGreater) {
1810
+ const clipped = [];
1811
+ for (let index = 0; index < polygon.length; index++) {
1812
+ const current = polygon[index];
1813
+ const next = polygon[(index + 1) % polygon.length];
1814
+ const currentInside = keepGreater ? current[0] >= boundaryX : current[0] <= boundaryX;
1815
+ const nextInside = keepGreater ? next[0] >= boundaryX : next[0] <= boundaryX;
1816
+ if (currentInside)
1817
+ clipped.push([current[0], current[1]]);
1818
+ if (currentInside === nextInside)
1819
+ continue;
1820
+ const ratio = (boundaryX - current[0]) / (next[0] - current[0]);
1821
+ clipped.push([boundaryX, current[1] + (next[1] - current[1]) * ratio]);
1822
+ }
1823
+ return clipped;
1824
+ }
1825
+ function sanitizeRoofPlanPolygon(polygon) {
1826
+ const tolerance = 1e-8;
1827
+ const points = polygon.filter((point, index) => {
1828
+ const previous = polygon[(index + polygon.length - 1) % polygon.length];
1829
+ return Math.hypot(point[0] - previous[0], point[1] - previous[1]) > tolerance;
1830
+ });
1831
+ let changed = true;
1832
+ while (changed && points.length >= 3) {
1833
+ changed = false;
1834
+ for (let index = 0; index < points.length; index++) {
1835
+ const previous = points[(index + points.length - 1) % points.length];
1836
+ const point = points[index];
1837
+ const next = points[(index + 1) % points.length];
1838
+ const cross = (point[0] - previous[0]) * (next[1] - point[1]) -
1839
+ (point[1] - previous[1]) * (next[0] - point[0]);
1840
+ if (Math.abs(cross) > tolerance)
1841
+ continue;
1842
+ points.splice(index, 1);
1843
+ changed = true;
1844
+ break;
1845
+ }
1846
+ }
1847
+ return points;
1848
+ }
1849
+ function facetBandedRoofPieces(pieces, width) {
1850
+ const facetCount = Math.max(4, Math.min(32, Math.ceil(width / 0.4)));
1851
+ const halfWidth = width / 2;
1852
+ const facetWidth = width / facetCount;
1853
+ const faceted = [];
1854
+ for (const piece of pieces) {
1855
+ for (let index = 0; index < facetCount; index++) {
1856
+ const minX = -halfWidth + index * facetWidth;
1857
+ const maxX = index === facetCount - 1 ? halfWidth : minX + facetWidth;
1858
+ const clipped = clipRoofPolygonAtX(clipRoofPolygonAtX(piece, minX, true), maxX, false);
1859
+ const area = clipped.reduce((sum, point, pointIndex) => {
1860
+ const next = clipped[(pointIndex + 1) % clipped.length];
1861
+ return sum + point[0] * next[1] - next[0] * point[1];
1862
+ }, 0);
1863
+ if (clipped.length >= 3 && Math.abs(area) > 1e-8)
1864
+ faceted.push(clipped);
1865
+ }
1866
+ }
1867
+ return faceted;
1868
+ }
1869
+ function facetBandedRoofBoundary(polygon, width) {
1870
+ const facetCount = Math.max(4, Math.min(32, Math.ceil(width / 0.4)));
1871
+ const halfWidth = width / 2;
1872
+ const facetWidth = width / facetCount;
1873
+ const boundaries = Array.from({ length: facetCount - 1 }, (_, index) => -halfWidth + (index + 1) * facetWidth);
1874
+ const segments = [];
1875
+ for (let index = 0; index < polygon.length; index++) {
1876
+ const start = polygon[index];
1877
+ const end = polygon[(index + 1) % polygon.length];
1878
+ const deltaX = end[0] - start[0];
1879
+ const splits = boundaries
1880
+ .flatMap((boundaryX) => {
1881
+ if (Math.abs(deltaX) <= 1e-8)
1882
+ return [];
1883
+ const ratio = (boundaryX - start[0]) / deltaX;
1884
+ return ratio > 1e-8 && ratio < 1 - 1e-8 ? [ratio] : [];
1885
+ })
1886
+ .sort((left, right) => left - right);
1887
+ const points = [0, ...splits, 1].map((ratio) => [
1888
+ start[0] + (end[0] - start[0]) * ratio,
1889
+ start[1] + (end[1] - start[1]) * ratio,
1890
+ ]);
1891
+ for (let pointIndex = 0; pointIndex + 1 < points.length; pointIndex++) {
1892
+ segments.push([points[pointIndex], points[pointIndex + 1]]);
1893
+ }
1894
+ }
1895
+ return segments;
1896
+ }
1897
+ function transformShedPlanPoint(node, point) {
1898
+ // `shedFootprintPieces` are persisted in the flat segment frame, while a
1899
+ // curved host's rendered deck is bent onto its annular band below. Keep the
1900
+ // overlap/joint queries in that same rendered frame; otherwise a curved run
1901
+ // can be matched against a neighbour at the opposite end of the wall.
1902
+ const frame = node.shedJointFrame;
1903
+ const bent = node.arc && frame
1904
+ ? bendBandPoint(node.arc, bandSignedRef(node.arc), point[0], point[1])
1905
+ : { x: point[0], z: point[1] };
1906
+ const rotation = node.rotation ?? 0;
1907
+ const cos = Math.cos(rotation);
1908
+ const sin = Math.sin(rotation);
1909
+ const segmentPoint = [
1910
+ (node.position[0] ?? 0) + bent.x * cos + bent.z * sin,
1911
+ (node.position[2] ?? 0) - bent.x * sin + bent.z * cos,
1912
+ ];
1913
+ if (!frame)
1914
+ return segmentPoint;
1915
+ const ownerCos = Math.cos(frame.rotation);
1916
+ const ownerSin = Math.sin(frame.rotation);
1917
+ return [
1918
+ frame.position[0] + segmentPoint[0] * ownerCos + segmentPoint[1] * ownerSin,
1919
+ frame.position[2] - segmentPoint[0] * ownerSin + segmentPoint[1] * ownerCos,
1920
+ ];
1921
+ }
1922
+ function inverseTransformShedPlanPoint(node, point) {
1923
+ const frame = node.shedJointFrame;
1924
+ let source = point;
1925
+ if (frame) {
1926
+ const ownerCos = Math.cos(frame.rotation);
1927
+ const ownerSin = Math.sin(frame.rotation);
1928
+ const dx = point[0] - frame.position[0];
1929
+ const dz = point[1] - frame.position[2];
1930
+ source = [dx * ownerCos - dz * ownerSin, dx * ownerSin + dz * ownerCos];
1931
+ }
1932
+ const rotation = node.rotation ?? 0;
1933
+ const cos = Math.cos(rotation);
1934
+ const sin = Math.sin(rotation);
1935
+ const dx = source[0] - (node.position[0] ?? 0);
1936
+ const dz = source[1] - (node.position[2] ?? 0);
1937
+ const segmentPoint = [dx * cos - dz * sin, dx * sin + dz * cos];
1938
+ if (!node.arc || !frame)
1939
+ return segmentPoint;
1940
+ const signedRef = bandSignedRef(node.arc);
1941
+ const radialX = segmentPoint[0] - node.arc.centerX;
1942
+ const radialZ = segmentPoint[1] - node.arc.centerZ;
1943
+ const radialSign = -(Math.sign(node.arc.centerZ) || 1);
1944
+ const phi = Math.atan2(-radialX * radialSign, radialZ * radialSign);
1945
+ const radial = Math.hypot(radialX, radialZ) * radialSign;
1946
+ return [node.arc.centerX + phi * signedRef, node.arc.centerZ + radial];
1947
+ }
1948
+ function pointInOrNearShedPolygon(point, polygon, tolerance) {
1949
+ if (pointInPolygon2D([point[0], point[1]], polygon, { includeBoundary: false }))
1950
+ return true;
1951
+ if (!(tolerance > 0))
1952
+ return false;
1953
+ const toleranceSquared = tolerance * tolerance;
1954
+ for (let index = 0; index < polygon.length; index++) {
1955
+ const start = polygon[index];
1956
+ const end = polygon[(index + 1) % polygon.length];
1957
+ const dx = end[0] - start[0];
1958
+ const dz = end[1] - start[1];
1959
+ const lengthSquared = dx * dx + dz * dz;
1960
+ const ratio = lengthSquared > 1e-12
1961
+ ? Math.max(0, Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared))
1962
+ : 0;
1963
+ const closestX = start[0] + dx * ratio;
1964
+ const closestZ = start[1] + dz * ratio;
1965
+ const distanceSquared = (point[0] - closestX) * (point[0] - closestX) + (point[1] - closestZ) * (point[1] - closestZ);
1966
+ if (distanceSquared <= toleranceSquared)
1967
+ return true;
1968
+ }
1969
+ return false;
1970
+ }
1971
+ // Managed shed segments record the ids of their joined owners. Restricting sibling detection
1972
+ // to these keeps a shed from mitering against a run it merely passes near in
1973
+ // world space (e.g. the two free ends of a J that overlap across its mouth).
1974
+ function isJoinedShedSibling(node, candidate) {
1975
+ const neighbors = node.shedJointNeighborIds;
1976
+ if (!neighbors || neighbors.length === 0)
1977
+ return true;
1978
+ return candidate.shedJointOwnerId !== undefined && neighbors.includes(candidate.shedJointOwnerId);
1979
+ }
1980
+ function shedJoinTolerance(node, sibling) {
1981
+ const sideOverhang = (segment) => {
1982
+ const structuralSpan = readFiniteNumber(segment.shedSideInfillSpan);
1983
+ return structuralSpan === null ? 0 : Math.max(0, (segment.width - structuralSpan) / 2);
1984
+ };
1985
+ return Math.max(0.02, Math.min(0.2, Math.max(node.overhang, sibling.overhang, node.wallThickness, sibling.wallThickness, sideOverhang(node), sideOverhang(sibling))));
1986
+ }
1987
+ function siblingShedSegments(node, nodes) {
1988
+ return Object.values(nodes).filter((candidate) => candidate.type === 'roof-segment' &&
1989
+ candidate.id !== node.id &&
1990
+ (Boolean(node.parentId && candidate.parentId === node.parentId) ||
1991
+ Boolean(node.shedJointScopeId && candidate.shedJointScopeId === node.shedJointScopeId)) &&
1992
+ candidate.roofType === 'shed' &&
1993
+ isJoinedShedSibling(node, candidate));
1994
+ }
1995
+ function edgeTouchesSiblingShed(node, start, end, nodes) {
1996
+ if (!nodes)
1997
+ return false;
1998
+ const siblings = siblingShedSegments(node, nodes);
1999
+ if (siblings.length === 0)
2000
+ return false;
2001
+ const dx = end[0] - start[0];
2002
+ const dz = end[1] - start[1];
2003
+ const length = Math.hypot(dx, dz);
2004
+ if (length <= 1e-6)
2005
+ return false;
2006
+ const nx = -dz / length;
2007
+ const nz = dx / length;
2008
+ for (const ratio of [0.25, 0.5, 0.75]) {
2009
+ const x = start[0] + dx * ratio;
2010
+ const z = start[1] + dz * ratio;
2011
+ const world = transformShedPlanPoint(node, [x, z]);
2012
+ const samples = [
2013
+ world,
2014
+ transformShedPlanPoint(node, [x + nx * 1e-4, z + nz * 1e-4]),
2015
+ transformShedPlanPoint(node, [x - nx * 1e-4, z - nz * 1e-4]),
2016
+ ];
2017
+ for (const sample of samples) {
2018
+ if (siblings.some((sibling) => {
2019
+ const local = inverseTransformShedPlanPoint(sibling, sample);
2020
+ const footprints = readShedFootprintPieces(sibling);
2021
+ const polygons = footprints.length > 0
2022
+ ? footprints
2023
+ : sibling.managedByParent
2024
+ ? [managedShedFootprint(sibling)]
2025
+ : [];
2026
+ return polygons.some((polygon) => pointInOrNearShedPolygon(local, polygon, shedJoinTolerance(node, sibling)));
2027
+ })) {
2028
+ return true;
2029
+ }
2030
+ }
2031
+ }
2032
+ return false;
2033
+ }
2034
+ function findSiblingShedAcrossEdge(node, start, end, nodes) {
2035
+ if (!nodes)
2036
+ return undefined;
2037
+ const siblings = siblingShedSegments(node, nodes);
2038
+ const dx = end[0] - start[0];
2039
+ const dz = end[1] - start[1];
2040
+ const length = Math.hypot(dx, dz);
2041
+ if (length <= 1e-6)
2042
+ return undefined;
2043
+ const nx = -dz / length;
2044
+ const nz = dx / length;
2045
+ return siblings.find((sibling) => [0.25, 0.5, 0.75].every((ratio) => {
2046
+ const x = start[0] + dx * ratio;
2047
+ const z = start[1] + dz * ratio;
2048
+ const samples = [
2049
+ transformShedPlanPoint(node, [x, z]),
2050
+ transformShedPlanPoint(node, [x + nx * 1e-4, z + nz * 1e-4]),
2051
+ transformShedPlanPoint(node, [x - nx * 1e-4, z - nz * 1e-4]),
2052
+ ];
2053
+ return samples.some((sample) => {
2054
+ const local = inverseTransformShedPlanPoint(sibling, sample);
2055
+ const footprints = readShedFootprintPieces(sibling);
2056
+ const polygons = footprints.length > 0
2057
+ ? footprints
2058
+ : sibling.managedByParent
2059
+ ? [managedShedFootprint(sibling)]
2060
+ : [];
2061
+ return polygons.some((polygon) => pointInOrNearShedPolygon(local, polygon, shedJoinTolerance(node, sibling)));
2062
+ });
2063
+ }));
2064
+ }
2065
+ function shedWorldYOrigin(node, nodes) {
2066
+ if (node.shedJointFrame)
2067
+ return node.shedJointFrame.position[1] + (node.position[1] ?? 0);
2068
+ const parent = node.parentId ? nodes[node.parentId] : undefined;
2069
+ return (parent?.type === 'roof' ? (parent.position[1] ?? 0) : 0) + (node.position[1] ?? 0);
2070
+ }
2071
+ function shedVerticalThickness(node) {
2072
+ const { cosTheta } = getSegmentSlopeFrame(node);
2073
+ return node.deckThickness / Math.max(0.1, cosTheta) + node.shingleThickness * cosTheta;
2074
+ }
2075
+ function siblingShedTopYInNodeFrame(node, sibling, point, nodes) {
2076
+ const worldPoint = transformShedPlanPoint(node, point);
2077
+ const siblingPoint = inverseTransformShedPlanPoint(sibling, worldPoint);
2078
+ return (shedWorldYOrigin(sibling, nodes) +
2079
+ getRoofSegmentSurfaceY(sibling, siblingPoint[0], siblingPoint[1]) +
2080
+ shedVerticalThickness(sibling) -
2081
+ shedWorldYOrigin(node, nodes));
2082
+ }
2083
+ function buildShedJointTransitionFaces(node, sibling, start, end, nodes, verticalThickness) {
2084
+ const heights = (point) => {
2085
+ const ownTop = getRoofSegmentSurfaceY(node, point[0], point[1]) + verticalThickness;
2086
+ const siblingTop = siblingShedTopYInNodeFrame(node, sibling, point, nodes);
2087
+ return { ownTop, siblingTop };
2088
+ };
2089
+ const startHeights = heights(start);
2090
+ const endHeights = heights(end);
2091
+ const startDelta = startHeights.siblingTop - startHeights.ownTop;
2092
+ const endDelta = endHeights.siblingTop - endHeights.ownTop;
2093
+ const tolerance = 1e-5;
2094
+ const face = (firstPoint, firstHeights, secondPoint, secondHeights) => {
2095
+ const firstOwn = new THREE.Vector3(firstPoint[0], firstHeights.ownTop, firstPoint[1]);
2096
+ const secondOwn = new THREE.Vector3(secondPoint[0], secondHeights.ownTop, secondPoint[1]);
2097
+ const secondSibling = new THREE.Vector3(secondPoint[0], secondHeights.siblingTop, secondPoint[1]);
2098
+ const firstSibling = new THREE.Vector3(firstPoint[0], firstHeights.siblingTop, firstPoint[1]);
2099
+ if (Math.abs(firstHeights.siblingTop - firstHeights.ownTop) <= tolerance) {
2100
+ return [firstOwn, secondOwn, secondSibling];
2101
+ }
2102
+ if (Math.abs(secondHeights.siblingTop - secondHeights.ownTop) <= tolerance) {
2103
+ return [firstOwn, secondOwn, firstSibling];
2104
+ }
2105
+ return [firstOwn, secondOwn, secondSibling, firstSibling];
2106
+ };
2107
+ // Geometric ownership: a run closes the step only along the stretch where its
2108
+ // own roof top sits BELOW the sibling's (delta > 0), raising a vertical wall up
2109
+ // to the sibling's top. `delta` is antisymmetric between the two runs, so every
2110
+ // point of a seam is owned by exactly one run — the lower one — independent of
2111
+ // run count, chain vs. loop topology, or random node ids. Flush stretches
2112
+ // (|delta| <= tol) have no step and draw nothing, so a reversed/continuous fold
2113
+ // stays open. This replaces the old id-order tiebreak and the "skip when joined
2114
+ // on both ends" rule, which together left every seam of a closed loop unclosed.
2115
+ const faces = [];
2116
+ if (startDelta > tolerance && endDelta > tolerance) {
2117
+ pushDoubleSidedFace(faces, face(start, startHeights, end, endHeights));
2118
+ }
2119
+ else if (startDelta > tolerance || endDelta > tolerance) {
2120
+ const ratio = startDelta / (startDelta - endDelta);
2121
+ const crossingPoint = [
2122
+ start[0] + (end[0] - start[0]) * ratio,
2123
+ start[1] + (end[1] - start[1]) * ratio,
2124
+ ];
2125
+ const crossingHeights = heights(crossingPoint);
2126
+ if (startDelta > tolerance) {
2127
+ pushDoubleSidedFace(faces, face(start, startHeights, crossingPoint, crossingHeights));
2128
+ }
2129
+ else {
2130
+ pushDoubleSidedFace(faces, face(crossingPoint, crossingHeights, end, endHeights));
2131
+ }
2132
+ }
2133
+ return faces;
2134
+ }
2135
+ function clipShedPolygonByLine(polygon, lineStart, lineEnd, outsidePoint) {
2136
+ const lineDx = lineEnd[0] - lineStart[0];
2137
+ const lineDz = lineEnd[1] - lineStart[1];
2138
+ const outsideSide = Math.sign(lineDx * (outsidePoint[1] - lineStart[1]) - lineDz * (outsidePoint[0] - lineStart[0])) || 1;
2139
+ const side = (point) => outsideSide * (lineDx * (point[1] - lineStart[1]) - lineDz * (point[0] - lineStart[0]));
2140
+ const clipped = [];
2141
+ for (let index = 0; index < polygon.length; index++) {
2142
+ const current = polygon[index];
2143
+ const next = polygon[(index + 1) % polygon.length];
2144
+ const currentSide = side(current);
2145
+ const nextSide = side(next);
2146
+ const currentInside = currentSide <= 1e-7;
2147
+ const nextInside = nextSide <= 1e-7;
2148
+ if (currentInside)
2149
+ clipped.push([current[0], current[1]]);
2150
+ if (currentInside !== nextInside) {
2151
+ const ratio = currentSide / (currentSide - nextSide);
2152
+ clipped.push([
2153
+ current[0] + (next[0] - current[0]) * ratio,
2154
+ current[1] + (next[1] - current[1]) * ratio,
2155
+ ]);
2156
+ }
2157
+ }
2158
+ return clipped;
2159
+ }
2160
+ function managedShedFootprint(node) {
2161
+ const trim = normalizeRoofSegmentTrim(node);
2162
+ let polygon = [
2163
+ [-node.width / 2 + trim.left, -node.depth / 2 + trim.back],
2164
+ [node.width / 2 - trim.right, -node.depth / 2 + trim.back],
2165
+ [node.width / 2 - trim.right, node.depth / 2 - trim.front],
2166
+ [-node.width / 2 + trim.left, node.depth / 2 - trim.front],
2167
+ ];
2168
+ const diagonalTrims = [
2169
+ {
2170
+ x: trim.frontLeftX,
2171
+ z: trim.frontLeftZ,
2172
+ start: [-node.width / 2 + trim.left + trim.frontLeftX, node.depth / 2 - trim.front],
2173
+ end: [-node.width / 2 + trim.left, node.depth / 2 - trim.front - trim.frontLeftZ],
2174
+ outside: [-node.width / 2 - 1, node.depth / 2 + 1],
2175
+ },
2176
+ {
2177
+ x: trim.frontRightX,
2178
+ z: trim.frontRightZ,
2179
+ start: [node.width / 2 - trim.right, node.depth / 2 - trim.front - trim.frontRightZ],
2180
+ end: [node.width / 2 - trim.right - trim.frontRightX, node.depth / 2 - trim.front],
2181
+ outside: [node.width / 2 + 1, node.depth / 2 + 1],
2182
+ },
2183
+ {
2184
+ x: trim.backLeftX,
2185
+ z: trim.backLeftZ,
2186
+ start: [-node.width / 2 + trim.left, -node.depth / 2 + trim.back + trim.backLeftZ],
2187
+ end: [-node.width / 2 + trim.left + trim.backLeftX, -node.depth / 2 + trim.back],
2188
+ outside: [-node.width / 2 - 1, -node.depth / 2 - 1],
2189
+ },
2190
+ {
2191
+ x: trim.backRightX,
2192
+ z: trim.backRightZ,
2193
+ start: [node.width / 2 - trim.right - trim.backRightX, -node.depth / 2 + trim.back],
2194
+ end: [node.width / 2 - trim.right, -node.depth / 2 + trim.back + trim.backRightZ],
2195
+ outside: [node.width / 2 + 1, -node.depth / 2 - 1],
2196
+ },
2197
+ ];
2198
+ for (const diagonal of diagonalTrims) {
2199
+ if (diagonal.x <= 0 || diagonal.z <= 0 || polygon.length < 3)
2200
+ continue;
2201
+ polygon = clipShedPolygonByLine(polygon, diagonal.start, diagonal.end, diagonal.outside);
2202
+ }
2203
+ return polygon;
2204
+ }
2205
+ function buildCustomShedGeometry(node, nodes) {
2206
+ if (node.roofType !== 'shed')
2207
+ return null;
2208
+ const storedPieces = readShedFootprintPieces(node);
2209
+ const pieces = storedPieces.length > 0
2210
+ ? storedPieces
2211
+ : node.managedByParent
2212
+ ? [managedShedFootprint(node)]
2213
+ : [];
2214
+ const banded = isBandedShedSegment(node) && node.arc;
2215
+ if (pieces.length === 0)
2216
+ return banded ? buildConcentricBandDeckGeometry(node) : null;
2217
+ const renderPieces = banded
2218
+ ? [...facetBandedRoofPieces(pieces.slice(0, 1), node.width), ...pieces.slice(1)]
2219
+ : pieces;
2220
+ const { cosTheta } = getSegmentSlopeFrame(node);
2221
+ const verticalThickness = node.deckThickness / Math.max(0.1, cosTheta) + node.shingleThickness * cosTheta;
2222
+ const geometries = [];
2223
+ for (const polygon of renderPieces) {
2224
+ const sanitized = sanitizeRoofPlanPolygon(polygon);
2225
+ const signedArea = sanitized.reduce((area, point, index) => {
2226
+ const next = sanitized[(index + 1) % sanitized.length];
2227
+ return area + point[0] * next[1] - next[0] * point[1];
2228
+ }, 0);
2229
+ if (Math.abs(signedArea) <= 1e-9)
2230
+ continue;
2231
+ const outline = signedArea > 0 ? sanitized : [...sanitized].reverse();
2232
+ const edgeSiblings = banded
2233
+ ? outline.map(() => undefined)
2234
+ : outline.map((point, index) => {
2235
+ const next = outline[(index + 1) % outline.length];
2236
+ return edgeTouchesSiblingShed(node, point, next, nodes)
2237
+ ? findSiblingShedAcrossEdge(node, point, next, nodes)
2238
+ : undefined;
2239
+ });
2240
+ const topY = outline.map(([x, z]) => getRoofSegmentSurfaceY(node, x, z) + verticalThickness);
2241
+ // A convex curved/straight joint places its triangular infill on the
2242
+ // straight run. Pin that patch's shared edge to the curved surface so the
2243
+ // infill meets both roofs instead of floating above the curved seam.
2244
+ if (!banded && outline.length === 3 && nodes) {
2245
+ for (let index = 0; index < outline.length; index++) {
2246
+ const sibling = edgeSiblings[index];
2247
+ if (!sibling?.arc)
2248
+ continue;
2249
+ const next = (index + 1) % outline.length;
2250
+ topY[index] = siblingShedTopYInNodeFrame(node, sibling, outline[index], nodes);
2251
+ topY[next] = siblingShedTopYInNodeFrame(node, sibling, outline[next], nodes);
2252
+ }
2253
+ }
2254
+ const bottom = outline.map(([x, z], index) => new THREE.Vector3(x, topY[index] - verticalThickness, z));
2255
+ const triangles = THREE.ShapeUtils.triangulateShape(outline.map(([x, z]) => new THREE.Vector2(x, z)), []);
2256
+ const jointTransitionFaces = [];
2257
+ const faces = triangles.flatMap((triangle) => {
2258
+ const bottomFace = triangle.map((index) => bottom[index].clone());
2259
+ const normalY = new THREE.Vector3()
2260
+ .subVectors(bottomFace[1], bottomFace[0])
2261
+ .cross(new THREE.Vector3().subVectors(bottomFace[2], bottomFace[0])).y;
2262
+ if (normalY > 0)
2263
+ bottomFace.reverse();
2264
+ const topFace = [...bottomFace]
2265
+ .reverse()
2266
+ .map((point) => new THREE.Vector3(point.x, point.y + verticalThickness, point.z));
2267
+ return [bottomFace, topFace];
2268
+ });
2269
+ if (!banded) {
2270
+ for (let index = 0; index < bottom.length; index++) {
2271
+ const next = (index + 1) % bottom.length;
2272
+ const sideFace = [
2273
+ bottom[next].clone(),
2274
+ bottom[index].clone(),
2275
+ new THREE.Vector3(bottom[index].x, bottom[index].y + verticalThickness, bottom[index].z),
2276
+ new THREE.Vector3(bottom[next].x, bottom[next].y + verticalThickness, bottom[next].z),
2277
+ ];
2278
+ const sibling = edgeSiblings[index];
2279
+ if (!sibling) {
2280
+ faces.push(sideFace);
2281
+ continue;
2282
+ }
2283
+ if (nodes && Boolean(node.arc) === Boolean(sibling.arc)) {
2284
+ jointTransitionFaces.push(...buildShedJointTransitionFaces(node, sibling, outline[index], outline[next], nodes, verticalThickness));
2285
+ }
2286
+ }
2287
+ }
2288
+ geometries.push(createGeometryFromFaces(faces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : ROOF_EDGE_MATERIAL_INDEX));
2289
+ if (jointTransitionFaces.length > 0) {
2290
+ geometries.push(createGeometryFromFaces(jointTransitionFaces, 3));
2291
+ }
2292
+ }
2293
+ if (banded) {
2294
+ const boundaryFaces = [];
2295
+ const jointTransitionFaces = [];
2296
+ for (const polygon of unionPolygons(pieces.map((piece) => [...piece]))) {
2297
+ for (const [start, end] of facetBandedRoofBoundary(sanitizeRoofPlanPolygon(polygon), node.width)) {
2298
+ const startBottom = new THREE.Vector3(start[0], getRoofSegmentSurfaceY(node, start[0], start[1]), start[1]);
2299
+ const endBottom = new THREE.Vector3(end[0], getRoofSegmentSurfaceY(node, end[0], end[1]), end[1]);
2300
+ const sibling = edgeTouchesSiblingShed(node, start, end, nodes)
2301
+ ? findSiblingShedAcrossEdge(node, start, end, nodes)
2302
+ : undefined;
2303
+ if (sibling && nodes && Boolean(node.arc) === Boolean(sibling.arc)) {
2304
+ jointTransitionFaces.push(...buildShedJointTransitionFaces(node, sibling, start, end, nodes, verticalThickness));
2305
+ continue;
2306
+ }
2307
+ if (sibling)
2308
+ continue;
2309
+ boundaryFaces.push([
2310
+ endBottom,
2311
+ startBottom,
2312
+ new THREE.Vector3(startBottom.x, startBottom.y + verticalThickness, startBottom.z),
2313
+ new THREE.Vector3(endBottom.x, endBottom.y + verticalThickness, endBottom.z),
2314
+ ]);
2315
+ }
2316
+ }
2317
+ if (boundaryFaces.length > 0) {
2318
+ geometries.push(createGeometryFromFaces(boundaryFaces, ROOF_EDGE_MATERIAL_INDEX));
2319
+ }
2320
+ if (jointTransitionFaces.length > 0) {
2321
+ geometries.push(createGeometryFromFaces(jointTransitionFaces, 3));
2322
+ }
2323
+ }
2324
+ if (geometries.length === 0)
2325
+ return null;
2326
+ const merged = mergeGeometriesPreservingGroups(geometries);
2327
+ for (const geometry of geometries)
2328
+ geometry.dispose();
2329
+ if (!merged)
2330
+ return null;
2331
+ if (banded)
2332
+ applyBandBendToGeometry(merged, banded);
2333
+ merged.computeVertexNormals();
2334
+ ensureRenderableGeometryAttributes(merged);
2335
+ return merged;
2336
+ }
1301
2337
  function collectGeometryPlanes(geometry) {
1302
2338
  const source = geometry.index ? geometry.toNonIndexed() : geometry;
1303
2339
  const position = source.getAttribute('position');
@@ -1720,6 +2756,108 @@ function buildDutchRakeBoards(W, D, wh, rh, i, shapeRatios, rake, thickness) {
1720
2756
  geometry.dispose();
1721
2757
  return merged;
1722
2758
  }
2759
+ function createShedInsetEndPanelGeometry(node) {
2760
+ if (node.roofType !== 'shed')
2761
+ return null;
2762
+ const trim = normalizeRoofSegmentTrim(node);
2763
+ const openEndSides = readShedOpenEndSides(node);
2764
+ const hasCornerSide = (side) => side < 0
2765
+ ? openEndSides.has('left') ||
2766
+ (trim.frontLeftX > 0 && trim.frontLeftZ > 0) ||
2767
+ (trim.backLeftX > 0 && trim.backLeftZ > 0)
2768
+ : (trim.frontRightX > 0 && trim.frontRightZ > 0) ||
2769
+ (trim.backRightX > 0 && trim.backRightZ > 0) ||
2770
+ openEndSides.has('right');
2771
+ const sideInset = Math.min(Math.max(node.wallThickness, 0.05), node.overhang * 0.5, 0.12);
2772
+ const fallbackPanelHalfWidth = Math.max(0.01, node.width / 2 - sideInset);
2773
+ const sidePanelX = (side) => resolveShedSideInfillX(node, side, fallbackPanelHalfWidth);
2774
+ const { activeRh, tanTheta } = getSegmentSlopeFrame(node);
2775
+ const shapeRatios = getRoofShapeRatios({
2776
+ gambrelLowerWidthRatio: node.gambrelLowerWidthRatio,
2777
+ mansardSteepWidthRatio: node.mansardSteepWidthRatio,
2778
+ dutchHipWidthRatio: node.dutchHipWidthRatio,
2779
+ dutchHipHeightRatio: node.dutchHipHeightRatio,
2780
+ dutchWaistLengthRatio: node.dutchWaistLengthRatio,
2781
+ dutchGabletRake: node.dutchGabletRake,
2782
+ });
2783
+ const wallOuterOffset = node.wallThickness / 2;
2784
+ const autoDrop = wallOuterOffset * tanTheta;
2785
+ const wh = Math.max(0.05, node.wallHeight - autoDrop);
2786
+ const rh = activeRh > 0 ? activeRh + 2 * autoDrop : activeRh;
2787
+ const faces = getRoofModuleFaces({
2788
+ type: 'shed',
2789
+ w: node.width + node.wallThickness,
2790
+ d: node.depth + node.wallThickness,
2791
+ wh,
2792
+ rh,
2793
+ baseY: 0,
2794
+ insets: {},
2795
+ baseW: node.width,
2796
+ baseD: node.depth,
2797
+ tanTheta,
2798
+ shapeRatios,
2799
+ dutchTopRakeThickness: node.dutchTopRakeThickness,
2800
+ }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
2801
+ const wallFaces = [];
2802
+ for (const faceIndex of [6, 8]) {
2803
+ const face = faces[faceIndex];
2804
+ if (!face)
2805
+ continue;
2806
+ const faceSide = face.some((point) => point.x < 0) ? -1 : 1;
2807
+ if (hasCornerSide(faceSide))
2808
+ continue;
2809
+ wallFaces.push(face.map((point) => {
2810
+ const side = point.x < 0 ? -1 : 1;
2811
+ return new THREE.Vector3(sidePanelX(side), point.y, point.z);
2812
+ }));
2813
+ }
2814
+ if (wallFaces.length === 0)
2815
+ return null;
2816
+ return createGeometryFromFaces(wallFaces, ROOF_INSET_WALL_MATERIAL_INDEX);
2817
+ }
2818
+ function resolveShedSideInfillX(node, side, fallbackPanelHalfWidth) {
2819
+ const sideX = readFiniteNumber(side < 0 ? node.shedSideInfillMinX : node.shedSideInfillMaxX);
2820
+ if (sideX !== null)
2821
+ return THREE.MathUtils.clamp(sideX, -node.width / 2, node.width / 2);
2822
+ const span = readFiniteNumber(node.shedSideInfillSpan);
2823
+ if (span !== null && span > 0) {
2824
+ return side * Math.min(span / 2, node.width / 2);
2825
+ }
2826
+ return side * fallbackPanelHalfWidth;
2827
+ }
2828
+ function readFiniteNumber(value) {
2829
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
2830
+ }
2831
+ function addShedInsetEndPanels(geometry, segments, applySegmentTransform) {
2832
+ const shedSegments = segments.filter((segment) => segment.roofType === 'shed' && segment.shedInsetEndPanels);
2833
+ if (shedSegments.length === 0)
2834
+ return geometry;
2835
+ const panelGeometries = [];
2836
+ for (const segment of shedSegments) {
2837
+ const panel = createShedInsetEndPanelGeometry(segment);
2838
+ if (!panel)
2839
+ continue;
2840
+ // A banded (curved) deck rotates each span end about the arc center; the flat
2841
+ // end panel is at a fixed X, so the bend is a rigid rotation that seats it on
2842
+ // the arc end. Applied before any segment transform so it stays segment-local.
2843
+ if (isBandedShedSegment(segment) && segment.arc)
2844
+ applyBandBendToGeometry(panel, segment.arc);
2845
+ if (applySegmentTransform) {
2846
+ _matrix.compose(_position.set(segment.position[0], segment.position[1], segment.position[2]), _quaternion.setFromAxisAngle(_yAxis, segment.rotation), _scale);
2847
+ panel.applyMatrix4(_matrix);
2848
+ }
2849
+ panelGeometries.push(panel);
2850
+ }
2851
+ if (panelGeometries.length === 0)
2852
+ return geometry;
2853
+ const merged = mergeGeometriesPreservingGroups([geometry, ...panelGeometries]);
2854
+ for (const panel of panelGeometries)
2855
+ panel.dispose();
2856
+ if (!merged)
2857
+ return geometry;
2858
+ geometry.dispose();
2859
+ return merged;
2860
+ }
1723
2861
  /**
1724
2862
  * Converts an array of face polygons into a BufferGeometry.
1725
2863
  * Each face is triangulated via fan triangulation.
@@ -1884,7 +3022,7 @@ export function getRoofOuterSurfaceFrameAtPoint(segment, lx, lz) {
1884
3022
  let shinTopW = shinBotW;
1885
3023
  let shinTopD = shinBotD;
1886
3024
  let transZ = 0;
1887
- if (['hip', 'mansard', 'dutch'].includes(roofType)) {
3025
+ if (['hip', 'mansard', 'dutch', 'conical'].includes(roofType)) {
1888
3026
  shinTopW += 2 * stSin;
1889
3027
  shinTopD += 2 * stSin;
1890
3028
  }
@@ -1906,7 +3044,7 @@ export function getRoofOuterSurfaceFrameAtPoint(segment, lx, lz) {
1906
3044
  let iB = 0;
1907
3045
  let iL = 0;
1908
3046
  let iR = 0;
1909
- if (roofType === 'hip') {
3047
+ if (roofType === 'hip' || roofType === 'conical') {
1910
3048
  iF = inset;
1911
3049
  iB = inset;
1912
3050
  iL = inset;