@pascal-app/viewer 1.0.1 → 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.
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +8 -3
- package/dist/components/viewer/frame-limiter.d.ts +1 -0
- package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.js +22 -3
- package/dist/components/viewer/glb-interactive.d.ts +25 -1
- package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
- package/dist/components/viewer/glb-interactive.js +252 -85
- package/dist/components/viewer/glb-mechanisms.d.ts +10 -0
- package/dist/components/viewer/glb-mechanisms.d.ts.map +1 -0
- package/dist/components/viewer/glb-mechanisms.js +19 -0
- package/dist/components/viewer/glb-scene.d.ts +45 -27
- package/dist/components/viewer/glb-scene.d.ts.map +1 -1
- package/dist/components/viewer/glb-scene.js +420 -506
- package/dist/components/viewer/glb-walkthrough-controller.d.ts +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.js +12 -1
- package/dist/components/viewer/index.d.ts +26 -0
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +108 -29
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +6 -1
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
- package/dist/components/viewer/perf-monitor.js +6 -0
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +8 -3
- package/dist/components/viewer/registered-systems.d.ts.map +1 -1
- package/dist/components/viewer/registered-systems.js +16 -2
- package/dist/components/viewer/render-error.d.ts +18 -0
- package/dist/components/viewer/render-error.d.ts.map +1 -0
- package/dist/components/viewer/render-error.js +29 -0
- package/dist/components/viewer/scene-ground-replacement.d.ts +6 -0
- package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -1
- package/dist/components/viewer/scene-ground-replacement.js +15 -0
- package/dist/components/viewer/selection-manager.d.ts.map +1 -1
- package/dist/components/viewer/selection-manager.js +10 -0
- package/dist/components/viewer/viewer-camera.d.ts +19 -1
- package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
- package/dist/components/viewer/viewer-camera.js +53 -5
- package/dist/index.d.ts +28 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -18
- package/dist/lib/asset-url.d.ts.map +1 -1
- package/dist/lib/asset-url.js +7 -1
- package/dist/lib/csg-utils.d.ts +1 -1
- package/dist/lib/csg-utils.d.ts.map +1 -1
- package/dist/lib/csg-utils.js +1 -1
- package/dist/lib/display-state.d.ts +25 -0
- package/dist/lib/display-state.d.ts.map +1 -0
- package/dist/lib/display-state.js +1 -0
- package/dist/lib/hero-pose.d.ts +12 -2
- package/dist/lib/hero-pose.d.ts.map +1 -1
- package/dist/lib/hero-pose.js +72 -5
- package/dist/lib/ink-edges.d.ts +4 -2
- package/dist/lib/ink-edges.d.ts.map +1 -1
- package/dist/lib/ink-edges.js +17 -7
- package/dist/lib/key-light-override.d.ts +11 -0
- package/dist/lib/key-light-override.d.ts.map +1 -0
- package/dist/lib/key-light-override.js +14 -0
- package/dist/lib/live-frame-hold.d.ts +14 -0
- package/dist/lib/live-frame-hold.d.ts.map +1 -0
- package/dist/lib/live-frame-hold.js +28 -0
- package/dist/lib/materials.d.ts +14 -7
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +31 -5
- package/dist/lib/node-top-surface-height.d.ts +6 -0
- package/dist/lib/node-top-surface-height.d.ts.map +1 -0
- package/dist/lib/node-top-surface-height.js +74 -0
- package/dist/lib/perf-probe-state.d.ts +39 -0
- package/dist/lib/perf-probe-state.d.ts.map +1 -0
- package/dist/lib/perf-probe-state.js +17 -0
- package/dist/lib/pointer-events.d.ts +8 -0
- package/dist/lib/pointer-events.d.ts.map +1 -1
- package/dist/lib/pointer-events.js +27 -4
- package/dist/lib/procedural-emission.d.ts +7 -0
- package/dist/lib/procedural-emission.d.ts.map +1 -0
- package/dist/lib/procedural-emission.js +54 -0
- package/dist/lib/renderer-capability.d.ts +2 -1
- package/dist/lib/renderer-capability.d.ts.map +1 -1
- package/dist/lib/renderer-capability.js +2 -2
- package/dist/lib/scene-support-height.d.ts +4 -0
- package/dist/lib/scene-support-height.d.ts.map +1 -0
- package/dist/lib/scene-support-height.js +34 -0
- package/dist/lib/scene-visibility.d.ts +6 -0
- package/dist/lib/scene-visibility.d.ts.map +1 -1
- package/dist/lib/scene-visibility.js +15 -3
- package/dist/lib/scripted-opening.d.ts +11 -0
- package/dist/lib/scripted-opening.d.ts.map +1 -0
- package/dist/lib/scripted-opening.js +20 -0
- package/dist/lib/snapshot-pipeline.d.ts +32 -2
- package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
- package/dist/lib/snapshot-pipeline.js +215 -105
- package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -1
- package/dist/lib/terrain-perimeter-fill.js +22 -14
- package/dist/lib/zone-geometry.d.ts +11 -0
- package/dist/lib/zone-geometry.d.ts.map +1 -0
- package/dist/lib/zone-geometry.js +46 -0
- package/dist/store/use-item-light-pool.d.ts +14 -10
- package/dist/store/use-item-light-pool.d.ts.map +1 -1
- package/dist/store/use-item-light-pool.js +52 -26
- package/dist/store/use-viewer.d.ts +11 -0
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +13 -6
- package/dist/systems/ceiling/ceiling-system.d.ts +15 -1
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +86 -6
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +73 -22
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts +3 -1
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
- package/dist/systems/floor-elevation/floor-elevation-system.js +148 -5
- package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
- package/dist/systems/geometry/geometry-system.js +71 -11
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +40 -15
- package/dist/systems/interactive/procedural-controls.d.ts +13 -0
- package/dist/systems/interactive/procedural-controls.d.ts.map +1 -0
- package/dist/systems/interactive/procedural-controls.js +17 -0
- package/dist/systems/interactive/scripted-clips.d.ts +19 -0
- package/dist/systems/interactive/scripted-clips.d.ts.map +1 -0
- package/dist/systems/interactive/scripted-clips.js +134 -0
- package/dist/systems/item/item-system.d.ts.map +1 -1
- package/dist/systems/item/item-system.js +9 -3
- package/dist/systems/item/object-cut-invalidation.d.ts +3 -0
- package/dist/systems/item/object-cut-invalidation.d.ts.map +1 -0
- package/dist/systems/item/object-cut-invalidation.js +68 -0
- package/dist/systems/item-light/item-light-system.d.ts +1 -1
- package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
- package/dist/systems/item-light/item-light-system.js +47 -52
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +17 -19
- package/dist/systems/level/level-utils.d.ts +28 -1
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +36 -4
- package/dist/systems/level/shadow-only-levels.d.ts +9 -0
- package/dist/systems/level/shadow-only-levels.d.ts.map +1 -0
- package/dist/systems/level/shadow-only-levels.js +7 -0
- package/dist/systems/roof/roof-materials.d.ts +14 -2
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +54 -15
- package/dist/systems/roof/roof-system.d.ts +13 -3
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +188 -13
- package/dist/systems/slab/slab-system.d.ts +1 -1
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +49 -19
- package/dist/systems/stair/stair-system.d.ts.map +1 -1
- package/dist/systems/stair/stair-system.js +133 -354
- package/dist/systems/wall/wall-cutout-cache.d.ts +11 -1
- package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout-cache.js +58 -20
- package/dist/systems/wall/wall-cutout.d.ts +9 -1
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +46 -32
- package/dist/systems/wall/wall-finish-data.d.ts +22 -0
- package/dist/systems/wall/wall-finish-data.d.ts.map +1 -0
- package/dist/systems/wall/wall-finish-data.js +17 -0
- package/dist/systems/wall/wall-materials.d.ts +17 -3
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +109 -156
- package/dist/systems/wall/wall-system.d.ts +43 -4
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +601 -163
- package/dist/systems/window/window-system.d.ts +1 -1
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +44 -24
- package/dist/xr/presentation-context.d.ts +7 -0
- package/dist/xr/presentation-context.d.ts.map +1 -0
- package/dist/xr/presentation-context.js +10 -0
- package/package.json +6 -6
- package/dist/systems/fence/fence-system.d.ts +0 -22
- package/dist/systems/fence/fence-system.d.ts.map +0 -1
- package/dist/systems/fence/fence-system.js +0 -357
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { DEFAULT_LEVEL_HEIGHT, getAdjacentWallIds, getEffectiveNode,
|
|
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(
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
return
|
|
273
|
-
const
|
|
274
|
-
return
|
|
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,
|
|
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(
|
|
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
|
|
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
|
|
356
|
-
: vertex
|
|
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
|
|
346
|
+
const denom = current[axis] - previous[axis];
|
|
364
347
|
if (Math.abs(denom) > WALL_BAND_SPLIT_EPSILON) {
|
|
365
|
-
out.push(interpolateSplitVertex(previous, current, (
|
|
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
|
|
384
|
-
|
|
385
|
-
.filter((plane) => Number.isFinite(plane)
|
|
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
|
-
|
|
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
|
|
396
|
+
for (const { axis, plane } of cuts) {
|
|
403
397
|
const next = [];
|
|
404
398
|
for (const polygon of polygons) {
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
if (plane <=
|
|
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 =
|
|
412
|
-
const above =
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
(
|
|
483
|
-
|
|
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';
|
|
@@ -496,14 +506,20 @@ export function getPendingWallRebuildCount() {
|
|
|
496
506
|
return drainStats.pendingNeighbours;
|
|
497
507
|
}
|
|
498
508
|
let placeholderSweepCountdown = WALL_PLACEHOLDER_SWEEP_INTERVAL;
|
|
499
|
-
export const WallSystem = () => {
|
|
509
|
+
export const WallSystem = ({ geometryAdapter } = {}) => {
|
|
500
510
|
useScene((state) => state.dirtyNodes);
|
|
501
511
|
useLiveNodeOverrides((s) => s.overrides);
|
|
502
|
-
useEffect(() => () =>
|
|
503
|
-
|
|
512
|
+
useEffect(() => () => {
|
|
513
|
+
clearLevelMiterCache();
|
|
514
|
+
zoneFinishRecords.clear();
|
|
515
|
+
zoneFinishNodes = null;
|
|
516
|
+
levelZones.clear();
|
|
517
|
+
levelZonesNodes = null;
|
|
518
|
+
}, []);
|
|
519
|
+
useFrame(() => runWallBuildFrame(geometryAdapter), 4);
|
|
504
520
|
return null;
|
|
505
521
|
};
|
|
506
|
-
export function runWallBuildFrame() {
|
|
522
|
+
export function runWallBuildFrame(geometryAdapter) {
|
|
507
523
|
const initialBuild = isWallInitialBuildActive();
|
|
508
524
|
const token = useScene.getState().hydrationToken;
|
|
509
525
|
if (token !== stalledHydrationToken) {
|
|
@@ -512,13 +528,100 @@ export function runWallBuildFrame() {
|
|
|
512
528
|
}
|
|
513
529
|
drainStats.wallsConsumedThisFrame = 0;
|
|
514
530
|
try {
|
|
515
|
-
consumeWallBuildFrame(initialBuild);
|
|
531
|
+
consumeWallBuildFrame(initialBuild, geometryAdapter);
|
|
516
532
|
}
|
|
517
533
|
finally {
|
|
518
534
|
publishWallDrainStats();
|
|
519
535
|
}
|
|
520
536
|
}
|
|
521
|
-
|
|
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
|
+
}
|
|
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) {
|
|
|
539
642
|
const dirtyNodes = useScene.getState().dirtyNodes;
|
|
540
643
|
const hasDirty = dirtyNodes.size > 0;
|
|
541
644
|
const hasPending = pendingAdjacentByLevel.size > 0;
|
|
542
|
-
if (!hasDirty
|
|
645
|
+
if (!(hasDirty || hasPending)) {
|
|
543
646
|
endInitialBuild();
|
|
544
647
|
return;
|
|
545
648
|
}
|
|
@@ -600,7 +703,7 @@ function consumeWallBuildFrame(initialBuild) {
|
|
|
600
703
|
}
|
|
601
704
|
const mesh = sceneRegistry.nodes.get(wallId);
|
|
602
705
|
if (mesh) {
|
|
603
|
-
timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), {
|
|
706
|
+
timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData, geometryAdapter), {
|
|
604
707
|
properties: [['node', wallId]],
|
|
605
708
|
});
|
|
606
709
|
clearDirty(wallId);
|
|
@@ -613,7 +716,7 @@ function consumeWallBuildFrame(initialBuild) {
|
|
|
613
716
|
else {
|
|
614
717
|
drainStats.reinvalidationBuilds++;
|
|
615
718
|
}
|
|
616
|
-
if (!initialBuild
|
|
719
|
+
if (!(initialBuild && firstBuild))
|
|
617
720
|
rebuiltWallIds.add(wallId);
|
|
618
721
|
rebuiltWallsThisFrame += 1;
|
|
619
722
|
drainStats.wallsConsumedThisFrame++;
|
|
@@ -641,7 +744,7 @@ function consumeWallBuildFrame(initialBuild) {
|
|
|
641
744
|
pendingAdjacentByLevel.set(levelId, pending);
|
|
642
745
|
}
|
|
643
746
|
for (const wallId of adjacentWallIds) {
|
|
644
|
-
if (!dirtyWallIds.has(wallId)
|
|
747
|
+
if (!(dirtyWallIds.has(wallId) || pending.has(wallId))) {
|
|
645
748
|
pending.add(wallId);
|
|
646
749
|
drainStats.pendingNeighbours++;
|
|
647
750
|
drainStats.neighbourEnqueues++;
|
|
@@ -677,7 +780,7 @@ function consumeWallBuildFrame(initialBuild) {
|
|
|
677
780
|
}
|
|
678
781
|
const mesh = sceneRegistry.nodes.get(wallId);
|
|
679
782
|
if (mesh) {
|
|
680
|
-
timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData), {
|
|
783
|
+
timeSpan('wall-rebuild', () => updateWallGeometry(wallId, miterData, geometryAdapter), {
|
|
681
784
|
properties: [['node', wallId]],
|
|
682
785
|
});
|
|
683
786
|
notifyWallRebuilt(wallId);
|
|
@@ -766,7 +869,7 @@ function getLevelWalls(levelId) {
|
|
|
766
869
|
* (override-merged) so a 2D drag visibly moves the 3D mesh without
|
|
767
870
|
* having touched `useScene` mid-drag.
|
|
768
871
|
*/
|
|
769
|
-
function updateWallGeometry(wallId, miterData) {
|
|
872
|
+
function updateWallGeometry(wallId, miterData, geometryAdapter) {
|
|
770
873
|
const nodes = useScene.getState().nodes;
|
|
771
874
|
const sceneNode = nodes[wallId];
|
|
772
875
|
if (sceneNode?.type !== 'wall')
|
|
@@ -779,54 +882,72 @@ function updateWallGeometry(wallId, miterData) {
|
|
|
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
|
-
// Merge live overrides into door / window children so cutouts track an
|
|
789
|
-
// in-flight resize drag (door width arrow, window height arrow, etc.)
|
|
790
|
-
// without waiting on the scene store. Non-cutout children pass through
|
|
791
|
-
// unchanged.
|
|
792
888
|
const childrenNodes = childrenIds
|
|
793
889
|
.map((childId) => nodes[childId])
|
|
794
890
|
.filter((n) => n !== undefined)
|
|
795
891
|
.map((child) => {
|
|
796
892
|
if (child.type !== 'door' && child.type !== 'window')
|
|
797
893
|
return child;
|
|
798
|
-
// `getEffectiveNode` folds in resize overrides (width/height arrows).
|
|
799
|
-
// Position moves publish to `useLiveTransforms` instead, so fold that
|
|
800
|
-
// in too — opening cutout brushes are rebuilt directly from the
|
|
801
|
-
// effective node position rather than from the rendered proxy mesh.
|
|
802
894
|
const effective = getEffectiveNode(child);
|
|
803
895
|
const live = useLiveTransforms.getState().get(child.id);
|
|
804
|
-
|
|
805
|
-
return effective;
|
|
806
|
-
return { ...effective, position: live.position };
|
|
896
|
+
return live?.position ? { ...effective, position: live.position } : effective;
|
|
807
897
|
});
|
|
808
|
-
const
|
|
898
|
+
const prepared = geometryAdapter?.prepareChildren?.(node, childrenNodes, {
|
|
899
|
+
isLive: (id) => useLiveNodeOverrides.getState().get(id) !== undefined ||
|
|
900
|
+
useLiveTransforms.getState().get(id) !== undefined,
|
|
901
|
+
}) ?? {
|
|
902
|
+
envelopeChildren: childrenNodes,
|
|
903
|
+
renderChildren: childrenNodes,
|
|
904
|
+
};
|
|
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)));
|
|
809
919
|
const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
|
|
810
920
|
// World transform the render mesh will apply (position + Y-rotation below).
|
|
811
921
|
// Reproduce it here so the UVs can be projected in WORLD space — see
|
|
812
922
|
// `applyWorldPlanarWallUVs`.
|
|
813
923
|
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);
|
|
814
|
-
const
|
|
924
|
+
const renderedGeo = geometryAdapter?.buildGeometry?.(node, builtGeo, prepared.renderChildren) ?? builtGeo;
|
|
925
|
+
const newGeo = applyWorldPlanarWallUVs(renderedGeo, wallWorldMatrix);
|
|
815
926
|
mesh.geometry.dispose();
|
|
816
927
|
// A degenerate rebuild (zero-length or fully cut wall) yields as few vertices
|
|
817
928
|
// as the mount-time placeholder; the stamp keeps the sweep from re-marking it.
|
|
818
929
|
newGeo.userData.built = true;
|
|
819
930
|
mesh.geometry = newGeo;
|
|
931
|
+
geometryAdapter?.syncAuxiliaryGeometry?.(node, mesh, newGeo);
|
|
820
932
|
// Update collision mesh
|
|
821
933
|
const collisionMesh = mesh.getObjectByName('collision-mesh');
|
|
822
934
|
if (collisionMesh) {
|
|
823
|
-
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);
|
|
824
936
|
collisionMesh.geometry.dispose();
|
|
825
937
|
collisionMesh.geometry = collisionGeo;
|
|
826
938
|
}
|
|
827
939
|
mesh.position.set(node.start[0], slabElevation, node.start[1]);
|
|
828
940
|
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]);
|
|
829
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;
|
|
830
951
|
}
|
|
831
952
|
const WALL_UV_Y_AXIS = new THREE.Vector3(0, 1, 0);
|
|
832
953
|
const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
|
|
@@ -843,8 +964,10 @@ const WALL_UV_UNIT_SCALE = new THREE.Vector3(1, 1, 1);
|
|
|
843
964
|
*/
|
|
844
965
|
function applyWorldPlanarWallUVs(geometry, worldMatrix) {
|
|
845
966
|
const target = geometry.index ? geometry.toNonIndexed() : geometry;
|
|
846
|
-
if (target !== geometry)
|
|
967
|
+
if (target !== geometry) {
|
|
968
|
+
target.userData = geometry.userData;
|
|
847
969
|
geometry.dispose();
|
|
970
|
+
}
|
|
848
971
|
const position = target.getAttribute('position');
|
|
849
972
|
if (!position || position.count === 0)
|
|
850
973
|
return target;
|
|
@@ -920,34 +1043,180 @@ function buildWallTerrainFillGeometry(perimeter, worldToLocal, wallBaseElevation
|
|
|
920
1043
|
});
|
|
921
1044
|
return buildTerrainPerimeterFillGeometry(localPoints, bottomY, 0);
|
|
922
1045
|
}
|
|
923
|
-
|
|
924
|
-
|
|
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)
|
|
925
1145
|
return body;
|
|
926
1146
|
const bodyGeometry = body.index ? body.toNonIndexed() : body;
|
|
927
1147
|
if (bodyGeometry !== body)
|
|
928
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));
|
|
929
1152
|
ensureRenderableGeometryAttributes(bodyGeometry);
|
|
930
|
-
|
|
931
|
-
|
|
1153
|
+
for (const fill of splitFills)
|
|
1154
|
+
ensureRenderableGeometryAttributes(fill);
|
|
1155
|
+
const merged = mergeGeometries([bodyGeometry, ...splitFills], false);
|
|
932
1156
|
if (!merged) {
|
|
933
|
-
fill
|
|
1157
|
+
for (const fill of splitFills)
|
|
1158
|
+
fill.dispose();
|
|
934
1159
|
return bodyGeometry;
|
|
935
1160
|
}
|
|
936
1161
|
bodyGeometry.dispose();
|
|
937
|
-
fill
|
|
1162
|
+
for (const fill of splitFills)
|
|
1163
|
+
fill.dispose();
|
|
938
1164
|
merged.computeVertexNormals();
|
|
939
|
-
assignWallMaterialGroups(merged, wall, boundaryEdges,
|
|
1165
|
+
assignWallMaterialGroups(merged, wall, boundaryEdges, finish);
|
|
940
1166
|
ensureRenderableGeometryAttributes(merged);
|
|
941
1167
|
return merged;
|
|
942
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
|
+
}
|
|
943
1198
|
export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabElevation = 0, baseElevation = slabElevation, baseSegments = [
|
|
944
1199
|
{ start: 0, end: 1, elevation: baseElevation },
|
|
945
|
-
], storeyHeight = DEFAULT_LEVEL_HEIGHT, terrainBottomAt) {
|
|
1200
|
+
], storeyHeight = DEFAULT_LEVEL_HEIGHT, terrainBottomAt, faceBase, finishLayout = buildWallFinishLayout(wallNode, []), sceneNodes = useScene.getState().nodes) {
|
|
946
1201
|
const wallStart = { x: wallNode.start[0], y: wallNode.start[1] };
|
|
947
1202
|
const wallEnd = { x: wallNode.end[0], y: wallNode.end[1] };
|
|
948
1203
|
const topElevation = resolveWallTop(wallNode, storeyHeight, slabElevation);
|
|
949
|
-
const
|
|
950
|
-
|
|
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);
|
|
951
1220
|
const localBottom = effectiveBaseElevation - slabElevation;
|
|
952
1221
|
const height = topElevation - effectiveBaseElevation;
|
|
953
1222
|
// A slab at or above the storey plane leaves a plane-bound wall with no
|
|
@@ -985,9 +1254,15 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
|
|
|
985
1254
|
// Convert polygon to local coordinates
|
|
986
1255
|
const localPoints = polyPoints.map(worldToLocal);
|
|
987
1256
|
const boundaryEdges = buildTaggedWallBoundaryEdges(wallNode, localPoints, miterData);
|
|
988
|
-
const
|
|
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
|
|
989
1263
|
? buildWallTerrainFillGeometry(polyPoints, worldToLocal, slabElevation, terrainBottomAt)
|
|
990
1264
|
: null;
|
|
1265
|
+
const belowBaseFills = [terrainFill, ...underpinningFills];
|
|
991
1266
|
// Build THREE.js shape
|
|
992
1267
|
// Shape uses (x, y) where we map: shape.x = local.x, shape.y = -local.z
|
|
993
1268
|
// The negation is needed because after rotateX(-PI/2), shape.y becomes -geometry.z
|
|
@@ -1007,20 +1282,31 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
|
|
|
1007
1282
|
if (Math.abs(localBottom) > 1e-9)
|
|
1008
1283
|
geometry.translate(0, localBottom, 0);
|
|
1009
1284
|
geometry.computeVertexNormals();
|
|
1010
|
-
assignWallMaterialGroups(geometry, wallNode, boundaryEdges,
|
|
1285
|
+
assignWallMaterialGroups(geometry, wallNode, boundaryEdges, finish);
|
|
1011
1286
|
ensureRenderableGeometryAttributes(geometry);
|
|
1012
1287
|
// Start with the lowest required wall prism, then remove the volume below
|
|
1013
1288
|
// each higher-supported run. This keeps the existing mitered footprint and
|
|
1014
1289
|
// opening CSG while giving one wall a stepped longitudinal base.
|
|
1015
1290
|
const baseProfileCutouts = [];
|
|
1016
|
-
|
|
1017
|
-
|
|
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);
|
|
1018
1301
|
const cutHeight = segmentElevation - effectiveBaseElevation;
|
|
1019
1302
|
if (cutHeight <= 1e-6 || segment.end - segment.start <= 1e-7)
|
|
1020
1303
|
continue;
|
|
1021
1304
|
const segmentStart = THREE.MathUtils.clamp(segment.start, 0, 1);
|
|
1022
1305
|
const segmentEnd = THREE.MathUtils.clamp(segment.end, 0, 1);
|
|
1023
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;
|
|
1024
1310
|
const worldCutoutPoints = [];
|
|
1025
1311
|
if (isCurvedWall(wallNode)) {
|
|
1026
1312
|
const sampleCount = Math.max(2, Math.ceil((segmentEnd - segmentStart) * 24));
|
|
@@ -1039,12 +1325,12 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
|
|
|
1039
1325
|
y: frame.point.y + frame.tangent.y * endpointExtension,
|
|
1040
1326
|
};
|
|
1041
1327
|
left.push({
|
|
1042
|
-
x: center.x + frame.normal.x *
|
|
1043
|
-
y: center.y + frame.normal.y *
|
|
1328
|
+
x: center.x + frame.normal.x * leftOffset,
|
|
1329
|
+
y: center.y + frame.normal.y * leftOffset,
|
|
1044
1330
|
});
|
|
1045
1331
|
right.push({
|
|
1046
|
-
x: center.x
|
|
1047
|
-
y: center.y
|
|
1332
|
+
x: center.x + frame.normal.x * rightOffset,
|
|
1333
|
+
y: center.y + frame.normal.y * rightOffset,
|
|
1048
1334
|
});
|
|
1049
1335
|
}
|
|
1050
1336
|
worldCutoutPoints.push(...left, ...right.reverse());
|
|
@@ -1065,11 +1351,11 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
|
|
|
1065
1351
|
y: wallStart.y + tangentY * (segmentEnd * L + endExtension),
|
|
1066
1352
|
};
|
|
1067
1353
|
worldCutoutPoints.push({
|
|
1068
|
-
x: startPoint.x + normalX *
|
|
1069
|
-
y: startPoint.y + normalY *
|
|
1070
|
-
}, { x: endPoint.x + normalX *
|
|
1071
|
-
x: startPoint.x
|
|
1072
|
-
y: startPoint.y
|
|
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,
|
|
1073
1359
|
});
|
|
1074
1360
|
}
|
|
1075
1361
|
const localCutoutPoints = worldCutoutPoints.map(worldToLocal);
|
|
@@ -1081,7 +1367,11 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
|
|
|
1081
1367
|
cutoutShape.lineTo(localCutoutPoints[index].x, -localCutoutPoints[index].z);
|
|
1082
1368
|
}
|
|
1083
1369
|
cutoutShape.closePath();
|
|
1084
|
-
const
|
|
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;
|
|
1085
1375
|
const cutoutTop = segmentElevation - slabElevation;
|
|
1086
1376
|
const cutoutGeometry = new THREE.ExtrudeGeometry(cutoutShape, {
|
|
1087
1377
|
depth: cutoutTop - cutoutBottom,
|
|
@@ -1089,19 +1379,31 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
|
|
|
1089
1379
|
});
|
|
1090
1380
|
cutoutGeometry.rotateX(-Math.PI / 2);
|
|
1091
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
|
+
}
|
|
1092
1394
|
computeGeometryBoundsTree(cutoutGeometry);
|
|
1093
1395
|
baseProfileCutouts.push(new Brush(cutoutGeometry));
|
|
1094
1396
|
}
|
|
1095
1397
|
const cutoutBrushes = [
|
|
1096
1398
|
...baseProfileCutouts,
|
|
1097
|
-
...collectCutoutBrushes(wallNode, childrenNodes, thickness),
|
|
1399
|
+
...collectCutoutBrushes(wallNode, childrenNodes, thickness, faceDatum, slabElevation, sceneNodes, baseSegments),
|
|
1098
1400
|
];
|
|
1099
1401
|
if (cutoutBrushes.length === 0) {
|
|
1100
|
-
const splitGeometry =
|
|
1402
|
+
const splitGeometry = splitGeometryAtPlanes(geometry, finishPlanes.y, finishPlanes.x);
|
|
1101
1403
|
splitGeometry.computeVertexNormals();
|
|
1102
|
-
assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges,
|
|
1404
|
+
assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, finish);
|
|
1103
1405
|
ensureRenderableGeometryAttributes(splitGeometry);
|
|
1104
|
-
return mergeWallTerrainFill(splitGeometry,
|
|
1406
|
+
return withWallFinishData(mergeWallTerrainFill(splitGeometry, belowBaseFills, wallNode, boundaryEdges, finish), finish);
|
|
1105
1407
|
}
|
|
1106
1408
|
// Create wall brush from geometry
|
|
1107
1409
|
// Pre-compute BVH with new API to avoid deprecation warning
|
|
@@ -1144,18 +1446,59 @@ export function generateExtrudedWall(wallNode, childrenNodes, miterData, slabEle
|
|
|
1144
1446
|
csgGeometry(brush).dispose();
|
|
1145
1447
|
}
|
|
1146
1448
|
const resultGeometry = csgGeometry(resultBrush);
|
|
1147
|
-
const splitResultGeometry =
|
|
1449
|
+
const splitResultGeometry = splitGeometryAtPlanes(resultGeometry, finishPlanes.y, finishPlanes.x);
|
|
1148
1450
|
splitResultGeometry.computeVertexNormals();
|
|
1149
|
-
assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges,
|
|
1451
|
+
assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, finish);
|
|
1150
1452
|
ensureRenderableGeometryAttributes(splitResultGeometry);
|
|
1151
|
-
return mergeWallTerrainFill(splitResultGeometry,
|
|
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;
|
|
1152
1463
|
}
|
|
1153
1464
|
/**
|
|
1154
1465
|
* Collects opening and item cutout brushes for CSG subtraction. Door/window
|
|
1155
1466
|
* cuts come directly from node geometry; item proxy meshes are transformed
|
|
1156
1467
|
* into wall-local boxes that pass through the wall.
|
|
1157
1468
|
*/
|
|
1158
|
-
|
|
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 }]) {
|
|
1159
1502
|
const brushes = [];
|
|
1160
1503
|
const wallMesh = sceneRegistry.nodes.get(wallNode.id);
|
|
1161
1504
|
if (!wallMesh)
|
|
@@ -1163,56 +1506,149 @@ function collectCutoutBrushes(wallNode, childrenNodes, wallThickness) {
|
|
|
1163
1506
|
// Get wall's world matrix inverse to transform cutouts to wall-local space
|
|
1164
1507
|
wallMesh.updateMatrixWorld();
|
|
1165
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 };
|
|
1166
1511
|
for (const child of childrenNodes) {
|
|
1167
1512
|
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door')
|
|
1168
1513
|
continue;
|
|
1169
|
-
|
|
1170
|
-
|
|
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();
|
|
1171
1602
|
continue;
|
|
1172
1603
|
}
|
|
1173
|
-
const
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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);
|
|
1196
1645
|
}
|
|
1197
|
-
if (!Number.isFinite(minX))
|
|
1198
|
-
continue;
|
|
1199
|
-
// Create a box geometry that extends through the wall thickness
|
|
1200
|
-
const width = maxX - minX;
|
|
1201
|
-
const height = maxY - minY;
|
|
1202
|
-
const depth = wallThickness * 2; // Extend beyond wall to ensure clean cut
|
|
1203
|
-
const boxGeo = new THREE.BoxGeometry(width, height, depth);
|
|
1204
|
-
// Position box at the center of the cutout
|
|
1205
|
-
boxGeo.translate(minX + width / 2, minY + height / 2, 0);
|
|
1206
|
-
// Pre-compute BVH with new API to avoid deprecation warning
|
|
1207
|
-
computeGeometryBoundsTree(boxGeo);
|
|
1208
|
-
const brush = new Brush(boxGeo);
|
|
1209
|
-
brushes.push(brush);
|
|
1210
1646
|
}
|
|
1211
1647
|
return brushes;
|
|
1212
1648
|
}
|
|
1213
|
-
function createOpeningCutoutBrush(opening, wallThickness) {
|
|
1649
|
+
function createOpeningCutoutBrush(opening, wallThickness, centerOffset, cutBottom) {
|
|
1214
1650
|
const halfWidth = opening.width / 2;
|
|
1215
|
-
const bottom = opening.position[1] - opening.height / 2;
|
|
1651
|
+
const bottom = cutBottom ?? opening.position[1] - opening.height / 2;
|
|
1216
1652
|
const bottomPadding = getOpeningCutoutBottomPadding(opening, bottom);
|
|
1217
1653
|
const geometry = buildOpeningCutoutGeometry(opening, {
|
|
1218
1654
|
left: opening.position[0] - halfWidth,
|
|
@@ -1220,6 +1656,8 @@ function createOpeningCutoutBrush(opening, wallThickness) {
|
|
|
1220
1656
|
bottom: bottom - bottomPadding,
|
|
1221
1657
|
top: opening.position[1] + opening.height / 2,
|
|
1222
1658
|
}, wallThickness * 2, wallThickness);
|
|
1659
|
+
if (centerOffset !== 0)
|
|
1660
|
+
geometry.translate(0, 0, centerOffset);
|
|
1223
1661
|
computeGeometryBoundsTree(geometry);
|
|
1224
1662
|
return new Brush(geometry);
|
|
1225
1663
|
}
|