cyclecad 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -357,142 +357,215 @@ export function createPrimitive(type, params = {}, options = {}) {
357
357
 
358
358
  /**
359
359
  * Apply a fillet (rounded edge) to mesh edges
360
+ * Creates rounded edges using torus geometry positioned at edge midpoints
360
361
  *
361
362
  * @param {THREE.Mesh} mesh - Source mesh
362
- * @param {array} edges - Edge indices or 'all' for all edges
363
+ * @param {array} edgeIndices - Edge indices (pairs of vertex indices) or 'all' for all edges
363
364
  * @param {number} radius - Fillet radius
364
365
  * @returns {THREE.Group} Group containing original mesh and fillet geometry
365
366
  */
366
- export function fillet(mesh, edges = 'all', radius = 0.1) {
367
+ export function fillet(mesh, edgeIndices = 'all', radius = 0.1) {
367
368
  const group = new THREE.Group();
368
369
  group.add(mesh);
369
370
 
370
- // Get geometry positions and indices
371
+ // Get geometry
371
372
  const geometry = mesh.geometry;
372
- const positions = geometry.attributes.position.array;
373
+ const positionAttr = geometry.attributes.position;
374
+
375
+ if (!positionAttr) return group;
376
+
377
+ const positions = positionAttr.array;
373
378
  const indices = geometry.index ? geometry.index.array : null;
374
379
 
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
+ // Extract unique edges from geometry
381
+ const edges = extractEdgesFromGeometry(positions, indices);
380
382
 
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
- }
383
+ // Filter to requested edges
384
+ let activeEdges = edges;
385
+ if (edgeIndices !== 'all' && Array.isArray(edgeIndices)) {
386
+ activeEdges = edgeIndices.map(idx => edges[idx]).filter(e => e);
387
+ }
391
388
 
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);
389
+ // Create rounded geometry for each edge
390
+ for (const edge of activeEdges) {
391
+ // Calculate edge midpoint and direction
392
+ const p1 = new THREE.Vector3(
393
+ positions[edge.v1 * 3],
394
+ positions[edge.v1 * 3 + 1],
395
+ positions[edge.v1 * 3 + 2]
396
+ );
397
+ const p2 = new THREE.Vector3(
398
+ positions[edge.v2 * 3],
399
+ positions[edge.v2 * 3 + 1],
400
+ positions[edge.v2 * 3 + 2]
401
+ );
402
+
403
+ const midpoint = p1.clone().add(p2).multiplyScalar(0.5);
404
+ const edgeDir = p2.clone().sub(p1).normalize();
405
+
406
+ // Create torus geometry for rounded edge
407
+ const torusGeom = new THREE.TorusGeometry(
408
+ radius,
409
+ radius * 0.25,
410
+ 8,
411
+ 24
412
+ );
413
+
414
+ // Rotate torus to align with edge direction
415
+ const quaternion = new THREE.Quaternion();
416
+ const upVector = new THREE.Vector3(0, 1, 0);
417
+ if (Math.abs(edgeDir.dot(upVector)) > 0.9) {
418
+ upVector.set(1, 0, 0);
400
419
  }
420
+ quaternion.setFromUnitVectors(upVector, edgeDir);
421
+ torusGeom.applyQuaternion(quaternion);
422
+ torusGeom.translate(midpoint.x, midpoint.y, midpoint.z);
423
+
424
+ const torusMesh = new THREE.Mesh(torusGeom, mesh.material.clone());
425
+ group.add(torusMesh);
401
426
  }
402
427
 
428
+ // Store parameters for rebuilding
429
+ group.userData = {
430
+ operation: 'fillet',
431
+ edgeIndices,
432
+ radius,
433
+ source: mesh
434
+ };
435
+
403
436
  return group;
404
437
  }
405
438
 
406
439
  /**
407
440
  * Apply a chamfer (beveled edge) to mesh edges
441
+ * Creates angled cut geometry at selected edges
408
442
  *
409
443
  * @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
444
+ * @param {array} edgeIndices - Edge indices (pairs of vertex indices) or 'all'
445
+ * @param {number} distance - Chamfer distance/size
446
+ * @returns {THREE.Group} Group with original mesh and chamfer geometry
413
447
  */
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
- }
448
+ export function chamfer(mesh, edgeIndices = 'all', distance = 0.1) {
449
+ const group = new THREE.Group();
450
+ group.add(mesh);
434
451
 
435
- const centerX = (minX + maxX) / 2;
436
- const centerY = (minY + maxY) / 2;
437
- const centerZ = (minZ + maxZ) / 2;
452
+ const geometry = mesh.geometry;
453
+ const positionAttr = geometry.attributes.position;
438
454
 
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];
455
+ if (!positionAttr) return group;
444
456
 
445
- // Check if vertex is at a corner
446
- const isCorner = [minX, maxX].includes(x) && [minY, maxY].includes(y) && [minZ, maxZ].includes(z);
457
+ const positions = positionAttr.array;
458
+ const indices = geometry.index ? geometry.index.array : null;
447
459
 
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
- }
460
+ // Extract unique edges
461
+ const edges = extractEdgesFromGeometry(positions, indices);
462
+
463
+ // Filter edges
464
+ let activeEdges = edges;
465
+ if (edgeIndices !== 'all' && Array.isArray(edgeIndices)) {
466
+ activeEdges = edgeIndices.map(idx => edges[idx]).filter(e => e);
467
+ }
468
+
469
+ // Create beveled geometry for each edge
470
+ for (const edge of activeEdges) {
471
+ const p1 = new THREE.Vector3(
472
+ positions[edge.v1 * 3],
473
+ positions[edge.v1 * 3 + 1],
474
+ positions[edge.v1 * 3 + 2]
475
+ );
476
+ const p2 = new THREE.Vector3(
477
+ positions[edge.v2 * 3],
478
+ positions[edge.v2 * 3 + 1],
479
+ positions[edge.v2 * 3 + 2]
480
+ );
481
+
482
+ const edgeDir = p2.clone().sub(p1).normalize();
483
+ const edgeLen = p1.distanceTo(p2);
484
+
485
+ // Create angled cut (cone-like shape)
486
+ const coneGeom = new THREE.ConeGeometry(distance, edgeLen, 4, 2);
487
+
488
+ // Rotate to align with edge
489
+ const quaternion = new THREE.Quaternion();
490
+ const upVector = new THREE.Vector3(0, 1, 0);
491
+ if (Math.abs(edgeDir.dot(upVector)) > 0.9) {
492
+ upVector.set(1, 0, 0);
454
493
  }
494
+ quaternion.setFromUnitVectors(upVector, edgeDir);
495
+ coneGeom.applyQuaternion(quaternion);
496
+
497
+ const midpoint = p1.clone().add(p2).multiplyScalar(0.5);
498
+ coneGeom.translate(midpoint.x, midpoint.y, midpoint.z);
455
499
 
456
- geometry.attributes.position.needsUpdate = true;
500
+ const coneMesh = new THREE.Mesh(coneGeom, mesh.material.clone());
501
+ group.add(coneMesh);
457
502
  }
458
503
 
459
- geometry.computeVertexNormals();
460
- const chamferedMesh = new THREE.Mesh(geometry, mesh.material);
461
- chamferedMesh.castShadow = true;
462
- chamferedMesh.receiveShadow = true;
504
+ // Store parameters
505
+ group.userData = {
506
+ operation: 'chamfer',
507
+ edgeIndices,
508
+ distance,
509
+ source: mesh
510
+ };
463
511
 
464
- return chamferedMesh;
512
+ return group;
465
513
  }
466
514
 
467
515
  /**
468
516
  * Boolean union of two meshes
469
- * Visual approximation: combines bounding boxes and renders both
470
- * For production, consider three-bvh-csg library
517
+ * Merges geometries and combines into single mesh
471
518
  *
472
519
  * @param {THREE.Mesh} meshA - First mesh
473
520
  * @param {THREE.Mesh} meshB - Second mesh
474
- * @returns {THREE.Group} Combined geometry group
521
+ * @returns {THREE.Mesh|THREE.Group} Combined geometry or group with operation metadata
475
522
  */
476
523
  export function booleanUnion(meshA, meshB) {
477
- const group = new THREE.Group();
524
+ try {
525
+ // Clone geometries to avoid modifying originals
526
+ const geomA = meshA.geometry.clone();
527
+ const geomB = meshB.geometry.clone();
528
+
529
+ // Transform geometry B to world space of A
530
+ const matrixB = meshB.matrixWorld;
531
+ geomB.applyMatrix4(matrixB);
532
+
533
+ // Merge geometries using BufferGeometryUtils
534
+ // Fallback: combine as group if merge not available
535
+ const mergedGeom = mergeGeometries([geomA, geomB]);
536
+
537
+ if (mergedGeom) {
538
+ mergedGeom.computeVertexNormals();
539
+ const unionMesh = new THREE.Mesh(mergedGeom, meshA.material.clone());
540
+ unionMesh.castShadow = true;
541
+ unionMesh.receiveShadow = true;
542
+
543
+ unionMesh.userData = {
544
+ operation: 'union',
545
+ source: [meshA, meshB]
546
+ };
547
+
548
+ return unionMesh;
549
+ }
550
+ } catch (e) {
551
+ console.warn('Geometry merge failed, using visual approximation:', e);
552
+ }
478
553
 
479
- // Add both meshes as visual representation
480
- // Real CSG would compute actual geometry intersection
554
+ // Fallback: return combined group with metadata
555
+ const group = new THREE.Group();
481
556
  const meshACopy = meshA.clone();
482
557
  const meshBCopy = meshB.clone();
483
558
 
484
559
  group.add(meshACopy);
485
560
  group.add(meshBCopy);
486
561
 
487
- // Calculate approximate bounding box of union
488
562
  const boxA = new THREE.Box3().setFromObject(meshA);
489
563
  const boxB = new THREE.Box3().setFromObject(meshB);
490
564
  const unionBox = boxA.union(boxB);
491
565
 
492
566
  group.userData = {
493
567
  operation: 'union',
494
- boxA,
495
- boxB,
568
+ source: [meshA, meshB],
496
569
  unionBox
497
570
  };
498
571
 
@@ -501,33 +574,47 @@ export function booleanUnion(meshA, meshB) {
501
574
 
502
575
  /**
503
576
  * Boolean cut (difference) of two meshes
504
- * Visual approximation: shows meshA with meshB subtracted from it
577
+ * Visually approximates by showing base with cutting volume indicated
578
+ * Use cutting geometry (meshB) as reference for intersection visualization
505
579
  *
506
580
  * @param {THREE.Mesh} meshA - Base mesh
507
- * @param {THREE.Mesh} meshB - Mesh to subtract
508
- * @returns {THREE.Group} Result group
581
+ * @param {THREE.Mesh} meshB - Mesh to subtract (cutting tool)
582
+ * @returns {THREE.Group} Result group with base and cut indicator
509
583
  */
510
584
  export function booleanCut(meshA, meshB) {
511
585
  const group = new THREE.Group();
512
586
 
513
587
  const meshACopy = meshA.clone();
588
+ group.add(meshACopy);
514
589
 
515
- // Make meshB semi-transparent to show cutting volume
590
+ // Create visual indicator of cutting volume
516
591
  const meshBCopy = meshB.clone();
517
592
  if (meshBCopy.material) {
518
- const cutMat = meshBCopy.material.clone();
519
- cutMat.opacity = 0.3;
520
- cutMat.transparent = true;
521
- meshBCopy.material = cutMat;
593
+ const cutIndicatorMat = meshBCopy.material.clone();
594
+ cutIndicatorMat.opacity = 0.25;
595
+ cutIndicatorMat.transparent = true;
596
+ cutIndicatorMat.depthWrite = false;
597
+ cutIndicatorMat.side = THREE.DoubleSide;
598
+ meshBCopy.material = cutIndicatorMat;
522
599
  }
523
600
 
524
- group.add(meshACopy);
601
+ // Add wireframe to show cutting geometry clearly
602
+ const wireframe = createWireframeEdges(meshBCopy);
603
+ wireframe.material.color.setHex(0xff6b6b); // Red to indicate cut
525
604
  group.add(meshBCopy);
605
+ group.add(wireframe);
606
+
607
+ // Calculate intersection bounds for reference
608
+ const boxA = new THREE.Box3().setFromObject(meshA);
609
+ const boxB = new THREE.Box3().setFromObject(meshB);
610
+ const intersectBox = new THREE.Box3();
611
+ boxA.intersectBox(boxB, intersectBox);
526
612
 
527
613
  group.userData = {
528
614
  operation: 'cut',
529
615
  base: meshA,
530
- tool: meshB
616
+ tool: meshB,
617
+ intersectBox: intersectBox.isEmpty() ? null : intersectBox
531
618
  };
532
619
 
533
620
  return group;
@@ -535,11 +622,11 @@ export function booleanCut(meshA, meshB) {
535
622
 
536
623
  /**
537
624
  * Boolean intersection of two meshes
538
- * Visual approximation: shows only overlapping volume
625
+ * Visual approximation: shows only overlapping volume bounds
539
626
  *
540
627
  * @param {THREE.Mesh} meshA - First mesh
541
628
  * @param {THREE.Mesh} meshB - Second mesh
542
- * @returns {THREE.Group} Intersection result
629
+ * @returns {THREE.Group} Intersection result with visual representation
543
630
  */
544
631
  export function booleanIntersect(meshA, meshB) {
545
632
  const group = new THREE.Group();
@@ -547,31 +634,43 @@ export function booleanIntersect(meshA, meshB) {
547
634
  // Calculate intersection boxes
548
635
  const boxA = new THREE.Box3().setFromObject(meshA);
549
636
  const boxB = new THREE.Box3().setFromObject(meshB);
550
- const intersectBox = boxA.intersectBox(boxB, new THREE.Box3());
637
+ const intersectBox = new THREE.Box3();
638
+ boxA.intersectBox(boxB, intersectBox);
551
639
 
552
- if (intersectBox === null) {
640
+ if (intersectBox.isEmpty()) {
553
641
  // No intersection
554
642
  group.userData = { operation: 'intersect', empty: true };
555
643
  return group;
556
644
  }
557
645
 
558
- // Create visual representation of intersection
646
+ // Create visual representation of intersection volume
559
647
  const size = new THREE.Vector3();
560
648
  intersectBox.getSize(size);
561
649
 
562
650
  const intersectGeom = new THREE.BoxGeometry(size.x, size.y, size.z);
563
- const mat = createMaterial('steel', { opacity: 0.7, transparent: true });
651
+ const mat = createMaterial('steel', {
652
+ opacity: 0.5,
653
+ transparent: true,
654
+ depthWrite: false,
655
+ side: THREE.DoubleSide
656
+ });
564
657
  const intersectMesh = new THREE.Mesh(intersectGeom, mat);
565
658
 
566
659
  const center = new THREE.Vector3();
567
660
  intersectBox.getCenter(center);
568
661
  intersectMesh.position.copy(center);
569
662
 
663
+ // Add wireframe for clarity
664
+ const wireframe = createWireframeEdges(intersectMesh);
665
+ wireframe.material.color.setHex(0x4ecdc4);
666
+
570
667
  group.add(intersectMesh);
668
+ group.add(wireframe);
669
+
571
670
  group.userData = {
572
671
  operation: 'intersect',
573
672
  intersectBox,
574
- intersectMesh
673
+ source: [meshA, meshB]
575
674
  };
576
675
 
577
676
  return group;
@@ -582,7 +681,7 @@ export function booleanIntersect(meshA, meshB) {
582
681
  * Disposes old geometry and creates new one
583
682
  *
584
683
  * @param {object} feature - Feature object with { type, mesh, wireframe, params }
585
- * @returns {object} New feature with updated geometry
684
+ * @returns {object|THREE.Group} New feature with updated geometry
586
685
  */
587
686
  export function rebuildFeature(feature) {
588
687
  const { type, mesh, wireframe, params } = feature;
@@ -592,11 +691,9 @@ export function rebuildFeature(feature) {
592
691
  const rotation = mesh?.rotation.clone() || new THREE.Euler();
593
692
  const scale = mesh?.scale.clone() || new THREE.Vector3(1, 1, 1);
594
693
 
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();
694
+ // Dispose old geometry and materials
695
+ disposeGeometry(mesh);
696
+ disposeGeometry(wireframe);
600
697
 
601
698
  // Create new geometry based on type
602
699
  let newFeature;
@@ -610,24 +707,313 @@ export function rebuildFeature(feature) {
610
707
  case 'primitive':
611
708
  newFeature = createPrimitive(params.type, params.params, params.options);
612
709
  break;
710
+ case 'shell':
711
+ newFeature = createShell(params.source, params.thickness, params.options);
712
+ break;
713
+ case 'pattern':
714
+ newFeature = createPattern(params.source, params.type, params.count, params.spacing, params.options);
715
+ break;
716
+ case 'fillet':
717
+ newFeature = fillet(params.source, params.edgeIndices, params.radius);
718
+ break;
719
+ case 'chamfer':
720
+ newFeature = chamfer(params.source, params.edgeIndices, params.distance);
721
+ break;
613
722
  default:
614
723
  throw new Error(`Cannot rebuild unknown feature type: ${type}`);
615
724
  }
616
725
 
617
- // Restore transform
618
- newFeature.mesh.position.copy(position);
619
- newFeature.mesh.rotation.copy(rotation);
620
- newFeature.mesh.scale.copy(scale);
726
+ // Restore transform if mesh present
727
+ if (newFeature.mesh) {
728
+ newFeature.mesh.position.copy(position);
729
+ newFeature.mesh.rotation.copy(rotation);
730
+ newFeature.mesh.scale.copy(scale);
621
731
 
622
- if (newFeature.wireframe) {
623
- newFeature.wireframe.position.copy(position);
624
- newFeature.wireframe.rotation.copy(rotation);
625
- newFeature.wireframe.scale.copy(scale);
732
+ if (newFeature.wireframe) {
733
+ newFeature.wireframe.position.copy(position);
734
+ newFeature.wireframe.rotation.copy(rotation);
735
+ newFeature.wireframe.scale.copy(scale);
736
+ }
737
+ } else if (newFeature instanceof THREE.Group) {
738
+ // For group-based features (shell, pattern, boolean, etc.)
739
+ newFeature.position.copy(position);
740
+ newFeature.rotation.copy(rotation);
741
+ newFeature.scale.copy(scale);
626
742
  }
627
743
 
628
744
  return newFeature;
629
745
  }
630
746
 
747
+ /**
748
+ * Create a hollow shell from a mesh
749
+ * Approximation: creates scaled-down inner copy and combines with original
750
+ *
751
+ * @param {THREE.Mesh} mesh - Source mesh
752
+ * @param {number} thickness - Wall thickness
753
+ * @param {object} options - Configuration
754
+ * - material: material preset (default: 'steel')
755
+ * @returns {THREE.Group} Group with shell geometry
756
+ */
757
+ export function createShell(mesh, thickness = 0.1, options = {}) {
758
+ const { material = 'steel' } = options;
759
+ const group = new THREE.Group();
760
+
761
+ // Outer mesh (original)
762
+ const outerMesh = mesh.clone();
763
+ group.add(outerMesh);
764
+
765
+ // Inner mesh (scaled down by thickness ratio)
766
+ const innerMesh = mesh.clone();
767
+ const geometry = innerMesh.geometry.clone();
768
+
769
+ // Calculate scale factor based on bounding box
770
+ const bbox = new THREE.Box3().setFromObject(mesh);
771
+ const size = new THREE.Vector3();
772
+ bbox.getSize(size);
773
+ const maxDim = Math.max(size.x, size.y, size.z);
774
+ const scaleFactor = (maxDim - thickness * 2) / maxDim;
775
+
776
+ geometry.scale(scaleFactor, scaleFactor, scaleFactor);
777
+ geometry.translate(0, 0, 0); // Center scaled geometry
778
+
779
+ // Make inner mesh transparent to show hollowness
780
+ const innerMaterial = createMaterial(material, {
781
+ opacity: 0.3,
782
+ transparent: true,
783
+ side: THREE.BackSide
784
+ });
785
+ innerMesh.geometry = geometry;
786
+ innerMesh.material = innerMaterial;
787
+
788
+ group.add(innerMesh);
789
+
790
+ group.userData = {
791
+ operation: 'shell',
792
+ thickness,
793
+ source: mesh,
794
+ scaleFactor
795
+ };
796
+
797
+ return group;
798
+ }
799
+
800
+ /**
801
+ * Create a rectangular or circular pattern of cloned geometry
802
+ *
803
+ * @param {THREE.Mesh} mesh - Source mesh to pattern
804
+ * @param {string} type - 'rect' for rectangular, 'circular' for polar pattern
805
+ * @param {number} count - Number of copies (total with original)
806
+ * @param {number} spacing - Spacing distance between copies
807
+ * @param {object} options - Configuration
808
+ * - axis: 'X', 'Y', 'Z' for direction (default: 'Z')
809
+ * - material: material preset (default: 'steel')
810
+ * @returns {THREE.Group} Group with all pattern copies
811
+ */
812
+ export function createPattern(mesh, type = 'rect', count = 3, spacing = 1, options = {}) {
813
+ const { axis = 'Z', material = 'steel' } = options;
814
+ const group = new THREE.Group();
815
+
816
+ if (type === 'rect') {
817
+ // Rectangular pattern (1D or 2D array)
818
+ const cols = Math.ceil(Math.sqrt(count));
819
+ const rows = Math.ceil(count / cols);
820
+
821
+ let idx = 0;
822
+ for (let row = 0; row < rows && idx < count; row++) {
823
+ for (let col = 0; col < cols && idx < count; col++) {
824
+ const copy = mesh.clone();
825
+
826
+ // Calculate offset based on axis
827
+ let offsetX = 0, offsetY = 0, offsetZ = 0;
828
+ if (axis === 'X') offsetX = col * spacing - ((cols - 1) * spacing) / 2;
829
+ else if (axis === 'Y') offsetY = row * spacing - ((rows - 1) * spacing) / 2;
830
+ else offsetZ = idx * spacing - ((count - 1) * spacing) / 2;
831
+
832
+ copy.position.add(new THREE.Vector3(offsetX, offsetY, offsetZ));
833
+ group.add(copy);
834
+ idx++;
835
+ }
836
+ }
837
+ } else if (type === 'circular') {
838
+ // Circular pattern around axis
839
+ const angleStep = (Math.PI * 2) / count;
840
+ const radius = spacing;
841
+
842
+ for (let i = 0; i < count; i++) {
843
+ const copy = mesh.clone();
844
+ const angle = i * angleStep;
845
+
846
+ let x = 0, y = 0, z = 0;
847
+ if (axis === 'Z') {
848
+ x = radius * Math.cos(angle);
849
+ y = radius * Math.sin(angle);
850
+ } else if (axis === 'X') {
851
+ y = radius * Math.cos(angle);
852
+ z = radius * Math.sin(angle);
853
+ } else if (axis === 'Y') {
854
+ x = radius * Math.cos(angle);
855
+ z = radius * Math.sin(angle);
856
+ }
857
+
858
+ copy.position.add(new THREE.Vector3(x, y, z));
859
+
860
+ // Rotate to face outward
861
+ const rotAxis = new THREE.Vector3();
862
+ if (axis === 'Z') rotAxis.set(0, 0, 1);
863
+ else if (axis === 'X') rotAxis.set(1, 0, 0);
864
+ else rotAxis.set(0, 1, 0);
865
+
866
+ copy.rotateOnWorldAxis(rotAxis, angle);
867
+ group.add(copy);
868
+ }
869
+ }
870
+
871
+ group.userData = {
872
+ operation: 'pattern',
873
+ type,
874
+ count,
875
+ spacing,
876
+ axis,
877
+ source: mesh
878
+ };
879
+
880
+ return group;
881
+ }
882
+
883
+ /**
884
+ * Extract edges from geometry vertices and indices
885
+ * Returns array of edge objects with v1, v2 vertex indices
886
+ *
887
+ * @param {Float32Array} positions - Position attribute array
888
+ * @param {Uint16Array|Uint32Array} indices - Index array
889
+ * @returns {array} Array of edge objects
890
+ */
891
+ export function extractEdgesFromGeometry(positions, indices) {
892
+ const edges = [];
893
+ const edgeSet = new Set();
894
+
895
+ if (indices) {
896
+ // Use indices to find edges
897
+ for (let i = 0; i < indices.length; i += 3) {
898
+ const a = indices[i];
899
+ const b = indices[i + 1];
900
+ const c = indices[i + 2];
901
+
902
+ // Add three edges of triangle
903
+ addEdge(a, b, edges, edgeSet);
904
+ addEdge(b, c, edges, edgeSet);
905
+ addEdge(c, a, edges, edgeSet);
906
+ }
907
+ } else {
908
+ // Use position array directly (assume triangles)
909
+ for (let i = 0; i < positions.length; i += 9) {
910
+ addEdge(i / 3, (i + 3) / 3, edges, edgeSet);
911
+ addEdge((i + 3) / 3, (i + 6) / 3, edges, edgeSet);
912
+ addEdge((i + 6) / 3, i / 3, edges, edgeSet);
913
+ }
914
+ }
915
+
916
+ return edges;
917
+ }
918
+
919
+ /**
920
+ * Add an edge to the edge list, avoiding duplicates
921
+ *
922
+ * @param {number} v1 - First vertex index
923
+ * @param {number} v2 - Second vertex index
924
+ * @param {array} edges - Edges array
925
+ * @param {Set} edgeSet - Set for deduplication
926
+ */
927
+ function addEdge(v1, v2, edges, edgeSet) {
928
+ const key = v1 < v2 ? `${v1}-${v2}` : `${v2}-${v1}`;
929
+ if (!edgeSet.has(key)) {
930
+ edgeSet.add(key);
931
+ edges.push({ v1: Math.min(v1, v2), v2: Math.max(v1, v2) });
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Merge multiple geometries into a single BufferGeometry
937
+ * Combines vertices and faces from all input geometries
938
+ *
939
+ * @param {array} geometries - Array of THREE.BufferGeometry objects
940
+ * @returns {THREE.BufferGeometry|null} Merged geometry or null if merge fails
941
+ */
942
+ export function mergeGeometries(geometries) {
943
+ if (!geometries || geometries.length === 0) return null;
944
+ if (geometries.length === 1) return geometries[0];
945
+
946
+ try {
947
+ const mergedGeometry = new THREE.BufferGeometry();
948
+ let vertexOffset = 0;
949
+ const vertices = [];
950
+ const indices = [];
951
+ const normals = [];
952
+
953
+ for (const geometry of geometries) {
954
+ const posAttr = geometry.attributes.position;
955
+ if (!posAttr) continue;
956
+
957
+ const positions = posAttr.array;
958
+ const geomIndices = geometry.index?.array || null;
959
+
960
+ // Add vertices
961
+ for (let i = 0; i < positions.length; i += 3) {
962
+ vertices.push(positions[i], positions[i + 1], positions[i + 2]);
963
+ }
964
+
965
+ // Add indices with offset
966
+ if (geomIndices) {
967
+ for (let i = 0; i < geomIndices.length; i++) {
968
+ indices.push(geomIndices[i] + vertexOffset);
969
+ }
970
+ } else {
971
+ for (let i = 0; i < positions.length / 3; i++) {
972
+ indices.push(i + vertexOffset);
973
+ }
974
+ }
975
+
976
+ vertexOffset += positions.length / 3;
977
+ }
978
+
979
+ mergedGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3));
980
+ if (indices.length > 0) {
981
+ const IndexType = vertices.length / 3 > 65535 ? Uint32Array : Uint16Array;
982
+ mergedGeometry.setIndex(new THREE.BufferAttribute(new IndexType(indices), 1));
983
+ }
984
+
985
+ return mergedGeometry;
986
+ } catch (e) {
987
+ console.error('Geometry merge error:', e);
988
+ return null;
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Dispose geometry and material resources safely
994
+ *
995
+ * @param {THREE.Object3D} object - Object to dispose
996
+ */
997
+ export function disposeGeometry(object) {
998
+ if (!object) return;
999
+
1000
+ if (object.geometry) {
1001
+ object.geometry.dispose();
1002
+ }
1003
+
1004
+ if (object.material) {
1005
+ if (Array.isArray(object.material)) {
1006
+ object.material.forEach(m => m.dispose());
1007
+ } else {
1008
+ object.material.dispose();
1009
+ }
1010
+ }
1011
+
1012
+ if (object.children) {
1013
+ object.children.forEach(child => disposeGeometry(child));
1014
+ }
1015
+ }
1016
+
631
1017
  /**
632
1018
  * Create wireframe edge visualization for a mesh
633
1019
  * Uses EdgesGeometry + LineBasicMaterial
@@ -687,3 +1073,6 @@ export function getMaterialPresets() {
687
1073
  export function getMaterialPreset(name) {
688
1074
  return MATERIAL_PRESETS[name] || MATERIAL_PRESETS.steel;
689
1075
  }
1076
+
1077
+ // Export new operations are already exported above
1078
+ // Export helper functions are already exported above