@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.
- 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 +14 -0
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +84 -26
- 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 +4 -0
- package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
- package/dist/components/viewer/viewer-camera.js +11 -1
- package/dist/index.d.ts +22 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -14
- package/dist/lib/asset-url.d.ts.map +1 -1
- package/dist/lib/asset-url.js +7 -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/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 +55 -17
- 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 +33 -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 +3 -5
- package/dist/systems/level/level-utils.d.ts +7 -4
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +17 -6
- 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 +7 -0
- package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout-cache.js +46 -17
- package/dist/systems/wall/wall-cutout.d.ts +6 -0
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +42 -28
- 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 +9 -4
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +99 -152
- package/dist/systems/wall/wall-system.d.ts +28 -2
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +582 -143
- 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 +41 -22
- package/package.json +5 -5
- 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';
|
|
@@ -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(() => () =>
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
923
|
-
|
|
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
|
-
|
|
930
|
-
|
|
1153
|
+
for (const fill of splitFills)
|
|
1154
|
+
ensureRenderableGeometryAttributes(fill);
|
|
1155
|
+
const merged = mergeGeometries([bodyGeometry, ...splitFills], false);
|
|
931
1156
|
if (!merged) {
|
|
932
|
-
fill
|
|
1157
|
+
for (const fill of splitFills)
|
|
1158
|
+
fill.dispose();
|
|
933
1159
|
return bodyGeometry;
|
|
934
1160
|
}
|
|
935
1161
|
bodyGeometry.dispose();
|
|
936
|
-
fill
|
|
1162
|
+
for (const fill of splitFills)
|
|
1163
|
+
fill.dispose();
|
|
937
1164
|
merged.computeVertexNormals();
|
|
938
|
-
assignWallMaterialGroups(merged, wall, boundaryEdges,
|
|
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
|
|
949
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
1016
|
-
|
|
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 *
|
|
1042
|
-
y: center.y + frame.normal.y *
|
|
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
|
|
1046
|
-
y: center.y
|
|
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 *
|
|
1068
|
-
y: startPoint.y + normalY *
|
|
1069
|
-
}, { x: endPoint.x + normalX *
|
|
1070
|
-
x: startPoint.x
|
|
1071
|
-
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,
|
|
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
|
|
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 =
|
|
1402
|
+
const splitGeometry = splitGeometryAtPlanes(geometry, finishPlanes.y, finishPlanes.x);
|
|
1100
1403
|
splitGeometry.computeVertexNormals();
|
|
1101
|
-
assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges,
|
|
1404
|
+
assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges, finish);
|
|
1102
1405
|
ensureRenderableGeometryAttributes(splitGeometry);
|
|
1103
|
-
return mergeWallTerrainFill(splitGeometry,
|
|
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 =
|
|
1449
|
+
const splitResultGeometry = splitGeometryAtPlanes(resultGeometry, finishPlanes.y, finishPlanes.x);
|
|
1147
1450
|
splitResultGeometry.computeVertexNormals();
|
|
1148
|
-
assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges,
|
|
1451
|
+
assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges, finish);
|
|
1149
1452
|
ensureRenderableGeometryAttributes(splitResultGeometry);
|
|
1150
|
-
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;
|
|
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
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
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
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
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
|
}
|