@pascal-app/core 0.1.13 → 0.3.0
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/events/bus.d.ts +15 -2
- package/dist/events/bus.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.d.ts +5 -1
- package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
- package/dist/hooks/scene-registry/scene-registry.js +10 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +8 -8
- package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-manager.js +88 -36
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid-sync.js +16 -8
- package/dist/hooks/spatial-grid/spatial-grid.d.ts +3 -3
- package/dist/hooks/spatial-grid/spatial-grid.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/spatial-grid.js +2 -2
- package/dist/hooks/spatial-grid/use-spatial-query.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/wall-spatial-grid.d.ts +2 -2
- package/dist/hooks/spatial-grid/wall-spatial-grid.d.ts.map +1 -1
- package/dist/hooks/spatial-grid/wall-spatial-grid.js +2 -2
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/lib/space-detection.d.ts.map +1 -1
- package/dist/lib/space-detection.js +3 -1
- package/dist/schema/collections.d.ts +11 -0
- package/dist/schema/collections.d.ts.map +1 -0
- package/dist/schema/collections.js +2 -0
- package/dist/schema/index.d.ts +11 -8
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +11 -7
- package/dist/schema/nodes/door.d.ts +78 -0
- package/dist/schema/nodes/door.d.ts.map +1 -0
- package/dist/schema/nodes/door.js +67 -0
- package/dist/schema/nodes/item.d.ts +234 -0
- package/dist/schema/nodes/item.d.ts.map +1 -1
- package/dist/schema/nodes/item.js +65 -1
- package/dist/schema/nodes/level.d.ts.map +1 -1
- package/dist/schema/nodes/level.js +11 -1
- package/dist/schema/nodes/roof-segment.d.ts +51 -0
- package/dist/schema/nodes/roof-segment.d.ts.map +1 -0
- package/dist/schema/nodes/roof-segment.js +36 -0
- package/dist/schema/nodes/roof.d.ts +1 -4
- package/dist/schema/nodes/roof.d.ts.map +1 -1
- package/dist/schema/nodes/roof.js +9 -16
- package/dist/schema/nodes/site.d.ts +46 -0
- package/dist/schema/nodes/site.d.ts.map +1 -1
- package/dist/schema/types.d.ts +191 -4
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js +4 -0
- package/dist/store/actions/node-actions.d.ts.map +1 -1
- package/dist/store/actions/node-actions.js +23 -4
- package/dist/store/use-interactive.d.ts +18 -0
- package/dist/store/use-interactive.d.ts.map +1 -0
- package/dist/store/use-interactive.js +50 -0
- package/dist/store/use-scene.d.ts +10 -1
- package/dist/store/use-scene.d.ts.map +1 -1
- package/dist/store/use-scene.js +190 -57
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +5 -0
- package/dist/systems/door/door-system.d.ts +2 -0
- package/dist/systems/door/door-system.d.ts.map +1 -0
- package/dist/systems/door/door-system.js +211 -0
- package/dist/systems/item/item-system.js +3 -2
- package/dist/systems/roof/roof-system.d.ts +11 -3
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +705 -210
- package/dist/systems/slab/slab-system.js +3 -3
- package/dist/systems/wall/wall-footprint.d.ts +7 -0
- package/dist/systems/wall/wall-footprint.d.ts.map +1 -0
- package/dist/systems/wall/wall-footprint.js +49 -0
- package/dist/systems/wall/wall-mitering.js +2 -2
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +13 -50
- package/dist/systems/window/window-system.js +3 -3
- package/package.json +6 -6
|
@@ -1,254 +1,749 @@
|
|
|
1
1
|
import { useFrame } from '@react-three/fiber';
|
|
2
2
|
import * as THREE from 'three';
|
|
3
|
+
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
4
|
+
import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
|
|
5
|
+
import { computeBoundsTree } from 'three-mesh-bvh';
|
|
3
6
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry';
|
|
4
7
|
import useScene from '../../store/use-scene';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
8
|
+
const csgEvaluator = new Evaluator();
|
|
9
|
+
csgEvaluator.useGroups = true;
|
|
10
|
+
csgEvaluator.attributes = ['position', 'normal'];
|
|
11
|
+
// Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry
|
|
12
|
+
const _matrix = new THREE.Matrix4();
|
|
13
|
+
const _position = new THREE.Vector3();
|
|
14
|
+
const _quaternion = new THREE.Quaternion();
|
|
15
|
+
const _scale = new THREE.Vector3(1, 1, 1);
|
|
16
|
+
const _yAxis = new THREE.Vector3(0, 1, 0);
|
|
17
|
+
// Pending merged-roof updates carried across frames (for throttling)
|
|
18
|
+
const pendingRoofUpdates = new Set();
|
|
19
|
+
const MAX_ROOFS_PER_FRAME = 1;
|
|
20
|
+
const MAX_SEGMENTS_PER_FRAME = 3;
|
|
15
21
|
// ============================================================================
|
|
16
22
|
// ROOF SYSTEM
|
|
17
23
|
// ============================================================================
|
|
18
24
|
export const RoofSystem = () => {
|
|
19
25
|
const dirtyNodes = useScene((state) => state.dirtyNodes);
|
|
20
26
|
const clearDirty = useScene((state) => state.clearDirty);
|
|
27
|
+
const rootNodeIds = useScene((state) => state.rootNodeIds);
|
|
21
28
|
useFrame(() => {
|
|
22
|
-
|
|
29
|
+
// Clear stale pending updates when the scene is unloaded
|
|
30
|
+
if (rootNodeIds.length === 0) {
|
|
31
|
+
pendingRoofUpdates.clear();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (dirtyNodes.size === 0 && pendingRoofUpdates.size === 0)
|
|
23
35
|
return;
|
|
24
36
|
const nodes = useScene.getState().nodes;
|
|
25
|
-
// Process dirty
|
|
37
|
+
// --- Pass 1: Process dirty roof-segments (throttled) ---
|
|
38
|
+
let segmentsProcessed = 0;
|
|
26
39
|
dirtyNodes.forEach((id) => {
|
|
27
40
|
const node = nodes[id];
|
|
28
|
-
if (!node
|
|
41
|
+
if (!node)
|
|
29
42
|
return;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
if (node.type === 'roof-segment') {
|
|
44
|
+
const mesh = sceneRegistry.nodes.get(id);
|
|
45
|
+
if (mesh) {
|
|
46
|
+
// Only compute expensive individual CSG when the segment is actually rendered
|
|
47
|
+
// (its parent group is visible = the roof is selected for editing)
|
|
48
|
+
const isVisible = mesh.parent?.visible !== false;
|
|
49
|
+
if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
|
|
50
|
+
updateRoofSegmentGeometry(node, mesh);
|
|
51
|
+
segmentsProcessed++;
|
|
52
|
+
}
|
|
53
|
+
else if (isVisible) {
|
|
54
|
+
return; // Over budget — keep dirty, process next frame
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
// Just sync transform, skip CSG — the merged roof handles visuals.
|
|
58
|
+
// But replace the initial BoxGeometry once: it has 6 groups (materialIndex 0-5)
|
|
59
|
+
// while roofMaterials only has 4 entries. Three.js raycasts into invisible groups,
|
|
60
|
+
// so MeshBVH hits groups[4].materialIndex → undefined.side → crash.
|
|
61
|
+
if (mesh.geometry.type === 'BoxGeometry') {
|
|
62
|
+
mesh.geometry.dispose();
|
|
63
|
+
const placeholder = new THREE.BufferGeometry();
|
|
64
|
+
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3));
|
|
65
|
+
placeholder.computeBoundsTree = computeBoundsTree;
|
|
66
|
+
placeholder.computeBoundsTree({ maxLeafSize: 10 });
|
|
67
|
+
mesh.geometry = placeholder;
|
|
68
|
+
}
|
|
69
|
+
mesh.position.set(node.position[0], node.position[1], node.position[2]);
|
|
70
|
+
mesh.rotation.y = node.rotation;
|
|
71
|
+
}
|
|
72
|
+
clearDirty(id);
|
|
73
|
+
}
|
|
74
|
+
// Queue the parent roof for a merged geometry update
|
|
75
|
+
if (node.parentId) {
|
|
76
|
+
pendingRoofUpdates.add(node.parentId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (node.type === 'roof') {
|
|
80
|
+
pendingRoofUpdates.add(id);
|
|
33
81
|
clearDirty(id);
|
|
34
82
|
}
|
|
35
|
-
// If mesh not found, keep it dirty for next frame
|
|
36
83
|
});
|
|
37
|
-
|
|
84
|
+
// --- Pass 2: Process pending merged-roof updates (max 1 per frame) ---
|
|
85
|
+
let roofsProcessed = 0;
|
|
86
|
+
for (const id of pendingRoofUpdates) {
|
|
87
|
+
if (roofsProcessed >= MAX_ROOFS_PER_FRAME)
|
|
88
|
+
break;
|
|
89
|
+
const node = nodes[id];
|
|
90
|
+
if (!node || node.type !== 'roof') {
|
|
91
|
+
pendingRoofUpdates.delete(id);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const group = sceneRegistry.nodes.get(id);
|
|
95
|
+
if (group) {
|
|
96
|
+
const mergedMesh = group.getObjectByName('merged-roof');
|
|
97
|
+
if (mergedMesh?.visible !== false) {
|
|
98
|
+
// Only rebuild when visible — RoofEditSystem re-triggers via markDirty on edit mode exit
|
|
99
|
+
updateMergedRoofGeometry(node, group, nodes);
|
|
100
|
+
roofsProcessed++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
pendingRoofUpdates.delete(id);
|
|
104
|
+
}
|
|
105
|
+
}, 5); // Priority 5: run after all other systems have settled
|
|
38
106
|
return null;
|
|
39
107
|
};
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
function
|
|
44
|
-
const newGeo =
|
|
108
|
+
// ============================================================================
|
|
109
|
+
// GEOMETRY GENERATION
|
|
110
|
+
// ============================================================================
|
|
111
|
+
function updateRoofSegmentGeometry(node, mesh) {
|
|
112
|
+
const newGeo = generateRoofSegmentGeometry(node);
|
|
45
113
|
mesh.geometry.dispose();
|
|
46
114
|
mesh.geometry = newGeo;
|
|
47
|
-
|
|
115
|
+
newGeo.computeBoundsTree = computeBoundsTree;
|
|
116
|
+
newGeo.computeBoundsTree({ maxLeafSize: 10 });
|
|
48
117
|
mesh.position.set(node.position[0], node.position[1], node.position[2]);
|
|
49
118
|
mesh.rotation.y = node.rotation;
|
|
50
119
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
120
|
+
function updateMergedRoofGeometry(roofNode, group, nodes) {
|
|
121
|
+
const mergedMesh = group.getObjectByName('merged-roof');
|
|
122
|
+
if (!mergedMesh)
|
|
123
|
+
return;
|
|
124
|
+
const children = (roofNode.children ?? [])
|
|
125
|
+
.map((id) => nodes[id])
|
|
126
|
+
.filter(Boolean);
|
|
127
|
+
if (children.length === 0) {
|
|
128
|
+
mergedMesh.geometry.dispose();
|
|
129
|
+
// Keep a valid position attribute so Drei's BVH can index safely.
|
|
130
|
+
mergedMesh.geometry = new THREE.BoxGeometry(0, 0, 0);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let totalShinSlab = null;
|
|
134
|
+
let totalDeckSlab = null;
|
|
135
|
+
let totalWall = null;
|
|
136
|
+
let totalInner = null;
|
|
137
|
+
for (const child of children) {
|
|
138
|
+
const brushes = getRoofSegmentBrushes(child);
|
|
139
|
+
if (!brushes)
|
|
140
|
+
continue;
|
|
141
|
+
_matrix.compose(_position.set(child.position[0], child.position[1], child.position[2]), _quaternion.setFromAxisAngle(_yAxis, child.rotation), _scale);
|
|
142
|
+
const applyTransform = (brush) => {
|
|
143
|
+
brush.geometry.applyMatrix4(_matrix);
|
|
144
|
+
brush.updateMatrixWorld();
|
|
145
|
+
};
|
|
146
|
+
applyTransform(brushes.shinSlab);
|
|
147
|
+
applyTransform(brushes.deckSlab);
|
|
148
|
+
applyTransform(brushes.wallBrush);
|
|
149
|
+
applyTransform(brushes.innerBrush);
|
|
150
|
+
if (totalShinSlab) {
|
|
151
|
+
const next = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION);
|
|
152
|
+
totalShinSlab.geometry.dispose();
|
|
153
|
+
brushes.shinSlab.geometry.dispose();
|
|
154
|
+
totalShinSlab = next;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
totalShinSlab = brushes.shinSlab;
|
|
158
|
+
}
|
|
159
|
+
if (totalDeckSlab) {
|
|
160
|
+
const next = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION);
|
|
161
|
+
totalDeckSlab.geometry.dispose();
|
|
162
|
+
brushes.deckSlab.geometry.dispose();
|
|
163
|
+
totalDeckSlab = next;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
totalDeckSlab = brushes.deckSlab;
|
|
167
|
+
}
|
|
168
|
+
if (totalWall) {
|
|
169
|
+
const next = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION);
|
|
170
|
+
totalWall.geometry.dispose();
|
|
171
|
+
brushes.wallBrush.geometry.dispose();
|
|
172
|
+
totalWall = next;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
totalWall = brushes.wallBrush;
|
|
176
|
+
}
|
|
177
|
+
if (totalInner) {
|
|
178
|
+
const next = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION);
|
|
179
|
+
totalInner.geometry.dispose();
|
|
180
|
+
brushes.innerBrush.geometry.dispose();
|
|
181
|
+
totalInner = next;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
totalInner = brushes.innerBrush;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (totalShinSlab && totalDeckSlab && totalWall && totalInner) {
|
|
188
|
+
try {
|
|
189
|
+
const finalShinTrimmed = csgEvaluator.evaluate(totalShinSlab, totalInner, SUBTRACTION);
|
|
190
|
+
const finalDeckTrimmed = csgEvaluator.evaluate(totalDeckSlab, totalInner, SUBTRACTION);
|
|
191
|
+
const finalWallTrimmed = csgEvaluator.evaluate(totalWall, totalInner, SUBTRACTION);
|
|
192
|
+
const shinDeck = csgEvaluator.evaluate(finalShinTrimmed, finalDeckTrimmed, ADDITION);
|
|
193
|
+
const combined = csgEvaluator.evaluate(shinDeck, finalWallTrimmed, ADDITION);
|
|
194
|
+
const resultGeo = combined.geometry;
|
|
195
|
+
const resultMaterials = Array.isArray(combined.material)
|
|
196
|
+
? combined.material
|
|
197
|
+
: [combined.material];
|
|
198
|
+
const matToIndex = new Map([
|
|
199
|
+
[dummyMats[0], 0],
|
|
200
|
+
[dummyMats[1], 1],
|
|
201
|
+
[dummyMats[2], 2],
|
|
202
|
+
[dummyMats[3], 3],
|
|
203
|
+
]);
|
|
204
|
+
for (const g of resultGeo.groups) {
|
|
205
|
+
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex);
|
|
206
|
+
}
|
|
207
|
+
resultGeo.computeVertexNormals();
|
|
208
|
+
mergedMesh.geometry.dispose();
|
|
209
|
+
mergedMesh.geometry = resultGeo;
|
|
210
|
+
finalShinTrimmed.geometry.dispose();
|
|
211
|
+
finalDeckTrimmed.geometry.dispose();
|
|
212
|
+
finalWallTrimmed.geometry.dispose();
|
|
213
|
+
shinDeck.geometry.dispose();
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
console.error('Merged roof CSG failed:', e);
|
|
217
|
+
}
|
|
218
|
+
totalShinSlab.geometry.dispose();
|
|
219
|
+
totalDeckSlab.geometry.dispose();
|
|
220
|
+
totalWall.geometry.dispose();
|
|
221
|
+
totalInner.geometry.dispose();
|
|
62
222
|
}
|
|
63
|
-
const phi = Math.atan2(rise, run);
|
|
64
|
-
const shift = Math.asin(T / R);
|
|
65
|
-
return phi - shift;
|
|
66
223
|
}
|
|
224
|
+
const dummyMats = [
|
|
225
|
+
new THREE.MeshBasicMaterial(),
|
|
226
|
+
new THREE.MeshBasicMaterial(),
|
|
227
|
+
new THREE.MeshBasicMaterial(),
|
|
228
|
+
new THREE.MeshBasicMaterial(),
|
|
229
|
+
];
|
|
230
|
+
const ROOF_MATERIAL_SLOT_COUNT = 4;
|
|
231
|
+
function mapRoofGroupMaterialIndex(groupMaterialIndex, csgMaterials, matToIndex) {
|
|
232
|
+
if (groupMaterialIndex === undefined)
|
|
233
|
+
return 0;
|
|
234
|
+
const sourceMaterial = csgMaterials[groupMaterialIndex];
|
|
235
|
+
const mappedIndex = sourceMaterial ? matToIndex.get(sourceMaterial) : undefined;
|
|
236
|
+
return mappedIndex ?? 0;
|
|
237
|
+
}
|
|
238
|
+
function normalizeRoofMaterialIndex(materialIndex) {
|
|
239
|
+
if (materialIndex === undefined || !Number.isFinite(materialIndex))
|
|
240
|
+
return 0;
|
|
241
|
+
const normalized = Math.trunc(materialIndex);
|
|
242
|
+
if (normalized < 0 || normalized >= ROOF_MATERIAL_SLOT_COUNT)
|
|
243
|
+
return 0;
|
|
244
|
+
return normalized;
|
|
245
|
+
}
|
|
246
|
+
const SHINGLE_SURFACE_EPSILON = 0.02;
|
|
247
|
+
const RAKE_FACE_NORMAL_EPSILON = 0.3;
|
|
248
|
+
const RAKE_FACE_ALIGNMENT_EPSILON = 0.35;
|
|
67
249
|
/**
|
|
68
|
-
*
|
|
250
|
+
* Generate complete hollow-shell geometry for a roof segment.
|
|
251
|
+
* Ports the prototype's CSG approach using three-bvh-csg.
|
|
69
252
|
*/
|
|
70
|
-
function
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
253
|
+
export function getRoofSegmentBrushes(node) {
|
|
254
|
+
const { roofType, width, depth, wallHeight, roofHeight, wallThickness, deckThickness, overhang, shingleThickness, } = node;
|
|
255
|
+
const activeRh = roofType === 'flat' ? 0 : roofHeight;
|
|
256
|
+
let run = Math.min(width, depth) / 2;
|
|
257
|
+
let rise = activeRh;
|
|
258
|
+
if (roofType === 'shed') {
|
|
259
|
+
run = depth;
|
|
260
|
+
}
|
|
261
|
+
if (roofType === 'gable') {
|
|
262
|
+
run = depth / 2;
|
|
263
|
+
}
|
|
264
|
+
if (roofType === 'gambrel') {
|
|
265
|
+
run = depth / 4;
|
|
266
|
+
rise = activeRh * 0.6;
|
|
267
|
+
}
|
|
268
|
+
if (roofType === 'mansard') {
|
|
269
|
+
run = Math.min(width, depth) * 0.15;
|
|
270
|
+
rise = activeRh * 0.7;
|
|
271
|
+
}
|
|
272
|
+
if (roofType === 'dutch') {
|
|
273
|
+
run = Math.min(width, depth) * 0.25;
|
|
274
|
+
rise = activeRh * 0.5;
|
|
275
|
+
}
|
|
276
|
+
const tanTheta = run > 0 ? rise / run : 0;
|
|
277
|
+
const cosTheta = Math.cos(Math.atan2(rise, run)) || 1;
|
|
278
|
+
const sinTheta = Math.sin(Math.atan2(rise, run)) || 0;
|
|
279
|
+
const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness;
|
|
280
|
+
const baseI = Math.min(width, depth) * 0.25;
|
|
281
|
+
const getVol = (wExt, vOffset, baseY, matIndex, isVoid) => {
|
|
282
|
+
const wV = Math.max(0.01, width + 2 * wExt);
|
|
283
|
+
const dV = Math.max(0.01, depth + 2 * wExt);
|
|
284
|
+
const autoDrop = wExt * tanTheta;
|
|
285
|
+
const whV = wallHeight - autoDrop + vOffset;
|
|
286
|
+
let rhV = activeRh;
|
|
287
|
+
if (activeRh > 0) {
|
|
288
|
+
rhV = activeRh + autoDrop;
|
|
289
|
+
if (roofType === 'shed')
|
|
290
|
+
rhV = activeRh + 2 * autoDrop;
|
|
291
|
+
}
|
|
292
|
+
const safeBaseY = Math.min(baseY, whV - 0.05);
|
|
293
|
+
let structuralI = baseI;
|
|
294
|
+
if (isVoid) {
|
|
295
|
+
structuralI += deckThickness;
|
|
296
|
+
}
|
|
297
|
+
const faces = getModuleFaces(roofType, wV, dV, whV, rhV, safeBaseY, { dutchI: structuralI }, width, depth, tanTheta);
|
|
298
|
+
return createGeometryFromFaces(faces, matIndex);
|
|
299
|
+
};
|
|
300
|
+
const wallGeo = getVol(wallThickness / 2, 0, 0, 0, false);
|
|
301
|
+
const innerGeo = getVol(-wallThickness / 2, 0, -5, 2, false);
|
|
302
|
+
const horizontalOverhang = overhang * cosTheta;
|
|
303
|
+
const deckExt = wallThickness / 2 + horizontalOverhang;
|
|
304
|
+
const deckTopGeo = getVol(deckExt, verticalRt, 0, 1, false);
|
|
305
|
+
const deckBotGeo = getVol(deckExt, 0, -5, 0, true);
|
|
306
|
+
const stSin = shingleThickness * sinTheta;
|
|
307
|
+
const stCos = shingleThickness * cosTheta;
|
|
308
|
+
const shinBotW = Math.max(0.01, width + 2 * deckExt);
|
|
309
|
+
const shinBotD = Math.max(0.01, depth + 2 * deckExt);
|
|
310
|
+
const deckDrop = deckExt * tanTheta;
|
|
311
|
+
const shinBotWh = wallHeight - deckDrop + verticalRt;
|
|
312
|
+
let shinBotRh = activeRh;
|
|
313
|
+
if (activeRh > 0) {
|
|
314
|
+
shinBotRh = activeRh + deckDrop;
|
|
315
|
+
if (roofType === 'shed')
|
|
316
|
+
shinBotRh = activeRh + 2 * deckDrop;
|
|
317
|
+
}
|
|
318
|
+
let shinTopW = shinBotW;
|
|
319
|
+
let shinTopD = shinBotD;
|
|
320
|
+
let transZ = 0;
|
|
321
|
+
if (['hip', 'mansard', 'dutch'].includes(roofType)) {
|
|
322
|
+
shinTopW += 2 * stSin;
|
|
323
|
+
shinTopD += 2 * stSin;
|
|
324
|
+
}
|
|
325
|
+
else if (['gable', 'gambrel'].includes(roofType)) {
|
|
326
|
+
shinTopD += 2 * stSin;
|
|
327
|
+
}
|
|
328
|
+
else if (roofType === 'shed') {
|
|
329
|
+
shinTopD += stSin;
|
|
330
|
+
transZ = stSin / 2;
|
|
331
|
+
}
|
|
332
|
+
const shinTopWh = shinBotWh + stCos;
|
|
333
|
+
let shinTopRh = shinBotRh;
|
|
334
|
+
if (activeRh > 0) {
|
|
335
|
+
shinTopRh = shinBotRh + stSin * tanTheta;
|
|
336
|
+
}
|
|
337
|
+
const availableR = (Math.min(shinBotW, shinBotD) / 2) * 0.95;
|
|
338
|
+
const maxDrop = tanTheta > 0.001 ? availableR / tanTheta : 2.0;
|
|
339
|
+
const dropTop = Math.min(1.0, maxDrop * 0.4);
|
|
340
|
+
const dropBot = Math.min(2.0, maxDrop * 0.8);
|
|
341
|
+
const topBaseY = shinBotWh - dropTop;
|
|
342
|
+
const botBaseY = shinBotWh - dropBot;
|
|
343
|
+
const getInsets = (wh, bY, isVoid, brushW, brushD) => {
|
|
344
|
+
let inset = (wh - bY) * tanTheta;
|
|
345
|
+
const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005;
|
|
346
|
+
if (inset > maxSafeInset) {
|
|
347
|
+
inset = maxSafeInset;
|
|
348
|
+
}
|
|
349
|
+
let iF = 0, iB = 0, iL = 0, iR = 0;
|
|
350
|
+
if (['hip', 'mansard', 'dutch'].includes(roofType)) {
|
|
351
|
+
iF = inset;
|
|
352
|
+
iB = inset;
|
|
353
|
+
iL = inset;
|
|
354
|
+
iR = inset;
|
|
355
|
+
}
|
|
356
|
+
else if (['gable', 'gambrel'].includes(roofType)) {
|
|
357
|
+
iF = inset;
|
|
358
|
+
iB = inset;
|
|
359
|
+
}
|
|
360
|
+
else if (roofType === 'shed') {
|
|
361
|
+
iF = inset;
|
|
362
|
+
}
|
|
363
|
+
let structuralI = baseI;
|
|
364
|
+
if (isVoid) {
|
|
365
|
+
structuralI += shingleThickness;
|
|
366
|
+
}
|
|
367
|
+
return { iF, iB, iL, iR, dutchI: structuralI };
|
|
368
|
+
};
|
|
369
|
+
const insetsBot = getInsets(shinBotWh, botBaseY, true, shinBotW, shinBotD);
|
|
370
|
+
const insetsTop = getInsets(shinTopWh, topBaseY, false, shinTopW, shinTopD);
|
|
371
|
+
const botFaces = getModuleFaces(roofType, shinBotW, shinBotD, shinBotWh, shinBotRh, botBaseY, insetsBot, width, depth, tanTheta);
|
|
372
|
+
const topFaces = getModuleFaces(roofType, shinTopW, shinTopD, shinTopWh, shinTopRh, topBaseY, insetsTop, width, depth, tanTheta);
|
|
373
|
+
const shinBotGeo = createGeometryFromFaces(botFaces, 1);
|
|
374
|
+
const shinTopGeo = createGeometryFromFaces(topFaces, (normal) => normal.y > SHINGLE_SURFACE_EPSILON ? 3 : 1);
|
|
375
|
+
if (transZ !== 0) {
|
|
376
|
+
shinTopGeo.translate(0, 0, transZ);
|
|
377
|
+
}
|
|
378
|
+
const toBrush = (geo) => {
|
|
379
|
+
if (!geo?.attributes.position || geo.attributes.position.count === 0)
|
|
380
|
+
return null;
|
|
381
|
+
if (!geo.index)
|
|
382
|
+
return null;
|
|
383
|
+
geo.computeBoundsTree = computeBoundsTree;
|
|
384
|
+
geo.computeBoundsTree({ maxLeafSize: 10 });
|
|
385
|
+
const brush = new Brush(geo, dummyMats);
|
|
386
|
+
brush.updateMatrixWorld();
|
|
387
|
+
return brush;
|
|
388
|
+
};
|
|
389
|
+
const eps = 0.002;
|
|
390
|
+
const wallBrush = toBrush(wallGeo);
|
|
391
|
+
const innerBrush = toBrush(innerGeo);
|
|
392
|
+
if (innerBrush) {
|
|
393
|
+
const wV = Math.max(0.01, width - wallThickness);
|
|
394
|
+
const dV = Math.max(0.01, depth - wallThickness);
|
|
395
|
+
innerBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV);
|
|
396
|
+
innerBrush.updateMatrixWorld();
|
|
397
|
+
}
|
|
398
|
+
const deckTopBrush = toBrush(deckTopGeo);
|
|
399
|
+
const deckBotBrush = toBrush(deckBotGeo);
|
|
400
|
+
if (deckBotBrush) {
|
|
401
|
+
const wV = Math.max(0.01, width + 2 * deckExt);
|
|
402
|
+
const dV = Math.max(0.01, depth + 2 * deckExt);
|
|
403
|
+
deckBotBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV);
|
|
404
|
+
deckBotBrush.updateMatrixWorld();
|
|
405
|
+
}
|
|
406
|
+
const shinTopBrush = toBrush(shinTopGeo);
|
|
407
|
+
const shinBotBrush = toBrush(shinBotGeo);
|
|
408
|
+
if (shinBotBrush) {
|
|
409
|
+
const wV = shinBotW;
|
|
410
|
+
const dV = shinBotD;
|
|
411
|
+
shinBotBrush.scale.set(1 + eps / wV, 1, 1 + eps / dV);
|
|
412
|
+
shinBotBrush.updateMatrixWorld();
|
|
413
|
+
}
|
|
414
|
+
wallGeo.dispose();
|
|
415
|
+
innerGeo.dispose();
|
|
416
|
+
deckTopGeo.dispose();
|
|
417
|
+
deckBotGeo.dispose();
|
|
418
|
+
shinTopGeo.dispose();
|
|
419
|
+
shinBotGeo.dispose();
|
|
420
|
+
if (deckTopBrush && deckBotBrush && wallBrush && innerBrush && shinTopBrush && shinBotBrush) {
|
|
421
|
+
try {
|
|
422
|
+
const deckSlab = csgEvaluator.evaluate(deckTopBrush, deckBotBrush, SUBTRACTION);
|
|
423
|
+
const shinSlab = csgEvaluator.evaluate(shinTopBrush, shinBotBrush, SUBTRACTION);
|
|
424
|
+
deckTopBrush.geometry.dispose();
|
|
425
|
+
deckBotBrush.geometry.dispose();
|
|
426
|
+
shinTopBrush.geometry.dispose();
|
|
427
|
+
shinBotBrush.geometry.dispose();
|
|
428
|
+
return { deckSlab, shinSlab, wallBrush, innerBrush };
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
console.error('CSG prep failed:', e);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (deckTopBrush)
|
|
435
|
+
deckTopBrush.geometry.dispose();
|
|
436
|
+
if (deckBotBrush)
|
|
437
|
+
deckBotBrush.geometry.dispose();
|
|
438
|
+
if (shinTopBrush)
|
|
439
|
+
shinTopBrush.geometry.dispose();
|
|
440
|
+
if (shinBotBrush)
|
|
441
|
+
shinBotBrush.geometry.dispose();
|
|
442
|
+
if (wallBrush)
|
|
443
|
+
wallBrush.geometry.dispose();
|
|
444
|
+
if (innerBrush)
|
|
445
|
+
innerBrush.geometry.dispose();
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
export function generateRoofSegmentGeometry(node) {
|
|
449
|
+
const brushes = getRoofSegmentBrushes(node);
|
|
450
|
+
if (!brushes) {
|
|
451
|
+
// Fallback: simple box
|
|
452
|
+
return new THREE.BoxGeometry(node.width, node.wallHeight, node.depth);
|
|
453
|
+
}
|
|
454
|
+
const { deckSlab, shinSlab, wallBrush, innerBrush } = brushes;
|
|
455
|
+
let resultGeo = new THREE.BufferGeometry();
|
|
456
|
+
try {
|
|
457
|
+
const hollowWall = csgEvaluator.evaluate(wallBrush, innerBrush, SUBTRACTION);
|
|
458
|
+
const shinDeck = csgEvaluator.evaluate(shinSlab, deckSlab, ADDITION);
|
|
459
|
+
const combined = csgEvaluator.evaluate(shinDeck, hollowWall, ADDITION);
|
|
460
|
+
resultGeo = combined.geometry;
|
|
461
|
+
const resultMaterials = Array.isArray(combined.material)
|
|
462
|
+
? combined.material
|
|
463
|
+
: [combined.material];
|
|
464
|
+
const matToIndex = new Map([
|
|
465
|
+
[dummyMats[0], 0],
|
|
466
|
+
[dummyMats[1], 1],
|
|
467
|
+
[dummyMats[2], 2],
|
|
468
|
+
[dummyMats[3], 3],
|
|
469
|
+
]);
|
|
470
|
+
for (const group of resultGeo.groups) {
|
|
471
|
+
group.materialIndex = mapRoofGroupMaterialIndex(group.materialIndex, resultMaterials, matToIndex);
|
|
472
|
+
}
|
|
473
|
+
remapRoofShellFaces(resultGeo, node);
|
|
474
|
+
hollowWall.geometry.dispose();
|
|
475
|
+
shinDeck.geometry.dispose();
|
|
476
|
+
}
|
|
477
|
+
catch (e) {
|
|
478
|
+
console.error('Roof CSG failed:', e);
|
|
479
|
+
resultGeo = wallBrush.geometry.clone();
|
|
480
|
+
}
|
|
481
|
+
deckSlab.geometry.dispose();
|
|
482
|
+
shinSlab.geometry.dispose();
|
|
483
|
+
wallBrush.geometry.dispose();
|
|
484
|
+
innerBrush.geometry.dispose();
|
|
485
|
+
resultGeo.computeVertexNormals();
|
|
486
|
+
return resultGeo;
|
|
487
|
+
}
|
|
488
|
+
function remapRoofShellFaces(geometry, node) {
|
|
489
|
+
const position = geometry.getAttribute('position');
|
|
490
|
+
const index = geometry.getIndex();
|
|
491
|
+
if (!(position && index) || index.count === 0 || geometry.groups.length === 0)
|
|
492
|
+
return;
|
|
493
|
+
geometry.computeBoundingBox();
|
|
494
|
+
const triangleCount = index.count / 3;
|
|
495
|
+
const triangleMaterials = new Array(triangleCount).fill(0);
|
|
496
|
+
const a = new THREE.Vector3();
|
|
497
|
+
const b = new THREE.Vector3();
|
|
498
|
+
const c = new THREE.Vector3();
|
|
499
|
+
const ab = new THREE.Vector3();
|
|
500
|
+
const ac = new THREE.Vector3();
|
|
501
|
+
const centroid = new THREE.Vector3();
|
|
502
|
+
const normal = new THREE.Vector3();
|
|
503
|
+
for (const group of geometry.groups) {
|
|
504
|
+
const startTriangle = Math.floor(group.start / 3);
|
|
505
|
+
const endTriangle = Math.min(triangleCount, Math.floor((group.start + group.count) / 3));
|
|
506
|
+
for (let triangleIndex = startTriangle; triangleIndex < endTriangle; triangleIndex++) {
|
|
507
|
+
const indexOffset = triangleIndex * 3;
|
|
508
|
+
let materialIndex = normalizeRoofMaterialIndex(group.materialIndex);
|
|
509
|
+
if (materialIndex === 1 || materialIndex === 3) {
|
|
510
|
+
const ia = index.getX(indexOffset);
|
|
511
|
+
const ib = index.getX(indexOffset + 1);
|
|
512
|
+
const ic = index.getX(indexOffset + 2);
|
|
513
|
+
a.fromBufferAttribute(position, ia);
|
|
514
|
+
b.fromBufferAttribute(position, ib);
|
|
515
|
+
c.fromBufferAttribute(position, ic);
|
|
516
|
+
ab.subVectors(b, a);
|
|
517
|
+
ac.subVectors(c, a);
|
|
518
|
+
normal.crossVectors(ab, ac).normalize();
|
|
519
|
+
centroid
|
|
520
|
+
.copy(a)
|
|
521
|
+
.add(b)
|
|
522
|
+
.add(c)
|
|
523
|
+
.multiplyScalar(1 / 3);
|
|
524
|
+
if (normal.y > SHINGLE_SURFACE_EPSILON) {
|
|
525
|
+
materialIndex = 3;
|
|
526
|
+
}
|
|
527
|
+
else if (isRakeFace(node, geometry, centroid, normal)) {
|
|
528
|
+
materialIndex = 0;
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
materialIndex = 1;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
triangleMaterials[triangleIndex] = materialIndex;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
geometry.clearGroups();
|
|
538
|
+
let currentMaterial = triangleMaterials[0] ?? 0;
|
|
539
|
+
let groupStart = 0;
|
|
540
|
+
for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex++) {
|
|
541
|
+
const materialIndex = triangleMaterials[triangleIndex] ?? 0;
|
|
542
|
+
if (materialIndex === currentMaterial)
|
|
543
|
+
continue;
|
|
544
|
+
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial);
|
|
545
|
+
groupStart = triangleIndex;
|
|
546
|
+
currentMaterial = materialIndex;
|
|
547
|
+
}
|
|
548
|
+
geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial);
|
|
549
|
+
}
|
|
550
|
+
function isRakeFace(node, geometry, centroid, normal) {
|
|
551
|
+
const rakeAxis = getRakeAxis(node);
|
|
552
|
+
const bounds = geometry.boundingBox;
|
|
553
|
+
if (!(rakeAxis && bounds))
|
|
554
|
+
return false;
|
|
555
|
+
if (Math.abs(normal.y) > RAKE_FACE_NORMAL_EPSILON)
|
|
556
|
+
return false;
|
|
557
|
+
const axisNormal = rakeAxis === 'x' ? Math.abs(normal.x) : Math.abs(normal.z);
|
|
558
|
+
if (axisNormal < RAKE_FACE_ALIGNMENT_EPSILON)
|
|
559
|
+
return false;
|
|
560
|
+
const halfExtent = rakeAxis === 'x'
|
|
561
|
+
? Math.max(Math.abs(bounds.min.x), Math.abs(bounds.max.x))
|
|
562
|
+
: Math.max(Math.abs(bounds.min.z), Math.abs(bounds.max.z));
|
|
563
|
+
const axisCoord = rakeAxis === 'x' ? Math.abs(centroid.x) : Math.abs(centroid.z);
|
|
564
|
+
const planeTolerance = Math.max(node.overhang + node.wallThickness + node.deckThickness + node.shingleThickness, 0.25);
|
|
565
|
+
if (halfExtent - axisCoord > planeTolerance)
|
|
566
|
+
return false;
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
function getRakeAxis(node) {
|
|
570
|
+
if (node.roofType === 'gable' || node.roofType === 'gambrel')
|
|
571
|
+
return 'x';
|
|
572
|
+
if (node.roofType === 'dutch')
|
|
573
|
+
return node.width >= node.depth ? 'x' : 'z';
|
|
574
|
+
return null;
|
|
86
575
|
}
|
|
87
576
|
/**
|
|
88
|
-
*
|
|
577
|
+
* Generates faces for a roof module volume.
|
|
578
|
+
* Supports: hip, gable, shed, gambrel, dutch, mansard, flat.
|
|
89
579
|
*/
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
{
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
580
|
+
function getModuleFaces(type, w, d, wh, rh, baseY, insets, baseW, baseD, tanTheta) {
|
|
581
|
+
const v = (x, y, z) => new THREE.Vector3(x, y, z);
|
|
582
|
+
const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets;
|
|
583
|
+
const b1 = v(-w / 2 + iL, baseY, d / 2 - iF);
|
|
584
|
+
const b2 = v(w / 2 - iR, baseY, d / 2 - iF);
|
|
585
|
+
const b3 = v(w / 2 - iR, baseY, -d / 2 + iB);
|
|
586
|
+
const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB);
|
|
587
|
+
const bottom = [b4, b3, b2, b1];
|
|
588
|
+
const e1 = v(-w / 2, wh, d / 2);
|
|
589
|
+
const e2 = v(w / 2, wh, d / 2);
|
|
590
|
+
const e3 = v(w / 2, wh, -d / 2);
|
|
591
|
+
const e4 = v(-w / 2, wh, -d / 2);
|
|
592
|
+
const faces = [];
|
|
593
|
+
faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom);
|
|
594
|
+
const h = wh + Math.max(0.001, rh);
|
|
595
|
+
if (type === 'flat' || rh === 0) {
|
|
596
|
+
faces.push([e1, e2, e3, e4]);
|
|
597
|
+
}
|
|
598
|
+
else if (type === 'gable') {
|
|
599
|
+
const r1 = v(-w / 2, h, 0);
|
|
600
|
+
const r2 = v(w / 2, h, 0);
|
|
601
|
+
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]);
|
|
602
|
+
}
|
|
603
|
+
else if (type === 'hip') {
|
|
604
|
+
if (Math.abs(w - d) < 0.01) {
|
|
605
|
+
const r = v(0, h, 0);
|
|
606
|
+
faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r]);
|
|
607
|
+
}
|
|
608
|
+
else if (w >= d) {
|
|
609
|
+
const r1 = v(-w / 2 + d / 2, h, 0);
|
|
610
|
+
const r2 = v(w / 2 - d / 2, h, 0);
|
|
611
|
+
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
const r1 = v(0, h, d / 2 - w / 2);
|
|
615
|
+
const r2 = v(0, h, -d / 2 + w / 2);
|
|
616
|
+
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
else if (type === 'shed') {
|
|
620
|
+
const t1 = v(-w / 2, h, -d / 2);
|
|
621
|
+
const t2 = v(w / 2, h, -d / 2);
|
|
622
|
+
faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1]);
|
|
623
|
+
}
|
|
624
|
+
else if (type === 'gambrel') {
|
|
625
|
+
const mz = (baseD / 2) * 0.5;
|
|
626
|
+
const dist = d / 2 - mz;
|
|
627
|
+
const mh = wh + dist * (tanTheta || 0);
|
|
628
|
+
const m1 = v(-w / 2, mh, mz);
|
|
629
|
+
const m2 = v(w / 2, mh, mz);
|
|
630
|
+
const m3 = v(w / 2, mh, -mz);
|
|
631
|
+
const m4 = v(-w / 2, mh, -mz);
|
|
632
|
+
const r1 = v(-w / 2, h, 0);
|
|
633
|
+
const r2 = v(w / 2, h, 0);
|
|
634
|
+
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]);
|
|
635
|
+
}
|
|
636
|
+
else if (type === 'mansard') {
|
|
637
|
+
const i = Math.min(baseW, baseD) * 0.15;
|
|
638
|
+
const mh = wh + i * (tanTheta || 0);
|
|
639
|
+
const m1 = v(-w / 2 + i, mh, d / 2 - i);
|
|
640
|
+
const m2 = v(w / 2 - i, mh, d / 2 - i);
|
|
641
|
+
const m3 = v(w / 2 - i, mh, -d / 2 + i);
|
|
642
|
+
const m4 = v(-w / 2 + i, mh, -d / 2 + i);
|
|
643
|
+
const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2);
|
|
644
|
+
const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2);
|
|
645
|
+
const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2);
|
|
646
|
+
const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2);
|
|
647
|
+
if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) {
|
|
648
|
+
if (w >= d) {
|
|
649
|
+
const r1 = v(-w / 2 + d / 2, h, 0);
|
|
650
|
+
const r2 = v(w / 2 - d / 2, h, 0);
|
|
651
|
+
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2]);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
const r1 = v(0, h, d / 2 - w / 2);
|
|
655
|
+
const r2 = v(0, h, -d / 2 + w / 2);
|
|
656
|
+
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2]);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
faces.push([t1, t2, t3, t4], [e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4], [m1, m2, t2, t1], [m2, m3, t3, t2], [m3, m4, t4, t3], [m4, m1, t1, t4]);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
else if (type === 'dutch') {
|
|
664
|
+
const i = insets.dutchI !== undefined ? insets.dutchI : Math.min(baseW, baseD) * 0.25;
|
|
665
|
+
const mh = wh + i * (tanTheta || 0);
|
|
666
|
+
if (w >= d) {
|
|
667
|
+
const m1 = v(-w / 2 + i, mh, d / 2 - i);
|
|
668
|
+
const m2 = v(w / 2 - i, mh, d / 2 - i);
|
|
669
|
+
const m3 = v(w / 2 - i, mh, -d / 2 + i);
|
|
670
|
+
const m4 = v(-w / 2 + i, mh, -d / 2 + i);
|
|
671
|
+
const r1 = v(-w / 2 + i, h, 0);
|
|
672
|
+
const r2 = v(w / 2 - i, h, 0);
|
|
673
|
+
faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4], [m4, m1, r1], [m2, m3, r2], [m1, m2, r2, r1], [m3, m4, r1, r2]);
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
const m1 = v(-w / 2 + i, mh, d / 2 - i);
|
|
677
|
+
const m2 = v(w / 2 - i, mh, d / 2 - i);
|
|
678
|
+
const m3 = v(w / 2 - i, mh, -d / 2 + i);
|
|
679
|
+
const m4 = v(-w / 2 + i, mh, -d / 2 + i);
|
|
680
|
+
const r1 = v(0, h, d / 2 - i);
|
|
681
|
+
const r2 = v(0, h, -d / 2 + i);
|
|
682
|
+
faces.push([e1, e2, m2, m1], [e2, e3, m3, m2], [e3, e4, m4, m3], [e4, e1, m1, m4], [m1, m2, r1], [m3, m4, r2], [m2, m3, r2, r1], [m4, m1, r1, r2]);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return faces;
|
|
152
686
|
}
|
|
153
687
|
/**
|
|
154
|
-
*
|
|
688
|
+
* Converts an array of face polygons into a BufferGeometry.
|
|
689
|
+
* Each face is triangulated via fan triangulation.
|
|
155
690
|
*/
|
|
156
|
-
|
|
157
|
-
const { length, height, leftWidth, rightWidth } = roofNode;
|
|
158
|
-
const ridgeLength = length;
|
|
159
|
-
// Get profiles for both sides
|
|
160
|
-
const leftP = getSideProfile(1, leftWidth, height);
|
|
161
|
-
const rightP = getSideProfile(-1, rightWidth, height);
|
|
162
|
-
// Create shapes from profiles
|
|
163
|
-
const shapes = {
|
|
164
|
-
ALeft: createShape(leftP.pointsA),
|
|
165
|
-
ARight: createShape(rightP.pointsA),
|
|
166
|
-
BLeft: createShape(leftP.pointsB),
|
|
167
|
-
BRight: createShape(rightP.pointsB),
|
|
168
|
-
SideLeft: createShape(leftP.pointsSide),
|
|
169
|
-
SideRight: createShape(rightP.pointsSide),
|
|
170
|
-
C1Left: createShape(leftP.pointsC1),
|
|
171
|
-
C1Right: createShape(rightP.pointsC1),
|
|
172
|
-
C2Left: createShape(leftP.pointsC2),
|
|
173
|
-
C2Right: createShape(rightP.pointsC2),
|
|
174
|
-
};
|
|
175
|
-
// Calculate extrusion lengths and offsets
|
|
176
|
-
const lengths = {
|
|
177
|
-
A: ridgeLength + 2 * RAKE_OVERHANG + 2 * ROOF_COVER_OVERHANG + WALL_THICKNESS,
|
|
178
|
-
B: ridgeLength + 2 * RAKE_OVERHANG + WALL_THICKNESS,
|
|
179
|
-
Side: ridgeLength + WALL_THICKNESS,
|
|
180
|
-
Gable: WALL_THICKNESS,
|
|
181
|
-
};
|
|
182
|
-
const offsets = {
|
|
183
|
-
A: -RAKE_OVERHANG - ROOF_COVER_OVERHANG - WALL_THICKNESS / 2,
|
|
184
|
-
B: -RAKE_OVERHANG - WALL_THICKNESS / 2,
|
|
185
|
-
Side: -WALL_THICKNESS / 2,
|
|
186
|
-
GableFront: -WALL_THICKNESS / 2,
|
|
187
|
-
GableBack: ridgeLength - WALL_THICKNESS / 2,
|
|
188
|
-
};
|
|
189
|
-
// Helper to create and position extruded geometry
|
|
190
|
-
const createPart = (shape, depth, xOffset) => {
|
|
191
|
-
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
|
|
192
|
-
// Rotate to align: extrusion goes along X axis
|
|
193
|
-
geo.rotateY(Math.PI / 2);
|
|
194
|
-
geo.translate(xOffset, 0, 0);
|
|
195
|
-
return geo;
|
|
196
|
-
};
|
|
197
|
-
// Create all parts
|
|
198
|
-
const geometries = [];
|
|
199
|
-
// Layer A (Cover) - both sides
|
|
200
|
-
geometries.push(createPart(shapes.ALeft, lengths.A, offsets.A));
|
|
201
|
-
geometries.push(createPart(shapes.ARight, lengths.A, offsets.A));
|
|
202
|
-
// Layer B (Structure) - both sides
|
|
203
|
-
geometries.push(createPart(shapes.BLeft, lengths.B, offsets.B));
|
|
204
|
-
geometries.push(createPart(shapes.BRight, lengths.B, offsets.B));
|
|
205
|
-
// Side Walls - both sides
|
|
206
|
-
geometries.push(createPart(shapes.SideLeft, lengths.Side, offsets.Side));
|
|
207
|
-
geometries.push(createPart(shapes.SideRight, lengths.Side, offsets.Side));
|
|
208
|
-
// Gable Walls (Front)
|
|
209
|
-
geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableFront));
|
|
210
|
-
geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableFront));
|
|
211
|
-
geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableFront));
|
|
212
|
-
geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableFront));
|
|
213
|
-
// Gable Walls (Back)
|
|
214
|
-
geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableBack));
|
|
215
|
-
geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableBack));
|
|
216
|
-
geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableBack));
|
|
217
|
-
geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableBack));
|
|
218
|
-
// Merge all geometries
|
|
219
|
-
const mergedGeometry = new THREE.BufferGeometry();
|
|
691
|
+
function createGeometryFromFaces(faces, matRule = null) {
|
|
220
692
|
const positions = [];
|
|
221
693
|
const normals = [];
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
694
|
+
const indices = [];
|
|
695
|
+
const groups = [];
|
|
696
|
+
let vertexCount = 0;
|
|
697
|
+
for (const face of faces) {
|
|
698
|
+
if (face.length < 3)
|
|
699
|
+
continue;
|
|
700
|
+
const p0 = face[0];
|
|
701
|
+
const p1 = face[1];
|
|
702
|
+
const p2 = face[2];
|
|
703
|
+
const vA = new THREE.Vector3().subVectors(p1, p0);
|
|
704
|
+
const vB = new THREE.Vector3().subVectors(p2, p0);
|
|
705
|
+
const normal = new THREE.Vector3().crossVectors(vA, vB).normalize();
|
|
706
|
+
let assignedMatIndex = 0;
|
|
707
|
+
if (typeof matRule === 'function') {
|
|
708
|
+
assignedMatIndex = matRule(normal);
|
|
231
709
|
}
|
|
232
|
-
if (
|
|
233
|
-
|
|
234
|
-
normals.push(normAttr.getX(i), normAttr.getY(i), normAttr.getZ(i));
|
|
235
|
-
}
|
|
710
|
+
else if (matRule !== null && matRule !== undefined) {
|
|
711
|
+
assignedMatIndex = matRule;
|
|
236
712
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
713
|
+
else {
|
|
714
|
+
const isVertical = Math.abs(normal.y) < 0.01;
|
|
715
|
+
assignedMatIndex = isVertical ? 0 : 1;
|
|
716
|
+
}
|
|
717
|
+
let faceVertexCount = 0;
|
|
718
|
+
const startVertexCount = vertexCount;
|
|
719
|
+
for (let i = 1; i < face.length - 1; i++) {
|
|
720
|
+
const fi = face[i];
|
|
721
|
+
const fi1 = face[i + 1];
|
|
722
|
+
positions.push(p0.x, p0.y, p0.z);
|
|
723
|
+
positions.push(fi.x, fi.y, fi.z);
|
|
724
|
+
positions.push(fi1.x, fi1.y, fi1.z);
|
|
725
|
+
normals.push(normal.x, normal.y, normal.z);
|
|
726
|
+
normals.push(normal.x, normal.y, normal.z);
|
|
727
|
+
normals.push(normal.x, normal.y, normal.z);
|
|
728
|
+
indices.push(vertexCount, vertexCount + 1, vertexCount + 2);
|
|
729
|
+
faceVertexCount += 3;
|
|
730
|
+
vertexCount += 3;
|
|
241
731
|
}
|
|
242
|
-
|
|
732
|
+
groups.push({
|
|
733
|
+
start: startVertexCount,
|
|
734
|
+
count: faceVertexCount,
|
|
735
|
+
materialIndex: assignedMatIndex,
|
|
736
|
+
});
|
|
243
737
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
738
|
+
const geometry = new THREE.BufferGeometry();
|
|
739
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
740
|
+
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
|
741
|
+
geometry.setIndex(indices);
|
|
742
|
+
for (const g of groups) {
|
|
743
|
+
geometry.addGroup(g.start, g.count, g.materialIndex);
|
|
248
744
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return mergedGeometry;
|
|
745
|
+
// Merge identical vertices to optimize geometry for CSG and create clean topology
|
|
746
|
+
const mergedGeo = mergeVertices(geometry, 1e-4);
|
|
747
|
+
geometry.dispose();
|
|
748
|
+
return mergedGeo;
|
|
254
749
|
}
|