@pascal-app/viewer 0.9.1 → 1.0.0-beta.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 (206) hide show
  1. package/README.md +20 -5
  2. package/dist/components/error-boundary.d.ts +6 -0
  3. package/dist/components/error-boundary.d.ts.map +1 -1
  4. package/dist/components/error-boundary.js +6 -0
  5. package/dist/components/renderers/node-renderer.d.ts +5 -1
  6. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  7. package/dist/components/renderers/node-renderer.js +5 -2
  8. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  9. package/dist/components/renderers/parametric-node-renderer.js +7 -4
  10. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  11. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  12. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  13. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  14. package/dist/components/viewer/frame-limiter.js +59 -4
  15. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  16. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  17. package/dist/components/viewer/glb-interactive.js +394 -0
  18. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  19. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  20. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  21. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  22. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  23. package/dist/components/viewer/glb-replace-instances.js +61 -0
  24. package/dist/components/viewer/glb-scene.d.ts +54 -0
  25. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  26. package/dist/components/viewer/glb-scene.js +920 -0
  27. package/dist/components/viewer/glb-walkthrough-controller.d.ts +22 -0
  28. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  29. package/dist/components/viewer/glb-walkthrough-controller.js +445 -0
  30. package/dist/components/viewer/index.d.ts +19 -2
  31. package/dist/components/viewer/index.d.ts.map +1 -1
  32. package/dist/components/viewer/index.js +113 -12
  33. package/dist/components/viewer/lights.d.ts.map +1 -1
  34. package/dist/components/viewer/lights.js +36 -5
  35. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  36. package/dist/components/viewer/perf-monitor.js +17 -2
  37. package/dist/components/viewer/post-processing.d.ts +7 -1
  38. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  39. package/dist/components/viewer/post-processing.js +180 -24
  40. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  41. package/dist/components/viewer/registered-systems.js +6 -4
  42. package/dist/components/viewer/scene-bvh.d.ts +2 -1
  43. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  44. package/dist/components/viewer/scene-bvh.js +3 -0
  45. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  46. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  47. package/dist/components/viewer/scene-bvh.test.js +13 -0
  48. package/dist/components/viewer/scene-environment.d.ts +2 -0
  49. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  50. package/dist/components/viewer/scene-environment.js +79 -0
  51. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  52. package/dist/components/viewer/selection-manager.js +23 -12
  53. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  54. package/dist/hooks/use-gltf-ktx2.js +2 -24
  55. package/dist/hooks/use-node-events.d.ts +1 -1
  56. package/dist/hooks/use-node-events.d.ts.map +1 -1
  57. package/dist/hooks/use-node-events.js +22 -14
  58. package/dist/index.d.ts +24 -10
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +29 -13
  61. package/dist/lib/backdrop.d.ts +33 -0
  62. package/dist/lib/backdrop.d.ts.map +1 -0
  63. package/dist/lib/backdrop.js +92 -0
  64. package/dist/lib/box-uv.d.ts +13 -0
  65. package/dist/lib/box-uv.d.ts.map +1 -0
  66. package/dist/lib/box-uv.js +32 -0
  67. package/dist/lib/csg-utils.d.ts +3 -9
  68. package/dist/lib/csg-utils.d.ts.map +1 -1
  69. package/dist/lib/csg-utils.js +65 -5
  70. package/dist/lib/csg-utils.test.d.ts +2 -0
  71. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  72. package/dist/lib/csg-utils.test.js +45 -0
  73. package/dist/lib/edge-style.d.ts +1 -0
  74. package/dist/lib/edge-style.d.ts.map +1 -1
  75. package/dist/lib/edge-style.js +26 -6
  76. package/dist/lib/hero-pose.d.ts +47 -0
  77. package/dist/lib/hero-pose.d.ts.map +1 -0
  78. package/dist/lib/hero-pose.js +227 -0
  79. package/dist/lib/ink-edges.d.ts +1 -1
  80. package/dist/lib/ink-edges.d.ts.map +1 -1
  81. package/dist/lib/ink-edges.js +13 -7
  82. package/dist/lib/ktx2-loader.d.ts +46 -0
  83. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  84. package/dist/lib/ktx2-loader.js +142 -0
  85. package/dist/lib/layers.d.ts +10 -0
  86. package/dist/lib/layers.d.ts.map +1 -1
  87. package/dist/lib/layers.js +10 -0
  88. package/dist/lib/materials.d.ts +16 -1
  89. package/dist/lib/materials.d.ts.map +1 -1
  90. package/dist/lib/materials.js +158 -34
  91. package/dist/lib/materials.test.d.ts +2 -0
  92. package/dist/lib/materials.test.d.ts.map +1 -0
  93. package/dist/lib/materials.test.js +25 -0
  94. package/dist/lib/merged-outline-node.test.d.ts +2 -0
  95. package/dist/lib/merged-outline-node.test.d.ts.map +1 -0
  96. package/dist/lib/merged-outline-node.test.js +22 -0
  97. package/dist/lib/scene-themes.d.ts +1 -0
  98. package/dist/lib/scene-themes.d.ts.map +1 -1
  99. package/dist/lib/scene-themes.js +25 -16
  100. package/dist/lib/shadow-only.d.ts +4 -0
  101. package/dist/lib/shadow-only.d.ts.map +1 -0
  102. package/dist/lib/shadow-only.js +36 -0
  103. package/dist/lib/snapshot-pipeline.d.ts +42 -0
  104. package/dist/lib/snapshot-pipeline.d.ts.map +1 -0
  105. package/dist/lib/snapshot-pipeline.js +254 -0
  106. package/dist/lib/terrain-perimeter-fill.d.ts +7 -0
  107. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -0
  108. package/dist/lib/terrain-perimeter-fill.js +75 -0
  109. package/dist/lib/texture-node-guard.d.ts +13 -0
  110. package/dist/lib/texture-node-guard.d.ts.map +1 -0
  111. package/dist/lib/texture-node-guard.js +39 -0
  112. package/dist/lib/texture-reference.d.ts +31 -0
  113. package/dist/lib/texture-reference.d.ts.map +1 -0
  114. package/dist/lib/texture-reference.js +139 -0
  115. package/dist/lib/texture-reference.test.d.ts +2 -0
  116. package/dist/lib/texture-reference.test.d.ts.map +1 -0
  117. package/dist/lib/texture-reference.test.js +86 -0
  118. package/dist/lib/tsl-compat.d.ts +12 -0
  119. package/dist/lib/tsl-compat.d.ts.map +1 -0
  120. package/dist/lib/tsl-compat.js +13 -0
  121. package/dist/store/use-viewer.d.ts +73 -18
  122. package/dist/store/use-viewer.d.ts.map +1 -1
  123. package/dist/store/use-viewer.js +191 -4
  124. package/dist/store/use-viewer.test.d.ts +2 -0
  125. package/dist/store/use-viewer.test.d.ts.map +1 -0
  126. package/dist/store/use-viewer.test.js +61 -0
  127. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  128. package/dist/systems/ceiling/ceiling-system.js +12 -5
  129. package/dist/systems/door/door-animation.test.d.ts +2 -0
  130. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  131. package/dist/systems/door/door-animation.test.js +51 -0
  132. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  133. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  134. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  135. package/dist/systems/door/door-system.d.ts +24 -0
  136. package/dist/systems/door/door-system.d.ts.map +1 -1
  137. package/dist/systems/door/door-system.js +494 -161
  138. package/dist/systems/fence/fence-system.d.ts +17 -0
  139. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  140. package/dist/systems/fence/fence-system.js +221 -53
  141. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  142. package/dist/systems/floor-elevation/floor-elevation-system.js +30 -7
  143. package/dist/systems/geometry/geometry-system.d.ts +7 -0
  144. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  145. package/dist/systems/geometry/geometry-system.js +95 -26
  146. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  147. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  148. package/dist/systems/geometry/geometry-system.test.js +30 -0
  149. package/dist/systems/interactive/control-widget.d.ts +10 -0
  150. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  151. package/dist/systems/interactive/control-widget.js +35 -0
  152. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  153. package/dist/systems/interactive/interactive-system.js +55 -56
  154. package/dist/systems/item/item-system.d.ts.map +1 -1
  155. package/dist/systems/item/item-system.js +20 -6
  156. package/dist/systems/level/level-system.d.ts.map +1 -1
  157. package/dist/systems/level/level-system.js +34 -9
  158. package/dist/systems/level/level-utils.d.ts +0 -3
  159. package/dist/systems/level/level-utils.d.ts.map +1 -1
  160. package/dist/systems/level/level-utils.js +7 -45
  161. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  162. package/dist/systems/roof/roof-materials.js +29 -8
  163. package/dist/systems/roof/roof-system.d.ts +21 -8
  164. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  165. package/dist/systems/roof/roof-system.js +1079 -221
  166. package/dist/systems/slab/slab-system.d.ts +7 -4
  167. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  168. package/dist/systems/slab/slab-system.js +31 -83
  169. package/dist/systems/slab/slab-system.test.js +54 -14
  170. package/dist/systems/stair/stair-system.js +7 -4
  171. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  172. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  173. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  174. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  175. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  176. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  177. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  178. package/dist/systems/wall/wall-cutout.js +49 -17
  179. package/dist/systems/wall/wall-materials.d.ts +11 -7
  180. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  181. package/dist/systems/wall/wall-materials.js +244 -35
  182. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  183. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  184. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  185. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  186. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  187. package/dist/systems/wall/wall-support-extension.test.js +116 -0
  188. package/dist/systems/wall/wall-system.d.ts +4 -8
  189. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  190. package/dist/systems/wall/wall-system.js +397 -177
  191. package/dist/systems/wall/wall-terrain-bottom.test.d.ts +2 -0
  192. package/dist/systems/wall/wall-terrain-bottom.test.d.ts.map +1 -0
  193. package/dist/systems/wall/wall-terrain-bottom.test.js +34 -0
  194. package/dist/systems/window/window-animation-system.d.ts +12 -0
  195. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  196. package/dist/systems/window/window-animation-system.js +16 -5
  197. package/dist/systems/window/window-system.d.ts +7 -0
  198. package/dist/systems/window/window-system.d.ts.map +1 -1
  199. package/dist/systems/window/window-system.js +240 -14
  200. package/package.json +4 -4
  201. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  202. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  203. package/dist/components/viewer/ground-occluder.js +0 -67
  204. package/dist/components/viewer/walkthrough-controls.d.ts +0 -2
  205. package/dist/components/viewer/walkthrough-controls.d.ts.map +0 -1
  206. package/dist/components/viewer/walkthrough-controls.js +0 -131
@@ -1,13 +1,37 @@
1
- import { calculateLevelMiters, DEFAULT_WALL_HEIGHT, getAdjacentWallIds, getEffectiveNode, getWallCurveFrameAt, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, pointToKey, resolveLevelId, sceneRegistry, spatialGridManager, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core';
1
+ import { calculateLevelMiters, DEFAULT_LEVEL_HEIGHT, getAdjacentWallIds, getEffectiveNode, getWallBandSlotId, getWallCurveFrameAt, getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, pointToKey, resolveLevelId, resolveWallTop, sceneRegistry, spatialGridManager, terrainSupportLift, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
3
  import * as THREE from 'three';
4
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
4
5
  import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
5
6
  import { computeBoundsTree } from 'three-mesh-bvh';
7
+ import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils';
8
+ import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill';
9
+ import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, } from './opening-cutout-geometry';
6
10
  // Reusable CSG evaluator for better performance
7
11
  const csgEvaluator = new Evaluator();
12
+ csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'];
8
13
  const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015;
9
14
  const WALL_FACE_NORMAL_Y_EPSILON = 0.6;
10
15
  const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003;
16
+ const WALL_BAND_SPLIT_EPSILON = 1e-5;
17
+ const WALL_BAND_SLOT_MATERIAL_INDEX = {
18
+ interior: 1,
19
+ exterior: 2,
20
+ lowerInterior: 3,
21
+ middleInterior: 4,
22
+ upperInterior: 5,
23
+ topInterior: 6,
24
+ lowerExterior: 7,
25
+ middleExterior: 8,
26
+ upperExterior: 9,
27
+ topExterior: 10,
28
+ skirtingInterior: 0,
29
+ skirtingExterior: 0,
30
+ crownInterior: 0,
31
+ crownExterior: 0,
32
+ chairRailInterior: 0,
33
+ chairRailExterior: 0,
34
+ };
11
35
  function computeGeometryBoundsTree(geometry) {
12
36
  ;
13
37
  geometry.computeBoundsTree = computeBoundsTree;
@@ -16,12 +40,6 @@ function computeGeometryBoundsTree(geometry) {
16
40
  function csgGeometry(brush) {
17
41
  return brush.geometry;
18
42
  }
19
- function ensureUv2Attribute(geometry) {
20
- const uv = geometry.getAttribute('uv');
21
- if (!uv)
22
- return;
23
- geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2));
24
- }
25
43
  function insetCurvedWallBoundaryPointsFor3D(wall, boundaryPoints, miterData) {
26
44
  if (!(boundaryPoints && isCurvedWall(wall))) {
27
45
  return boundaryPoints;
@@ -117,16 +135,17 @@ function distanceToWallBoundaryEdge(point, edge) {
117
135
  const closestZ = edge.start.y + edgeDz * t;
118
136
  return Math.hypot(point.x - closestX, point.y - closestZ);
119
137
  }
120
- function getWallFaceMaterialIndex(wall, face) {
138
+ function getWallFaceMaterialIndex(wall, face, y, effectiveWallHeight) {
121
139
  const semantic = face === 'front' ? wall.frontSide : wall.backSide;
122
- const fallback = face === 'front' ? 1 : 2;
123
- if (semantic === 'interior')
124
- return 1;
125
- if (semantic === 'exterior')
126
- return 2;
127
- return fallback;
140
+ const fallback = face === 'front' ? 'interior' : 'exterior';
141
+ const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback;
142
+ const bands = getWallFaceBandConfig(wall, effectiveWallHeight);
143
+ if (!bands.enabled)
144
+ return WALL_BAND_SLOT_MATERIAL_INDEX[side];
145
+ const band = getWallFaceBandForHeight(wall, y, effectiveWallHeight);
146
+ return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)];
128
147
  }
129
- function assignWallMaterialGroups(geometry, wall, boundaryEdges) {
148
+ function assignWallMaterialGroups(geometry, wall, boundaryEdges, effectiveWallHeight) {
130
149
  const position = geometry.getAttribute('position');
131
150
  if (!position)
132
151
  return;
@@ -189,7 +208,7 @@ function assignWallMaterialGroups(geometry, wall, boundaryEdges) {
189
208
  triangleMaterials[triangleIndex] = 0;
190
209
  continue;
191
210
  }
192
- triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag);
211
+ triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y, effectiveWallHeight);
193
212
  }
194
213
  geometry.clearGroups();
195
214
  let currentMaterial = triangleMaterials[0] ?? 0;
@@ -204,6 +223,105 @@ function assignWallMaterialGroups(geometry, wall, boundaryEdges) {
204
223
  }
205
224
  geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial);
206
225
  }
226
+ function interpolateSplitVertex(a, b, t) {
227
+ return {
228
+ x: a.x + (b.x - a.x) * t,
229
+ y: a.y + (b.y - a.y) * t,
230
+ z: a.z + (b.z - a.z) * t,
231
+ };
232
+ }
233
+ function clipPolygonByY(polygon, planeY, keepBelow) {
234
+ const out = [];
235
+ if (polygon.length === 0)
236
+ return out;
237
+ const isInside = (vertex) => keepBelow
238
+ ? vertex.y <= planeY + WALL_BAND_SPLIT_EPSILON
239
+ : vertex.y >= planeY - WALL_BAND_SPLIT_EPSILON;
240
+ for (let index = 0; index < polygon.length; index += 1) {
241
+ const current = polygon[index];
242
+ const previous = polygon[(index + polygon.length - 1) % polygon.length];
243
+ const currentInside = isInside(current);
244
+ const previousInside = isInside(previous);
245
+ if (currentInside !== previousInside) {
246
+ const denom = current.y - previous.y;
247
+ if (Math.abs(denom) > WALL_BAND_SPLIT_EPSILON) {
248
+ out.push(interpolateSplitVertex(previous, current, (planeY - previous.y) / denom));
249
+ }
250
+ }
251
+ if (currentInside)
252
+ out.push(current);
253
+ }
254
+ return out;
255
+ }
256
+ function triangulateSplitPolygon(polygon, positions) {
257
+ if (polygon.length < 3)
258
+ return;
259
+ const first = polygon[0];
260
+ for (let index = 1; index < polygon.length - 1; index += 1) {
261
+ const b = polygon[index];
262
+ const c = polygon[index + 1];
263
+ positions.push(first.x, first.y, first.z, b.x, b.y, b.z, c.x, c.y, c.z);
264
+ }
265
+ }
266
+ function splitGeometryAtHorizontalPlanes(geometry, planes) {
267
+ const splitPlanes = Array.from(new Set(planes
268
+ .filter((plane) => Number.isFinite(plane) && plane > WALL_BAND_SPLIT_EPSILON)
269
+ .map((plane) => Math.round(plane / WALL_BAND_SPLIT_EPSILON) * WALL_BAND_SPLIT_EPSILON))).sort((a, b) => a - b);
270
+ if (splitPlanes.length === 0)
271
+ return geometry;
272
+ const source = geometry.index ? geometry.toNonIndexed() : geometry;
273
+ const position = source.getAttribute('position');
274
+ if (!position || position.count === 0)
275
+ return source;
276
+ const positions = [];
277
+ for (let index = 0; index < position.count; index += 3) {
278
+ let polygons = [
279
+ [
280
+ { x: position.getX(index), y: position.getY(index), z: position.getZ(index) },
281
+ { x: position.getX(index + 1), y: position.getY(index + 1), z: position.getZ(index + 1) },
282
+ { x: position.getX(index + 2), y: position.getY(index + 2), z: position.getZ(index + 2) },
283
+ ],
284
+ ];
285
+ for (const plane of splitPlanes) {
286
+ const next = [];
287
+ for (const polygon of polygons) {
288
+ const minY = Math.min(...polygon.map((vertex) => vertex.y));
289
+ const maxY = Math.max(...polygon.map((vertex) => vertex.y));
290
+ if (plane <= minY + WALL_BAND_SPLIT_EPSILON || plane >= maxY - WALL_BAND_SPLIT_EPSILON) {
291
+ next.push(polygon);
292
+ continue;
293
+ }
294
+ const below = clipPolygonByY(polygon, plane, true);
295
+ const above = clipPolygonByY(polygon, plane, false);
296
+ if (below.length >= 3)
297
+ next.push(below);
298
+ if (above.length >= 3)
299
+ next.push(above);
300
+ }
301
+ polygons = next;
302
+ }
303
+ for (const polygon of polygons)
304
+ triangulateSplitPolygon(polygon, positions);
305
+ }
306
+ if (source !== geometry)
307
+ geometry.dispose();
308
+ source.dispose();
309
+ const split = new THREE.BufferGeometry();
310
+ split.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
311
+ split.computeVertexNormals();
312
+ return split;
313
+ }
314
+ function getWallBandSplitPlanes(wall, effectiveWallHeight) {
315
+ const bands = getWallFaceBandConfig(wall, effectiveWallHeight);
316
+ if (!bands.enabled)
317
+ return [];
318
+ const planes = [bands.lowerTop];
319
+ if (bands.count >= 3)
320
+ planes.push(bands.middleTop);
321
+ if (bands.count >= 4)
322
+ planes.push(bands.upperTop);
323
+ return planes.filter((plane) => plane > WALL_BAND_SPLIT_EPSILON && plane < effectiveWallHeight - WALL_BAND_SPLIT_EPSILON);
324
+ }
207
325
  // ============================================================================
208
326
  // WALL SYSTEM
209
327
  // ============================================================================
@@ -258,7 +376,7 @@ export const WallSystem = () => {
258
376
  if (hasDirty) {
259
377
  dirtyNodes.forEach((id) => {
260
378
  const node = nodes[id];
261
- if (!node || node.type !== 'wall')
379
+ if (node?.type !== 'wall')
262
380
  return;
263
381
  const levelId = node.parentId;
264
382
  if (!levelId)
@@ -387,7 +505,7 @@ function getEffectiveWall(wall) {
387
505
  function getLevelWalls(levelId) {
388
506
  const { nodes } = useScene.getState();
389
507
  const level = nodes[levelId];
390
- if (!level || level.type !== 'level')
508
+ if (level?.type !== 'level')
391
509
  return [];
392
510
  const walls = [];
393
511
  for (const childId of level.children) {
@@ -406,14 +524,21 @@ function getLevelWalls(levelId) {
406
524
  function updateWallGeometry(wallId, miterData) {
407
525
  const nodes = useScene.getState().nodes;
408
526
  const sceneNode = nodes[wallId];
409
- if (!sceneNode || sceneNode.type !== 'wall')
527
+ if (sceneNode?.type !== 'wall')
410
528
  return;
411
529
  const node = getEffectiveWall(sceneNode);
412
530
  const mesh = sceneRegistry.nodes.get(wallId);
413
531
  if (!mesh)
414
532
  return;
415
533
  const levelId = resolveLevelId(node, nodes);
416
- const slabElevation = spatialGridManager.getSlabElevationForWall(levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness);
534
+ // Covering-clamped plane: a flush/thick slab on the level above shortens
535
+ // the plane-bound walls below it (explicit-height walls ignore the value).
536
+ const planeTop = getWallPlaneTop(node, levelId, nodes);
537
+ const slabSupport = spatialGridManager.getSlabSupportForWall(levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, node.supportSlabId, undefined, node.supportOffset);
538
+ const slabElevation = slabSupport.elevation;
539
+ const terrainBottomAt = node.fillToTerrain
540
+ ? (x, z) => terrainSupportLift(nodes, levelId, x, z)
541
+ : undefined;
417
542
  const childrenIds = node.children || [];
418
543
  // Merge live overrides into door / window children so cutouts track an
419
544
  // in-flight resize drag (door width arrow, window height arrow, etc.)
@@ -427,22 +552,27 @@ function updateWallGeometry(wallId, miterData) {
427
552
  return child;
428
553
  // `getEffectiveNode` folds in resize overrides (width/height arrows).
429
554
  // Position moves publish to `useLiveTransforms` instead, so fold that
430
- // in too — otherwise shaped openings (arch/rounded/`opening`), whose
431
- // cutout brush is rebuilt from `node.position`, lag the live move
432
- // (rectangular cutouts already track via the live mesh matrixWorld).
555
+ // in too — opening cutout brushes are rebuilt directly from the
556
+ // effective node position rather than from the rendered proxy mesh.
433
557
  const effective = getEffectiveNode(child);
434
558
  const live = useLiveTransforms.getState().get(child.id);
435
559
  if (!live?.position)
436
560
  return effective;
437
561
  return { ...effective, position: live.position };
438
562
  });
439
- const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation);
563
+ const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt);
564
+ const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
565
+ // World transform the render mesh will apply (position + Y-rotation below).
566
+ // Reproduce it here so the UVs can be projected in WORLD space — see
567
+ // `applyWorldPlanarWallUVs`.
568
+ const wallWorldMatrix = new THREE.Matrix4().compose(new THREE.Vector3(node.start[0], slabElevation, node.start[1]), new THREE.Quaternion().setFromAxisAngle(WALL_UV_Y_AXIS, -wallAngle), WALL_UV_UNIT_SCALE);
569
+ const newGeo = applyWorldPlanarWallUVs(builtGeo, wallWorldMatrix);
440
570
  mesh.geometry.dispose();
441
571
  mesh.geometry = newGeo;
442
572
  // Update collision mesh
443
573
  const collisionMesh = mesh.getObjectByName('collision-mesh');
444
574
  if (collisionMesh) {
445
- const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation);
575
+ const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt);
446
576
  collisionMesh.geometry.dispose();
447
577
  collisionMesh.geometry = collisionGeo;
448
578
  }
@@ -450,19 +580,133 @@ function updateWallGeometry(wallId, miterData) {
450
580
  const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
451
581
  mesh.rotation.y = -angle;
452
582
  }
583
+ const WALL_UV_Y_AXIS = new THREE.Vector3(0, 1, 0);
584
+ const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
585
+ /**
586
+ * Re-project a wall's UVs in WORLD space (1 UV unit = 1 m) so the finish tiles
587
+ * continuously across adjacent walls and lines up with the roof gable above —
588
+ * instead of THREE's `ExtrudeGeometry` UVs, which restart at each wall's own
589
+ * start/end. Matches `roof-system`'s `pushRoofUv` projection exactly: vertical
590
+ * faces use `U = ±worldX/Z` (the axis across the face normal) and `V = 1 -
591
+ * worldY`; the thin top/bottom caps use `(worldX, worldZ)`. De-indexes first so
592
+ * every triangle projects by its own face normal (no shared-vertex seams at
593
+ * edges). Applied only to the render mesh; collision/floorplan geometry is
594
+ * untouched.
595
+ */
596
+ function applyWorldPlanarWallUVs(geometry, worldMatrix) {
597
+ const target = geometry.index ? geometry.toNonIndexed() : geometry;
598
+ if (target !== geometry)
599
+ geometry.dispose();
600
+ const position = target.getAttribute('position');
601
+ if (!position || position.count === 0)
602
+ return target;
603
+ const a = new THREE.Vector3();
604
+ const b = new THREE.Vector3();
605
+ const c = new THREE.Vector3();
606
+ const normal = new THREE.Vector3();
607
+ const edgeAB = new THREE.Vector3();
608
+ const edgeAC = new THREE.Vector3();
609
+ const uvs = new Float32Array(position.count * 2);
610
+ for (let i = 0; i < position.count; i += 3) {
611
+ a.fromBufferAttribute(position, i).applyMatrix4(worldMatrix);
612
+ b.fromBufferAttribute(position, i + 1).applyMatrix4(worldMatrix);
613
+ c.fromBufferAttribute(position, i + 2).applyMatrix4(worldMatrix);
614
+ edgeAB.subVectors(b, a);
615
+ edgeAC.subVectors(c, a);
616
+ normal.crossVectors(edgeAB, edgeAC).normalize();
617
+ const absX = Math.abs(normal.x);
618
+ const absY = Math.abs(normal.y);
619
+ const absZ = Math.abs(normal.z);
620
+ for (let k = 0; k < 3; k += 1) {
621
+ const p = k === 0 ? a : k === 1 ? b : c;
622
+ let u;
623
+ let v;
624
+ if (absY >= absX && absY >= absZ) {
625
+ u = p.x;
626
+ v = p.z;
627
+ }
628
+ else {
629
+ v = 1 - p.y;
630
+ u = absX >= absZ ? (normal.x >= 0 ? p.z : -p.z) : normal.z >= 0 ? p.x : -p.x;
631
+ }
632
+ uvs[(i + k) * 2] = u;
633
+ uvs[(i + k) * 2 + 1] = v;
634
+ }
635
+ }
636
+ target.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
637
+ target.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2));
638
+ return target;
639
+ }
453
640
  /**
454
641
  * Generates extruded wall geometry with mitering and cutouts
455
642
  *
456
643
  * Key insight from demo: polygon is built in WORLD coordinates first,
457
644
  * then we transform to wall-local for the 3D mesh.
458
645
  */
459
- export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabElevation = 0) {
646
+ const WALL_TERRAIN_SAMPLE_STEP = 0.25;
647
+ function densifyClosedWallPerimeter(points) {
648
+ const dense = [];
649
+ for (let index = 0; index < points.length; index += 1) {
650
+ const start = points[index];
651
+ const end = points[(index + 1) % points.length];
652
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
653
+ const segments = Math.max(1, Math.ceil(length / WALL_TERRAIN_SAMPLE_STEP));
654
+ for (let segment = 0; segment < segments; segment += 1) {
655
+ const t = segment / segments;
656
+ dense.push({
657
+ x: start.x + (end.x - start.x) * t,
658
+ y: start.y + (end.y - start.y) * t,
659
+ });
660
+ }
661
+ }
662
+ return dense;
663
+ }
664
+ function buildWallTerrainFillGeometry(perimeter, worldToLocal, wallBaseElevation, terrainBottomAt) {
665
+ const worldPoints = densifyClosedWallPerimeter(perimeter);
666
+ if (worldPoints.length < 3)
667
+ return null;
668
+ const localPoints = worldPoints.map(worldToLocal);
669
+ const bottomY = worldPoints.map((point) => {
670
+ const terrainElevation = terrainBottomAt(point.x, point.y);
671
+ return terrainElevation == null ? 0 : Math.min(0, terrainElevation - wallBaseElevation);
672
+ });
673
+ return buildTerrainPerimeterFillGeometry(localPoints, bottomY, 0);
674
+ }
675
+ function mergeWallTerrainFill(body, fill, wall, boundaryEdges, effectiveWallHeight) {
676
+ if (!fill)
677
+ return body;
678
+ const bodyGeometry = body.index ? body.toNonIndexed() : body;
679
+ if (bodyGeometry !== body)
680
+ body.dispose();
681
+ ensureRenderableGeometryAttributes(bodyGeometry);
682
+ ensureRenderableGeometryAttributes(fill);
683
+ const merged = mergeGeometries([bodyGeometry, fill], false);
684
+ if (!merged) {
685
+ fill.dispose();
686
+ return bodyGeometry;
687
+ }
688
+ bodyGeometry.dispose();
689
+ fill.dispose();
690
+ merged.computeVertexNormals();
691
+ assignWallMaterialGroups(merged, wall, boundaryEdges, effectiveWallHeight);
692
+ ensureRenderableGeometryAttributes(merged);
693
+ return merged;
694
+ }
695
+ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabElevation = 0, baseElevation = slabElevation, baseSegments = [
696
+ { start: 0, end: 1, elevation: baseElevation },
697
+ ], storeyHeight = DEFAULT_LEVEL_HEIGHT, terrainBottomAt) {
460
698
  const wallStart = { x: wallNode.start[0], y: wallNode.start[1] };
461
699
  const wallEnd = { x: wallNode.end[0], y: wallNode.end[1] };
462
- // Positive slab: shift the whole wall up (full height preserved)
463
- // Negative slab: extend wall downward so top stays fixed at wallNode.height
464
- const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT;
465
- const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation;
700
+ const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation);
701
+ const effectiveWallHeight = topElevation - slabElevation;
702
+ const effectiveBaseElevation = Math.min(baseElevation, slabElevation);
703
+ const localBottom = effectiveBaseElevation - slabElevation;
704
+ const height = topElevation - effectiveBaseElevation;
705
+ // A slab at or above the storey plane leaves a plane-bound wall with no
706
+ // body — bail before ExtrudeGeometry sees a non-positive depth.
707
+ if (height <= 1e-9) {
708
+ return new THREE.BufferGeometry();
709
+ }
466
710
  const thickness = getWallThickness(wallNode);
467
711
  // Wall direction and normal (exactly like demo)
468
712
  const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y };
@@ -493,6 +737,9 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
493
737
  // Convert polygon to local coordinates
494
738
  const localPoints = polyPoints.map(worldToLocal);
495
739
  const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData);
740
+ const terrainFill = terrainBottomAt
741
+ ? buildWallTerrainFillGeometry(polyPoints, worldToLocal, slabElevation, terrainBottomAt)
742
+ : null;
496
743
  // Build THREE.js shape
497
744
  // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
498
745
  // The negation is needed because after rotateX(-PI/2), shape.y becomes -geometry.z
@@ -509,24 +756,118 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
509
756
  });
510
757
  // Rotate so extrusion direction (Z) becomes height direction (Y)
511
758
  geometry.rotateX(-Math.PI / 2);
759
+ if (Math.abs(localBottom) > 1e-9)
760
+ geometry.translate(0, localBottom, 0);
512
761
  geometry.computeVertexNormals();
513
- assignWallMaterialGroups(geometry, wallNode, boundaryEdges);
514
- ensureUv2Attribute(geometry);
515
- // Apply CSG subtraction for cutouts (doors/windows)
516
- const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness);
762
+ assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight);
763
+ ensureRenderableGeometryAttributes(geometry);
764
+ // Start with the lowest required wall prism, then remove the volume below
765
+ // each higher-supported run. This keeps the existing mitered footprint and
766
+ // opening CSG while giving one wall a stepped longitudinal base.
767
+ const baseProfileCutouts = [];
768
+ for (const segment of baseSegments) {
769
+ const segmentElevation = Math.min(segment.elevation, slabElevation);
770
+ const cutHeight = segmentElevation - effectiveBaseElevation;
771
+ if (cutHeight <= 1e-6 || segment.end - segment.start <= 1e-7)
772
+ continue;
773
+ const segmentStart = THREE.MathUtils.clamp(segment.start, 0, 1);
774
+ const segmentEnd = THREE.MathUtils.clamp(segment.end, 0, 1);
775
+ const cutHalfWidth = Math.max(thickness * 2, 0.2);
776
+ const worldCutoutPoints = [];
777
+ if (isCurvedWall(wallNode)) {
778
+ const sampleCount = Math.max(2, Math.ceil((segmentEnd - segmentStart) * 24));
779
+ const left = [];
780
+ const right = [];
781
+ for (let index = 0; index <= sampleCount; index++) {
782
+ const t = segmentStart + ((segmentEnd - segmentStart) * index) / sampleCount;
783
+ const frame = getWallCurveFrameAt(wallNode, t);
784
+ const endpointExtension = index === 0 && segmentStart <= 1e-7
785
+ ? -cutHalfWidth
786
+ : index === sampleCount && segmentEnd >= 1 - 1e-7
787
+ ? cutHalfWidth
788
+ : 0;
789
+ const center = {
790
+ x: frame.point.x + frame.tangent.x * endpointExtension,
791
+ y: frame.point.y + frame.tangent.y * endpointExtension,
792
+ };
793
+ left.push({
794
+ x: center.x + frame.normal.x * cutHalfWidth,
795
+ y: center.y + frame.normal.y * cutHalfWidth,
796
+ });
797
+ right.push({
798
+ x: center.x - frame.normal.x * cutHalfWidth,
799
+ y: center.y - frame.normal.y * cutHalfWidth,
800
+ });
801
+ }
802
+ worldCutoutPoints.push(...left, ...right.reverse());
803
+ }
804
+ else {
805
+ const tangentX = v.x / L;
806
+ const tangentY = v.y / L;
807
+ const normalX = -tangentY;
808
+ const normalY = tangentX;
809
+ const startExtension = segmentStart <= 1e-7 ? cutHalfWidth : 0;
810
+ const endExtension = segmentEnd >= 1 - 1e-7 ? cutHalfWidth : 0;
811
+ const startPoint = {
812
+ x: wallStart.x + tangentX * (segmentStart * L - startExtension),
813
+ y: wallStart.y + tangentY * (segmentStart * L - startExtension),
814
+ };
815
+ const endPoint = {
816
+ x: wallStart.x + tangentX * (segmentEnd * L + endExtension),
817
+ y: wallStart.y + tangentY * (segmentEnd * L + endExtension),
818
+ };
819
+ worldCutoutPoints.push({
820
+ x: startPoint.x + normalX * cutHalfWidth,
821
+ y: startPoint.y + normalY * cutHalfWidth,
822
+ }, { x: endPoint.x + normalX * cutHalfWidth, y: endPoint.y + normalY * cutHalfWidth }, { x: endPoint.x - normalX * cutHalfWidth, y: endPoint.y - normalY * cutHalfWidth }, {
823
+ x: startPoint.x - normalX * cutHalfWidth,
824
+ y: startPoint.y - normalY * cutHalfWidth,
825
+ });
826
+ }
827
+ const localCutoutPoints = worldCutoutPoints.map(worldToLocal);
828
+ if (localCutoutPoints.length < 3)
829
+ continue;
830
+ const cutoutShape = new THREE.Shape();
831
+ cutoutShape.moveTo(localCutoutPoints[0].x, -localCutoutPoints[0].z);
832
+ for (let index = 1; index < localCutoutPoints.length; index++) {
833
+ cutoutShape.lineTo(localCutoutPoints[index].x, -localCutoutPoints[index].z);
834
+ }
835
+ cutoutShape.closePath();
836
+ const cutoutBottom = localBottom - 0.01;
837
+ const cutoutTop = segmentElevation - slabElevation;
838
+ const cutoutGeometry = new THREE.ExtrudeGeometry(cutoutShape, {
839
+ depth: cutoutTop - cutoutBottom,
840
+ bevelEnabled: false,
841
+ });
842
+ cutoutGeometry.rotateX(-Math.PI / 2);
843
+ cutoutGeometry.translate(0, cutoutBottom, 0);
844
+ computeGeometryBoundsTree(cutoutGeometry);
845
+ baseProfileCutouts.push(new Brush(cutoutGeometry));
846
+ }
847
+ // Apply base-profile and opening cutouts in one CSG pass.
848
+ const cutoutBrushes = [
849
+ ...baseProfileCutouts,
850
+ ...collectCutoutBrushes(wallNode, childrenNodes, thickness),
851
+ ];
517
852
  if (cutoutBrushes.length === 0) {
518
- return geometry;
853
+ const splitGeometry = splitGeometryAtHorizontalPlanes(geometry, getWallBandSplitPlanes(wallNode, effectiveWallHeight));
854
+ splitGeometry.computeVertexNormals();
855
+ assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight);
856
+ ensureRenderableGeometryAttributes(splitGeometry);
857
+ return mergeWallTerrainFill(splitGeometry, terrainFill, wallNode, boundaryEdges, effectiveWallHeight);
519
858
  }
520
859
  // Create wall brush from geometry
521
860
  // Pre-compute BVH with new API to avoid deprecation warning
861
+ ensureRenderableGeometryAttributes(geometry);
522
862
  computeGeometryBoundsTree(geometry);
523
863
  const wallBrush = new Brush(geometry);
524
864
  wallBrush.updateMatrixWorld();
525
865
  // Subtract each cutout from the wall
526
866
  let resultBrush = wallBrush;
527
867
  for (const cutoutBrush of cutoutBrushes) {
528
- cutoutBrush.updateMatrixWorld();
868
+ prepareBrushForCSG(cutoutBrush);
529
869
  const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION);
870
+ prepareBrushForCSG(newResult);
530
871
  if (resultBrush !== wallBrush) {
531
872
  csgGeometry(resultBrush).dispose();
532
873
  }
@@ -538,14 +879,16 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
538
879
  csgGeometry(brush).dispose();
539
880
  }
540
881
  const resultGeometry = csgGeometry(resultBrush);
541
- resultGeometry.computeVertexNormals();
542
- assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges);
543
- ensureUv2Attribute(resultGeometry);
544
- return resultGeometry;
882
+ const splitResultGeometry = splitGeometryAtHorizontalPlanes(resultGeometry, getWallBandSplitPlanes(wallNode, effectiveWallHeight));
883
+ splitResultGeometry.computeVertexNormals();
884
+ assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight);
885
+ ensureRenderableGeometryAttributes(splitResultGeometry);
886
+ return mergeWallTerrainFill(splitResultGeometry, terrainFill, wallNode, boundaryEdges, effectiveWallHeight);
545
887
  }
546
888
  /**
547
- * Collects cutout brushes from child items for CSG subtraction
548
- * The cutout mesh is a plane, so we extrude it into a box that goes through the wall
889
+ * Collects opening and item cutout brushes for CSG subtraction. Door/window
890
+ * cuts come directly from node geometry; item proxy meshes are transformed
891
+ * into wall-local boxes that pass through the wall.
549
892
  */
550
893
  function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
551
894
  const brushes = [];
@@ -558,15 +901,8 @@ function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
558
901
  for (const child of childrenNodes) {
559
902
  if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door')
560
903
  continue;
561
- if ((child.type === 'door' && child.openingKind === 'opening') ||
562
- (child.type === 'door' &&
563
- child.openingKind === 'door' &&
564
- (child.openingShape === 'arch' || child.openingShape === 'rounded')) ||
565
- (child.type === 'window' && child.openingKind === 'opening') ||
566
- (child.type === 'window' &&
567
- child.openingKind === 'window' &&
568
- (child.openingShape === 'arch' || child.openingShape === 'rounded'))) {
569
- brushes.push(createShapedOpeningCutoutBrush(child, wallThickness));
904
+ if (child.type === 'door' || child.type === 'window') {
905
+ brushes.push(createOpeningCutoutBrush(child, wallThickness));
570
906
  continue;
571
907
  }
572
908
  const childMesh = sceneRegistry.nodes.get(child.id);
@@ -609,132 +945,16 @@ function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
609
945
  }
610
946
  return brushes;
611
947
  }
612
- function createShapedOpeningCutoutBrush(opening, wallThickness) {
613
- const shape = createShapedOpeningCutoutShape(opening);
614
- const depth = wallThickness * 2;
615
- const bevelSize = opening.openingShape === 'rounded'
616
- ? Math.min(Math.max(opening.openingRevealRadius ?? 0.025, 0), Math.max(wallThickness * 0.45, 0.001), Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001))
617
- : 0;
618
- const geometry = new THREE.ExtrudeGeometry(shape, {
619
- depth,
620
- bevelEnabled: bevelSize > 0,
621
- bevelSegments: bevelSize > 0 ? 8 : 0,
622
- bevelSize,
623
- bevelThickness: bevelSize,
624
- curveSegments: 24,
625
- });
626
- geometry.translate(0, 0, -depth / 2);
627
- computeGeometryBoundsTree(geometry);
628
- return new Brush(geometry);
629
- }
630
- function createShapedOpeningCutoutShape(opening) {
948
+ function createOpeningCutoutBrush(opening, wallThickness) {
631
949
  const halfWidth = opening.width / 2;
632
950
  const bottom = opening.position[1] - opening.height / 2;
633
- const top = opening.position[1] + opening.height / 2;
634
- const centerX = opening.position[0];
635
- const left = centerX - halfWidth;
636
- const right = centerX + halfWidth;
637
- const width = Math.max(opening.width, 1e-6);
638
- const height = Math.max(opening.height, 1e-6);
639
- const shape = new THREE.Shape();
640
- if (opening.openingShape === 'arch') {
641
- const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height);
642
- const springY = top - archHeight;
643
- const segments = 32;
644
- shape.moveTo(left, bottom);
645
- shape.lineTo(right, bottom);
646
- shape.lineTo(right, springY);
647
- for (let index = 1; index <= segments; index += 1) {
648
- const x = right + (left - right) * (index / segments);
649
- const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1);
650
- const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0));
651
- shape.lineTo(x, y);
652
- }
653
- shape.lineTo(left, bottom);
654
- shape.closePath();
655
- return shape;
656
- }
657
- if (opening.openingShape === 'rounded') {
658
- const radii = getRoundedOpeningRadii(opening, width, height);
659
- applyRoundedOpeningShape(shape, left, right, bottom, top, radii);
660
- return shape;
661
- }
662
- shape.moveTo(left, bottom);
663
- shape.lineTo(right, bottom);
664
- shape.lineTo(right, top);
665
- shape.lineTo(left, top);
666
- shape.closePath();
667
- return shape;
668
- }
669
- function getRoundedOpeningRadii(opening, width, height) {
670
- if (opening.type !== 'window') {
671
- if (opening.openingRadiusMode === 'individual') {
672
- const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15];
673
- return normalizeCornerRadii({
674
- topLeft: Math.max(topLeft, 0),
675
- topRight: Math.max(topRight, 0),
676
- bottomRight: 0,
677
- bottomLeft: 0,
678
- }, width, height);
679
- }
680
- const maxRadius = Math.min(width / 2, height);
681
- const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius);
682
- return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 };
683
- }
684
- if (opening.openingRadiusMode === 'individual') {
685
- const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15];
686
- return normalizeCornerRadii({
687
- topLeft: Math.max(topLeft, 0),
688
- topRight: Math.max(topRight, 0),
689
- bottomRight: Math.max(bottomRight, 0),
690
- bottomLeft: Math.max(bottomLeft, 0),
691
- }, width, height);
692
- }
693
- const maxRadius = Math.min(width / 2, height / 2);
694
- const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius);
695
- return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius };
696
- }
697
- function normalizeCornerRadii(radii, width, height) {
698
- const next = { ...radii };
699
- const maxScale = Math.min(1, width / Math.max(next.topLeft + next.topRight, 1e-6), width / Math.max(next.bottomLeft + next.bottomRight, 1e-6), height / Math.max(next.topLeft + next.bottomLeft, 1e-6), height / Math.max(next.topRight + next.bottomRight, 1e-6));
700
- if (maxScale < 1) {
701
- next.topLeft *= maxScale;
702
- next.topRight *= maxScale;
703
- next.bottomRight *= maxScale;
704
- next.bottomLeft *= maxScale;
705
- }
706
- return next;
707
- }
708
- function applyRoundedOpeningShape(shape, left, right, bottom, top, radii) {
709
- const { topLeft, topRight, bottomRight, bottomLeft } = radii;
710
- shape.moveTo(left + bottomLeft, bottom);
711
- shape.lineTo(right - bottomRight, bottom);
712
- if (bottomRight > 1e-6) {
713
- shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false);
714
- }
715
- else {
716
- shape.lineTo(right, bottom);
717
- }
718
- shape.lineTo(right, top - topRight);
719
- if (topRight > 1e-6) {
720
- shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false);
721
- }
722
- else {
723
- shape.lineTo(right, top);
724
- }
725
- shape.lineTo(left + topLeft, top);
726
- if (topLeft > 1e-6) {
727
- shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false);
728
- }
729
- else {
730
- shape.lineTo(left, top);
731
- }
732
- shape.lineTo(left, bottom + bottomLeft);
733
- if (bottomLeft > 1e-6) {
734
- shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false);
735
- }
736
- else {
737
- shape.lineTo(left, bottom);
738
- }
739
- shape.closePath();
951
+ const bottomPadding = getOpeningCutoutBottomPadding(opening, bottom);
952
+ const geometry = buildOpeningCutoutGeometry(opening, {
953
+ left: opening.position[0] - halfWidth,
954
+ right: opening.position[0] + halfWidth,
955
+ bottom: bottom - bottomPadding,
956
+ top: opening.position[1] + opening.height / 2,
957
+ }, wallThickness * 2, wallThickness);
958
+ computeGeometryBoundsTree(geometry);
959
+ return new Brush(geometry);
740
960
  }