@pascal-app/viewer 1.0.3 → 1.0.5

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 (276) hide show
  1. package/dist/capture/capture-runtime.js +1 -1
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/renderers/node-renderer.d.ts +1 -1
  4. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  5. package/dist/components/renderers/node-renderer.js +12 -4
  6. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
  7. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -1
  8. package/dist/components/viewer/frame-limiter.d.ts +1 -0
  9. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  10. package/dist/components/viewer/frame-limiter.js +22 -3
  11. package/dist/components/viewer/glb-interactive.d.ts +25 -1
  12. package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
  13. package/dist/components/viewer/glb-interactive.js +253 -86
  14. package/dist/components/viewer/glb-mechanisms.d.ts +10 -0
  15. package/dist/components/viewer/glb-mechanisms.d.ts.map +1 -0
  16. package/dist/components/viewer/glb-mechanisms.js +19 -0
  17. package/dist/components/viewer/glb-reference-nodes.js +1 -1
  18. package/dist/components/viewer/glb-replace-instances.js +1 -1
  19. package/dist/components/viewer/glb-scene.d.ts +45 -27
  20. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  21. package/dist/components/viewer/glb-scene.js +421 -508
  22. package/dist/components/viewer/glb-walkthrough-controller.d.ts +1 -1
  23. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  24. package/dist/components/viewer/glb-walkthrough-controller.js +13 -2
  25. package/dist/components/viewer/index.d.ts +24 -1
  26. package/dist/components/viewer/index.d.ts.map +1 -1
  27. package/dist/components/viewer/index.js +111 -37
  28. package/dist/components/viewer/lights.d.ts.map +1 -1
  29. package/dist/components/viewer/lights.js +113 -22
  30. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  31. package/dist/components/viewer/perf-monitor.js +6 -0
  32. package/dist/components/viewer/perf-panel.d.ts.map +1 -1
  33. package/dist/components/viewer/perf-panel.js +1 -1
  34. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  35. package/dist/components/viewer/post-processing.js +104 -36
  36. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  37. package/dist/components/viewer/registered-systems.js +17 -3
  38. package/dist/components/viewer/render-error.d.ts +18 -0
  39. package/dist/components/viewer/render-error.d.ts.map +1 -0
  40. package/dist/components/viewer/render-error.js +29 -0
  41. package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
  42. package/dist/components/viewer/scene-bvh.js +1 -1
  43. package/dist/components/viewer/scene-environment.d.ts.map +1 -1
  44. package/dist/components/viewer/scene-environment.js +31 -11
  45. package/dist/components/viewer/scene-ground-replacement.d.ts +6 -0
  46. package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -1
  47. package/dist/components/viewer/scene-ground-replacement.js +15 -0
  48. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  49. package/dist/components/viewer/selection-manager.js +25 -7
  50. package/dist/components/viewer/underground-ambiance.d.ts +9 -0
  51. package/dist/components/viewer/underground-ambiance.d.ts.map +1 -0
  52. package/dist/components/viewer/underground-ambiance.js +105 -0
  53. package/dist/components/viewer/viewer-camera.d.ts +4 -0
  54. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  55. package/dist/components/viewer/viewer-camera.js +12 -2
  56. package/dist/components/viewer/viewer-presentations.d.ts.map +1 -1
  57. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  58. package/dist/hooks/use-gltf-ktx2.js +2 -0
  59. package/dist/hooks/use-node-events.d.ts.map +1 -1
  60. package/dist/index.d.ts +36 -16
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +34 -19
  63. package/dist/lib/asset-url.d.ts.map +1 -1
  64. package/dist/lib/asset-url.js +7 -1
  65. package/dist/lib/backdrop.d.ts +17 -0
  66. package/dist/lib/backdrop.d.ts.map +1 -1
  67. package/dist/lib/backdrop.js +18 -1
  68. package/dist/lib/construction-dust.d.ts +57 -0
  69. package/dist/lib/construction-dust.d.ts.map +1 -0
  70. package/dist/lib/construction-dust.js +187 -0
  71. package/dist/lib/csg-utils.js +1 -1
  72. package/dist/lib/display-state.d.ts +25 -0
  73. package/dist/lib/display-state.d.ts.map +1 -0
  74. package/dist/lib/display-state.js +1 -0
  75. package/dist/lib/foundation-materials.d.ts +26 -0
  76. package/dist/lib/foundation-materials.d.ts.map +1 -0
  77. package/dist/lib/foundation-materials.js +65 -0
  78. package/dist/lib/gltf-unlit.d.ts +10 -0
  79. package/dist/lib/gltf-unlit.d.ts.map +1 -0
  80. package/dist/lib/gltf-unlit.js +23 -0
  81. package/dist/lib/ground-window.d.ts +33 -0
  82. package/dist/lib/ground-window.d.ts.map +1 -0
  83. package/dist/lib/ground-window.js +114 -0
  84. package/dist/lib/hero-pose.d.ts +13 -3
  85. package/dist/lib/hero-pose.d.ts.map +1 -1
  86. package/dist/lib/hero-pose.js +72 -5
  87. package/dist/lib/ink-edges.d.ts +6 -3
  88. package/dist/lib/ink-edges.d.ts.map +1 -1
  89. package/dist/lib/ink-edges.js +17 -7
  90. package/dist/lib/key-light-override.d.ts +11 -0
  91. package/dist/lib/key-light-override.d.ts.map +1 -0
  92. package/dist/lib/key-light-override.js +14 -0
  93. package/dist/lib/ktx2-loader.d.ts +7 -0
  94. package/dist/lib/ktx2-loader.d.ts.map +1 -1
  95. package/dist/lib/ktx2-loader.js +35 -0
  96. package/dist/lib/layer-pass.d.ts.map +1 -1
  97. package/dist/lib/layers.d.ts +11 -7
  98. package/dist/lib/layers.d.ts.map +1 -1
  99. package/dist/lib/layers.js +16 -6
  100. package/dist/lib/live-frame-hold.d.ts +14 -0
  101. package/dist/lib/live-frame-hold.d.ts.map +1 -0
  102. package/dist/lib/live-frame-hold.js +28 -0
  103. package/dist/lib/materials.d.ts +19 -7
  104. package/dist/lib/materials.d.ts.map +1 -1
  105. package/dist/lib/materials.js +65 -9
  106. package/dist/lib/merged-outline-node.d.ts +5 -0
  107. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  108. package/dist/lib/merged-outline-node.js +80 -2
  109. package/dist/lib/node-top-surface-height.d.ts +6 -0
  110. package/dist/lib/node-top-surface-height.d.ts.map +1 -0
  111. package/dist/lib/node-top-surface-height.js +74 -0
  112. package/dist/lib/perf-probe-state.d.ts +39 -0
  113. package/dist/lib/perf-probe-state.d.ts.map +1 -0
  114. package/dist/lib/perf-probe-state.js +17 -0
  115. package/dist/lib/pointer-events.d.ts +26 -2
  116. package/dist/lib/pointer-events.d.ts.map +1 -1
  117. package/dist/lib/pointer-events.js +99 -12
  118. package/dist/lib/post-processing-resources.d.ts.map +1 -1
  119. package/dist/lib/procedural-emission.d.ts +7 -0
  120. package/dist/lib/procedural-emission.d.ts.map +1 -0
  121. package/dist/lib/procedural-emission.js +54 -0
  122. package/dist/lib/reveal-candidates.d.ts +80 -0
  123. package/dist/lib/reveal-candidates.d.ts.map +1 -0
  124. package/dist/lib/reveal-candidates.js +524 -0
  125. package/dist/lib/reveal-ghost.d.ts +9 -0
  126. package/dist/lib/reveal-ghost.d.ts.map +1 -0
  127. package/dist/lib/reveal-ghost.js +62 -0
  128. package/dist/lib/reveal-pending.d.ts +12 -0
  129. package/dist/lib/reveal-pending.d.ts.map +1 -0
  130. package/dist/lib/reveal-pending.js +12 -0
  131. package/dist/lib/reveal-pose.d.ts +70 -0
  132. package/dist/lib/reveal-pose.d.ts.map +1 -0
  133. package/dist/lib/reveal-pose.js +248 -0
  134. package/dist/lib/reveal-schedule.d.ts +208 -0
  135. package/dist/lib/reveal-schedule.d.ts.map +1 -0
  136. package/dist/lib/reveal-schedule.js +410 -0
  137. package/dist/lib/reveal-texture-scale.d.ts +5 -0
  138. package/dist/lib/reveal-texture-scale.d.ts.map +1 -0
  139. package/dist/lib/reveal-texture-scale.js +113 -0
  140. package/dist/lib/reveal-windows.d.ts +23 -0
  141. package/dist/lib/reveal-windows.d.ts.map +1 -0
  142. package/dist/lib/reveal-windows.js +31 -0
  143. package/dist/lib/reveal-wipe.d.ts +71 -0
  144. package/dist/lib/reveal-wipe.d.ts.map +1 -0
  145. package/dist/lib/reveal-wipe.js +105 -0
  146. package/dist/lib/scene-pass-mrt.d.ts +12 -0
  147. package/dist/lib/scene-pass-mrt.d.ts.map +1 -0
  148. package/dist/lib/scene-pass-mrt.js +23 -0
  149. package/dist/lib/scene-support-height.d.ts +4 -0
  150. package/dist/lib/scene-support-height.d.ts.map +1 -0
  151. package/dist/lib/scene-support-height.js +34 -0
  152. package/dist/lib/scene-themes.d.ts +30 -8
  153. package/dist/lib/scene-themes.d.ts.map +1 -1
  154. package/dist/lib/scene-themes.js +36 -0
  155. package/dist/lib/scene-visibility.d.ts +17 -1
  156. package/dist/lib/scene-visibility.d.ts.map +1 -1
  157. package/dist/lib/scene-visibility.js +53 -3
  158. package/dist/lib/scripted-opening.d.ts +11 -0
  159. package/dist/lib/scripted-opening.d.ts.map +1 -0
  160. package/dist/lib/scripted-opening.js +20 -0
  161. package/dist/lib/site-surfaces.d.ts +32 -0
  162. package/dist/lib/site-surfaces.d.ts.map +1 -0
  163. package/dist/lib/site-surfaces.js +70 -0
  164. package/dist/lib/snapshot-pipeline.d.ts +34 -3
  165. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  166. package/dist/lib/snapshot-pipeline.js +247 -117
  167. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -1
  168. package/dist/lib/terrain-perimeter-fill.js +22 -14
  169. package/dist/lib/tsl-compat.d.ts +2 -2
  170. package/dist/lib/tsl-compat.d.ts.map +1 -1
  171. package/dist/lib/underground-ambiance.d.ts +168 -0
  172. package/dist/lib/underground-ambiance.d.ts.map +1 -0
  173. package/dist/lib/underground-ambiance.js +334 -0
  174. package/dist/lib/zone-geometry.d.ts +11 -0
  175. package/dist/lib/zone-geometry.d.ts.map +1 -0
  176. package/dist/lib/zone-geometry.js +46 -0
  177. package/dist/store/use-item-light-pool.d.ts +14 -10
  178. package/dist/store/use-item-light-pool.d.ts.map +1 -1
  179. package/dist/store/use-item-light-pool.js +52 -26
  180. package/dist/store/use-viewer.d.ts +194 -26
  181. package/dist/store/use-viewer.d.ts.map +1 -1
  182. package/dist/store/use-viewer.js +164 -21
  183. package/dist/systems/ceiling/ceiling-system.d.ts +15 -1
  184. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  185. package/dist/systems/ceiling/ceiling-system.js +88 -6
  186. package/dist/systems/construction-reveal/construction-dust.d.ts +16 -0
  187. package/dist/systems/construction-reveal/construction-dust.d.ts.map +1 -0
  188. package/dist/systems/construction-reveal/construction-dust.js +92 -0
  189. package/dist/systems/construction-reveal/construction-reveal.d.ts +276 -0
  190. package/dist/systems/construction-reveal/construction-reveal.d.ts.map +1 -0
  191. package/dist/systems/construction-reveal/construction-reveal.js +2383 -0
  192. package/dist/systems/construction-reveal/dust-material.d.ts +10 -0
  193. package/dist/systems/construction-reveal/dust-material.d.ts.map +1 -0
  194. package/dist/systems/construction-reveal/dust-material.js +17 -0
  195. package/dist/systems/construction-reveal/ghost-dress.d.ts +10 -0
  196. package/dist/systems/construction-reveal/ghost-dress.d.ts.map +1 -0
  197. package/dist/systems/construction-reveal/ghost-dress.js +24 -0
  198. package/dist/systems/door/door-system.d.ts.map +1 -1
  199. package/dist/systems/door/door-system.js +55 -17
  200. package/dist/systems/elevator/elevator-interaction-system.d.ts +7 -0
  201. package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -1
  202. package/dist/systems/elevator/elevator-interaction-system.js +18 -1
  203. package/dist/systems/floor-elevation/floor-elevation-system.d.ts +3 -1
  204. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  205. package/dist/systems/floor-elevation/floor-elevation-system.js +33 -5
  206. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  207. package/dist/systems/geometry/geometry-system.js +71 -11
  208. package/dist/systems/interactive/control-widget.d.ts.map +1 -1
  209. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  210. package/dist/systems/interactive/interactive-system.js +52 -17
  211. package/dist/systems/interactive/procedural-controls.d.ts +13 -0
  212. package/dist/systems/interactive/procedural-controls.d.ts.map +1 -0
  213. package/dist/systems/interactive/procedural-controls.js +17 -0
  214. package/dist/systems/interactive/scripted-clips.d.ts +19 -0
  215. package/dist/systems/interactive/scripted-clips.d.ts.map +1 -0
  216. package/dist/systems/interactive/scripted-clips.js +134 -0
  217. package/dist/systems/item/item-system.d.ts.map +1 -1
  218. package/dist/systems/item/item-system.js +9 -3
  219. package/dist/systems/item/object-cut-invalidation.d.ts +3 -0
  220. package/dist/systems/item/object-cut-invalidation.d.ts.map +1 -0
  221. package/dist/systems/item/object-cut-invalidation.js +68 -0
  222. package/dist/systems/item-light/item-light-system.d.ts +1 -1
  223. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  224. package/dist/systems/item-light/item-light-system.js +48 -53
  225. package/dist/systems/level/level-hold.d.ts +4 -0
  226. package/dist/systems/level/level-hold.d.ts.map +1 -0
  227. package/dist/systems/level/level-hold.js +14 -0
  228. package/dist/systems/level/level-system.d.ts.map +1 -1
  229. package/dist/systems/level/level-system.js +19 -6
  230. package/dist/systems/level/level-utils.d.ts +19 -6
  231. package/dist/systems/level/level-utils.d.ts.map +1 -1
  232. package/dist/systems/level/level-utils.js +68 -9
  233. package/dist/systems/level/shadow-only-levels.d.ts +9 -0
  234. package/dist/systems/level/shadow-only-levels.d.ts.map +1 -0
  235. package/dist/systems/level/shadow-only-levels.js +7 -0
  236. package/dist/systems/roof/__fixtures__/roof-plane-cases.d.ts +103 -0
  237. package/dist/systems/roof/__fixtures__/roof-plane-cases.d.ts.map +1 -0
  238. package/dist/systems/roof/__fixtures__/roof-plane-cases.js +317 -0
  239. package/dist/systems/roof/roof-materials.d.ts +16 -2
  240. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  241. package/dist/systems/roof/roof-materials.js +64 -31
  242. package/dist/systems/roof/roof-system.d.ts +21 -4
  243. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  244. package/dist/systems/roof/roof-system.js +374 -49
  245. package/dist/systems/slab/slab-system.d.ts +1 -1
  246. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  247. package/dist/systems/slab/slab-system.js +49 -19
  248. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  249. package/dist/systems/stair/stair-system.js +133 -354
  250. package/dist/systems/wall/wall-cutout-cache.d.ts +39 -3
  251. package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
  252. package/dist/systems/wall/wall-cutout-cache.js +141 -19
  253. package/dist/systems/wall/wall-cutout.d.ts +6 -0
  254. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  255. package/dist/systems/wall/wall-cutout.js +42 -28
  256. package/dist/systems/wall/wall-finish-data.d.ts +22 -0
  257. package/dist/systems/wall/wall-finish-data.d.ts.map +1 -0
  258. package/dist/systems/wall/wall-finish-data.js +17 -0
  259. package/dist/systems/wall/wall-material-variant.d.ts.map +1 -1
  260. package/dist/systems/wall/wall-materials.d.ts +9 -4
  261. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  262. package/dist/systems/wall/wall-materials.js +135 -153
  263. package/dist/systems/wall/wall-placeholder-sweep.d.ts.map +1 -1
  264. package/dist/systems/wall/wall-system.d.ts +33 -2
  265. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  266. package/dist/systems/wall/wall-system.js +621 -159
  267. package/dist/systems/window/corner-frame.d.ts +10 -0
  268. package/dist/systems/window/corner-frame.d.ts.map +1 -0
  269. package/dist/systems/window/corner-frame.js +17 -0
  270. package/dist/systems/window/window-system.d.ts +1 -1
  271. package/dist/systems/window/window-system.d.ts.map +1 -1
  272. package/dist/systems/window/window-system.js +71 -27
  273. package/package.json +10 -10
  274. package/dist/systems/fence/fence-system.d.ts +0 -22
  275. package/dist/systems/fence/fence-system.d.ts.map +0 -1
  276. package/dist/systems/fence/fence-system.js +0 -357
@@ -1,17 +1,20 @@
1
- import { 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';
1
+ import { buildWallFinishLayout, containsPoint, cornerPair, DEFAULT_LEVEL_HEIGHT, getAdjacentWallIds, getEffectiveNode, getOpeningWallCut, getWallBodyCenterOffset, getWallCurveFrameAt, getWallCurveLength, getWallFaceOffsets, getWallLevelZones, getWallMiterBoundaryPoints, getWallPlaneTop, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, isCutterName, pointToKey, resolveCutterHost, resolveLevelId, resolveWallFaceBottom, resolveWallFinish, resolveWallTop, sceneRegistry, spatialGridManager, terrainSupportLift, useLiveNodeOverrides, useLiveTransforms, useScene, WALL_SURFACE_SLOT_DEFAULTS, wallFinishMaterialIndex, zoneHasWallFinish, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
3
  import { useEffect } from 'react';
4
4
  import * as THREE from 'three';
5
5
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
6
- import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
6
+ import { ADDITION, Brush, Evaluator, INTERSECTION, SUBTRACTION } from 'three-bvh-csg';
7
7
  import { computeBoundsTree } from 'three-mesh-bvh';
8
8
  import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils';
9
9
  import { setGroupsSortedByMaterial } from '../../lib/geometry-groups';
10
10
  import { timeSpan } from '../../lib/perf-tracks';
11
+ import { isRevealWaiting } from '../../lib/reveal-pending';
11
12
  import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill';
13
+ import { cornerFrameStale } from '../window/corner-frame';
12
14
  import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache';
13
15
  import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, } from './opening-cutout-geometry';
14
16
  import { drainStats, endInitialBuild, initiallyBuiltWalls, isWallInitialBuildActive, pendingAdjacentByLevel, publishWallDrainStats, } from './wall-build-lifecycle';
17
+ import { getWallFaceBaseAt, } from './wall-finish-data';
15
18
  import { sweepUnbuiltWalls, WALL_PLACEHOLDER_SWEEP_INTERVAL } from './wall-placeholder-sweep';
16
19
  import { notifyWallRebuilt } from './wall-rebuild-notifications';
17
20
  export { isWallInitialBuildActive } from './wall-build-lifecycle';
@@ -23,28 +26,10 @@ const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015;
23
26
  const WALL_FACE_NORMAL_Y_EPSILON = 0.6;
24
27
  const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003;
25
28
  const WALL_BAND_SPLIT_EPSILON = 1e-5;
26
- const WALL_BAND_SLOT_MATERIAL_INDEX = {
27
- interior: 1,
28
- exterior: 2,
29
- lowerInterior: 3,
30
- middleInterior: 4,
31
- upperInterior: 5,
32
- topInterior: 6,
33
- lowerExterior: 7,
34
- middleExterior: 8,
35
- upperExterior: 9,
36
- topExterior: 10,
37
- skirtingInterior: 0,
38
- skirtingExterior: 0,
39
- crownInterior: 0,
40
- crownExterior: 0,
41
- chairRailInterior: 0,
42
- chairRailExterior: 0,
43
- };
44
29
  function computeGeometryBoundsTree(geometry) {
45
30
  ;
46
31
  geometry.computeBoundsTree = computeBoundsTree;
47
- geometry.computeBoundsTree({ maxLeafSize: 10 });
32
+ geometry.computeBoundsTree({ targetLeafSize: 10 });
48
33
  }
49
34
  function csgGeometry(brush) {
50
35
  return brush.geometry;
@@ -263,17 +248,17 @@ function distanceToWallBoundaryEdge(point, edge) {
263
248
  const closestZ = edge.start.y + edgeDz * t;
264
249
  return Math.hypot(point.x - closestX, point.y - closestZ);
265
250
  }
266
- function getWallFaceMaterialIndex(wall, face, y, effectiveWallHeight) {
267
- const semantic = face === 'front' ? wall.frontSide : wall.backSide;
268
- const fallback = face === 'front' ? 'interior' : 'exterior';
269
- const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback;
270
- const bands = getWallFaceBandConfig(wall, effectiveWallHeight);
271
- if (!bands.enabled)
272
- return WALL_BAND_SLOT_MATERIAL_INDEX[side];
273
- const band = getWallFaceBandForHeight(wall, y, effectiveWallHeight);
274
- return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)];
251
+ function getWallFaceMaterialIndex(context, face, x, y) {
252
+ const { layout, foundation } = context;
253
+ // The underpinning's stemwall: everything under the rim depth is concrete.
254
+ if (foundation && y < foundation.top + WALL_BAND_SPLIT_EPSILON)
255
+ return foundation.index;
256
+ if (layout.plain)
257
+ return face === 'a' ? 1 : 2;
258
+ const hit = resolveWallFinish(layout, face, x, y - getWallFaceBaseAt(context, face, x));
259
+ return wallFinishMaterialIndex(layout, face, hit);
275
260
  }
276
- function assignWallMaterialGroups(geometry, wall, boundaryEdges, effectiveWallHeight) {
261
+ function assignWallMaterialGroups(geometry, wall, boundaryEdges, finish) {
277
262
  const position = geometry.getAttribute('position');
278
263
  if (!position)
279
264
  return;
@@ -336,7 +321,7 @@ function assignWallMaterialGroups(geometry, wall, boundaryEdges, effectiveWallHe
336
321
  triangleMaterials[triangleIndex] = 0;
337
322
  continue;
338
323
  }
339
- triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y, effectiveWallHeight);
324
+ triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(finish, nearestTag === 'front' ? 'a' : 'b', centroid.x, centroid.y);
340
325
  }
341
326
  setGroupsSortedByMaterial(geometry, triangleMaterials);
342
327
  }
@@ -347,22 +332,22 @@ function interpolateSplitVertex(a, b, t) {
347
332
  z: a.z + (b.z - a.z) * t,
348
333
  };
349
334
  }
350
- function clipPolygonByY(polygon, planeY, keepBelow) {
335
+ function clipPolygonByPlane(polygon, axis, plane, keepBelow) {
351
336
  const out = [];
352
337
  if (polygon.length === 0)
353
338
  return out;
354
339
  const isInside = (vertex) => keepBelow
355
- ? vertex.y <= planeY + WALL_BAND_SPLIT_EPSILON
356
- : vertex.y >= planeY - WALL_BAND_SPLIT_EPSILON;
340
+ ? vertex[axis] <= plane + WALL_BAND_SPLIT_EPSILON
341
+ : vertex[axis] >= plane - WALL_BAND_SPLIT_EPSILON;
357
342
  for (let index = 0; index < polygon.length; index += 1) {
358
343
  const current = polygon[index];
359
344
  const previous = polygon[(index + polygon.length - 1) % polygon.length];
360
345
  const currentInside = isInside(current);
361
346
  const previousInside = isInside(previous);
362
347
  if (currentInside !== previousInside) {
363
- const denom = current.y - previous.y;
348
+ const denom = current[axis] - previous[axis];
364
349
  if (Math.abs(denom) > WALL_BAND_SPLIT_EPSILON) {
365
- out.push(interpolateSplitVertex(previous, current, (planeY - previous.y) / denom));
350
+ out.push(interpolateSplitVertex(previous, current, (plane - previous[axis]) / denom));
366
351
  }
367
352
  }
368
353
  if (currentInside)
@@ -380,11 +365,22 @@ function triangulateSplitPolygon(polygon, positions) {
380
365
  positions.push(first.x, first.y, first.z, b.x, b.y, b.z, c.x, c.y, c.z);
381
366
  }
382
367
  }
383
- function splitGeometryAtHorizontalPlanes(geometry, planes) {
384
- const splitPlanes = Array.from(new Set(planes
385
- .filter((plane) => Number.isFinite(plane) && plane > WALL_BAND_SPLIT_EPSILON)
368
+ function normalizeSplitPlanes(planes) {
369
+ return Array.from(new Set(planes
370
+ .filter((plane) => Number.isFinite(plane))
386
371
  .map((plane) => Math.round(plane / WALL_BAND_SPLIT_EPSILON) * WALL_BAND_SPLIT_EPSILON))).sort((a, b) => a - b);
387
- if (splitPlanes.length === 0)
372
+ }
373
+ /**
374
+ * Cuts triangles at the vertical planes `x = const` (wall-local stations) and
375
+ * horizontal planes `y = const`, so each piece lies in one finish cell. With no
376
+ * planes the geometry is returned untouched — plain walls never pay for this.
377
+ */
378
+ function splitGeometryAtPlanes(geometry, yPlanes, xPlanes = []) {
379
+ const cuts = [
380
+ ...normalizeSplitPlanes(xPlanes).map((plane) => ({ axis: 'x', plane })),
381
+ ...normalizeSplitPlanes(yPlanes).map((plane) => ({ axis: 'y', plane })),
382
+ ];
383
+ if (cuts.length === 0)
388
384
  return geometry;
389
385
  const source = geometry.index ? geometry.toNonIndexed() : geometry;
390
386
  const position = source.getAttribute('position');
@@ -399,17 +395,17 @@ function splitGeometryAtHorizontalPlanes(geometry, planes) {
399
395
  { x: position.getX(index + 2), y: position.getY(index + 2), z: position.getZ(index + 2) },
400
396
  ],
401
397
  ];
402
- for (const plane of splitPlanes) {
398
+ for (const { axis, plane } of cuts) {
403
399
  const next = [];
404
400
  for (const polygon of polygons) {
405
- const minY = Math.min(...polygon.map((vertex) => vertex.y));
406
- const maxY = Math.max(...polygon.map((vertex) => vertex.y));
407
- if (plane <= minY + WALL_BAND_SPLIT_EPSILON || plane >= maxY - WALL_BAND_SPLIT_EPSILON) {
401
+ const min = Math.min(...polygon.map((vertex) => vertex[axis]));
402
+ const max = Math.max(...polygon.map((vertex) => vertex[axis]));
403
+ if (plane <= min + WALL_BAND_SPLIT_EPSILON || plane >= max - WALL_BAND_SPLIT_EPSILON) {
408
404
  next.push(polygon);
409
405
  continue;
410
406
  }
411
- const below = clipPolygonByY(polygon, plane, true);
412
- const above = clipPolygonByY(polygon, plane, false);
407
+ const below = clipPolygonByPlane(polygon, axis, plane, true);
408
+ const above = clipPolygonByPlane(polygon, axis, plane, false);
413
409
  if (below.length >= 3)
414
410
  next.push(below);
415
411
  if (above.length >= 3)
@@ -428,16 +424,26 @@ function splitGeometryAtHorizontalPlanes(geometry, planes) {
428
424
  split.computeVertexNormals();
429
425
  return split;
430
426
  }
431
- function getWallBandSplitPlanes(wall, effectiveWallHeight) {
432
- const bands = getWallFaceBandConfig(wall, effectiveWallHeight);
433
- if (!bands.enabled)
434
- return [];
435
- const planes = [bands.lowerTop];
436
- if (bands.count >= 3)
437
- planes.push(bands.middleTop);
438
- if (bands.count >= 4)
439
- planes.push(bands.upperTop);
440
- return planes.filter((plane) => plane > WALL_BAND_SPLIT_EPSILON && plane < effectiveWallHeight - WALL_BAND_SPLIT_EPSILON);
427
+ /** Split planes a finish layout needs, in wall-local coordinates. */
428
+ function getWallFinishSplitPlanes(finish) {
429
+ const { layout, faceBase } = finish;
430
+ if (layout.plain)
431
+ return { x: [], y: [] };
432
+ const x = [...layout.uSplits];
433
+ const bases = new Set([0]);
434
+ if (faceBase && layout.vSplits.length > 0) {
435
+ bases.clear();
436
+ for (const run of [...faceBase.a, ...faceBase.b]) {
437
+ bases.add(run.y);
438
+ // A face base that steps moves every height with it, so cut at the step too.
439
+ if (run.start > 0)
440
+ x.push(run.start);
441
+ if (run.end < layout.length)
442
+ x.push(run.end);
443
+ }
444
+ }
445
+ const y = [...bases].flatMap((base) => layout.vSplits.map((v) => base + v));
446
+ return { x, y };
441
447
  }
442
448
  // ============================================================================
443
449
  // WALL SYSTEM
@@ -477,10 +483,16 @@ function wallRebuildExitReason(wallId, nodes, rebuiltThisFrame, elapsedMs, initi
477
483
  let cutouts = 0;
478
484
  for (const childId of getEffectiveWall(wall).children ?? []) {
479
485
  const child = nodes[childId];
480
- if (child?.type === 'door' ||
481
- child?.type === 'window' ||
482
- (child?.type === 'item' &&
483
- sceneRegistry.nodes.get(childId)?.getObjectByName('cutout')?.geometry?.getAttribute('position')?.count)) {
486
+ let hasCutter = false;
487
+ if (child?.type === 'item') {
488
+ sceneRegistry.nodes.get(childId)?.traverse((object) => {
489
+ const mesh = object;
490
+ const name = typeof mesh.userData.name === 'string' ? mesh.userData.name : mesh.name;
491
+ if (mesh.isMesh && isCutterName(name) && mesh.geometry?.getAttribute('position')?.count)
492
+ hasCutter = true;
493
+ });
494
+ }
495
+ if (child?.type === 'door' || child?.type === 'window' || hasCutter) {
484
496
  cutouts++;
485
497
  if (cutouts >= HEAVY_WALL_OPENINGS)
486
498
  return 'heavy';
@@ -499,7 +511,13 @@ let placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL;
499
511
  export const WallSystem = ({ geometryAdapter } = {}) => {
500
512
  useScene((state) => state.dirtyNodes);
501
513
  useLiveNodeOverrides((s) => s.overrides);
502
- useEffect(() => () => clearLevelMiterCache(), []);
514
+ useEffect(() => () => {
515
+ clearLevelMiterCache();
516
+ zoneFinishRecords.clear();
517
+ zoneFinishNodes = null;
518
+ levelZones.clear();
519
+ levelZonesNodes = null;
520
+ }, []);
503
521
  useFrame(() => runWallBuildFrame(geometryAdapter), 4);
504
522
  return null;
505
523
  };
@@ -518,7 +536,94 @@ export function runWallBuildFrame(geometryAdapter) {
518
536
  publishWallDrainStats();
519
537
  }
520
538
  }
539
+ // Zones per level for the current scene snapshot: a floor's walls rebuild
540
+ // together, and each one would otherwise rescan the level's children.
541
+ const levelZones = new Map();
542
+ let levelZonesNodes = null;
543
+ function getCachedLevelZones(wall, nodes) {
544
+ if (nodes !== levelZonesNodes) {
545
+ levelZones.clear();
546
+ levelZonesNodes = nodes;
547
+ }
548
+ const key = wall.parentId ?? '';
549
+ let zones = levelZones.get(key);
550
+ if (!zones) {
551
+ zones = getWallLevelZones(wall, nodes);
552
+ levelZones.set(key, zones);
553
+ }
554
+ return zones;
555
+ }
556
+ const zoneFinishRecords = new Map();
557
+ let zoneFinishNodes = null;
558
+ function zoneFinishSignature(zone) {
559
+ const painted = zoneHasWallFinish(zone);
560
+ if (!painted && zone.floor?.elevation === undefined)
561
+ return null;
562
+ return JSON.stringify([
563
+ zone.parentId,
564
+ zone.floor?.elevation,
565
+ zone.wallMaterial,
566
+ zone.wallOverrides,
567
+ // A floor-only room reshape does not change the datum of every boundary wall.
568
+ // Endpoint edits already invalidate their walls and queue adjacent miters.
569
+ painted ? zone.polygon : undefined,
570
+ painted ? zone.holes : undefined,
571
+ zone.boundaryWallIds,
572
+ ]);
573
+ }
574
+ /**
575
+ * A room's wall finish lives on its zone, so a zone edit must rebuild the walls
576
+ * bounding it — the old boundary and the new one. Zones without a wall finish
577
+ * never touch their walls, so ordinary room edits cost nothing here.
578
+ */
579
+ export function markWallsForZoneFinishChanges() {
580
+ const state = useScene.getState();
581
+ const nodes = state.nodes;
582
+ if (nodes === zoneFinishNodes)
583
+ return;
584
+ zoneFinishNodes = nodes;
585
+ const walls = new Set();
586
+ const seen = new Set();
587
+ const collect = (zone) => {
588
+ for (const id of zone.boundaryWallIds ?? [])
589
+ walls.add(id);
590
+ for (const entry of zone.wallOverrides ?? [])
591
+ walls.add(entry.wallId);
592
+ };
593
+ for (const id in nodes) {
594
+ const node = nodes[id];
595
+ if (node?.type !== 'zone')
596
+ continue;
597
+ seen.add(id);
598
+ const previous = zoneFinishRecords.get(id);
599
+ if (previous?.zone === node)
600
+ continue;
601
+ const signature = zoneFinishSignature(node);
602
+ if (signature !== (previous?.signature ?? null)) {
603
+ if (previous)
604
+ collect(previous.zone);
605
+ collect(node);
606
+ }
607
+ zoneFinishRecords.set(id, { zone: node, signature });
608
+ }
609
+ for (const [id, record] of zoneFinishRecords) {
610
+ if (seen.has(id))
611
+ continue;
612
+ if (record.signature !== null)
613
+ collect(record.zone);
614
+ zoneFinishRecords.delete(id);
615
+ }
616
+ for (const id of walls) {
617
+ const wall = nodes[id];
618
+ if (wall?.type === 'wall') {
619
+ state.markDirty(wall.id);
620
+ for (const child of wall.children)
621
+ state.markDirty(child);
622
+ }
623
+ }
624
+ }
521
625
  function consumeWallBuildFrame(initialBuild, geometryAdapter) {
626
+ markWallsForZoneFinishChanges();
522
627
  const clearDirty = useScene.getState().clearDirty;
523
628
  // Self-heal: any registered wall still on its mount-time placeholder
524
629
  // geometry with NO dirty mark gets re-marked, so a lost mark (system
@@ -539,7 +644,7 @@ function consumeWallBuildFrame(initialBuild, geometryAdapter) {
539
644
  const dirtyNodes = useScene.getState().dirtyNodes;
540
645
  const hasDirty = dirtyNodes.size > 0;
541
646
  const hasPending = pendingAdjacentByLevel.size > 0;
542
- if (!hasDirty && !hasPending) {
647
+ if (!(hasDirty || hasPending)) {
543
648
  endInitialBuild();
544
649
  return;
545
650
  }
@@ -613,7 +718,7 @@ function consumeWallBuildFrame(initialBuild, geometryAdapter) {
613
718
  else {
614
719
  drainStats.reinvalidationBuilds++;
615
720
  }
616
- if (!initialBuild || !firstBuild)
721
+ if (!(initialBuild && firstBuild))
617
722
  rebuiltWallIds.add(wallId);
618
723
  rebuiltWallsThisFrame += 1;
619
724
  drainStats.wallsConsumedThisFrame++;
@@ -641,7 +746,7 @@ function consumeWallBuildFrame(initialBuild, geometryAdapter) {
641
746
  pendingAdjacentByLevel.set(levelId, pending);
642
747
  }
643
748
  for (const wallId of adjacentWallIds) {
644
- if (!dirtyWallIds.has(wallId) && !pending.has(wallId)) {
749
+ if (!(dirtyWallIds.has(wallId) || pending.has(wallId))) {
645
750
  pending.add(wallId);
646
751
  drainStats.pendingNeighbours++;
647
752
  drainStats.neighbourEnqueues++;
@@ -777,24 +882,11 @@ function updateWallGeometry(wallId, miterData, geometryAdapter) {
777
882
  return;
778
883
  const levelId = resolveLevelId(node, nodes);
779
884
  // Covering-clamped plane: a flush/thick slab on the level above shortens
780
- // the plane-bound walls below it (explicit-height walls ignore the value).
885
+ // the plane-bound walls below it.
781
886
  const planeTop = getWallPlaneTop(node, levelId, nodes);
782
- const slabSupport = spatialGridManager.getSlabSupportForWall(levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, node.supportSlabId, undefined, node.supportOffset);
887
+ const slabSupport = spatialGridManager.getSlabSupportForWall(levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, node.supportSlabId, undefined, node.supportOffset, node.justification);
783
888
  const slabElevation = slabSupport.elevation;
784
- const terrainBottomAt = node.fillToTerrain
785
- ? (x, z) => terrainSupportLift(nodes, levelId, x, z)
786
- : undefined;
787
- const childrenIds = node.children || [];
788
- const childrenNodes = childrenIds
789
- .map((childId) => nodes[childId])
790
- .filter((n) => n !== undefined)
791
- .map((child) => {
792
- if (child.type !== 'door' && child.type !== 'window')
793
- return child;
794
- const effective = getEffectiveNode(child);
795
- const live = useLiveTransforms.getState().get(child.id);
796
- return live?.position ? { ...effective, position: live.position } : effective;
797
- });
889
+ const childrenNodes = wallGeometryChildren(node, nodes);
798
890
  const prepared = geometryAdapter?.prepareChildren?.(node, childrenNodes, {
799
891
  isLive: (id) => useLiveNodeOverrides.getState().get(id) !== undefined ||
800
892
  useLiveTransforms.getState().get(id) !== undefined,
@@ -802,7 +894,20 @@ function updateWallGeometry(wallId, miterData, geometryAdapter) {
802
894
  envelopeChildren: childrenNodes,
803
895
  renderChildren: childrenNodes,
804
896
  };
805
- const builtGeo = generateExtrudedWall(node, prepared.envelopeChildren, miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt);
897
+ const fillMasks = node.fillToTerrain
898
+ ? [
899
+ ...Object.values(nodes).flatMap((slab) => slab.type === 'slab' && slab.parentId === levelId && slab.support !== 'open'
900
+ ? [{ outer: slab.polygon, holes: slab.holes }]
901
+ : []),
902
+ ...childrenNodes.flatMap((child) => child.type === 'door' || child.type === 'window'
903
+ ? getOpeningWallCut(node, child, nodes, slabSupport).aperture
904
+ : []),
905
+ ]
906
+ : [];
907
+ const terrainBottomAt = node.fillToTerrain
908
+ ? (x, z) => containsPoint(fillMasks, [x, z]) ? slabElevation : terrainSupportLift(nodes, levelId, x, z)
909
+ : undefined;
910
+ const builtGeo = generateExtrudedWall(node, prepared.envelopeChildren, miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt, slabSupport.faceDatum, buildWallFinishLayout(node, getCachedLevelZones(node, nodes)));
806
911
  const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
807
912
  // World transform the render mesh will apply (position + Y-rotation below).
808
913
  // Reproduce it here so the UVs can be projected in WORLD space — see
@@ -819,13 +924,43 @@ function updateWallGeometry(wallId, miterData, geometryAdapter) {
819
924
  // Update collision mesh
820
925
  const collisionMesh = mesh.getObjectByName('collision-mesh');
821
926
  if (collisionMesh) {
822
- const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt);
927
+ const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt, slabSupport.faceDatum);
823
928
  collisionMesh.geometry.dispose();
824
929
  collisionMesh.geometry = collisionGeo;
825
930
  }
826
931
  mesh.position.set(node.start[0], slabElevation, node.start[1]);
827
932
  const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
828
933
  mesh.rotation.y = -angle;
934
+ const offsets = getWallFaceOffsets(node);
935
+ const frameKey = `${offsets.a}:${offsets.b}`;
936
+ // Child systems otherwise only see their own edits. Invalidate on a frame
937
+ // change once, avoiding a wall → opening → wall rebuild loop.
938
+ if (mesh.userData.wallFrameKey !== undefined && mesh.userData.wallFrameKey !== frameKey) {
939
+ for (const child of childrenNodes)
940
+ useScene.getState().markDirty(child.id);
941
+ }
942
+ mesh.userData.wallFrameKey = frameKey;
943
+ // A wall moved apart from the other wall of a corner window, or back, makes or breaks the pair:
944
+ // its windows rebuild their frames once (they read the pair only then).
945
+ for (const child of childrenNodes)
946
+ if (cornerFrameStale(child, nodes))
947
+ useScene.getState().markDirty(child.id);
948
+ }
949
+ /**
950
+ * The children a wall builds around, as they stand live: an opening still waiting for its
951
+ * construction reveal is left out, so the wall rises whole and is cut at the opening's turn.
952
+ */
953
+ export function wallGeometryChildren(node, nodes) {
954
+ return (node.children ?? [])
955
+ .map((childId) => nodes[childId])
956
+ .filter((child) => child !== undefined && !isRevealWaiting(child.id))
957
+ .map((child) => {
958
+ if (child.type !== 'door' && child.type !== 'window')
959
+ return child;
960
+ const effective = getEffectiveNode(child);
961
+ const live = useLiveTransforms.getState().get(child.id);
962
+ return live?.position ? { ...effective, position: live.position } : effective;
963
+ });
829
964
  }
830
965
  const WALL_UV_Y_AXIS = new THREE.Vector3(0, 1, 0);
831
966
  const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
@@ -842,8 +977,10 @@ const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
842
977
  */
843
978
  function applyWorldPlanarWallUVs(geometry, worldMatrix) {
844
979
  const target = geometry.index ? geometry.toNonIndexed() : geometry;
845
- if (target !== geometry)
980
+ if (target !== geometry) {
981
+ target.userData = geometry.userData;
846
982
  geometry.dispose();
983
+ }
847
984
  const position = target.getAttribute('position');
848
985
  if (!position || position.count === 0)
849
986
  return target;
@@ -919,34 +1056,183 @@ function buildWallTerrainFillGeometry(perimeter, worldToLocal, wallBaseElevation
919
1056
  });
920
1057
  return buildTerrainPerimeterFillGeometry(localPoints, bottomY, 0);
921
1058
  }
922
- function mergeWallTerrainFill(body, fill, wall, boundaryEdges, effectiveWallHeight) {
923
- if (!fill)
1059
+ /**
1060
+ * The underpinning under a wall (`WallNode.underpinning`): a skirt of the
1061
+ * wall's own faces `rim` deep below the base, then the stemwall skirt from
1062
+ * there down `stem` more — or to the terrain wherever that is lower when
1063
+ * the wall also fills to terrain. Two fills, split at the rim depth, so the
1064
+ * material groups can paint the stem concrete and the rim in the finish.
1065
+ */
1066
+ function buildWallUnderpinningGeometry(perimeter, worldToLocal, wallBaseElevation, wall, terrainBottomAt, curved = false) {
1067
+ const underpinning = wall.underpinning;
1068
+ if (!underpinning)
1069
+ return [];
1070
+ const worldPoints = densifyClosedWallPerimeter(perimeter);
1071
+ if (worldPoints.length < 3)
1072
+ return [];
1073
+ const localPoints = worldPoints.map(worldToLocal);
1074
+ const rimBottom = -underpinning.rim;
1075
+ const fills = [];
1076
+ if (underpinning.rim > 1e-6) {
1077
+ const rim = buildTerrainPerimeterFillGeometry(localPoints, worldPoints.map(() => rimBottom), 0);
1078
+ if (rim)
1079
+ fills.push(rim);
1080
+ }
1081
+ const stemBottom = worldPoints.map((point) => {
1082
+ let y = rimBottom - underpinning.stem;
1083
+ if (terrainBottomAt) {
1084
+ const terrainElevation = terrainBottomAt(point.x, point.y);
1085
+ if (terrainElevation != null)
1086
+ y = Math.min(y, terrainElevation - wallBaseElevation);
1087
+ }
1088
+ return y;
1089
+ });
1090
+ if (stemBottom.some((y) => y < rimBottom - 1e-6)) {
1091
+ const stem = buildTerrainPerimeterFillGeometry(localPoints, stemBottom, rimBottom);
1092
+ if (stem) {
1093
+ fills.push(underpinning.openings?.length && !curved
1094
+ ? cutUnderpinningOpenings(stem, localPoints, rimBottom, underpinning.openings)
1095
+ : stem);
1096
+ }
1097
+ }
1098
+ return fills;
1099
+ }
1100
+ /**
1101
+ * The stem skirt with its openings (`WallUnderpinning.openings`) cut through
1102
+ * it: the skirt is closed with a cap at its top so it is a solid, then each
1103
+ * opening's box is subtracted across the full thickness. The cap lands on
1104
+ * the rim's (or the body's) own bottom face, inside the wall.
1105
+ */
1106
+ function cutUnderpinningOpenings(stem, localPoints, topY, openings) {
1107
+ const stemPositions = stem.getAttribute('position');
1108
+ const positions = Array.from(stemPositions.array);
1109
+ const faces = THREE.ShapeUtils.triangulateShape(localPoints.map((point) => new THREE.Vector2(point.x, point.z)), []);
1110
+ for (const [ia, ib, ic] of faces) {
1111
+ const a = localPoints[ia];
1112
+ const b = localPoints[ib];
1113
+ const c = localPoints[ic];
1114
+ const cross = (b.x - a.x) * (c.z - a.z) - (b.z - a.z) * (c.x - a.x);
1115
+ const [second, third] = cross >= 0 ? [c, b] : [b, c];
1116
+ positions.push(a.x, topY, a.z, second.x, topY, second.z, third.x, topY, third.z);
1117
+ }
1118
+ stem.dispose();
1119
+ const solid = new THREE.BufferGeometry();
1120
+ solid.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
1121
+ solid.computeVertexNormals();
1122
+ ensureRenderableGeometryAttributes(solid);
1123
+ computeGeometryBoundsTree(solid);
1124
+ let minZ = Number.POSITIVE_INFINITY;
1125
+ let maxZ = Number.NEGATIVE_INFINITY;
1126
+ for (const point of localPoints) {
1127
+ minZ = Math.min(minZ, point.z);
1128
+ maxZ = Math.max(maxZ, point.z);
1129
+ }
1130
+ const depth = maxZ - minZ + 0.2;
1131
+ const centerZ = (minZ + maxZ) / 2;
1132
+ let result = new Brush(solid);
1133
+ result.updateMatrixWorld();
1134
+ for (const opening of openings) {
1135
+ const height = opening.bottom - opening.top;
1136
+ if (height <= 1e-6)
1137
+ continue;
1138
+ const box = new THREE.BoxGeometry(opening.width, height, depth);
1139
+ box.translate(opening.u, -(opening.top + opening.bottom) / 2, centerZ);
1140
+ const cutter = new Brush(box);
1141
+ prepareBrushForCSG(cutter);
1142
+ const next = csgEvaluator.evaluate(result, cutter, SUBTRACTION);
1143
+ csgGeometry(cutter).dispose();
1144
+ csgGeometry(result).dispose();
1145
+ result = next;
1146
+ }
1147
+ // the other skirts and the body merge non-indexed
1148
+ const cut = csgGeometry(result);
1149
+ const geometry = cut.index ? cut.toNonIndexed() : cut;
1150
+ if (geometry !== cut)
1151
+ cut.dispose();
1152
+ geometry.clearGroups();
1153
+ return geometry;
1154
+ }
1155
+ function mergeWallTerrainFill(body, fills, wall, boundaryEdges, finish) {
1156
+ const present = fills.filter((fill) => fill !== null);
1157
+ if (present.length === 0)
924
1158
  return body;
925
1159
  const bodyGeometry = body.index ? body.toNonIndexed() : body;
926
1160
  if (bodyGeometry !== body)
927
1161
  body.dispose();
1162
+ // The fill is part of each face below the base: it splits at the same finish bounds.
1163
+ const planes = getWallFinishSplitPlanes(finish);
1164
+ const splitFills = present.map((fill) => splitGeometryAtPlanes(fill, planes.y, planes.x));
928
1165
  ensureRenderableGeometryAttributes(bodyGeometry);
929
- ensureRenderableGeometryAttributes(fill);
930
- const merged = mergeGeometries([bodyGeometry, fill], false);
1166
+ for (const fill of splitFills)
1167
+ ensureRenderableGeometryAttributes(fill);
1168
+ const merged = mergeGeometries([bodyGeometry, ...splitFills], false);
931
1169
  if (!merged) {
932
- fill.dispose();
1170
+ for (const fill of splitFills)
1171
+ fill.dispose();
933
1172
  return bodyGeometry;
934
1173
  }
935
1174
  bodyGeometry.dispose();
936
- fill.dispose();
1175
+ for (const fill of splitFills)
1176
+ fill.dispose();
937
1177
  merged.computeVertexNormals();
938
- assignWallMaterialGroups(merged, wall, boundaryEdges, effectiveWallHeight);
1178
+ assignWallMaterialGroups(merged, wall, boundaryEdges, finish);
939
1179
  ensureRenderableGeometryAttributes(merged);
940
1180
  return merged;
941
1181
  }
1182
+ /** Wall-local x of a support-segment parameter (curve parameter on arcs). */
1183
+ function wallStationX(wall, t, length) {
1184
+ if (!isCurvedWall(wall))
1185
+ return t * length;
1186
+ const point = getWallCurveFrameAt(wall, t).point;
1187
+ const dx = wall.end[0] - wall.start[0];
1188
+ const dz = wall.end[1] - wall.start[1];
1189
+ return ((point.x - wall.start[0]) * dx + (point.y - wall.start[1]) * dz) / length;
1190
+ }
1191
+ function buildWallFinishContext(wall, layout, faceBase, slabElevation) {
1192
+ const refs = [...layout.refs];
1193
+ let foundation = null;
1194
+ if (wall.underpinning) {
1195
+ const ref = wall.slots?.foundation ?? WALL_SURFACE_SLOT_DEFAULTS.foundation;
1196
+ if (!refs.includes(ref))
1197
+ refs.push(ref);
1198
+ foundation = { index: 3 + refs.indexOf(ref), top: -wall.underpinning.rim };
1199
+ }
1200
+ if (!faceBase || layout.plain)
1201
+ return { layout, faceBase: null, refs, foundation };
1202
+ const runs = (segments) => segments
1203
+ .map((segment) => ({
1204
+ start: wallStationX(wall, segment.start, layout.length),
1205
+ end: wallStationX(wall, segment.end, layout.length),
1206
+ y: segment.elevation - slabElevation,
1207
+ }))
1208
+ .sort((left, right) => left.start - right.start);
1209
+ return { layout, faceBase: { a: runs(faceBase.a), b: runs(faceBase.b) }, refs, foundation };
1210
+ }
942
1211
  export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabElevation = 0, baseElevation = slabElevation, baseSegments = [
943
1212
  { start: 0, end: 1, elevation: baseElevation },
944
- ], storeyHeight = DEFAULT_LEVEL_HEIGHT, terrainBottomAt) {
1213
+ ], storeyHeight = DEFAULT_LEVEL_HEIGHT, terrainBottomAt, faceBase, finishLayout = buildWallFinishLayout(wallNode, []), sceneNodes = useScene.getState().nodes) {
945
1214
  const wallStart = { x: wallNode.start[0], y: wallNode.start[1] };
946
1215
  const wallEnd = { x: wallNode.end[0], y: wallNode.end[1] };
947
- const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation);
948
- const effectiveWallHeight = topElevation - slabElevation;
949
- const effectiveBaseElevation = Math.min(baseElevation, slabElevation);
1216
+ const levelId = resolveLevelId(wallNode, sceneNodes);
1217
+ const level = sceneNodes[levelId];
1218
+ const authoredStoreyHeight = level?.type === 'level' ? (level.height ?? null) : null;
1219
+ const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation, authoredStoreyHeight);
1220
+ const faceDatum = faceBase;
1221
+ if (faceBase)
1222
+ faceBase = {
1223
+ a: resolveWallFaceBottom(faceBase.a, baseSegments, slabElevation),
1224
+ b: resolveWallFaceBottom(faceBase.b, baseSegments, slabElevation),
1225
+ };
1226
+ const faceSegments = faceBase ? [...faceBase.a, ...faceBase.b] : [];
1227
+ const sameBase = faceBase &&
1228
+ [faceBase.a, faceBase.b].every((segments) => JSON.stringify(segments) === JSON.stringify(baseSegments));
1229
+ const useFaceBase = faceBase && !sameBase;
1230
+ const effectiveBaseElevation = useFaceBase
1231
+ ? Math.min(...faceSegments.flatMap((segment) => [
1232
+ segment.elevation,
1233
+ segment.endElevation ?? segment.elevation,
1234
+ ]), slabElevation)
1235
+ : Math.min(baseElevation, slabElevation);
950
1236
  const localBottom = effectiveBaseElevation - slabElevation;
951
1237
  const height = topElevation - effectiveBaseElevation;
952
1238
  // A slab at or above the storey plane leaves a plane-bound wall with no
@@ -984,9 +1270,15 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
984
1270
  // Convert polygon to local coordinates
985
1271
  const localPoints = polyPoints.map(worldToLocal);
986
1272
  const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData);
987
- const terrainFill = terrainBottomAt
1273
+ const finish = buildWallFinishContext(wallNode, finishLayout, faceDatum, slabElevation);
1274
+ const finishPlanes = getWallFinishSplitPlanes(finish);
1275
+ // An underpinned wall's stem skirt reaches the terrain itself; the plain
1276
+ // terrain fill is for a wall with no underpinning.
1277
+ const underpinningFills = buildWallUnderpinningGeometry(polyPoints, worldToLocal, slabElevation, wallNode, terrainBottomAt, isCurvedWall(wallNode));
1278
+ const terrainFill = terrainBottomAt && !wallNode.underpinning
988
1279
  ? buildWallTerrainFillGeometry(polyPoints, worldToLocal, slabElevation, terrainBottomAt)
989
1280
  : null;
1281
+ const belowBaseFills = [terrainFill, ...underpinningFills];
990
1282
  // Build THREE.js shape
991
1283
  // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
992
1284
  // The negation is needed because after rotateX(-PI/2), shape.y becomes -geometry.z
@@ -1006,20 +1298,31 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1006
1298
  if (Math.abs(localBottom) > 1e-9)
1007
1299
  geometry.translate(0, localBottom, 0);
1008
1300
  geometry.computeVertexNormals();
1009
- assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight);
1301
+ assignWallMaterialGroups(geometry, wallNode, boundaryEdges, finish);
1010
1302
  ensureRenderableGeometryAttributes(geometry);
1011
1303
  // Start with the lowest required wall prism, then remove the volume below
1012
1304
  // each higher-supported run. This keeps the existing mitered footprint and
1013
1305
  // opening CSG while giving one wall a stepped longitudinal base.
1014
1306
  const baseProfileCutouts = [];
1015
- for (const segment of baseSegments) {
1016
- const segmentElevation = Math.min(segment.elevation, slabElevation);
1307
+ const profiles = useFaceBase && faceBase
1308
+ ? [
1309
+ ...faceBase.a.map((segment) => ({ segment, face: 'a' })),
1310
+ ...faceBase.b.map((segment) => ({ segment, face: 'b' })),
1311
+ ]
1312
+ : baseSegments.map((segment) => ({ segment, face: undefined }));
1313
+ for (const { segment, face } of profiles) {
1314
+ const segmentElevation = face
1315
+ ? Math.min(Math.max(segment.elevation, segment.endElevation ?? segment.elevation), topElevation)
1316
+ : Math.min(segment.elevation, slabElevation);
1017
1317
  const cutHeight = segmentElevation - effectiveBaseElevation;
1018
1318
  if (cutHeight <= 1e-6 || segment.end - segment.start <= 1e-7)
1019
1319
  continue;
1020
1320
  const segmentStart = THREE.MathUtils.clamp(segment.start, 0, 1);
1021
1321
  const segmentEnd = THREE.MathUtils.clamp(segment.end, 0, 1);
1022
1322
  const cutHalfWidth = Math.max(thickness * 2, 0.2);
1323
+ const centerOffset = getWallBodyCenterOffset(wallNode);
1324
+ const leftOffset = face === 'b' ? centerOffset : cutHalfWidth;
1325
+ const rightOffset = face === 'a' ? centerOffset : -cutHalfWidth;
1023
1326
  const worldCutoutPoints = [];
1024
1327
  if (isCurvedWall(wallNode)) {
1025
1328
  const sampleCount = Math.max(2, Math.ceil((segmentEnd - segmentStart) * 24));
@@ -1038,12 +1341,12 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1038
1341
  y: frame.point.y + frame.tangent.y * endpointExtension,
1039
1342
  };
1040
1343
  left.push({
1041
- x: center.x + frame.normal.x * cutHalfWidth,
1042
- y: center.y + frame.normal.y * cutHalfWidth,
1344
+ x: center.x + frame.normal.x * leftOffset,
1345
+ y: center.y + frame.normal.y * leftOffset,
1043
1346
  });
1044
1347
  right.push({
1045
- x: center.x - frame.normal.x * cutHalfWidth,
1046
- y: center.y - frame.normal.y * cutHalfWidth,
1348
+ x: center.x + frame.normal.x * rightOffset,
1349
+ y: center.y + frame.normal.y * rightOffset,
1047
1350
  });
1048
1351
  }
1049
1352
  worldCutoutPoints.push(...left, ...right.reverse());
@@ -1064,11 +1367,11 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1064
1367
  y: wallStart.y + tangentY * (segmentEnd * L + endExtension),
1065
1368
  };
1066
1369
  worldCutoutPoints.push({
1067
- x: startPoint.x + normalX * cutHalfWidth,
1068
- y: startPoint.y + normalY * cutHalfWidth,
1069
- }, { x: endPoint.x + normalX * cutHalfWidth, y: endPoint.y + normalY * cutHalfWidth }, { x: endPoint.x - normalX * cutHalfWidth, y: endPoint.y - normalY * cutHalfWidth }, {
1070
- x: startPoint.x - normalX * cutHalfWidth,
1071
- y: startPoint.y - normalY * cutHalfWidth,
1370
+ x: startPoint.x + normalX * leftOffset,
1371
+ y: startPoint.y + normalY * leftOffset,
1372
+ }, { x: endPoint.x + normalX * leftOffset, y: endPoint.y + normalY * leftOffset }, { x: endPoint.x + normalX * rightOffset, y: endPoint.y + normalY * rightOffset }, {
1373
+ x: startPoint.x + normalX * rightOffset,
1374
+ y: startPoint.y + normalY * rightOffset,
1072
1375
  });
1073
1376
  }
1074
1377
  const localCutoutPoints = worldCutoutPoints.map(worldToLocal);
@@ -1080,7 +1383,11 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1080
1383
  cutoutShape.lineTo(localCutoutPoints[index].x, -localCutoutPoints[index].z);
1081
1384
  }
1082
1385
  cutoutShape.closePath();
1083
- const cutoutBottom = localBottom - 0.01;
1386
+ const slopeExtension = segment.endElevation === undefined
1387
+ ? 0
1388
+ : (Math.abs(segment.endElevation - segment.elevation) * cutHalfWidth) /
1389
+ ((segmentEnd - segmentStart) * L);
1390
+ const cutoutBottom = localBottom - 0.01 - slopeExtension;
1084
1391
  const cutoutTop = segmentElevation - slabElevation;
1085
1392
  const cutoutGeometry = new THREE.ExtrudeGeometry(cutoutShape, {
1086
1393
  depth: cutoutTop - cutoutBottom,
@@ -1088,19 +1395,31 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1088
1395
  });
1089
1396
  cutoutGeometry.rotateX(-Math.PI / 2);
1090
1397
  cutoutGeometry.translate(0, cutoutBottom, 0);
1398
+ if (segment.endElevation !== undefined) {
1399
+ const from = wallStationX(wallNode, segmentStart, L);
1400
+ const to = wallStationX(wallNode, segmentEnd, L);
1401
+ const positions = cutoutGeometry.getAttribute('position');
1402
+ for (let i = 0; i < positions.count; i++) {
1403
+ if (Math.abs(positions.getY(i) - cutoutTop) > 1e-5)
1404
+ continue;
1405
+ const t = (positions.getX(i) - from) / (to - from);
1406
+ positions.setY(i, Math.min(topElevation, segment.elevation + (segment.endElevation - segment.elevation) * t) - slabElevation);
1407
+ }
1408
+ cutoutGeometry.computeVertexNormals();
1409
+ }
1091
1410
  computeGeometryBoundsTree(cutoutGeometry);
1092
1411
  baseProfileCutouts.push(new Brush(cutoutGeometry));
1093
1412
  }
1094
1413
  const cutoutBrushes = [
1095
1414
  ...baseProfileCutouts,
1096
- ...collectCutoutBrushes(wallNode, childrenNodes, thickness),
1415
+ ...collectCutoutBrushes(wallNode, childrenNodes, thickness, faceDatum, slabElevation, sceneNodes, baseSegments),
1097
1416
  ];
1098
1417
  if (cutoutBrushes.length === 0) {
1099
- const splitGeometry = splitGeometryAtHorizontalPlanes(geometry, getWallBandSplitPlanes(wallNode, effectiveWallHeight));
1418
+ const splitGeometry = splitGeometryAtPlanes(geometry, finishPlanes.y, finishPlanes.x);
1100
1419
  splitGeometry.computeVertexNormals();
1101
- assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight);
1420
+ assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, finish);
1102
1421
  ensureRenderableGeometryAttributes(splitGeometry);
1103
- return mergeWallTerrainFill(splitGeometry, terrainFill, wallNode, boundaryEdges, effectiveWallHeight);
1422
+ return withWallFinishData(mergeWallTerrainFill(splitGeometry, belowBaseFills, wallNode, boundaryEdges, finish), finish);
1104
1423
  }
1105
1424
  // Create wall brush from geometry
1106
1425
  // Pre-compute BVH with new API to avoid deprecation warning
@@ -1143,18 +1462,59 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1143
1462
  csgGeometry(brush).dispose();
1144
1463
  }
1145
1464
  const resultGeometry = csgGeometry(resultBrush);
1146
- const splitResultGeometry = splitGeometryAtHorizontalPlanes(resultGeometry, getWallBandSplitPlanes(wallNode, effectiveWallHeight));
1465
+ const splitResultGeometry = splitGeometryAtPlanes(resultGeometry, finishPlanes.y, finishPlanes.x);
1147
1466
  splitResultGeometry.computeVertexNormals();
1148
- assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight);
1467
+ assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, finish);
1149
1468
  ensureRenderableGeometryAttributes(splitResultGeometry);
1150
- return mergeWallTerrainFill(splitResultGeometry, terrainFill, wallNode, boundaryEdges, effectiveWallHeight);
1469
+ return withWallFinishData(mergeWallTerrainFill(splitResultGeometry, belowBaseFills, wallNode, boundaryEdges, finish), finish);
1470
+ }
1471
+ function withWallFinishData(geometry, finish) {
1472
+ if (!finish.layout.plain || finish.foundation) {
1473
+ geometry.userData.wallFinish = {
1474
+ refs: finish.refs,
1475
+ faceBase: finish.faceBase,
1476
+ };
1477
+ }
1478
+ return geometry;
1151
1479
  }
1152
1480
  /**
1153
1481
  * Collects opening and item cutout brushes for CSG subtraction. Door/window
1154
1482
  * cuts come directly from node geometry; item proxy meshes are transformed
1155
1483
  * into wall-local boxes that pass through the wall.
1156
1484
  */
1157
- function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
1485
+ /**
1486
+ * A CSG brush from an authored object's `cutout` mesh: the mesh in wall-local
1487
+ * space, its depth stretched to twice the wall's thickness about the wall's
1488
+ * body centre so it overshoots both faces. Null when the mesh has no depth to
1489
+ * stretch (a flat cutter), so the caller falls back to its bounding box.
1490
+ */
1491
+ function authoredCutoutBrush(cutoutMesh, wallMatrixInverse, wallThickness, wallNode) {
1492
+ const geometry = cutoutMesh.geometry.clone();
1493
+ geometry.applyMatrix4(new THREE.Matrix4().multiplyMatrices(wallMatrixInverse, cutoutMesh.matrixWorld));
1494
+ geometry.computeBoundingBox();
1495
+ const box = geometry.boundingBox;
1496
+ const depth = box.max.z - box.min.z;
1497
+ if (!(depth > 1e-4)) {
1498
+ geometry.dispose();
1499
+ return null;
1500
+ }
1501
+ const centre = getWallBodyCenterOffset(wallNode);
1502
+ const meshCentre = (box.min.z + box.max.z) / 2;
1503
+ const scale = (wallThickness * 2) / depth;
1504
+ const positions = geometry.getAttribute('position');
1505
+ for (let i = 0; i < positions.count; i++) {
1506
+ positions.setZ(i, centre + (positions.getZ(i) - meshCentre) * scale);
1507
+ }
1508
+ positions.needsUpdate = true;
1509
+ // The evaluator needs the attributes every brush carries.
1510
+ if (!geometry.getAttribute('uv')) {
1511
+ geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(positions.count * 2), 2));
1512
+ }
1513
+ geometry.computeVertexNormals();
1514
+ computeGeometryBoundsTree(geometry);
1515
+ return new Brush(geometry);
1516
+ }
1517
+ function collectCutoutBrushes(wallNode, childrenNodes, wallThickness, faceBase, elevation = 0, sceneNodes = useScene.getState().nodes, baseSegments = [{ start: 0, end: 1, elevation }]) {
1158
1518
  const brushes = [];
1159
1519
  const wallMesh = sceneRegistry.nodes.get(wallNode.id);
1160
1520
  if (!wallMesh)
@@ -1162,63 +1522,165 @@ function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
1162
1522
  // Get wall's world matrix inverse to transform cutouts to wall-local space
1163
1523
  wallMesh.updateMatrixWorld();
1164
1524
  const wallMatrixInverse = wallMesh.matrixWorld.clone().invert();
1525
+ // A wall child can only bind to its parent, so the wall alone resolves every cutter.
1526
+ const hostNodes = { [wallNode.id]: wallNode };
1165
1527
  for (const child of childrenNodes) {
1166
1528
  if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door')
1167
1529
  continue;
1168
- if (child.type === 'door' || child.type === 'window') {
1169
- brushes.push(createOpeningCutoutBrush(child, wallThickness));
1530
+ const cutterMeshes = [];
1531
+ sceneRegistry.nodes.get(child.id)?.traverse((object) => {
1532
+ const name = typeof object.userData.name === 'string' ? object.userData.name : object.name;
1533
+ if (object.isMesh &&
1534
+ resolveCutterHost(child, name, hostNodes)?.id === wallNode.id) {
1535
+ cutterMeshes.push(object);
1536
+ }
1537
+ });
1538
+ // A window or door built from a script cuts its `cutout` mesh like an
1539
+ // authored item (below); without one, or until it loads, its outline.
1540
+ const scriptedCutout = child.type !== 'item' && child.source && cutterMeshes.length > 0;
1541
+ if ((child.type === 'door' || child.type === 'window') && !scriptedCutout) {
1542
+ const nodes = {
1543
+ ...sceneNodes,
1544
+ [wallNode.id]: wallNode,
1545
+ ...Object.fromEntries(childrenNodes.map((node) => [node.id, node])),
1546
+ };
1547
+ const datum = faceBase ?? {
1548
+ a: [{ start: 0, end: 1, elevation }],
1549
+ b: [{ start: 0, end: 1, elevation }],
1550
+ };
1551
+ const support = {
1552
+ elevation,
1553
+ electedSlabId: null,
1554
+ baseElevation: elevation,
1555
+ baseSegments: [...baseSegments],
1556
+ faceDatum: { a: [...datum.a], b: [...datum.b] },
1557
+ faceBottom: {
1558
+ a: resolveWallFaceBottom(datum.a, baseSegments, elevation),
1559
+ b: resolveWallFaceBottom(datum.b, baseSegments, elevation),
1560
+ },
1561
+ };
1562
+ const cut = getOpeningWallCut(wallNode, child, nodes, support);
1563
+ // The cutter must overshoot the faces it cuts through; `cut.aperture` is
1564
+ // clipped to the wall's own footprint (the doorway floor), so extruding it
1565
+ // would leave coplanar faces and CSG would drop the cut.
1566
+ if (!isCurvedWall(wallNode)) {
1567
+ brushes.push(createOpeningCutoutBrush({
1568
+ ...child,
1569
+ position: [
1570
+ child.position[0],
1571
+ child.position[1] + cut.datum - elevation,
1572
+ child.position[2],
1573
+ ],
1574
+ }, wallThickness, getWallBodyCenterOffset(wallNode), cut.bottom - elevation, child.type === 'window' ? cornerPair(sceneNodes, child)?.end : undefined));
1575
+ continue;
1576
+ }
1577
+ if (cut.band.length < 3)
1578
+ continue;
1579
+ const angle = Math.atan2(wallNode.end[1] - wallNode.start[1], wallNode.end[0] - wallNode.start[0]);
1580
+ const points = cut.band.map(([x, z]) => {
1581
+ const dx = x - wallNode.start[0], dz = z - wallNode.start[1];
1582
+ return new THREE.Vector2(dx * Math.cos(angle) + dz * Math.sin(angle), dx * Math.sin(angle) - dz * Math.cos(angle));
1583
+ });
1584
+ const padding = getOpeningCutoutBottomPadding(child, cut.bottom - elevation);
1585
+ const geometry = new THREE.ExtrudeGeometry(new THREE.Shape(points), {
1586
+ depth: cut.top - cut.bottom + padding,
1587
+ bevelEnabled: false,
1588
+ });
1589
+ geometry.rotateX(-Math.PI / 2);
1590
+ geometry.translate(0, cut.bottom - elevation - padding, 0);
1591
+ computeGeometryBoundsTree(geometry);
1592
+ const bandBrush = new Brush(geometry);
1593
+ if (child.openingShape !== 'arch' && child.openingShape !== 'rounded') {
1594
+ brushes.push(bandBrush);
1595
+ continue;
1596
+ }
1597
+ const shaped = createOpeningCutoutBrush({
1598
+ ...child,
1599
+ position: [
1600
+ child.position[0],
1601
+ child.position[1] + cut.datum - elevation,
1602
+ child.position[2],
1603
+ ],
1604
+ }, wallThickness, getWallBodyCenterOffset(wallNode), cut.bottom - elevation);
1605
+ const frame = getWallCurveFrameAt(wallNode, child.position[0] / getWallCurveLength(wallNode));
1606
+ const offset = getWallBodyCenterOffset(wallNode);
1607
+ const dx = frame.point.x + frame.normal.x * offset - wallNode.start[0], dz = frame.point.y + frame.normal.y * offset - wallNode.start[1];
1608
+ csgGeometry(shaped).translate(-child.position[0], 0, -offset);
1609
+ csgGeometry(shaped).rotateY(angle - Math.atan2(frame.tangent.y, frame.tangent.x));
1610
+ csgGeometry(shaped).translate(dx * Math.cos(angle) + dz * Math.sin(angle), 0, -dx * Math.sin(angle) + dz * Math.cos(angle));
1611
+ prepareBrushForCSG(bandBrush);
1612
+ prepareBrushForCSG(shaped);
1613
+ bandBrush.updateMatrixWorld();
1614
+ shaped.updateMatrixWorld();
1615
+ brushes.push(csgEvaluator.evaluate(bandBrush, shaped, INTERSECTION));
1616
+ geometry.dispose();
1617
+ csgGeometry(shaped).dispose();
1170
1618
  continue;
1171
1619
  }
1172
- const childMesh = sceneRegistry.nodes.get(child.id);
1173
- if (!childMesh)
1174
- continue;
1175
- const cutoutMesh = childMesh.getObjectByName('cutout');
1176
- if (!cutoutMesh)
1177
- continue;
1178
- // Get the cutout's bounding box in world space
1179
- cutoutMesh.updateMatrixWorld();
1180
- const positions = cutoutMesh.geometry?.attributes?.position;
1181
- if (!positions)
1182
- continue;
1183
- // Calculate bounds in wall-local space
1184
- const v3 = new THREE.Vector3();
1185
- let minX = Number.POSITIVE_INFINITY, maxX = Number.NEGATIVE_INFINITY;
1186
- let minY = Number.POSITIVE_INFINITY, maxY = Number.NEGATIVE_INFINITY;
1187
- for (let i = 0; i < positions.count; i++) {
1188
- v3.fromBufferAttribute(positions, i);
1189
- v3.applyMatrix4(cutoutMesh.matrixWorld);
1190
- v3.applyMatrix4(wallMatrixInverse);
1191
- minX = Math.min(minX, v3.x);
1192
- maxX = Math.max(maxX, v3.x);
1193
- minY = Math.min(minY, v3.y);
1194
- maxY = Math.max(maxY, v3.y);
1620
+ for (const cutoutMesh of cutterMeshes) {
1621
+ // Get the cutout's bounding box in world space
1622
+ cutoutMesh.updateMatrixWorld();
1623
+ const positions = cutoutMesh.geometry?.attributes?.position;
1624
+ if (!positions)
1625
+ continue;
1626
+ // Calculate bounds in wall-local space
1627
+ const v3 = new THREE.Vector3();
1628
+ let minX = Number.POSITIVE_INFINITY, maxX = Number.NEGATIVE_INFINITY;
1629
+ let minY = Number.POSITIVE_INFINITY, maxY = Number.NEGATIVE_INFINITY;
1630
+ for (let i = 0; i < positions.count; i++) {
1631
+ v3.fromBufferAttribute(positions, i);
1632
+ v3.applyMatrix4(cutoutMesh.matrixWorld);
1633
+ v3.applyMatrix4(wallMatrixInverse);
1634
+ minX = Math.min(minX, v3.x);
1635
+ maxX = Math.max(maxX, v3.x);
1636
+ minY = Math.min(minY, v3.y);
1637
+ maxY = Math.max(maxY, v3.y);
1638
+ }
1639
+ if (!Number.isFinite(minX))
1640
+ continue;
1641
+ // An authored object's cutout keeps its shape (an arch, a circle): its own
1642
+ // geometry in wall space, stretched across the wall so it cuts both faces.
1643
+ if (child.source) {
1644
+ const shaped = authoredCutoutBrush(cutoutMesh, wallMatrixInverse, wallThickness, wallNode);
1645
+ if (shaped) {
1646
+ brushes.push(shaped);
1647
+ continue;
1648
+ }
1649
+ }
1650
+ // Create a box geometry that extends through the wall thickness
1651
+ const width = maxX - minX;
1652
+ const height = maxY - minY;
1653
+ const depth = wallThickness * 2; // Extend beyond wall to ensure clean cut
1654
+ const boxGeo = new THREE.BoxGeometry(width, height, depth);
1655
+ // Position box at the center of the cutout
1656
+ boxGeo.translate(minX + width / 2, minY + height / 2, getWallBodyCenterOffset(wallNode));
1657
+ // Pre-compute BVH with new API to avoid deprecation warning
1658
+ computeGeometryBoundsTree(boxGeo);
1659
+ const brush = new Brush(boxGeo);
1660
+ brushes.push(brush);
1195
1661
  }
1196
- if (!Number.isFinite(minX))
1197
- continue;
1198
- // Create a box geometry that extends through the wall thickness
1199
- const width = maxX - minX;
1200
- const height = maxY - minY;
1201
- const depth = wallThickness * 2; // Extend beyond wall to ensure clean cut
1202
- const boxGeo = new THREE.BoxGeometry(width, height, depth);
1203
- // Position box at the center of the cutout
1204
- boxGeo.translate(minX + width / 2, minY + height / 2, 0);
1205
- // Pre-compute BVH with new API to avoid deprecation warning
1206
- computeGeometryBoundsTree(boxGeo);
1207
- const brush = new Brush(boxGeo);
1208
- brushes.push(brush);
1209
1662
  }
1210
1663
  return brushes;
1211
1664
  }
1212
- function createOpeningCutoutBrush(opening, wallThickness) {
1665
+ /**
1666
+ * How far a corner window's cut runs past its wall's end: past this wall's half of the corner at any
1667
+ * angle (the miter stops at five thicknesses), so the two sides open the corner between them. The
1668
+ * cut only takes this wall's own geometry, so running past it touches nothing else.
1669
+ */
1670
+ const CORNER_CUT_REACH = 6;
1671
+ function createOpeningCutoutBrush(opening, wallThickness, centerOffset, cutBottom, cornerEnd) {
1213
1672
  const halfWidth = opening.width / 2;
1214
- const bottom = opening.position[1] - opening.height / 2;
1673
+ const bottom = cutBottom ?? opening.position[1] - opening.height / 2;
1215
1674
  const bottomPadding = getOpeningCutoutBottomPadding(opening, bottom);
1675
+ const reach = CORNER_CUT_REACH * wallThickness;
1216
1676
  const geometry = buildOpeningCutoutGeometry(opening, {
1217
- left: opening.position[0] - halfWidth,
1218
- right: opening.position[0] + halfWidth,
1677
+ left: opening.position[0] - halfWidth - (cornerEnd === 'start' ? reach : 0),
1678
+ right: opening.position[0] + halfWidth + (cornerEnd === 'end' ? reach : 0),
1219
1679
  bottom: bottom - bottomPadding,
1220
1680
  top: opening.position[1] + opening.height / 2,
1221
1681
  }, wallThickness * 2, wallThickness);
1682
+ if (centerOffset !== 0)
1683
+ geometry.translate(0, 0, centerOffset);
1222
1684
  computeGeometryBoundsTree(geometry);
1223
1685
  return new Brush(geometry);
1224
1686
  }