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.
- package/CLAUDE.md +20 -9
- package/app/index.html +451 -3
- package/app/js/advanced-ops.js +762 -0
- package/app/js/assembly.js +1102 -0
- package/app/js/constraint-solver.js +1046 -0
- package/app/js/dxf-export.js +1173 -0
- package/app/js/viewport.js +83 -0
- package/app/mobile.html +1276 -0
- package/package.json +1 -1
- package/DUO-MANIFEST-README.md +0 -233
- package/app/duo-manifest-demo.html +0 -337
- package/app/duo-manifest.json +0 -7375
|
@@ -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
|
+
};
|