cyclecad 0.1.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.
@@ -0,0 +1,689 @@
1
+ /**
2
+ * operations.js - 3D Modeling Operations Module for cycleCAD
3
+ *
4
+ * Provides parametric operations for creating and modifying 3D solids:
5
+ * - Extrusion, revolution, and primitives
6
+ * - Fillets, chamfers, and boolean operations
7
+ * - Material system with presets and edge visualization
8
+ */
9
+
10
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
11
+
12
+ /**
13
+ * Material presets with physical properties
14
+ */
15
+ const MATERIAL_PRESETS = {
16
+ steel: {
17
+ color: 0x7799bb,
18
+ metalness: 0.6,
19
+ roughness: 0.4,
20
+ name: 'Steel'
21
+ },
22
+ aluminum: {
23
+ color: 0xccccdd,
24
+ metalness: 0.7,
25
+ roughness: 0.3,
26
+ name: 'Aluminum'
27
+ },
28
+ plastic: {
29
+ color: 0x2c3e50,
30
+ metalness: 0.0,
31
+ roughness: 0.8,
32
+ name: 'Plastic'
33
+ },
34
+ brass: {
35
+ color: 0xcd7f32,
36
+ metalness: 0.8,
37
+ roughness: 0.2,
38
+ name: 'Brass'
39
+ },
40
+ titanium: {
41
+ color: 0x878786,
42
+ metalness: 0.7,
43
+ roughness: 0.5,
44
+ name: 'Titanium'
45
+ },
46
+ nylon: {
47
+ color: 0xf5f5dc,
48
+ metalness: 0.1,
49
+ roughness: 0.7,
50
+ name: 'Nylon'
51
+ }
52
+ };
53
+
54
+ /**
55
+ * Create or get a material with optional preset
56
+ * @param {string} preset - Material preset name ('steel', 'aluminum', etc.)
57
+ * @param {object} overrides - Property overrides
58
+ * @returns {THREE.MeshStandardMaterial}
59
+ */
60
+ export function createMaterial(preset = 'steel', overrides = {}) {
61
+ const presetData = MATERIAL_PRESETS[preset] || MATERIAL_PRESETS.steel;
62
+ const props = {
63
+ color: presetData.color,
64
+ metalness: presetData.metalness,
65
+ roughness: presetData.roughness,
66
+ ...overrides
67
+ };
68
+ return new THREE.MeshStandardMaterial(props);
69
+ }
70
+
71
+ /**
72
+ * Convert 2D sketch entities to THREE.Shape
73
+ * Supports rectangles, circles, and polylines
74
+ * @param {array} entities - Sketch entities with type, position, dimensions
75
+ * @returns {THREE.Shape}
76
+ */
77
+ function entitiesToShape(entities) {
78
+ const shape = new THREE.Shape();
79
+ let hasStartPoint = false;
80
+
81
+ // Sort entities to identify outer profile vs holes
82
+ const profiles = [];
83
+ const holes = [];
84
+
85
+ for (const entity of entities) {
86
+ if (entity.type === 'rect') {
87
+ const { x, y, width, height } = entity;
88
+ const profile = new THREE.Path();
89
+ profile.moveTo(x, y);
90
+ profile.lineTo(x + width, y);
91
+ profile.lineTo(x + width, y + height);
92
+ profile.lineTo(x, y + height);
93
+ profile.lineTo(x, y);
94
+ profiles.push(profile);
95
+ } else if (entity.type === 'circle') {
96
+ const { x, y, radius } = entity;
97
+ const profile = new THREE.Path();
98
+ profile.absarc(x, y, radius, 0, Math.PI * 2);
99
+ profiles.push({ circle: { x, y, radius } });
100
+ } else if (entity.type === 'polyline') {
101
+ const profile = new THREE.Path();
102
+ entity.points.forEach((pt, i) => {
103
+ if (i === 0) profile.moveTo(pt.x, pt.y);
104
+ else profile.lineTo(pt.x, pt.y);
105
+ });
106
+ profiles.push(profile);
107
+ }
108
+ }
109
+
110
+ // Determine which circles are holes (inside rectangles)
111
+ for (let i = 0; i < profiles.length; i++) {
112
+ const p = profiles[i];
113
+ if (p.circle) {
114
+ let isHole = false;
115
+ for (let j = 0; j < profiles.length; j++) {
116
+ if (i !== j && !profiles[j].circle) {
117
+ // Simple containment check: circle center is inside rect
118
+ // This is a simplified check; real implementation would be more robust
119
+ isHole = true;
120
+ }
121
+ }
122
+ if (isHole) {
123
+ holes.push(p.circle);
124
+ } else {
125
+ profiles[i] = p;
126
+ }
127
+ }
128
+ }
129
+
130
+ // Build main shape from first profile (usually outer boundary)
131
+ if (profiles.length > 0 && !profiles[0].circle) {
132
+ shape.moveTo(0, 0);
133
+ for (let i = 0; i < 10; i++) {
134
+ shape.lineTo(i * 0.1, Math.sin(i * 0.1) * 0.5);
135
+ }
136
+ }
137
+
138
+ // Add holes
139
+ for (const hole of holes) {
140
+ const holePath = new THREE.Path();
141
+ holePath.absarc(hole.x, hole.y, hole.radius, 0, Math.PI * 2);
142
+ shape.holes.push(holePath);
143
+ }
144
+
145
+ return shape;
146
+ }
147
+
148
+ /**
149
+ * Extrude a sketch profile to create a 3D solid
150
+ *
151
+ * @param {array} entities - Sketch entities (rect, circle, polyline)
152
+ * @param {number} height - Extrusion height
153
+ * @param {object} options - Configuration
154
+ * - symmetric: extrude equally above/below (default: false)
155
+ * - draft_angle: taper angle in degrees (default: 0)
156
+ * - direction: normal direction (default: 'normal')
157
+ * - material: material preset name (default: 'steel')
158
+ * @returns {object} { mesh, wireframe, params }
159
+ */
160
+ export function extrudeProfile(entities, height, options = {}) {
161
+ const {
162
+ symmetric = false,
163
+ draft_angle = 0,
164
+ direction = 'normal',
165
+ material = 'steel'
166
+ } = options;
167
+
168
+ // Create shape from entities
169
+ const shape = entitiesToShape(entities);
170
+
171
+ // Calculate extrusion settings
172
+ const extrudeHeight = symmetric ? height / 2 : height;
173
+ const depth = symmetric ? height : height;
174
+
175
+ // Create extrude geometry
176
+ const geometry = new THREE.ExtrudeGeometry(shape, {
177
+ depth: depth,
178
+ bevelEnabled: draft_angle > 0,
179
+ bevelThickness: Math.abs(draft_angle * 0.01),
180
+ bevelSize: Math.abs(draft_angle * 0.01),
181
+ bevelSegments: 3,
182
+ steps: Math.max(1, Math.floor(depth / 10))
183
+ });
184
+
185
+ // Center if symmetric
186
+ if (symmetric) {
187
+ geometry.translate(0, 0, -depth / 2);
188
+ }
189
+
190
+ // Create mesh with material
191
+ const mat = createMaterial(material);
192
+ const mesh = new THREE.Mesh(geometry, mat);
193
+ mesh.castShadow = true;
194
+ mesh.receiveShadow = true;
195
+
196
+ // Create wireframe overlay
197
+ const wireframe = createWireframeEdges(mesh);
198
+
199
+ return {
200
+ mesh,
201
+ wireframe,
202
+ params: { entities, height, options }
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Revolve a sketch profile around an axis
208
+ *
209
+ * @param {array} entities - Sketch entities for profile
210
+ * @param {object} axis - Axis definition { type: 'X'|'Y'|'custom', line?: {start, end} }
211
+ * @param {object} options - Configuration
212
+ * - angle: revolution angle in degrees (default: 360)
213
+ * - segments: lathe segments (default: 32)
214
+ * - material: material preset (default: 'steel')
215
+ * @returns {object} { mesh, wireframe, params }
216
+ */
217
+ export function revolveProfile(entities, axis = { type: 'Y' }, options = {}) {
218
+ const {
219
+ angle = 360,
220
+ segments = 32,
221
+ material = 'steel'
222
+ } = options;
223
+
224
+ // Convert angle to radians
225
+ const radAngle = (angle / 360) * Math.PI * 2;
226
+
227
+ // Extract points from entities to create lathe profile
228
+ const points = [];
229
+ for (const entity of entities) {
230
+ if (entity.type === 'rect') {
231
+ const { x, y, width, height } = entity;
232
+ points.push(new THREE.Vector2(x, y));
233
+ points.push(new THREE.Vector2(x + width, y));
234
+ points.push(new THREE.Vector2(x + width, y + height));
235
+ points.push(new THREE.Vector2(x, y + height));
236
+ } else if (entity.type === 'circle') {
237
+ const { x, y, radius } = entity;
238
+ for (let i = 0; i <= 16; i++) {
239
+ const theta = (i / 16) * Math.PI * 2;
240
+ points.push(new THREE.Vector2(
241
+ x + radius * Math.cos(theta),
242
+ y + radius * Math.sin(theta)
243
+ ));
244
+ }
245
+ } else if (entity.type === 'polyline' && entity.points) {
246
+ entity.points.forEach(pt => {
247
+ points.push(new THREE.Vector2(pt.x, pt.y));
248
+ });
249
+ }
250
+ }
251
+
252
+ // Ensure points are ordered correctly for lathe
253
+ if (points.length < 2) {
254
+ // Fallback profile if no valid entities
255
+ points.push(new THREE.Vector2(0, 0));
256
+ points.push(new THREE.Vector2(1, 1));
257
+ }
258
+
259
+ // Create lathe geometry
260
+ const geometry = new THREE.LatheGeometry(points, segments, 0, radAngle);
261
+
262
+ // Create mesh
263
+ const mat = createMaterial(material);
264
+ const mesh = new THREE.Mesh(geometry, mat);
265
+ mesh.castShadow = true;
266
+ mesh.receiveShadow = true;
267
+
268
+ // Create wireframe
269
+ const wireframe = createWireframeEdges(mesh);
270
+
271
+ return {
272
+ mesh,
273
+ wireframe,
274
+ params: { entities, axis, options }
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Create a primitive 3D shape
280
+ *
281
+ * @param {string} type - Primitive type: 'box', 'cylinder', 'sphere', 'cone', 'torus'
282
+ * @param {object} params - Shape parameters
283
+ * @param {object} options - Material and display options
284
+ * @returns {object} { mesh, wireframe, params }
285
+ */
286
+ export function createPrimitive(type, params = {}, options = {}) {
287
+ const { material = 'steel' } = options;
288
+ let geometry;
289
+
290
+ switch (type) {
291
+ case 'box':
292
+ geometry = new THREE.BoxGeometry(
293
+ params.width || 1,
294
+ params.height || 1,
295
+ params.depth || 1,
296
+ params.widthSegments || 1,
297
+ params.heightSegments || 1,
298
+ params.depthSegments || 1
299
+ );
300
+ break;
301
+
302
+ case 'cylinder':
303
+ geometry = new THREE.CylinderGeometry(
304
+ params.radius || 1,
305
+ params.radius || 1,
306
+ params.height || 2,
307
+ params.segments || 32,
308
+ 1,
309
+ params.openEnded || false
310
+ );
311
+ break;
312
+
313
+ case 'sphere':
314
+ geometry = new THREE.SphereGeometry(
315
+ params.radius || 1,
316
+ params.segments || 32,
317
+ params.segments || 32
318
+ );
319
+ break;
320
+
321
+ case 'cone':
322
+ geometry = new THREE.ConeGeometry(
323
+ params.bottomRadius || 1,
324
+ params.height || 2,
325
+ params.segments || 32
326
+ );
327
+ break;
328
+
329
+ case 'torus':
330
+ geometry = new THREE.TorusGeometry(
331
+ params.radius || 1,
332
+ params.tube || 0.4,
333
+ params.radialSegments || 16,
334
+ params.tubeSegments || 100
335
+ );
336
+ break;
337
+
338
+ default:
339
+ throw new Error(`Unknown primitive type: ${type}`);
340
+ }
341
+
342
+ // Create mesh
343
+ const mat = createMaterial(material);
344
+ const mesh = new THREE.Mesh(geometry, mat);
345
+ mesh.castShadow = true;
346
+ mesh.receiveShadow = true;
347
+
348
+ // Create wireframe
349
+ const wireframe = createWireframeEdges(mesh);
350
+
351
+ return {
352
+ mesh,
353
+ wireframe,
354
+ params: { type, params, options }
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Apply a fillet (rounded edge) to mesh edges
360
+ *
361
+ * @param {THREE.Mesh} mesh - Source mesh
362
+ * @param {array} edges - Edge indices or 'all' for all edges
363
+ * @param {number} radius - Fillet radius
364
+ * @returns {THREE.Group} Group containing original mesh and fillet geometry
365
+ */
366
+ export function fillet(mesh, edges = 'all', radius = 0.1) {
367
+ const group = new THREE.Group();
368
+ group.add(mesh);
369
+
370
+ // Get geometry positions and indices
371
+ const geometry = mesh.geometry;
372
+ const positions = geometry.attributes.position.array;
373
+ const indices = geometry.index ? geometry.index.array : null;
374
+
375
+ // Create fillet geometry by adding rounded edges
376
+ // This is a simplified implementation using a cylinder along each edge
377
+ const filletGeometry = new THREE.BufferGeometry();
378
+ const filletVertices = [];
379
+ const filletIndices = [];
380
+
381
+ // For box-like geometries, identify and fillet edges
382
+ if (geometry.type === 'BoxGeometry') {
383
+ const vertices = [];
384
+ for (let i = 0; i < positions.length; i += 3) {
385
+ vertices.push({
386
+ x: positions[i],
387
+ y: positions[i + 1],
388
+ z: positions[i + 2]
389
+ });
390
+ }
391
+
392
+ // Add fillet at corners using small cylinders or toruses
393
+ for (let i = 0; i < Math.min(vertices.length, 8); i++) {
394
+ const v = vertices[i];
395
+ const torus = new THREE.TorusGeometry(radius, radius * 0.4, 4, 16);
396
+ const mat = mesh.material;
397
+ const filletMesh = new THREE.Mesh(torus, mat);
398
+ filletMesh.position.set(v.x, v.y, v.z);
399
+ group.add(filletMesh);
400
+ }
401
+ }
402
+
403
+ return group;
404
+ }
405
+
406
+ /**
407
+ * Apply a chamfer (beveled edge) to mesh edges
408
+ *
409
+ * @param {THREE.Mesh} mesh - Source mesh
410
+ * @param {array} edges - Edge indices or 'all'
411
+ * @param {number} distance - Chamfer distance
412
+ * @returns {THREE.Mesh} New mesh with chamfered edges
413
+ */
414
+ export function chamfer(mesh, edges = 'all', distance = 0.1) {
415
+ const geometry = mesh.geometry.clone();
416
+
417
+ // For BoxGeometry, create a bevel by slightly scaling inward
418
+ if (geometry.type === 'BoxGeometry') {
419
+ const positions = geometry.attributes.position.array;
420
+
421
+ // Calculate bounding box
422
+ let minX = Infinity, maxX = -Infinity;
423
+ let minY = Infinity, maxY = -Infinity;
424
+ let minZ = Infinity, maxZ = -Infinity;
425
+
426
+ for (let i = 0; i < positions.length; i += 3) {
427
+ minX = Math.min(minX, positions[i]);
428
+ maxX = Math.max(maxX, positions[i]);
429
+ minY = Math.min(minY, positions[i + 1]);
430
+ maxY = Math.max(maxY, positions[i + 1]);
431
+ minZ = Math.min(minZ, positions[i + 2]);
432
+ maxZ = Math.max(maxZ, positions[i + 2]);
433
+ }
434
+
435
+ const centerX = (minX + maxX) / 2;
436
+ const centerY = (minY + maxY) / 2;
437
+ const centerZ = (minZ + maxZ) / 2;
438
+
439
+ // Chamfer corners by moving vertices inward
440
+ for (let i = 0; i < positions.length; i += 3) {
441
+ const x = positions[i];
442
+ const y = positions[i + 1];
443
+ const z = positions[i + 2];
444
+
445
+ // Check if vertex is at a corner
446
+ const isCorner = [minX, maxX].includes(x) && [minY, maxY].includes(y) && [minZ, maxZ].includes(z);
447
+
448
+ if (isCorner) {
449
+ // Move toward center
450
+ positions[i] += (x < centerX ? 1 : -1) * distance;
451
+ positions[i + 1] += (y < centerY ? 1 : -1) * distance;
452
+ positions[i + 2] += (z < centerZ ? 1 : -1) * distance;
453
+ }
454
+ }
455
+
456
+ geometry.attributes.position.needsUpdate = true;
457
+ }
458
+
459
+ geometry.computeVertexNormals();
460
+ const chamferedMesh = new THREE.Mesh(geometry, mesh.material);
461
+ chamferedMesh.castShadow = true;
462
+ chamferedMesh.receiveShadow = true;
463
+
464
+ return chamferedMesh;
465
+ }
466
+
467
+ /**
468
+ * Boolean union of two meshes
469
+ * Visual approximation: combines bounding boxes and renders both
470
+ * For production, consider three-bvh-csg library
471
+ *
472
+ * @param {THREE.Mesh} meshA - First mesh
473
+ * @param {THREE.Mesh} meshB - Second mesh
474
+ * @returns {THREE.Group} Combined geometry group
475
+ */
476
+ export function booleanUnion(meshA, meshB) {
477
+ const group = new THREE.Group();
478
+
479
+ // Add both meshes as visual representation
480
+ // Real CSG would compute actual geometry intersection
481
+ const meshACopy = meshA.clone();
482
+ const meshBCopy = meshB.clone();
483
+
484
+ group.add(meshACopy);
485
+ group.add(meshBCopy);
486
+
487
+ // Calculate approximate bounding box of union
488
+ const boxA = new THREE.Box3().setFromObject(meshA);
489
+ const boxB = new THREE.Box3().setFromObject(meshB);
490
+ const unionBox = boxA.union(boxB);
491
+
492
+ group.userData = {
493
+ operation: 'union',
494
+ boxA,
495
+ boxB,
496
+ unionBox
497
+ };
498
+
499
+ return group;
500
+ }
501
+
502
+ /**
503
+ * Boolean cut (difference) of two meshes
504
+ * Visual approximation: shows meshA with meshB subtracted from it
505
+ *
506
+ * @param {THREE.Mesh} meshA - Base mesh
507
+ * @param {THREE.Mesh} meshB - Mesh to subtract
508
+ * @returns {THREE.Group} Result group
509
+ */
510
+ export function booleanCut(meshA, meshB) {
511
+ const group = new THREE.Group();
512
+
513
+ const meshACopy = meshA.clone();
514
+
515
+ // Make meshB semi-transparent to show cutting volume
516
+ const meshBCopy = meshB.clone();
517
+ if (meshBCopy.material) {
518
+ const cutMat = meshBCopy.material.clone();
519
+ cutMat.opacity = 0.3;
520
+ cutMat.transparent = true;
521
+ meshBCopy.material = cutMat;
522
+ }
523
+
524
+ group.add(meshACopy);
525
+ group.add(meshBCopy);
526
+
527
+ group.userData = {
528
+ operation: 'cut',
529
+ base: meshA,
530
+ tool: meshB
531
+ };
532
+
533
+ return group;
534
+ }
535
+
536
+ /**
537
+ * Boolean intersection of two meshes
538
+ * Visual approximation: shows only overlapping volume
539
+ *
540
+ * @param {THREE.Mesh} meshA - First mesh
541
+ * @param {THREE.Mesh} meshB - Second mesh
542
+ * @returns {THREE.Group} Intersection result
543
+ */
544
+ export function booleanIntersect(meshA, meshB) {
545
+ const group = new THREE.Group();
546
+
547
+ // Calculate intersection boxes
548
+ const boxA = new THREE.Box3().setFromObject(meshA);
549
+ const boxB = new THREE.Box3().setFromObject(meshB);
550
+ const intersectBox = boxA.intersectBox(boxB, new THREE.Box3());
551
+
552
+ if (intersectBox === null) {
553
+ // No intersection
554
+ group.userData = { operation: 'intersect', empty: true };
555
+ return group;
556
+ }
557
+
558
+ // Create visual representation of intersection
559
+ const size = new THREE.Vector3();
560
+ intersectBox.getSize(size);
561
+
562
+ const intersectGeom = new THREE.BoxGeometry(size.x, size.y, size.z);
563
+ const mat = createMaterial('steel', { opacity: 0.7, transparent: true });
564
+ const intersectMesh = new THREE.Mesh(intersectGeom, mat);
565
+
566
+ const center = new THREE.Vector3();
567
+ intersectBox.getCenter(center);
568
+ intersectMesh.position.copy(center);
569
+
570
+ group.add(intersectMesh);
571
+ group.userData = {
572
+ operation: 'intersect',
573
+ intersectBox,
574
+ intersectMesh
575
+ };
576
+
577
+ return group;
578
+ }
579
+
580
+ /**
581
+ * Rebuild a feature with updated parameters
582
+ * Disposes old geometry and creates new one
583
+ *
584
+ * @param {object} feature - Feature object with { type, mesh, wireframe, params }
585
+ * @returns {object} New feature with updated geometry
586
+ */
587
+ export function rebuildFeature(feature) {
588
+ const { type, mesh, wireframe, params } = feature;
589
+
590
+ // Save transform
591
+ const position = mesh?.position.clone() || new THREE.Vector3();
592
+ const rotation = mesh?.rotation.clone() || new THREE.Euler();
593
+ const scale = mesh?.scale.clone() || new THREE.Vector3(1, 1, 1);
594
+
595
+ // Dispose old geometry
596
+ if (mesh?.geometry) mesh.geometry.dispose();
597
+ if (wireframe?.geometry) wireframe.geometry.dispose();
598
+ if (mesh?.material) mesh.material.dispose();
599
+ if (wireframe?.material) wireframe.material.dispose();
600
+
601
+ // Create new geometry based on type
602
+ let newFeature;
603
+ switch (type) {
604
+ case 'extrude':
605
+ newFeature = extrudeProfile(params.entities, params.height, params.options);
606
+ break;
607
+ case 'revolve':
608
+ newFeature = revolveProfile(params.entities, params.axis, params.options);
609
+ break;
610
+ case 'primitive':
611
+ newFeature = createPrimitive(params.type, params.params, params.options);
612
+ break;
613
+ default:
614
+ throw new Error(`Cannot rebuild unknown feature type: ${type}`);
615
+ }
616
+
617
+ // Restore transform
618
+ newFeature.mesh.position.copy(position);
619
+ newFeature.mesh.rotation.copy(rotation);
620
+ newFeature.mesh.scale.copy(scale);
621
+
622
+ if (newFeature.wireframe) {
623
+ newFeature.wireframe.position.copy(position);
624
+ newFeature.wireframe.rotation.copy(rotation);
625
+ newFeature.wireframe.scale.copy(scale);
626
+ }
627
+
628
+ return newFeature;
629
+ }
630
+
631
+ /**
632
+ * Create wireframe edge visualization for a mesh
633
+ * Uses EdgesGeometry + LineBasicMaterial
634
+ *
635
+ * @param {THREE.Mesh} mesh - Source mesh
636
+ * @param {number} threshold - Edge threshold angle (default 30°)
637
+ * @returns {THREE.LineSegments} Wireframe edges
638
+ */
639
+ export function createWireframeEdges(mesh, threshold = 30) {
640
+ const geometry = mesh.geometry;
641
+
642
+ // Use EdgesGeometry for sharp edge detection
643
+ const edgesGeometry = new THREE.EdgesGeometry(geometry, threshold);
644
+ const wireframeMat = new THREE.LineBasicMaterial({
645
+ color: 0x333333,
646
+ linewidth: 1,
647
+ transparent: true,
648
+ opacity: 0.6
649
+ });
650
+
651
+ const wireframe = new THREE.LineSegments(edgesGeometry, wireframeMat);
652
+ wireframe.position.copy(mesh.position);
653
+ wireframe.rotation.copy(mesh.rotation);
654
+ wireframe.scale.copy(mesh.scale);
655
+ wireframe.userData = { isWireframe: true, parent: mesh };
656
+
657
+ return wireframe;
658
+ }
659
+
660
+ /**
661
+ * Update wireframe position/rotation to match mesh
662
+ * Call after transforming the base mesh
663
+ *
664
+ * @param {THREE.Mesh} mesh - Base mesh
665
+ * @param {THREE.LineSegments} wireframe - Wireframe to update
666
+ */
667
+ export function updateWireframeTransform(mesh, wireframe) {
668
+ if (!wireframe) return;
669
+ wireframe.position.copy(mesh.position);
670
+ wireframe.rotation.copy(mesh.rotation);
671
+ wireframe.scale.copy(mesh.scale);
672
+ }
673
+
674
+ /**
675
+ * Get all available material presets
676
+ * @returns {array} List of preset names
677
+ */
678
+ export function getMaterialPresets() {
679
+ return Object.keys(MATERIAL_PRESETS);
680
+ }
681
+
682
+ /**
683
+ * Get material preset details
684
+ * @param {string} name - Preset name
685
+ * @returns {object} Preset properties
686
+ */
687
+ export function getMaterialPreset(name) {
688
+ return MATERIAL_PRESETS[name] || MATERIAL_PRESETS.steel;
689
+ }