@pascal-app/viewer 0.8.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/error-boundary.d.ts +6 -0
- package/dist/components/error-boundary.d.ts.map +1 -1
- package/dist/components/error-boundary.js +6 -0
- package/dist/components/renderers/node-renderer.d.ts +6 -2
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +41 -22
- package/dist/components/renderers/parametric-node-renderer.d.ts +5 -0
- package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -0
- package/dist/components/renderers/parametric-node-renderer.js +37 -0
- package/dist/components/renderers/scene-renderer.d.ts +1 -1
- package/dist/components/renderers/scene-renderer.d.ts.map +1 -1
- package/dist/components/viewer/bvh-ecctrl.d.ts +77 -0
- package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -0
- package/dist/components/viewer/bvh-ecctrl.js +552 -0
- package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.js +39 -4
- package/dist/components/viewer/glb-interactive.d.ts +41 -0
- package/dist/components/viewer/glb-interactive.d.ts.map +1 -0
- package/dist/components/viewer/glb-interactive.js +394 -0
- package/dist/components/viewer/glb-reference-nodes.d.ts +29 -0
- package/dist/components/viewer/glb-reference-nodes.d.ts.map +1 -0
- package/dist/components/viewer/glb-reference-nodes.js +80 -0
- package/dist/components/viewer/glb-replace-instances.d.ts +18 -0
- package/dist/components/viewer/glb-replace-instances.d.ts.map +1 -0
- package/dist/components/viewer/glb-replace-instances.js +61 -0
- package/dist/components/viewer/glb-scene.d.ts +54 -0
- package/dist/components/viewer/glb-scene.d.ts.map +1 -0
- package/dist/components/viewer/glb-scene.js +920 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts +12 -0
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -0
- package/dist/components/viewer/glb-walkthrough-controller.js +357 -0
- package/dist/components/viewer/index.d.ts +56 -3
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +280 -65
- package/dist/components/viewer/lights.d.ts +1 -1
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +201 -43
- package/dist/components/viewer/perf-monitor.d.ts +1 -1
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
- package/dist/components/viewer/perf-monitor.js +115 -12
- package/dist/components/viewer/post-processing.d.ts +7 -1
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +395 -63
- package/dist/components/viewer/registered-systems.d.ts +12 -0
- package/dist/components/viewer/registered-systems.d.ts.map +1 -0
- package/dist/components/viewer/registered-systems.js +49 -0
- package/dist/components/viewer/scene-bvh.d.ts +18 -0
- package/dist/components/viewer/scene-bvh.d.ts.map +1 -0
- package/dist/components/viewer/scene-bvh.js +97 -0
- package/dist/components/viewer/scene-bvh.test.d.ts +2 -0
- package/dist/components/viewer/scene-bvh.test.d.ts.map +1 -0
- package/dist/components/viewer/scene-bvh.test.js +13 -0
- package/dist/components/viewer/scene-environment.d.ts +2 -0
- package/dist/components/viewer/scene-environment.d.ts.map +1 -0
- package/dist/components/viewer/scene-environment.js +79 -0
- package/dist/components/viewer/selection-manager.d.ts +1 -1
- package/dist/components/viewer/selection-manager.d.ts.map +1 -1
- package/dist/components/viewer/selection-manager.js +37 -14
- package/dist/components/viewer/viewer-camera.d.ts +1 -1
- package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
- package/dist/components/viewer/walkthrough-controls.d.ts +2 -1
- package/dist/components/viewer/walkthrough-controls.d.ts.map +1 -1
- package/dist/components/viewer/walkthrough-controls.js +20 -0
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +3 -22
- package/dist/hooks/use-node-events.d.ts +6 -74
- package/dist/hooks/use-node-events.d.ts.map +1 -1
- package/dist/hooks/use-node-events.js +28 -8
- package/dist/index.d.ts +48 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +82 -4
- package/dist/lib/backdrop.d.ts +33 -0
- package/dist/lib/backdrop.d.ts.map +1 -0
- package/dist/lib/backdrop.js +92 -0
- package/dist/lib/box-uv.d.ts +13 -0
- package/dist/lib/box-uv.d.ts.map +1 -0
- package/dist/lib/box-uv.js +32 -0
- package/dist/lib/csg-utils.d.ts +10 -0
- package/dist/lib/csg-utils.d.ts.map +1 -0
- package/dist/lib/csg-utils.js +93 -0
- package/dist/lib/csg-utils.test.d.ts +2 -0
- package/dist/lib/csg-utils.test.d.ts.map +1 -0
- package/dist/lib/csg-utils.test.js +45 -0
- package/dist/lib/drawable-geometry.d.ts +21 -0
- package/dist/lib/drawable-geometry.d.ts.map +1 -0
- package/dist/lib/drawable-geometry.js +22 -0
- package/dist/lib/edge-style.d.ts +4 -0
- package/dist/lib/edge-style.d.ts.map +1 -0
- package/dist/lib/edge-style.js +31 -0
- package/dist/lib/gpu-perf.d.ts +4 -0
- package/dist/lib/gpu-perf.d.ts.map +1 -0
- package/dist/lib/gpu-perf.js +23 -0
- package/dist/lib/ink-edges.d.ts +9 -0
- package/dist/lib/ink-edges.d.ts.map +1 -0
- package/dist/lib/ink-edges.js +49 -0
- package/dist/lib/isolation.d.ts +39 -0
- package/dist/lib/isolation.d.ts.map +1 -0
- package/dist/lib/isolation.js +103 -0
- package/dist/lib/ktx2-loader.d.ts +43 -0
- package/dist/lib/ktx2-loader.d.ts.map +1 -0
- package/dist/lib/ktx2-loader.js +136 -0
- package/dist/lib/layers.d.ts +28 -0
- package/dist/lib/layers.d.ts.map +1 -1
- package/dist/lib/layers.js +28 -0
- package/dist/lib/materials.d.ts +38 -15
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +364 -72
- package/dist/lib/merged-outline-node.d.ts.map +1 -1
- package/dist/lib/merged-outline-node.js +7 -0
- package/dist/lib/polygon-union.d.ts +4 -0
- package/dist/lib/polygon-union.d.ts.map +1 -0
- package/dist/lib/polygon-union.js +289 -0
- package/dist/lib/polygon-union.test.d.ts +2 -0
- package/dist/lib/polygon-union.test.d.ts.map +1 -0
- package/dist/lib/polygon-union.test.js +105 -0
- package/dist/lib/scene-themes.d.ts +30 -0
- package/dist/lib/scene-themes.d.ts.map +1 -0
- package/dist/lib/scene-themes.js +202 -0
- package/dist/lib/shadow-only.d.ts +4 -0
- package/dist/lib/shadow-only.d.ts.map +1 -0
- package/dist/lib/shadow-only.js +36 -0
- package/dist/lib/tsl-compat.d.ts +12 -0
- package/dist/lib/tsl-compat.d.ts.map +1 -0
- package/dist/lib/tsl-compat.js +13 -0
- package/dist/store/use-viewer.d.ts +92 -12
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +201 -6
- package/dist/store/use-viewer.test.d.ts +2 -0
- package/dist/store/use-viewer.test.d.ts.map +1 -0
- package/dist/store/use-viewer.test.js +42 -0
- package/dist/systems/ceiling/ceiling-system.d.ts +6 -2
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +66 -13
- package/dist/systems/door/door-animation.test.d.ts +2 -0
- package/dist/systems/door/door-animation.test.d.ts.map +1 -0
- package/dist/systems/door/door-animation.test.js +51 -0
- package/dist/systems/door/door-floor-alignment.test.d.ts +2 -0
- package/dist/systems/door/door-floor-alignment.test.d.ts.map +1 -0
- package/dist/systems/door/door-floor-alignment.test.js +50 -0
- package/dist/systems/door/door-system.d.ts +24 -0
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +864 -180
- package/dist/systems/elevator/elevator-interaction-system.d.ts +2 -0
- package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -0
- package/dist/systems/elevator/elevator-interaction-system.js +62 -0
- package/dist/systems/fence/fence-system.d.ts +20 -0
- package/dist/systems/fence/fence-system.d.ts.map +1 -1
- package/dist/systems/fence/fence-system.js +229 -57
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts +20 -0
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -0
- package/dist/systems/floor-elevation/floor-elevation-system.js +78 -0
- package/dist/systems/geometry/geometry-system.d.ts +41 -0
- package/dist/systems/geometry/geometry-system.d.ts.map +1 -0
- package/dist/systems/geometry/geometry-system.js +348 -0
- package/dist/systems/geometry/geometry-system.test.d.ts +2 -0
- package/dist/systems/geometry/geometry-system.test.d.ts.map +1 -0
- package/dist/systems/geometry/geometry-system.test.js +30 -0
- package/dist/systems/interactive/control-widget.d.ts +10 -0
- package/dist/systems/interactive/control-widget.d.ts.map +1 -0
- package/dist/systems/interactive/control-widget.js +35 -0
- package/dist/systems/interactive/interactive-system.d.ts +1 -1
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +55 -56
- package/dist/systems/item/item-system.d.ts +7 -0
- package/dist/systems/item/item-system.d.ts.map +1 -1
- package/dist/systems/item/item-system.js +28 -17
- 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 +18 -5
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +28 -4
- package/dist/systems/level/level-utils.d.ts +0 -3
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +2 -41
- package/dist/systems/roof/roof-materials.d.ts +3 -2
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +59 -14
- package/dist/systems/roof/roof-system.d.ts +46 -6
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +1365 -170
- package/dist/systems/slab/slab-system.d.ts +5 -3
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +204 -68
- package/dist/systems/slab/slab-system.test.d.ts +2 -0
- package/dist/systems/slab/slab-system.test.d.ts.map +1 -0
- package/dist/systems/slab/slab-system.test.js +63 -0
- package/dist/systems/stair/stair-materials.d.ts +4 -3
- package/dist/systems/stair/stair-materials.d.ts.map +1 -1
- package/dist/systems/stair/stair-materials.js +30 -15
- package/dist/systems/stair/stair-system.d.ts.map +1 -1
- package/dist/systems/stair/stair-system.js +55 -63
- package/dist/systems/surface-hole-geometry.d.ts +3 -0
- package/dist/systems/surface-hole-geometry.d.ts.map +1 -0
- package/dist/systems/surface-hole-geometry.js +4 -0
- package/dist/systems/wall/opening-cutout-geometry.d.ts +31 -0
- package/dist/systems/wall/opening-cutout-geometry.d.ts.map +1 -0
- package/dist/systems/wall/opening-cutout-geometry.js +167 -0
- package/dist/systems/wall/opening-cutout-geometry.test.d.ts +2 -0
- package/dist/systems/wall/opening-cutout-geometry.test.d.ts.map +1 -0
- package/dist/systems/wall/opening-cutout-geometry.test.js +172 -0
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +62 -17
- package/dist/systems/wall/wall-materials.d.ts +12 -7
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +279 -46
- package/dist/systems/wall/wall-opening-cutout.test.d.ts +2 -0
- package/dist/systems/wall/wall-opening-cutout.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-opening-cutout.test.js +59 -0
- package/dist/systems/wall/wall-support-extension.test.d.ts +2 -0
- package/dist/systems/wall/wall-support-extension.test.d.ts.map +1 -0
- package/dist/systems/wall/wall-support-extension.test.js +48 -0
- package/dist/systems/wall/wall-system.d.ts +2 -2
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +493 -193
- package/dist/systems/window/window-animation-system.d.ts +12 -0
- package/dist/systems/window/window-animation-system.d.ts.map +1 -1
- package/dist/systems/window/window-animation-system.js +16 -5
- package/dist/systems/window/window-system.d.ts +7 -0
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +308 -18
- package/package.json +5 -6
- package/dist/components/renderers/building/building-renderer.d.ts +0 -5
- package/dist/components/renderers/building/building-renderer.d.ts.map +0 -1
- package/dist/components/renderers/building/building-renderer.js +0 -11
- package/dist/components/renderers/ceiling/ceiling-renderer.d.ts +0 -5
- package/dist/components/renderers/ceiling/ceiling-renderer.d.ts.map +0 -1
- package/dist/components/renderers/ceiling/ceiling-renderer.js +0 -58
- package/dist/components/renderers/column/column-renderer.d.ts +0 -5
- package/dist/components/renderers/column/column-renderer.d.ts.map +0 -1
- package/dist/components/renderers/column/column-renderer.js +0 -597
- package/dist/components/renderers/door/door-renderer.d.ts +0 -5
- package/dist/components/renderers/door/door-renderer.d.ts.map +0 -1
- package/dist/components/renderers/door/door-renderer.js +0 -16
- package/dist/components/renderers/fence/fence-renderer.d.ts +0 -5
- package/dist/components/renderers/fence/fence-renderer.d.ts.map +0 -1
- package/dist/components/renderers/fence/fence-renderer.js +0 -15
- package/dist/components/renderers/guide/guide-renderer.d.ts +0 -5
- package/dist/components/renderers/guide/guide-renderer.d.ts.map +0 -1
- package/dist/components/renderers/guide/guide-renderer.js +0 -38
- package/dist/components/renderers/item/item-renderer.d.ts +0 -5
- package/dist/components/renderers/item/item-renderer.d.ts.map +0 -1
- package/dist/components/renderers/item/item-renderer.js +0 -172
- package/dist/components/renderers/level/level-renderer.d.ts +0 -5
- package/dist/components/renderers/level/level-renderer.d.ts.map +0 -1
- package/dist/components/renderers/level/level-renderer.js +0 -11
- package/dist/components/renderers/roof/roof-materials.d.ts +0 -4
- package/dist/components/renderers/roof/roof-materials.d.ts.map +0 -1
- package/dist/components/renderers/roof/roof-materials.js +0 -16
- package/dist/components/renderers/roof/roof-renderer.d.ts +0 -5
- package/dist/components/renderers/roof/roof-renderer.d.ts.map +0 -1
- package/dist/components/renderers/roof/roof-renderer.js +0 -32
- package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts +0 -5
- package/dist/components/renderers/roof-segment/roof-segment-renderer.d.ts.map +0 -1
- package/dist/components/renderers/roof-segment/roof-segment-renderer.js +0 -40
- package/dist/components/renderers/scan/scan-renderer.d.ts +0 -5
- package/dist/components/renderers/scan/scan-renderer.d.ts.map +0 -1
- package/dist/components/renderers/scan/scan-renderer.js +0 -54
- package/dist/components/renderers/site/site-renderer.d.ts +0 -5
- package/dist/components/renderers/site/site-renderer.d.ts.map +0 -1
- package/dist/components/renderers/site/site-renderer.js +0 -106
- package/dist/components/renderers/slab/slab-renderer.d.ts +0 -5
- package/dist/components/renderers/slab/slab-renderer.d.ts.map +0 -1
- package/dist/components/renderers/slab/slab-renderer.js +0 -58
- package/dist/components/renderers/spawn/spawn-renderer.d.ts +0 -5
- package/dist/components/renderers/spawn/spawn-renderer.d.ts.map +0 -1
- package/dist/components/renderers/spawn/spawn-renderer.js +0 -32
- package/dist/components/renderers/stair/stair-renderer.d.ts +0 -5
- package/dist/components/renderers/stair/stair-renderer.d.ts.map +0 -1
- package/dist/components/renderers/stair/stair-renderer.js +0 -603
- package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts +0 -5
- package/dist/components/renderers/stair-segment/stair-segment-renderer.d.ts.map +0 -1
- package/dist/components/renderers/stair-segment/stair-segment-renderer.js +0 -51
- package/dist/components/renderers/wall/wall-renderer.d.ts +0 -5
- package/dist/components/renderers/wall/wall-renderer.d.ts.map +0 -1
- package/dist/components/renderers/wall/wall-renderer.js +0 -16
- package/dist/components/renderers/window/window-renderer.d.ts +0 -5
- package/dist/components/renderers/window/window-renderer.d.ts.map +0 -1
- package/dist/components/renderers/window/window-renderer.js +0 -21
- package/dist/components/renderers/zone/zone-renderer.d.ts +0 -5
- package/dist/components/renderers/zone/zone-renderer.d.ts.map +0 -1
- package/dist/components/renderers/zone/zone-renderer.js +0 -181
- package/dist/components/viewer/ground-occluder.d.ts +0 -2
- package/dist/components/viewer/ground-occluder.d.ts.map +0 -1
- package/dist/components/viewer/ground-occluder.js +0 -79
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { sceneRegistry, useScene, } from '@pascal-app/core';
|
|
1
|
+
import { getDutchEndSlopeFaces, getDutchRoofShapeMetrics, getEffectiveNode, getRoofModuleFaces, getRoofShapeInsets, getRoofShapeRatios, getSegmentSlopeFrame, hasSegmentMaterialOverride, nodeRegistry, normalizeRoofSegmentTrim, ROOF_SHAPE_DEFAULTS, sceneRegistry, useLiveNodeOverrides, useScene, } from '@pascal-app/core';
|
|
2
2
|
import { useFrame } from '@react-three/fiber';
|
|
3
3
|
import * as THREE from 'three';
|
|
4
|
-
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
4
|
+
import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
5
5
|
import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
|
|
6
6
|
import { computeBoundsTree } from 'three-mesh-bvh';
|
|
7
|
+
import { applyWorldScaleBoxUVs } from '../../lib/box-uv';
|
|
8
|
+
import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils';
|
|
7
9
|
function csgGeometry(brush) {
|
|
8
10
|
return brush.geometry;
|
|
9
11
|
}
|
|
@@ -14,13 +16,14 @@ function csgMaterials(brush) {
|
|
|
14
16
|
const csgEvaluator = new Evaluator();
|
|
15
17
|
csgEvaluator.useGroups = true;
|
|
16
18
|
csgEvaluator.consolidateGroups = false; // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash
|
|
17
|
-
csgEvaluator.attributes = ['position', 'normal', 'uv'];
|
|
19
|
+
csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'];
|
|
18
20
|
function computeGeometryBoundsTree(geometry) {
|
|
19
21
|
;
|
|
20
22
|
geometry.computeBoundsTree = computeBoundsTree;
|
|
21
23
|
geometry.computeBoundsTree({ maxLeafSize: 10 });
|
|
22
24
|
}
|
|
23
25
|
function prepareBrushForCSG(brush) {
|
|
26
|
+
ensureRenderableGeometryAttributes(brush.geometry);
|
|
24
27
|
computeGeometryBoundsTree(brush.geometry);
|
|
25
28
|
brush.updateMatrixWorld();
|
|
26
29
|
}
|
|
@@ -31,8 +34,81 @@ const _quaternion = new THREE.Quaternion();
|
|
|
31
34
|
const _scale = new THREE.Vector3(1, 1, 1);
|
|
32
35
|
const _yAxis = new THREE.Vector3(0, 1, 0);
|
|
33
36
|
const _uvFaceNormal = new THREE.Vector3();
|
|
37
|
+
const _uvWorldDown = new THREE.Vector3(0, -1, 0);
|
|
38
|
+
const _uvDownSlope = new THREE.Vector3();
|
|
39
|
+
const _uvAcrossSlope = new THREE.Vector3();
|
|
40
|
+
// World transform of the segment whose geometry is currently being built, so
|
|
41
|
+
// vertical (gable wall) faces project their UVs in WORLD space — identical to
|
|
42
|
+
// the wall kind's `applyWorldPlanarWallUVs` (U = ±worldX/Z, V = 1 - worldY) so
|
|
43
|
+
// the gable band tiles continuously into the walls below. Identity = local
|
|
44
|
+
// (the default outside a segment build). Set via `withSegmentUvMatrix`.
|
|
45
|
+
const _segmentUvMatrix = new THREE.Matrix4();
|
|
46
|
+
const _segmentUvNormalMatrix = new THREE.Matrix3();
|
|
47
|
+
const _uvWorldPoint = new THREE.Vector3();
|
|
48
|
+
const _uvWorldNormal = new THREE.Vector3();
|
|
49
|
+
const _prevSegmentUvMatrix = new THREE.Matrix4();
|
|
50
|
+
const _prevSegmentUvNormalMatrix = new THREE.Matrix3();
|
|
51
|
+
function withSegmentUvMatrix(matrix, build) {
|
|
52
|
+
_prevSegmentUvMatrix.copy(_segmentUvMatrix);
|
|
53
|
+
_prevSegmentUvNormalMatrix.copy(_segmentUvNormalMatrix);
|
|
54
|
+
_segmentUvMatrix.copy(matrix);
|
|
55
|
+
_segmentUvNormalMatrix.getNormalMatrix(matrix);
|
|
56
|
+
try {
|
|
57
|
+
return build();
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
_segmentUvMatrix.copy(_prevSegmentUvMatrix);
|
|
61
|
+
_segmentUvNormalMatrix.copy(_prevSegmentUvNormalMatrix);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Scratch matrices for composing a segment's world transform (roof group ∘
|
|
65
|
+
// segment) at each build, without per-call allocation.
|
|
66
|
+
const _segWorldMatrix = new THREE.Matrix4();
|
|
67
|
+
const _roofGroupMatrix = new THREE.Matrix4();
|
|
68
|
+
const _segLocalMatrix = new THREE.Matrix4();
|
|
69
|
+
const _uvTmpPos = new THREE.Vector3();
|
|
70
|
+
const _uvTmpQuat = new THREE.Quaternion();
|
|
71
|
+
const _uvUnitScale = new THREE.Vector3(1, 1, 1);
|
|
72
|
+
/** Compose the world transform `T(roofPos)·Ry(roofRot) · T(segPos)·Ry(segRot)`. */
|
|
73
|
+
function composeSegmentWorldMatrix(roofPosition, roofRotation, segPosition, segRotation) {
|
|
74
|
+
_roofGroupMatrix.compose(_uvTmpPos.set(roofPosition?.[0] ?? 0, roofPosition?.[1] ?? 0, roofPosition?.[2] ?? 0), _uvTmpQuat.setFromAxisAngle(_yAxis, roofRotation), _uvUnitScale);
|
|
75
|
+
_segLocalMatrix.compose(_uvTmpPos.set(segPosition[0] ?? 0, segPosition[1] ?? 0, segPosition[2] ?? 0), _uvTmpQuat.setFromAxisAngle(_yAxis, segRotation), _uvUnitScale);
|
|
76
|
+
return _segWorldMatrix.multiplyMatrices(_roofGroupMatrix, _segLocalMatrix);
|
|
77
|
+
}
|
|
78
|
+
const _tmpVec3A = new THREE.Vector3();
|
|
79
|
+
const _tmpVec3B = new THREE.Vector3();
|
|
80
|
+
const _surfaceRay = new THREE.Ray();
|
|
81
|
+
const _surfaceOrigin = new THREE.Vector3();
|
|
82
|
+
const _surfaceDir = new THREE.Vector3(0, -1, 0);
|
|
83
|
+
const _surfaceHits = [];
|
|
84
|
+
const _surfaceV0 = new THREE.Vector3();
|
|
85
|
+
const _surfaceV1 = new THREE.Vector3();
|
|
86
|
+
const _surfaceV2 = new THREE.Vector3();
|
|
87
|
+
const _surfaceFaceNormal = new THREE.Vector3();
|
|
88
|
+
/**
|
|
89
|
+
* Degenerate placeholder for a roof mesh with nothing to draw (initial
|
|
90
|
+
* BoxGeometry swap-out, or a roof whose segments were all deleted/painted).
|
|
91
|
+
* Three zero-vertices (one invisible triangle), not an empty attribute: an
|
|
92
|
+
* empty position (count 0) leaves WebGPU vertex buffer slot 0 unbound if the
|
|
93
|
+
* mesh is ever drawn, and computeBoundsTree needs a real position buffer to
|
|
94
|
+
* index. Deliberately NO groups: count-0 groups crash MeshBVH's packed-tree
|
|
95
|
+
* build (it partitions roots by group), and a BoxGeometry's 6 groups against
|
|
96
|
+
* the 4 roof materials crash raycasts and GLTFExporter. Group-less + a
|
|
97
|
+
* zero-area triangle is safe everywhere — it draws nothing under an array
|
|
98
|
+
* material and can never be ray-hit.
|
|
99
|
+
*/
|
|
100
|
+
function createDegenerateRoofPlaceholder() {
|
|
101
|
+
const placeholder = new THREE.BufferGeometry();
|
|
102
|
+
placeholder.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
|
|
103
|
+
placeholder.setAttribute('normal', new THREE.Float32BufferAttribute(new Float32Array(9), 3));
|
|
104
|
+
placeholder.setAttribute('uv', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
|
|
105
|
+
placeholder.setAttribute('uv2', new THREE.Float32BufferAttribute(new Float32Array(6), 2));
|
|
106
|
+
computeGeometryBoundsTree(placeholder);
|
|
107
|
+
return placeholder;
|
|
108
|
+
}
|
|
34
109
|
// Pending merged-roof updates carried across frames (for throttling)
|
|
35
110
|
const pendingRoofUpdates = new Set();
|
|
111
|
+
const warnedMergedRoofNaNIds = new Set();
|
|
36
112
|
const MAX_ROOFS_PER_FRAME = 1;
|
|
37
113
|
const MAX_SEGMENTS_PER_FRAME = 3;
|
|
38
114
|
// ============================================================================
|
|
@@ -42,10 +118,20 @@ export const RoofSystem = () => {
|
|
|
42
118
|
const dirtyNodes = useScene((state) => state.dirtyNodes);
|
|
43
119
|
const clearDirty = useScene((state) => state.clearDirty);
|
|
44
120
|
const rootNodeIds = useScene((state) => state.rootNodeIds);
|
|
121
|
+
// Subscribe so an override-only update (no scene write) still re-runs
|
|
122
|
+
// the component, letting the useFrame loop pick up the latest dirtyNodes
|
|
123
|
+
// set from the render pass that received the override-publishing
|
|
124
|
+
// `markDirty` call. Mirrors WallSystem / DoorSystem.
|
|
125
|
+
useLiveNodeOverrides((s) => s.overrides);
|
|
45
126
|
useFrame(() => {
|
|
46
127
|
// Clear stale pending updates when the scene is unloaded
|
|
47
128
|
if (rootNodeIds.length === 0) {
|
|
48
129
|
pendingRoofUpdates.clear();
|
|
130
|
+
warnedMergedRoofNaNIds.clear();
|
|
131
|
+
for (const cached of mergedRoofSegmentGeometryCache.values()) {
|
|
132
|
+
disposeCachedMergedRoofSegmentGeometrySet(cached);
|
|
133
|
+
}
|
|
134
|
+
mergedRoofSegmentGeometryCache.clear();
|
|
49
135
|
return;
|
|
50
136
|
}
|
|
51
137
|
if (dirtyNodes.size === 0 && pendingRoofUpdates.size === 0)
|
|
@@ -57,17 +143,49 @@ export const RoofSystem = () => {
|
|
|
57
143
|
const node = nodes[id];
|
|
58
144
|
if (!node)
|
|
59
145
|
return;
|
|
146
|
+
// Cutting roof accessories cascade their dirty mark to the host
|
|
147
|
+
// segment's parent roof so the merged shell re-CSGs with the new
|
|
148
|
+
// cut. Non-cutting accessories (vents, panels, gutters, etc.) sit on
|
|
149
|
+
// top of the shell and should not force a full roof merge.
|
|
150
|
+
const def = nodeRegistry.get(node.type);
|
|
151
|
+
// Kinds with `dirtyHandledByOwnSystem` (door / window) reach the roof
|
|
152
|
+
// through their own geometry system's parentId cascade instead —
|
|
153
|
+
// their dirty marks belong to that system, not to this loop.
|
|
154
|
+
const roofAccessory = def?.capabilities?.roofAccessory;
|
|
155
|
+
if (roofAccessory && !roofAccessory.dirtyHandledByOwnSystem) {
|
|
156
|
+
const segId = node.roofSegmentId;
|
|
157
|
+
const seg = segId ? nodes[segId] : undefined;
|
|
158
|
+
if (roofAccessory.buildCut && seg?.parentId) {
|
|
159
|
+
pendingRoofUpdates.add(seg.parentId);
|
|
160
|
+
}
|
|
161
|
+
clearDirty(id);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
60
164
|
if (node.type === 'roof-segment') {
|
|
61
165
|
const mesh = sceneRegistry.nodes.get(id);
|
|
166
|
+
// Merge any live override (width / depth / wallHeight / pitch /
|
|
167
|
+
// rotation) so the mesh rebuild reflects the in-flight handle drag
|
|
168
|
+
// without zustand churn. When no override is set this returns the
|
|
169
|
+
// scene node unchanged. Same pattern as DoorSystem / WallSystem.
|
|
170
|
+
const effectiveSegment = getEffectiveNode(node);
|
|
62
171
|
if (mesh) {
|
|
63
172
|
// Only compute expensive individual CSG when the segment is actually rendered
|
|
64
173
|
// (its parent group is visible = the roof is selected for editing)
|
|
65
174
|
const isVisible = mesh.parent?.visible !== false;
|
|
66
|
-
|
|
67
|
-
|
|
175
|
+
// Accessory-reveal mode (RoofEditSystem): the wrapper is shown so
|
|
176
|
+
// portaled handles render, but the merged shell stays visible and
|
|
177
|
+
// the segment meshes are stripped to empty placeholders. Rebuilding
|
|
178
|
+
// per-segment CSG here would draw UNCUT geometry on top of the
|
|
179
|
+
// merged shell — hiding a freshly cut opening (door / window /
|
|
180
|
+
// skylight) until the next deselect. Full edit mode hides the
|
|
181
|
+
// merged mesh, so gate the rebuild on its visibility.
|
|
182
|
+
const revealOnly = mesh.parent?.name === 'segments-wrapper' &&
|
|
183
|
+
mesh.parent?.parent?.getObjectByName('merged-roof')?.visible === true;
|
|
184
|
+
if (isVisible && !revealOnly && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
|
|
185
|
+
updateRoofSegmentGeometry(effectiveSegment, mesh, nodes);
|
|
68
186
|
segmentsProcessed++;
|
|
69
187
|
}
|
|
70
|
-
else if (isVisible) {
|
|
188
|
+
else if (isVisible && !revealOnly) {
|
|
71
189
|
return; // Over budget — keep dirty, process next frame
|
|
72
190
|
}
|
|
73
191
|
else {
|
|
@@ -77,13 +195,10 @@ export const RoofSystem = () => {
|
|
|
77
195
|
// so MeshBVH hits groups[4].materialIndex → undefined.side → crash.
|
|
78
196
|
if (mesh.geometry.type === 'BoxGeometry') {
|
|
79
197
|
mesh.geometry.dispose();
|
|
80
|
-
|
|
81
|
-
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
|
|
82
|
-
computeGeometryBoundsTree(placeholder);
|
|
83
|
-
mesh.geometry = placeholder;
|
|
198
|
+
mesh.geometry = createDegenerateRoofPlaceholder();
|
|
84
199
|
}
|
|
85
|
-
mesh.position.set(
|
|
86
|
-
mesh.rotation.y =
|
|
200
|
+
mesh.position.set(effectiveSegment.position[0], effectiveSegment.position[1], effectiveSegment.position[2]);
|
|
201
|
+
mesh.rotation.y = effectiveSegment.rotation;
|
|
87
202
|
}
|
|
88
203
|
clearDirty(id);
|
|
89
204
|
}
|
|
@@ -91,8 +206,8 @@ export const RoofSystem = () => {
|
|
|
91
206
|
clearDirty(id);
|
|
92
207
|
}
|
|
93
208
|
// Queue the parent roof for a merged geometry update
|
|
94
|
-
if (
|
|
95
|
-
pendingRoofUpdates.add(
|
|
209
|
+
if (effectiveSegment.parentId) {
|
|
210
|
+
pendingRoofUpdates.add(effectiveSegment.parentId);
|
|
96
211
|
}
|
|
97
212
|
}
|
|
98
213
|
else if (node.type === 'roof') {
|
|
@@ -106,18 +221,20 @@ export const RoofSystem = () => {
|
|
|
106
221
|
if (roofsProcessed >= MAX_ROOFS_PER_FRAME)
|
|
107
222
|
break;
|
|
108
223
|
const node = nodes[id];
|
|
109
|
-
if (
|
|
224
|
+
if (node?.type !== 'roof') {
|
|
110
225
|
pendingRoofUpdates.delete(id);
|
|
111
226
|
continue;
|
|
112
227
|
}
|
|
113
228
|
const group = sceneRegistry.nodes.get(id);
|
|
114
|
-
if (group)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
229
|
+
if (!group)
|
|
230
|
+
continue;
|
|
231
|
+
const mergedMesh = group.getObjectByName('merged-roof');
|
|
232
|
+
if (!mergedMesh)
|
|
233
|
+
continue;
|
|
234
|
+
if (mergedMesh.visible !== false) {
|
|
235
|
+
// Only rebuild when visible — RoofEditSystem re-triggers via markDirty on edit mode exit
|
|
236
|
+
updateMergedRoofGeometry(node, group, nodes);
|
|
237
|
+
roofsProcessed++;
|
|
121
238
|
}
|
|
122
239
|
pendingRoofUpdates.delete(id);
|
|
123
240
|
}
|
|
@@ -127,44 +244,180 @@ export const RoofSystem = () => {
|
|
|
127
244
|
// ============================================================================
|
|
128
245
|
// GEOMETRY GENERATION
|
|
129
246
|
// ============================================================================
|
|
130
|
-
function updateRoofSegmentGeometry(node, mesh) {
|
|
131
|
-
const newGeo = generateRoofSegmentGeometry(node);
|
|
247
|
+
function updateRoofSegmentGeometry(node, mesh, nodes) {
|
|
248
|
+
const newGeo = generateRoofSegmentGeometry(node, nodes);
|
|
132
249
|
mesh.geometry.dispose();
|
|
133
250
|
mesh.geometry = newGeo;
|
|
134
251
|
computeGeometryBoundsTree(newGeo);
|
|
135
252
|
mesh.position.set(node.position[0], node.position[1], node.position[2]);
|
|
136
253
|
mesh.rotation.y = node.rotation;
|
|
137
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Subtract every hosted accessory cut (`capabilities.roofAccessory.
|
|
257
|
+
* buildCut`) from a segment's brushes, in SEGMENT-LOCAL space. Shared by
|
|
258
|
+
* the merged-shell path AND the per-segment path (full edit mode /
|
|
259
|
+
* painted segments) — without the latter, selecting a segment used to
|
|
260
|
+
* swap the merged shell for uncut per-segment meshes and every door /
|
|
261
|
+
* window / skylight hole vanished until deselect. Children are read
|
|
262
|
+
* live-effective so an in-flight handle drag carves the live hole.
|
|
263
|
+
* Registry-driven so the viewer never names a kind.
|
|
264
|
+
*/
|
|
265
|
+
function subtractAccessoryCuts(brushes, segment, nodes) {
|
|
266
|
+
let workingShin = brushes.shinSlab;
|
|
267
|
+
let workingDeck = brushes.deckSlab;
|
|
268
|
+
let workingWall = brushes.wallBrush;
|
|
269
|
+
for (const childElemId of segment.children ?? []) {
|
|
270
|
+
const storedChild = nodes[childElemId];
|
|
271
|
+
if (!storedChild)
|
|
272
|
+
continue;
|
|
273
|
+
const childElem = getEffectiveNode(storedChild);
|
|
274
|
+
const meta = typeof childElem.metadata === 'object' && childElem.metadata !== null
|
|
275
|
+
? childElem.metadata
|
|
276
|
+
: undefined;
|
|
277
|
+
if (meta?.isTransient)
|
|
278
|
+
continue;
|
|
279
|
+
const childDef = nodeRegistry.get(childElem.type);
|
|
280
|
+
const buildCut = childDef?.capabilities?.roofAccessory?.buildCut;
|
|
281
|
+
if (!buildCut)
|
|
282
|
+
continue;
|
|
283
|
+
const cutGeo = buildCut(childElem, segment);
|
|
284
|
+
if (!cutGeo)
|
|
285
|
+
continue;
|
|
286
|
+
// Wrap the kind-emitted geometry in a Brush. Kinds return raw
|
|
287
|
+
// shapes; the viewer welds (mandatory after rotations leave
|
|
288
|
+
// duplicated verts), attaches a single material group, and
|
|
289
|
+
// builds the bounds tree — keeping kind code free of
|
|
290
|
+
// three-bvh-csg / three-mesh-bvh imports.
|
|
291
|
+
const welded = mergeVertices(cutGeo, 1e-4);
|
|
292
|
+
cutGeo.dispose();
|
|
293
|
+
const idxCount = welded.getIndex()?.count ?? 0;
|
|
294
|
+
if (idxCount === 0) {
|
|
295
|
+
welded.dispose();
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
welded.clearGroups();
|
|
299
|
+
welded.addGroup(0, idxCount, 0);
|
|
300
|
+
welded.computeVertexNormals();
|
|
301
|
+
ensureRenderableGeometryAttributes(welded);
|
|
302
|
+
computeGeometryBoundsTree(welded);
|
|
303
|
+
const cut = new Brush(welded, dummyMats[0]);
|
|
304
|
+
cut.updateMatrixWorld();
|
|
305
|
+
const cutScope = childDef?.capabilities?.roofAccessory?.cutScope ?? 'all';
|
|
306
|
+
try {
|
|
307
|
+
if (cutScope !== 'wall') {
|
|
308
|
+
const nextShin = csgEvaluator.evaluate(workingShin, cut, SUBTRACTION);
|
|
309
|
+
workingShin.geometry.dispose();
|
|
310
|
+
prepareBrushForCSG(nextShin);
|
|
311
|
+
workingShin = nextShin;
|
|
312
|
+
const nextDeck = csgEvaluator.evaluate(workingDeck, cut, SUBTRACTION);
|
|
313
|
+
workingDeck.geometry.dispose();
|
|
314
|
+
prepareBrushForCSG(nextDeck);
|
|
315
|
+
workingDeck = nextDeck;
|
|
316
|
+
}
|
|
317
|
+
const nextWall = csgEvaluator.evaluate(workingWall, cut, SUBTRACTION);
|
|
318
|
+
workingWall.geometry.dispose();
|
|
319
|
+
prepareBrushForCSG(nextWall);
|
|
320
|
+
workingWall = nextWall;
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
console.error(`[${childElem.type}] cut CSG failed:`, e);
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
cut.geometry.dispose();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
brushes.shinSlab = workingShin;
|
|
330
|
+
brushes.deckSlab = workingDeck;
|
|
331
|
+
brushes.wallBrush = workingWall;
|
|
332
|
+
}
|
|
333
|
+
function getMergedRoofSegmentBrushes(roofNode, segment, nodes) {
|
|
334
|
+
const segmentId = segment.id;
|
|
335
|
+
const cacheKey = getMergedRoofSegmentCacheKey(roofNode, segment, nodes);
|
|
336
|
+
const cached = mergedRoofSegmentGeometryCache.get(segmentId);
|
|
337
|
+
if (cached?.key === cacheKey) {
|
|
338
|
+
return cloneCachedMergedRoofSegmentBrushes(cached);
|
|
339
|
+
}
|
|
340
|
+
const brushes = withSegmentUvMatrix(composeSegmentWorldMatrix(roofNode.position, roofNode.rotation ?? 0, segment.position, segment.rotation ?? 0), () => getRoofSegmentBrushes(segment));
|
|
341
|
+
if (!brushes) {
|
|
342
|
+
disposeCachedMergedRoofSegmentGeometrySet(cached);
|
|
343
|
+
mergedRoofSegmentGeometryCache.delete(segmentId);
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
subtractAccessoryCuts(brushes, segment, nodes);
|
|
347
|
+
_matrix.compose(_position.set(segment.position[0], segment.position[1], segment.position[2]), _quaternion.setFromAxisAngle(_yAxis, segment.rotation), _scale);
|
|
348
|
+
const applyTransform = (brush) => {
|
|
349
|
+
csgGeometry(brush).applyMatrix4(_matrix);
|
|
350
|
+
brush.updateMatrixWorld();
|
|
351
|
+
};
|
|
352
|
+
applyTransform(brushes.shinSlab);
|
|
353
|
+
applyTransform(brushes.deckSlab);
|
|
354
|
+
applyTransform(brushes.wallBrush);
|
|
355
|
+
applyTransform(brushes.innerBrush);
|
|
356
|
+
brushes.rakeBoards?.applyMatrix4(_matrix);
|
|
357
|
+
const nextCached = {
|
|
358
|
+
key: cacheKey,
|
|
359
|
+
deckSlab: {
|
|
360
|
+
geometry: csgGeometry(brushes.deckSlab).clone(),
|
|
361
|
+
materials: csgMaterials(brushes.deckSlab),
|
|
362
|
+
},
|
|
363
|
+
shinSlab: {
|
|
364
|
+
geometry: csgGeometry(brushes.shinSlab).clone(),
|
|
365
|
+
materials: csgMaterials(brushes.shinSlab),
|
|
366
|
+
},
|
|
367
|
+
wallBrush: {
|
|
368
|
+
geometry: csgGeometry(brushes.wallBrush).clone(),
|
|
369
|
+
materials: csgMaterials(brushes.wallBrush),
|
|
370
|
+
},
|
|
371
|
+
innerBrush: {
|
|
372
|
+
geometry: csgGeometry(brushes.innerBrush).clone(),
|
|
373
|
+
materials: csgMaterials(brushes.innerBrush),
|
|
374
|
+
},
|
|
375
|
+
rakeBoards: brushes.rakeBoards?.clone() ?? null,
|
|
376
|
+
};
|
|
377
|
+
disposeCachedMergedRoofSegmentGeometrySet(cached);
|
|
378
|
+
mergedRoofSegmentGeometryCache.set(segmentId, nextCached);
|
|
379
|
+
const cloned = cloneCachedMergedRoofSegmentBrushes(nextCached);
|
|
380
|
+
disposeRoofSegmentBrushSet(brushes);
|
|
381
|
+
return cloned;
|
|
382
|
+
}
|
|
138
383
|
function updateMergedRoofGeometry(roofNode, group, nodes) {
|
|
139
384
|
const mergedMesh = group.getObjectByName('merged-roof');
|
|
140
385
|
if (!mergedMesh)
|
|
141
386
|
return;
|
|
387
|
+
// Segments that carry their own material / preset (catch-all or any of
|
|
388
|
+
// the role-specific fields) are rendered as their own per-segment mesh
|
|
389
|
+
// in `RoofRenderer` so the painted material is preserved. Exclude them
|
|
390
|
+
// from the merged shell — otherwise the merged mesh would draw on top
|
|
391
|
+
// with the roof's default material.
|
|
392
|
+
//
|
|
393
|
+
// Merge each child through `getEffectiveNode` so an in-flight handle
|
|
394
|
+
// drag (live override on width / depth / wallHeight / pitch / rotation)
|
|
395
|
+
// is reflected in the merged shell during the drag, not only on commit.
|
|
142
396
|
const children = (roofNode.children ?? [])
|
|
143
|
-
.map((id) =>
|
|
144
|
-
|
|
397
|
+
.map((id) => {
|
|
398
|
+
const scn = nodes[id];
|
|
399
|
+
return scn ? getEffectiveNode(scn) : undefined;
|
|
400
|
+
})
|
|
401
|
+
.filter((n) => n !== undefined && !hasSegmentMaterialOverride(n));
|
|
145
402
|
if (children.length === 0) {
|
|
146
403
|
mergedMesh.geometry.dispose();
|
|
147
|
-
//
|
|
148
|
-
|
|
404
|
+
// Not BoxGeometry: its 6 groups against the merged mesh's 4-material array
|
|
405
|
+
// crash GLTFExporter (materials[4] → undefined) when the roof bakes.
|
|
406
|
+
mergedMesh.geometry = createDegenerateRoofPlaceholder();
|
|
149
407
|
return;
|
|
150
408
|
}
|
|
151
409
|
let totalShinSlab = null;
|
|
152
410
|
let totalDeckSlab = null;
|
|
153
411
|
let totalWall = null;
|
|
154
412
|
let totalInner = null;
|
|
413
|
+
const rakeBoardGeometries = [];
|
|
155
414
|
for (const child of children) {
|
|
156
|
-
const brushes =
|
|
415
|
+
const brushes = getMergedRoofSegmentBrushes(roofNode, child, nodes);
|
|
157
416
|
if (!brushes)
|
|
158
417
|
continue;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
brush.updateMatrixWorld();
|
|
163
|
-
};
|
|
164
|
-
applyTransform(brushes.shinSlab);
|
|
165
|
-
applyTransform(brushes.deckSlab);
|
|
166
|
-
applyTransform(brushes.wallBrush);
|
|
167
|
-
applyTransform(brushes.innerBrush);
|
|
418
|
+
if (brushes.rakeBoards) {
|
|
419
|
+
rakeBoardGeometries.push(brushes.rakeBoards);
|
|
420
|
+
}
|
|
168
421
|
if (totalShinSlab) {
|
|
169
422
|
const next = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION);
|
|
170
423
|
totalShinSlab.geometry.dispose();
|
|
@@ -208,12 +461,29 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
|
|
|
208
461
|
}
|
|
209
462
|
if (totalShinSlab && totalDeckSlab && totalWall && totalInner) {
|
|
210
463
|
try {
|
|
211
|
-
const finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION);
|
|
212
|
-
const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION);
|
|
213
464
|
const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION);
|
|
214
|
-
|
|
465
|
+
prepareBrushForCSG(finalWallTrimmed);
|
|
466
|
+
const shinDeck = csgEvaluator.evaluate(totalShinSlab, totalDeckSlab, ADDITION);
|
|
467
|
+
prepareBrushForCSG(shinDeck);
|
|
215
468
|
const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION);
|
|
469
|
+
prepareBrushForCSG(combined);
|
|
216
470
|
const resultGeo = csgGeometry(combined);
|
|
471
|
+
if (geometryHasInvalidAttributes(resultGeo)) {
|
|
472
|
+
if (!warnedMergedRoofNaNIds.has(roofNode.id)) {
|
|
473
|
+
console.warn('[RoofSystem] Skipping merged roof geometry with invalid attributes', roofNode.id);
|
|
474
|
+
warnedMergedRoofNaNIds.add(roofNode.id);
|
|
475
|
+
}
|
|
476
|
+
resultGeo.dispose();
|
|
477
|
+
finalWallTrimmed.geometry.dispose();
|
|
478
|
+
shinDeck.geometry.dispose();
|
|
479
|
+
totalShinSlab.geometry.dispose();
|
|
480
|
+
totalDeckSlab.geometry.dispose();
|
|
481
|
+
totalWall.geometry.dispose();
|
|
482
|
+
totalInner.geometry.dispose();
|
|
483
|
+
for (const geometry of rakeBoardGeometries)
|
|
484
|
+
geometry.dispose();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
217
487
|
const resultMaterials = csgMaterials(combined);
|
|
218
488
|
const matToIndex = new Map([
|
|
219
489
|
[dummyMats[0], 0],
|
|
@@ -224,12 +494,20 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
|
|
|
224
494
|
for (const g of resultGeo.groups) {
|
|
225
495
|
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex);
|
|
226
496
|
}
|
|
227
|
-
resultGeo
|
|
228
|
-
|
|
497
|
+
let finalGeo = resultGeo;
|
|
498
|
+
if (rakeBoardGeometries.length > 0) {
|
|
499
|
+
const merged = mergeGeometriesPreservingGroups([finalGeo, ...rakeBoardGeometries]);
|
|
500
|
+
if (merged) {
|
|
501
|
+
finalGeo.dispose();
|
|
502
|
+
finalGeo = merged;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
for (const geometry of rakeBoardGeometries)
|
|
506
|
+
geometry.dispose();
|
|
507
|
+
finalGeo.computeVertexNormals();
|
|
508
|
+
ensureRenderableGeometryAttributes(finalGeo);
|
|
229
509
|
mergedMesh.geometry.dispose();
|
|
230
|
-
mergedMesh.geometry =
|
|
231
|
-
finalShinTrimmed.geometry.dispose();
|
|
232
|
-
finalDeckTrimmed.geometry.dispose();
|
|
510
|
+
mergedMesh.geometry = finalGeo;
|
|
233
511
|
finalWallTrimmed.geometry.dispose();
|
|
234
512
|
shinDeck.geometry.dispose();
|
|
235
513
|
}
|
|
@@ -240,21 +518,138 @@ function updateMergedRoofGeometry(roofNode, group, nodes) {
|
|
|
240
518
|
totalDeckSlab.geometry.dispose();
|
|
241
519
|
totalWall.geometry.dispose();
|
|
242
520
|
totalInner.geometry.dispose();
|
|
521
|
+
for (const geometry of rakeBoardGeometries)
|
|
522
|
+
geometry.dispose();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function geometryHasInvalidAttributes(geometry) {
|
|
526
|
+
const position = geometry.getAttribute('position');
|
|
527
|
+
if (!(position && position.count > 0))
|
|
528
|
+
return true;
|
|
529
|
+
for (const name of ['position', 'normal', 'uv', 'uv2']) {
|
|
530
|
+
const attribute = geometry.getAttribute(name);
|
|
531
|
+
if (!attribute)
|
|
532
|
+
continue;
|
|
533
|
+
for (let i = 0; i < attribute.array.length; i++) {
|
|
534
|
+
if (!Number.isFinite(attribute.array[i]))
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const index = geometry.getIndex();
|
|
539
|
+
if (!index || index.count === 0)
|
|
540
|
+
return true;
|
|
541
|
+
for (let i = 0; i < index.count; i++) {
|
|
542
|
+
const value = index.getX(i);
|
|
543
|
+
if (!Number.isInteger(value) || value < 0 || value >= position.count)
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
for (const group of geometry.groups) {
|
|
547
|
+
if (!Number.isInteger(group.start) ||
|
|
548
|
+
!Number.isInteger(group.count) ||
|
|
549
|
+
group.start < 0 ||
|
|
550
|
+
group.count <= 0 ||
|
|
551
|
+
group.start + group.count > index.count) {
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
243
554
|
}
|
|
555
|
+
return false;
|
|
244
556
|
}
|
|
245
|
-
|
|
557
|
+
/**
|
|
558
|
+
* Four dummy materials used as identity placeholders during CSG. Shared
|
|
559
|
+
* across every input brush so three-bvh-csg can preserve reference
|
|
560
|
+
* equality on the result and `mapRoofGroupMaterialIndex` can map result
|
|
561
|
+
* groups back to slots 0..3. Exposed so kinds that compose additional
|
|
562
|
+
* CSG ops on top of `getRoofSegmentBrushes` (e.g. dormer) use the same
|
|
563
|
+
* identity refs.
|
|
564
|
+
*/
|
|
565
|
+
export const roofCsgDummyMats = [
|
|
246
566
|
new THREE.MeshBasicMaterial(),
|
|
247
567
|
new THREE.MeshBasicMaterial(),
|
|
248
568
|
new THREE.MeshBasicMaterial(),
|
|
249
569
|
new THREE.MeshBasicMaterial(),
|
|
250
570
|
];
|
|
251
|
-
|
|
252
|
-
|
|
571
|
+
// Internal alias kept so the surrounding file's many call sites don't churn.
|
|
572
|
+
const dummyMats = roofCsgDummyMats;
|
|
573
|
+
export const ROOF_MATERIAL_SLOT_COUNT = 4;
|
|
574
|
+
const mergedRoofSegmentGeometryCache = new Map();
|
|
575
|
+
function disposeCachedMergedRoofSegmentGeometrySet(cached) {
|
|
576
|
+
if (!cached)
|
|
577
|
+
return;
|
|
578
|
+
cached.deckSlab.geometry.dispose();
|
|
579
|
+
cached.shinSlab.geometry.dispose();
|
|
580
|
+
cached.wallBrush.geometry.dispose();
|
|
581
|
+
cached.innerBrush.geometry.dispose();
|
|
582
|
+
cached.rakeBoards?.dispose();
|
|
583
|
+
}
|
|
584
|
+
function disposeRoofSegmentBrushSet(brushes) {
|
|
585
|
+
brushes.deckSlab.geometry.dispose();
|
|
586
|
+
brushes.shinSlab.geometry.dispose();
|
|
587
|
+
brushes.wallBrush.geometry.dispose();
|
|
588
|
+
brushes.innerBrush.geometry.dispose();
|
|
589
|
+
brushes.rakeBoards?.dispose();
|
|
590
|
+
}
|
|
591
|
+
function cloneCachedBrush(cached) {
|
|
592
|
+
const brush = new Brush(cached.geometry.clone(), cached.materials);
|
|
593
|
+
prepareBrushForCSG(brush);
|
|
594
|
+
return brush;
|
|
595
|
+
}
|
|
596
|
+
function cloneCachedMergedRoofSegmentBrushes(cached) {
|
|
597
|
+
return {
|
|
598
|
+
deckSlab: cloneCachedBrush(cached.deckSlab),
|
|
599
|
+
shinSlab: cloneCachedBrush(cached.shinSlab),
|
|
600
|
+
wallBrush: cloneCachedBrush(cached.wallBrush),
|
|
601
|
+
innerBrush: cloneCachedBrush(cached.innerBrush),
|
|
602
|
+
rakeBoards: cached.rakeBoards?.clone() ?? null,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
function getMergedRoofAccessoryCachePayload(segment, nodes) {
|
|
606
|
+
const payload = [];
|
|
607
|
+
for (const childElemId of segment.children ?? []) {
|
|
608
|
+
const storedChild = nodes[childElemId];
|
|
609
|
+
if (!storedChild)
|
|
610
|
+
continue;
|
|
611
|
+
const childElem = getEffectiveNode(storedChild);
|
|
612
|
+
const meta = typeof childElem.metadata === 'object' && childElem.metadata !== null
|
|
613
|
+
? childElem.metadata
|
|
614
|
+
: undefined;
|
|
615
|
+
if (meta?.isTransient)
|
|
616
|
+
continue;
|
|
617
|
+
const childDef = nodeRegistry.get(childElem.type);
|
|
618
|
+
if (!childDef?.capabilities?.roofAccessory?.buildCut)
|
|
619
|
+
continue;
|
|
620
|
+
payload.push(childElem);
|
|
621
|
+
}
|
|
622
|
+
return payload;
|
|
623
|
+
}
|
|
624
|
+
function getMergedRoofSegmentCacheKey(roofNode, segment, nodes) {
|
|
625
|
+
return JSON.stringify({
|
|
626
|
+
roofPosition: roofNode.position ?? [0, 0, 0],
|
|
627
|
+
roofRotation: roofNode.rotation ?? 0,
|
|
628
|
+
segment,
|
|
629
|
+
accessories: getMergedRoofAccessoryCachePayload(segment, nodes),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
export function mapRoofGroupMaterialIndex(groupMaterialIndex, csgMaterials, matToIndex) {
|
|
253
633
|
if (groupMaterialIndex === undefined)
|
|
254
634
|
return 0;
|
|
635
|
+
// Primary path — reference-equality lookup. Fast and exact when
|
|
636
|
+
// three-bvh-csg preserves the original `dummyMats` references on
|
|
637
|
+
// the result brush.
|
|
255
638
|
const sourceMaterial = csgMaterials[groupMaterialIndex];
|
|
256
639
|
const mappedIndex = sourceMaterial ? matToIndex.get(sourceMaterial) : undefined;
|
|
257
|
-
|
|
640
|
+
if (mappedIndex !== undefined)
|
|
641
|
+
return mappedIndex;
|
|
642
|
+
// Robust fallback — every input brush was constructed with the same
|
|
643
|
+
// 4-slot `dummyMats` array, so after N union/subtraction passes the
|
|
644
|
+
// result's material array is `[dummyMats[0..3], dummyMats[0..3], ...]`
|
|
645
|
+
// and the group's materialIndex is `slot + (brushOffset * 4)`. The
|
|
646
|
+
// slot we care about is therefore `materialIndex % 4`. Without this
|
|
647
|
+
// fallback, any CSG pass that returns a fresh `Material` object (or
|
|
648
|
+
// clones the dummyMats refs) makes every group collapse to slot 0
|
|
649
|
+
// (Wall) — which is the "shape is there but the wrong colour"
|
|
650
|
+
// symptom roofs show after deselect / refresh.
|
|
651
|
+
return (((groupMaterialIndex % ROOF_MATERIAL_SLOT_COUNT) + ROOF_MATERIAL_SLOT_COUNT) %
|
|
652
|
+
ROOF_MATERIAL_SLOT_COUNT);
|
|
258
653
|
}
|
|
259
654
|
function normalizeRoofMaterialIndex(materialIndex) {
|
|
260
655
|
if (materialIndex === undefined || !Number.isFinite(materialIndex))
|
|
@@ -264,46 +659,315 @@ function normalizeRoofMaterialIndex(materialIndex) {
|
|
|
264
659
|
return 0;
|
|
265
660
|
return normalized;
|
|
266
661
|
}
|
|
662
|
+
function remapDutchRakeBoardMaterials(geometry) {
|
|
663
|
+
const position = geometry.getAttribute('position');
|
|
664
|
+
if (!position)
|
|
665
|
+
return;
|
|
666
|
+
const index = geometry.getIndex();
|
|
667
|
+
const triangleCount = (index?.count ?? position.count) / 3;
|
|
668
|
+
if (!Number.isFinite(triangleCount) || triangleCount <= 0)
|
|
669
|
+
return;
|
|
670
|
+
const a = new THREE.Vector3();
|
|
671
|
+
const b = new THREE.Vector3();
|
|
672
|
+
const c = new THREE.Vector3();
|
|
673
|
+
const ab = new THREE.Vector3();
|
|
674
|
+
const ac = new THREE.Vector3();
|
|
675
|
+
const normal = new THREE.Vector3();
|
|
676
|
+
const triangleMaterials = new Array(triangleCount).fill(DUTCH_RAKE_SIDE_MATERIAL_INDEX);
|
|
677
|
+
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
|
|
678
|
+
const offset = triangleIndex * 3;
|
|
679
|
+
const ia = index ? index.getX(offset) : offset;
|
|
680
|
+
const ib = index ? index.getX(offset + 1) : offset + 1;
|
|
681
|
+
const ic = index ? index.getX(offset + 2) : offset + 2;
|
|
682
|
+
a.fromBufferAttribute(position, ia);
|
|
683
|
+
b.fromBufferAttribute(position, ib);
|
|
684
|
+
c.fromBufferAttribute(position, ic);
|
|
685
|
+
ab.subVectors(b, a);
|
|
686
|
+
ac.subVectors(c, a);
|
|
687
|
+
normal.crossVectors(ab, ac).normalize();
|
|
688
|
+
triangleMaterials[triangleIndex] =
|
|
689
|
+
Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON
|
|
690
|
+
? DUTCH_RAKE_TOP_MATERIAL_INDEX
|
|
691
|
+
: DUTCH_RAKE_SIDE_MATERIAL_INDEX;
|
|
692
|
+
}
|
|
693
|
+
geometry.clearGroups();
|
|
694
|
+
let currentMaterial = triangleMaterials[0] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX;
|
|
695
|
+
let groupStart = 0;
|
|
696
|
+
for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) {
|
|
697
|
+
const materialIndex = triangleMaterials[triangleIndex] ?? DUTCH_RAKE_SIDE_MATERIAL_INDEX;
|
|
698
|
+
if (materialIndex === currentMaterial)
|
|
699
|
+
continue;
|
|
700
|
+
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial);
|
|
701
|
+
groupStart = triangleIndex;
|
|
702
|
+
currentMaterial = materialIndex;
|
|
703
|
+
}
|
|
704
|
+
geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial);
|
|
705
|
+
}
|
|
267
706
|
const SHINGLE_SURFACE_EPSILON = 0.02;
|
|
268
707
|
const RAKE_FACE_NORMAL_EPSILON = 0.3;
|
|
269
708
|
const RAKE_FACE_ALIGNMENT_EPSILON = 0.35;
|
|
709
|
+
const TRIM_CUT_EPSILON = 0.002;
|
|
710
|
+
const DUTCH_RAKE_SIDE_MATERIAL_INDEX = 1;
|
|
711
|
+
const DUTCH_RAKE_TOP_MATERIAL_INDEX = 3;
|
|
712
|
+
const DUTCH_RAKE_SLOPE_SEAT_OFFSET = 0.0002;
|
|
713
|
+
function pushDoubleSidedFace(targetFaces, face) {
|
|
714
|
+
targetFaces.push(face);
|
|
715
|
+
targetFaces.push(face.map((point) => point.clone()).reverse());
|
|
716
|
+
}
|
|
717
|
+
function hasSegmentTrim(node) {
|
|
718
|
+
const trim = normalizeRoofSegmentTrim(node);
|
|
719
|
+
return (trim.left > 0 ||
|
|
720
|
+
trim.right > 0 ||
|
|
721
|
+
trim.front > 0 ||
|
|
722
|
+
trim.back > 0 ||
|
|
723
|
+
trim.frontLeft > 0 ||
|
|
724
|
+
trim.frontRight > 0 ||
|
|
725
|
+
trim.backLeft > 0 ||
|
|
726
|
+
trim.backRight > 0 ||
|
|
727
|
+
trim.frontLeftX > 0 ||
|
|
728
|
+
trim.frontLeftZ > 0 ||
|
|
729
|
+
trim.frontRightX > 0 ||
|
|
730
|
+
trim.frontRightZ > 0 ||
|
|
731
|
+
trim.backLeftX > 0 ||
|
|
732
|
+
trim.backLeftZ > 0 ||
|
|
733
|
+
trim.backRightX > 0 ||
|
|
734
|
+
trim.backRightZ > 0);
|
|
735
|
+
}
|
|
736
|
+
// Material slot the freshly exposed trim plane is assigned to. Slot 0 is the
|
|
737
|
+
// wall/trim band — the same finish the gable walls render with (concrete-drywall
|
|
738
|
+
// by default, continuous with the walls below). `remapRoofShellFaces` does not
|
|
739
|
+
// reclassify slot 0, so any new interior face CSG carves out of a BoxGeometry
|
|
740
|
+
// cutter lands on the wall band regardless of which box face it came from.
|
|
741
|
+
// Without this the box's default 6 groups (materialIndex 0..5) collapse via
|
|
742
|
+
// mod-4 and `remapRoofShellFaces` would reshuffle the vertical ones across
|
|
743
|
+
// slots. Accessories still clamp the slot via `useSegmentTrimClippedGeometry`
|
|
744
|
+
// when they expose fewer material slots.
|
|
745
|
+
const TRIM_CUT_MATERIAL_SLOT = 0;
|
|
746
|
+
function assignTrimCutterSlot(geometry) {
|
|
747
|
+
geometry.clearGroups();
|
|
748
|
+
const count = geometry.index ? geometry.index.count : geometry.getAttribute('position').count;
|
|
749
|
+
geometry.addGroup(0, count, TRIM_CUT_MATERIAL_SLOT);
|
|
750
|
+
}
|
|
751
|
+
function buildTrimCutBrush(minX, maxX, minZ, maxZ, minY, maxY) {
|
|
752
|
+
const width = maxX - minX;
|
|
753
|
+
const height = maxY - minY;
|
|
754
|
+
const depth = maxZ - minZ;
|
|
755
|
+
if (!(width > 0 && height > 0 && depth > 0))
|
|
756
|
+
return null;
|
|
757
|
+
const geometry = new THREE.BoxGeometry(width, height, depth);
|
|
758
|
+
geometry.translate((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
|
|
759
|
+
// World-metre UVs on the cutter so the newly exposed interior faces produced
|
|
760
|
+
// by CSG inherit tiled UVs (1 uv unit per metre) instead of the box's default
|
|
761
|
+
// 0→1-per-face, which would stretch the finish to fit each cut face.
|
|
762
|
+
applyWorldScaleBoxUVs(geometry, width, height, depth);
|
|
763
|
+
assignTrimCutterSlot(geometry);
|
|
764
|
+
ensureRenderableGeometryAttributes(geometry);
|
|
765
|
+
computeGeometryBoundsTree(geometry);
|
|
766
|
+
const cut = new Brush(geometry, dummyMats);
|
|
767
|
+
cut.updateMatrixWorld();
|
|
768
|
+
return cut;
|
|
769
|
+
}
|
|
770
|
+
function buildDiagonalTrimCutBrush(bounds, lineA, lineB, outsidePoint) {
|
|
771
|
+
const dx = lineB[0] - lineA[0];
|
|
772
|
+
const dz = lineB[1] - lineA[1];
|
|
773
|
+
const lineLength = Math.hypot(dx, dz);
|
|
774
|
+
const height = bounds.maxY - bounds.minY;
|
|
775
|
+
if (!(lineLength > 0 && height > 0))
|
|
776
|
+
return null;
|
|
777
|
+
const ux = dx / lineLength;
|
|
778
|
+
const uz = dz / lineLength;
|
|
779
|
+
let nx = -uz;
|
|
780
|
+
let nz = ux;
|
|
781
|
+
const midX = (lineA[0] + lineB[0]) / 2;
|
|
782
|
+
const midZ = (lineA[1] + lineB[1]) / 2;
|
|
783
|
+
const toOutsideX = outsidePoint[0] - midX;
|
|
784
|
+
const toOutsideZ = outsidePoint[1] - midZ;
|
|
785
|
+
let normalFlipped = false;
|
|
786
|
+
if (nx * toOutsideX + nz * toOutsideZ < 0) {
|
|
787
|
+
nx *= -1;
|
|
788
|
+
nz *= -1;
|
|
789
|
+
normalFlipped = true;
|
|
790
|
+
}
|
|
791
|
+
const boundsDiagonal = Math.hypot(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ);
|
|
792
|
+
const cutterLength = Math.max(lineLength, boundsDiagonal) * 2;
|
|
793
|
+
const cutterDepth = boundsDiagonal * 2;
|
|
794
|
+
const centerX = midX + nx * (cutterDepth / 2 - TRIM_CUT_EPSILON);
|
|
795
|
+
const centerZ = midZ + nz * (cutterDepth / 2 - TRIM_CUT_EPSILON);
|
|
796
|
+
const yaw = Math.atan2(-uz, ux) + (normalFlipped ? Math.PI : 0);
|
|
797
|
+
const geometry = new THREE.BoxGeometry(cutterLength, height, cutterDepth);
|
|
798
|
+
geometry.rotateY(yaw);
|
|
799
|
+
geometry.translate(centerX, (bounds.minY + bounds.maxY) / 2, centerZ);
|
|
800
|
+
// World-metre UVs so the diagonal cut's interior face inherits tiled UVs.
|
|
801
|
+
applyWorldScaleBoxUVs(geometry, cutterLength, height, cutterDepth);
|
|
802
|
+
assignTrimCutterSlot(geometry);
|
|
803
|
+
ensureRenderableGeometryAttributes(geometry);
|
|
804
|
+
computeGeometryBoundsTree(geometry);
|
|
805
|
+
const cut = new Brush(geometry, dummyMats);
|
|
806
|
+
cut.updateMatrixWorld();
|
|
807
|
+
return cut;
|
|
808
|
+
}
|
|
809
|
+
function subtractCutFromSegmentBrushes(brushes, cut) {
|
|
810
|
+
for (const key of ['shinSlab', 'deckSlab', 'wallBrush', 'innerBrush']) {
|
|
811
|
+
const next = csgEvaluator.evaluate(brushes[key], cut, SUBTRACTION);
|
|
812
|
+
brushes[key].geometry.dispose();
|
|
813
|
+
prepareBrushForCSG(next);
|
|
814
|
+
brushes[key] = next;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function buildSegmentTrimCutBrushes(segment) {
|
|
818
|
+
const trim = normalizeRoofSegmentTrim(segment);
|
|
819
|
+
if (trim.left === 0 &&
|
|
820
|
+
trim.right === 0 &&
|
|
821
|
+
trim.front === 0 &&
|
|
822
|
+
trim.back === 0 &&
|
|
823
|
+
trim.frontLeft === 0 &&
|
|
824
|
+
trim.frontRight === 0 &&
|
|
825
|
+
trim.backLeft === 0 &&
|
|
826
|
+
trim.backRight === 0 &&
|
|
827
|
+
trim.frontLeftX === 0 &&
|
|
828
|
+
trim.frontLeftZ === 0 &&
|
|
829
|
+
trim.frontRightX === 0 &&
|
|
830
|
+
trim.frontRightZ === 0 &&
|
|
831
|
+
trim.backLeftX === 0 &&
|
|
832
|
+
trim.backLeftZ === 0 &&
|
|
833
|
+
trim.backRightX === 0 &&
|
|
834
|
+
trim.backRightZ === 0) {
|
|
835
|
+
return [];
|
|
836
|
+
}
|
|
837
|
+
const { activeRh } = getSegmentSlopeFrame(segment);
|
|
838
|
+
const extra = segment.wallThickness + segment.overhang + segment.deckThickness + segment.shingleThickness + 2;
|
|
839
|
+
const minX = -segment.width / 2 - extra;
|
|
840
|
+
const maxX = segment.width / 2 + extra;
|
|
841
|
+
const minZ = -segment.depth / 2 - extra;
|
|
842
|
+
const maxZ = segment.depth / 2 + extra;
|
|
843
|
+
const minY = -2;
|
|
844
|
+
const maxY = segment.wallHeight + activeRh + segment.deckThickness + segment.shingleThickness + 2;
|
|
845
|
+
const cuts = [];
|
|
846
|
+
if (trim.left > 0) {
|
|
847
|
+
const planeX = -segment.width / 2 + trim.left;
|
|
848
|
+
const cut = buildTrimCutBrush(minX, planeX + TRIM_CUT_EPSILON, minZ, maxZ, minY, maxY);
|
|
849
|
+
if (cut)
|
|
850
|
+
cuts.push(cut);
|
|
851
|
+
}
|
|
852
|
+
if (trim.right > 0) {
|
|
853
|
+
const planeX = segment.width / 2 - trim.right;
|
|
854
|
+
const cut = buildTrimCutBrush(planeX - TRIM_CUT_EPSILON, maxX, minZ, maxZ, minY, maxY);
|
|
855
|
+
if (cut)
|
|
856
|
+
cuts.push(cut);
|
|
857
|
+
}
|
|
858
|
+
if (trim.front > 0) {
|
|
859
|
+
const planeZ = segment.depth / 2 - trim.front;
|
|
860
|
+
const cut = buildTrimCutBrush(minX, maxX, planeZ - TRIM_CUT_EPSILON, maxZ, minY, maxY);
|
|
861
|
+
if (cut)
|
|
862
|
+
cuts.push(cut);
|
|
863
|
+
}
|
|
864
|
+
if (trim.back > 0) {
|
|
865
|
+
const planeZ = -segment.depth / 2 + trim.back;
|
|
866
|
+
const cut = buildTrimCutBrush(minX, maxX, minZ, planeZ + TRIM_CUT_EPSILON, minY, maxY);
|
|
867
|
+
if (cut)
|
|
868
|
+
cuts.push(cut);
|
|
869
|
+
}
|
|
870
|
+
const leftX = -segment.width / 2 + trim.left;
|
|
871
|
+
const rightX = segment.width / 2 - trim.right;
|
|
872
|
+
const frontZ = segment.depth / 2 - trim.front;
|
|
873
|
+
const backZ = -segment.depth / 2 + trim.back;
|
|
874
|
+
if (trim.frontLeftX > 0 && trim.frontLeftZ > 0) {
|
|
875
|
+
const cut = buildDiagonalTrimCutBrush({ minX, maxX, minZ, maxZ, minY, maxY }, [leftX + trim.frontLeftX + TRIM_CUT_EPSILON, frontZ], [leftX, frontZ - trim.frontLeftZ - TRIM_CUT_EPSILON], [minX, maxZ]);
|
|
876
|
+
if (cut)
|
|
877
|
+
cuts.push(cut);
|
|
878
|
+
}
|
|
879
|
+
if (trim.frontRightX > 0 && trim.frontRightZ > 0) {
|
|
880
|
+
const cut = buildDiagonalTrimCutBrush({ minX, maxX, minZ, maxZ, minY, maxY }, [rightX, frontZ - trim.frontRightZ - TRIM_CUT_EPSILON], [rightX - trim.frontRightX - TRIM_CUT_EPSILON, frontZ], [maxX, maxZ]);
|
|
881
|
+
if (cut)
|
|
882
|
+
cuts.push(cut);
|
|
883
|
+
}
|
|
884
|
+
if (trim.backLeftX > 0 && trim.backLeftZ > 0) {
|
|
885
|
+
const cut = buildDiagonalTrimCutBrush({ minX, maxX, minZ, maxZ, minY, maxY }, [leftX, backZ + trim.backLeftZ + TRIM_CUT_EPSILON], [leftX + trim.backLeftX + TRIM_CUT_EPSILON, backZ], [minX, minZ]);
|
|
886
|
+
if (cut)
|
|
887
|
+
cuts.push(cut);
|
|
888
|
+
}
|
|
889
|
+
if (trim.backRightX > 0 && trim.backRightZ > 0) {
|
|
890
|
+
const cut = buildDiagonalTrimCutBrush({ minX, maxX, minZ, maxZ, minY, maxY }, [rightX - trim.backRightX - TRIM_CUT_EPSILON, backZ], [rightX, backZ + trim.backRightZ + TRIM_CUT_EPSILON], [maxX, minZ]);
|
|
891
|
+
if (cut)
|
|
892
|
+
cuts.push(cut);
|
|
893
|
+
}
|
|
894
|
+
return cuts;
|
|
895
|
+
}
|
|
896
|
+
function subtractSegmentTrimCuts(brushes, segment) {
|
|
897
|
+
const cuts = buildSegmentTrimCutBrushes(segment);
|
|
898
|
+
for (const cut of cuts) {
|
|
899
|
+
try {
|
|
900
|
+
subtractCutFromSegmentBrushes(brushes, cut);
|
|
901
|
+
}
|
|
902
|
+
catch (e) {
|
|
903
|
+
console.error('Roof trim CSG failed:', e);
|
|
904
|
+
}
|
|
905
|
+
finally {
|
|
906
|
+
cut.geometry.dispose();
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Subtract a segment's trim cuts from an arbitrary segment-local geometry,
|
|
912
|
+
* returning the clipped result. The input geometry is consumed (disposed) on
|
|
913
|
+
* each successful CSG pass — callers that need to keep the original must pass a
|
|
914
|
+
* clone. Returns the input untouched when the segment has no trim. Used
|
|
915
|
+
* internally for rake-board / end-slope attachments and exported so roof
|
|
916
|
+
* accessories (chimney, vents, skylight, …) can clip their own meshes by the
|
|
917
|
+
* same trim, in the same segment-local frame.
|
|
918
|
+
*/
|
|
919
|
+
export function clipGeometryBySegmentTrim(geometry, segment) {
|
|
920
|
+
if (!geometry)
|
|
921
|
+
return null;
|
|
922
|
+
const cuts = buildSegmentTrimCutBrushes(segment);
|
|
923
|
+
if (cuts.length === 0)
|
|
924
|
+
return geometry;
|
|
925
|
+
let currentGeometry = geometry;
|
|
926
|
+
for (const cut of cuts) {
|
|
927
|
+
try {
|
|
928
|
+
const brush = new Brush(currentGeometry, dummyMats);
|
|
929
|
+
prepareBrushForCSG(brush);
|
|
930
|
+
const next = csgEvaluator.evaluate(brush, cut, SUBTRACTION);
|
|
931
|
+
const trimmed = csgGeometry(next).clone();
|
|
932
|
+
currentGeometry.dispose();
|
|
933
|
+
next.geometry.dispose();
|
|
934
|
+
ensureRenderableGeometryAttributes(trimmed);
|
|
935
|
+
currentGeometry = trimmed;
|
|
936
|
+
}
|
|
937
|
+
catch (e) {
|
|
938
|
+
console.error('Roof trim CSG failed for attachment geometry:', e);
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
cut.geometry.dispose();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return currentGeometry;
|
|
945
|
+
}
|
|
270
946
|
/**
|
|
271
947
|
* Generate complete hollow-shell geometry for a roof segment.
|
|
272
948
|
* Ports the prototype's CSG approach using three-bvh-csg.
|
|
273
949
|
*/
|
|
274
950
|
export function getRoofSegmentBrushes(node) {
|
|
275
|
-
const { roofType, width, depth, wallHeight,
|
|
276
|
-
const activeRh
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
if (roofType === 'gambrel') {
|
|
286
|
-
run = depth / 4;
|
|
287
|
-
rise = activeRh * 0.6;
|
|
288
|
-
}
|
|
289
|
-
if (roofType === 'mansard') {
|
|
290
|
-
run = Math.min(width, depth) * 0.15;
|
|
291
|
-
rise = activeRh * 0.7;
|
|
292
|
-
}
|
|
293
|
-
if (roofType === 'dutch') {
|
|
294
|
-
run = Math.min(width, depth) * 0.25;
|
|
295
|
-
rise = activeRh * 0.5;
|
|
296
|
-
}
|
|
297
|
-
const tanTheta = run > 0 ? rise / run : 0;
|
|
298
|
-
const cosTheta = Math.cos(Math.atan2(rise, run)) || 1;
|
|
299
|
-
const sinTheta = Math.sin(Math.atan2(rise, run)) || 0;
|
|
951
|
+
const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang, shingleThickness, } = node;
|
|
952
|
+
const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(node);
|
|
953
|
+
const shapeRatios = getRoofShapeRatios({
|
|
954
|
+
gambrelLowerWidthRatio: node.gambrelLowerWidthRatio,
|
|
955
|
+
mansardSteepWidthRatio: node.mansardSteepWidthRatio,
|
|
956
|
+
dutchHipWidthRatio: node.dutchHipWidthRatio,
|
|
957
|
+
dutchHipHeightRatio: node.dutchHipHeightRatio,
|
|
958
|
+
dutchWaistLengthRatio: node.dutchWaistLengthRatio,
|
|
959
|
+
dutchGabletRake: node.dutchGabletRake,
|
|
960
|
+
});
|
|
300
961
|
const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness;
|
|
301
|
-
|
|
962
|
+
// Gablet inset must track dutchHipWidthRatio so the 3D waist matches both
|
|
963
|
+
// the 2D floorplan and the slope frame (which derives activeRh from the
|
|
964
|
+
// same ratio). A hardcoded 0.25 desyncs the gablet from the parameter.
|
|
965
|
+
const baseI = Math.min(width, depth) * node.dutchHipWidthRatio;
|
|
302
966
|
const getVol = (wExt, vOffset, baseY, matIndex, isVoid) => {
|
|
303
967
|
const wV = Math.max(0.01, width + 2 * wExt);
|
|
304
968
|
const dV = Math.max(0.01, depth + 2 * wExt);
|
|
305
969
|
const autoDrop = wExt * tanTheta;
|
|
306
|
-
const whV = wallHeight - autoDrop + vOffset;
|
|
970
|
+
const whV = Math.max(0.01, wallHeight - autoDrop + vOffset);
|
|
307
971
|
let rhV = activeRh;
|
|
308
972
|
if (activeRh > 0) {
|
|
309
973
|
rhV = activeRh + autoDrop;
|
|
@@ -315,7 +979,20 @@ export function getRoofSegmentBrushes(node) {
|
|
|
315
979
|
if (isVoid) {
|
|
316
980
|
structuralI += deckThickness;
|
|
317
981
|
}
|
|
318
|
-
const faces =
|
|
982
|
+
const faces = getRoofModuleFaces({
|
|
983
|
+
type: roofType,
|
|
984
|
+
w: wV,
|
|
985
|
+
d: dV,
|
|
986
|
+
wh: whV,
|
|
987
|
+
rh: rhV,
|
|
988
|
+
baseY: safeBaseY,
|
|
989
|
+
insets: { dutchI: structuralI },
|
|
990
|
+
baseW: width,
|
|
991
|
+
baseD: depth,
|
|
992
|
+
tanTheta,
|
|
993
|
+
shapeRatios,
|
|
994
|
+
dutchTopRakeThickness: node.dutchTopRakeThickness,
|
|
995
|
+
}).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
|
|
319
996
|
return createGeometryFromFaces(faces, matIndex);
|
|
320
997
|
};
|
|
321
998
|
const wallGeo = getVol(wallThickness / 2, 0, 0, 0, false);
|
|
@@ -361,40 +1038,69 @@ export function getRoofSegmentBrushes(node) {
|
|
|
361
1038
|
const dropBot = Math.min(2.0, maxDrop * 0.8);
|
|
362
1039
|
const topBaseY = shinBotWh - dropTop;
|
|
363
1040
|
const botBaseY = shinBotWh - dropBot;
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
};
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
1041
|
+
const insetsBot = getRoofShapeInsets({
|
|
1042
|
+
roofType,
|
|
1043
|
+
width,
|
|
1044
|
+
depth,
|
|
1045
|
+
wh: shinBotWh,
|
|
1046
|
+
baseY: botBaseY,
|
|
1047
|
+
isVoid: true,
|
|
1048
|
+
brushW: shinBotW,
|
|
1049
|
+
brushD: shinBotD,
|
|
1050
|
+
tanTheta,
|
|
1051
|
+
shingleThickness,
|
|
1052
|
+
dutchHipWidthRatio: node.dutchHipWidthRatio,
|
|
1053
|
+
});
|
|
1054
|
+
const insetsTop = getRoofShapeInsets({
|
|
1055
|
+
roofType,
|
|
1056
|
+
width,
|
|
1057
|
+
depth,
|
|
1058
|
+
wh: shinTopWh,
|
|
1059
|
+
baseY: topBaseY,
|
|
1060
|
+
isVoid: false,
|
|
1061
|
+
brushW: shinTopW,
|
|
1062
|
+
brushD: shinTopD,
|
|
1063
|
+
tanTheta,
|
|
1064
|
+
shingleThickness,
|
|
1065
|
+
dutchHipWidthRatio: node.dutchHipWidthRatio,
|
|
1066
|
+
});
|
|
1067
|
+
const botFaces = getRoofModuleFaces({
|
|
1068
|
+
type: roofType,
|
|
1069
|
+
w: shinBotW,
|
|
1070
|
+
d: shinBotD,
|
|
1071
|
+
wh: shinBotWh,
|
|
1072
|
+
rh: shinBotRh,
|
|
1073
|
+
baseY: botBaseY,
|
|
1074
|
+
insets: insetsBot,
|
|
1075
|
+
baseW: width,
|
|
1076
|
+
baseD: depth,
|
|
1077
|
+
tanTheta,
|
|
1078
|
+
shapeRatios,
|
|
1079
|
+
dutchTopRakeThickness: node.dutchTopRakeThickness,
|
|
1080
|
+
}).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
|
|
1081
|
+
const topFaces = getRoofModuleFaces({
|
|
1082
|
+
type: roofType,
|
|
1083
|
+
w: shinTopW,
|
|
1084
|
+
d: shinTopD,
|
|
1085
|
+
wh: shinTopWh,
|
|
1086
|
+
rh: shinTopRh,
|
|
1087
|
+
baseY: topBaseY,
|
|
1088
|
+
insets: insetsTop,
|
|
1089
|
+
baseW: width,
|
|
1090
|
+
baseD: depth,
|
|
1091
|
+
tanTheta,
|
|
1092
|
+
shapeRatios,
|
|
1093
|
+
dutchTopRakeThickness: node.dutchTopRakeThickness,
|
|
1094
|
+
}).map((face) => face.map((point) => new THREE.Vector3(point.x, point.y, point.z)));
|
|
1095
|
+
let rakeBoards = null;
|
|
1096
|
+
if (roofType === 'dutch' && insetsTop.dutchI !== undefined) {
|
|
1097
|
+
rakeBoards = buildDutchRakeBoards(shinTopW, shinTopD, shinTopWh, shinTopRh, insetsTop.dutchI, shapeRatios, node.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake, node.dutchTopRakeThickness ?? ROOF_SHAPE_DEFAULTS.dutchTopRakeThickness);
|
|
1098
|
+
}
|
|
1099
|
+
const shinBotGeo = createGeometryFromFaces(botFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1);
|
|
395
1100
|
const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1);
|
|
396
1101
|
if (transZ !== 0) {
|
|
397
1102
|
shinTopGeo.translate(0, 0, transZ);
|
|
1103
|
+
rakeBoards?.translate(0, 0, transZ);
|
|
398
1104
|
}
|
|
399
1105
|
const toBrush = (geo) => {
|
|
400
1106
|
if (!geo?.attributes.position || geo.attributes.position.count === 0)
|
|
@@ -406,6 +1112,9 @@ export function getRoofSegmentBrushes(node) {
|
|
|
406
1112
|
geo.groups = geo.groups.filter((g) => g.count > 0);
|
|
407
1113
|
if (geo.groups.length === 0)
|
|
408
1114
|
return null;
|
|
1115
|
+
ensureRenderableGeometryAttributes(geo);
|
|
1116
|
+
if (geometryHasInvalidAttributes(geo))
|
|
1117
|
+
return null;
|
|
409
1118
|
computeGeometryBoundsTree(geo);
|
|
410
1119
|
const brush = new Brush(geo, dummyMats);
|
|
411
1120
|
brush.updateMatrixWorld();
|
|
@@ -445,12 +1154,32 @@ export function getRoofSegmentBrushes(node) {
|
|
|
445
1154
|
if (deckTopBrush && deckBotBrush && wallBrush && innerBrush && shinTopBrush && shinBotBrush) {
|
|
446
1155
|
try {
|
|
447
1156
|
const deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION);
|
|
1157
|
+
prepareBrushForCSG(deckSlab);
|
|
448
1158
|
const shinSlab = csgEvaluator.evaluate(shinTopBrush, shinBotBrush, SUBTRACTION);
|
|
1159
|
+
prepareBrushForCSG(shinSlab);
|
|
449
1160
|
deckTopBrush.geometry.dispose();
|
|
450
1161
|
deckBotBrush.geometry.dispose();
|
|
451
1162
|
shinTopBrush.geometry.dispose();
|
|
452
1163
|
shinBotBrush.geometry.dispose();
|
|
453
|
-
|
|
1164
|
+
const brushes = {
|
|
1165
|
+
deckSlab,
|
|
1166
|
+
shinSlab,
|
|
1167
|
+
wallBrush,
|
|
1168
|
+
innerBrush,
|
|
1169
|
+
rakeBoards,
|
|
1170
|
+
};
|
|
1171
|
+
if (hasSegmentTrim(node)) {
|
|
1172
|
+
subtractSegmentTrimCuts(brushes, node);
|
|
1173
|
+
brushes.rakeBoards = clipGeometryBySegmentTrim(brushes.rakeBoards, node);
|
|
1174
|
+
// The clip is a CSG subtraction: rake faces can come back as
|
|
1175
|
+
// `slot + 4n` because the cutter contributes its own material array.
|
|
1176
|
+
// Preserve top roof-material faces (slot 3) and force only cutter /
|
|
1177
|
+
// side faces back to the rake side material.
|
|
1178
|
+
if (brushes.rakeBoards) {
|
|
1179
|
+
remapDutchRakeBoardMaterials(brushes.rakeBoards);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return brushes;
|
|
454
1183
|
}
|
|
455
1184
|
catch (e) {
|
|
456
1185
|
console.error('CSG prep failed:', e);
|
|
@@ -468,21 +1197,37 @@ export function getRoofSegmentBrushes(node) {
|
|
|
468
1197
|
wallBrush.geometry.dispose();
|
|
469
1198
|
if (innerBrush)
|
|
470
1199
|
innerBrush.geometry.dispose();
|
|
1200
|
+
rakeBoards?.dispose();
|
|
471
1201
|
return null;
|
|
472
1202
|
}
|
|
473
|
-
export function generateRoofSegmentGeometry(node) {
|
|
474
|
-
const
|
|
1203
|
+
export function generateRoofSegmentGeometry(node, nodes) {
|
|
1204
|
+
const parentRoof = node.parentId ? nodes?.[node.parentId] : undefined;
|
|
1205
|
+
const parentRoofPosition = parentRoof && 'position' in parentRoof ? parentRoof.position : undefined;
|
|
1206
|
+
const parentRoofRotation = parentRoof && 'rotation' in parentRoof
|
|
1207
|
+
? (parentRoof.rotation ?? 0)
|
|
1208
|
+
: 0;
|
|
1209
|
+
const brushes = withSegmentUvMatrix(composeSegmentWorldMatrix(parentRoofPosition, parentRoofRotation, node.position, node.rotation ?? 0), () => getRoofSegmentBrushes(node));
|
|
475
1210
|
if (!brushes) {
|
|
476
1211
|
// Fallback: simple box
|
|
477
1212
|
return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth);
|
|
478
1213
|
}
|
|
479
|
-
|
|
1214
|
+
if (nodes) {
|
|
1215
|
+
subtractAccessoryCuts(brushes, node, nodes);
|
|
1216
|
+
}
|
|
1217
|
+
const { deckSlab, shinSlab, wallBrush, innerBrush, rakeBoards } = brushes;
|
|
480
1218
|
let resultGeo = new THREE.BufferGeometry();
|
|
481
1219
|
try {
|
|
482
1220
|
const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION);
|
|
1221
|
+
prepareBrushForCSG(hollowWall);
|
|
483
1222
|
const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION);
|
|
1223
|
+
prepareBrushForCSG(shinDeck);
|
|
484
1224
|
const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION);
|
|
1225
|
+
prepareBrushForCSG(combined);
|
|
485
1226
|
resultGeo = csgGeometry(combined);
|
|
1227
|
+
if (geometryHasInvalidAttributes(resultGeo)) {
|
|
1228
|
+
resultGeo.dispose();
|
|
1229
|
+
resultGeo = csgGeometry(wallBrush).clone();
|
|
1230
|
+
}
|
|
486
1231
|
const resultMaterials = csgMaterials(combined);
|
|
487
1232
|
const matToIndex = new Map([
|
|
488
1233
|
[dummyMats[0], 0],
|
|
@@ -505,11 +1250,142 @@ export function generateRoofSegmentGeometry(node) {
|
|
|
505
1250
|
shinSlab.geometry.dispose();
|
|
506
1251
|
wallBrush.geometry.dispose();
|
|
507
1252
|
innerBrush.geometry.dispose();
|
|
1253
|
+
if (rakeBoards) {
|
|
1254
|
+
const merged = mergeGeometriesPreservingGroups([resultGeo, rakeBoards]);
|
|
1255
|
+
rakeBoards.dispose();
|
|
1256
|
+
if (merged) {
|
|
1257
|
+
resultGeo.dispose();
|
|
1258
|
+
resultGeo = merged;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
508
1261
|
resultGeo.computeVertexNormals();
|
|
509
|
-
|
|
1262
|
+
ensureRenderableGeometryAttributes(resultGeo);
|
|
510
1263
|
return resultGeo;
|
|
511
1264
|
}
|
|
512
|
-
function
|
|
1265
|
+
function createGeometryFromRawAttributes(positions, normals, uvs, groups) {
|
|
1266
|
+
const geometry = new THREE.BufferGeometry();
|
|
1267
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
1268
|
+
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
|
1269
|
+
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
1270
|
+
geometry.setIndex(Array.from({ length: positions.length / 3 }, (_, i) => i));
|
|
1271
|
+
for (const group of groups) {
|
|
1272
|
+
geometry.addGroup(group.start, group.count, group.materialIndex);
|
|
1273
|
+
}
|
|
1274
|
+
ensureRenderableGeometryAttributes(geometry);
|
|
1275
|
+
return geometry;
|
|
1276
|
+
}
|
|
1277
|
+
function mergeGeometriesPreservingGroups(geometries) {
|
|
1278
|
+
if (geometries.length === 0)
|
|
1279
|
+
return null;
|
|
1280
|
+
const merged = mergeGeometries(geometries, false);
|
|
1281
|
+
if (!merged)
|
|
1282
|
+
return null;
|
|
1283
|
+
merged.clearGroups();
|
|
1284
|
+
let indexStart = 0;
|
|
1285
|
+
for (const geometry of geometries) {
|
|
1286
|
+
if (geometry.groups.length > 0) {
|
|
1287
|
+
for (const group of geometry.groups) {
|
|
1288
|
+
merged.addGroup(indexStart + group.start, group.count, group.materialIndex ?? 0);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
else {
|
|
1292
|
+
const count = geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0;
|
|
1293
|
+
if (count > 0) {
|
|
1294
|
+
merged.addGroup(indexStart, count, 0);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
indexStart += geometry.index?.count ?? geometry.getAttribute('position')?.count ?? 0;
|
|
1298
|
+
}
|
|
1299
|
+
return merged;
|
|
1300
|
+
}
|
|
1301
|
+
function collectGeometryPlanes(geometry) {
|
|
1302
|
+
const source = geometry.index ? geometry.toNonIndexed() : geometry;
|
|
1303
|
+
const position = source.getAttribute('position');
|
|
1304
|
+
const planes = [];
|
|
1305
|
+
const a = new THREE.Vector3();
|
|
1306
|
+
const b = new THREE.Vector3();
|
|
1307
|
+
const c = new THREE.Vector3();
|
|
1308
|
+
if (!position)
|
|
1309
|
+
return planes;
|
|
1310
|
+
for (let i = 0; i + 2 < position.count; i += 3) {
|
|
1311
|
+
a.fromBufferAttribute(position, i);
|
|
1312
|
+
b.fromBufferAttribute(position, i + 1);
|
|
1313
|
+
c.fromBufferAttribute(position, i + 2);
|
|
1314
|
+
const plane = new THREE.Plane().setFromCoplanarPoints(a, b, c);
|
|
1315
|
+
if (Number.isFinite(plane.normal.x) && plane.normal.lengthSq() > 1e-10) {
|
|
1316
|
+
planes.push(plane.normalize());
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (source !== geometry)
|
|
1320
|
+
source.dispose();
|
|
1321
|
+
return planes;
|
|
1322
|
+
}
|
|
1323
|
+
function splitShellByFacePlanes(geometry, planes, materialIndex) {
|
|
1324
|
+
if (planes.length === 0)
|
|
1325
|
+
return geometry;
|
|
1326
|
+
const source = geometry.index ? geometry.toNonIndexed() : geometry.clone();
|
|
1327
|
+
const position = source.getAttribute('position');
|
|
1328
|
+
const normal = source.getAttribute('normal');
|
|
1329
|
+
const uv = source.getAttribute('uv');
|
|
1330
|
+
if (!position || !normal || !uv) {
|
|
1331
|
+
source.dispose();
|
|
1332
|
+
return geometry;
|
|
1333
|
+
}
|
|
1334
|
+
const groups = source.groups.length > 0 ? source.groups : [{ start: 0, count: position.count, materialIndex }];
|
|
1335
|
+
const keptPositions = [];
|
|
1336
|
+
const keptNormals = [];
|
|
1337
|
+
const keptUvs = [];
|
|
1338
|
+
const keptGroups = [];
|
|
1339
|
+
let keptVertexCount = 0;
|
|
1340
|
+
const point = new THREE.Vector3();
|
|
1341
|
+
const triNormal = new THREE.Vector3();
|
|
1342
|
+
const pushTriangle = (targetPositions, targetNormals, targetUvs, targetGroups, startVertex, groupMaterialIndex, vertexOffset) => {
|
|
1343
|
+
if (targetGroups.length === 0 ||
|
|
1344
|
+
targetGroups[targetGroups.length - 1].materialIndex !== groupMaterialIndex) {
|
|
1345
|
+
targetGroups.push({ start: startVertex, count: 0, materialIndex: groupMaterialIndex });
|
|
1346
|
+
}
|
|
1347
|
+
const targetGroup = targetGroups[targetGroups.length - 1];
|
|
1348
|
+
for (let k = 0; k < 3; k += 1) {
|
|
1349
|
+
const vi = vertexOffset + k;
|
|
1350
|
+
targetPositions.push(position.getX(vi), position.getY(vi), position.getZ(vi));
|
|
1351
|
+
targetNormals.push(normal.getX(vi), normal.getY(vi), normal.getZ(vi));
|
|
1352
|
+
targetUvs.push(uv.getX(vi), uv.getY(vi));
|
|
1353
|
+
}
|
|
1354
|
+
targetGroup.count += 3;
|
|
1355
|
+
};
|
|
1356
|
+
const isPlaneMatch = (vertexOffset) => {
|
|
1357
|
+
if (materialIndex >= 0 &&
|
|
1358
|
+
triNormal.fromBufferAttribute(normal, vertexOffset).y <= SHINGLE_SURFACE_EPSILON) {
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
1361
|
+
triNormal.fromBufferAttribute(normal, vertexOffset).normalize();
|
|
1362
|
+
return planes.some((plane) => {
|
|
1363
|
+
if (Math.abs(triNormal.dot(plane.normal)) < 0.999)
|
|
1364
|
+
return false;
|
|
1365
|
+
for (let k = 0; k < 3; k += 1) {
|
|
1366
|
+
point.fromBufferAttribute(position, vertexOffset + k);
|
|
1367
|
+
if (Math.abs(plane.distanceToPoint(point)) > 1e-3)
|
|
1368
|
+
return false;
|
|
1369
|
+
}
|
|
1370
|
+
return true;
|
|
1371
|
+
});
|
|
1372
|
+
};
|
|
1373
|
+
for (const group of groups) {
|
|
1374
|
+
const groupStart = Math.max(0, group.start);
|
|
1375
|
+
const groupEnd = Math.min(position.count, group.start + group.count);
|
|
1376
|
+
for (let i = groupStart; i + 2 < groupEnd; i += 3) {
|
|
1377
|
+
if (group.materialIndex === materialIndex && isPlaneMatch(i)) {
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
pushTriangle(keptPositions, keptNormals, keptUvs, keptGroups, keptVertexCount, group.materialIndex ?? 0, i);
|
|
1381
|
+
keptVertexCount += 3;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
source.dispose();
|
|
1385
|
+
geometry.dispose();
|
|
1386
|
+
return createGeometryFromRawAttributes(keptPositions, keptNormals, keptUvs, keptGroups);
|
|
1387
|
+
}
|
|
1388
|
+
export function remapRoofShellFaces(geometry, node) {
|
|
513
1389
|
const position = geometry.getAttribute('position');
|
|
514
1390
|
const index = geometry.getIndex();
|
|
515
1391
|
if (!(position && index) || index.count === 0 || geometry.groups.length === 0)
|
|
@@ -545,14 +1421,17 @@ function remapRoofShellFaces(geometry, node) {
|
|
|
545
1421
|
.add(b)
|
|
546
1422
|
.add(c)
|
|
547
1423
|
.multiplyScalar(1 / 3);
|
|
548
|
-
if (normal.y > SHINGLE_SURFACE_EPSILON) {
|
|
1424
|
+
if (node.roofType === 'dutch' && Math.abs(normal.y) > SHINGLE_SURFACE_EPSILON) {
|
|
1425
|
+
materialIndex = 3;
|
|
1426
|
+
}
|
|
1427
|
+
else if (normal.y > SHINGLE_SURFACE_EPSILON) {
|
|
549
1428
|
materialIndex = 3;
|
|
550
1429
|
}
|
|
551
1430
|
else if (isRakeFace(node, geometry, centroid, normal)) {
|
|
552
|
-
materialIndex =
|
|
1431
|
+
materialIndex = 1;
|
|
553
1432
|
}
|
|
554
1433
|
else {
|
|
555
|
-
materialIndex =
|
|
1434
|
+
materialIndex = 0;
|
|
556
1435
|
}
|
|
557
1436
|
}
|
|
558
1437
|
triangleMaterials[triangleIndex] = materialIndex;
|
|
@@ -593,15 +1472,17 @@ function isRakeFace(node, geometry, centroid, normal) {
|
|
|
593
1472
|
function getRakeAxis(node) {
|
|
594
1473
|
if (node.roofType === 'gable' || node.roofType === 'gambrel')
|
|
595
1474
|
return 'x';
|
|
596
|
-
if (node.roofType === 'dutch')
|
|
597
|
-
return node.width >= node.depth ? 'x' : 'z';
|
|
598
1475
|
return null;
|
|
599
1476
|
}
|
|
600
1477
|
/**
|
|
601
1478
|
* Generates faces for a roof module volume.
|
|
602
1479
|
* Supports: hip, gable, shed, gambrel, dutch, mansard, flat.
|
|
1480
|
+
*
|
|
1481
|
+
* `shapeRatios` controls the kink positions on multi-slope roofs. The
|
|
1482
|
+
* height ratios are already baked into `tanTheta` (via the slope frame)
|
|
1483
|
+
* so they don't need to be threaded again.
|
|
603
1484
|
*/
|
|
604
|
-
function getModuleFaces(type, w, d, wh, rh, baseY, insets, baseW, baseD, tanTheta) {
|
|
1485
|
+
function getModuleFaces(type, w, d, wh, rh, baseY, insets, baseW, baseD, tanTheta, shapeRatios, dutchTopRakeThickness) {
|
|
605
1486
|
const v = (x, y, z) => new THREE.Vector3(x, y, z);
|
|
606
1487
|
const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets;
|
|
607
1488
|
const b1 = v(-w / 2 + iL, baseY, d / 2 - iF);
|
|
@@ -646,7 +1527,7 @@ function getModuleFaces(type, w, d, wh, rh, baseY, insets, baseW, baseD, tanThet
|
|
|
646
1527
|
faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1]);
|
|
647
1528
|
}
|
|
648
1529
|
else if (type === 'gambrel') {
|
|
649
|
-
const mz = (baseD / 2) *
|
|
1530
|
+
const mz = (baseD / 2) * shapeRatios.gambrelLowerWidthRatio;
|
|
650
1531
|
const dist = d / 2 - mz;
|
|
651
1532
|
const mh = wh + dist * (tanTheta || 0);
|
|
652
1533
|
const m1 = v(-w / 2, mh, mz);
|
|
@@ -658,61 +1539,192 @@ function getModuleFaces(type, w, d, wh, rh, baseY, insets, baseW, baseD, tanThet
|
|
|
658
1539
|
faces.push([e4, e1, m1, r1, m4], [e2, e3, m3, r2, m2], [e1, e2, m2, m1], [m1, m2, r2, r1], [e3, e4, m4, m3], [m3, m4, r1, r2]);
|
|
659
1540
|
}
|
|
660
1541
|
else if (type === 'mansard') {
|
|
661
|
-
const i = Math.min(baseW, baseD) *
|
|
1542
|
+
const i = Math.min(baseW, baseD) * shapeRatios.mansardSteepWidthRatio;
|
|
662
1543
|
const mh = wh + i * (tanTheta || 0);
|
|
663
1544
|
const m1 = v(-w / 2 + i, mh, d / 2 - i);
|
|
664
1545
|
const m2 = v(w / 2 - i, mh, d / 2 - i);
|
|
665
1546
|
const m3 = v(w / 2 - i, mh, -d / 2 + i);
|
|
666
1547
|
const m4 = v(-w / 2 + i, mh, -d / 2 + i);
|
|
667
|
-
const
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
const r1 = v(0, h, d / 2 - w / 2);
|
|
679
|
-
const r2 = v(0, h, -d / 2 + w / 2);
|
|
680
|
-
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]);
|
|
681
|
-
}
|
|
1548
|
+
const topW = w - i * 2;
|
|
1549
|
+
const topD = d - i * 2;
|
|
1550
|
+
faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4]);
|
|
1551
|
+
if (Math.abs(topW - topD) < 0.01) {
|
|
1552
|
+
const r = v(0, h, 0);
|
|
1553
|
+
faces.push([m4, m1, r], [m1, m2, r], [m2, m3, r], [m3, m4, r]);
|
|
1554
|
+
}
|
|
1555
|
+
else if (topW >= topD) {
|
|
1556
|
+
const r1 = v(-topW / 2 + topD / 2, h, 0);
|
|
1557
|
+
const r2 = v(topW / 2 - topD / 2, h, 0);
|
|
1558
|
+
faces.push([m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2]);
|
|
682
1559
|
}
|
|
683
1560
|
else {
|
|
684
|
-
|
|
1561
|
+
const r1 = v(0, h, topD / 2 - topW / 2);
|
|
1562
|
+
const r2 = v(0, h, -topD / 2 + topW / 2);
|
|
1563
|
+
faces.push([m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2]);
|
|
685
1564
|
}
|
|
686
1565
|
}
|
|
687
1566
|
else if (type === 'dutch') {
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
1567
|
+
const dutch = getDutchRoofShapeMetrics({
|
|
1568
|
+
w,
|
|
1569
|
+
d,
|
|
1570
|
+
wh,
|
|
1571
|
+
rh,
|
|
1572
|
+
dutchI: insets.dutchI,
|
|
1573
|
+
baseW,
|
|
1574
|
+
baseD,
|
|
1575
|
+
shapeRatios,
|
|
1576
|
+
});
|
|
1577
|
+
if (!dutch)
|
|
1578
|
+
return faces;
|
|
1579
|
+
if (dutch.axis === 'width') {
|
|
1580
|
+
const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
|
|
1581
|
+
const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
|
|
1582
|
+
const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
|
|
1583
|
+
const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
|
|
1584
|
+
const o1 = v(-dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
|
|
1585
|
+
const o2 = v(dutch.outerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
|
|
1586
|
+
const o3 = v(dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
|
|
1587
|
+
const o4 = v(-dutch.outerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
|
|
1588
|
+
const r1 = v(-dutch.innerWaistHalfX, h, 0);
|
|
1589
|
+
const r2 = v(dutch.innerWaistHalfX, h, 0);
|
|
1590
|
+
const endSlopes = getDutchEndSlopeFaces({
|
|
1591
|
+
w,
|
|
1592
|
+
d,
|
|
1593
|
+
wh,
|
|
1594
|
+
rh,
|
|
1595
|
+
insets,
|
|
1596
|
+
baseW,
|
|
1597
|
+
baseD,
|
|
1598
|
+
shapeRatios,
|
|
1599
|
+
dutchTopRakeThickness,
|
|
1600
|
+
}).map((face) => face.map((point) => v(point.x, point.y, point.z)));
|
|
1601
|
+
faces.push([e1, e2, o2, m2, m1, o1], [e3, e4, o4, m4, m3, o3]);
|
|
1602
|
+
if (endSlopes.length === 2) {
|
|
1603
|
+
faces.push(...endSlopes);
|
|
1604
|
+
}
|
|
1605
|
+
else {
|
|
1606
|
+
faces.push([e2, e3, o3, o2], [e4, e1, o1, o4]);
|
|
1607
|
+
}
|
|
1608
|
+
faces.push([m1, m2, r2, r1], [m3, m4, r1, r2]);
|
|
1609
|
+
faces.push([m4, m1, r1], [m2, m3, r2]);
|
|
698
1610
|
}
|
|
699
1611
|
else {
|
|
700
|
-
const m1 = v(-
|
|
701
|
-
const m2 = v(
|
|
702
|
-
const m3 = v(
|
|
703
|
-
const m4 = v(-
|
|
704
|
-
const
|
|
705
|
-
const
|
|
706
|
-
|
|
1612
|
+
const m1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
|
|
1613
|
+
const m2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ);
|
|
1614
|
+
const m3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
|
|
1615
|
+
const m4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ);
|
|
1616
|
+
const o1 = v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ);
|
|
1617
|
+
const o2 = v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.outerWaistHalfZ);
|
|
1618
|
+
const o3 = v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ);
|
|
1619
|
+
const o4 = v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.outerWaistHalfZ);
|
|
1620
|
+
const r1 = v(0, h, dutch.innerWaistHalfZ);
|
|
1621
|
+
const r2 = v(0, h, -dutch.innerWaistHalfZ);
|
|
1622
|
+
const endSlopes = getDutchEndSlopeFaces({
|
|
1623
|
+
w,
|
|
1624
|
+
d,
|
|
1625
|
+
wh,
|
|
1626
|
+
rh,
|
|
1627
|
+
insets,
|
|
1628
|
+
baseW,
|
|
1629
|
+
baseD,
|
|
1630
|
+
shapeRatios,
|
|
1631
|
+
dutchTopRakeThickness,
|
|
1632
|
+
}).map((face) => face.map((point) => v(point.x, point.y, point.z)));
|
|
1633
|
+
faces.push([e2, e3, o3, m3, m2, o2], [e4, e1, o1, m1, m4, o4]);
|
|
1634
|
+
if (endSlopes.length === 2) {
|
|
1635
|
+
faces.push(...endSlopes);
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
faces.push([e1, e2, o2, o1], [e3, e4, o4, o3]);
|
|
1639
|
+
}
|
|
1640
|
+
faces.push([m2, m3, r2, r1], [m4, m1, r1, r2]);
|
|
1641
|
+
faces.push([m1, m2, r1], [m3, m4, r2]);
|
|
707
1642
|
}
|
|
708
1643
|
}
|
|
709
1644
|
return faces;
|
|
710
1645
|
}
|
|
1646
|
+
function addDutchRakeBoard(apex, base, outward, reach, thickness, topFaces, sideFaces) {
|
|
1647
|
+
if (!(reach > 0.001) || !(thickness > 0.0001))
|
|
1648
|
+
return;
|
|
1649
|
+
const apexOuter = apex.clone().addScaledVector(outward, reach);
|
|
1650
|
+
const baseOuter = base.clone().addScaledVector(outward, reach);
|
|
1651
|
+
const topPoly = [apex.clone(), base.clone(), baseOuter, apexOuter];
|
|
1652
|
+
const normal = new THREE.Vector3()
|
|
1653
|
+
.crossVectors(new THREE.Vector3().subVectors(topPoly[1], topPoly[0]), new THREE.Vector3().subVectors(topPoly[2], topPoly[0]))
|
|
1654
|
+
.normalize();
|
|
1655
|
+
if (normal.y < 0) {
|
|
1656
|
+
topPoly.reverse();
|
|
1657
|
+
normal.multiplyScalar(-1);
|
|
1658
|
+
}
|
|
1659
|
+
const top = topPoly.map((point) => point.clone().addScaledVector(normal, DUTCH_RAKE_SLOPE_SEAT_OFFSET));
|
|
1660
|
+
const bottom = top.map((point) => new THREE.Vector3(point.x, point.y - thickness, point.z));
|
|
1661
|
+
pushDoubleSidedFace(topFaces, top);
|
|
1662
|
+
pushDoubleSidedFace(sideFaces, bottom.slice().reverse());
|
|
1663
|
+
for (let i = 0; i < top.length; i += 1) {
|
|
1664
|
+
const next = (i + 1) % top.length;
|
|
1665
|
+
pushDoubleSidedFace(sideFaces, [
|
|
1666
|
+
top[i].clone(),
|
|
1667
|
+
top[next].clone(),
|
|
1668
|
+
bottom[next].clone(),
|
|
1669
|
+
bottom[i].clone(),
|
|
1670
|
+
]);
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
function buildDutchRakeBoards(W, D, wh, rh, i, shapeRatios, rake, thickness) {
|
|
1674
|
+
if (!(rake > 0.001) || !(thickness > 0.0001) || !(i > 0.001) || !(rh > 0.001)) {
|
|
1675
|
+
return null;
|
|
1676
|
+
}
|
|
1677
|
+
const dutch = getDutchRoofShapeMetrics({
|
|
1678
|
+
w: W,
|
|
1679
|
+
d: D,
|
|
1680
|
+
wh,
|
|
1681
|
+
rh,
|
|
1682
|
+
dutchI: i,
|
|
1683
|
+
baseW: W,
|
|
1684
|
+
baseD: D,
|
|
1685
|
+
shapeRatios: {
|
|
1686
|
+
...shapeRatios,
|
|
1687
|
+
dutchGabletRake: rake,
|
|
1688
|
+
},
|
|
1689
|
+
});
|
|
1690
|
+
if (!dutch || !(dutch.rakeReach > 0.001))
|
|
1691
|
+
return null;
|
|
1692
|
+
const v = (x, y, z) => new THREE.Vector3(x, y, z);
|
|
1693
|
+
const topFaces = [];
|
|
1694
|
+
const sideFaces = [];
|
|
1695
|
+
if (dutch.axis === 'width') {
|
|
1696
|
+
addDutchRakeBoard(v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(-1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces);
|
|
1697
|
+
addDutchRakeBoard(v(-dutch.innerWaistHalfX, dutch.peakHeight, 0), v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(-1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces);
|
|
1698
|
+
addDutchRakeBoard(v(dutch.innerWaistHalfX, dutch.peakHeight, 0), v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces);
|
|
1699
|
+
addDutchRakeBoard(v(dutch.innerWaistHalfX, dutch.peakHeight, 0), v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(1, 0, 0), dutch.rakeReach, thickness, topFaces, sideFaces);
|
|
1700
|
+
}
|
|
1701
|
+
else {
|
|
1702
|
+
addDutchRakeBoard(v(0, dutch.peakHeight, dutch.innerWaistHalfZ), v(-dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(0, 0, 1), dutch.rakeReach, thickness, topFaces, sideFaces);
|
|
1703
|
+
addDutchRakeBoard(v(0, dutch.peakHeight, dutch.innerWaistHalfZ), v(dutch.innerWaistHalfX, dutch.middleHeight, dutch.innerWaistHalfZ), v(0, 0, 1), dutch.rakeReach, thickness, topFaces, sideFaces);
|
|
1704
|
+
addDutchRakeBoard(v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), v(-dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(0, 0, -1), dutch.rakeReach, thickness, topFaces, sideFaces);
|
|
1705
|
+
addDutchRakeBoard(v(0, dutch.peakHeight, -dutch.innerWaistHalfZ), v(dutch.innerWaistHalfX, dutch.middleHeight, -dutch.innerWaistHalfZ), v(0, 0, -1), dutch.rakeReach, thickness, topFaces, sideFaces);
|
|
1706
|
+
}
|
|
1707
|
+
const geometries = [];
|
|
1708
|
+
if (topFaces.length > 0) {
|
|
1709
|
+
geometries.push(createGeometryFromFaces(topFaces, DUTCH_RAKE_TOP_MATERIAL_INDEX));
|
|
1710
|
+
}
|
|
1711
|
+
if (sideFaces.length > 0) {
|
|
1712
|
+
geometries.push(createGeometryFromFaces(sideFaces, DUTCH_RAKE_SIDE_MATERIAL_INDEX));
|
|
1713
|
+
}
|
|
1714
|
+
if (geometries.length === 0)
|
|
1715
|
+
return null;
|
|
1716
|
+
if (geometries.length === 1)
|
|
1717
|
+
return geometries[0];
|
|
1718
|
+
const merged = mergeGeometriesPreservingGroups(geometries);
|
|
1719
|
+
for (const geometry of geometries)
|
|
1720
|
+
geometry.dispose();
|
|
1721
|
+
return merged;
|
|
1722
|
+
}
|
|
711
1723
|
/**
|
|
712
1724
|
* Converts an array of face polygons into a BufferGeometry.
|
|
713
1725
|
* Each face is triangulated via fan triangulation.
|
|
714
1726
|
*/
|
|
715
|
-
function createGeometryFromFaces(faces, matRule = null) {
|
|
1727
|
+
function createGeometryFromFaces(faces, matRule = null, options) {
|
|
716
1728
|
const positions = [];
|
|
717
1729
|
const normals = [];
|
|
718
1730
|
const uvs = [];
|
|
@@ -728,6 +1740,30 @@ function createGeometryFromFaces(faces, matRule = null) {
|
|
|
728
1740
|
const vA = new THREE.Vector3().subVectors(p1, p0);
|
|
729
1741
|
const vB = new THREE.Vector3().subVectors(p2, p0);
|
|
730
1742
|
const normal = new THREE.Vector3().crossVectors(vA, vB).normalize();
|
|
1743
|
+
if (normal.lengthSq() < 1e-12)
|
|
1744
|
+
continue;
|
|
1745
|
+
let slopeAlignedDown = null;
|
|
1746
|
+
let slopeAlignedAcross = null;
|
|
1747
|
+
let slopeAlignedVOrigin = 0;
|
|
1748
|
+
const slopeUvNormal = options?.treatBidirectionalSlopeFacesAsSlope && normal.y < 0
|
|
1749
|
+
? normal.clone().multiplyScalar(-1)
|
|
1750
|
+
: normal;
|
|
1751
|
+
if (Math.abs(slopeUvNormal.y) > SHINGLE_SURFACE_EPSILON) {
|
|
1752
|
+
_uvDownSlope.copy(_uvWorldDown).projectOnPlane(slopeUvNormal);
|
|
1753
|
+
if (_uvDownSlope.lengthSq() > 1e-8) {
|
|
1754
|
+
_uvDownSlope.normalize();
|
|
1755
|
+
_uvAcrossSlope.crossVectors(_uvDownSlope, slopeUvNormal).normalize();
|
|
1756
|
+
let highestPoint = face[0];
|
|
1757
|
+
for (const candidate of face) {
|
|
1758
|
+
if (candidate.y > highestPoint.y) {
|
|
1759
|
+
highestPoint = candidate;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
slopeAlignedDown = _uvDownSlope.clone();
|
|
1763
|
+
slopeAlignedAcross = _uvAcrossSlope.clone();
|
|
1764
|
+
slopeAlignedVOrigin = highestPoint.dot(slopeAlignedDown);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
731
1767
|
let assignedMatIndex = 0;
|
|
732
1768
|
if (typeof matRule === 'function') {
|
|
733
1769
|
assignedMatIndex = matRule(normal);
|
|
@@ -750,9 +1786,16 @@ function createGeometryFromFaces(faces, matRule = null) {
|
|
|
750
1786
|
normals.push(normal.x, normal.y, normal.z);
|
|
751
1787
|
normals.push(normal.x, normal.y, normal.z);
|
|
752
1788
|
normals.push(normal.x, normal.y, normal.z);
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
1789
|
+
if (slopeAlignedDown && slopeAlignedAcross) {
|
|
1790
|
+
uvs.push(p0.dot(slopeAlignedAcross), slopeAlignedVOrigin - p0.dot(slopeAlignedDown));
|
|
1791
|
+
uvs.push(fi.dot(slopeAlignedAcross), slopeAlignedVOrigin - fi.dot(slopeAlignedDown));
|
|
1792
|
+
uvs.push(fi1.dot(slopeAlignedAcross), slopeAlignedVOrigin - fi1.dot(slopeAlignedDown));
|
|
1793
|
+
}
|
|
1794
|
+
else {
|
|
1795
|
+
pushRoofUv(uvs, p0, normal);
|
|
1796
|
+
pushRoofUv(uvs, fi, normal);
|
|
1797
|
+
pushRoofUv(uvs, fi1, normal);
|
|
1798
|
+
}
|
|
756
1799
|
indices.push(vertexCount, vertexCount + 1, vertexCount + 2);
|
|
757
1800
|
faceVertexCount += 3;
|
|
758
1801
|
vertexCount += 3;
|
|
@@ -774,27 +1817,179 @@ function createGeometryFromFaces(faces, matRule = null) {
|
|
|
774
1817
|
// Merge identical vertices to optimize geometry for CSG and create clean topology
|
|
775
1818
|
const mergedGeo = mergeVertices(geometry, 1e-4);
|
|
776
1819
|
geometry.dispose();
|
|
777
|
-
|
|
1820
|
+
ensureRenderableGeometryAttributes(mergedGeo);
|
|
778
1821
|
return mergedGeo;
|
|
779
1822
|
}
|
|
780
1823
|
function pushRoofUv(uvs, point, normal) {
|
|
781
|
-
|
|
1824
|
+
// Project in WORLD space (via the current segment's world transform) so
|
|
1825
|
+
// vertical gable faces tile identically to the walls below; for a local
|
|
1826
|
+
// build the matrix is identity and this is the original behaviour.
|
|
1827
|
+
const p = _uvWorldPoint.copy(point).applyMatrix4(_segmentUvMatrix);
|
|
1828
|
+
_uvFaceNormal.copy(normal).applyMatrix3(_segmentUvNormalMatrix).normalize();
|
|
1829
|
+
_uvWorldNormal.copy(_uvFaceNormal);
|
|
782
1830
|
const absX = Math.abs(_uvFaceNormal.x);
|
|
783
1831
|
const absY = Math.abs(_uvFaceNormal.y);
|
|
784
1832
|
const absZ = Math.abs(_uvFaceNormal.z);
|
|
785
1833
|
if (absY >= absX && absY >= absZ) {
|
|
786
|
-
uvs.push(
|
|
1834
|
+
uvs.push(p.x, p.z);
|
|
787
1835
|
return;
|
|
788
1836
|
}
|
|
1837
|
+
if (_uvFaceNormal.y > SHINGLE_SURFACE_EPSILON) {
|
|
1838
|
+
_uvDownSlope.copy(_uvWorldDown).projectOnPlane(_uvFaceNormal);
|
|
1839
|
+
if (_uvDownSlope.lengthSq() > 1e-8) {
|
|
1840
|
+
_uvDownSlope.normalize();
|
|
1841
|
+
_uvAcrossSlope.crossVectors(_uvDownSlope, _uvFaceNormal).normalize();
|
|
1842
|
+
uvs.push(p.dot(_uvAcrossSlope), p.dot(_uvDownSlope));
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
// Vertical (gable wall) faces: U = ±worldX/Z (axis across the face normal),
|
|
1847
|
+
// V = 1 - worldY — the same world-space projection the wall kind uses.
|
|
1848
|
+
const wallV = 1 - p.y;
|
|
789
1849
|
if (absX >= absZ) {
|
|
790
|
-
uvs.push(
|
|
1850
|
+
uvs.push(_uvWorldNormal.x >= 0 ? p.z : -p.z, wallV);
|
|
791
1851
|
return;
|
|
792
1852
|
}
|
|
793
|
-
uvs.push(
|
|
1853
|
+
uvs.push(_uvWorldNormal.z >= 0 ? p.x : -p.x, wallV);
|
|
794
1854
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
1855
|
+
/**
|
|
1856
|
+
* Returns the outer roof surface frame (point + normal) at a given segment-local XZ.
|
|
1857
|
+
* This is used for skylight placement + cut direction so cutouts remain perpendicular
|
|
1858
|
+
* to the true roof surface even on multi-slope roofs (gambrel/mansard/dutch).
|
|
1859
|
+
*/
|
|
1860
|
+
export function getRoofOuterSurfaceFrameAtPoint(segment, lx, lz) {
|
|
1861
|
+
const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang, shingleThickness, } = segment;
|
|
1862
|
+
const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment);
|
|
1863
|
+
if (roofType === 'flat' || activeRh === 0) {
|
|
1864
|
+
return {
|
|
1865
|
+
point: new THREE.Vector3(lx, wallHeight + deckThickness + shingleThickness, lz),
|
|
1866
|
+
normal: new THREE.Vector3(0, 1, 0),
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
const verticalRt = deckThickness / cosTheta;
|
|
1870
|
+
const horizontalOverhang = overhang * cosTheta;
|
|
1871
|
+
const deckExt = wallThickness / 2 + horizontalOverhang;
|
|
1872
|
+
const stSin = shingleThickness * sinTheta;
|
|
1873
|
+
const stCos = shingleThickness * cosTheta;
|
|
1874
|
+
const shinBotW = Math.max(0.01, width + 2 * deckExt);
|
|
1875
|
+
const shinBotD = Math.max(0.01, depth + 2 * deckExt);
|
|
1876
|
+
const deckDrop = deckExt * tanTheta;
|
|
1877
|
+
const shinBotWh = wallHeight - deckDrop + verticalRt;
|
|
1878
|
+
let shinBotRh = activeRh;
|
|
1879
|
+
if (activeRh > 0) {
|
|
1880
|
+
shinBotRh = activeRh + deckDrop;
|
|
1881
|
+
if (roofType === 'shed')
|
|
1882
|
+
shinBotRh = activeRh + 2 * deckDrop;
|
|
1883
|
+
}
|
|
1884
|
+
let shinTopW = shinBotW;
|
|
1885
|
+
let shinTopD = shinBotD;
|
|
1886
|
+
let transZ = 0;
|
|
1887
|
+
if (['hip', 'mansard', 'dutch'].includes(roofType)) {
|
|
1888
|
+
shinTopW += 2 * stSin;
|
|
1889
|
+
shinTopD += 2 * stSin;
|
|
1890
|
+
}
|
|
1891
|
+
else {
|
|
1892
|
+
shinTopW += 2 * stSin;
|
|
1893
|
+
shinTopD += 2 * stSin;
|
|
1894
|
+
transZ = stSin;
|
|
1895
|
+
}
|
|
1896
|
+
const shinTopWh = shinBotWh + stCos;
|
|
1897
|
+
const shinTopRh = shinBotRh + stCos;
|
|
1898
|
+
const topBaseY = 0;
|
|
1899
|
+
const baseI = Math.min(width, depth) * 0.25;
|
|
1900
|
+
// Dutch gablet waist tracks dutchHipWidthRatio (see getRoofSegmentBrushes);
|
|
1901
|
+
// the generic baseI above still drives the bottom-rect insets for other types.
|
|
1902
|
+
const dutchBaseI = Math.min(width, depth) * segment.dutchHipWidthRatio;
|
|
1903
|
+
const getInsets = (_wh, _baseY, isVoid, _wV, _dV) => {
|
|
1904
|
+
const inset = Math.max(0.01, baseI);
|
|
1905
|
+
let iF = 0;
|
|
1906
|
+
let iB = 0;
|
|
1907
|
+
let iL = 0;
|
|
1908
|
+
let iR = 0;
|
|
1909
|
+
if (roofType === 'hip') {
|
|
1910
|
+
iF = inset;
|
|
1911
|
+
iB = inset;
|
|
1912
|
+
iL = inset;
|
|
1913
|
+
iR = inset;
|
|
1914
|
+
}
|
|
1915
|
+
else if (roofType === 'gable' || roofType === 'gambrel') {
|
|
1916
|
+
iL = inset;
|
|
1917
|
+
iR = inset;
|
|
1918
|
+
}
|
|
1919
|
+
else if (roofType === 'mansard' || roofType === 'dutch') {
|
|
1920
|
+
iF = inset;
|
|
1921
|
+
iB = inset;
|
|
1922
|
+
iL = inset;
|
|
1923
|
+
iR = inset;
|
|
1924
|
+
}
|
|
1925
|
+
else if (roofType === 'shed') {
|
|
1926
|
+
iF = inset;
|
|
1927
|
+
}
|
|
1928
|
+
let structuralI = dutchBaseI;
|
|
1929
|
+
if (isVoid) {
|
|
1930
|
+
structuralI += shingleThickness;
|
|
1931
|
+
}
|
|
1932
|
+
return { iF, iB, iL, iR, dutchI: structuralI };
|
|
1933
|
+
};
|
|
1934
|
+
const insetsTop = getInsets(shinTopWh, topBaseY, false, shinTopW, shinTopD);
|
|
1935
|
+
const shapeRatios = {
|
|
1936
|
+
gambrelLowerWidthRatio: segment.gambrelLowerWidthRatio,
|
|
1937
|
+
mansardSteepWidthRatio: segment.mansardSteepWidthRatio,
|
|
1938
|
+
dutchHipWidthRatio: segment.dutchHipWidthRatio,
|
|
1939
|
+
dutchHipHeightRatio: segment.dutchHipHeightRatio,
|
|
1940
|
+
dutchWaistLengthRatio: segment.dutchWaistLengthRatio ?? ROOF_SHAPE_DEFAULTS.dutchWaistLengthRatio,
|
|
1941
|
+
dutchGabletRake: segment.dutchGabletRake ?? ROOF_SHAPE_DEFAULTS.dutchGabletRake,
|
|
1942
|
+
};
|
|
1943
|
+
const topFaces = getModuleFaces(roofType, shinTopW, shinTopD, shinTopWh, shinTopRh, topBaseY, insetsTop, width, depth, tanTheta, shapeRatios, segment.dutchTopRakeThickness);
|
|
1944
|
+
const topGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1);
|
|
1945
|
+
if (transZ !== 0)
|
|
1946
|
+
topGeo.translate(0, 0, transZ);
|
|
1947
|
+
topGeo.computeBoundingBox();
|
|
1948
|
+
const topY = wallHeight + activeRh + deckThickness + shingleThickness + 10;
|
|
1949
|
+
_surfaceOrigin.set(lx, topY, lz);
|
|
1950
|
+
_surfaceRay.set(_surfaceOrigin, _surfaceDir);
|
|
1951
|
+
_surfaceHits.length = 0;
|
|
1952
|
+
const pos = topGeo.getAttribute('position');
|
|
1953
|
+
const index = topGeo.getIndex();
|
|
1954
|
+
if (!pos || !index) {
|
|
1955
|
+
topGeo.dispose();
|
|
1956
|
+
return {
|
|
1957
|
+
point: new THREE.Vector3(lx, wallHeight, lz),
|
|
1958
|
+
normal: new THREE.Vector3(0, 1, 0),
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
let bestT = Number.POSITIVE_INFINITY;
|
|
1962
|
+
let bestPoint = null;
|
|
1963
|
+
let bestNormal = null;
|
|
1964
|
+
for (let i = 0; i < index.count; i += 3) {
|
|
1965
|
+
const a = index.getX(i);
|
|
1966
|
+
const b = index.getX(i + 1);
|
|
1967
|
+
const c = index.getX(i + 2);
|
|
1968
|
+
_surfaceV0.fromBufferAttribute(pos, a);
|
|
1969
|
+
_surfaceV1.fromBufferAttribute(pos, b);
|
|
1970
|
+
_surfaceV2.fromBufferAttribute(pos, c);
|
|
1971
|
+
const hit = _surfaceRay.intersectTriangle(_surfaceV0, _surfaceV1, _surfaceV2, false, _tmpVec3A);
|
|
1972
|
+
if (!hit)
|
|
1973
|
+
continue;
|
|
1974
|
+
const t = hit.distanceTo(_surfaceOrigin);
|
|
1975
|
+
if (t < bestT) {
|
|
1976
|
+
bestT = t;
|
|
1977
|
+
bestPoint = hit.clone();
|
|
1978
|
+
_surfaceFaceNormal
|
|
1979
|
+
.subVectors(_surfaceV1, _surfaceV0)
|
|
1980
|
+
.cross(_tmpVec3B.subVectors(_surfaceV2, _surfaceV0))
|
|
1981
|
+
.normalize();
|
|
1982
|
+
bestNormal = _surfaceFaceNormal.clone();
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
topGeo.dispose();
|
|
1986
|
+
if (!bestPoint || !bestNormal) {
|
|
1987
|
+
return {
|
|
1988
|
+
point: new THREE.Vector3(lx, wallHeight, lz),
|
|
1989
|
+
normal: new THREE.Vector3(0, 1, 0),
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
if (bestNormal.y < 0)
|
|
1993
|
+
bestNormal.multiplyScalar(-1);
|
|
1994
|
+
return { point: bestPoint, normal: bestNormal };
|
|
800
1995
|
}
|