@pascal-app/viewer 0.9.1 → 0.9.2

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 (176) hide show
  1. package/dist/components/error-boundary.d.ts +6 -0
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/error-boundary.js +6 -0
  4. package/dist/components/renderers/node-renderer.d.ts +5 -1
  5. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  6. package/dist/components/renderers/node-renderer.js +5 -2
  7. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  8. package/dist/components/renderers/parametric-node-renderer.js +7 -4
  9. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  10. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  11. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  12. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  13. package/dist/components/viewer/frame-limiter.js +39 -4
  14. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  15. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  16. package/dist/components/viewer/glb-interactive.js +394 -0
  17. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  18. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  19. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  20. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  21. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  22. package/dist/components/viewer/glb-replace-instances.js +61 -0
  23. package/dist/components/viewer/glb-scene.d.ts +54 -0
  24. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  25. package/dist/components/viewer/glb-scene.js +920 -0
  26. package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
  27. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  28. package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
  29. package/dist/components/viewer/index.d.ts +19 -2
  30. package/dist/components/viewer/index.d.ts.map +1 -1
  31. package/dist/components/viewer/index.js +104 -12
  32. package/dist/components/viewer/lights.d.ts.map +1 -1
  33. package/dist/components/viewer/lights.js +36 -5
  34. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  35. package/dist/components/viewer/perf-monitor.js +17 -2
  36. package/dist/components/viewer/post-processing.d.ts +7 -1
  37. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  38. package/dist/components/viewer/post-processing.js +179 -24
  39. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  40. package/dist/components/viewer/registered-systems.js +6 -4
  41. package/dist/components/viewer/scene-bvh.d.ts +2 -1
  42. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  43. package/dist/components/viewer/scene-bvh.js +3 -0
  44. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  45. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  46. package/dist/components/viewer/scene-bvh.test.js +13 -0
  47. package/dist/components/viewer/scene-environment.d.ts +2 -0
  48. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  49. package/dist/components/viewer/scene-environment.js +79 -0
  50. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  51. package/dist/components/viewer/selection-manager.js +18 -10
  52. package/dist/components/viewer/walkthrough-controls.d.ts +1 -0
  53. package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
  54. package/dist/components/viewer/walkthrough-controls.js +20 -0
  55. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  56. package/dist/hooks/use-gltf-ktx2.js +3 -22
  57. package/dist/hooks/use-node-events.d.ts +1 -1
  58. package/dist/hooks/use-node-events.d.ts.map +1 -1
  59. package/dist/hooks/use-node-events.js +22 -14
  60. package/dist/index.d.ts +19 -8
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +22 -8
  63. package/dist/lib/backdrop.d.ts +33 -0
  64. package/dist/lib/backdrop.d.ts.map +1 -0
  65. package/dist/lib/backdrop.js +92 -0
  66. package/dist/lib/box-uv.d.ts +13 -0
  67. package/dist/lib/box-uv.d.ts.map +1 -0
  68. package/dist/lib/box-uv.js +32 -0
  69. package/dist/lib/csg-utils.d.ts +3 -9
  70. package/dist/lib/csg-utils.d.ts.map +1 -1
  71. package/dist/lib/csg-utils.js +65 -5
  72. package/dist/lib/csg-utils.test.d.ts +2 -0
  73. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  74. package/dist/lib/csg-utils.test.js +45 -0
  75. package/dist/lib/edge-style.d.ts +1 -0
  76. package/dist/lib/edge-style.d.ts.map +1 -1
  77. package/dist/lib/edge-style.js +26 -6
  78. package/dist/lib/ink-edges.d.ts +1 -1
  79. package/dist/lib/ink-edges.d.ts.map +1 -1
  80. package/dist/lib/ink-edges.js +13 -7
  81. package/dist/lib/ktx2-loader.d.ts +43 -0
  82. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  83. package/dist/lib/ktx2-loader.js +136 -0
  84. package/dist/lib/layers.d.ts +10 -0
  85. package/dist/lib/layers.d.ts.map +1 -1
  86. package/dist/lib/layers.js +10 -0
  87. package/dist/lib/materials.d.ts +14 -1
  88. package/dist/lib/materials.d.ts.map +1 -1
  89. package/dist/lib/materials.js +103 -26
  90. package/dist/lib/scene-themes.d.ts +1 -0
  91. package/dist/lib/scene-themes.d.ts.map +1 -1
  92. package/dist/lib/scene-themes.js +25 -16
  93. package/dist/lib/shadow-only.d.ts +4 -0
  94. package/dist/lib/shadow-only.d.ts.map +1 -0
  95. package/dist/lib/shadow-only.js +36 -0
  96. package/dist/lib/tsl-compat.d.ts +12 -0
  97. package/dist/lib/tsl-compat.d.ts.map +1 -0
  98. package/dist/lib/tsl-compat.js +13 -0
  99. package/dist/store/use-viewer.d.ts +60 -18
  100. package/dist/store/use-viewer.d.ts.map +1 -1
  101. package/dist/store/use-viewer.js +175 -3
  102. package/dist/store/use-viewer.test.d.ts +2 -0
  103. package/dist/store/use-viewer.test.d.ts.map +1 -0
  104. package/dist/store/use-viewer.test.js +42 -0
  105. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  106. package/dist/systems/ceiling/ceiling-system.js +4 -1
  107. package/dist/systems/door/door-animation.test.d.ts +2 -0
  108. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  109. package/dist/systems/door/door-animation.test.js +51 -0
  110. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  111. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  112. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  113. package/dist/systems/door/door-system.d.ts +24 -0
  114. package/dist/systems/door/door-system.d.ts.map +1 -1
  115. package/dist/systems/door/door-system.js +494 -161
  116. package/dist/systems/fence/fence-system.d.ts +17 -0
  117. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  118. package/dist/systems/fence/fence-system.js +221 -53
  119. package/dist/systems/geometry/geometry-system.d.ts +7 -0
  120. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  121. package/dist/systems/geometry/geometry-system.js +95 -26
  122. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  123. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  124. package/dist/systems/geometry/geometry-system.test.js +30 -0
  125. package/dist/systems/interactive/control-widget.d.ts +10 -0
  126. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  127. package/dist/systems/interactive/control-widget.js +35 -0
  128. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  129. package/dist/systems/interactive/interactive-system.js +55 -56
  130. package/dist/systems/item/item-system.d.ts.map +1 -1
  131. package/dist/systems/item/item-system.js +20 -6
  132. package/dist/systems/level/level-system.d.ts.map +1 -1
  133. package/dist/systems/level/level-system.js +28 -4
  134. package/dist/systems/level/level-utils.d.ts +0 -3
  135. package/dist/systems/level/level-utils.d.ts.map +1 -1
  136. package/dist/systems/level/level-utils.js +2 -41
  137. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  138. package/dist/systems/roof/roof-materials.js +29 -8
  139. package/dist/systems/roof/roof-system.d.ts +21 -8
  140. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  141. package/dist/systems/roof/roof-system.js +1079 -221
  142. package/dist/systems/slab/slab-system.d.ts +5 -3
  143. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  144. package/dist/systems/slab/slab-system.js +41 -23
  145. package/dist/systems/slab/slab-system.test.js +3 -2
  146. package/dist/systems/stair/stair-system.js +6 -3
  147. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  148. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  149. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  150. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  151. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  152. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  153. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  154. package/dist/systems/wall/wall-cutout.js +46 -17
  155. package/dist/systems/wall/wall-materials.d.ts +11 -7
  156. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  157. package/dist/systems/wall/wall-materials.js +244 -35
  158. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  159. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  160. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  161. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  162. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  163. package/dist/systems/wall/wall-support-extension.test.js +48 -0
  164. package/dist/systems/wall/wall-system.d.ts +2 -2
  165. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  166. package/dist/systems/wall/wall-system.js +329 -174
  167. package/dist/systems/window/window-animation-system.d.ts +12 -0
  168. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  169. package/dist/systems/window/window-animation-system.js +16 -5
  170. package/dist/systems/window/window-system.d.ts +7 -0
  171. package/dist/systems/window/window-system.d.ts.map +1 -1
  172. package/dist/systems/window/window-system.js +240 -14
  173. package/package.json +4 -4
  174. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  175. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  176. package/dist/components/viewer/ground-occluder.js +0 -67
@@ -1,9 +1,11 @@
1
- import { getEffectiveNode, getSegmentSlopeFrame, hasSegmentMaterialOverride, nodeRegistry, sceneRegistry, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
1
+ import { getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getEffectiveNode, getRoofModuleFaces, getRoofShapeInsets, getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, nodeRegistry, normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, sceneRegistry, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
3
  import * as THREE from 'three';
4
- import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
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
+ import { applyWorldScaleBoxUVs } from '../../lib/box-uv';
8
+ import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils';
7
9
  function csgGeometry(brush) {
8
10
  return brush.geometry;
9
11
  }
@@ -14,13 +16,14 @@ function csgMaterials(brush) {
14
16
  const csgEvaluator = new Evaluator();
15
17
  csgEvaluator.useGroups = true;
16
18
  csgEvaluator.consolidateGroups = false; // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
17
- csgEvaluator.attributes = ['position', 'normal', 'uv'];
19
+ csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'];
18
20
  function computeGeometryBoundsTree(geometry) {
19
21
  ;
20
22
  geometry.computeBoundsTree = computeBoundsTree;
21
23
  geometry.computeBoundsTree({ maxLeafSize: 10 });
22
24
  }
23
25
  function prepareBrushForCSG(brush) {
26
+ ensureRenderableGeometryAttributes(brush.geometry);
24
27
  computeGeometryBoundsTree(brush.geometry);
25
28
  brush.updateMatrixWorld();
26
29
  }
@@ -34,6 +37,44 @@ const _uvFaceNormal = new THREE.Vector3();
34
37
  const _uvWorldDown = new THREE.Vector3(0, -1, 0);
35
38
  const _uvDownSlope = new THREE.Vector3();
36
39
  const _uvAcrossSlope = new THREE.Vector3();
40
+ // World transform of the segment whose geometry is currently being built, so
41
+ // vertical (gable wall) faces project their UVs in WORLD space — identical to
42
+ // the wall kind's `applyWorldPlanarWallUVs` (U = ±worldX/Z, V = 1 - worldY) so
43
+ // the gable band tiles continuously into the walls below. Identity = local
44
+ // (the default outside a segment build). Set via `withSegmentUvMatrix`.
45
+ const _segmentUvMatrix = new THREE.Matrix4();
46
+ const _segmentUvNormalMatrix = new THREE.Matrix3();
47
+ const _uvWorldPoint = new THREE.Vector3();
48
+ const _uvWorldNormal = new THREE.Vector3();
49
+ const _prevSegmentUvMatrix = new THREE.Matrix4();
50
+ const _prevSegmentUvNormalMatrix = new THREE.Matrix3();
51
+ function withSegmentUvMatrix(matrix, build) {
52
+ _prevSegmentUvMatrix.copy(_segmentUvMatrix);
53
+ _prevSegmentUvNormalMatrix.copy(_segmentUvNormalMatrix);
54
+ _segmentUvMatrix.copy(matrix);
55
+ _segmentUvNormalMatrix.getNormalMatrix(matrix);
56
+ try {
57
+ return build();
58
+ }
59
+ finally {
60
+ _segmentUvMatrix.copy(_prevSegmentUvMatrix);
61
+ _segmentUvNormalMatrix.copy(_prevSegmentUvNormalMatrix);
62
+ }
63
+ }
64
+ // Scratch matrices for composing a segment's world transform (roof group ∘
65
+ // segment) at each build, without per-call allocation.
66
+ const _segWorldMatrix = new THREE.Matrix4();
67
+ const _roofGroupMatrix = new THREE.Matrix4();
68
+ const _segLocalMatrix = new THREE.Matrix4();
69
+ const _uvTmpPos = new THREE.Vector3();
70
+ const _uvTmpQuat = new THREE.Quaternion();
71
+ const _uvUnitScale = new THREE.Vector3(1, 1, 1);
72
+ /** Compose the world transform `T(roofPos)·Ry(roofRot) · T(segPos)·Ry(segRot)`. */
73
+ function composeSegmentWorldMatrix(roofPosition, roofRotation, segPosition, segRotation) {
74
+ _roofGroupMatrix.compose(_uvTmpPos.set(roofPosition?.[0] ?? 0, roofPosition?.[1] ?? 0, roofPosition?.[2] ?? 0), _uvTmpQuat.setFromAxisAngle(_yAxis, roofRotation), _uvUnitScale);
75
+ _segLocalMatrix.compose(_uvTmpPos.set(segPosition[0] ?? 0, segPosition[1] ?? 0, segPosition[2] ?? 0), _uvTmpQuat.setFromAxisAngle(_yAxis, segRotation), _uvUnitScale);
76
+ return _segWorldMatrix.multiplyMatrices(_roofGroupMatrix, _segLocalMatrix);
77
+ }
37
78
  const _tmpVec3A = new THREE.Vector3();
38
79
  const _tmpVec3B = new THREE.Vector3();
39
80
  const _surfaceRay = new THREE.Ray();
@@ -44,6 +85,27 @@ const _surfaceV0 = new THREE.Vector3();
44
85
  const _surfaceV1 = new THREE.Vector3();
45
86
  const _surfaceV2 = new THREE.Vector3();
46
87
  const _surfaceFaceNormal = new THREE.Vector3();
88
+ /**
89
+ * Degenerate placeholder for a roof mesh with nothing to draw (initial
90
+ * BoxGeometry swap-out, or a roof whose segments were all deleted/painted).
91
+ * Three zero-vertices (one invisible triangle), not an empty attribute: an
92
+ * empty position (count 0) leaves WebGPU vertex buffer slot 0 unbound if the
93
+ * mesh is ever drawn, and computeBoundsTree needs a real position buffer to
94
+ * index. Deliberately NO groups: count-0 groups crash MeshBVH's packed-tree
95
+ * build (it partitions roots by group), and a BoxGeometry's 6 groups against
96
+ * the 4 roof materials crash raycasts and GLTFExporter. Group-less + a
97
+ * zero-area triangle is safe everywhere — it draws nothing under an array
98
+ * material and can never be ray-hit.
99
+ */
100
+ function createDegenerateRoofPlaceholder() {
101
+ const placeholder = new THREE.BufferGeometry();
102
+ placeholder.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
103
+ placeholder.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
104
+ placeholder.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
105
+ placeholder.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
106
+ computeGeometryBoundsTree(placeholder);
107
+ return placeholder;
108
+ }
47
109
  // Pending merged-roof updates carried across frames (for throttling)
48
110
  const pendingRoofUpdates = new Set();
49
111
  const warnedMergedRoofNaNIds = new Set();
@@ -66,6 +128,10 @@ export const RoofSystem = () => {
66
128
  if (rootNodeIds.length === 0) {
67
129
  pendingRoofUpdates.clear();
68
130
  warnedMergedRoofNaNIds.clear();
131
+ for (const cached of mergedRoofSegmentGeometryCache.values()) {
132
+ disposeCachedMergedRoofSegmentGeometrySet(cached);
133
+ }
134
+ mergedRoofSegmentGeometryCache.clear();
69
135
  return;
70
136
  }
71
137
  if (dirtyNodes.size === 0 && pendingRoofUpdates.size === 0)
@@ -77,19 +143,19 @@ export const RoofSystem = () => {
77
143
  const node = nodes[id];
78
144
  if (!node)
79
145
  return;
80
- // Roof accessories (chimney, skylight, solar-panel, dormer,
81
- // ridge-vent, box-vent — anything declaring
82
- // `capabilities.roofAccessory` on its NodeDefinition) cascade
83
- // their dirty mark to the host segment's parent roof so the
84
- // merged shell re-CSGs with the new cut. Without this, moving /
85
- // resizing an accessory leaves the merged roof showing the
86
- // previous cut shape (stale CSG) once the user exits segment
87
- // edit mode. Registry-driven so the viewer stays kind-agnostic.
146
+ // Cutting roof accessories cascade their dirty mark to the host
147
+ // segment's parent roof so the merged shell re-CSGs with the new
148
+ // cut. Non-cutting accessories (vents, panels, gutters, etc.) sit on
149
+ // top of the shell and should not force a full roof merge.
88
150
  const def = nodeRegistry.get(node.type);
89
- if (def?.capabilities?.roofAccessory) {
151
+ // Kinds with `dirtyHandledByOwnSystem` (door / window) reach the roof
152
+ // through their own geometry system's parentId cascade instead —
153
+ // their dirty marks belong to that system, not to this loop.
154
+ const roofAccessory = def?.capabilities?.roofAccessory;
155
+ if (roofAccessory && !roofAccessory.dirtyHandledByOwnSystem) {
90
156
  const segId = node.roofSegmentId;
91
157
  const seg = segId ? nodes[segId] : undefined;
92
- if (seg?.parentId) {
158
+ if (roofAccessory.buildCut && seg?.parentId) {
93
159
  pendingRoofUpdates.add(seg.parentId);
94
160
  }
95
161
  clearDirty(id);
@@ -106,11 +172,20 @@ export const RoofSystem = () => {
106
172
  // Only compute expensive individual CSG when the segment is actually rendered
107
173
  // (its parent group is visible = the roof is selected for editing)
108
174
  const isVisible = mesh.parent?.visible !== false;
109
- if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
110
- updateRoofSegmentGeometry(effectiveSegment, mesh);
175
+ // Accessory-reveal mode (RoofEditSystem): the wrapper is shown so
176
+ // portaled handles render, but the merged shell stays visible and
177
+ // the segment meshes are stripped to empty placeholders. Rebuilding
178
+ // per-segment CSG here would draw UNCUT geometry on top of the
179
+ // merged shell — hiding a freshly cut opening (door / window /
180
+ // skylight) until the next deselect. Full edit mode hides the
181
+ // merged mesh, so gate the rebuild on its visibility.
182
+ const revealOnly = mesh.parent?.name === 'segments-wrapper' &&
183
+ mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true;
184
+ if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
185
+ updateRoofSegmentGeometry(effectiveSegment, mesh, nodes);
111
186
  segmentsProcessed++;
112
187
  }
113
- else if (isVisible) {
188
+ else if (isVisible && !revealOnly) {
114
189
  return; // Over budget — keep dirty, process next frame
115
190
  }
116
191
  else {
@@ -120,14 +195,7 @@ export const RoofSystem = () => {
120
195
  // so MeshBVH hits groups[4].materialIndex → undefined.side → crash.
121
196
  if (mesh.geometry.type === 'BoxGeometry') {
122
197
  mesh.geometry.dispose();
123
- const placeholder = new THREE.BufferGeometry();
124
- // Three zero-vertices (one degenerate, invisible triangle), not an
125
- // empty attribute: an empty position (count 0) leaves WebGPU vertex
126
- // buffer slot 0 unbound if the mesh is ever drawn, and computeBoundsTree
127
- // needs a real position buffer to index.
128
- placeholder.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
129
- computeGeometryBoundsTree(placeholder);
130
- mesh.geometry = placeholder;
198
+ mesh.geometry = createDegenerateRoofPlaceholder();
131
199
  }
132
200
  mesh.position.set(effectiveSegment.position[0], effectiveSegment.position[1], effectiveSegment.position[2]);
133
201
  mesh.rotation.y = effectiveSegment.rotation;
@@ -153,7 +221,7 @@ export const RoofSystem = () => {
153
221
  if (roofsProcessed >= MAX_ROOFS_PER_FRAME)
154
222
  break;
155
223
  const node = nodes[id];
156
- if (!node || node.type !== 'roof') {
224
+ if (node?.type !== 'roof') {
157
225
  pendingRoofUpdates.delete(id);
158
226
  continue;
159
227
  }
@@ -176,14 +244,142 @@ export const RoofSystem = () => {
176
244
  // ============================================================================
177
245
  // GEOMETRY GENERATION
178
246
  // ============================================================================
179
- function updateRoofSegmentGeometry(node, mesh) {
180
- const newGeo = generateRoofSegmentGeometry(node);
247
+ function updateRoofSegmentGeometry(node, mesh, nodes) {
248
+ const newGeo = generateRoofSegmentGeometry(node, nodes);
181
249
  mesh.geometry.dispose();
182
250
  mesh.geometry = newGeo;
183
251
  computeGeometryBoundsTree(newGeo);
184
252
  mesh.position.set(node.position[0], node.position[1], node.position[2]);
185
253
  mesh.rotation.y = node.rotation;
186
254
  }
255
+ /**
256
+ * Subtract every hosted accessory cut (`capabilities.roofAccessory.
257
+ * buildCut`) from a segment's brushes, in SEGMENT-LOCAL space. Shared by
258
+ * the merged-shell path AND the per-segment path (full edit mode /
259
+ * painted segments) — without the latter, selecting a segment used to
260
+ * swap the merged shell for uncut per-segment meshes and every door /
261
+ * window / skylight hole vanished until deselect. Children are read
262
+ * live-effective so an in-flight handle drag carves the live hole.
263
+ * Registry-driven so the viewer never names a kind.
264
+ */
265
+ function subtractAccessoryCuts(brushes, segment, nodes) {
266
+ let workingShin = brushes.shinSlab;
267
+ let workingDeck = brushes.deckSlab;
268
+ let workingWall = brushes.wallBrush;
269
+ for (const childElemId of segment.children ?? []) {
270
+ const storedChild = nodes[childElemId];
271
+ if (!storedChild)
272
+ continue;
273
+ const childElem = getEffectiveNode(storedChild);
274
+ const meta = typeof childElem.metadata === 'object' && childElem.metadata !== null
275
+ ? childElem.metadata
276
+ : undefined;
277
+ if (meta?.isTransient)
278
+ continue;
279
+ const childDef = nodeRegistry.get(childElem.type);
280
+ const buildCut = childDef?.capabilities?.roofAccessory?.buildCut;
281
+ if (!buildCut)
282
+ continue;
283
+ const cutGeo = buildCut(childElem, segment);
284
+ if (!cutGeo)
285
+ continue;
286
+ // Wrap the kind-emitted geometry in a Brush. Kinds return raw
287
+ // shapes; the viewer welds (mandatory after rotations leave
288
+ // duplicated verts), attaches a single material group, and
289
+ // builds the bounds tree — keeping kind code free of
290
+ // three-bvh-csg / three-mesh-bvh imports.
291
+ const welded = mergeVertices(cutGeo, 1e-4);
292
+ cutGeo.dispose();
293
+ const idxCount = welded.getIndex()?.count ?? 0;
294
+ if (idxCount === 0) {
295
+ welded.dispose();
296
+ continue;
297
+ }
298
+ welded.clearGroups();
299
+ welded.addGroup(0, idxCount, 0);
300
+ welded.computeVertexNormals();
301
+ ensureRenderableGeometryAttributes(welded);
302
+ computeGeometryBoundsTree(welded);
303
+ const cut = new Brush(welded, dummyMats[0]);
304
+ cut.updateMatrixWorld();
305
+ const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all';
306
+ try {
307
+ if (cutScope !== 'wall') {
308
+ const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION);
309
+ workingShin.geometry.dispose();
310
+ prepareBrushForCSG(nextShin);
311
+ workingShin = nextShin;
312
+ const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION);
313
+ workingDeck.geometry.dispose();
314
+ prepareBrushForCSG(nextDeck);
315
+ workingDeck = nextDeck;
316
+ }
317
+ const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION);
318
+ workingWall.geometry.dispose();
319
+ prepareBrushForCSG(nextWall);
320
+ workingWall = nextWall;
321
+ }
322
+ catch (e) {
323
+ console.error(`[${childElem.type}] cut CSG failed:`, e);
324
+ }
325
+ finally {
326
+ cut.geometry.dispose();
327
+ }
328
+ }
329
+ brushes.shinSlab = workingShin;
330
+ brushes.deckSlab = workingDeck;
331
+ brushes.wallBrush = workingWall;
332
+ }
333
+ function getMergedRoofSegmentBrushes(roofNode, segment, nodes) {
334
+ const segmentId = segment.id;
335
+ const cacheKey = getMergedRoofSegmentCacheKey(roofNode, segment, nodes);
336
+ const cached = mergedRoofSegmentGeometryCache.get(segmentId);
337
+ if (cached?.key === cacheKey) {
338
+ return cloneCachedMergedRoofSegmentBrushes(cached);
339
+ }
340
+ const brushes = withSegmentUvMatrix(composeSegmentWorldMatrix(roofNode.position, roofNode.rotation ?? 0, segment.position, segment.rotation ?? 0), () => getRoofSegmentBrushes(segment));
341
+ if (!brushes) {
342
+ disposeCachedMergedRoofSegmentGeometrySet(cached);
343
+ mergedRoofSegmentGeometryCache.delete(segmentId);
344
+ return null;
345
+ }
346
+ subtractAccessoryCuts(brushes, segment, nodes);
347
+ _matrix.compose(_position.set(segment.position[0], segment.position[1], segment.position[2]), _quaternion.setFromAxisAngle(_yAxis, segment.rotation), _scale);
348
+ const applyTransform = (brush) => {
349
+ csgGeometry(brush).applyMatrix4(_matrix);
350
+ brush.updateMatrixWorld();
351
+ };
352
+ applyTransform(brushes.shinSlab);
353
+ applyTransform(brushes.deckSlab);
354
+ applyTransform(brushes.wallBrush);
355
+ applyTransform(brushes.innerBrush);
356
+ brushes.rakeBoards?.applyMatrix4(_matrix);
357
+ const nextCached = {
358
+ key: cacheKey,
359
+ deckSlab: {
360
+ geometry: csgGeometry(brushes.deckSlab).clone(),
361
+ materials: csgMaterials(brushes.deckSlab),
362
+ },
363
+ shinSlab: {
364
+ geometry: csgGeometry(brushes.shinSlab).clone(),
365
+ materials: csgMaterials(brushes.shinSlab),
366
+ },
367
+ wallBrush: {
368
+ geometry: csgGeometry(brushes.wallBrush).clone(),
369
+ materials: csgMaterials(brushes.wallBrush),
370
+ },
371
+ innerBrush: {
372
+ geometry: csgGeometry(brushes.innerBrush).clone(),
373
+ materials: csgMaterials(brushes.innerBrush),
374
+ },
375
+ rakeBoards: brushes.rakeBoards?.clone() ?? null,
376
+ };
377
+ disposeCachedMergedRoofSegmentGeometrySet(cached);
378
+ mergedRoofSegmentGeometryCache.set(segmentId, nextCached);
379
+ const cloned = cloneCachedMergedRoofSegmentBrushes(nextCached);
380
+ disposeRoofSegmentBrushSet(brushes);
381
+ return cloned;
382
+ }
187
383
  function updateMergedRoofGeometry(roofNode, group, nodes) {
188
384
  const mergedMesh = group.getObjectByName('merged-roof');
189
385
  if (!mergedMesh)
@@ -205,94 +401,23 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
205
401
  .filter((n) => n !== undefined && !hasSegmentMaterialOverride(n));
206
402
  if (children.length === 0) {
207
403
  mergedMesh.geometry.dispose();
208
- // Keep a valid position attribute so Drei's BVH can index safely.
209
- mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0);
404
+ // Not BoxGeometry: its 6 groups against the merged mesh's 4-material array
405
+ // crash GLTFExporter (materials[4] → undefined) when the roof bakes.
406
+ mergedMesh.geometry = createDegenerateRoofPlaceholder();
210
407
  return;
211
408
  }
212
409
  let totalShinSlab = null;
213
410
  let totalDeckSlab = null;
214
411
  let totalWall = null;
215
412
  let totalInner = null;
413
+ const rakeBoardGeometries = [];
216
414
  for (const child of children) {
217
- const brushes = getRoofSegmentBrushes(child);
415
+ const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes);
218
416
  if (!brushes)
219
417
  continue;
220
- // Per-child cuts in SEGMENT-LOCAL space: subtract every accessory
221
- // that contributes a cut (declares
222
- // `capabilities.roofAccessory.buildCut`) from shin / deck / wall
223
- // before we accumulate. Mirrors roof-system v1 — the cut is built
224
- // in segment-local, then carved out before the segment transform
225
- // stacks on. Registry-driven so the viewer never names a kind.
226
- let workingShin = brushes.shinSlab;
227
- let workingDeck = brushes.deckSlab;
228
- let workingWall = brushes.wallBrush;
229
- for (const childElemId of child.children ?? []) {
230
- const childElem = nodes[childElemId];
231
- if (!childElem)
232
- continue;
233
- const meta = typeof childElem.metadata === 'object' && childElem.metadata !== null
234
- ? childElem.metadata
235
- : undefined;
236
- if (meta?.isTransient)
237
- continue;
238
- const childDef = nodeRegistry.get(childElem.type);
239
- const buildCut = childDef?.capabilities?.roofAccessory?.buildCut;
240
- if (!buildCut)
241
- continue;
242
- const cutGeo = buildCut(childElem, child);
243
- if (!cutGeo)
244
- continue;
245
- // Wrap the kind-emitted geometry in a Brush. Kinds return raw
246
- // shapes; the viewer welds (mandatory after rotations leave
247
- // duplicated verts), attaches a single material group, and
248
- // builds the bounds tree — keeping kind code free of
249
- // three-bvh-csg / three-mesh-bvh imports.
250
- const welded = mergeVertices(cutGeo, 1e-4);
251
- cutGeo.dispose();
252
- const idxCount = welded.getIndex()?.count ?? 0;
253
- if (idxCount === 0) {
254
- welded.dispose();
255
- continue;
256
- }
257
- welded.clearGroups();
258
- welded.addGroup(0, idxCount, 0);
259
- welded.computeVertexNormals();
260
- computeGeometryBoundsTree(welded);
261
- const cut = new Brush(welded, dummyMats[0]);
262
- cut.updateMatrixWorld();
263
- try {
264
- const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION);
265
- workingShin.geometry.dispose();
266
- prepareBrushForCSG(nextShin);
267
- workingShin = nextShin;
268
- const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION);
269
- workingDeck.geometry.dispose();
270
- prepareBrushForCSG(nextDeck);
271
- workingDeck = nextDeck;
272
- const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION);
273
- workingWall.geometry.dispose();
274
- prepareBrushForCSG(nextWall);
275
- workingWall = nextWall;
276
- }
277
- catch (e) {
278
- console.error(`[${childElem.type}] cut CSG failed:`, e);
279
- }
280
- finally {
281
- cut.geometry.dispose();
282
- }
418
+ if (brushes.rakeBoards) {
419
+ rakeBoardGeometries.push(brushes.rakeBoards);
283
420
  }
284
- brushes.shinSlab = workingShin;
285
- brushes.deckSlab = workingDeck;
286
- brushes.wallBrush = workingWall;
287
- _matrix.compose(_position.set(child.position[0], child.position[1], child.position[2]), _quaternion.setFromAxisAngle(_yAxis, child.rotation), _scale);
288
- const applyTransform = (brush) => {
289
- csgGeometry(brush).applyMatrix4(_matrix);
290
- brush.updateMatrixWorld();
291
- };
292
- applyTransform(brushes.shinSlab);
293
- applyTransform(brushes.deckSlab);
294
- applyTransform(brushes.wallBrush);
295
- applyTransform(brushes.innerBrush);
296
421
  if (totalShinSlab) {
297
422
  const next = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION);
298
423
  totalShinSlab.geometry.dispose();
@@ -336,26 +461,27 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
336
461
  }
337
462
  if (totalShinSlab && totalDeckSlab && totalWall && totalInner) {
338
463
  try {
339
- const finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION);
340
- const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION);
341
464
  const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION);
342
- const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION);
465
+ prepareBrushForCSG(finalWallTrimmed);
466
+ const shinDeck = csgEvaluator.evaluate(totalShinSlab, totalDeckSlab, ADDITION);
467
+ prepareBrushForCSG(shinDeck);
343
468
  const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION);
469
+ prepareBrushForCSG(combined);
344
470
  const resultGeo = csgGeometry(combined);
345
- if (geometryHasNaNPositions(resultGeo)) {
471
+ if (geometryHasInvalidAttributes(resultGeo)) {
346
472
  if (!warnedMergedRoofNaNIds.has(roofNode.id)) {
347
- console.warn('[RoofSystem] Skipping merged roof geometry with NaN positions', roofNode.id);
473
+ console.warn('[RoofSystem] Skipping merged roof geometry with invalid attributes', roofNode.id);
348
474
  warnedMergedRoofNaNIds.add(roofNode.id);
349
475
  }
350
476
  resultGeo.dispose();
351
- finalShinTrimmed.geometry.dispose();
352
- finalDeckTrimmed.geometry.dispose();
353
477
  finalWallTrimmed.geometry.dispose();
354
478
  shinDeck.geometry.dispose();
355
479
  totalShinSlab.geometry.dispose();
356
480
  totalDeckSlab.geometry.dispose();
357
481
  totalWall.geometry.dispose();
358
482
  totalInner.geometry.dispose();
483
+ for (const geometry of rakeBoardGeometries)
484
+ geometry.dispose();
359
485
  return;
360
486
  }
361
487
  const resultMaterials = csgMaterials(combined);
@@ -368,12 +494,20 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
368
494
  for (const g of resultGeo.groups) {
369
495
  g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex);
370
496
  }
371
- resultGeo.computeVertexNormals();
372
- ensureUv2Attribute(resultGeo);
497
+ let finalGeo = resultGeo;
498
+ if (rakeBoardGeometries.length > 0) {
499
+ const merged = mergeGeometriesPreservingGroups([finalGeo, ...rakeBoardGeometries]);
500
+ if (merged) {
501
+ finalGeo.dispose();
502
+ finalGeo = merged;
503
+ }
504
+ }
505
+ for (const geometry of rakeBoardGeometries)
506
+ geometry.dispose();
507
+ finalGeo.computeVertexNormals();
508
+ ensureRenderableGeometryAttributes(finalGeo);
373
509
  mergedMesh.geometry.dispose();
374
- mergedMesh.geometry = resultGeo;
375
- finalShinTrimmed.geometry.dispose();
376
- finalDeckTrimmed.geometry.dispose();
510
+ mergedMesh.geometry = finalGeo;
377
511
  finalWallTrimmed.geometry.dispose();
378
512
  shinDeck.geometry.dispose();
379
513
  }
@@ -384,16 +518,40 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
384
518
  totalDeckSlab.geometry.dispose();
385
519
  totalWall.geometry.dispose();
386
520
  totalInner.geometry.dispose();
521
+ for (const geometry of rakeBoardGeometries)
522
+ geometry.dispose();
387
523
  }
388
524
  }
389
- function geometryHasNaNPositions(geometry) {
525
+ function geometryHasInvalidAttributes(geometry) {
390
526
  const position = geometry.getAttribute('position');
391
- if (!position)
392
- return false;
393
- for (let i = 0; i < position.array.length; i++) {
394
- if (Number.isNaN(position.array[i]))
527
+ if (!(position && position.count > 0))
528
+ return true;
529
+ for (const name of ['position', 'normal', 'uv', 'uv2']) {
530
+ const attribute = geometry.getAttribute(name);
531
+ if (!attribute)
532
+ continue;
533
+ for (let i = 0; i < attribute.array.length; i++) {
534
+ if (!Number.isFinite(attribute.array[i]))
535
+ return true;
536
+ }
537
+ }
538
+ const index = geometry.getIndex();
539
+ if (!index || index.count === 0)
540
+ return true;
541
+ for (let i = 0; i < index.count; i++) {
542
+ const value = index.getX(i);
543
+ if (!Number.isInteger(value) || value < 0 || value >= position.count)
395
544
  return true;
396
545
  }
546
+ for (const group of geometry.groups) {
547
+ if (!Number.isInteger(group.start) ||
548
+ !Number.isInteger(group.count) ||
549
+ group.start < 0 ||
550
+ group.count <= 0 ||
551
+ group.start + group.count > index.count) {
552
+ return true;
553
+ }
554
+ }
397
555
  return false;
398
556
  }
399
557
  /**
@@ -413,6 +571,64 @@ export const roofCsgDummyMats = [
413
571
  // Internal alias kept so the surrounding file's many call sites don't churn.
414
572
  const dummyMats = roofCsgDummyMats;
415
573
  export const ROOF_MATERIAL_SLOT_COUNT = 4;
574
+ const mergedRoofSegmentGeometryCache = new Map();
575
+ function disposeCachedMergedRoofSegmentGeometrySet(cached) {
576
+ if (!cached)
577
+ return;
578
+ cached.deckSlab.geometry.dispose();
579
+ cached.shinSlab.geometry.dispose();
580
+ cached.wallBrush.geometry.dispose();
581
+ cached.innerBrush.geometry.dispose();
582
+ cached.rakeBoards?.dispose();
583
+ }
584
+ function disposeRoofSegmentBrushSet(brushes) {
585
+ brushes.deckSlab.geometry.dispose();
586
+ brushes.shinSlab.geometry.dispose();
587
+ brushes.wallBrush.geometry.dispose();
588
+ brushes.innerBrush.geometry.dispose();
589
+ brushes.rakeBoards?.dispose();
590
+ }
591
+ function cloneCachedBrush(cached) {
592
+ const brush = new Brush(cached.geometry.clone(), cached.materials);
593
+ prepareBrushForCSG(brush);
594
+ return brush;
595
+ }
596
+ function cloneCachedMergedRoofSegmentBrushes(cached) {
597
+ return {
598
+ deckSlab: cloneCachedBrush(cached.deckSlab),
599
+ shinSlab: cloneCachedBrush(cached.shinSlab),
600
+ wallBrush: cloneCachedBrush(cached.wallBrush),
601
+ innerBrush: cloneCachedBrush(cached.innerBrush),
602
+ rakeBoards: cached.rakeBoards?.clone() ?? null,
603
+ };
604
+ }
605
+ function getMergedRoofAccessoryCachePayload(segment, nodes) {
606
+ const payload = [];
607
+ for (const childElemId of segment.children ?? []) {
608
+ const storedChild = nodes[childElemId];
609
+ if (!storedChild)
610
+ continue;
611
+ const childElem = getEffectiveNode(storedChild);
612
+ const meta = typeof childElem.metadata === 'object' && childElem.metadata !== null
613
+ ? childElem.metadata
614
+ : undefined;
615
+ if (meta?.isTransient)
616
+ continue;
617
+ const childDef = nodeRegistry.get(childElem.type);
618
+ if (!childDef?.capabilities?.roofAccessory?.buildCut)
619
+ continue;
620
+ payload.push(childElem);
621
+ }
622
+ return payload;
623
+ }
624
+ function getMergedRoofSegmentCacheKey(roofNode, segment, nodes) {
625
+ return JSON.stringify({
626
+ roofPosition: roofNode.position ?? [0, 0, 0],
627
+ roofRotation: roofNode.rotation ?? 0,
628
+ segment,
629
+ accessories: getMergedRoofAccessoryCachePayload(segment, nodes),
630
+ });
631
+ }
416
632
  export function mapRoofGroupMaterialIndex(groupMaterialIndex, csgMaterials, matToIndex) {
417
633
  if (groupMaterialIndex === undefined)
418
634
  return 0;
@@ -443,9 +659,290 @@ function normalizeRoofMaterialIndex(materialIndex) {
443
659
  return 0;
444
660
  return normalized;
445
661
  }
662
+ function remapDutchRakeBoardMaterials(geometry) {
663
+ const position = geometry.getAttribute('position');
664
+ if (!position)
665
+ return;
666
+ const index = geometry.getIndex();
667
+ const triangleCount = (index?.count ?? position.count) / 3;
668
+ if (!Number.isFinite(triangleCount) || triangleCount <= 0)
669
+ return;
670
+ const a = new THREE.Vector3();
671
+ const b = new THREE.Vector3();
672
+ const c = new THREE.Vector3();
673
+ const ab = new THREE.Vector3();
674
+ const ac = new THREE.Vector3();
675
+ const normal = new THREE.Vector3();
676
+ const triangleMaterials = new Array(triangleCount).fill(DUTCH_RAKE_SIDE_MATERIAL_INDEX);
677
+ for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
678
+ const offset = triangleIndex * 3;
679
+ const ia = index ? index.getX(offset) : offset;
680
+ const ib = index ? index.getX(offset + 1) : offset + 1;
681
+ const ic = index ? index.getX(offset + 2) : offset + 2;
682
+ a.fromBufferAttribute(position, ia);
683
+ b.fromBufferAttribute(position, ib);
684
+ c.fromBufferAttribute(position, ic);
685
+ ab.subVectors(b, a);
686
+ ac.subVectors(c, a);
687
+ normal.crossVectors(ab, ac).normalize();
688
+ triangleMaterials[triangleIndex] =
689
+ Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON
690
+ ? DUTCH_RAKE_TOP_MATERIAL_INDEX
691
+ : DUTCH_RAKE_SIDE_MATERIAL_INDEX;
692
+ }
693
+ geometry.clearGroups();
694
+ let currentMaterial = triangleMaterials[0] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX;
695
+ let groupStart = 0;
696
+ for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) {
697
+ const materialIndex = triangleMaterials[triangleIndex] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX;
698
+ if (materialIndex === currentMaterial)
699
+ continue;
700
+ geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial);
701
+ groupStart = triangleIndex;
702
+ currentMaterial = materialIndex;
703
+ }
704
+ geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial);
705
+ }
446
706
  const SHINGLE_SURFACE_EPSILON = 0.02;
447
707
  const RAKE_FACE_NORMAL_EPSILON = 0.3;
448
708
  const RAKE_FACE_ALIGNMENT_EPSILON = 0.35;
709
+ const TRIM_CUT_EPSILON = 0.002;
710
+ const DUTCH_RAKE_SIDE_MATERIAL_INDEX = 1;
711
+ const DUTCH_RAKE_TOP_MATERIAL_INDEX = 3;
712
+ const DUTCH_RAKE_SLOPE_SEAT_OFFSET = 0.0002;
713
+ function pushDoubleSidedFace(targetFaces, face) {
714
+ targetFaces.push(face);
715
+ targetFaces.push(face.map((point) => point.clone()).reverse());
716
+ }
717
+ function hasSegmentTrim(node) {
718
+ const trim = normalizeRoofSegmentTrim(node);
719
+ return (trim.left > 0 ||
720
+ trim.right > 0 ||
721
+ trim.front > 0 ||
722
+ trim.back > 0 ||
723
+ trim.frontLeft > 0 ||
724
+ trim.frontRight > 0 ||
725
+ trim.backLeft > 0 ||
726
+ trim.backRight > 0 ||
727
+ trim.frontLeftX > 0 ||
728
+ trim.frontLeftZ > 0 ||
729
+ trim.frontRightX > 0 ||
730
+ trim.frontRightZ > 0 ||
731
+ trim.backLeftX > 0 ||
732
+ trim.backLeftZ > 0 ||
733
+ trim.backRightX > 0 ||
734
+ trim.backRightZ > 0);
735
+ }
736
+ // Material slot the freshly exposed trim plane is assigned to. Slot 0 is the
737
+ // wall/trim band — the same finish the gable walls render with (concrete-drywall
738
+ // by default, continuous with the walls below). `remapRoofShellFaces` does not
739
+ // reclassify slot 0, so any new interior face CSG carves out of a BoxGeometry
740
+ // cutter lands on the wall band regardless of which box face it came from.
741
+ // Without this the box's default 6 groups (materialIndex 0..5) collapse via
742
+ // mod-4 and `remapRoofShellFaces` would reshuffle the vertical ones across
743
+ // slots. Accessories still clamp the slot via `useSegmentTrimClippedGeometry`
744
+ // when they expose fewer material slots.
745
+ const TRIM_CUT_MATERIAL_SLOT = 0;
746
+ function assignTrimCutterSlot(geometry) {
747
+ geometry.clearGroups();
748
+ const count = geometry.index ? geometry.index.count : geometry.getAttribute('position').count;
749
+ geometry.addGroup(0, count, TRIM_CUT_MATERIAL_SLOT);
750
+ }
751
+ function buildTrimCutBrush(minX, maxX, minZ, maxZ, minY, maxY) {
752
+ const width = maxX - minX;
753
+ const height = maxY - minY;
754
+ const depth = maxZ - minZ;
755
+ if (!(width > 0 && height > 0 && depth > 0))
756
+ return null;
757
+ const geometry = new THREE.BoxGeometry(width, height, depth);
758
+ geometry.translate((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
759
+ // World-metre UVs on the cutter so the newly exposed interior faces produced
760
+ // by CSG inherit tiled UVs (1 uv unit per metre) instead of the box's default
761
+ // 0→1-per-face, which would stretch the finish to fit each cut face.
762
+ applyWorldScaleBoxUVs(geometry, width, height, depth);
763
+ assignTrimCutterSlot(geometry);
764
+ ensureRenderableGeometryAttributes(geometry);
765
+ computeGeometryBoundsTree(geometry);
766
+ const cut = new Brush(geometry, dummyMats);
767
+ cut.updateMatrixWorld();
768
+ return cut;
769
+ }
770
+ function buildDiagonalTrimCutBrush(bounds, lineA, lineB, outsidePoint) {
771
+ const dx = lineB[0] - lineA[0];
772
+ const dz = lineB[1] - lineA[1];
773
+ const lineLength = Math.hypot(dx, dz);
774
+ const height = bounds.maxY - bounds.minY;
775
+ if (!(lineLength > 0 && height > 0))
776
+ return null;
777
+ const ux = dx / lineLength;
778
+ const uz = dz / lineLength;
779
+ let nx = -uz;
780
+ let nz = ux;
781
+ const midX = (lineA[0] + lineB[0]) / 2;
782
+ const midZ = (lineA[1] + lineB[1]) / 2;
783
+ const toOutsideX = outsidePoint[0] - midX;
784
+ const toOutsideZ = outsidePoint[1] - midZ;
785
+ let normalFlipped = false;
786
+ if (nx * toOutsideX + nz * toOutsideZ < 0) {
787
+ nx *= -1;
788
+ nz *= -1;
789
+ normalFlipped = true;
790
+ }
791
+ const boundsDiagonal = Math.hypot(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ);
792
+ const cutterLength = Math.max(lineLength, boundsDiagonal) * 2;
793
+ const cutterDepth = boundsDiagonal * 2;
794
+ const centerX = midX + nx * (cutterDepth / 2 - TRIM_CUT_EPSILON);
795
+ const centerZ = midZ + nz * (cutterDepth / 2 - TRIM_CUT_EPSILON);
796
+ const yaw = Math.atan2(-uz, ux) + (normalFlipped ? Math.PI : 0);
797
+ const geometry = new THREE.BoxGeometry(cutterLength, height, cutterDepth);
798
+ geometry.rotateY(yaw);
799
+ geometry.translate(centerX, (bounds.minY + bounds.maxY) / 2, centerZ);
800
+ // World-metre UVs so the diagonal cut's interior face inherits tiled UVs.
801
+ applyWorldScaleBoxUVs(geometry, cutterLength, height, cutterDepth);
802
+ assignTrimCutterSlot(geometry);
803
+ ensureRenderableGeometryAttributes(geometry);
804
+ computeGeometryBoundsTree(geometry);
805
+ const cut = new Brush(geometry, dummyMats);
806
+ cut.updateMatrixWorld();
807
+ return cut;
808
+ }
809
+ function subtractCutFromSegmentBrushes(brushes, cut) {
810
+ for (const key of ['shinSlab', 'deckSlab', 'wallBrush', 'innerBrush']) {
811
+ const next = csgEvaluator.evaluate(brushes[key], cut, SUBTRACTION);
812
+ brushes[key].geometry.dispose();
813
+ prepareBrushForCSG(next);
814
+ brushes[key] = next;
815
+ }
816
+ }
817
+ function buildSegmentTrimCutBrushes(segment) {
818
+ const trim = normalizeRoofSegmentTrim(segment);
819
+ if (trim.left === 0 &&
820
+ trim.right === 0 &&
821
+ trim.front === 0 &&
822
+ trim.back === 0 &&
823
+ trim.frontLeft === 0 &&
824
+ trim.frontRight === 0 &&
825
+ trim.backLeft === 0 &&
826
+ trim.backRight === 0 &&
827
+ trim.frontLeftX === 0 &&
828
+ trim.frontLeftZ === 0 &&
829
+ trim.frontRightX === 0 &&
830
+ trim.frontRightZ === 0 &&
831
+ trim.backLeftX === 0 &&
832
+ trim.backLeftZ === 0 &&
833
+ trim.backRightX === 0 &&
834
+ trim.backRightZ === 0) {
835
+ return [];
836
+ }
837
+ const { activeRh } = getSegmentSlopeFrame(segment);
838
+ const extra = segment.wallThickness + segment.overhang + segment.deckThickness + segment.shingleThickness + 2;
839
+ const minX = -segment.width / 2 - extra;
840
+ const maxX = segment.width / 2 + extra;
841
+ const minZ = -segment.depth / 2 - extra;
842
+ const maxZ = segment.depth / 2 + extra;
843
+ const minY = -2;
844
+ const maxY = segment.wallHeight + activeRh + segment.deckThickness + segment.shingleThickness + 2;
845
+ const cuts = [];
846
+ if (trim.left > 0) {
847
+ const planeX = -segment.width / 2 + trim.left;
848
+ const cut = buildTrimCutBrush(minX, planeX + TRIM_CUT_EPSILON, minZ, maxZ, minY, maxY);
849
+ if (cut)
850
+ cuts.push(cut);
851
+ }
852
+ if (trim.right > 0) {
853
+ const planeX = segment.width / 2 - trim.right;
854
+ const cut = buildTrimCutBrush(planeX - TRIM_CUT_EPSILON, maxX, minZ, maxZ, minY, maxY);
855
+ if (cut)
856
+ cuts.push(cut);
857
+ }
858
+ if (trim.front > 0) {
859
+ const planeZ = segment.depth / 2 - trim.front;
860
+ const cut = buildTrimCutBrush(minX, maxX, planeZ - TRIM_CUT_EPSILON, maxZ, minY, maxY);
861
+ if (cut)
862
+ cuts.push(cut);
863
+ }
864
+ if (trim.back > 0) {
865
+ const planeZ = -segment.depth / 2 + trim.back;
866
+ const cut = buildTrimCutBrush(minX, maxX, minZ, planeZ + TRIM_CUT_EPSILON, minY, maxY);
867
+ if (cut)
868
+ cuts.push(cut);
869
+ }
870
+ const leftX = -segment.width / 2 + trim.left;
871
+ const rightX = segment.width / 2 - trim.right;
872
+ const frontZ = segment.depth / 2 - trim.front;
873
+ const backZ = -segment.depth / 2 + trim.back;
874
+ if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) {
875
+ const cut = buildDiagonalTrimCutBrush({ minX, maxX, minZ, maxZ, minY, maxY }, [leftX + trim.frontLeftX + TRIM_CUT_EPSILON, frontZ], [leftX, frontZ - trim.frontLeftZ - TRIM_CUT_EPSILON], [minX, maxZ]);
876
+ if (cut)
877
+ cuts.push(cut);
878
+ }
879
+ if (trim.frontRightX > 0 && trim.frontRightZ > 0) {
880
+ const cut = buildDiagonalTrimCutBrush({ minX, maxX, minZ, maxZ, minY, maxY }, [rightX, frontZ - trim.frontRightZ - TRIM_CUT_EPSILON], [rightX - trim.frontRightX - TRIM_CUT_EPSILON, frontZ], [maxX, maxZ]);
881
+ if (cut)
882
+ cuts.push(cut);
883
+ }
884
+ if (trim.backLeftX > 0 && trim.backLeftZ > 0) {
885
+ const cut = buildDiagonalTrimCutBrush({ minX, maxX, minZ, maxZ, minY, maxY }, [leftX, backZ + trim.backLeftZ + TRIM_CUT_EPSILON], [leftX + trim.backLeftX + TRIM_CUT_EPSILON, backZ], [minX, minZ]);
886
+ if (cut)
887
+ cuts.push(cut);
888
+ }
889
+ if (trim.backRightX > 0 && trim.backRightZ > 0) {
890
+ const cut = buildDiagonalTrimCutBrush({ minX, maxX, minZ, maxZ, minY, maxY }, [rightX - trim.backRightX - TRIM_CUT_EPSILON, backZ], [rightX, backZ + trim.backRightZ + TRIM_CUT_EPSILON], [maxX, minZ]);
891
+ if (cut)
892
+ cuts.push(cut);
893
+ }
894
+ return cuts;
895
+ }
896
+ function subtractSegmentTrimCuts(brushes, segment) {
897
+ const cuts = buildSegmentTrimCutBrushes(segment);
898
+ for (const cut of cuts) {
899
+ try {
900
+ subtractCutFromSegmentBrushes(brushes, cut);
901
+ }
902
+ catch (e) {
903
+ console.error('Roof trim CSG failed:', e);
904
+ }
905
+ finally {
906
+ cut.geometry.dispose();
907
+ }
908
+ }
909
+ }
910
+ /**
911
+ * Subtract a segment's trim cuts from an arbitrary segment-local geometry,
912
+ * returning the clipped result. The input geometry is consumed (disposed) on
913
+ * each successful CSG pass — callers that need to keep the original must pass a
914
+ * clone. Returns the input untouched when the segment has no trim. Used
915
+ * internally for rake-board / end-slope attachments and exported so roof
916
+ * accessories (chimney, vents, skylight, …) can clip their own meshes by the
917
+ * same trim, in the same segment-local frame.
918
+ */
919
+ export function clipGeometryBySegmentTrim(geometry, segment) {
920
+ if (!geometry)
921
+ return null;
922
+ const cuts = buildSegmentTrimCutBrushes(segment);
923
+ if (cuts.length === 0)
924
+ return geometry;
925
+ let currentGeometry = geometry;
926
+ for (const cut of cuts) {
927
+ try {
928
+ const brush = new Brush(currentGeometry, dummyMats);
929
+ prepareBrushForCSG(brush);
930
+ const next = csgEvaluator.evaluate(brush, cut, SUBTRACTION);
931
+ const trimmed = csgGeometry(next).clone();
932
+ currentGeometry.dispose();
933
+ next.geometry.dispose();
934
+ ensureRenderableGeometryAttributes(trimmed);
935
+ currentGeometry = trimmed;
936
+ }
937
+ catch (e) {
938
+ console.error('Roof trim CSG failed for attachment geometry:', e);
939
+ }
940
+ finally {
941
+ cut.geometry.dispose();
942
+ }
943
+ }
944
+ return currentGeometry;
945
+ }
449
946
  /**
450
947
  * Generate complete hollow-shell geometry for a roof segment.
451
948
  * Ports the prototype's CSG approach using three-bvh-csg.
@@ -453,13 +950,19 @@ const RAKE_FACE_ALIGNMENT_EPSILON = 0.35;
453
950
  export function getRoofSegmentBrushes(node) {
454
951
  const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang, shingleThickness, } = node;
455
952
  const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node);
456
- const shapeRatios = {
953
+ const shapeRatios = getRoofShapeRatios({
457
954
  gambrelLowerWidthRatio: node.gambrelLowerWidthRatio,
458
955
  mansardSteepWidthRatio: node.mansardSteepWidthRatio,
459
956
  dutchHipWidthRatio: node.dutchHipWidthRatio,
460
- };
957
+ dutchHipHeightRatio: node.dutchHipHeightRatio,
958
+ dutchWaistLengthRatio: node.dutchWaistLengthRatio,
959
+ dutchGabletRake: node.dutchGabletRake,
960
+ });
461
961
  const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness;
462
- const baseI = Math.min(width, depth) * 0.25;
962
+ // Gablet inset must track dutchHipWidthRatio so the 3D waist matches both
963
+ // the 2D floorplan and the slope frame (which derives activeRh from the
964
+ // same ratio). A hardcoded 0.25 desyncs the gablet from the parameter.
965
+ const baseI = Math.min(width, depth) * node.dutchHipWidthRatio;
463
966
  const getVol = (wExt, vOffset, baseY, matIndex, isVoid) => {
464
967
  const wV = Math.max(0.01, width + 2 * wExt);
465
968
  const dV = Math.max(0.01, depth + 2 * wExt);
@@ -476,7 +979,20 @@ export function getRoofSegmentBrushes(node) {
476
979
  if (isVoid) {
477
980
  structuralI += deckThickness;
478
981
  }
479
- const faces = getModuleFaces(roofType, wV, dV, whV, rhV, safeBaseY, { dutchI: structuralI }, width, depth, tanTheta, shapeRatios);
982
+ const faces = getRoofModuleFaces({
983
+ type: roofType,
984
+ w: wV,
985
+ d: dV,
986
+ wh: whV,
987
+ rh: rhV,
988
+ baseY: safeBaseY,
989
+ insets: { dutchI: structuralI },
990
+ baseW: width,
991
+ baseD: depth,
992
+ tanTheta,
993
+ shapeRatios,
994
+ dutchTopRakeThickness: node.dutchTopRakeThickness,
995
+ }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
480
996
  return createGeometryFromFaces(faces, matIndex);
481
997
  };
482
998
  const wallGeo = getVol(wallThickness / 2, 0, 0, 0, false);
@@ -522,40 +1038,69 @@ export function getRoofSegmentBrushes(node) {
522
1038
  const dropBot = Math.min(2.0, maxDrop * 0.8);
523
1039
  const topBaseY = shinBotWh - dropTop;
524
1040
  const botBaseY = shinBotWh - dropBot;
525
- const getInsets = (wh, bY, isVoid, brushW, brushD) => {
526
- let inset = (wh - bY) * tanTheta;
527
- const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005;
528
- if (inset > maxSafeInset) {
529
- inset = maxSafeInset;
530
- }
531
- let iF = 0, iB = 0, iL = 0, iR = 0;
532
- if (['hip', 'mansard', 'dutch'].includes(roofType)) {
533
- iF = inset;
534
- iB = inset;
535
- iL = inset;
536
- iR = inset;
537
- }
538
- else if (['gable', 'gambrel'].includes(roofType)) {
539
- iF = inset;
540
- iB = inset;
541
- }
542
- else if (roofType === 'shed') {
543
- iF = inset;
544
- }
545
- let structuralI = baseI;
546
- if (isVoid) {
547
- structuralI += shingleThickness;
548
- }
549
- return { iF, iB, iL, iR, dutchI: structuralI };
550
- };
551
- const insetsBot = getInsets(shinBotWh, botBaseY, true, shinBotW, shinBotD);
552
- const insetsTop = getInsets(shinTopWh, topBaseY, false, shinTopW, shinTopD);
553
- const botFaces = getModuleFaces(roofType, shinBotW, shinBotD, shinBotWh, shinBotRh, botBaseY, insetsBot, width, depth, tanTheta, shapeRatios);
554
- const topFaces = getModuleFaces(roofType, shinTopW, shinTopD, shinTopWh, shinTopRh, topBaseY, insetsTop, width, depth, tanTheta, shapeRatios);
555
- const shinBotGeo = createGeometryFromFaces(botFaces, 1);
1041
+ const insetsBot = getRoofShapeInsets({
1042
+ roofType,
1043
+ width,
1044
+ depth,
1045
+ wh: shinBotWh,
1046
+ baseY: botBaseY,
1047
+ isVoid: true,
1048
+ brushW: shinBotW,
1049
+ brushD: shinBotD,
1050
+ tanTheta,
1051
+ shingleThickness,
1052
+ dutchHipWidthRatio: node.dutchHipWidthRatio,
1053
+ });
1054
+ const insetsTop = getRoofShapeInsets({
1055
+ roofType,
1056
+ width,
1057
+ depth,
1058
+ wh: shinTopWh,
1059
+ baseY: topBaseY,
1060
+ isVoid: false,
1061
+ brushW: shinTopW,
1062
+ brushD: shinTopD,
1063
+ tanTheta,
1064
+ shingleThickness,
1065
+ dutchHipWidthRatio: node.dutchHipWidthRatio,
1066
+ });
1067
+ const botFaces = getRoofModuleFaces({
1068
+ type: roofType,
1069
+ w: shinBotW,
1070
+ d: shinBotD,
1071
+ wh: shinBotWh,
1072
+ rh: shinBotRh,
1073
+ baseY: botBaseY,
1074
+ insets: insetsBot,
1075
+ baseW: width,
1076
+ baseD: depth,
1077
+ tanTheta,
1078
+ shapeRatios,
1079
+ dutchTopRakeThickness: node.dutchTopRakeThickness,
1080
+ }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
1081
+ const topFaces = getRoofModuleFaces({
1082
+ type: roofType,
1083
+ w: shinTopW,
1084
+ d: shinTopD,
1085
+ wh: shinTopWh,
1086
+ rh: shinTopRh,
1087
+ baseY: topBaseY,
1088
+ insets: insetsTop,
1089
+ baseW: width,
1090
+ baseD: depth,
1091
+ tanTheta,
1092
+ shapeRatios,
1093
+ dutchTopRakeThickness: node.dutchTopRakeThickness,
1094
+ }).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
1095
+ let rakeBoards = null;
1096
+ if (roofType === 'dutch' && insetsTop.dutchI !== undefined) {
1097
+ rakeBoards = buildDutchRakeBoards(shinTopW, shinTopD, shinTopWh, shinTopRh, insetsTop.dutchI, shapeRatios, node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness);
1098
+ }
1099
+ const shinBotGeo = createGeometryFromFaces(botFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1);
556
1100
  const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1);
557
1101
  if (transZ !== 0) {
558
1102
  shinTopGeo.translate(0, 0, transZ);
1103
+ rakeBoards?.translate(0, 0, transZ);
559
1104
  }
560
1105
  const toBrush = (geo) => {
561
1106
  if (!geo?.attributes.position || geo.attributes.position.count === 0)
@@ -567,6 +1112,9 @@ export function getRoofSegmentBrushes(node) {
567
1112
  geo.groups = geo.groups.filter((g) => g.count > 0);
568
1113
  if (geo.groups.length === 0)
569
1114
  return null;
1115
+ ensureRenderableGeometryAttributes(geo);
1116
+ if (geometryHasInvalidAttributes(geo))
1117
+ return null;
570
1118
  computeGeometryBoundsTree(geo);
571
1119
  const brush = new Brush(geo, dummyMats);
572
1120
  brush.updateMatrixWorld();
@@ -606,12 +1154,32 @@ export function getRoofSegmentBrushes(node) {
606
1154
  if (deckTopBrush && deckBotBrush && wallBrush && innerBrush && shinTopBrush && shinBotBrush) {
607
1155
  try {
608
1156
  const deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION);
1157
+ prepareBrushForCSG(deckSlab);
609
1158
  const shinSlab = csgEvaluator.evaluate(shinTopBrush, shinBotBrush, SUBTRACTION);
1159
+ prepareBrushForCSG(shinSlab);
610
1160
  deckTopBrush.geometry.dispose();
611
1161
  deckBotBrush.geometry.dispose();
612
1162
  shinTopBrush.geometry.dispose();
613
1163
  shinBotBrush.geometry.dispose();
614
- return { deckSlab, shinSlab, wallBrush, innerBrush };
1164
+ const brushes = {
1165
+ deckSlab,
1166
+ shinSlab,
1167
+ wallBrush,
1168
+ innerBrush,
1169
+ rakeBoards,
1170
+ };
1171
+ if (hasSegmentTrim(node)) {
1172
+ subtractSegmentTrimCuts(brushes, node);
1173
+ brushes.rakeBoards = clipGeometryBySegmentTrim(brushes.rakeBoards, node);
1174
+ // The clip is a CSG subtraction: rake faces can come back as
1175
+ // `slot + 4n` because the cutter contributes its own material array.
1176
+ // Preserve top roof-material faces (slot 3) and force only cutter /
1177
+ // side faces back to the rake side material.
1178
+ if (brushes.rakeBoards) {
1179
+ remapDutchRakeBoardMaterials(brushes.rakeBoards);
1180
+ }
1181
+ }
1182
+ return brushes;
615
1183
  }
616
1184
  catch (e) {
617
1185
  console.error('CSG prep failed:', e);
@@ -629,21 +1197,37 @@ export function getRoofSegmentBrushes(node) {
629
1197
  wallBrush.geometry.dispose();
630
1198
  if (innerBrush)
631
1199
  innerBrush.geometry.dispose();
1200
+ rakeBoards?.dispose();
632
1201
  return null;
633
1202
  }
634
- export function generateRoofSegmentGeometry(node) {
635
- const brushes = getRoofSegmentBrushes(node);
1203
+ 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));
636
1210
  if (!brushes) {
637
1211
  // Fallback: simple box
638
1212
  return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth);
639
1213
  }
640
- const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes;
1214
+ if (nodes) {
1215
+ subtractAccessoryCuts(brushes, node, nodes);
1216
+ }
1217
+ const { deckSlab, shinSlab, wallBrush, innerBrush, rakeBoards } = brushes;
641
1218
  let resultGeo = new THREE.BufferGeometry();
642
1219
  try {
643
1220
  const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION);
1221
+ prepareBrushForCSG(hollowWall);
644
1222
  const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION);
1223
+ prepareBrushForCSG(shinDeck);
645
1224
  const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION);
1225
+ prepareBrushForCSG(combined);
646
1226
  resultGeo = csgGeometry(combined);
1227
+ if (geometryHasInvalidAttributes(resultGeo)) {
1228
+ resultGeo.dispose();
1229
+ resultGeo = csgGeometry(wallBrush).clone();
1230
+ }
647
1231
  const resultMaterials = csgMaterials(combined);
648
1232
  const matToIndex = new Map([
649
1233
  [dummyMats[0], 0],
@@ -666,10 +1250,141 @@ export function generateRoofSegmentGeometry(node) {
666
1250
  shinSlab.geometry.dispose();
667
1251
  wallBrush.geometry.dispose();
668
1252
  innerBrush.geometry.dispose();
1253
+ if (rakeBoards) {
1254
+ const merged = mergeGeometriesPreservingGroups([resultGeo, rakeBoards]);
1255
+ rakeBoards.dispose();
1256
+ if (merged) {
1257
+ resultGeo.dispose();
1258
+ resultGeo = merged;
1259
+ }
1260
+ }
669
1261
  resultGeo.computeVertexNormals();
670
- ensureUv2Attribute(resultGeo);
1262
+ ensureRenderableGeometryAttributes(resultGeo);
671
1263
  return resultGeo;
672
1264
  }
1265
+ function createGeometryFromRawAttributes(positions, normals, uvs, groups) {
1266
+ const geometry = new THREE.BufferGeometry();
1267
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
1268
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
1269
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
1270
+ geometry.setIndex(Array.from({ length: positions.length / 3 }, (_, i) => i));
1271
+ for (const group of groups) {
1272
+ geometry.addGroup(group.start, group.count, group.materialIndex);
1273
+ }
1274
+ ensureRenderableGeometryAttributes(geometry);
1275
+ return geometry;
1276
+ }
1277
+ function mergeGeometriesPreservingGroups(geometries) {
1278
+ if (geometries.length === 0)
1279
+ return null;
1280
+ const merged = mergeGeometries(geometries, false);
1281
+ if (!merged)
1282
+ return null;
1283
+ merged.clearGroups();
1284
+ let indexStart = 0;
1285
+ for (const geometry of geometries) {
1286
+ if (geometry.groups.length > 0) {
1287
+ for (const group of geometry.groups) {
1288
+ merged.addGroup(indexStart + group.start, group.count, group.materialIndex ?? 0);
1289
+ }
1290
+ }
1291
+ else {
1292
+ const count = geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0;
1293
+ if (count > 0) {
1294
+ merged.addGroup(indexStart, count, 0);
1295
+ }
1296
+ }
1297
+ indexStart += geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0;
1298
+ }
1299
+ return merged;
1300
+ }
1301
+ function collectGeometryPlanes(geometry) {
1302
+ const source = geometry.index ? geometry.toNonIndexed() : geometry;
1303
+ const position = source.getAttribute('position');
1304
+ const planes = [];
1305
+ const a = new THREE.Vector3();
1306
+ const b = new THREE.Vector3();
1307
+ const c = new THREE.Vector3();
1308
+ if (!position)
1309
+ return planes;
1310
+ for (let i = 0; i + 2 < position.count; i += 3) {
1311
+ a.fromBufferAttribute(position, i);
1312
+ b.fromBufferAttribute(position, i + 1);
1313
+ c.fromBufferAttribute(position, i + 2);
1314
+ const plane = new THREE.Plane().setFromCoplanarPoints(a, b, c);
1315
+ if (Number.isFinite(plane.normal.x) && plane.normal.lengthSq() > 1e-10) {
1316
+ planes.push(plane.normalize());
1317
+ }
1318
+ }
1319
+ if (source !== geometry)
1320
+ source.dispose();
1321
+ return planes;
1322
+ }
1323
+ function splitShellByFacePlanes(geometry, planes, materialIndex) {
1324
+ if (planes.length === 0)
1325
+ return geometry;
1326
+ const source = geometry.index ? geometry.toNonIndexed() : geometry.clone();
1327
+ const position = source.getAttribute('position');
1328
+ const normal = source.getAttribute('normal');
1329
+ const uv = source.getAttribute('uv');
1330
+ if (!position || !normal || !uv) {
1331
+ source.dispose();
1332
+ return geometry;
1333
+ }
1334
+ const groups = source.groups.length > 0 ? source.groups : [{ start: 0, count: position.count, materialIndex }];
1335
+ const keptPositions = [];
1336
+ const keptNormals = [];
1337
+ const keptUvs = [];
1338
+ const keptGroups = [];
1339
+ let keptVertexCount = 0;
1340
+ const point = new THREE.Vector3();
1341
+ const triNormal = new THREE.Vector3();
1342
+ const pushTriangle = (targetPositions, targetNormals, targetUvs, targetGroups, startVertex, groupMaterialIndex, vertexOffset) => {
1343
+ if (targetGroups.length === 0 ||
1344
+ targetGroups[targetGroups.length - 1].materialIndex !== groupMaterialIndex) {
1345
+ targetGroups.push({ start: startVertex, count: 0, materialIndex: groupMaterialIndex });
1346
+ }
1347
+ const targetGroup = targetGroups[targetGroups.length - 1];
1348
+ for (let k = 0; k < 3; k += 1) {
1349
+ const vi = vertexOffset + k;
1350
+ targetPositions.push(position.getX(vi), position.getY(vi), position.getZ(vi));
1351
+ targetNormals.push(normal.getX(vi), normal.getY(vi), normal.getZ(vi));
1352
+ targetUvs.push(uv.getX(vi), uv.getY(vi));
1353
+ }
1354
+ targetGroup.count += 3;
1355
+ };
1356
+ const isPlaneMatch = (vertexOffset) => {
1357
+ if (materialIndex >= 0 &&
1358
+ triNormal.fromBufferAttribute(normal, vertexOffset).y <= SHINGLE_SURFACE_EPSILON) {
1359
+ return false;
1360
+ }
1361
+ triNormal.fromBufferAttribute(normal, vertexOffset).normalize();
1362
+ return planes.some((plane) => {
1363
+ if (Math.abs(triNormal.dot(plane.normal)) < 0.999)
1364
+ return false;
1365
+ for (let k = 0; k < 3; k += 1) {
1366
+ point.fromBufferAttribute(position, vertexOffset + k);
1367
+ if (Math.abs(plane.distanceToPoint(point)) > 1e-3)
1368
+ return false;
1369
+ }
1370
+ return true;
1371
+ });
1372
+ };
1373
+ for (const group of groups) {
1374
+ const groupStart = Math.max(0, group.start);
1375
+ const groupEnd = Math.min(position.count, group.start + group.count);
1376
+ for (let i = groupStart; i + 2 < groupEnd; i += 3) {
1377
+ if (group.materialIndex === materialIndex && isPlaneMatch(i)) {
1378
+ continue;
1379
+ }
1380
+ pushTriangle(keptPositions, keptNormals, keptUvs, keptGroups, keptVertexCount, group.materialIndex ?? 0, i);
1381
+ keptVertexCount += 3;
1382
+ }
1383
+ }
1384
+ source.dispose();
1385
+ geometry.dispose();
1386
+ return createGeometryFromRawAttributes(keptPositions, keptNormals, keptUvs, keptGroups);
1387
+ }
673
1388
  export function remapRoofShellFaces(geometry, node) {
674
1389
  const position = geometry.getAttribute('position');
675
1390
  const index = geometry.getIndex();
@@ -706,14 +1421,17 @@ export function remapRoofShellFaces(geometry, node) {
706
1421
  .add(b)
707
1422
  .add(c)
708
1423
  .multiplyScalar(1 / 3);
709
- if (normal.y > SHINGLE_SURFACE_EPSILON) {
1424
+ if (node.roofType === 'dutch' && Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON) {
1425
+ materialIndex = 3;
1426
+ }
1427
+ else if (normal.y > SHINGLE_SURFACE_EPSILON) {
710
1428
  materialIndex = 3;
711
1429
  }
712
1430
  else if (isRakeFace(node, geometry, centroid, normal)) {
713
- materialIndex = 0;
1431
+ materialIndex = 1;
714
1432
  }
715
1433
  else {
716
- materialIndex = 1;
1434
+ materialIndex = 0;
717
1435
  }
718
1436
  }
719
1437
  triangleMaterials[triangleIndex] = materialIndex;
@@ -754,8 +1472,6 @@ function isRakeFace(node, geometry, centroid, normal) {
754
1472
  function getRakeAxis(node) {
755
1473
  if (node.roofType === 'gable' || node.roofType === 'gambrel')
756
1474
  return 'x';
757
- if (node.roofType === 'dutch')
758
- return node.width >= node.depth ? 'x' : 'z';
759
1475
  return null;
760
1476
  }
761
1477
  /**
@@ -766,7 +1482,7 @@ function getRakeAxis(node) {
766
1482
  * height ratios are already baked into `tanTheta` (via the slope frame)
767
1483
  * so they don't need to be threaded again.
768
1484
  */
769
- function getModuleFaces(type, w, d, wh, rh, baseY, insets, baseW, baseD, tanTheta, shapeRatios) {
1485
+ function getModuleFaces(type, w, d, wh, rh, baseY, insets, baseW, baseD, tanTheta, shapeRatios, dutchTopRakeThickness) {
770
1486
  const v = (x, y, z) => new THREE.Vector3(x, y, z);
771
1487
  const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets;
772
1488
  const b1 = v(-w / 2 + iL, baseY, d / 2 - iF);
@@ -829,57 +1545,186 @@ function getModuleFaces(type, w, d, wh, rh, baseY, insets, baseW, baseD, tanThet
829
1545
  const m2 = v(w / 2 - i, mh, d / 2 - i);
830
1546
  const m3 = v(w / 2 - i, mh, -d / 2 + i);
831
1547
  const m4 = v(-w / 2 + i, mh, -d / 2 + i);
832
- const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2);
833
- const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2);
834
- const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2);
835
- const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2);
836
- if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) {
837
- if (w >= d) {
838
- const r1 = v(-w / 2 + d / 2, h, 0);
839
- const r2 = v(w / 2 - d / 2, h, 0);
840
- faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]);
841
- }
842
- else {
843
- const r1 = v(0, h, d / 2 - w / 2);
844
- const r2 = v(0, h, -d / 2 + w / 2);
845
- faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]);
846
- }
1548
+ const topW = w - i * 2;
1549
+ const topD = d - i * 2;
1550
+ faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4]);
1551
+ if (Math.abs(topW - topD) < 0.01) {
1552
+ const r = v(0, h, 0);
1553
+ faces.push([m4, m1, r], [m1, m2, r], [m2, m3, r], [m3, m4, r]);
1554
+ }
1555
+ else if (topW >= topD) {
1556
+ const r1 = v(-topW / 2 + topD / 2, h, 0);
1557
+ const r2 = v(topW / 2 - topD / 2, h, 0);
1558
+ faces.push([m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2]);
847
1559
  }
848
1560
  else {
849
- faces.push([t1, t2, t3, t4], [e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4], [m1, m2, t2, t1], [m2, m3, t3, t2], [m3, m4, t4, t3], [m4, m1, t1, t4]);
1561
+ const r1 = v(0, h, topD / 2 - topW / 2);
1562
+ const r2 = v(0, h, -topD / 2 + topW / 2);
1563
+ faces.push([m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2]);
850
1564
  }
851
1565
  }
852
1566
  else if (type === 'dutch') {
853
- const i = insets.dutchI !== undefined
854
- ? insets.dutchI
855
- : Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio;
856
- const mh = wh + i * (tanTheta || 0);
857
- if (w >= d) {
858
- const m1 = v(-w / 2 + i, mh, d / 2 - i);
859
- const m2 = v(w / 2 - i, mh, d / 2 - i);
860
- const m3 = v(w / 2 - i, mh, -d / 2 + i);
861
- const m4 = v(-w / 2 + i, mh, -d / 2 + i);
862
- const r1 = v(-w / 2 + i, h, 0);
863
- const r2 = v(w / 2 - i, h, 0);
864
- faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4], [m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2]);
1567
+ const dutch = getDutchRoofShapeMetrics({
1568
+ w,
1569
+ d,
1570
+ wh,
1571
+ rh,
1572
+ dutchI: insets.dutchI,
1573
+ baseW,
1574
+ baseD,
1575
+ shapeRatios,
1576
+ });
1577
+ if (!dutch)
1578
+ return faces;
1579
+ if (dutch.axis === 'width') {
1580
+ const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
1581
+ const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
1582
+ const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
1583
+ const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
1584
+ const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
1585
+ const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
1586
+ const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
1587
+ const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
1588
+ const r1 = v(-dutch.innerWaistHalfX, h, 0);
1589
+ const r2 = v(dutch.innerWaistHalfX, h, 0);
1590
+ const endSlopes = getDutchEndSlopeFaces({
1591
+ w,
1592
+ d,
1593
+ wh,
1594
+ rh,
1595
+ insets,
1596
+ baseW,
1597
+ baseD,
1598
+ shapeRatios,
1599
+ dutchTopRakeThickness,
1600
+ }).map((face) => face.map((point) => v(point.x, point.y, point.z)));
1601
+ faces.push([e1, e2, o2, m2, m1, o1], [e3, e4, o4, m4, m3, o3]);
1602
+ if (endSlopes.length === 2) {
1603
+ faces.push(...endSlopes);
1604
+ }
1605
+ else {
1606
+ faces.push([e2, e3, o3, o2], [e4, e1, o1, o4]);
1607
+ }
1608
+ faces.push([m1, m2, r2, r1], [m3, m4, r1, r2]);
1609
+ faces.push([m4, m1, r1], [m2, m3, r2]);
865
1610
  }
866
1611
  else {
867
- const m1 = v(-w / 2 + i, mh, d / 2 - i);
868
- const m2 = v(w / 2 - i, mh, d / 2 - i);
869
- const m3 = v(w / 2 - i, mh, -d / 2 + i);
870
- const m4 = v(-w / 2 + i, mh, -d / 2 + i);
871
- const r1 = v(0, h, d / 2 - i);
872
- const r2 = v(0, h, -d / 2 + i);
873
- faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4], [m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2]);
1612
+ const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
1613
+ const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
1614
+ const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
1615
+ const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
1616
+ const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ);
1617
+ const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ);
1618
+ const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ);
1619
+ const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ);
1620
+ const r1 = v(0, h, dutch.innerWaistHalfZ);
1621
+ const r2 = v(0, h, -dutch.innerWaistHalfZ);
1622
+ const endSlopes = getDutchEndSlopeFaces({
1623
+ w,
1624
+ d,
1625
+ wh,
1626
+ rh,
1627
+ insets,
1628
+ baseW,
1629
+ baseD,
1630
+ shapeRatios,
1631
+ dutchTopRakeThickness,
1632
+ }).map((face) => face.map((point) => v(point.x, point.y, point.z)));
1633
+ faces.push([e2, e3, o3, m3, m2, o2], [e4, e1, o1, m1, m4, o4]);
1634
+ if (endSlopes.length === 2) {
1635
+ faces.push(...endSlopes);
1636
+ }
1637
+ else {
1638
+ faces.push([e1, e2, o2, o1], [e3, e4, o4, o3]);
1639
+ }
1640
+ faces.push([m2, m3, r2, r1], [m4, m1, r1, r2]);
1641
+ faces.push([m1, m2, r1], [m3, m4, r2]);
874
1642
  }
875
1643
  }
876
1644
  return faces;
877
1645
  }
1646
+ function addDutchRakeBoard(apex, base, outward, reach, thickness, topFaces, sideFaces) {
1647
+ if (!(reach > 0.001) || !(thickness > 0.0001))
1648
+ return;
1649
+ const apexOuter = apex.clone().addScaledVector(outward, reach);
1650
+ const baseOuter = base.clone().addScaledVector(outward, reach);
1651
+ const topPoly = [apex.clone(), base.clone(), baseOuter, apexOuter];
1652
+ const normal = new THREE.Vector3()
1653
+ .crossVectors(new THREE.Vector3().subVectors(topPoly[1], topPoly[0]), new THREE.Vector3().subVectors(topPoly[2], topPoly[0]))
1654
+ .normalize();
1655
+ if (normal.y < 0) {
1656
+ topPoly.reverse();
1657
+ normal.multiplyScalar(-1);
1658
+ }
1659
+ const top = topPoly.map((point) => point.clone().addScaledVector(normal, DUTCH_RAKE_SLOPE_SEAT_OFFSET));
1660
+ const bottom = top.map((point) => new THREE.Vector3(point.x, point.y - thickness, point.z));
1661
+ pushDoubleSidedFace(topFaces, top);
1662
+ pushDoubleSidedFace(sideFaces, bottom.slice().reverse());
1663
+ for (let i = 0; i < top.length; i += 1) {
1664
+ const next = (i + 1) % top.length;
1665
+ pushDoubleSidedFace(sideFaces, [
1666
+ top[i].clone(),
1667
+ top[next].clone(),
1668
+ bottom[next].clone(),
1669
+ bottom[i].clone(),
1670
+ ]);
1671
+ }
1672
+ }
1673
+ function buildDutchRakeBoards(W, D, wh, rh, i, shapeRatios, rake, thickness) {
1674
+ if (!(rake > 0.001) || !(thickness > 0.0001) || !(i > 0.001) || !(rh > 0.001)) {
1675
+ return null;
1676
+ }
1677
+ const dutch = getDutchRoofShapeMetrics({
1678
+ w: W,
1679
+ d: D,
1680
+ wh,
1681
+ rh,
1682
+ dutchI: i,
1683
+ baseW: W,
1684
+ baseD: D,
1685
+ shapeRatios: {
1686
+ ...shapeRatios,
1687
+ dutchGabletRake: rake,
1688
+ },
1689
+ });
1690
+ if (!dutch || !(dutch.rakeReach > 0.001))
1691
+ return null;
1692
+ const v = (x, y, z) => new THREE.Vector3(x, y, z);
1693
+ const topFaces = [];
1694
+ const sideFaces = [];
1695
+ if (dutch.axis === 'width') {
1696
+ addDutchRakeBoard(v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(-1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces);
1697
+ addDutchRakeBoard(v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(-1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces);
1698
+ addDutchRakeBoard(v(dutch.innerWaistHalfX, dutch.peakHeight, 0), v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces);
1699
+ addDutchRakeBoard(v(dutch.innerWaistHalfX, dutch.peakHeight, 0), v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces);
1700
+ }
1701
+ else {
1702
+ addDutchRakeBoard(v(0, dutch.peakHeight, dutch.innerWaistHalfZ), v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(0, 0, 1), dutch.rakeReach, thickness, topFaces, sideFaces);
1703
+ addDutchRakeBoard(v(0, dutch.peakHeight, dutch.innerWaistHalfZ), v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(0, 0, 1), dutch.rakeReach, thickness, topFaces, sideFaces);
1704
+ addDutchRakeBoard(v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(0, 0, -1), dutch.rakeReach, thickness, topFaces, sideFaces);
1705
+ addDutchRakeBoard(v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(0, 0, -1), dutch.rakeReach, thickness, topFaces, sideFaces);
1706
+ }
1707
+ const geometries = [];
1708
+ if (topFaces.length > 0) {
1709
+ geometries.push(createGeometryFromFaces(topFaces, DUTCH_RAKE_TOP_MATERIAL_INDEX));
1710
+ }
1711
+ if (sideFaces.length > 0) {
1712
+ geometries.push(createGeometryFromFaces(sideFaces, DUTCH_RAKE_SIDE_MATERIAL_INDEX));
1713
+ }
1714
+ if (geometries.length === 0)
1715
+ return null;
1716
+ if (geometries.length === 1)
1717
+ return geometries[0];
1718
+ const merged = mergeGeometriesPreservingGroups(geometries);
1719
+ for (const geometry of geometries)
1720
+ geometry.dispose();
1721
+ return merged;
1722
+ }
878
1723
  /**
879
1724
  * Converts an array of face polygons into a BufferGeometry.
880
1725
  * Each face is triangulated via fan triangulation.
881
1726
  */
882
- function createGeometryFromFaces(faces, matRule = null) {
1727
+ function createGeometryFromFaces(faces, matRule = null, options) {
883
1728
  const positions = [];
884
1729
  const normals = [];
885
1730
  const uvs = [];
@@ -895,14 +1740,19 @@ function createGeometryFromFaces(faces, matRule = null) {
895
1740
  const vA = new THREE.Vector3().subVectors(p1, p0);
896
1741
  const vB = new THREE.Vector3().subVectors(p2, p0);
897
1742
  const normal = new THREE.Vector3().crossVectors(vA, vB).normalize();
1743
+ if (normal.lengthSq() < 1e-12)
1744
+ continue;
898
1745
  let slopeAlignedDown = null;
899
1746
  let slopeAlignedAcross = null;
900
1747
  let slopeAlignedVOrigin = 0;
901
- if (normal.y > SHINGLE_SURFACE_EPSILON) {
902
- _uvDownSlope.copy(_uvWorldDown).projectOnPlane(normal);
1748
+ const slopeUvNormal = options?.treatBidirectionalSlopeFacesAsSlope && normal.y < 0
1749
+ ? normal.clone().multiplyScalar(-1)
1750
+ : normal;
1751
+ if (Math.abs(slopeUvNormal.y) > SHINGLE_SURFACE_EPSILON) {
1752
+ _uvDownSlope.copy(_uvWorldDown).projectOnPlane(slopeUvNormal);
903
1753
  if (_uvDownSlope.lengthSq() > 1e-8) {
904
1754
  _uvDownSlope.normalize();
905
- _uvAcrossSlope.crossVectors(_uvDownSlope, normal).normalize();
1755
+ _uvAcrossSlope.crossVectors(_uvDownSlope, slopeUvNormal).normalize();
906
1756
  let highestPoint = face[0];
907
1757
  for (const candidate of face) {
908
1758
  if (candidate.y > highestPoint.y) {
@@ -967,16 +1817,21 @@ function createGeometryFromFaces(faces, matRule = null) {
967
1817
  // Merge identical vertices to optimize geometry for CSG and create clean topology
968
1818
  const mergedGeo = mergeVertices(geometry, 1e-4);
969
1819
  geometry.dispose();
970
- ensureUv2Attribute(mergedGeo);
1820
+ ensureRenderableGeometryAttributes(mergedGeo);
971
1821
  return mergedGeo;
972
1822
  }
973
1823
  function pushRoofUv(uvs, point, normal) {
974
- _uvFaceNormal.copy(normal).normalize();
1824
+ // Project in WORLD space (via the current segment's world transform) so
1825
+ // vertical gable faces tile identically to the walls below; for a local
1826
+ // build the matrix is identity and this is the original behaviour.
1827
+ const p = _uvWorldPoint.copy(point).applyMatrix4(_segmentUvMatrix);
1828
+ _uvFaceNormal.copy(normal).applyMatrix3(_segmentUvNormalMatrix).normalize();
1829
+ _uvWorldNormal.copy(_uvFaceNormal);
975
1830
  const absX = Math.abs(_uvFaceNormal.x);
976
1831
  const absY = Math.abs(_uvFaceNormal.y);
977
1832
  const absZ = Math.abs(_uvFaceNormal.z);
978
1833
  if (absY >= absX && absY >= absZ) {
979
- uvs.push(point.x, point.z);
1834
+ uvs.push(p.x, p.z);
980
1835
  return;
981
1836
  }
982
1837
  if (_uvFaceNormal.y > SHINGLE_SURFACE_EPSILON) {
@@ -984,21 +1839,18 @@ function pushRoofUv(uvs, point, normal) {
984
1839
  if (_uvDownSlope.lengthSq() > 1e-8) {
985
1840
  _uvDownSlope.normalize();
986
1841
  _uvAcrossSlope.crossVectors(_uvDownSlope, _uvFaceNormal).normalize();
987
- uvs.push(point.dot(_uvAcrossSlope), point.dot(_uvDownSlope));
1842
+ uvs.push(p.dot(_uvAcrossSlope), p.dot(_uvDownSlope));
988
1843
  return;
989
1844
  }
990
1845
  }
1846
+ // Vertical (gable wall) faces: U = ±worldX/Z (axis across the face normal),
1847
+ // V = 1 - worldY — the same world-space projection the wall kind uses.
1848
+ const wallV = 1 - p.y;
991
1849
  if (absX >= absZ) {
992
- uvs.push(_uvFaceNormal.x >= 0 ? point.z : -point.z, -point.y);
1850
+ uvs.push(_uvWorldNormal.x >= 0 ? p.z : -p.z, wallV);
993
1851
  return;
994
1852
  }
995
- uvs.push(_uvFaceNormal.z >= 0 ? point.x : -point.x, -point.y);
996
- }
997
- function ensureUv2Attribute(geometry) {
998
- const uv = geometry.getAttribute('uv');
999
- if (!uv)
1000
- return;
1001
- geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2));
1853
+ uvs.push(_uvWorldNormal.z >= 0 ? p.x : -p.x, wallV);
1002
1854
  }
1003
1855
  /**
1004
1856
  * Returns the outer roof surface frame (point + normal) at a given segment-local XZ.
@@ -1045,6 +1897,9 @@ export function getRoofOuterSurfaceFrameAtPoint(segment, lx, lz) {
1045
1897
  const shinTopRh = shinBotRh + stCos;
1046
1898
  const topBaseY = 0;
1047
1899
  const baseI = Math.min(width, depth) * 0.25;
1900
+ // Dutch gablet waist tracks dutchHipWidthRatio (see getRoofSegmentBrushes);
1901
+ // the generic baseI above still drives the bottom-rect insets for other types.
1902
+ const dutchBaseI = Math.min(width, depth) * segment.dutchHipWidthRatio;
1048
1903
  const getInsets = (_wh, _baseY, isVoid, _wV, _dV) => {
1049
1904
  const inset = Math.max(0.01, baseI);
1050
1905
  let iF = 0;
@@ -1070,7 +1925,7 @@ export function getRoofOuterSurfaceFrameAtPoint(segment, lx, lz) {
1070
1925
  else if (roofType === 'shed') {
1071
1926
  iF = inset;
1072
1927
  }
1073
- let structuralI = baseI;
1928
+ let structuralI = dutchBaseI;
1074
1929
  if (isVoid) {
1075
1930
  structuralI += shingleThickness;
1076
1931
  }
@@ -1081,8 +1936,11 @@ export function getRoofOuterSurfaceFrameAtPoint(segment, lx, lz) {
1081
1936
  gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio,
1082
1937
  mansardSteepWidthRatio: segment.mansardSteepWidthRatio,
1083
1938
  dutchHipWidthRatio: segment.dutchHipWidthRatio,
1939
+ dutchHipHeightRatio: segment.dutchHipHeightRatio,
1940
+ dutchWaistLengthRatio: segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
1941
+ dutchGabletRake: segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake,
1084
1942
  };
1085
- const topFaces = getModuleFaces(roofType, shinTopW, shinTopD, shinTopWh, shinTopRh, topBaseY, insetsTop, width, depth, tanTheta, shapeRatios);
1943
+ const topFaces = getModuleFaces(roofType, shinTopW, shinTopD, shinTopWh, shinTopRh, topBaseY, insetsTop, width, depth, tanTheta, shapeRatios, segment.dutchTopRakeThickness);
1086
1944
  const topGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1);
1087
1945
  if (transZ !== 0)
1088
1946
  topGeo.translate(0, 0, transZ);