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.
- package/CLAUDE.md +233 -0
- package/DUO-MANIFEST-README.md +233 -0
- package/MASTERPLAN.md +182 -0
- package/app/duo-manifest-demo.html +337 -0
- package/app/duo-manifest.json +7375 -0
- package/app/index.html +1167 -23
- package/app/js/app.js +79 -9
- package/app/js/assembly-resolver.js +477 -0
- package/app/js/operations.js +501 -112
- package/app/js/project-browser.js +741 -0
- package/app/js/project-loader.js +579 -0
- package/app/js/rebuild-guide.js +743 -0
- package/app/js/viewport.js +24 -0
- package/package.json +2 -2
package/app/js/operations.js
CHANGED
|
@@ -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}
|
|
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,
|
|
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
|
|
371
|
+
// Get geometry
|
|
371
372
|
const geometry = mesh.geometry;
|
|
372
|
-
const
|
|
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
|
-
//
|
|
376
|
-
|
|
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
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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}
|
|
411
|
-
* @param {number} distance - Chamfer distance
|
|
412
|
-
* @returns {THREE.
|
|
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,
|
|
415
|
-
const
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
const centerZ = (minZ + maxZ) / 2;
|
|
452
|
+
const geometry = mesh.geometry;
|
|
453
|
+
const positionAttr = geometry.attributes.position;
|
|
438
454
|
|
|
439
|
-
|
|
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
|
-
|
|
446
|
-
|
|
457
|
+
const positions = positionAttr.array;
|
|
458
|
+
const indices = geometry.index ? geometry.index.array : null;
|
|
447
459
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
500
|
+
const coneMesh = new THREE.Mesh(coneGeom, mesh.material.clone());
|
|
501
|
+
group.add(coneMesh);
|
|
457
502
|
}
|
|
458
503
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
504
|
+
// Store parameters
|
|
505
|
+
group.userData = {
|
|
506
|
+
operation: 'chamfer',
|
|
507
|
+
edgeIndices,
|
|
508
|
+
distance,
|
|
509
|
+
source: mesh
|
|
510
|
+
};
|
|
463
511
|
|
|
464
|
-
return
|
|
512
|
+
return group;
|
|
465
513
|
}
|
|
466
514
|
|
|
467
515
|
/**
|
|
468
516
|
* Boolean union of two meshes
|
|
469
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
590
|
+
// Create visual indicator of cutting volume
|
|
516
591
|
const meshBCopy = meshB.clone();
|
|
517
592
|
if (meshBCopy.material) {
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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 =
|
|
637
|
+
const intersectBox = new THREE.Box3();
|
|
638
|
+
boxA.intersectBox(boxB, intersectBox);
|
|
551
639
|
|
|
552
|
-
if (intersectBox
|
|
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', {
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|