@pascal-app/viewer 1.0.3 → 1.0.4

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 (164) hide show
  1. package/dist/components/renderers/node-renderer.d.ts.map +1 -1
  2. package/dist/components/renderers/node-renderer.js +8 -3
  3. package/dist/components/viewer/frame-limiter.d.ts +1 -0
  4. package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
  5. package/dist/components/viewer/frame-limiter.js +22 -3
  6. package/dist/components/viewer/glb-interactive.d.ts +25 -1
  7. package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
  8. package/dist/components/viewer/glb-interactive.js +252 -85
  9. package/dist/components/viewer/glb-mechanisms.d.ts +10 -0
  10. package/dist/components/viewer/glb-mechanisms.d.ts.map +1 -0
  11. package/dist/components/viewer/glb-mechanisms.js +19 -0
  12. package/dist/components/viewer/glb-scene.d.ts +45 -27
  13. package/dist/components/viewer/glb-scene.d.ts.map +1 -1
  14. package/dist/components/viewer/glb-scene.js +420 -506
  15. package/dist/components/viewer/glb-walkthrough-controller.d.ts +1 -1
  16. package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
  17. package/dist/components/viewer/glb-walkthrough-controller.js +12 -1
  18. package/dist/components/viewer/index.d.ts +14 -0
  19. package/dist/components/viewer/index.d.ts.map +1 -1
  20. package/dist/components/viewer/index.js +84 -26
  21. package/dist/components/viewer/lights.d.ts.map +1 -1
  22. package/dist/components/viewer/lights.js +6 -1
  23. package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
  24. package/dist/components/viewer/perf-monitor.js +6 -0
  25. package/dist/components/viewer/post-processing.d.ts.map +1 -1
  26. package/dist/components/viewer/post-processing.js +8 -3
  27. package/dist/components/viewer/registered-systems.d.ts.map +1 -1
  28. package/dist/components/viewer/registered-systems.js +16 -2
  29. package/dist/components/viewer/render-error.d.ts +18 -0
  30. package/dist/components/viewer/render-error.d.ts.map +1 -0
  31. package/dist/components/viewer/render-error.js +29 -0
  32. package/dist/components/viewer/scene-ground-replacement.d.ts +6 -0
  33. package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -1
  34. package/dist/components/viewer/scene-ground-replacement.js +15 -0
  35. package/dist/components/viewer/selection-manager.d.ts.map +1 -1
  36. package/dist/components/viewer/selection-manager.js +10 -0
  37. package/dist/components/viewer/viewer-camera.d.ts +4 -0
  38. package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
  39. package/dist/components/viewer/viewer-camera.js +11 -1
  40. package/dist/index.d.ts +22 -11
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +20 -14
  43. package/dist/lib/asset-url.d.ts.map +1 -1
  44. package/dist/lib/asset-url.js +7 -1
  45. package/dist/lib/display-state.d.ts +25 -0
  46. package/dist/lib/display-state.d.ts.map +1 -0
  47. package/dist/lib/display-state.js +1 -0
  48. package/dist/lib/hero-pose.d.ts +12 -2
  49. package/dist/lib/hero-pose.d.ts.map +1 -1
  50. package/dist/lib/hero-pose.js +72 -5
  51. package/dist/lib/ink-edges.d.ts +4 -2
  52. package/dist/lib/ink-edges.d.ts.map +1 -1
  53. package/dist/lib/ink-edges.js +17 -7
  54. package/dist/lib/key-light-override.d.ts +11 -0
  55. package/dist/lib/key-light-override.d.ts.map +1 -0
  56. package/dist/lib/key-light-override.js +14 -0
  57. package/dist/lib/live-frame-hold.d.ts +14 -0
  58. package/dist/lib/live-frame-hold.d.ts.map +1 -0
  59. package/dist/lib/live-frame-hold.js +28 -0
  60. package/dist/lib/materials.d.ts +14 -7
  61. package/dist/lib/materials.d.ts.map +1 -1
  62. package/dist/lib/materials.js +31 -5
  63. package/dist/lib/node-top-surface-height.d.ts +6 -0
  64. package/dist/lib/node-top-surface-height.d.ts.map +1 -0
  65. package/dist/lib/node-top-surface-height.js +74 -0
  66. package/dist/lib/perf-probe-state.d.ts +39 -0
  67. package/dist/lib/perf-probe-state.d.ts.map +1 -0
  68. package/dist/lib/perf-probe-state.js +17 -0
  69. package/dist/lib/pointer-events.d.ts +8 -0
  70. package/dist/lib/pointer-events.d.ts.map +1 -1
  71. package/dist/lib/pointer-events.js +27 -4
  72. package/dist/lib/procedural-emission.d.ts +7 -0
  73. package/dist/lib/procedural-emission.d.ts.map +1 -0
  74. package/dist/lib/procedural-emission.js +54 -0
  75. package/dist/lib/scene-support-height.d.ts +4 -0
  76. package/dist/lib/scene-support-height.d.ts.map +1 -0
  77. package/dist/lib/scene-support-height.js +34 -0
  78. package/dist/lib/scene-visibility.d.ts +6 -0
  79. package/dist/lib/scene-visibility.d.ts.map +1 -1
  80. package/dist/lib/scene-visibility.js +15 -3
  81. package/dist/lib/scripted-opening.d.ts +11 -0
  82. package/dist/lib/scripted-opening.d.ts.map +1 -0
  83. package/dist/lib/scripted-opening.js +20 -0
  84. package/dist/lib/snapshot-pipeline.d.ts +32 -2
  85. package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
  86. package/dist/lib/snapshot-pipeline.js +215 -105
  87. package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -1
  88. package/dist/lib/terrain-perimeter-fill.js +22 -14
  89. package/dist/lib/zone-geometry.d.ts +11 -0
  90. package/dist/lib/zone-geometry.d.ts.map +1 -0
  91. package/dist/lib/zone-geometry.js +46 -0
  92. package/dist/store/use-item-light-pool.d.ts +14 -10
  93. package/dist/store/use-item-light-pool.d.ts.map +1 -1
  94. package/dist/store/use-item-light-pool.js +52 -26
  95. package/dist/store/use-viewer.d.ts +11 -0
  96. package/dist/store/use-viewer.d.ts.map +1 -1
  97. package/dist/store/use-viewer.js +13 -6
  98. package/dist/systems/ceiling/ceiling-system.d.ts +15 -1
  99. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  100. package/dist/systems/ceiling/ceiling-system.js +86 -6
  101. package/dist/systems/door/door-system.d.ts.map +1 -1
  102. package/dist/systems/door/door-system.js +55 -17
  103. package/dist/systems/floor-elevation/floor-elevation-system.d.ts +3 -1
  104. package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
  105. package/dist/systems/floor-elevation/floor-elevation-system.js +33 -5
  106. package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
  107. package/dist/systems/geometry/geometry-system.js +71 -11
  108. package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
  109. package/dist/systems/interactive/interactive-system.js +40 -15
  110. package/dist/systems/interactive/procedural-controls.d.ts +13 -0
  111. package/dist/systems/interactive/procedural-controls.d.ts.map +1 -0
  112. package/dist/systems/interactive/procedural-controls.js +17 -0
  113. package/dist/systems/interactive/scripted-clips.d.ts +19 -0
  114. package/dist/systems/interactive/scripted-clips.d.ts.map +1 -0
  115. package/dist/systems/interactive/scripted-clips.js +134 -0
  116. package/dist/systems/item/item-system.d.ts.map +1 -1
  117. package/dist/systems/item/item-system.js +9 -3
  118. package/dist/systems/item/object-cut-invalidation.d.ts +3 -0
  119. package/dist/systems/item/object-cut-invalidation.d.ts.map +1 -0
  120. package/dist/systems/item/object-cut-invalidation.js +68 -0
  121. package/dist/systems/item-light/item-light-system.d.ts +1 -1
  122. package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
  123. package/dist/systems/item-light/item-light-system.js +47 -52
  124. package/dist/systems/level/level-system.d.ts.map +1 -1
  125. package/dist/systems/level/level-system.js +3 -5
  126. package/dist/systems/level/level-utils.d.ts +7 -4
  127. package/dist/systems/level/level-utils.d.ts.map +1 -1
  128. package/dist/systems/level/level-utils.js +17 -6
  129. package/dist/systems/level/shadow-only-levels.d.ts +9 -0
  130. package/dist/systems/level/shadow-only-levels.d.ts.map +1 -0
  131. package/dist/systems/level/shadow-only-levels.js +7 -0
  132. package/dist/systems/roof/roof-materials.d.ts +14 -2
  133. package/dist/systems/roof/roof-materials.d.ts.map +1 -1
  134. package/dist/systems/roof/roof-materials.js +54 -15
  135. package/dist/systems/roof/roof-system.d.ts +13 -3
  136. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  137. package/dist/systems/roof/roof-system.js +188 -13
  138. package/dist/systems/slab/slab-system.d.ts +1 -1
  139. package/dist/systems/slab/slab-system.d.ts.map +1 -1
  140. package/dist/systems/slab/slab-system.js +49 -19
  141. package/dist/systems/stair/stair-system.d.ts.map +1 -1
  142. package/dist/systems/stair/stair-system.js +133 -354
  143. package/dist/systems/wall/wall-cutout-cache.d.ts +7 -0
  144. package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
  145. package/dist/systems/wall/wall-cutout-cache.js +46 -17
  146. package/dist/systems/wall/wall-cutout.d.ts +6 -0
  147. package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
  148. package/dist/systems/wall/wall-cutout.js +42 -28
  149. package/dist/systems/wall/wall-finish-data.d.ts +22 -0
  150. package/dist/systems/wall/wall-finish-data.d.ts.map +1 -0
  151. package/dist/systems/wall/wall-finish-data.js +17 -0
  152. package/dist/systems/wall/wall-materials.d.ts +9 -4
  153. package/dist/systems/wall/wall-materials.d.ts.map +1 -1
  154. package/dist/systems/wall/wall-materials.js +99 -152
  155. package/dist/systems/wall/wall-system.d.ts +28 -2
  156. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  157. package/dist/systems/wall/wall-system.js +582 -143
  158. package/dist/systems/window/window-system.d.ts +1 -1
  159. package/dist/systems/window/window-system.d.ts.map +1 -1
  160. package/dist/systems/window/window-system.js +41 -22
  161. package/package.json +5 -5
  162. package/dist/systems/fence/fence-system.d.ts +0 -22
  163. package/dist/systems/fence/fence-system.d.ts.map +0 -1
  164. package/dist/systems/fence/fence-system.js +0 -357
@@ -1,9 +1,9 @@
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, 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';
@@ -12,6 +12,7 @@ import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-f
12
12
  import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache';
13
13
  import { buildOpeningCutoutGeometry, getOpeningCutoutBottomPadding, } from './opening-cutout-geometry';
14
14
  import { drainStats, endInitialBuild, initiallyBuiltWalls, isWallInitialBuildActive, pendingAdjacentByLevel, publishWallDrainStats, } from './wall-build-lifecycle';
15
+ import { getWallFaceBaseAt, } from './wall-finish-data';
15
16
  import { sweepUnbuiltWalls, WALL_PLACEHOLDER_SWEEP_INTERVAL } from './wall-placeholder-sweep';
16
17
  import { notifyWallRebuilt } from './wall-rebuild-notifications';
17
18
  export { isWallInitialBuildActive } from './wall-build-lifecycle';
@@ -23,24 +24,6 @@ const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015;
23
24
  const WALL_FACE_NORMAL_Y_EPSILON = 0.6;
24
25
  const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003;
25
26
  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
27
  function computeGeometryBoundsTree(geometry) {
45
28
  ;
46
29
  geometry.computeBoundsTree = computeBoundsTree;
@@ -263,17 +246,17 @@ function distanceToWallBoundaryEdge(point, edge) {
263
246
  const closestZ = edge.start.y + edgeDz * t;
264
247
  return Math.hypot(point.x - closestX, point.y - closestZ);
265
248
  }
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)];
249
+ function getWallFaceMaterialIndex(context, face, x, y) {
250
+ const { layout, foundation } = context;
251
+ // The underpinning's stemwall: everything under the rim depth is concrete.
252
+ if (foundation && y < foundation.top + WALL_BAND_SPLIT_EPSILON)
253
+ return foundation.index;
254
+ if (layout.plain)
255
+ return face === 'a' ? 1 : 2;
256
+ const hit = resolveWallFinish(layout, face, x, y - getWallFaceBaseAt(context, face, x));
257
+ return wallFinishMaterialIndex(layout, face, hit);
275
258
  }
276
- function assignWallMaterialGroups(geometry, wall, boundaryEdges, effectiveWallHeight) {
259
+ function assignWallMaterialGroups(geometry, wall, boundaryEdges, finish) {
277
260
  const position = geometry.getAttribute('position');
278
261
  if (!position)
279
262
  return;
@@ -336,7 +319,7 @@ function assignWallMaterialGroups(geometry, wall, boundaryEdges, effectiveWallHe
336
319
  triangleMaterials[triangleIndex] = 0;
337
320
  continue;
338
321
  }
339
- triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y, effectiveWallHeight);
322
+ triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(finish, nearestTag === 'front' ? 'a' : 'b', centroid.x, centroid.y);
340
323
  }
341
324
  setGroupsSortedByMaterial(geometry, triangleMaterials);
342
325
  }
@@ -347,22 +330,22 @@ function interpolateSplitVertex(a, b, t) {
347
330
  z: a.z + (b.z - a.z) * t,
348
331
  };
349
332
  }
350
- function clipPolygonByY(polygon, planeY, keepBelow) {
333
+ function clipPolygonByPlane(polygon, axis, plane, keepBelow) {
351
334
  const out = [];
352
335
  if (polygon.length === 0)
353
336
  return out;
354
337
  const isInside = (vertex) => keepBelow
355
- ? vertex.y <= planeY + WALL_BAND_SPLIT_EPSILON
356
- : vertex.y >= planeY - WALL_BAND_SPLIT_EPSILON;
338
+ ? vertex[axis] <= plane + WALL_BAND_SPLIT_EPSILON
339
+ : vertex[axis] >= plane - WALL_BAND_SPLIT_EPSILON;
357
340
  for (let index = 0; index < polygon.length; index += 1) {
358
341
  const current = polygon[index];
359
342
  const previous = polygon[(index + polygon.length - 1) % polygon.length];
360
343
  const currentInside = isInside(current);
361
344
  const previousInside = isInside(previous);
362
345
  if (currentInside !== previousInside) {
363
- const denom = current.y - previous.y;
346
+ const denom = current[axis] - previous[axis];
364
347
  if (Math.abs(denom) > WALL_BAND_SPLIT_EPSILON) {
365
- out.push(interpolateSplitVertex(previous, current, (planeY - previous.y) / denom));
348
+ out.push(interpolateSplitVertex(previous, current, (plane - previous[axis]) / denom));
366
349
  }
367
350
  }
368
351
  if (currentInside)
@@ -380,11 +363,22 @@ function triangulateSplitPolygon(polygon, positions) {
380
363
  positions.push(first.x, first.y, first.z, b.x, b.y, b.z, c.x, c.y, c.z);
381
364
  }
382
365
  }
383
- function splitGeometryAtHorizontalPlanes(geometry, planes) {
384
- const splitPlanes = Array.from(new Set(planes
385
- .filter((plane) => Number.isFinite(plane) && plane > WALL_BAND_SPLIT_EPSILON)
366
+ function normalizeSplitPlanes(planes) {
367
+ return Array.from(new Set(planes
368
+ .filter((plane) => Number.isFinite(plane))
386
369
  .map((plane) => Math.round(plane / WALL_BAND_SPLIT_EPSILON) * WALL_BAND_SPLIT_EPSILON))).sort((a, b) => a - b);
387
- if (splitPlanes.length === 0)
370
+ }
371
+ /**
372
+ * Cuts triangles at the vertical planes `x = const` (wall-local stations) and
373
+ * horizontal planes `y = const`, so each piece lies in one finish cell. With no
374
+ * planes the geometry is returned untouched — plain walls never pay for this.
375
+ */
376
+ function splitGeometryAtPlanes(geometry, yPlanes, xPlanes = []) {
377
+ const cuts = [
378
+ ...normalizeSplitPlanes(xPlanes).map((plane) => ({ axis: 'x', plane })),
379
+ ...normalizeSplitPlanes(yPlanes).map((plane) => ({ axis: 'y', plane })),
380
+ ];
381
+ if (cuts.length === 0)
388
382
  return geometry;
389
383
  const source = geometry.index ? geometry.toNonIndexed() : geometry;
390
384
  const position = source.getAttribute('position');
@@ -399,17 +393,17 @@ function splitGeometryAtHorizontalPlanes(geometry, planes) {
399
393
  { x: position.getX(index + 2), y: position.getY(index + 2), z: position.getZ(index + 2) },
400
394
  ],
401
395
  ];
402
- for (const plane of splitPlanes) {
396
+ for (const { axis, plane } of cuts) {
403
397
  const next = [];
404
398
  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) {
399
+ const min = Math.min(...polygon.map((vertex) => vertex[axis]));
400
+ const max = Math.max(...polygon.map((vertex) => vertex[axis]));
401
+ if (plane <= min + WALL_BAND_SPLIT_EPSILON || plane >= max - WALL_BAND_SPLIT_EPSILON) {
408
402
  next.push(polygon);
409
403
  continue;
410
404
  }
411
- const below = clipPolygonByY(polygon, plane, true);
412
- const above = clipPolygonByY(polygon, plane, false);
405
+ const below = clipPolygonByPlane(polygon, axis, plane, true);
406
+ const above = clipPolygonByPlane(polygon, axis, plane, false);
413
407
  if (below.length >= 3)
414
408
  next.push(below);
415
409
  if (above.length >= 3)
@@ -428,16 +422,26 @@ function splitGeometryAtHorizontalPlanes(geometry, planes) {
428
422
  split.computeVertexNormals();
429
423
  return split;
430
424
  }
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);
425
+ /** Split planes a finish layout needs, in wall-local coordinates. */
426
+ function getWallFinishSplitPlanes(finish) {
427
+ const { layout, faceBase } = finish;
428
+ if (layout.plain)
429
+ return { x: [], y: [] };
430
+ const x = [...layout.uSplits];
431
+ const bases = new Set([0]);
432
+ if (faceBase && layout.vSplits.length > 0) {
433
+ bases.clear();
434
+ for (const run of [...faceBase.a, ...faceBase.b]) {
435
+ bases.add(run.y);
436
+ // A face base that steps moves every height with it, so cut at the step too.
437
+ if (run.start > 0)
438
+ x.push(run.start);
439
+ if (run.end < layout.length)
440
+ x.push(run.end);
441
+ }
442
+ }
443
+ const y = [...bases].flatMap((base) => layout.vSplits.map((v) => base + v));
444
+ return { x, y };
441
445
  }
442
446
  // ============================================================================
443
447
  // WALL SYSTEM
@@ -477,10 +481,16 @@ function wallRebuildExitReason(wallId, nodes, rebuiltThisFrame, elapsedMs, initi
477
481
  let cutouts = 0;
478
482
  for (const childId of getEffectiveWall(wall).children ?? []) {
479
483
  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)) {
484
+ let hasCutter = false;
485
+ if (child?.type === 'item') {
486
+ sceneRegistry.nodes.get(childId)?.traverse((object) => {
487
+ const mesh = object;
488
+ const name = typeof mesh.userData.name === 'string' ? mesh.userData.name : mesh.name;
489
+ if (mesh.isMesh && isCutterName(name) && mesh.geometry?.getAttribute('position')?.count)
490
+ hasCutter = true;
491
+ });
492
+ }
493
+ if (child?.type === 'door' || child?.type === 'window' || hasCutter) {
484
494
  cutouts++;
485
495
  if (cutouts >= HEAVY_WALL_OPENINGS)
486
496
  return 'heavy';
@@ -499,7 +509,13 @@ let placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL;
499
509
  export const WallSystem = ({ geometryAdapter } = {}) => {
500
510
  useScene((state) => state.dirtyNodes);
501
511
  useLiveNodeOverrides((s) => s.overrides);
502
- useEffect(() => () => clearLevelMiterCache(), []);
512
+ useEffect(() => () => {
513
+ clearLevelMiterCache();
514
+ zoneFinishRecords.clear();
515
+ zoneFinishNodes = null;
516
+ levelZones.clear();
517
+ levelZonesNodes = null;
518
+ }, []);
503
519
  useFrame(() => runWallBuildFrame(geometryAdapter), 4);
504
520
  return null;
505
521
  };
@@ -518,7 +534,94 @@ export function runWallBuildFrame(geometryAdapter) {
518
534
  publishWallDrainStats();
519
535
  }
520
536
  }
537
+ // Zones per level for the current scene snapshot: a floor's walls rebuild
538
+ // together, and each one would otherwise rescan the level's children.
539
+ const levelZones = new Map();
540
+ let levelZonesNodes = null;
541
+ function getCachedLevelZones(wall, nodes) {
542
+ if (nodes !== levelZonesNodes) {
543
+ levelZones.clear();
544
+ levelZonesNodes = nodes;
545
+ }
546
+ const key = wall.parentId ?? '';
547
+ let zones = levelZones.get(key);
548
+ if (!zones) {
549
+ zones = getWallLevelZones(wall, nodes);
550
+ levelZones.set(key, zones);
551
+ }
552
+ return zones;
553
+ }
554
+ const zoneFinishRecords = new Map();
555
+ let zoneFinishNodes = null;
556
+ function zoneFinishSignature(zone) {
557
+ const painted = zoneHasWallFinish(zone);
558
+ if (!painted && zone.floor?.elevation === undefined)
559
+ return null;
560
+ return JSON.stringify([
561
+ zone.parentId,
562
+ zone.floor?.elevation,
563
+ zone.wallMaterial,
564
+ zone.wallOverrides,
565
+ // A floor-only room reshape does not change the datum of every boundary wall.
566
+ // Endpoint edits already invalidate their walls and queue adjacent miters.
567
+ painted ? zone.polygon : undefined,
568
+ painted ? zone.holes : undefined,
569
+ zone.boundaryWallIds,
570
+ ]);
571
+ }
572
+ /**
573
+ * A room's wall finish lives on its zone, so a zone edit must rebuild the walls
574
+ * bounding it — the old boundary and the new one. Zones without a wall finish
575
+ * never touch their walls, so ordinary room edits cost nothing here.
576
+ */
577
+ export function markWallsForZoneFinishChanges() {
578
+ const state = useScene.getState();
579
+ const nodes = state.nodes;
580
+ if (nodes === zoneFinishNodes)
581
+ return;
582
+ zoneFinishNodes = nodes;
583
+ const walls = new Set();
584
+ const seen = new Set();
585
+ const collect = (zone) => {
586
+ for (const id of zone.boundaryWallIds ?? [])
587
+ walls.add(id);
588
+ for (const entry of zone.wallOverrides ?? [])
589
+ walls.add(entry.wallId);
590
+ };
591
+ for (const id in nodes) {
592
+ const node = nodes[id];
593
+ if (node?.type !== 'zone')
594
+ continue;
595
+ seen.add(id);
596
+ const previous = zoneFinishRecords.get(id);
597
+ if (previous?.zone === node)
598
+ continue;
599
+ const signature = zoneFinishSignature(node);
600
+ if (signature !== (previous?.signature ?? null)) {
601
+ if (previous)
602
+ collect(previous.zone);
603
+ collect(node);
604
+ }
605
+ zoneFinishRecords.set(id, { zone: node, signature });
606
+ }
607
+ for (const [id, record] of zoneFinishRecords) {
608
+ if (seen.has(id))
609
+ continue;
610
+ if (record.signature !== null)
611
+ collect(record.zone);
612
+ zoneFinishRecords.delete(id);
613
+ }
614
+ for (const id of walls) {
615
+ const wall = nodes[id];
616
+ if (wall?.type === 'wall') {
617
+ state.markDirty(wall.id);
618
+ for (const child of wall.children)
619
+ state.markDirty(child);
620
+ }
621
+ }
622
+ }
521
623
  function consumeWallBuildFrame(initialBuild, geometryAdapter) {
624
+ markWallsForZoneFinishChanges();
522
625
  const clearDirty = useScene.getState().clearDirty;
523
626
  // Self-heal: any registered wall still on its mount-time placeholder
524
627
  // geometry with NO dirty mark gets re-marked, so a lost mark (system
@@ -539,7 +642,7 @@ function consumeWallBuildFrame(initialBuild, geometryAdapter) {
539
642
  const dirtyNodes = useScene.getState().dirtyNodes;
540
643
  const hasDirty = dirtyNodes.size > 0;
541
644
  const hasPending = pendingAdjacentByLevel.size > 0;
542
- if (!hasDirty && !hasPending) {
645
+ if (!(hasDirty || hasPending)) {
543
646
  endInitialBuild();
544
647
  return;
545
648
  }
@@ -613,7 +716,7 @@ function consumeWallBuildFrame(initialBuild, geometryAdapter) {
613
716
  else {
614
717
  drainStats.reinvalidationBuilds++;
615
718
  }
616
- if (!initialBuild || !firstBuild)
719
+ if (!(initialBuild && firstBuild))
617
720
  rebuiltWallIds.add(wallId);
618
721
  rebuiltWallsThisFrame += 1;
619
722
  drainStats.wallsConsumedThisFrame++;
@@ -641,7 +744,7 @@ function consumeWallBuildFrame(initialBuild, geometryAdapter) {
641
744
  pendingAdjacentByLevel.set(levelId, pending);
642
745
  }
643
746
  for (const wallId of adjacentWallIds) {
644
- if (!dirtyWallIds.has(wallId) && !pending.has(wallId)) {
747
+ if (!(dirtyWallIds.has(wallId) || pending.has(wallId))) {
645
748
  pending.add(wallId);
646
749
  drainStats.pendingNeighbours++;
647
750
  drainStats.neighbourEnqueues++;
@@ -779,11 +882,8 @@ function updateWallGeometry(wallId, miterData, geometryAdapter) {
779
882
  // Covering-clamped plane: a flush/thick slab on the level above shortens
780
883
  // the plane-bound walls below it (explicit-height walls ignore the value).
781
884
  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);
885
+ const slabSupport = spatialGridManager.getSlabSupportForWall(levelId, node.start, node.end, node.curveOffset ?? 0, node.thickness, node.supportSlabId, undefined, node.supportOffset, node.justification);
783
886
  const slabElevation = slabSupport.elevation;
784
- const terrainBottomAt = node.fillToTerrain
785
- ? (x, z) => terrainSupportLift(nodes, levelId, x, z)
786
- : undefined;
787
887
  const childrenIds = node.children || [];
788
888
  const childrenNodes = childrenIds
789
889
  .map((childId) => nodes[childId])
@@ -802,7 +902,20 @@ function updateWallGeometry(wallId, miterData, geometryAdapter) {
802
902
  envelopeChildren: childrenNodes,
803
903
  renderChildren: childrenNodes,
804
904
  };
805
- const builtGeo = generateExtrudedWall(node, prepared.envelopeChildren, miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt);
905
+ const fillMasks = node.fillToTerrain
906
+ ? [
907
+ ...Object.values(nodes).flatMap((slab) => slab.type === 'slab' && slab.parentId === levelId && slab.support !== 'open'
908
+ ? [{ outer: slab.polygon, holes: slab.holes }]
909
+ : []),
910
+ ...childrenNodes.flatMap((child) => child.type === 'door' || child.type === 'window'
911
+ ? getOpeningWallCut(node, child, nodes, slabSupport).aperture
912
+ : []),
913
+ ]
914
+ : [];
915
+ const terrainBottomAt = node.fillToTerrain
916
+ ? (x, z) => containsPoint(fillMasks, [x, z]) ? slabElevation : terrainSupportLift(nodes, levelId, x, z)
917
+ : undefined;
918
+ const builtGeo = generateExtrudedWall(node, prepared.envelopeChildren, miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt, slabSupport.faceDatum, buildWallFinishLayout(node, getCachedLevelZones(node, nodes)));
806
919
  const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
807
920
  // World transform the render mesh will apply (position + Y-rotation below).
808
921
  // Reproduce it here so the UVs can be projected in WORLD space — see
@@ -819,13 +932,22 @@ function updateWallGeometry(wallId, miterData, geometryAdapter) {
819
932
  // Update collision mesh
820
933
  const collisionMesh = mesh.getObjectByName('collision-mesh');
821
934
  if (collisionMesh) {
822
- const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt);
935
+ const collisionGeo = generateExtrudedWall(node, [], miterData, slabElevation, slabSupport.baseElevation, slabSupport.baseSegments, planeTop, terrainBottomAt, slabSupport.faceDatum);
823
936
  collisionMesh.geometry.dispose();
824
937
  collisionMesh.geometry = collisionGeo;
825
938
  }
826
939
  mesh.position.set(node.start[0], slabElevation, node.start[1]);
827
940
  const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
828
941
  mesh.rotation.y = -angle;
942
+ const offsets = getWallFaceOffsets(node);
943
+ const frameKey = `${offsets.a}:${offsets.b}`;
944
+ // Child systems otherwise only see their own edits. Invalidate on a frame
945
+ // change once, avoiding a wall → opening → wall rebuild loop.
946
+ if (mesh.userData.wallFrameKey !== undefined && mesh.userData.wallFrameKey !== frameKey) {
947
+ for (const child of childrenNodes)
948
+ useScene.getState().markDirty(child.id);
949
+ }
950
+ mesh.userData.wallFrameKey = frameKey;
829
951
  }
830
952
  const WALL_UV_Y_AXIS = new THREE.Vector3(0, 1, 0);
831
953
  const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
@@ -842,8 +964,10 @@ const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
842
964
  */
843
965
  function applyWorldPlanarWallUVs(geometry, worldMatrix) {
844
966
  const target = geometry.index ? geometry.toNonIndexed() : geometry;
845
- if (target !== geometry)
967
+ if (target !== geometry) {
968
+ target.userData = geometry.userData;
846
969
  geometry.dispose();
970
+ }
847
971
  const position = target.getAttribute('position');
848
972
  if (!position || position.count === 0)
849
973
  return target;
@@ -919,34 +1043,180 @@ function buildWallTerrainFillGeometry(perimeter, worldToLocal, wallBaseElevation
919
1043
  });
920
1044
  return buildTerrainPerimeterFillGeometry(localPoints, bottomY, 0);
921
1045
  }
922
- function mergeWallTerrainFill(body, fill, wall, boundaryEdges, effectiveWallHeight) {
923
- if (!fill)
1046
+ /**
1047
+ * The underpinning under a wall (`WallNode.underpinning`): a skirt of the
1048
+ * wall's own faces `rim` deep below the base, then the stemwall skirt from
1049
+ * there down `stem` more — or to the terrain wherever that is lower when
1050
+ * the wall also fills to terrain. Two fills, split at the rim depth, so the
1051
+ * material groups can paint the stem concrete and the rim in the finish.
1052
+ */
1053
+ function buildWallUnderpinningGeometry(perimeter, worldToLocal, wallBaseElevation, wall, terrainBottomAt, curved = false) {
1054
+ const underpinning = wall.underpinning;
1055
+ if (!underpinning)
1056
+ return [];
1057
+ const worldPoints = densifyClosedWallPerimeter(perimeter);
1058
+ if (worldPoints.length < 3)
1059
+ return [];
1060
+ const localPoints = worldPoints.map(worldToLocal);
1061
+ const rimBottom = -underpinning.rim;
1062
+ const fills = [];
1063
+ if (underpinning.rim > 1e-6) {
1064
+ const rim = buildTerrainPerimeterFillGeometry(localPoints, worldPoints.map(() => rimBottom), 0);
1065
+ if (rim)
1066
+ fills.push(rim);
1067
+ }
1068
+ const stemBottom = worldPoints.map((point) => {
1069
+ let y = rimBottom - underpinning.stem;
1070
+ if (terrainBottomAt) {
1071
+ const terrainElevation = terrainBottomAt(point.x, point.y);
1072
+ if (terrainElevation != null)
1073
+ y = Math.min(y, terrainElevation - wallBaseElevation);
1074
+ }
1075
+ return y;
1076
+ });
1077
+ if (stemBottom.some((y) => y < rimBottom - 1e-6)) {
1078
+ const stem = buildTerrainPerimeterFillGeometry(localPoints, stemBottom, rimBottom);
1079
+ if (stem) {
1080
+ fills.push(underpinning.openings?.length && !curved
1081
+ ? cutUnderpinningOpenings(stem, localPoints, rimBottom, underpinning.openings)
1082
+ : stem);
1083
+ }
1084
+ }
1085
+ return fills;
1086
+ }
1087
+ /**
1088
+ * The stem skirt with its openings (`WallUnderpinning.openings`) cut through
1089
+ * it: the skirt is closed with a cap at its top so it is a solid, then each
1090
+ * opening's box is subtracted across the full thickness. The cap lands on
1091
+ * the rim's (or the body's) own bottom face, inside the wall.
1092
+ */
1093
+ function cutUnderpinningOpenings(stem, localPoints, topY, openings) {
1094
+ const stemPositions = stem.getAttribute('position');
1095
+ const positions = Array.from(stemPositions.array);
1096
+ const faces = THREE.ShapeUtils.triangulateShape(localPoints.map((point) => new THREE.Vector2(point.x, point.z)), []);
1097
+ for (const [ia, ib, ic] of faces) {
1098
+ const a = localPoints[ia];
1099
+ const b = localPoints[ib];
1100
+ const c = localPoints[ic];
1101
+ const cross = (b.x - a.x) * (c.z - a.z) - (b.z - a.z) * (c.x - a.x);
1102
+ const [second, third] = cross >= 0 ? [c, b] : [b, c];
1103
+ positions.push(a.x, topY, a.z, second.x, topY, second.z, third.x, topY, third.z);
1104
+ }
1105
+ stem.dispose();
1106
+ const solid = new THREE.BufferGeometry();
1107
+ solid.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
1108
+ solid.computeVertexNormals();
1109
+ ensureRenderableGeometryAttributes(solid);
1110
+ computeGeometryBoundsTree(solid);
1111
+ let minZ = Number.POSITIVE_INFINITY;
1112
+ let maxZ = Number.NEGATIVE_INFINITY;
1113
+ for (const point of localPoints) {
1114
+ minZ = Math.min(minZ, point.z);
1115
+ maxZ = Math.max(maxZ, point.z);
1116
+ }
1117
+ const depth = maxZ - minZ + 0.2;
1118
+ const centerZ = (minZ + maxZ) / 2;
1119
+ let result = new Brush(solid);
1120
+ result.updateMatrixWorld();
1121
+ for (const opening of openings) {
1122
+ const height = opening.bottom - opening.top;
1123
+ if (height <= 1e-6)
1124
+ continue;
1125
+ const box = new THREE.BoxGeometry(opening.width, height, depth);
1126
+ box.translate(opening.u, -(opening.top + opening.bottom) / 2, centerZ);
1127
+ const cutter = new Brush(box);
1128
+ prepareBrushForCSG(cutter);
1129
+ const next = csgEvaluator.evaluate(result, cutter, SUBTRACTION);
1130
+ csgGeometry(cutter).dispose();
1131
+ csgGeometry(result).dispose();
1132
+ result = next;
1133
+ }
1134
+ // the other skirts and the body merge non-indexed
1135
+ const cut = csgGeometry(result);
1136
+ const geometry = cut.index ? cut.toNonIndexed() : cut;
1137
+ if (geometry !== cut)
1138
+ cut.dispose();
1139
+ geometry.clearGroups();
1140
+ return geometry;
1141
+ }
1142
+ function mergeWallTerrainFill(body, fills, wall, boundaryEdges, finish) {
1143
+ const present = fills.filter((fill) => fill !== null);
1144
+ if (present.length === 0)
924
1145
  return body;
925
1146
  const bodyGeometry = body.index ? body.toNonIndexed() : body;
926
1147
  if (bodyGeometry !== body)
927
1148
  body.dispose();
1149
+ // The fill is part of each face below the base: it splits at the same finish bounds.
1150
+ const planes = getWallFinishSplitPlanes(finish);
1151
+ const splitFills = present.map((fill) => splitGeometryAtPlanes(fill, planes.y, planes.x));
928
1152
  ensureRenderableGeometryAttributes(bodyGeometry);
929
- ensureRenderableGeometryAttributes(fill);
930
- const merged = mergeGeometries([bodyGeometry, fill], false);
1153
+ for (const fill of splitFills)
1154
+ ensureRenderableGeometryAttributes(fill);
1155
+ const merged = mergeGeometries([bodyGeometry, ...splitFills], false);
931
1156
  if (!merged) {
932
- fill.dispose();
1157
+ for (const fill of splitFills)
1158
+ fill.dispose();
933
1159
  return bodyGeometry;
934
1160
  }
935
1161
  bodyGeometry.dispose();
936
- fill.dispose();
1162
+ for (const fill of splitFills)
1163
+ fill.dispose();
937
1164
  merged.computeVertexNormals();
938
- assignWallMaterialGroups(merged, wall, boundaryEdges, effectiveWallHeight);
1165
+ assignWallMaterialGroups(merged, wall, boundaryEdges, finish);
939
1166
  ensureRenderableGeometryAttributes(merged);
940
1167
  return merged;
941
1168
  }
1169
+ /** Wall-local x of a support-segment parameter (curve parameter on arcs). */
1170
+ function wallStationX(wall, t, length) {
1171
+ if (!isCurvedWall(wall))
1172
+ return t * length;
1173
+ const point = getWallCurveFrameAt(wall, t).point;
1174
+ const dx = wall.end[0] - wall.start[0];
1175
+ const dz = wall.end[1] - wall.start[1];
1176
+ return ((point.x - wall.start[0]) * dx + (point.y - wall.start[1]) * dz) / length;
1177
+ }
1178
+ function buildWallFinishContext(wall, layout, faceBase, slabElevation) {
1179
+ const refs = [...layout.refs];
1180
+ let foundation = null;
1181
+ if (wall.underpinning) {
1182
+ const ref = wall.slots?.foundation ?? WALL_SURFACE_SLOT_DEFAULTS.foundation;
1183
+ if (!refs.includes(ref))
1184
+ refs.push(ref);
1185
+ foundation = { index: 3 + refs.indexOf(ref), top: -wall.underpinning.rim };
1186
+ }
1187
+ if (!faceBase || layout.plain)
1188
+ return { layout, faceBase: null, refs, foundation };
1189
+ const runs = (segments) => segments
1190
+ .map((segment) => ({
1191
+ start: wallStationX(wall, segment.start, layout.length),
1192
+ end: wallStationX(wall, segment.end, layout.length),
1193
+ y: segment.elevation - slabElevation,
1194
+ }))
1195
+ .sort((left, right) => left.start - right.start);
1196
+ return { layout, faceBase: { a: runs(faceBase.a), b: runs(faceBase.b) }, refs, foundation };
1197
+ }
942
1198
  export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabElevation = 0, baseElevation = slabElevation, baseSegments = [
943
1199
  { start: 0, end: 1, elevation: baseElevation },
944
- ], storeyHeight = DEFAULT_LEVEL_HEIGHT, terrainBottomAt) {
1200
+ ], storeyHeight = DEFAULT_LEVEL_HEIGHT, terrainBottomAt, faceBase, finishLayout = buildWallFinishLayout(wallNode, []), sceneNodes = useScene.getState().nodes) {
945
1201
  const wallStart = { x: wallNode.start[0], y: wallNode.start[1] };
946
1202
  const wallEnd = { x: wallNode.end[0], y: wallNode.end[1] };
947
1203
  const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation);
948
- const effectiveWallHeight = topElevation - slabElevation;
949
- const effectiveBaseElevation = Math.min(baseElevation, slabElevation);
1204
+ const faceDatum = faceBase;
1205
+ if (faceBase)
1206
+ faceBase = {
1207
+ a: resolveWallFaceBottom(faceBase.a, baseSegments, slabElevation),
1208
+ b: resolveWallFaceBottom(faceBase.b, baseSegments, slabElevation),
1209
+ };
1210
+ const faceSegments = faceBase ? [...faceBase.a, ...faceBase.b] : [];
1211
+ const sameBase = faceBase &&
1212
+ [faceBase.a, faceBase.b].every((segments) => JSON.stringify(segments) === JSON.stringify(baseSegments));
1213
+ const useFaceBase = faceBase && !sameBase;
1214
+ const effectiveBaseElevation = useFaceBase
1215
+ ? Math.min(...faceSegments.flatMap((segment) => [
1216
+ segment.elevation,
1217
+ segment.endElevation ?? segment.elevation,
1218
+ ]), slabElevation)
1219
+ : Math.min(baseElevation, slabElevation);
950
1220
  const localBottom = effectiveBaseElevation - slabElevation;
951
1221
  const height = topElevation - effectiveBaseElevation;
952
1222
  // A slab at or above the storey plane leaves a plane-bound wall with no
@@ -984,9 +1254,15 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
984
1254
  // Convert polygon to local coordinates
985
1255
  const localPoints = polyPoints.map(worldToLocal);
986
1256
  const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData);
987
- const terrainFill = terrainBottomAt
1257
+ const finish = buildWallFinishContext(wallNode, finishLayout, faceDatum, slabElevation);
1258
+ const finishPlanes = getWallFinishSplitPlanes(finish);
1259
+ // An underpinned wall's stem skirt reaches the terrain itself; the plain
1260
+ // terrain fill is for a wall with no underpinning.
1261
+ const underpinningFills = buildWallUnderpinningGeometry(polyPoints, worldToLocal, slabElevation, wallNode, terrainBottomAt, isCurvedWall(wallNode));
1262
+ const terrainFill = terrainBottomAt && !wallNode.underpinning
988
1263
  ? buildWallTerrainFillGeometry(polyPoints, worldToLocal, slabElevation, terrainBottomAt)
989
1264
  : null;
1265
+ const belowBaseFills = [terrainFill, ...underpinningFills];
990
1266
  // Build THREE.js shape
991
1267
  // Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
992
1268
  // The negation is needed because after rotateX(-PI/2), shape.y becomes -geometry.z
@@ -1006,20 +1282,31 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1006
1282
  if (Math.abs(localBottom) > 1e-9)
1007
1283
  geometry.translate(0, localBottom, 0);
1008
1284
  geometry.computeVertexNormals();
1009
- assignWallMaterialGroups(geometry, wallNode, boundaryEdges, effectiveWallHeight);
1285
+ assignWallMaterialGroups(geometry, wallNode, boundaryEdges, finish);
1010
1286
  ensureRenderableGeometryAttributes(geometry);
1011
1287
  // Start with the lowest required wall prism, then remove the volume below
1012
1288
  // each higher-supported run. This keeps the existing mitered footprint and
1013
1289
  // opening CSG while giving one wall a stepped longitudinal base.
1014
1290
  const baseProfileCutouts = [];
1015
- for (const segment of baseSegments) {
1016
- const segmentElevation = Math.min(segment.elevation, slabElevation);
1291
+ const profiles = useFaceBase && faceBase
1292
+ ? [
1293
+ ...faceBase.a.map((segment) => ({ segment, face: 'a' })),
1294
+ ...faceBase.b.map((segment) => ({ segment, face: 'b' })),
1295
+ ]
1296
+ : baseSegments.map((segment) => ({ segment, face: undefined }));
1297
+ for (const { segment, face } of profiles) {
1298
+ const segmentElevation = face
1299
+ ? Math.min(Math.max(segment.elevation, segment.endElevation ?? segment.elevation), topElevation)
1300
+ : Math.min(segment.elevation, slabElevation);
1017
1301
  const cutHeight = segmentElevation - effectiveBaseElevation;
1018
1302
  if (cutHeight <= 1e-6 || segment.end - segment.start <= 1e-7)
1019
1303
  continue;
1020
1304
  const segmentStart = THREE.MathUtils.clamp(segment.start, 0, 1);
1021
1305
  const segmentEnd = THREE.MathUtils.clamp(segment.end, 0, 1);
1022
1306
  const cutHalfWidth = Math.max(thickness * 2, 0.2);
1307
+ const centerOffset = getWallBodyCenterOffset(wallNode);
1308
+ const leftOffset = face === 'b' ? centerOffset : cutHalfWidth;
1309
+ const rightOffset = face === 'a' ? centerOffset : -cutHalfWidth;
1023
1310
  const worldCutoutPoints = [];
1024
1311
  if (isCurvedWall(wallNode)) {
1025
1312
  const sampleCount = Math.max(2, Math.ceil((segmentEnd - segmentStart) * 24));
@@ -1038,12 +1325,12 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1038
1325
  y: frame.point.y + frame.tangent.y * endpointExtension,
1039
1326
  };
1040
1327
  left.push({
1041
- x: center.x + frame.normal.x * cutHalfWidth,
1042
- y: center.y + frame.normal.y * cutHalfWidth,
1328
+ x: center.x + frame.normal.x * leftOffset,
1329
+ y: center.y + frame.normal.y * leftOffset,
1043
1330
  });
1044
1331
  right.push({
1045
- x: center.x - frame.normal.x * cutHalfWidth,
1046
- y: center.y - frame.normal.y * cutHalfWidth,
1332
+ x: center.x + frame.normal.x * rightOffset,
1333
+ y: center.y + frame.normal.y * rightOffset,
1047
1334
  });
1048
1335
  }
1049
1336
  worldCutoutPoints.push(...left, ...right.reverse());
@@ -1064,11 +1351,11 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1064
1351
  y: wallStart.y + tangentY * (segmentEnd * L + endExtension),
1065
1352
  };
1066
1353
  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,
1354
+ x: startPoint.x + normalX * leftOffset,
1355
+ y: startPoint.y + normalY * leftOffset,
1356
+ }, { x: endPoint.x + normalX * leftOffset, y: endPoint.y + normalY * leftOffset }, { x: endPoint.x + normalX * rightOffset, y: endPoint.y + normalY * rightOffset }, {
1357
+ x: startPoint.x + normalX * rightOffset,
1358
+ y: startPoint.y + normalY * rightOffset,
1072
1359
  });
1073
1360
  }
1074
1361
  const localCutoutPoints = worldCutoutPoints.map(worldToLocal);
@@ -1080,7 +1367,11 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1080
1367
  cutoutShape.lineTo(localCutoutPoints[index].x, -localCutoutPoints[index].z);
1081
1368
  }
1082
1369
  cutoutShape.closePath();
1083
- const cutoutBottom = localBottom - 0.01;
1370
+ const slopeExtension = segment.endElevation === undefined
1371
+ ? 0
1372
+ : (Math.abs(segment.endElevation - segment.elevation) * cutHalfWidth) /
1373
+ ((segmentEnd - segmentStart) * L);
1374
+ const cutoutBottom = localBottom - 0.01 - slopeExtension;
1084
1375
  const cutoutTop = segmentElevation - slabElevation;
1085
1376
  const cutoutGeometry = new THREE.ExtrudeGeometry(cutoutShape, {
1086
1377
  depth: cutoutTop - cutoutBottom,
@@ -1088,19 +1379,31 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1088
1379
  });
1089
1380
  cutoutGeometry.rotateX(-Math.PI / 2);
1090
1381
  cutoutGeometry.translate(0, cutoutBottom, 0);
1382
+ if (segment.endElevation !== undefined) {
1383
+ const from = wallStationX(wallNode, segmentStart, L);
1384
+ const to = wallStationX(wallNode, segmentEnd, L);
1385
+ const positions = cutoutGeometry.getAttribute('position');
1386
+ for (let i = 0; i < positions.count; i++) {
1387
+ if (Math.abs(positions.getY(i) - cutoutTop) > 1e-5)
1388
+ continue;
1389
+ const t = (positions.getX(i) - from) / (to - from);
1390
+ positions.setY(i, Math.min(topElevation, segment.elevation + (segment.endElevation - segment.elevation) * t) - slabElevation);
1391
+ }
1392
+ cutoutGeometry.computeVertexNormals();
1393
+ }
1091
1394
  computeGeometryBoundsTree(cutoutGeometry);
1092
1395
  baseProfileCutouts.push(new Brush(cutoutGeometry));
1093
1396
  }
1094
1397
  const cutoutBrushes = [
1095
1398
  ...baseProfileCutouts,
1096
- ...collectCutoutBrushes(wallNode, childrenNodes, thickness),
1399
+ ...collectCutoutBrushes(wallNode, childrenNodes, thickness, faceDatum, slabElevation, sceneNodes, baseSegments),
1097
1400
  ];
1098
1401
  if (cutoutBrushes.length === 0) {
1099
- const splitGeometry = splitGeometryAtHorizontalPlanes(geometry, getWallBandSplitPlanes(wallNode, effectiveWallHeight));
1402
+ const splitGeometry = splitGeometryAtPlanes(geometry, finishPlanes.y, finishPlanes.x);
1100
1403
  splitGeometry.computeVertexNormals();
1101
- assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, effectiveWallHeight);
1404
+ assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, finish);
1102
1405
  ensureRenderableGeometryAttributes(splitGeometry);
1103
- return mergeWallTerrainFill(splitGeometry, terrainFill, wallNode, boundaryEdges, effectiveWallHeight);
1406
+ return withWallFinishData(mergeWallTerrainFill(splitGeometry, belowBaseFills, wallNode, boundaryEdges, finish), finish);
1104
1407
  }
1105
1408
  // Create wall brush from geometry
1106
1409
  // Pre-compute BVH with new API to avoid deprecation warning
@@ -1143,18 +1446,59 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
1143
1446
  csgGeometry(brush).dispose();
1144
1447
  }
1145
1448
  const resultGeometry = csgGeometry(resultBrush);
1146
- const splitResultGeometry = splitGeometryAtHorizontalPlanes(resultGeometry, getWallBandSplitPlanes(wallNode, effectiveWallHeight));
1449
+ const splitResultGeometry = splitGeometryAtPlanes(resultGeometry, finishPlanes.y, finishPlanes.x);
1147
1450
  splitResultGeometry.computeVertexNormals();
1148
- assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, effectiveWallHeight);
1451
+ assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, finish);
1149
1452
  ensureRenderableGeometryAttributes(splitResultGeometry);
1150
- return mergeWallTerrainFill(splitResultGeometry, terrainFill, wallNode, boundaryEdges, effectiveWallHeight);
1453
+ return withWallFinishData(mergeWallTerrainFill(splitResultGeometry, belowBaseFills, wallNode, boundaryEdges, finish), finish);
1454
+ }
1455
+ function withWallFinishData(geometry, finish) {
1456
+ if (!finish.layout.plain || finish.foundation) {
1457
+ geometry.userData.wallFinish = {
1458
+ refs: finish.refs,
1459
+ faceBase: finish.faceBase,
1460
+ };
1461
+ }
1462
+ return geometry;
1151
1463
  }
1152
1464
  /**
1153
1465
  * Collects opening and item cutout brushes for CSG subtraction. Door/window
1154
1466
  * cuts come directly from node geometry; item proxy meshes are transformed
1155
1467
  * into wall-local boxes that pass through the wall.
1156
1468
  */
1157
- function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
1469
+ /**
1470
+ * A CSG brush from an authored object's `cutout` mesh: the mesh in wall-local
1471
+ * space, its depth stretched to twice the wall's thickness about the wall's
1472
+ * body centre so it overshoots both faces. Null when the mesh has no depth to
1473
+ * stretch (a flat cutter), so the caller falls back to its bounding box.
1474
+ */
1475
+ function authoredCutoutBrush(cutoutMesh, wallMatrixInverse, wallThickness, wallNode) {
1476
+ const geometry = cutoutMesh.geometry.clone();
1477
+ geometry.applyMatrix4(new THREE.Matrix4().multiplyMatrices(wallMatrixInverse, cutoutMesh.matrixWorld));
1478
+ geometry.computeBoundingBox();
1479
+ const box = geometry.boundingBox;
1480
+ const depth = box.max.z - box.min.z;
1481
+ if (!(depth > 1e-4)) {
1482
+ geometry.dispose();
1483
+ return null;
1484
+ }
1485
+ const centre = getWallBodyCenterOffset(wallNode);
1486
+ const meshCentre = (box.min.z + box.max.z) / 2;
1487
+ const scale = (wallThickness * 2) / depth;
1488
+ const positions = geometry.getAttribute('position');
1489
+ for (let i = 0; i < positions.count; i++) {
1490
+ positions.setZ(i, centre + (positions.getZ(i) - meshCentre) * scale);
1491
+ }
1492
+ positions.needsUpdate = true;
1493
+ // The evaluator needs the attributes every brush carries.
1494
+ if (!geometry.getAttribute('uv')) {
1495
+ geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(positions.count * 2), 2));
1496
+ }
1497
+ geometry.computeVertexNormals();
1498
+ computeGeometryBoundsTree(geometry);
1499
+ return new Brush(geometry);
1500
+ }
1501
+ function collectCutoutBrushes(wallNode, childrenNodes, wallThickness, faceBase, elevation = 0, sceneNodes = useScene.getState().nodes, baseSegments = [{ start: 0, end: 1, elevation }]) {
1158
1502
  const brushes = [];
1159
1503
  const wallMesh = sceneRegistry.nodes.get(wallNode.id);
1160
1504
  if (!wallMesh)
@@ -1162,56 +1506,149 @@ function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
1162
1506
  // Get wall's world matrix inverse to transform cutouts to wall-local space
1163
1507
  wallMesh.updateMatrixWorld();
1164
1508
  const wallMatrixInverse = wallMesh.matrixWorld.clone().invert();
1509
+ // A wall child can only bind to its parent, so the wall alone resolves every cutter.
1510
+ const hostNodes = { [wallNode.id]: wallNode };
1165
1511
  for (const child of childrenNodes) {
1166
1512
  if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door')
1167
1513
  continue;
1168
- if (child.type === 'door' || child.type === 'window') {
1169
- brushes.push(createOpeningCutoutBrush(child, wallThickness));
1514
+ const cutterMeshes = [];
1515
+ sceneRegistry.nodes.get(child.id)?.traverse((object) => {
1516
+ const name = typeof object.userData.name === 'string' ? object.userData.name : object.name;
1517
+ if (object.isMesh &&
1518
+ resolveCutterHost(child, name, hostNodes)?.id === wallNode.id) {
1519
+ cutterMeshes.push(object);
1520
+ }
1521
+ });
1522
+ // A window or door built from a script cuts its `cutout` mesh like an
1523
+ // authored item (below); without one, or until it loads, its outline.
1524
+ const scriptedCutout = child.type !== 'item' && child.source && cutterMeshes.length > 0;
1525
+ if ((child.type === 'door' || child.type === 'window') && !scriptedCutout) {
1526
+ const nodes = {
1527
+ ...sceneNodes,
1528
+ [wallNode.id]: wallNode,
1529
+ ...Object.fromEntries(childrenNodes.map((node) => [node.id, node])),
1530
+ };
1531
+ const datum = faceBase ?? {
1532
+ a: [{ start: 0, end: 1, elevation }],
1533
+ b: [{ start: 0, end: 1, elevation }],
1534
+ };
1535
+ const support = {
1536
+ elevation,
1537
+ electedSlabId: null,
1538
+ baseElevation: elevation,
1539
+ baseSegments: [...baseSegments],
1540
+ faceDatum: { a: [...datum.a], b: [...datum.b] },
1541
+ faceBottom: {
1542
+ a: resolveWallFaceBottom(datum.a, baseSegments, elevation),
1543
+ b: resolveWallFaceBottom(datum.b, baseSegments, elevation),
1544
+ },
1545
+ };
1546
+ const cut = getOpeningWallCut(wallNode, child, nodes, support);
1547
+ // The cutter must overshoot the faces it cuts through; `cut.aperture` is
1548
+ // clipped to the wall's own footprint (the doorway floor), so extruding it
1549
+ // would leave coplanar faces and CSG would drop the cut.
1550
+ if (!isCurvedWall(wallNode)) {
1551
+ brushes.push(createOpeningCutoutBrush({
1552
+ ...child,
1553
+ position: [
1554
+ child.position[0],
1555
+ child.position[1] + cut.datum - elevation,
1556
+ child.position[2],
1557
+ ],
1558
+ }, wallThickness, getWallBodyCenterOffset(wallNode), cut.bottom - elevation));
1559
+ continue;
1560
+ }
1561
+ if (cut.band.length < 3)
1562
+ continue;
1563
+ const angle = Math.atan2(wallNode.end[1] - wallNode.start[1], wallNode.end[0] - wallNode.start[0]);
1564
+ const points = cut.band.map(([x, z]) => {
1565
+ const dx = x - wallNode.start[0], dz = z - wallNode.start[1];
1566
+ return new THREE.Vector2(dx * Math.cos(angle) + dz * Math.sin(angle), dx * Math.sin(angle) - dz * Math.cos(angle));
1567
+ });
1568
+ const padding = getOpeningCutoutBottomPadding(child, cut.bottom - elevation);
1569
+ const geometry = new THREE.ExtrudeGeometry(new THREE.Shape(points), {
1570
+ depth: cut.top - cut.bottom + padding,
1571
+ bevelEnabled: false,
1572
+ });
1573
+ geometry.rotateX(-Math.PI / 2);
1574
+ geometry.translate(0, cut.bottom - elevation - padding, 0);
1575
+ computeGeometryBoundsTree(geometry);
1576
+ const bandBrush = new Brush(geometry);
1577
+ if (child.openingShape !== 'arch' && child.openingShape !== 'rounded') {
1578
+ brushes.push(bandBrush);
1579
+ continue;
1580
+ }
1581
+ const shaped = createOpeningCutoutBrush({
1582
+ ...child,
1583
+ position: [
1584
+ child.position[0],
1585
+ child.position[1] + cut.datum - elevation,
1586
+ child.position[2],
1587
+ ],
1588
+ }, wallThickness, getWallBodyCenterOffset(wallNode), cut.bottom - elevation);
1589
+ const frame = getWallCurveFrameAt(wallNode, child.position[0] / getWallCurveLength(wallNode));
1590
+ const offset = getWallBodyCenterOffset(wallNode);
1591
+ const dx = frame.point.x + frame.normal.x * offset - wallNode.start[0], dz = frame.point.y + frame.normal.y * offset - wallNode.start[1];
1592
+ csgGeometry(shaped).translate(-child.position[0], 0, -offset);
1593
+ csgGeometry(shaped).rotateY(angle - Math.atan2(frame.tangent.y, frame.tangent.x));
1594
+ csgGeometry(shaped).translate(dx * Math.cos(angle) + dz * Math.sin(angle), 0, -dx * Math.sin(angle) + dz * Math.cos(angle));
1595
+ prepareBrushForCSG(bandBrush);
1596
+ prepareBrushForCSG(shaped);
1597
+ bandBrush.updateMatrixWorld();
1598
+ shaped.updateMatrixWorld();
1599
+ brushes.push(csgEvaluator.evaluate(bandBrush, shaped, INTERSECTION));
1600
+ geometry.dispose();
1601
+ csgGeometry(shaped).dispose();
1170
1602
  continue;
1171
1603
  }
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);
1604
+ for (const cutoutMesh of cutterMeshes) {
1605
+ // Get the cutout's bounding box in world space
1606
+ cutoutMesh.updateMatrixWorld();
1607
+ const positions = cutoutMesh.geometry?.attributes?.position;
1608
+ if (!positions)
1609
+ continue;
1610
+ // Calculate bounds in wall-local space
1611
+ const v3 = new THREE.Vector3();
1612
+ let minX = Number.POSITIVE_INFINITY, maxX = Number.NEGATIVE_INFINITY;
1613
+ let minY = Number.POSITIVE_INFINITY, maxY = Number.NEGATIVE_INFINITY;
1614
+ for (let i = 0; i < positions.count; i++) {
1615
+ v3.fromBufferAttribute(positions, i);
1616
+ v3.applyMatrix4(cutoutMesh.matrixWorld);
1617
+ v3.applyMatrix4(wallMatrixInverse);
1618
+ minX = Math.min(minX, v3.x);
1619
+ maxX = Math.max(maxX, v3.x);
1620
+ minY = Math.min(minY, v3.y);
1621
+ maxY = Math.max(maxY, v3.y);
1622
+ }
1623
+ if (!Number.isFinite(minX))
1624
+ continue;
1625
+ // An authored object's cutout keeps its shape (an arch, a circle): its own
1626
+ // geometry in wall space, stretched across the wall so it cuts both faces.
1627
+ if (child.source) {
1628
+ const shaped = authoredCutoutBrush(cutoutMesh, wallMatrixInverse, wallThickness, wallNode);
1629
+ if (shaped) {
1630
+ brushes.push(shaped);
1631
+ continue;
1632
+ }
1633
+ }
1634
+ // Create a box geometry that extends through the wall thickness
1635
+ const width = maxX - minX;
1636
+ const height = maxY - minY;
1637
+ const depth = wallThickness * 2; // Extend beyond wall to ensure clean cut
1638
+ const boxGeo = new THREE.BoxGeometry(width, height, depth);
1639
+ // Position box at the center of the cutout
1640
+ boxGeo.translate(minX + width / 2, minY + height / 2, getWallBodyCenterOffset(wallNode));
1641
+ // Pre-compute BVH with new API to avoid deprecation warning
1642
+ computeGeometryBoundsTree(boxGeo);
1643
+ const brush = new Brush(boxGeo);
1644
+ brushes.push(brush);
1195
1645
  }
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
1646
  }
1210
1647
  return brushes;
1211
1648
  }
1212
- function createOpeningCutoutBrush(opening, wallThickness) {
1649
+ function createOpeningCutoutBrush(opening, wallThickness, centerOffset, cutBottom) {
1213
1650
  const halfWidth = opening.width / 2;
1214
- const bottom = opening.position[1] - opening.height / 2;
1651
+ const bottom = cutBottom ?? opening.position[1] - opening.height / 2;
1215
1652
  const bottomPadding = getOpeningCutoutBottomPadding(opening, bottom);
1216
1653
  const geometry = buildOpeningCutoutGeometry(opening, {
1217
1654
  left: opening.position[0] - halfWidth,
@@ -1219,6 +1656,8 @@ function createOpeningCutoutBrush(opening, wallThickness) {
1219
1656
  bottom: bottom - bottomPadding,
1220
1657
  top: opening.position[1] + opening.height / 2,
1221
1658
  }, wallThickness * 2, wallThickness);
1659
+ if (centerOffset !== 0)
1660
+ geometry.translate(0, 0, centerOffset);
1222
1661
  computeGeometryBoundsTree(geometry);
1223
1662
  return new Brush(geometry);
1224
1663
  }