@pascal-app/viewer 0.8.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/dist/components/error-boundary.d.ts +6 -0
  2. package/dist/components/error-boundary.d.ts.map +1 -1
  3. package/dist/components/error-boundary.js +6 -0
  4. package/dist/components/renderers/node-renderer.d.ts +6 -2
  5. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  6. package/dist/components/renderers/node-renderer.js +41 -22
  7. package/dist/components/renderers/parametric-node-renderer.d.ts +5 -0
  8. package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -0
  9. package/dist/components/renderers/parametric-node-renderer.js +37 -0
  10. package/dist/components/renderers/scene-renderer.d.ts +1 -1
  11. package/dist/components/renderers/scene-renderer.d.ts.map +1 -1
  12. package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
  13. package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
  14. package/dist/components/viewer/bvh-ecctrl.js +552 -0
  15. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  16. package/dist/components/viewer/frame-limiter.js +39 -4
  17. package/dist/components/viewer/glb-interactive.d.ts +41 -0
  18. package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
  19. package/dist/components/viewer/glb-interactive.js +394 -0
  20. package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
  21. package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
  22. package/dist/components/viewer/glb-reference-nodes.js +80 -0
  23. package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
  24. package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
  25. package/dist/components/viewer/glb-replace-instances.js +61 -0
  26. package/dist/components/viewer/glb-scene.d.ts +54 -0
  27. package/dist/components/viewer/glb-scene.d.ts.map +1 -0
  28. package/dist/components/viewer/glb-scene.js +920 -0
  29. package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
  30. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
  31. package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
  32. package/dist/components/viewer/index.d.ts +56 -3
  33. package/dist/components/viewer/index.d.ts.map +1 -1
  34. package/dist/components/viewer/index.js +280 -65
  35. package/dist/components/viewer/lights.d.ts +1 -1
  36. package/dist/components/viewer/lights.d.ts.map +1 -1
  37. package/dist/components/viewer/lights.js +201 -43
  38. package/dist/components/viewer/perf-monitor.d.ts +1 -1
  39. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  40. package/dist/components/viewer/perf-monitor.js +115 -12
  41. package/dist/components/viewer/post-processing.d.ts +7 -1
  42. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  43. package/dist/components/viewer/post-processing.js +395 -63
  44. package/dist/components/viewer/registered-systems.d.ts +12 -0
  45. package/dist/components/viewer/registered-systems.d.ts.map +1 -0
  46. package/dist/components/viewer/registered-systems.js +49 -0
  47. package/dist/components/viewer/scene-bvh.d.ts +18 -0
  48. package/dist/components/viewer/scene-bvh.d.ts.map +1 -0
  49. package/dist/components/viewer/scene-bvh.js +97 -0
  50. package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
  51. package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
  52. package/dist/components/viewer/scene-bvh.test.js +13 -0
  53. package/dist/components/viewer/scene-environment.d.ts +2 -0
  54. package/dist/components/viewer/scene-environment.d.ts.map +1 -0
  55. package/dist/components/viewer/scene-environment.js +79 -0
  56. package/dist/components/viewer/selection-manager.d.ts +1 -1
  57. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  58. package/dist/components/viewer/selection-manager.js +37 -14
  59. package/dist/components/viewer/viewer-camera.d.ts +1 -1
  60. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  61. package/dist/components/viewer/walkthrough-controls.d.ts +2 -1
  62. package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
  63. package/dist/components/viewer/walkthrough-controls.js +20 -0
  64. package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
  65. package/dist/hooks/use-gltf-ktx2.js +3 -22
  66. package/dist/hooks/use-node-events.d.ts +6 -74
  67. package/dist/hooks/use-node-events.d.ts.map +1 -1
  68. package/dist/hooks/use-node-events.js +28 -8
  69. package/dist/index.d.ts +48 -5
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +82 -4
  72. package/dist/lib/backdrop.d.ts +33 -0
  73. package/dist/lib/backdrop.d.ts.map +1 -0
  74. package/dist/lib/backdrop.js +92 -0
  75. package/dist/lib/box-uv.d.ts +13 -0
  76. package/dist/lib/box-uv.d.ts.map +1 -0
  77. package/dist/lib/box-uv.js +32 -0
  78. package/dist/lib/csg-utils.d.ts +10 -0
  79. package/dist/lib/csg-utils.d.ts.map +1 -0
  80. package/dist/lib/csg-utils.js +93 -0
  81. package/dist/lib/csg-utils.test.d.ts +2 -0
  82. package/dist/lib/csg-utils.test.d.ts.map +1 -0
  83. package/dist/lib/csg-utils.test.js +45 -0
  84. package/dist/lib/drawable-geometry.d.ts +21 -0
  85. package/dist/lib/drawable-geometry.d.ts.map +1 -0
  86. package/dist/lib/drawable-geometry.js +22 -0
  87. package/dist/lib/edge-style.d.ts +4 -0
  88. package/dist/lib/edge-style.d.ts.map +1 -0
  89. package/dist/lib/edge-style.js +31 -0
  90. package/dist/lib/gpu-perf.d.ts +4 -0
  91. package/dist/lib/gpu-perf.d.ts.map +1 -0
  92. package/dist/lib/gpu-perf.js +23 -0
  93. package/dist/lib/ink-edges.d.ts +9 -0
  94. package/dist/lib/ink-edges.d.ts.map +1 -0
  95. package/dist/lib/ink-edges.js +49 -0
  96. package/dist/lib/isolation.d.ts +39 -0
  97. package/dist/lib/isolation.d.ts.map +1 -0
  98. package/dist/lib/isolation.js +103 -0
  99. package/dist/lib/ktx2-loader.d.ts +43 -0
  100. package/dist/lib/ktx2-loader.d.ts.map +1 -0
  101. package/dist/lib/ktx2-loader.js +136 -0
  102. package/dist/lib/layers.d.ts +28 -0
  103. package/dist/lib/layers.d.ts.map +1 -1
  104. package/dist/lib/layers.js +28 -0
  105. package/dist/lib/materials.d.ts +38 -15
  106. package/dist/lib/materials.d.ts.map +1 -1
  107. package/dist/lib/materials.js +364 -72
  108. package/dist/lib/merged-outline-node.d.ts.map +1 -1
  109. package/dist/lib/merged-outline-node.js +7 -0
  110. package/dist/lib/polygon-union.d.ts +4 -0
  111. package/dist/lib/polygon-union.d.ts.map +1 -0
  112. package/dist/lib/polygon-union.js +289 -0
  113. package/dist/lib/polygon-union.test.d.ts +2 -0
  114. package/dist/lib/polygon-union.test.d.ts.map +1 -0
  115. package/dist/lib/polygon-union.test.js +105 -0
  116. package/dist/lib/scene-themes.d.ts +30 -0
  117. package/dist/lib/scene-themes.d.ts.map +1 -0
  118. package/dist/lib/scene-themes.js +202 -0
  119. package/dist/lib/shadow-only.d.ts +4 -0
  120. package/dist/lib/shadow-only.d.ts.map +1 -0
  121. package/dist/lib/shadow-only.js +36 -0
  122. package/dist/lib/tsl-compat.d.ts +12 -0
  123. package/dist/lib/tsl-compat.d.ts.map +1 -0
  124. package/dist/lib/tsl-compat.js +13 -0
  125. package/dist/store/use-viewer.d.ts +92 -12
  126. package/dist/store/use-viewer.d.ts.map +1 -1
  127. package/dist/store/use-viewer.js +201 -6
  128. package/dist/store/use-viewer.test.d.ts +2 -0
  129. package/dist/store/use-viewer.test.d.ts.map +1 -0
  130. package/dist/store/use-viewer.test.js +42 -0
  131. package/dist/systems/ceiling/ceiling-system.d.ts +6 -2
  132. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  133. package/dist/systems/ceiling/ceiling-system.js +66 -13
  134. package/dist/systems/door/door-animation.test.d.ts +2 -0
  135. package/dist/systems/door/door-animation.test.d.ts.map +1 -0
  136. package/dist/systems/door/door-animation.test.js +51 -0
  137. package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
  138. package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
  139. package/dist/systems/door/door-floor-alignment.test.js +50 -0
  140. package/dist/systems/door/door-system.d.ts +24 -0
  141. package/dist/systems/door/door-system.d.ts.map +1 -1
  142. package/dist/systems/door/door-system.js +864 -180
  143. package/dist/systems/elevator/elevator-interaction-system.d.ts +2 -0
  144. package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -0
  145. package/dist/systems/elevator/elevator-interaction-system.js +62 -0
  146. package/dist/systems/fence/fence-system.d.ts +20 -0
  147. package/dist/systems/fence/fence-system.d.ts.map +1 -1
  148. package/dist/systems/fence/fence-system.js +229 -57
  149. package/dist/systems/floor-elevation/floor-elevation-system.d.ts +20 -0
  150. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -0
  151. package/dist/systems/floor-elevation/floor-elevation-system.js +78 -0
  152. package/dist/systems/geometry/geometry-system.d.ts +41 -0
  153. package/dist/systems/geometry/geometry-system.d.ts.map +1 -0
  154. package/dist/systems/geometry/geometry-system.js +348 -0
  155. package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
  156. package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
  157. package/dist/systems/geometry/geometry-system.test.js +30 -0
  158. package/dist/systems/interactive/control-widget.d.ts +10 -0
  159. package/dist/systems/interactive/control-widget.d.ts.map +1 -0
  160. package/dist/systems/interactive/control-widget.js +35 -0
  161. package/dist/systems/interactive/interactive-system.d.ts +1 -1
  162. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  163. package/dist/systems/interactive/interactive-system.js +55 -56
  164. package/dist/systems/item/item-system.d.ts +7 -0
  165. package/dist/systems/item/item-system.d.ts.map +1 -1
  166. package/dist/systems/item/item-system.js +28 -17
  167. package/dist/systems/item-light/item-light-system.d.ts +1 -1
  168. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  169. package/dist/systems/item-light/item-light-system.js +18 -5
  170. package/dist/systems/level/level-system.d.ts.map +1 -1
  171. package/dist/systems/level/level-system.js +28 -4
  172. package/dist/systems/level/level-utils.d.ts +0 -3
  173. package/dist/systems/level/level-utils.d.ts.map +1 -1
  174. package/dist/systems/level/level-utils.js +2 -41
  175. package/dist/systems/roof/roof-materials.d.ts +3 -2
  176. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  177. package/dist/systems/roof/roof-materials.js +59 -14
  178. package/dist/systems/roof/roof-system.d.ts +46 -6
  179. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  180. package/dist/systems/roof/roof-system.js +1365 -170
  181. package/dist/systems/slab/slab-system.d.ts +5 -3
  182. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  183. package/dist/systems/slab/slab-system.js +204 -68
  184. package/dist/systems/slab/slab-system.test.d.ts +2 -0
  185. package/dist/systems/slab/slab-system.test.d.ts.map +1 -0
  186. package/dist/systems/slab/slab-system.test.js +63 -0
  187. package/dist/systems/stair/stair-materials.d.ts +4 -3
  188. package/dist/systems/stair/stair-materials.d.ts.map +1 -1
  189. package/dist/systems/stair/stair-materials.js +30 -15
  190. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  191. package/dist/systems/stair/stair-system.js +55 -63
  192. package/dist/systems/surface-hole-geometry.d.ts +3 -0
  193. package/dist/systems/surface-hole-geometry.d.ts.map +1 -0
  194. package/dist/systems/surface-hole-geometry.js +4 -0
  195. package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
  196. package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
  197. package/dist/systems/wall/opening-cutout-geometry.js +167 -0
  198. package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
  199. package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
  200. package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
  201. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  202. package/dist/systems/wall/wall-cutout.js +62 -17
  203. package/dist/systems/wall/wall-materials.d.ts +12 -7
  204. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  205. package/dist/systems/wall/wall-materials.js +279 -46
  206. package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
  207. package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
  208. package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
  209. package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
  210. package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
  211. package/dist/systems/wall/wall-support-extension.test.js +48 -0
  212. package/dist/systems/wall/wall-system.d.ts +2 -2
  213. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  214. package/dist/systems/wall/wall-system.js +493 -193
  215. package/dist/systems/window/window-animation-system.d.ts +12 -0
  216. package/dist/systems/window/window-animation-system.d.ts.map +1 -1
  217. package/dist/systems/window/window-animation-system.js +16 -5
  218. package/dist/systems/window/window-system.d.ts +7 -0
  219. package/dist/systems/window/window-system.d.ts.map +1 -1
  220. package/dist/systems/window/window-system.js +308 -18
  221. package/package.json +5 -6
  222. package/dist/components/renderers/building/building-renderer.d.ts +0 -5
  223. package/dist/components/renderers/building/building-renderer.d.ts.map +0 -1
  224. package/dist/components/renderers/building/building-renderer.js +0 -11
  225. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts +0 -5
  226. package/dist/components/renderers/ceiling/ceiling-renderer.d.ts.map +0 -1
  227. package/dist/components/renderers/ceiling/ceiling-renderer.js +0 -58
  228. package/dist/components/renderers/column/column-renderer.d.ts +0 -5
  229. package/dist/components/renderers/column/column-renderer.d.ts.map +0 -1
  230. package/dist/components/renderers/column/column-renderer.js +0 -597
  231. package/dist/components/renderers/door/door-renderer.d.ts +0 -5
  232. package/dist/components/renderers/door/door-renderer.d.ts.map +0 -1
  233. package/dist/components/renderers/door/door-renderer.js +0 -16
  234. package/dist/components/renderers/fence/fence-renderer.d.ts +0 -5
  235. package/dist/components/renderers/fence/fence-renderer.d.ts.map +0 -1
  236. package/dist/components/renderers/fence/fence-renderer.js +0 -15
  237. package/dist/components/renderers/guide/guide-renderer.d.ts +0 -5
  238. package/dist/components/renderers/guide/guide-renderer.d.ts.map +0 -1
  239. package/dist/components/renderers/guide/guide-renderer.js +0 -38
  240. package/dist/components/renderers/item/item-renderer.d.ts +0 -5
  241. package/dist/components/renderers/item/item-renderer.d.ts.map +0 -1
  242. package/dist/components/renderers/item/item-renderer.js +0 -172
  243. package/dist/components/renderers/level/level-renderer.d.ts +0 -5
  244. package/dist/components/renderers/level/level-renderer.d.ts.map +0 -1
  245. package/dist/components/renderers/level/level-renderer.js +0 -11
  246. package/dist/components/renderers/roof/roof-materials.d.ts +0 -4
  247. package/dist/components/renderers/roof/roof-materials.d.ts.map +0 -1
  248. package/dist/components/renderers/roof/roof-materials.js +0 -16
  249. package/dist/components/renderers/roof/roof-renderer.d.ts +0 -5
  250. package/dist/components/renderers/roof/roof-renderer.d.ts.map +0 -1
  251. package/dist/components/renderers/roof/roof-renderer.js +0 -32
  252. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts +0 -5
  253. package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts.map +0 -1
  254. package/dist/components/renderers/roof-segment/roof-segment-renderer.js +0 -40
  255. package/dist/components/renderers/scan/scan-renderer.d.ts +0 -5
  256. package/dist/components/renderers/scan/scan-renderer.d.ts.map +0 -1
  257. package/dist/components/renderers/scan/scan-renderer.js +0 -54
  258. package/dist/components/renderers/site/site-renderer.d.ts +0 -5
  259. package/dist/components/renderers/site/site-renderer.d.ts.map +0 -1
  260. package/dist/components/renderers/site/site-renderer.js +0 -106
  261. package/dist/components/renderers/slab/slab-renderer.d.ts +0 -5
  262. package/dist/components/renderers/slab/slab-renderer.d.ts.map +0 -1
  263. package/dist/components/renderers/slab/slab-renderer.js +0 -58
  264. package/dist/components/renderers/spawn/spawn-renderer.d.ts +0 -5
  265. package/dist/components/renderers/spawn/spawn-renderer.d.ts.map +0 -1
  266. package/dist/components/renderers/spawn/spawn-renderer.js +0 -32
  267. package/dist/components/renderers/stair/stair-renderer.d.ts +0 -5
  268. package/dist/components/renderers/stair/stair-renderer.d.ts.map +0 -1
  269. package/dist/components/renderers/stair/stair-renderer.js +0 -603
  270. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts +0 -5
  271. package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts.map +0 -1
  272. package/dist/components/renderers/stair-segment/stair-segment-renderer.js +0 -51
  273. package/dist/components/renderers/wall/wall-renderer.d.ts +0 -5
  274. package/dist/components/renderers/wall/wall-renderer.d.ts.map +0 -1
  275. package/dist/components/renderers/wall/wall-renderer.js +0 -16
  276. package/dist/components/renderers/window/window-renderer.d.ts +0 -5
  277. package/dist/components/renderers/window/window-renderer.d.ts.map +0 -1
  278. package/dist/components/renderers/window/window-renderer.js +0 -21
  279. package/dist/components/renderers/zone/zone-renderer.d.ts +0 -5
  280. package/dist/components/renderers/zone/zone-renderer.d.ts.map +0 -1
  281. package/dist/components/renderers/zone/zone-renderer.js +0 -181
  282. package/dist/components/viewer/ground-occluder.d.ts +0 -2
  283. package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
  284. package/dist/components/viewer/ground-occluder.js +0 -79
@@ -1,13 +1,35 @@
1
- import { calculateLevelMiters, DEFAULT_WALL_HEIGHT, getAdjacentWallIds, getWallCurveFrameAt, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, pointToKey, resolveLevelId, sceneRegistry, spatialGridManager, useScene, } from '@pascal-app/core';
1
+ import { calculateLevelMiters, DEFAULT_WALL_HEIGHT, getAdjacentWallIds, getEffectiveNode, getWallBandSlotId, getWallCurveFrameAt, getWallFaceBandConfig, getWallFaceBandForHeight, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, getWallThickness, isCurvedWall, pointToKey, resolveLevelId, sceneRegistry, spatialGridManager, useLiveNodeOverrides, useLiveTransforms, useScene, } from '@pascal-app/core';
2
2
  import { useFrame } from '@react-three/fiber';
3
3
  import * as THREE from 'three';
4
4
  import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
5
5
  import { computeBoundsTree } from 'three-mesh-bvh';
6
+ import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils';
7
+ import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, } from './opening-cutout-geometry';
6
8
  // Reusable CSG evaluator for better performance
7
9
  const csgEvaluator = new Evaluator();
10
+ csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'];
8
11
  const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015;
9
12
  const WALL_FACE_NORMAL_Y_EPSILON = 0.6;
10
13
  const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003;
14
+ const WALL_BAND_SPLIT_EPSILON = 1e-5;
15
+ const WALL_BAND_SLOT_MATERIAL_INDEX = {
16
+ interior: 1,
17
+ exterior: 2,
18
+ lowerInterior: 3,
19
+ middleInterior: 4,
20
+ upperInterior: 5,
21
+ topInterior: 6,
22
+ lowerExterior: 7,
23
+ middleExterior: 8,
24
+ upperExterior: 9,
25
+ topExterior: 10,
26
+ skirtingInterior: 0,
27
+ skirtingExterior: 0,
28
+ crownInterior: 0,
29
+ crownExterior: 0,
30
+ chairRailInterior: 0,
31
+ chairRailExterior: 0,
32
+ };
11
33
  function computeGeometryBoundsTree(geometry) {
12
34
  ;
13
35
  geometry.computeBoundsTree = computeBoundsTree;
@@ -16,12 +38,6 @@ function computeGeometryBoundsTree(geometry) {
16
38
  function csgGeometry(brush) {
17
39
  return brush.geometry;
18
40
  }
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
41
  function insetCurvedWallBoundaryPointsFor3D(wall, boundaryPoints, miterData) {
26
42
  if (!(boundaryPoints && isCurvedWall(wall))) {
27
43
  return boundaryPoints;
@@ -117,14 +133,15 @@ function distanceToWallBoundaryEdge(point, edge) {
117
133
  const closestZ = edge.start.y + edgeDz * t;
118
134
  return Math.hypot(point.x - closestX, point.y - closestZ);
119
135
  }
120
- function getWallFaceMaterialIndex(wall, face) {
136
+ function getWallFaceMaterialIndex(wall, face, y) {
121
137
  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;
138
+ const fallback = face === 'front' ? 'interior' : 'exterior';
139
+ const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback;
140
+ const bands = getWallFaceBandConfig(wall);
141
+ if (!bands.enabled)
142
+ return WALL_BAND_SLOT_MATERIAL_INDEX[side];
143
+ const band = getWallFaceBandForHeight(wall, y);
144
+ return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)];
128
145
  }
129
146
  function assignWallMaterialGroups(geometry, wall, boundaryEdges) {
130
147
  const position = geometry.getAttribute('position');
@@ -189,7 +206,7 @@ function assignWallMaterialGroups(geometry, wall, boundaryEdges) {
189
206
  triangleMaterials[triangleIndex] = 0;
190
207
  continue;
191
208
  }
192
- triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag);
209
+ triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y);
193
210
  }
194
211
  geometry.clearGroups();
195
212
  let currentMaterial = triangleMaterials[0] ?? 0;
@@ -204,53 +221,261 @@ function assignWallMaterialGroups(geometry, wall, boundaryEdges) {
204
221
  }
205
222
  geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial);
206
223
  }
224
+ function interpolateSplitVertex(a, b, t) {
225
+ return {
226
+ x: a.x + (b.x - a.x) * t,
227
+ y: a.y + (b.y - a.y) * t,
228
+ z: a.z + (b.z - a.z) * t,
229
+ };
230
+ }
231
+ function clipPolygonByY(polygon, planeY, keepBelow) {
232
+ const out = [];
233
+ if (polygon.length === 0)
234
+ return out;
235
+ const isInside = (vertex) => keepBelow
236
+ ? vertex.y <= planeY + WALL_BAND_SPLIT_EPSILON
237
+ : vertex.y >= planeY - WALL_BAND_SPLIT_EPSILON;
238
+ for (let index = 0; index < polygon.length; index += 1) {
239
+ const current = polygon[index];
240
+ const previous = polygon[(index + polygon.length - 1) % polygon.length];
241
+ const currentInside = isInside(current);
242
+ const previousInside = isInside(previous);
243
+ if (currentInside !== previousInside) {
244
+ const denom = current.y - previous.y;
245
+ if (Math.abs(denom) > WALL_BAND_SPLIT_EPSILON) {
246
+ out.push(interpolateSplitVertex(previous, current, (planeY - previous.y) / denom));
247
+ }
248
+ }
249
+ if (currentInside)
250
+ out.push(current);
251
+ }
252
+ return out;
253
+ }
254
+ function triangulateSplitPolygon(polygon, positions) {
255
+ if (polygon.length < 3)
256
+ return;
257
+ const first = polygon[0];
258
+ for (let index = 1; index < polygon.length - 1; index += 1) {
259
+ const b = polygon[index];
260
+ const c = polygon[index + 1];
261
+ positions.push(first.x, first.y, first.z, b.x, b.y, b.z, c.x, c.y, c.z);
262
+ }
263
+ }
264
+ function splitGeometryAtHorizontalPlanes(geometry, planes) {
265
+ const splitPlanes = Array.from(new Set(planes
266
+ .filter((plane) => Number.isFinite(plane) && plane > WALL_BAND_SPLIT_EPSILON)
267
+ .map((plane) => Math.round(plane / WALL_BAND_SPLIT_EPSILON) * WALL_BAND_SPLIT_EPSILON))).sort((a, b) => a - b);
268
+ if (splitPlanes.length === 0)
269
+ return geometry;
270
+ const source = geometry.index ? geometry.toNonIndexed() : geometry;
271
+ const position = source.getAttribute('position');
272
+ if (!position || position.count === 0)
273
+ return source;
274
+ const positions = [];
275
+ for (let index = 0; index < position.count; index += 3) {
276
+ let polygons = [
277
+ [
278
+ { x: position.getX(index), y: position.getY(index), z: position.getZ(index) },
279
+ { x: position.getX(index + 1), y: position.getY(index + 1), z: position.getZ(index + 1) },
280
+ { x: position.getX(index + 2), y: position.getY(index + 2), z: position.getZ(index + 2) },
281
+ ],
282
+ ];
283
+ for (const plane of splitPlanes) {
284
+ const next = [];
285
+ for (const polygon of polygons) {
286
+ const minY = Math.min(...polygon.map((vertex) => vertex.y));
287
+ const maxY = Math.max(...polygon.map((vertex) => vertex.y));
288
+ if (plane <= minY + WALL_BAND_SPLIT_EPSILON || plane >= maxY - WALL_BAND_SPLIT_EPSILON) {
289
+ next.push(polygon);
290
+ continue;
291
+ }
292
+ const below = clipPolygonByY(polygon, plane, true);
293
+ const above = clipPolygonByY(polygon, plane, false);
294
+ if (below.length >= 3)
295
+ next.push(below);
296
+ if (above.length >= 3)
297
+ next.push(above);
298
+ }
299
+ polygons = next;
300
+ }
301
+ for (const polygon of polygons)
302
+ triangulateSplitPolygon(polygon, positions);
303
+ }
304
+ if (source !== geometry)
305
+ geometry.dispose();
306
+ source.dispose();
307
+ const split = new THREE.BufferGeometry();
308
+ split.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
309
+ split.computeVertexNormals();
310
+ return split;
311
+ }
312
+ function getWallBandSplitPlanes(wall) {
313
+ const bands = getWallFaceBandConfig(wall);
314
+ if (!bands.enabled)
315
+ return [];
316
+ const planes = [bands.lowerTop];
317
+ if (bands.count >= 3)
318
+ planes.push(bands.middleTop);
319
+ if (bands.count >= 4)
320
+ planes.push(bands.upperTop);
321
+ return planes.filter((plane) => plane > WALL_BAND_SPLIT_EPSILON && plane < (wall.height ?? 2.5) - WALL_BAND_SPLIT_EPSILON);
322
+ }
207
323
  // ============================================================================
208
324
  // WALL SYSTEM
209
325
  // ============================================================================
210
326
  let useFrameNb = 0;
327
+ // ─── Drag-throttle state (singleton — one WallSystem mounted globally) ──
328
+ //
329
+ // Endpoint drags fire `markDirty(wallId)` on every pointermove tick. Without
330
+ // throttling, each tick rebuilds the dragged wall (~1 CSG + miter pass) AND
331
+ // every adjacent wall sharing a corner (3–4× in a t-junction or room).
332
+ // Visible as drag lag, especially on walls with door/window cutouts.
333
+ //
334
+ // Strategy: rebuild the dragged wall every tick (so the drag follows the
335
+ // cursor with full fidelity), but defer adjacent rebuilds to a trailing-
336
+ // edge flush DRAG_FLUSH_MS after the dirty stream stops. Visually, neighbor
337
+ // corners stay at their pre-drag miter until release, then snap into place
338
+ // within ~80ms. Standard CAD-app behavior. Speeds up t-junction drags ~3×,
339
+ // 4-corner-room drags ~4×.
340
+ const DRAG_FLUSH_MS = 80;
341
+ const MAX_WALL_REBUILDS_PER_FRAME = 8;
342
+ const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME;
343
+ const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8;
344
+ let lastWallDirtyAtMs = 0;
345
+ const pendingAdjacentByLevel = new Map();
346
+ function getPendingAdjacentCount() {
347
+ let count = 0;
348
+ for (const ids of pendingAdjacentByLevel.values()) {
349
+ count += ids.size;
350
+ }
351
+ return count;
352
+ }
211
353
  export const WallSystem = () => {
212
354
  const dirtyNodes = useScene((state) => state.dirtyNodes);
213
355
  const clearDirty = useScene((state) => state.clearDirty);
356
+ // Subscribe so override-only changes (no scene write) still re-run
357
+ // this component, which lets the gate below pick up the latest
358
+ // `dirtyNodes` set from the same render pass that received the
359
+ // override-publishing `markDirty` call. Without this, very fast
360
+ // drags could land an override and a markDirty in the same React
361
+ // tick and the next `useFrame` would still see the stale closure.
362
+ useLiveNodeOverrides((s) => s.overrides);
214
363
  useFrame(() => {
215
- if (dirtyNodes.size === 0)
364
+ const hasDirty = dirtyNodes.size > 0;
365
+ const hasPending = pendingAdjacentByLevel.size > 0;
366
+ if (!hasDirty && !hasPending)
216
367
  return;
217
368
  const nodes = useScene.getState().nodes;
369
+ const now = performance.now();
218
370
  // Collect dirty walls and their levels
219
371
  const dirtyWallsByLevel = new Map();
372
+ let dirtyWallCount = 0;
220
373
  useFrameNb += 1;
221
- dirtyNodes.forEach((id) => {
222
- const node = nodes[id];
223
- if (!node || node.type !== 'wall')
224
- return;
225
- const levelId = node.parentId;
226
- if (!levelId)
227
- return;
228
- if (!dirtyWallsByLevel.has(levelId)) {
229
- dirtyWallsByLevel.set(levelId, new Set());
230
- }
231
- dirtyWallsByLevel.get(levelId)?.add(id);
232
- });
374
+ if (hasDirty) {
375
+ dirtyNodes.forEach((id) => {
376
+ const node = nodes[id];
377
+ if (node?.type !== 'wall')
378
+ return;
379
+ const levelId = node.parentId;
380
+ if (!levelId)
381
+ return;
382
+ if (!dirtyWallsByLevel.has(levelId)) {
383
+ dirtyWallsByLevel.set(levelId, new Set());
384
+ }
385
+ dirtyWallsByLevel.get(levelId)?.add(id);
386
+ dirtyWallCount += 1;
387
+ });
388
+ }
389
+ const hasDirtyWalls = dirtyWallsByLevel.size > 0;
390
+ if (hasDirtyWalls) {
391
+ lastWallDirtyAtMs = now;
392
+ }
393
+ const useProgressiveWallRebuilds = dirtyWallCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD;
394
+ let rebuiltWallsThisFrame = 0;
395
+ const rebuildFrameStartedAt = now;
233
396
  // Process each level that has dirty walls
234
397
  for (const [levelId, dirtyWallIds] of dirtyWallsByLevel) {
398
+ if (useProgressiveWallRebuilds && rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
399
+ break;
400
+ }
235
401
  const levelWalls = getLevelWalls(levelId);
236
402
  const miterData = calculateLevelMiters(levelWalls);
237
- // Update dirty walls
403
+ const rebuiltWallIds = new Set();
404
+ // Update dirty walls — always, no throttling. The dragged wall must
405
+ // follow the cursor with full fidelity (cutouts and all). Large imports
406
+ // enter the progressive path so initial load can't lock the tab.
238
407
  for (const wallId of dirtyWallIds) {
408
+ if (useProgressiveWallRebuilds) {
409
+ if (rebuiltWallsThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
410
+ break;
411
+ }
412
+ if (rebuiltWallsThisFrame > 0 &&
413
+ performance.now() - rebuildFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS) {
414
+ break;
415
+ }
416
+ }
239
417
  const mesh = sceneRegistry.nodes.get(wallId);
240
418
  if (mesh) {
241
419
  updateWallGeometry(wallId, miterData);
242
420
  clearDirty(wallId);
421
+ rebuiltWallIds.add(wallId);
422
+ rebuiltWallsThisFrame += 1;
243
423
  }
244
424
  // If mesh not found, keep it dirty for next frame
245
425
  }
246
- // Update adjacent walls that share junctions
247
- const adjacentWallIds = getAdjacentWallIds(levelWalls, dirtyWallIds);
426
+ if (rebuiltWallIds.size === 0) {
427
+ continue;
428
+ }
429
+ // Adjacent walls sharing junctions — *defer* during active drag
430
+ // (dirty arrived this frame), flush on the trailing edge.
431
+ const adjacentWallIds = getAdjacentWallIds(levelWalls, rebuiltWallIds);
432
+ let pending = pendingAdjacentByLevel.get(levelId);
433
+ if (!pending) {
434
+ pending = new Set();
435
+ pendingAdjacentByLevel.set(levelId, pending);
436
+ }
248
437
  for (const wallId of adjacentWallIds) {
249
438
  if (!dirtyWallIds.has(wallId)) {
439
+ pending.add(wallId);
440
+ }
441
+ }
442
+ }
443
+ // Trailing-edge flush: if no new dirty marks for DRAG_FLUSH_MS, the
444
+ // drag has ended — rebuild the queued neighbors so corners snap into
445
+ // their correct miter joins.
446
+ const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS;
447
+ if (quiet && pendingAdjacentByLevel.size > 0) {
448
+ const pendingCount = getPendingAdjacentCount();
449
+ const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD;
450
+ let rebuiltAdjacentThisFrame = 0;
451
+ const adjacentFrameStartedAt = performance.now();
452
+ for (const [levelId, pendingIds] of pendingAdjacentByLevel) {
453
+ if (pendingIds.size === 0)
454
+ continue;
455
+ const levelWalls = getLevelWalls(levelId);
456
+ const miterData = calculateLevelMiters(levelWalls);
457
+ for (const wallId of Array.from(pendingIds)) {
458
+ if (useProgressiveAdjacentRebuilds) {
459
+ if (rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
460
+ break;
461
+ }
462
+ if (rebuiltAdjacentThisFrame > 0 &&
463
+ performance.now() - adjacentFrameStartedAt >= WALL_PROGRESSIVE_TIME_BUDGET_MS) {
464
+ break;
465
+ }
466
+ }
250
467
  const mesh = sceneRegistry.nodes.get(wallId);
251
- if (mesh) {
468
+ if (mesh)
252
469
  updateWallGeometry(wallId, miterData);
253
- }
470
+ pendingIds.delete(wallId);
471
+ rebuiltAdjacentThisFrame += 1;
472
+ }
473
+ if (pendingIds.size === 0) {
474
+ pendingAdjacentByLevel.delete(levelId);
475
+ }
476
+ if (useProgressiveAdjacentRebuilds &&
477
+ rebuiltAdjacentThisFrame >= MAX_WALL_REBUILDS_PER_FRAME) {
478
+ break;
254
479
  }
255
480
  }
256
481
  }
@@ -258,46 +483,88 @@ export const WallSystem = () => {
258
483
  return null;
259
484
  };
260
485
  /**
261
- * Gets all walls that belong to a level
486
+ * Merge any live override for a wall into the scene record. Lets the
487
+ * 2D move handler publish `{ start, end, curveOffset }` to
488
+ * `useLiveNodeOverrides` and have the geometry / miter pipeline use
489
+ * those values without zustand churn during the drag. When no
490
+ * override is set, the wall is returned unchanged.
491
+ */
492
+ function getEffectiveWall(wall) {
493
+ const override = useLiveNodeOverrides.getState().get(wall.id);
494
+ if (!override || Object.keys(override).length === 0)
495
+ return wall;
496
+ return { ...wall, ...override };
497
+ }
498
+ /**
499
+ * Gets all walls that belong to a level, with any live overrides
500
+ * merged in so miters compute against the cursor-driven positions
501
+ * (not the pre-drag scene state).
262
502
  */
263
503
  function getLevelWalls(levelId) {
264
504
  const { nodes } = useScene.getState();
265
505
  const level = nodes[levelId];
266
- if (!level || level.type !== 'level')
506
+ if (level?.type !== 'level')
267
507
  return [];
268
508
  const walls = [];
269
509
  for (const childId of level.children) {
270
510
  const child = nodes[childId];
271
511
  if (child?.type === 'wall') {
272
- walls.push(child);
512
+ walls.push(getEffectiveWall(child));
273
513
  }
274
514
  }
275
515
  return walls;
276
516
  }
277
517
  /**
278
- * Updates the geometry for a single wall
518
+ * Updates the geometry for a single wall. Reads the effective node
519
+ * (override-merged) so a 2D drag visibly moves the 3D mesh without
520
+ * having touched `useScene` mid-drag.
279
521
  */
280
522
  function updateWallGeometry(wallId, miterData) {
281
523
  const nodes = useScene.getState().nodes;
282
- const node = nodes[wallId];
283
- if (!node || node.type !== 'wall')
524
+ const sceneNode = nodes[wallId];
525
+ if (sceneNode?.type !== 'wall')
284
526
  return;
527
+ const node = getEffectiveWall(sceneNode);
285
528
  const mesh = sceneRegistry.nodes.get(wallId);
286
529
  if (!mesh)
287
530
  return;
288
531
  const levelId = resolveLevelId(node, nodes);
289
- const slabElevation = spatialGridManager.getSlabElevationForWall(levelId, node.start, node.end);
532
+ const slabSupport = spatialGridManager.getSlabSupportForWall(levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness);
533
+ const slabElevation = slabSupport.elevation;
290
534
  const childrenIds = node.children || [];
535
+ // Merge live overrides into door / window children so cutouts track an
536
+ // in-flight resize drag (door width arrow, window height arrow, etc.)
537
+ // without waiting on the scene store. Non-cutout children pass through
538
+ // unchanged.
291
539
  const childrenNodes = childrenIds
292
540
  .map((childId) => nodes[childId])
293
- .filter((n) => n !== undefined);
294
- const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation);
541
+ .filter((n) => n !== undefined)
542
+ .map((child) => {
543
+ if (child.type !== 'door' && child.type !== 'window')
544
+ return child;
545
+ // `getEffectiveNode` folds in resize overrides (width/height arrows).
546
+ // Position moves publish to `useLiveTransforms` instead, so fold that
547
+ // in too — opening cutout brushes are rebuilt directly from the
548
+ // effective node position rather than from the rendered proxy mesh.
549
+ const effective = getEffectiveNode(child);
550
+ const live = useLiveTransforms.getState().get(child.id);
551
+ if (!live?.position)
552
+ return effective;
553
+ return { ...effective, position: live.position };
554
+ });
555
+ const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments);
556
+ const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
557
+ // World transform the render mesh will apply (position + Y-rotation below).
558
+ // Reproduce it here so the UVs can be projected in WORLD space — see
559
+ // `applyWorldPlanarWallUVs`.
560
+ 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);
561
+ const newGeo = applyWorldPlanarWallUVs(builtGeo, wallWorldMatrix);
295
562
  mesh.geometry.dispose();
296
563
  mesh.geometry = newGeo;
297
564
  // Update collision mesh
298
565
  const collisionMesh = mesh.getObjectByName('collision-mesh');
299
566
  if (collisionMesh) {
300
- const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation);
567
+ const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments);
301
568
  collisionMesh.geometry.dispose();
302
569
  collisionMesh.geometry = collisionGeo;
303
570
  }
@@ -305,19 +572,79 @@ function updateWallGeometry(wallId, miterData) {
305
572
  const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
306
573
  mesh.rotation.y = -angle;
307
574
  }
575
+ const WALL_UV_Y_AXIS = new THREE.Vector3(0, 1, 0);
576
+ const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
577
+ /**
578
+ * Re-project a wall's UVs in WORLD space (1 UV unit = 1 m) so the finish tiles
579
+ * continuously across adjacent walls and lines up with the roof gable above —
580
+ * instead of THREE's `ExtrudeGeometry` UVs, which restart at each wall's own
581
+ * start/end. Matches `roof-system`'s `pushRoofUv` projection exactly: vertical
582
+ * faces use `U = ±worldX/Z` (the axis across the face normal) and `V = 1 -
583
+ * worldY`; the thin top/bottom caps use `(worldX, worldZ)`. De-indexes first so
584
+ * every triangle projects by its own face normal (no shared-vertex seams at
585
+ * edges). Applied only to the render mesh; collision/floorplan geometry is
586
+ * untouched.
587
+ */
588
+ function applyWorldPlanarWallUVs(geometry, worldMatrix) {
589
+ const target = geometry.index ? geometry.toNonIndexed() : geometry;
590
+ if (target !== geometry)
591
+ geometry.dispose();
592
+ const position = target.getAttribute('position');
593
+ if (!position || position.count === 0)
594
+ return target;
595
+ const a = new THREE.Vector3();
596
+ const b = new THREE.Vector3();
597
+ const c = new THREE.Vector3();
598
+ const normal = new THREE.Vector3();
599
+ const edgeAB = new THREE.Vector3();
600
+ const edgeAC = new THREE.Vector3();
601
+ const uvs = new Float32Array(position.count * 2);
602
+ for (let i = 0; i < position.count; i += 3) {
603
+ a.fromBufferAttribute(position, i).applyMatrix4(worldMatrix);
604
+ b.fromBufferAttribute(position, i + 1).applyMatrix4(worldMatrix);
605
+ c.fromBufferAttribute(position, i + 2).applyMatrix4(worldMatrix);
606
+ edgeAB.subVectors(b, a);
607
+ edgeAC.subVectors(c, a);
608
+ normal.crossVectors(edgeAB, edgeAC).normalize();
609
+ const absX = Math.abs(normal.x);
610
+ const absY = Math.abs(normal.y);
611
+ const absZ = Math.abs(normal.z);
612
+ for (let k = 0; k < 3; k += 1) {
613
+ const p = k === 0 ? a : k === 1 ? b : c;
614
+ let u;
615
+ let v;
616
+ if (absY >= absX && absY >= absZ) {
617
+ u = p.x;
618
+ v = p.z;
619
+ }
620
+ else {
621
+ v = 1 - p.y;
622
+ u = absX >= absZ ? (normal.x >= 0 ? p.z : -p.z) : normal.z >= 0 ? p.x : -p.x;
623
+ }
624
+ uvs[(i + k) * 2] = u;
625
+ uvs[(i + k) * 2 + 1] = v;
626
+ }
627
+ }
628
+ target.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
629
+ target.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2));
630
+ return target;
631
+ }
308
632
  /**
309
633
  * Generates extruded wall geometry with mitering and cutouts
310
634
  *
311
635
  * Key insight from demo: polygon is built in WORLD coordinates first,
312
636
  * then we transform to wall-local for the 3D mesh.
313
637
  */
314
- export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabElevation = 0) {
638
+ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabElevation = 0, baseElevation = slabElevation, baseSegments = [
639
+ { start: 0, end: 1, elevation: baseElevation },
640
+ ]) {
315
641
  const wallStart = { x: wallNode.start[0], y: wallNode.start[1] };
316
642
  const wallEnd = { x: wallNode.end[0], y: wallNode.end[1] };
317
- // Positive slab: shift the whole wall up (full height preserved)
318
- // Negative slab: extend wall downward so top stays fixed at wallNode.height
319
643
  const wallHeight = wallNode.height ?? DEFAULT_WALL_HEIGHT;
320
- const height = slabElevation > 0 ? wallHeight : wallHeight - slabElevation;
644
+ const topElevation = slabElevation > 0 ? slabElevation + wallHeight : wallHeight;
645
+ const effectiveBaseElevation = Math.min(baseElevation, slabElevation);
646
+ const localBottom = effectiveBaseElevation - slabElevation;
647
+ const height = topElevation - effectiveBaseElevation;
321
648
  const thickness = getWallThickness(wallNode);
322
649
  // Wall direction and normal (exactly like demo)
323
650
  const v = { x: wallEnd.x - wallStart.x, y: wallEnd.y - wallStart.y };
@@ -364,24 +691,118 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
364
691
  });
365
692
  // Rotate so extrusion direction (Z) becomes height direction (Y)
366
693
  geometry.rotateX(-Math.PI / 2);
694
+ if (Math.abs(localBottom) > 1e-9)
695
+ geometry.translate(0, localBottom, 0);
367
696
  geometry.computeVertexNormals();
368
697
  assignWallMaterialGroups(geometry, wallNode, boundaryEdges);
369
- ensureUv2Attribute(geometry);
370
- // Apply CSG subtraction for cutouts (doors/windows)
371
- const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness);
698
+ ensureRenderableGeometryAttributes(geometry);
699
+ // Start with the lowest required wall prism, then remove the volume below
700
+ // each higher-supported run. This keeps the existing mitered footprint and
701
+ // opening CSG while giving one wall a stepped longitudinal base.
702
+ const baseProfileCutouts = [];
703
+ for (const segment of baseSegments) {
704
+ const segmentElevation = Math.min(segment.elevation, slabElevation);
705
+ const cutHeight = segmentElevation - effectiveBaseElevation;
706
+ if (cutHeight <= 1e-6 || segment.end - segment.start <= 1e-7)
707
+ continue;
708
+ const segmentStart = THREE.MathUtils.clamp(segment.start, 0, 1);
709
+ const segmentEnd = THREE.MathUtils.clamp(segment.end, 0, 1);
710
+ const cutHalfWidth = Math.max(thickness * 2, 0.2);
711
+ const worldCutoutPoints = [];
712
+ if (isCurvedWall(wallNode)) {
713
+ const sampleCount = Math.max(2, Math.ceil((segmentEnd - segmentStart) * 24));
714
+ const left = [];
715
+ const right = [];
716
+ for (let index = 0; index <= sampleCount; index++) {
717
+ const t = segmentStart + ((segmentEnd - segmentStart) * index) / sampleCount;
718
+ const frame = getWallCurveFrameAt(wallNode, t);
719
+ const endpointExtension = index === 0 && segmentStart <= 1e-7
720
+ ? -cutHalfWidth
721
+ : index === sampleCount && segmentEnd >= 1 - 1e-7
722
+ ? cutHalfWidth
723
+ : 0;
724
+ const center = {
725
+ x: frame.point.x + frame.tangent.x * endpointExtension,
726
+ y: frame.point.y + frame.tangent.y * endpointExtension,
727
+ };
728
+ left.push({
729
+ x: center.x + frame.normal.x * cutHalfWidth,
730
+ y: center.y + frame.normal.y * cutHalfWidth,
731
+ });
732
+ right.push({
733
+ x: center.x - frame.normal.x * cutHalfWidth,
734
+ y: center.y - frame.normal.y * cutHalfWidth,
735
+ });
736
+ }
737
+ worldCutoutPoints.push(...left, ...right.reverse());
738
+ }
739
+ else {
740
+ const tangentX = v.x / L;
741
+ const tangentY = v.y / L;
742
+ const normalX = -tangentY;
743
+ const normalY = tangentX;
744
+ const startExtension = segmentStart <= 1e-7 ? cutHalfWidth : 0;
745
+ const endExtension = segmentEnd >= 1 - 1e-7 ? cutHalfWidth : 0;
746
+ const startPoint = {
747
+ x: wallStart.x + tangentX * (segmentStart * L - startExtension),
748
+ y: wallStart.y + tangentY * (segmentStart * L - startExtension),
749
+ };
750
+ const endPoint = {
751
+ x: wallStart.x + tangentX * (segmentEnd * L + endExtension),
752
+ y: wallStart.y + tangentY * (segmentEnd * L + endExtension),
753
+ };
754
+ worldCutoutPoints.push({
755
+ x: startPoint.x + normalX * cutHalfWidth,
756
+ y: startPoint.y + normalY * cutHalfWidth,
757
+ }, { x: endPoint.x + normalX * cutHalfWidth, y: endPoint.y + normalY * cutHalfWidth }, { x: endPoint.x - normalX * cutHalfWidth, y: endPoint.y - normalY * cutHalfWidth }, {
758
+ x: startPoint.x - normalX * cutHalfWidth,
759
+ y: startPoint.y - normalY * cutHalfWidth,
760
+ });
761
+ }
762
+ const localCutoutPoints = worldCutoutPoints.map(worldToLocal);
763
+ if (localCutoutPoints.length < 3)
764
+ continue;
765
+ const cutoutShape = new THREE.Shape();
766
+ cutoutShape.moveTo(localCutoutPoints[0].x, -localCutoutPoints[0].z);
767
+ for (let index = 1; index < localCutoutPoints.length; index++) {
768
+ cutoutShape.lineTo(localCutoutPoints[index].x, -localCutoutPoints[index].z);
769
+ }
770
+ cutoutShape.closePath();
771
+ const cutoutBottom = localBottom - 0.01;
772
+ const cutoutTop = segmentElevation - slabElevation;
773
+ const cutoutGeometry = new THREE.ExtrudeGeometry(cutoutShape, {
774
+ depth: cutoutTop - cutoutBottom,
775
+ bevelEnabled: false,
776
+ });
777
+ cutoutGeometry.rotateX(-Math.PI / 2);
778
+ cutoutGeometry.translate(0, cutoutBottom, 0);
779
+ computeGeometryBoundsTree(cutoutGeometry);
780
+ baseProfileCutouts.push(new Brush(cutoutGeometry));
781
+ }
782
+ // Apply base-profile and opening cutouts in one CSG pass.
783
+ const cutoutBrushes = [
784
+ ...baseProfileCutouts,
785
+ ...collectCutoutBrushes(wallNode, childrenNodes, thickness),
786
+ ];
372
787
  if (cutoutBrushes.length === 0) {
373
- return geometry;
788
+ const splitGeometry = splitGeometryAtHorizontalPlanes(geometry, getWallBandSplitPlanes(wallNode));
789
+ splitGeometry.computeVertexNormals();
790
+ assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges);
791
+ ensureRenderableGeometryAttributes(splitGeometry);
792
+ return splitGeometry;
374
793
  }
375
794
  // Create wall brush from geometry
376
795
  // Pre-compute BVH with new API to avoid deprecation warning
796
+ ensureRenderableGeometryAttributes(geometry);
377
797
  computeGeometryBoundsTree(geometry);
378
798
  const wallBrush = new Brush(geometry);
379
799
  wallBrush.updateMatrixWorld();
380
800
  // Subtract each cutout from the wall
381
801
  let resultBrush = wallBrush;
382
802
  for (const cutoutBrush of cutoutBrushes) {
383
- cutoutBrush.updateMatrixWorld();
803
+ prepareBrushForCSG(cutoutBrush);
384
804
  const newResult = csgEvaluator.evaluate(resultBrush, cutoutBrush, SUBTRACTION);
805
+ prepareBrushForCSG(newResult);
385
806
  if (resultBrush !== wallBrush) {
386
807
  csgGeometry(resultBrush).dispose();
387
808
  }
@@ -393,14 +814,16 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
393
814
  csgGeometry(brush).dispose();
394
815
  }
395
816
  const resultGeometry = csgGeometry(resultBrush);
396
- resultGeometry.computeVertexNormals();
397
- assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges);
398
- ensureUv2Attribute(resultGeometry);
399
- return resultGeometry;
817
+ const splitResultGeometry = splitGeometryAtHorizontalPlanes(resultGeometry, getWallBandSplitPlanes(wallNode));
818
+ splitResultGeometry.computeVertexNormals();
819
+ assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges);
820
+ ensureRenderableGeometryAttributes(splitResultGeometry);
821
+ return splitResultGeometry;
400
822
  }
401
823
  /**
402
- * Collects cutout brushes from child items for CSG subtraction
403
- * The cutout mesh is a plane, so we extrude it into a box that goes through the wall
824
+ * Collects opening and item cutout brushes for CSG subtraction. Door/window
825
+ * cuts come directly from node geometry; item proxy meshes are transformed
826
+ * into wall-local boxes that pass through the wall.
404
827
  */
405
828
  function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
406
829
  const brushes = [];
@@ -413,15 +836,8 @@ function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
413
836
  for (const child of childrenNodes) {
414
837
  if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door')
415
838
  continue;
416
- if ((child.type === 'door' && child.openingKind === 'opening') ||
417
- (child.type === 'door' &&
418
- child.openingKind === 'door' &&
419
- (child.openingShape === 'arch' || child.openingShape === 'rounded')) ||
420
- (child.type === 'window' && child.openingKind === 'opening') ||
421
- (child.type === 'window' &&
422
- child.openingKind === 'window' &&
423
- (child.openingShape === 'arch' || child.openingShape === 'rounded'))) {
424
- brushes.push(createShapedOpeningCutoutBrush(child, wallThickness));
839
+ if (child.type === 'door' || child.type === 'window') {
840
+ brushes.push(createOpeningCutoutBrush(child, wallThickness));
425
841
  continue;
426
842
  }
427
843
  const childMesh = sceneRegistry.nodes.get(child.id);
@@ -464,132 +880,16 @@ function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
464
880
  }
465
881
  return brushes;
466
882
  }
467
- function createShapedOpeningCutoutBrush(opening, wallThickness) {
468
- const shape = createShapedOpeningCutoutShape(opening);
469
- const depth = wallThickness * 2;
470
- const bevelSize = opening.openingShape === 'rounded'
471
- ? 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))
472
- : 0;
473
- const geometry = new THREE.ExtrudeGeometry(shape, {
474
- depth,
475
- bevelEnabled: bevelSize > 0,
476
- bevelSegments: bevelSize > 0 ? 8 : 0,
477
- bevelSize,
478
- bevelThickness: bevelSize,
479
- curveSegments: 24,
480
- });
481
- geometry.translate(0, 0, -depth / 2);
482
- computeGeometryBoundsTree(geometry);
483
- return new Brush(geometry);
484
- }
485
- function createShapedOpeningCutoutShape(opening) {
883
+ function createOpeningCutoutBrush(opening, wallThickness) {
486
884
  const halfWidth = opening.width / 2;
487
885
  const bottom = opening.position[1] - opening.height / 2;
488
- const top = opening.position[1] + opening.height / 2;
489
- const centerX = opening.position[0];
490
- const left = centerX - halfWidth;
491
- const right = centerX + halfWidth;
492
- const width = Math.max(opening.width, 1e-6);
493
- const height = Math.max(opening.height, 1e-6);
494
- const shape = new THREE.Shape();
495
- if (opening.openingShape === 'arch') {
496
- const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height);
497
- const springY = top - archHeight;
498
- const segments = 32;
499
- shape.moveTo(left, bottom);
500
- shape.lineTo(right, bottom);
501
- shape.lineTo(right, springY);
502
- for (let index = 1; index <= segments; index += 1) {
503
- const x = right + (left - right) * (index / segments);
504
- const normalizedX = Math.min(Math.abs((x - centerX) / halfWidth), 1);
505
- const y = springY + archHeight * Math.sqrt(Math.max(1 - normalizedX * normalizedX, 0));
506
- shape.lineTo(x, y);
507
- }
508
- shape.lineTo(left, bottom);
509
- shape.closePath();
510
- return shape;
511
- }
512
- if (opening.openingShape === 'rounded') {
513
- const radii = getRoundedOpeningRadii(opening, width, height);
514
- applyRoundedOpeningShape(shape, left, right, bottom, top, radii);
515
- return shape;
516
- }
517
- shape.moveTo(left, bottom);
518
- shape.lineTo(right, bottom);
519
- shape.lineTo(right, top);
520
- shape.lineTo(left, top);
521
- shape.closePath();
522
- return shape;
523
- }
524
- function getRoundedOpeningRadii(opening, width, height) {
525
- if (opening.type !== 'window') {
526
- if (opening.openingRadiusMode === 'individual') {
527
- const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15];
528
- return normalizeCornerRadii({
529
- topLeft: Math.max(topLeft, 0),
530
- topRight: Math.max(topRight, 0),
531
- bottomRight: 0,
532
- bottomLeft: 0,
533
- }, width, height);
534
- }
535
- const maxRadius = Math.min(width / 2, height);
536
- const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius);
537
- return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 };
538
- }
539
- if (opening.openingRadiusMode === 'individual') {
540
- const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] = opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15];
541
- return normalizeCornerRadii({
542
- topLeft: Math.max(topLeft, 0),
543
- topRight: Math.max(topRight, 0),
544
- bottomRight: Math.max(bottomRight, 0),
545
- bottomLeft: Math.max(bottomLeft, 0),
546
- }, width, height);
547
- }
548
- const maxRadius = Math.min(width / 2, height / 2);
549
- const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius);
550
- return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius };
551
- }
552
- function normalizeCornerRadii(radii, width, height) {
553
- const next = { ...radii };
554
- 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));
555
- if (maxScale < 1) {
556
- next.topLeft *= maxScale;
557
- next.topRight *= maxScale;
558
- next.bottomRight *= maxScale;
559
- next.bottomLeft *= maxScale;
560
- }
561
- return next;
562
- }
563
- function applyRoundedOpeningShape(shape, left, right, bottom, top, radii) {
564
- const { topLeft, topRight, bottomRight, bottomLeft } = radii;
565
- shape.moveTo(left + bottomLeft, bottom);
566
- shape.lineTo(right - bottomRight, bottom);
567
- if (bottomRight > 1e-6) {
568
- shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false);
569
- }
570
- else {
571
- shape.lineTo(right, bottom);
572
- }
573
- shape.lineTo(right, top - topRight);
574
- if (topRight > 1e-6) {
575
- shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false);
576
- }
577
- else {
578
- shape.lineTo(right, top);
579
- }
580
- shape.lineTo(left + topLeft, top);
581
- if (topLeft > 1e-6) {
582
- shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false);
583
- }
584
- else {
585
- shape.lineTo(left, top);
586
- }
587
- shape.lineTo(left, bottom + bottomLeft);
588
- if (bottomLeft > 1e-6) {
589
- shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false);
590
- }
591
- else {
592
- shape.lineTo(left, bottom);
593
- }
594
- shape.closePath();
886
+ const bottomPadding = getOpeningCutoutBottomPadding(opening, bottom);
887
+ const geometry = buildOpeningCutoutGeometry(opening, {
888
+ left: opening.position[0] - halfWidth,
889
+ right: opening.position[0] + halfWidth,
890
+ bottom: bottom - bottomPadding,
891
+ top: opening.position[1] + opening.height / 2,
892
+ }, wallThickness * 2, wallThickness);
893
+ computeGeometryBoundsTree(geometry);
894
+ return new Brush(geometry);
595
895
  }