@pascal-app/core 0.1.13 → 0.2.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.
Files changed (71) hide show
  1. package/dist/events/bus.d.ts +14 -2
  2. package/dist/events/bus.d.ts.map +1 -1
  3. package/dist/hooks/scene-registry/scene-registry.d.ts +5 -1
  4. package/dist/hooks/scene-registry/scene-registry.d.ts.map +1 -1
  5. package/dist/hooks/scene-registry/scene-registry.js +10 -1
  6. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts +8 -8
  7. package/dist/hooks/spatial-grid/spatial-grid-manager.d.ts.map +1 -1
  8. package/dist/hooks/spatial-grid/spatial-grid-manager.js +88 -36
  9. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts +1 -1
  10. package/dist/hooks/spatial-grid/spatial-grid-sync.d.ts.map +1 -1
  11. package/dist/hooks/spatial-grid/spatial-grid-sync.js +16 -8
  12. package/dist/hooks/spatial-grid/spatial-grid.d.ts +3 -3
  13. package/dist/hooks/spatial-grid/spatial-grid.d.ts.map +1 -1
  14. package/dist/hooks/spatial-grid/spatial-grid.js +2 -2
  15. package/dist/hooks/spatial-grid/use-spatial-query.d.ts.map +1 -1
  16. package/dist/hooks/spatial-grid/wall-spatial-grid.d.ts +2 -2
  17. package/dist/hooks/spatial-grid/wall-spatial-grid.d.ts.map +1 -1
  18. package/dist/hooks/spatial-grid/wall-spatial-grid.js +2 -2
  19. package/dist/index.d.ts +4 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -1
  22. package/dist/lib/space-detection.d.ts.map +1 -1
  23. package/dist/lib/space-detection.js +1 -1
  24. package/dist/schema/collections.d.ts +11 -0
  25. package/dist/schema/collections.d.ts.map +1 -0
  26. package/dist/schema/collections.js +2 -0
  27. package/dist/schema/index.d.ts +11 -8
  28. package/dist/schema/index.d.ts.map +1 -1
  29. package/dist/schema/index.js +11 -7
  30. package/dist/schema/nodes/door.d.ts +78 -0
  31. package/dist/schema/nodes/door.d.ts.map +1 -0
  32. package/dist/schema/nodes/door.js +67 -0
  33. package/dist/schema/nodes/item.d.ts +234 -0
  34. package/dist/schema/nodes/item.d.ts.map +1 -1
  35. package/dist/schema/nodes/item.js +65 -1
  36. package/dist/schema/nodes/level.d.ts.map +1 -1
  37. package/dist/schema/nodes/level.js +11 -1
  38. package/dist/schema/nodes/roof-segment.d.ts +51 -0
  39. package/dist/schema/nodes/roof-segment.d.ts.map +1 -0
  40. package/dist/schema/nodes/roof-segment.js +36 -0
  41. package/dist/schema/nodes/roof.d.ts +1 -4
  42. package/dist/schema/nodes/roof.d.ts.map +1 -1
  43. package/dist/schema/nodes/roof.js +9 -16
  44. package/dist/schema/nodes/site.d.ts +46 -0
  45. package/dist/schema/nodes/site.d.ts.map +1 -1
  46. package/dist/schema/types.d.ts +191 -4
  47. package/dist/schema/types.d.ts.map +1 -1
  48. package/dist/schema/types.js +4 -0
  49. package/dist/store/actions/node-actions.d.ts.map +1 -1
  50. package/dist/store/actions/node-actions.js +23 -4
  51. package/dist/store/use-interactive.d.ts +18 -0
  52. package/dist/store/use-interactive.d.ts.map +1 -0
  53. package/dist/store/use-interactive.js +50 -0
  54. package/dist/store/use-scene.d.ts +10 -1
  55. package/dist/store/use-scene.d.ts.map +1 -1
  56. package/dist/store/use-scene.js +180 -57
  57. package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
  58. package/dist/systems/ceiling/ceiling-system.js +5 -0
  59. package/dist/systems/door/door-system.d.ts +2 -0
  60. package/dist/systems/door/door-system.d.ts.map +1 -0
  61. package/dist/systems/door/door-system.js +211 -0
  62. package/dist/systems/item/item-system.js +3 -2
  63. package/dist/systems/roof/roof-system.d.ts +11 -3
  64. package/dist/systems/roof/roof-system.d.ts.map +1 -1
  65. package/dist/systems/roof/roof-system.js +705 -210
  66. package/dist/systems/slab/slab-system.js +3 -3
  67. package/dist/systems/wall/wall-mitering.js +2 -2
  68. package/dist/systems/wall/wall-system.d.ts.map +1 -1
  69. package/dist/systems/wall/wall-system.js +6 -6
  70. package/dist/systems/window/window-system.js +3 -3
  71. 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
- // ROOF GEOMETRY CONSTANTS
7
- // ============================================================================
8
- const THICKNESS_A = 0.05; // Roof cover thickness (5cm)
9
- const THICKNESS_B = 0.1; // Structure thickness (10cm)
10
- const ROOF_COVER_OVERHANG = 0.05; // Extension of cover past structure (5cm)
11
- const EAVE_OVERHANG = 0.4; // Horizontal eave overhang (40cm)
12
- const RAKE_OVERHANG = 0.3; // Overhang at gable ends (30cm)
13
- const WALL_THICKNESS = 0.2; // Gable wall thickness (20cm)
14
- const BASE_HEIGHT = 0.5; // Base height / knee wall / truss heel (50cm)
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
- if (dirtyNodes.size === 0)
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 roofs
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 || node.type !== 'roof')
41
+ if (!node)
29
42
  return;
30
- const mesh = sceneRegistry.nodes.get(id);
31
- if (mesh) {
32
- updateRoofGeometry(node, mesh);
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
- * Updates the geometry and transform for a single roof
42
- */
43
- function updateRoofGeometry(node, mesh) {
44
- const newGeo = generateRoofGeometry(node);
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
- // Update position and rotation
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
- * Helper to solve pitch angle analytically given rise, run and thicknesses
53
- * Solves: run * tan(a) + (ThickA + ThickB)/cos(a) = rise
54
- */
55
- function solvePitch(rise, run, thickA, thickB) {
56
- const T = thickA + thickB;
57
- if (run < 0.01)
58
- return 0;
59
- const R = Math.sqrt(run * run + rise * rise);
60
- if (R <= T) {
61
- return Math.atan2(rise, run) * 0.5; // Fallback
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
- * Helper to create a Three.js Shape from polygon points
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 createShape(points) {
71
- const shape = new THREE.Shape();
72
- if (points.length === 0)
73
- return shape;
74
- const firstPoint = points[0];
75
- if (!firstPoint)
76
- return shape;
77
- shape.moveTo(firstPoint.x, firstPoint.y);
78
- for (let i = 1; i < points.length; i++) {
79
- const point = points[i];
80
- if (point) {
81
- shape.lineTo(point.x, point.y);
82
- }
83
- }
84
- shape.closePath();
85
- return shape;
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
- * Generate profile for one side of the roof (left or right)
577
+ * Generates faces for a roof module volume.
578
+ * Supports: hip, gable, shed, gambrel, dutch, mansard, flat.
89
579
  */
90
- function getSideProfile(dir, width, roofHeight) {
91
- const halfWall = WALL_THICKNESS / 2;
92
- const rise = Math.max(0, roofHeight - BASE_HEIGHT);
93
- const run = width - halfWall;
94
- const angle = solvePitch(rise, run, THICKNESS_A, THICKNESS_B);
95
- const tanA = Math.tan(angle);
96
- const cosA = Math.cos(angle);
97
- const sinA = Math.sin(angle);
98
- const ridgeUnderY = BASE_HEIGHT + run * tanA;
99
- const ridgeInterfaceY = ridgeUnderY + THICKNESS_B / cosA;
100
- const ridgeTopY = ridgeInterfaceY + THICKNESS_A / cosA;
101
- const wallOuterTopY = BASE_HEIGHT - WALL_THICKNESS * tanA;
102
- const overhangDx = EAVE_OVERHANG * cosA;
103
- const eaveTopZ = width + halfWall + overhangDx;
104
- const eaveTopY = ridgeTopY - eaveTopZ * tanA;
105
- const coverExtDx = ROOF_COVER_OVERHANG * cosA;
106
- const coverExtDy = ROOF_COVER_OVERHANG * sinA;
107
- const eaveTopExtZ = eaveTopZ + coverExtDx;
108
- const eaveTopExtY = eaveTopY - coverExtDy;
109
- const eaveInterfaceExtZ = eaveTopExtZ - THICKNESS_A * sinA;
110
- const eaveInterfaceExtY = eaveTopExtY - THICKNESS_A * cosA;
111
- const eaveInterfaceZ = eaveTopZ;
112
- const eaveBottomZ = eaveTopZ;
113
- const eaveBottomY = ridgeUnderY - eaveTopZ * tanA;
114
- // Layer A (Cover)
115
- const pointsA = [
116
- { x: 0, y: ridgeTopY },
117
- { x: dir * eaveTopExtZ, y: eaveTopExtY },
118
- { x: dir * eaveInterfaceExtZ, y: eaveInterfaceExtY },
119
- { x: 0, y: ridgeInterfaceY },
120
- ];
121
- // Layer B (Structure)
122
- const pointsB = [
123
- { x: 0, y: ridgeInterfaceY },
124
- { x: dir * eaveInterfaceZ, y: ridgeInterfaceY - eaveTopZ * tanA },
125
- { x: dir * eaveBottomZ, y: eaveBottomY },
126
- { x: 0, y: ridgeUnderY },
127
- ];
128
- // Side Wall
129
- const zInner = width - halfWall;
130
- const zOuter = width + halfWall;
131
- const pointsSide = [
132
- { x: dir * zInner, y: 0 },
133
- { x: dir * zOuter, y: 0 },
134
- { x: dir * zOuter, y: Math.max(0, wallOuterTopY) },
135
- { x: dir * zInner, y: BASE_HEIGHT },
136
- ];
137
- // Gable Top (C1)
138
- const pointsC1 = [
139
- { x: 0, y: BASE_HEIGHT },
140
- { x: dir * zInner, y: BASE_HEIGHT },
141
- { x: dir * zInner, y: BASE_HEIGHT },
142
- { x: 0, y: ridgeUnderY },
143
- ];
144
- // Gable Base (C2)
145
- const pointsC2 = [
146
- { x: 0, y: 0 },
147
- { x: dir * zInner, y: 0 },
148
- { x: dir * zInner, y: BASE_HEIGHT },
149
- { x: 0, y: BASE_HEIGHT },
150
- ];
151
- return { pointsA, pointsB, pointsSide, pointsC1, pointsC2 };
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
- * Generates detailed gable roof geometry with layers, walls, and overhangs
688
+ * Converts an array of face polygons into a BufferGeometry.
689
+ * Each face is triangulated via fan triangulation.
155
690
  */
156
- export function generateRoofGeometry(roofNode) {
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 uvs = [];
223
- for (const geo of geometries) {
224
- const posAttr = geo.getAttribute('position');
225
- const normAttr = geo.getAttribute('normal');
226
- const uvAttr = geo.getAttribute('uv');
227
- if (posAttr) {
228
- for (let i = 0; i < posAttr.count; i++) {
229
- positions.push(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i));
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 (normAttr) {
233
- for (let i = 0; i < normAttr.count; i++) {
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
- if (uvAttr) {
238
- for (let i = 0; i < uvAttr.count; i++) {
239
- uvs.push(uvAttr.getX(i), uvAttr.getY(i));
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
- geo.dispose();
732
+ groups.push({
733
+ start: startVertexCount,
734
+ count: faceVertexCount,
735
+ materialIndex: assignedMatIndex,
736
+ });
243
737
  }
244
- mergedGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
245
- mergedGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
246
- if (uvs.length > 0) {
247
- mergedGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
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
- mergedGeometry.computeVertexNormals();
250
- // Center the geometry at X=0 (translate by -ridgeLength/2)
251
- // This matches the old geometry centering behavior
252
- mergedGeometry.translate(-ridgeLength / 2, 0, 0);
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
  }