cyclecad 0.1.4 → 0.1.7

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,762 @@
1
+ /**
2
+ * advanced-ops.js
3
+ *
4
+ * Advanced 3D modeling operations for cycleCAD:
5
+ * - Sweep: extrude profile along path
6
+ * - Loft: interpolate between profiles
7
+ * - Sheet Metal: bend, flange, tab, slot, unfold
8
+ * - Utilities: spring, thread
9
+ *
10
+ * Uses Three.js r170 ES Modules
11
+ */
12
+
13
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
14
+
15
+ /**
16
+ * Create a Frenet frame at a point along a curve
17
+ * @param {THREE.Vector3} tangent - tangent direction
18
+ * @param {THREE.Vector3} [prevNormal] - previous normal (for continuous frame rotation)
19
+ * @returns {object} {tangent, normal, binormal}
20
+ */
21
+ function computeFrenetFrame(tangent, prevNormal = null) {
22
+ const N = new THREE.Vector3();
23
+
24
+ if (prevNormal) {
25
+ N.copy(prevNormal);
26
+ } else {
27
+ // Find a vector not parallel to tangent
28
+ if (Math.abs(tangent.x) < 0.9) {
29
+ N.set(1, 0, 0);
30
+ } else {
31
+ N.set(0, 1, 0);
32
+ }
33
+ }
34
+
35
+ // Gram-Schmidt: make N perpendicular to tangent
36
+ N.sub(tangent.clone().multiplyScalar(tangent.dot(N)));
37
+ N.normalize();
38
+
39
+ // Binormal
40
+ const B = tangent.clone().cross(N).normalize();
41
+
42
+ // Recalculate N for orthonormal basis
43
+ N.copy(B).cross(tangent).normalize();
44
+
45
+ return { tangent: tangent.normalize(), normal: N, binormal: B };
46
+ }
47
+
48
+ /**
49
+ * Sweep a 2D profile along a 3D path
50
+ * @param {Array<{x:number, y:number}>} profile - 2D profile points (in XY plane)
51
+ * @param {Array<{x:number, y:number, z:number}>|THREE.Curve3} path - 3D path
52
+ * @param {object} options - {segments: 64, closed: false, twist: 0, scale: 1.0, material: Material}
53
+ * @returns {THREE.Mesh} swept mesh
54
+ */
55
+ export function createSweep(profile, path, options = {}) {
56
+ const {
57
+ segments = 64,
58
+ closed = false,
59
+ twist = 0, // degrees per unit length
60
+ scale = 1.0,
61
+ material = null
62
+ } = options;
63
+
64
+ // Ensure profile is array of Vector2
65
+ const profileVecs = profile.map(p => new THREE.Vector2(p.x, p.y));
66
+
67
+ // Ensure path is array of Vector3
68
+ let pathPoints;
69
+ if (path instanceof THREE.Curve3 || (path.getPointAt && typeof path.getPointAt === 'function')) {
70
+ pathPoints = [];
71
+ for (let i = 0; i <= segments; i++) {
72
+ const t = i / segments;
73
+ pathPoints.push(path.getPointAt(t));
74
+ }
75
+ } else {
76
+ pathPoints = path.map(p => new THREE.Vector3(p.x, p.y, p.z));
77
+ }
78
+
79
+ const vertices = [];
80
+ const faces = [];
81
+ const profileSegments = profileVecs.length;
82
+
83
+ // Compute path length for twist calculation
84
+ let totalPathLength = 0;
85
+ const segmentLengths = [0];
86
+ for (let i = 1; i < pathPoints.length; i++) {
87
+ const segLen = pathPoints[i].distanceTo(pathPoints[i-1]);
88
+ totalPathLength += segLen;
89
+ segmentLengths.push(totalPathLength);
90
+ }
91
+
92
+ // Build frames along path and create vertices
93
+ let prevNormal = null;
94
+ for (let i = 0; i < pathPoints.length; i++) {
95
+ const pathPoint = pathPoints[i];
96
+
97
+ // Compute tangent
98
+ let tangent = new THREE.Vector3();
99
+ if (i === 0) {
100
+ tangent.subVectors(pathPoints[1], pathPoints[0]);
101
+ } else if (i === pathPoints.length - 1) {
102
+ tangent.subVectors(pathPoints[i], pathPoints[i-1]);
103
+ } else {
104
+ tangent.subVectors(pathPoints[i+1], pathPoints[i-1]).multiplyScalar(0.5);
105
+ }
106
+ tangent.normalize();
107
+
108
+ // Compute Frenet frame
109
+ const frame = computeFrenetFrame(tangent, prevNormal);
110
+ prevNormal = frame.normal;
111
+
112
+ // Twist rotation
113
+ const twistAngle = (twist / 360) * segmentLengths[i];
114
+ const twistQuat = new THREE.Quaternion();
115
+ twistQuat.setFromAxisAngle(frame.tangent, twistAngle);
116
+
117
+ // Scale interpolation
118
+ const scaleT = i / (pathPoints.length - 1);
119
+ const currentScale = 1.0 + (scale - 1.0) * scaleT;
120
+
121
+ // Create profile vertices at this path point
122
+ for (let j = 0; j < profileSegments; j++) {
123
+ const profilePt = profileVecs[j];
124
+
125
+ // Apply twist
126
+ let pt2D = profilePt.clone();
127
+ const rotZ = new THREE.Vector2(
128
+ Math.cos(twistAngle) * pt2D.x - Math.sin(twistAngle) * pt2D.y,
129
+ Math.sin(twistAngle) * pt2D.x + Math.cos(twistAngle) * pt2D.y
130
+ );
131
+ pt2D = rotZ;
132
+
133
+ // Apply scale
134
+ pt2D.multiplyScalar(currentScale);
135
+
136
+ // Convert 2D to 3D using Frenet frame
137
+ const pt3D = pathPoint.clone();
138
+ pt3D.addScaledVector(frame.normal, pt2D.x);
139
+ pt3D.addScaledVector(frame.binormal, pt2D.y);
140
+
141
+ vertices.push(pt3D.x, pt3D.y, pt3D.z);
142
+ }
143
+ }
144
+
145
+ // Create faces
146
+ for (let i = 0; i < pathPoints.length - 1; i++) {
147
+ for (let j = 0; j < profileSegments; j++) {
148
+ const nextJ = (j + 1) % profileSegments;
149
+
150
+ const v0 = i * profileSegments + j;
151
+ const v1 = i * profileSegments + nextJ;
152
+ const v2 = (i + 1) * profileSegments + j;
153
+ const v3 = (i + 1) * profileSegments + nextJ;
154
+
155
+ // Two triangles per quad
156
+ faces.push(v0, v2, v1);
157
+ faces.push(v1, v2, v3);
158
+ }
159
+ }
160
+
161
+ // Create geometry
162
+ const geometry = new THREE.BufferGeometry();
163
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3));
164
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(faces), 1));
165
+ geometry.computeVertexNormals();
166
+
167
+ const finalMaterial = material || new THREE.MeshStandardMaterial({
168
+ color: 0x888888,
169
+ metalness: 0.6,
170
+ roughness: 0.4
171
+ });
172
+
173
+ return new THREE.Mesh(geometry, finalMaterial);
174
+ }
175
+
176
+ /**
177
+ * Loft between multiple 2D profiles at different 3D positions
178
+ * @param {Array<{points: Array<{x,y}>, position: {x,y,z}, rotation: {x,y,z}, scale: number}>} profiles
179
+ * @param {object} options - {segments: 32, closed: false, material: Material}
180
+ * @returns {THREE.Mesh}
181
+ */
182
+ export function createLoft(profiles, options = {}) {
183
+ const {
184
+ segments = 32,
185
+ closed = false,
186
+ material = null
187
+ } = options;
188
+
189
+ if (profiles.length < 2) {
190
+ throw new Error('Loft requires at least 2 profiles');
191
+ }
192
+
193
+ // Find max profile length
194
+ const maxLength = Math.max(...profiles.map(p => p.points.length));
195
+
196
+ // Resample all profiles to same length using spline interpolation
197
+ const resampledProfiles = profiles.map((profile, idx) => {
198
+ const origPoints = profile.points.map(p => new THREE.Vector2(p.x, p.y));
199
+
200
+ if (origPoints.length === maxLength) {
201
+ return {
202
+ points: origPoints,
203
+ position: new THREE.Vector3(profile.position.x, profile.position.y, profile.position.z),
204
+ rotation: new THREE.Euler(profile.rotation?.x || 0, profile.rotation?.y || 0, profile.rotation?.z || 0),
205
+ scale: profile.scale || 1.0
206
+ };
207
+ }
208
+
209
+ // Linear resampling for simplicity
210
+ const resampled = [];
211
+ for (let i = 0; i < maxLength; i++) {
212
+ const t = i / (maxLength - 1);
213
+ const srcT = t * (origPoints.length - 1);
214
+ const srcIdx = Math.floor(srcT);
215
+ const blend = srcT - srcIdx;
216
+
217
+ if (srcIdx >= origPoints.length - 1) {
218
+ resampled.push(origPoints[origPoints.length - 1].clone());
219
+ } else {
220
+ const p0 = origPoints[srcIdx];
221
+ const p1 = origPoints[srcIdx + 1];
222
+ resampled.push(p0.clone().lerp(p1, blend));
223
+ }
224
+ }
225
+
226
+ return {
227
+ points: resampled,
228
+ position: new THREE.Vector3(profile.position.x, profile.position.y, profile.position.z),
229
+ rotation: new THREE.Euler(profile.rotation?.x || 0, profile.rotation?.y || 0, profile.rotation?.z || 0),
230
+ scale: profile.scale || 1.0
231
+ };
232
+ });
233
+
234
+ const vertices = [];
235
+ const faces = [];
236
+
237
+ // Create vertices for each profile
238
+ for (let profileIdx = 0; profileIdx < resampledProfiles.length; profileIdx++) {
239
+ const prof = resampledProfiles[profileIdx];
240
+ const rotMat = new THREE.Matrix4().makeRotationFromEuler(prof.rotation);
241
+
242
+ for (let pointIdx = 0; pointIdx < prof.points.length; pointIdx++) {
243
+ const pt2D = prof.points[pointIdx];
244
+
245
+ // Scale
246
+ const scaled = pt2D.clone().multiplyScalar(prof.scale);
247
+
248
+ // Rotate
249
+ const pt3D = new THREE.Vector3(scaled.x, scaled.y, 0);
250
+ pt3D.applyMatrix4(rotMat);
251
+
252
+ // Translate
253
+ pt3D.add(prof.position);
254
+
255
+ vertices.push(pt3D.x, pt3D.y, pt3D.z);
256
+ }
257
+ }
258
+
259
+ // Create faces between profiles (linear interpolation)
260
+ for (let profileIdx = 0; profileIdx < resampledProfiles.length - 1; profileIdx++) {
261
+ const pointCount = resampledProfiles[profileIdx].points.length;
262
+
263
+ for (let pointIdx = 0; pointIdx < pointCount; pointIdx++) {
264
+ const nextPoint = (pointIdx + 1) % pointCount;
265
+
266
+ const v0 = profileIdx * pointCount + pointIdx;
267
+ const v1 = profileIdx * pointCount + nextPoint;
268
+ const v2 = (profileIdx + 1) * pointCount + pointIdx;
269
+ const v3 = (profileIdx + 1) * pointCount + nextPoint;
270
+
271
+ // Two triangles per quad
272
+ faces.push(v0, v2, v1);
273
+ faces.push(v1, v2, v3);
274
+ }
275
+ }
276
+
277
+ // Create geometry
278
+ const geometry = new THREE.BufferGeometry();
279
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3));
280
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(faces), 1));
281
+ geometry.computeVertexNormals();
282
+
283
+ const finalMaterial = material || new THREE.MeshStandardMaterial({
284
+ color: 0xaaaaaa,
285
+ metalness: 0.5,
286
+ roughness: 0.5,
287
+ side: THREE.DoubleSide
288
+ });
289
+
290
+ return new THREE.Mesh(geometry, finalMaterial);
291
+ }
292
+
293
+ /**
294
+ * Bend a flat plate along a line by a given angle
295
+ * @param {THREE.Mesh} mesh - flat plate mesh
296
+ * @param {{start: {x,y,z}, end: {x,y,z}}} bendLine - bend axis
297
+ * @param {number} angle - bend angle in degrees
298
+ * @param {number} radius - inner bend radius
299
+ * @param {object} options - {kFactor: 0.44, segments: 16, material: Material}
300
+ * @returns {THREE.Mesh} bent mesh
301
+ */
302
+ export function createBend(mesh, bendLine, angle, radius, options = {}) {
303
+ const {
304
+ kFactor = 0.44,
305
+ segments = 16,
306
+ material = null
307
+ } = options;
308
+
309
+ const bendStart = new THREE.Vector3(bendLine.start.x, bendLine.start.y, bendLine.start.z);
310
+ const bendEnd = new THREE.Vector3(bendLine.end.x, bendLine.end.y, bendLine.end.z);
311
+ const bendAxis = bendEnd.clone().sub(bendStart).normalize();
312
+
313
+ // Get original geometry
314
+ const geometry = mesh.geometry.clone();
315
+ const positions = geometry.attributes.position.array;
316
+ const newPositions = new Float32Array(positions.length);
317
+ newPositions.set(positions);
318
+
319
+ const angleRad = (angle / 360) * Math.PI * 2;
320
+ const bendRadius = radius + kFactor * geometry.boundingBox.getSize(new THREE.Vector3()).z;
321
+
322
+ // Transform vertices
323
+ for (let i = 0; i < positions.length; i += 3) {
324
+ const vertex = new THREE.Vector3(positions[i], positions[i+1], positions[i+2]);
325
+
326
+ // Project vertex onto bend line
327
+ const toVertex = vertex.clone().sub(bendStart);
328
+ const projDist = toVertex.dot(bendAxis);
329
+ const projPoint = bendStart.clone().addScaledVector(bendAxis, projDist);
330
+
331
+ // Distance from bend line
332
+ const distFromLine = vertex.distanceTo(projPoint);
333
+
334
+ // Perpendicular direction (in plane perpendicular to bend axis)
335
+ const perpDir = vertex.clone().sub(projPoint);
336
+ if (perpDir.length() > 0.001) {
337
+ perpDir.normalize();
338
+
339
+ // Apply bend: rotate around bend line
340
+ const bendQuat = new THREE.Quaternion();
341
+ bendQuat.setFromAxisAngle(bendAxis, angleRad * (distFromLine / (bendRadius + geometry.boundingBox.getSize(new THREE.Vector3()).z)));
342
+
343
+ const offset = perpDir.multiplyScalar(bendRadius);
344
+ offset.applyQuaternion(bendQuat);
345
+ offset.addScaledVector(bendAxis, projDist);
346
+
347
+ const newVertex = projPoint.clone().add(offset);
348
+ newPositions[i] = newVertex.x;
349
+ newPositions[i+1] = newVertex.y;
350
+ newPositions[i+2] = newVertex.z;
351
+ }
352
+ }
353
+
354
+ geometry.attributes.position.array = newPositions;
355
+ geometry.attributes.position.needsUpdate = true;
356
+ geometry.computeVertexNormals();
357
+
358
+ const finalMaterial = material || new THREE.MeshStandardMaterial({
359
+ color: 0x888888,
360
+ metalness: 0.6,
361
+ roughness: 0.4
362
+ });
363
+
364
+ return new THREE.Mesh(geometry, finalMaterial);
365
+ }
366
+
367
+ /**
368
+ * Add a flange to an edge of a mesh
369
+ * @param {THREE.Mesh} mesh - base mesh
370
+ * @param {{start: {x,y,z}, end: {x,y,z}}} edge - edge to flange
371
+ * @param {number} length - flange length
372
+ * @param {number} angle - flange angle (90 default)
373
+ * @param {object} options - {segments: 8, material: Material}
374
+ * @returns {THREE.Mesh} mesh with flange
375
+ */
376
+ export function createFlange(mesh, edge, length, angle = 90, options = {}) {
377
+ const { segments = 8, material = null } = options;
378
+
379
+ const edgeStart = new THREE.Vector3(edge.start.x, edge.start.y, edge.start.z);
380
+ const edgeEnd = new THREE.Vector3(edge.end.x, edge.end.y, edge.end.z);
381
+ const edgeDir = edgeEnd.clone().sub(edgeStart).normalize();
382
+ const edgeLen = edgeEnd.distanceTo(edgeStart);
383
+
384
+ // Find perpendicular direction (guess: prefer Z if edge not parallel to Z)
385
+ let perpDir = new THREE.Vector3(0, 0, 1);
386
+ if (Math.abs(edgeDir.dot(perpDir)) > 0.9) {
387
+ perpDir = new THREE.Vector3(1, 0, 0);
388
+ }
389
+ perpDir.cross(edgeDir).normalize();
390
+
391
+ // Create flange geometry
392
+ const vertices = [];
393
+ const faces = [];
394
+
395
+ // Bottom edge (on mesh)
396
+ for (let i = 0; i <= segments; i++) {
397
+ const t = i / segments;
398
+ const pt = edgeStart.clone().addScaledVector(edgeDir, edgeLen * t);
399
+ vertices.push(pt.x, pt.y, pt.z);
400
+ }
401
+
402
+ // Top edge (flange edge)
403
+ const angleRad = (angle / 360) * Math.PI * 2;
404
+ const flangeDir = perpDir.clone().applyAxisAngle(edgeDir, angleRad).multiplyScalar(length);
405
+
406
+ for (let i = 0; i <= segments; i++) {
407
+ const t = i / segments;
408
+ const pt = edgeStart.clone().addScaledVector(edgeDir, edgeLen * t).add(flangeDir);
409
+ vertices.push(pt.x, pt.y, pt.z);
410
+ }
411
+
412
+ // Create quad faces
413
+ for (let i = 0; i < segments; i++) {
414
+ const v0 = i;
415
+ const v1 = i + 1;
416
+ const v2 = (segments + 1) + i;
417
+ const v3 = (segments + 1) + i + 1;
418
+
419
+ faces.push(v0, v2, v1);
420
+ faces.push(v1, v2, v3);
421
+ }
422
+
423
+ const geometry = new THREE.BufferGeometry();
424
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3));
425
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(faces), 1));
426
+ geometry.computeVertexNormals();
427
+
428
+ const finalMaterial = material || new THREE.MeshStandardMaterial({
429
+ color: 0x888888,
430
+ metalness: 0.6,
431
+ roughness: 0.4
432
+ });
433
+
434
+ const flangeMesh = new THREE.Mesh(geometry, finalMaterial);
435
+
436
+ // Merge with original
437
+ const group = new THREE.Group();
438
+ group.add(mesh);
439
+ group.add(flangeMesh);
440
+
441
+ return group;
442
+ }
443
+
444
+ /**
445
+ * Add a tab for assembly to an edge
446
+ * @param {THREE.Mesh} mesh - base mesh
447
+ * @param {{start: {x,y,z}, end: {x,y,z}}} edge - edge to add tab to
448
+ * @param {number} width - tab width
449
+ * @param {number} depth - tab depth (protrusion)
450
+ * @param {object} options - {segments: 4, material: Material}
451
+ * @returns {THREE.Mesh}
452
+ */
453
+ export function createTab(mesh, edge, width, depth, options = {}) {
454
+ const { segments = 4, material = null } = options;
455
+
456
+ const edgeStart = new THREE.Vector3(edge.start.x, edge.start.y, edge.start.z);
457
+ const edgeEnd = new THREE.Vector3(edge.end.x, edge.end.y, edge.end.z);
458
+ const edgeDir = edgeEnd.clone().sub(edgeStart).normalize();
459
+
460
+ const perpDir = new THREE.Vector3(0, 0, 1);
461
+ if (Math.abs(edgeDir.dot(perpDir)) > 0.9) {
462
+ perpDir.set(1, 0, 0);
463
+ }
464
+ perpDir.cross(edgeDir).normalize();
465
+
466
+ const vertices = [];
467
+ const faces = [];
468
+
469
+ // Tab base (on edge)
470
+ const halfWidth = width / 2;
471
+ const pt0 = edgeStart.clone().addScaledVector(perpDir, -halfWidth);
472
+ const pt1 = edgeStart.clone().addScaledVector(perpDir, halfWidth);
473
+ const pt2 = edgeEnd.clone().addScaledVector(perpDir, halfWidth);
474
+ const pt3 = edgeEnd.clone().addScaledVector(perpDir, -halfWidth);
475
+
476
+ vertices.push(pt0.x, pt0.y, pt0.z);
477
+ vertices.push(pt1.x, pt1.y, pt1.z);
478
+ vertices.push(pt2.x, pt2.y, pt2.z);
479
+ vertices.push(pt3.x, pt3.y, pt3.z);
480
+
481
+ // Tab protrusion
482
+ const depthDir = perpDir.clone().cross(edgeDir);
483
+ const pt4 = pt0.clone().addScaledVector(depthDir, depth);
484
+ const pt5 = pt1.clone().addScaledVector(depthDir, depth);
485
+ const pt6 = pt2.clone().addScaledVector(depthDir, depth);
486
+ const pt7 = pt3.clone().addScaledVector(depthDir, depth);
487
+
488
+ vertices.push(pt4.x, pt4.y, pt4.z);
489
+ vertices.push(pt5.x, pt5.y, pt5.z);
490
+ vertices.push(pt6.x, pt6.y, pt6.z);
491
+ vertices.push(pt7.x, pt7.y, pt7.z);
492
+
493
+ // Base quad
494
+ faces.push(0, 1, 2);
495
+ faces.push(0, 2, 3);
496
+
497
+ // Side faces
498
+ faces.push(0, 4, 5, 1); // Left
499
+ faces.push(1, 5, 6, 2); // Top
500
+ faces.push(2, 6, 7, 3); // Right
501
+ faces.push(3, 7, 4, 0); // Bottom
502
+
503
+ // Protrusion face
504
+ faces.push(4, 7, 6, 5);
505
+
506
+ // Convert quads to triangles
507
+ const triangles = [];
508
+ for (const face of faces) {
509
+ if (Array.isArray(face)) {
510
+ for (let i = 0; i < face.length - 2; i++) {
511
+ triangles.push(face[0], face[i+1], face[i+2]);
512
+ }
513
+ } else {
514
+ triangles.push(face);
515
+ }
516
+ }
517
+
518
+ const geometry = new THREE.BufferGeometry();
519
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(vertices), 3));
520
+ geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(triangles), 1));
521
+ geometry.computeVertexNormals();
522
+
523
+ const finalMaterial = material || new THREE.MeshStandardMaterial({
524
+ color: 0x888888,
525
+ metalness: 0.6,
526
+ roughness: 0.4
527
+ });
528
+
529
+ return new THREE.Mesh(geometry, finalMaterial);
530
+ }
531
+
532
+ /**
533
+ * Cut a slot in a mesh at a given position
534
+ * @param {THREE.Mesh} mesh - base mesh
535
+ * @param {{x:number, y:number, z:number}} position - slot center
536
+ * @param {number} width - slot width
537
+ * @param {number} depth - slot depth
538
+ * @param {object} options - {length: 10, axis: 'x', material: Material}
539
+ * @returns {THREE.Mesh} modified mesh
540
+ */
541
+ export function createSlot(mesh, position, width, depth, options = {}) {
542
+ const { length = 10, axis = 'x', material = null } = options;
543
+
544
+ // For now, create visual slot representation
545
+ // Full boolean operation would require CSG library
546
+
547
+ const slotGeom = new THREE.BoxGeometry(
548
+ axis === 'x' ? length : width,
549
+ axis === 'y' ? length : depth,
550
+ axis === 'z' ? length : width
551
+ );
552
+
553
+ const slotPos = new THREE.Vector3(position.x, position.y, position.z);
554
+
555
+ const slotMat = material || new THREE.MeshStandardMaterial({
556
+ color: 0x333333,
557
+ metalness: 0.2,
558
+ roughness: 0.8
559
+ });
560
+
561
+ const slotMesh = new THREE.Mesh(slotGeom, slotMat);
562
+ slotMesh.position.copy(slotPos);
563
+
564
+ // Return group for now (real implementation would use CSG)
565
+ const group = new THREE.Group();
566
+ group.add(mesh);
567
+ group.add(slotMesh);
568
+
569
+ return group;
570
+ }
571
+
572
+ /**
573
+ * Compute flat pattern from bent sheet metal
574
+ * @param {THREE.Mesh} mesh - bent sheet mesh
575
+ * @param {Array<{bendLine: {start, end}, angle: number, radius: number}>} bends - bend definitions
576
+ * @param {object} options - {kFactor: 0.44, material: Material}
577
+ * @returns {{flatMesh: THREE.Mesh, bendLines: THREE.LineSegments}}
578
+ */
579
+ export function unfoldSheetMetal(mesh, bends, options = {}) {
580
+ const { kFactor = 0.44, material = null } = options;
581
+
582
+ const flatGeom = mesh.geometry.clone();
583
+ const flatPositions = flatGeom.attributes.position.array.slice();
584
+
585
+ // Simple unfolding: translate back by calculated arc length
586
+ let cumulativeOffset = 0;
587
+ for (const bend of bends) {
588
+ const thickness = mesh.geometry.boundingBox?.getSize(new THREE.Vector3()).z || 1;
589
+ const bendRadius = bend.radius + kFactor * thickness;
590
+ const angleRad = (bend.angle / 360) * Math.PI * 2;
591
+ const arcLen = bendRadius * angleRad;
592
+ cumulativeOffset += arcLen;
593
+ }
594
+
595
+ // Offset all vertices along primary axis
596
+ for (let i = 0; i < flatPositions.length; i += 3) {
597
+ flatPositions[i] -= cumulativeOffset;
598
+ }
599
+
600
+ flatGeom.attributes.position.array = flatPositions;
601
+ flatGeom.attributes.position.needsUpdate = true;
602
+ flatGeom.computeVertexNormals();
603
+
604
+ const flatMaterial = material || new THREE.MeshStandardMaterial({
605
+ color: 0xcccccc,
606
+ metalness: 0.4,
607
+ roughness: 0.6
608
+ });
609
+
610
+ const flatMesh = new THREE.Mesh(flatGeom, flatMaterial);
611
+
612
+ // Create bend lines
613
+ const bendLineVertices = [];
614
+ let offset = 0;
615
+ for (const bend of bends) {
616
+ const thickness = mesh.geometry.boundingBox?.getSize(new THREE.Vector3()).z || 1;
617
+ const bendRadius = bend.radius + kFactor * thickness;
618
+ const angleRad = (bend.angle / 360) * Math.PI * 2;
619
+ const arcLen = bendRadius * angleRad;
620
+
621
+ // Bend line at current offset
622
+ const start = new THREE.Vector3(offset, -1, 0);
623
+ const end = new THREE.Vector3(offset, 1, 0);
624
+ bendLineVertices.push(start.x, start.y, start.z, end.x, end.y, end.z);
625
+
626
+ offset += arcLen;
627
+ }
628
+
629
+ const bendLineGeom = new THREE.BufferGeometry();
630
+ bendLineGeom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(bendLineVertices), 3));
631
+
632
+ const bendLineMat = new THREE.LineBasicMaterial({ color: 0xff0000, linewidth: 2 });
633
+ const bendLines = new THREE.LineSegments(bendLineGeom, bendLineMat);
634
+
635
+ return { flatMesh, bendLines };
636
+ }
637
+
638
+ /**
639
+ * Create a helical spring using sweep
640
+ * @param {number} radius - outer radius
641
+ * @param {number} wireRadius - wire/thread radius
642
+ * @param {number} height - spring height
643
+ * @param {number} turns - number of turns
644
+ * @param {object} options - {material: Material}
645
+ * @returns {THREE.Mesh}
646
+ */
647
+ export function createSpring(radius, wireRadius, height, turns, options = {}) {
648
+ const { material = null } = options;
649
+
650
+ // Create helix path
651
+ const segments = Math.max(64, turns * 16);
652
+ const pathPoints = [];
653
+ for (let i = 0; i <= segments; i++) {
654
+ const t = i / segments;
655
+ const angle = t * turns * Math.PI * 2;
656
+ const z = t * height;
657
+ pathPoints.push({
658
+ x: Math.cos(angle) * radius,
659
+ y: Math.sin(angle) * radius,
660
+ z: z
661
+ });
662
+ }
663
+
664
+ // Wire cross-section (circle)
665
+ const wireSegments = 16;
666
+ const profile = [];
667
+ for (let i = 0; i < wireSegments; i++) {
668
+ const angle = (i / wireSegments) * Math.PI * 2;
669
+ profile.push({
670
+ x: Math.cos(angle) * wireRadius,
671
+ y: Math.sin(angle) * wireRadius
672
+ });
673
+ }
674
+
675
+ // Use sweep to create spring
676
+ return createSweep(profile, pathPoints, {
677
+ segments: segments,
678
+ closed: true,
679
+ material: material || new THREE.MeshStandardMaterial({
680
+ color: 0xddaa00,
681
+ metalness: 0.8,
682
+ roughness: 0.3
683
+ })
684
+ });
685
+ }
686
+
687
+ /**
688
+ * Create a screw thread geometry
689
+ * @param {number} outerRadius - outer radius
690
+ * @param {number} innerRadius - inner radius (core)
691
+ * @param {number} pitch - thread pitch (distance per turn)
692
+ * @param {number} length - thread length
693
+ * @param {object} options - {turns: 4, material: Material}
694
+ * @returns {THREE.Mesh}
695
+ */
696
+ export function createThread(outerRadius, innerRadius, pitch, length, options = {}) {
697
+ const { turns = 4, material = null } = options;
698
+
699
+ const pathPoints = [];
700
+ const segments = Math.max(64, turns * 16);
701
+
702
+ for (let i = 0; i <= segments; i++) {
703
+ const t = i / segments;
704
+ const angle = t * turns * Math.PI * 2;
705
+ const z = t * length;
706
+ pathPoints.push({
707
+ x: 0,
708
+ y: 0,
709
+ z: z
710
+ });
711
+ }
712
+
713
+ // Thread profile (triangular cross-section)
714
+ const profile = [];
715
+ const threadDepth = (outerRadius - innerRadius) / 2;
716
+ const halfPitch = pitch / 2;
717
+
718
+ for (let i = 0; i < 16; i++) {
719
+ const angle = (i / 16) * Math.PI * 2;
720
+ const r = innerRadius + Math.sin(angle * turns * Math.PI * 2) * threadDepth;
721
+ profile.push({
722
+ x: Math.cos(angle) * r,
723
+ y: Math.sin(angle) * r
724
+ });
725
+ }
726
+
727
+ // Create helix path
728
+ const helixPath = [];
729
+ for (let i = 0; i <= segments; i++) {
730
+ const t = i / segments;
731
+ const angle = t * turns * Math.PI * 2;
732
+ const z = t * length;
733
+ helixPath.push({
734
+ x: Math.cos(angle) * outerRadius,
735
+ y: Math.sin(angle) * outerRadius,
736
+ z: z
737
+ });
738
+ }
739
+
740
+ return createSweep(profile, helixPath, {
741
+ segments: segments,
742
+ closed: true,
743
+ twist: (turns * 360) / length,
744
+ material: material || new THREE.MeshStandardMaterial({
745
+ color: 0x888888,
746
+ metalness: 0.7,
747
+ roughness: 0.4
748
+ })
749
+ });
750
+ }
751
+
752
+ export default {
753
+ createSweep,
754
+ createLoft,
755
+ createBend,
756
+ createFlange,
757
+ createTab,
758
+ createSlot,
759
+ unfoldSheetMetal,
760
+ createSpring,
761
+ createThread
762
+ };