cyclecad 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IMPLEMENTATION_GUIDE.md +502 -0
- package/INTEGRATION-GUIDE.md +377 -0
- package/MODULES_PHASES_6_7.md +780 -0
- package/app/index.html +106 -2
- package/app/js/brep-kernel.js +1353 -455
- package/app/js/help-module.js +1437 -0
- package/app/js/kernel.js +364 -40
- package/app/js/modules/animation-module.js +967 -0
- package/app/js/modules/assembly-module.js +47 -3
- package/app/js/modules/cam-module.js +1067 -0
- package/app/js/modules/collaboration-module.js +1102 -0
- package/app/js/modules/data-module.js +1656 -0
- package/app/js/modules/drawing-module.js +54 -8
- package/app/js/modules/formats-module.js +1173 -0
- package/app/js/modules/inspection-module.js +937 -0
- package/app/js/modules/mesh-module.js +968 -0
- package/app/js/modules/operations-module.js +40 -7
- package/app/js/modules/plugin-module.js +957 -0
- package/app/js/modules/rendering-module.js +1306 -0
- package/app/js/modules/scripting-module.js +955 -0
- package/app/js/modules/simulation-module.js +60 -3
- package/app/js/modules/sketch-module.js +1032 -90
- package/app/js/modules/step-module.js +47 -6
- package/app/js/modules/surface-module.js +728 -0
- package/app/js/modules/version-module.js +1410 -0
- package/app/js/modules/viewport-module.js +95 -8
- package/app/test-agent-v2.html +881 -1316
- package/docs/ARCHITECTURE.html +838 -1408
- package/docs/DEVELOPER-GUIDE.md +1504 -0
- package/docs/TUTORIAL.md +740 -0
- package/package.json +1 -1
- package/.github/scripts/cad-diff.js +0 -590
- package/.github/workflows/cad-diff.yml +0 -117
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* @file sketch-module.js
|
|
3
|
+
* @description SketchModule — 2D Sketching Engine with Fusion 360 parity
|
|
4
|
+
* LEGO block for cycleCAD microkernel, providing a complete 2D constraint-based
|
|
5
|
+
* sketching environment on 3D faces or in standalone mode.
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
+
* @version 1.0.0
|
|
8
|
+
* @author cycleCAD Team
|
|
9
|
+
* @license MIT
|
|
10
|
+
* @see {@link https://github.com/vvlars-cmd/cyclecad}
|
|
11
|
+
*
|
|
12
|
+
* @module sketch-module
|
|
13
|
+
* @requires viewport (3D scene for sketch visualization)
|
|
14
|
+
*
|
|
15
|
+
* Features:
|
|
16
|
+
* - Drawing tools: Line, Rectangle, Circle, Arc, Ellipse, Spline, Polygon, Slot, Text
|
|
17
|
+
* - Editing tools: Trim, Extend, Offset, Mirror, Fillet, Chamfer
|
|
18
|
+
* - Construction geometry (reference-only entities)
|
|
19
|
+
* - Dimensions: Linear, angular, radial, diameter, ordinate
|
|
20
|
+
* - Constraints: Coincident, horizontal, vertical, parallel, perpendicular, tangent, etc.
|
|
21
|
+
* - Grid snap and point snap (configurable)
|
|
22
|
+
* - Live preview while drawing
|
|
23
|
+
* - Undo/redo support
|
|
24
|
+
* - 2D/3D canvas visualization
|
|
25
|
+
* - Profile export for extrude/revolve operations
|
|
26
|
+
*
|
|
27
|
+
* Workflow:
|
|
28
|
+
* 1. User triggers sketch mode on face or starts new sketch
|
|
29
|
+
* 2. Sketch plane is established (normal, origin, U/V axes)
|
|
30
|
+
* 3. Drawing toolbar appears with tool buttons
|
|
31
|
+
* 4. User draws entities (lines, circles, etc.)
|
|
32
|
+
* 5. Entities are added to entity list and rendered to canvas
|
|
33
|
+
* 6. User applies dimensions and constraints
|
|
34
|
+
* 7. User finishes sketch (Esc or Finish button)
|
|
35
|
+
* 8. Sketch profile is returned for use in extrude/revolve/pad/pocket operations
|
|
7
36
|
*/
|
|
8
37
|
|
|
9
38
|
const SketchModule = {
|
|
@@ -126,18 +155,93 @@ const SketchModule = {
|
|
|
126
155
|
},
|
|
127
156
|
|
|
128
157
|
startOnFace(faceId) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
/**
|
|
159
|
+
* SKETCH ON FACE: Start sketch on a 3D model face
|
|
160
|
+
*
|
|
161
|
+
* Algorithm:
|
|
162
|
+
* 1. Get face normal and center from 3D mesh
|
|
163
|
+
* 2. Compute local U/V axes (perpendicular to normal)
|
|
164
|
+
* 3. Set sketch plane to face's coordinate system
|
|
165
|
+
* 4. All drawn entities transform to world coords when sketch ends
|
|
166
|
+
*/
|
|
167
|
+
const face = this.getFaceData(faceId);
|
|
168
|
+
if (!face) {
|
|
169
|
+
console.warn('Could not find face:', faceId);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Compute orthonormal basis for the face
|
|
174
|
+
const normal = face.normal.clone().normalize();
|
|
175
|
+
let u = new THREE.Vector3(1, 0, 0);
|
|
176
|
+
|
|
177
|
+
// If normal is nearly parallel to X axis, use Y instead
|
|
178
|
+
if (Math.abs(normal.dot(u)) > 0.9) {
|
|
179
|
+
u = new THREE.Vector3(0, 1, 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// u = normal × reference, then v = normal × u
|
|
183
|
+
u = new THREE.Vector3().crossVectors(normal, u).normalize();
|
|
184
|
+
const v = new THREE.Vector3().crossVectors(normal, u).normalize();
|
|
185
|
+
|
|
132
186
|
const plane = {
|
|
133
|
-
normal
|
|
187
|
+
normal,
|
|
134
188
|
origin: face.origin || new THREE.Vector3(0, 0, 0),
|
|
135
|
-
u
|
|
136
|
-
v
|
|
189
|
+
u,
|
|
190
|
+
v,
|
|
191
|
+
faceId // Store for reference
|
|
137
192
|
};
|
|
193
|
+
|
|
194
|
+
this.state.sketchOnFace = true;
|
|
195
|
+
this.state.faceId = faceId;
|
|
138
196
|
this.start(plane);
|
|
139
197
|
},
|
|
140
198
|
|
|
199
|
+
getFaceData(faceId) {
|
|
200
|
+
/**
|
|
201
|
+
* Get face data from 3D scene (simplified version)
|
|
202
|
+
* In production, ray-cast scene or use model's face database
|
|
203
|
+
*/
|
|
204
|
+
if (!window._scene) return null;
|
|
205
|
+
|
|
206
|
+
// This is a simplified approach — would need real face selection
|
|
207
|
+
// For now, return default XY plane
|
|
208
|
+
return {
|
|
209
|
+
normal: new THREE.Vector3(0, 0, 1),
|
|
210
|
+
origin: new THREE.Vector3(0, 0, 0),
|
|
211
|
+
// Real implementation would compute actual face normal
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
transformSketchToWorld() {
|
|
216
|
+
/**
|
|
217
|
+
* Transform all sketch entities from face-local to world coordinates
|
|
218
|
+
* Called when finishing sketch on a face
|
|
219
|
+
*/
|
|
220
|
+
if (!this.state.sketchOnFace || !this.state.plane) return;
|
|
221
|
+
|
|
222
|
+
const plane = this.state.plane;
|
|
223
|
+
const matrix = new THREE.Matrix4();
|
|
224
|
+
|
|
225
|
+
// Build transformation matrix: local to world
|
|
226
|
+
const localX = plane.u;
|
|
227
|
+
const localY = plane.v;
|
|
228
|
+
const localZ = plane.normal;
|
|
229
|
+
|
|
230
|
+
matrix.makeBasis(localX, localY, localZ);
|
|
231
|
+
matrix.setPosition(plane.origin);
|
|
232
|
+
|
|
233
|
+
// Transform all entities
|
|
234
|
+
this.state.entities.forEach(entity => {
|
|
235
|
+
entity.points = entity.points.map(p => {
|
|
236
|
+
const v3 = new THREE.Vector3(p.x, p.y, 0); // 2D → 3D in local space
|
|
237
|
+
v3.applyMatrix4(matrix);
|
|
238
|
+
return new THREE.Vector2(v3.x, v3.y); // Back to 2D in world space
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
this.state.sketchOnFace = false;
|
|
243
|
+
},
|
|
244
|
+
|
|
141
245
|
finish() {
|
|
142
246
|
if (!this.state.isActive) return null;
|
|
143
247
|
|
|
@@ -146,6 +250,11 @@ const SketchModule = {
|
|
|
146
250
|
document.getElementById('sketch-status-bar').style.display = 'none';
|
|
147
251
|
if (this.state.canvas) this.state.canvas.style.display = 'none';
|
|
148
252
|
|
|
253
|
+
// If sketching on a face, transform entities to world coordinates
|
|
254
|
+
if (this.state.sketchOnFace) {
|
|
255
|
+
this.transformSketchToWorld();
|
|
256
|
+
}
|
|
257
|
+
|
|
149
258
|
// Remove 3D group
|
|
150
259
|
if (this.state.canvasGroup && window._scene) {
|
|
151
260
|
window._scene.remove(this.state.canvasGroup);
|
|
@@ -153,7 +262,12 @@ const SketchModule = {
|
|
|
153
262
|
|
|
154
263
|
const profile = this.getProfile();
|
|
155
264
|
window.dispatchEvent(new CustomEvent('sketch:finished', {
|
|
156
|
-
detail: {
|
|
265
|
+
detail: {
|
|
266
|
+
entities: this.state.entities,
|
|
267
|
+
profile,
|
|
268
|
+
plane: this.state.plane,
|
|
269
|
+
faceId: this.state.faceId || null
|
|
270
|
+
}
|
|
157
271
|
}));
|
|
158
272
|
|
|
159
273
|
return { entities: this.state.entities, profile };
|
|
@@ -243,10 +357,135 @@ const SketchModule = {
|
|
|
243
357
|
},
|
|
244
358
|
|
|
245
359
|
drawSpline(controlPoints) {
|
|
246
|
-
|
|
360
|
+
/**
|
|
361
|
+
* SPLINE TOOL: Cubic B-spline with draggable control points
|
|
362
|
+
*
|
|
363
|
+
* Uses De Boor's algorithm to evaluate curve at arbitrary parameter.
|
|
364
|
+
* Requires minimum 3 control points.
|
|
365
|
+
* Curve passes near (not through) control points unless clamped.
|
|
366
|
+
*/
|
|
367
|
+
if (controlPoints.length < 3) {
|
|
368
|
+
console.warn('Spline requires at least 3 control points');
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const spline = this.addEntity('spline', {
|
|
247
373
|
points: controlPoints,
|
|
248
|
-
data: {
|
|
374
|
+
data: {
|
|
375
|
+
degree: 3,
|
|
376
|
+
knotVector: this.generateBSplineKnots(controlPoints.length, 3)
|
|
377
|
+
}
|
|
249
378
|
});
|
|
379
|
+
|
|
380
|
+
// Render control polygon (dashed line connecting control points)
|
|
381
|
+
this.renderSplineControlPolygon(spline);
|
|
382
|
+
|
|
383
|
+
return spline;
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
generateBSplineKnots(n, degree) {
|
|
387
|
+
/**
|
|
388
|
+
* Generate clamped B-spline knot vector.
|
|
389
|
+
* Knot vector has n + degree + 1 values.
|
|
390
|
+
* For clamped spline: first and last (degree+1) knots are at 0 and 1.
|
|
391
|
+
*/
|
|
392
|
+
const knots = [];
|
|
393
|
+
const knotCount = n + degree + 1;
|
|
394
|
+
|
|
395
|
+
// Clamp at start
|
|
396
|
+
for (let i = 0; i <= degree; i++) {
|
|
397
|
+
knots.push(0);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Interior knots distributed uniformly
|
|
401
|
+
const interior = knotCount - 2 * (degree + 1);
|
|
402
|
+
for (let i = 1; i <= interior; i++) {
|
|
403
|
+
knots.push(i / (interior + 1));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Clamp at end
|
|
407
|
+
for (let i = 0; i <= degree; i++) {
|
|
408
|
+
knots.push(1);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return knots;
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
evaluateBSpline(controlPoints, t, degree = 3) {
|
|
415
|
+
/**
|
|
416
|
+
* DE BOOR'S ALGORITHM for cubic B-spline evaluation
|
|
417
|
+
*
|
|
418
|
+
* Given control points P0..Pn, knot vector U, and parameter t,
|
|
419
|
+
* compute point on curve at parameter t.
|
|
420
|
+
*
|
|
421
|
+
* Algorithm:
|
|
422
|
+
* 1. Find knot span k such that U[k] <= t < U[k+1]
|
|
423
|
+
* 2. For d = 1 to degree:
|
|
424
|
+
* 3. For i = k-degree+d to k:
|
|
425
|
+
* 4. Compute intermediate points using affine combination
|
|
426
|
+
* 5. Return the single intermediate point
|
|
427
|
+
*/
|
|
428
|
+
if (controlPoints.length < degree + 1) {
|
|
429
|
+
throw new Error('Not enough control points for degree');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Generate knot vector if not provided
|
|
433
|
+
const knots = this.generateBSplineKnots(controlPoints.length, degree);
|
|
434
|
+
|
|
435
|
+
// Find knot span k such that knots[k] <= t < knots[k+1]
|
|
436
|
+
let k = 0;
|
|
437
|
+
for (let i = 0; i < knots.length - 1; i++) {
|
|
438
|
+
if (knots[i] <= t && t < knots[i + 1]) {
|
|
439
|
+
k = i;
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Handle edge case: t == 1.0
|
|
444
|
+
if (t === 1.0) k = knots.length - degree - 2;
|
|
445
|
+
|
|
446
|
+
// Initialize with control points
|
|
447
|
+
const d = [];
|
|
448
|
+
for (let j = k - degree; j <= k; j++) {
|
|
449
|
+
d[j] = controlPoints[j] ? controlPoints[j].clone() : new THREE.Vector2(0, 0);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// De Boor recurrence
|
|
453
|
+
for (let r = 1; r <= degree; r++) {
|
|
454
|
+
for (let j = k; j >= k - degree + r; j--) {
|
|
455
|
+
const alpha = (t - knots[j]) / (knots[j + degree - r + 1] - knots[j]);
|
|
456
|
+
if (isNaN(alpha) || !isFinite(alpha)) {
|
|
457
|
+
continue; // Skip degenerate knot spans
|
|
458
|
+
}
|
|
459
|
+
// d[j] = (1-alpha) * d[j-1] + alpha * d[j]
|
|
460
|
+
const d_prev = d[j - 1] || new THREE.Vector2(0, 0);
|
|
461
|
+
d[j] = new THREE.Vector2(
|
|
462
|
+
(1 - alpha) * d_prev.x + alpha * d[j].x,
|
|
463
|
+
(1 - alpha) * d_prev.y + alpha * d[j].y
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return d[k];
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
renderSplineControlPolygon(spline) {
|
|
472
|
+
// Render dashed line connecting control points
|
|
473
|
+
if (!this.state.canvasGroup) return;
|
|
474
|
+
|
|
475
|
+
const existing = this.state.canvasGroup.children.find(c => c.userData.entityId === spline.id + '_polygon');
|
|
476
|
+
if (existing) this.state.canvasGroup.remove(existing);
|
|
477
|
+
|
|
478
|
+
const geometry = new THREE.BufferGeometry().setFromPoints(spline.points);
|
|
479
|
+
const material = new THREE.LineDashedMaterial({
|
|
480
|
+
color: 0x888888,
|
|
481
|
+
dashSize: 0.5,
|
|
482
|
+
gapSize: 0.3,
|
|
483
|
+
linewidth: 0.05
|
|
484
|
+
});
|
|
485
|
+
const line = new THREE.Line(geometry, material);
|
|
486
|
+
line.userData.entityId = spline.id + '_polygon';
|
|
487
|
+
line.computeLineDistances();
|
|
488
|
+
this.state.canvasGroup.add(line);
|
|
250
489
|
},
|
|
251
490
|
|
|
252
491
|
drawPolygon(center, sides, radius, circumscribed = true) {
|
|
@@ -283,75 +522,215 @@ const SketchModule = {
|
|
|
283
522
|
// ===== EDITING TOOLS =====
|
|
284
523
|
|
|
285
524
|
trim(entityId, clickPoint) {
|
|
525
|
+
/**
|
|
526
|
+
* TRIM TOOL: Remove segment between two intersections or endpoints
|
|
527
|
+
*
|
|
528
|
+
* Algorithm:
|
|
529
|
+
* 1. Find all intersection points on entity with other entities
|
|
530
|
+
* 2. Sort intersections along entity's parametric direction
|
|
531
|
+
* 3. Find which segment the click point falls into
|
|
532
|
+
* 4. Remove that segment, keeping others
|
|
533
|
+
* 5. For lines: creates two new lines. For arcs: splits arc.
|
|
534
|
+
*/
|
|
286
535
|
const entity = this.state.entities.find(e => e.id === entityId);
|
|
287
|
-
if (!entity) return;
|
|
536
|
+
if (!entity || !['line', 'arc', 'spline'].includes(entity.type)) return;
|
|
537
|
+
|
|
538
|
+
// Find all intersection points on this entity with other entities
|
|
539
|
+
const intersections = this.findAllIntersectionsOnEntity(entity);
|
|
540
|
+
if (intersections.length === 0) return;
|
|
541
|
+
|
|
542
|
+
// Sort intersections by parametric position along entity
|
|
543
|
+
const sortedInts = intersections.sort((a, b) => a.t - b.t);
|
|
544
|
+
|
|
545
|
+
// Find which segment the click falls into
|
|
546
|
+
let segmentStart = null, segmentEnd = null;
|
|
547
|
+
for (let i = 0; i < sortedInts.length - 1; i++) {
|
|
548
|
+
const midPoint = new THREE.Vector2(
|
|
549
|
+
(sortedInts[i].point.x + sortedInts[i + 1].point.x) / 2,
|
|
550
|
+
(sortedInts[i].point.y + sortedInts[i + 1].point.y) / 2
|
|
551
|
+
);
|
|
552
|
+
if (midPoint.distanceTo(clickPoint) < this.state.snapDistance) {
|
|
553
|
+
segmentStart = sortedInts[i];
|
|
554
|
+
segmentEnd = sortedInts[i + 1];
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
288
558
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
559
|
+
if (!segmentStart || !segmentEnd) return;
|
|
560
|
+
|
|
561
|
+
// Remove segment and keep remaining pieces
|
|
562
|
+
if (entity.type === 'line') {
|
|
563
|
+
this.splitLineAtTrim(entity, segmentStart, segmentEnd);
|
|
564
|
+
} else if (entity.type === 'arc') {
|
|
565
|
+
this.splitArcAtTrim(entity, segmentStart, segmentEnd);
|
|
566
|
+
} else if (entity.type === 'spline') {
|
|
567
|
+
this.splitSplineAtTrim(entity, segmentStart, segmentEnd);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
this.renderEntity(entity);
|
|
571
|
+
window.dispatchEvent(new CustomEvent('sketch:entityModified', { detail: { entity } }));
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
splitLineAtTrim(entity, intStart, intEnd) {
|
|
575
|
+
// For line: keep segments before intStart and after intEnd, discard middle
|
|
576
|
+
const [p1, p2] = entity.points;
|
|
577
|
+
|
|
578
|
+
// Create first segment: p1 to intStart
|
|
579
|
+
if (intStart.t > 0.01) {
|
|
580
|
+
this.addEntity('line', {
|
|
581
|
+
points: [p1, intStart.point],
|
|
582
|
+
isConstruction: entity.isConstruction
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Create second segment: intEnd to p2
|
|
587
|
+
if (intEnd.t < 0.99) {
|
|
588
|
+
this.addEntity('line', {
|
|
589
|
+
points: [intEnd.point, p2],
|
|
590
|
+
isConstruction: entity.isConstruction
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Remove original line
|
|
595
|
+
const idx = this.state.entities.indexOf(entity);
|
|
596
|
+
if (idx > -1) this.state.entities.splice(idx, 1);
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
splitArcAtTrim(entity, intStart, intEnd) {
|
|
600
|
+
// For arc: keep segments before intStart and after intEnd
|
|
601
|
+
const { radius, startAngle, endAngle } = entity.data;
|
|
602
|
+
const [center] = entity.points;
|
|
603
|
+
|
|
604
|
+
// Calculate angles at intersection points
|
|
605
|
+
const intStartAngle = Math.atan2(intStart.point.y - center.y, intStart.point.x - center.x);
|
|
606
|
+
const intEndAngle = Math.atan2(intEnd.point.y - center.y, intEnd.point.x - center.x);
|
|
607
|
+
|
|
608
|
+
// Create first arc: startAngle to intStartAngle
|
|
609
|
+
if (Math.abs(intStartAngle - startAngle) > 0.01) {
|
|
610
|
+
this.addEntity('arc', {
|
|
611
|
+
points: [
|
|
612
|
+
new THREE.Vector2(center.x + Math.cos(startAngle) * radius, center.y + Math.sin(startAngle) * radius),
|
|
613
|
+
intStart.point,
|
|
614
|
+
center
|
|
615
|
+
],
|
|
616
|
+
data: { radius, startAngle, endAngle: intStartAngle },
|
|
617
|
+
isConstruction: entity.isConstruction
|
|
618
|
+
});
|
|
619
|
+
}
|
|
295
620
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
this.
|
|
621
|
+
// Create second arc: intEndAngle to endAngle
|
|
622
|
+
if (Math.abs(endAngle - intEndAngle) > 0.01) {
|
|
623
|
+
this.addEntity('arc', {
|
|
624
|
+
points: [
|
|
625
|
+
intEnd.point,
|
|
626
|
+
new THREE.Vector2(center.x + Math.cos(endAngle) * radius, center.y + Math.sin(endAngle) * radius),
|
|
627
|
+
center
|
|
628
|
+
],
|
|
629
|
+
data: { radius, startAngle: intEndAngle, endAngle },
|
|
630
|
+
isConstruction: entity.isConstruction
|
|
631
|
+
});
|
|
299
632
|
}
|
|
633
|
+
|
|
634
|
+
// Remove original arc
|
|
635
|
+
const idx = this.state.entities.indexOf(entity);
|
|
636
|
+
if (idx > -1) this.state.entities.splice(idx, 1);
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
splitSplineAtTrim(entity, intStart, intEnd) {
|
|
640
|
+
// For spline: split at parametric values and keep segments
|
|
641
|
+
const points = entity.points;
|
|
642
|
+
|
|
643
|
+
// Evaluate spline at intStart.t and intEnd.t to get split points
|
|
644
|
+
const p1 = this.evaluateBSpline(points, intStart.t);
|
|
645
|
+
const p2 = this.evaluateBSpline(points, intEnd.t);
|
|
646
|
+
|
|
647
|
+
// First segment: start to intStart
|
|
648
|
+
const segmentCount = Math.ceil(intStart.t * points.length);
|
|
649
|
+
const firstSegmentPoints = points.slice(0, segmentCount);
|
|
650
|
+
firstSegmentPoints.push(p1);
|
|
651
|
+
this.addEntity('spline', {
|
|
652
|
+
points: firstSegmentPoints,
|
|
653
|
+
data: { degree: entity.data.degree },
|
|
654
|
+
isConstruction: entity.isConstruction
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Second segment: intEnd to end
|
|
658
|
+
const startCount = Math.ceil(intEnd.t * points.length);
|
|
659
|
+
const secondSegmentPoints = [p2, ...points.slice(startCount)];
|
|
660
|
+
this.addEntity('spline', {
|
|
661
|
+
points: secondSegmentPoints,
|
|
662
|
+
data: { degree: entity.data.degree },
|
|
663
|
+
isConstruction: entity.isConstruction
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Remove original spline
|
|
667
|
+
const idx = this.state.entities.indexOf(entity);
|
|
668
|
+
if (idx > -1) this.state.entities.splice(idx, 1);
|
|
300
669
|
},
|
|
301
670
|
|
|
302
671
|
extend(entityId) {
|
|
672
|
+
/**
|
|
673
|
+
* EXTEND TOOL: Extend line or arc to nearest intersection with other geometry
|
|
674
|
+
*
|
|
675
|
+
* Algorithm:
|
|
676
|
+
* 1. Identify the endpoint to extend (the one furthest from everything)
|
|
677
|
+
* 2. Find all potential intersection targets (other lines, circles, arcs)
|
|
678
|
+
* 3. Compute intersection point with each target
|
|
679
|
+
* 4. Pick nearest intersection
|
|
680
|
+
* 5. Move endpoint to intersection point
|
|
681
|
+
*/
|
|
303
682
|
const entity = this.state.entities.find(e => e.id === entityId);
|
|
304
683
|
if (!entity || !['line', 'arc'].includes(entity.type)) return;
|
|
305
684
|
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
685
|
+
// Identify which endpoint to extend (the one that's not connected to anything)
|
|
686
|
+
let extendPoint = null;
|
|
687
|
+
if (entity.type === 'line') {
|
|
688
|
+
const [p1, p2] = entity.points;
|
|
689
|
+
// Heuristic: extend from the endpoint closer to mouse (or the second one if ambiguous)
|
|
690
|
+
extendPoint = p2;
|
|
691
|
+
} else if (entity.type === 'arc') {
|
|
692
|
+
extendPoint = entity.points[1]; // endAngle point
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (!extendPoint) return;
|
|
696
|
+
|
|
697
|
+
// Find all intersection points with other entities
|
|
698
|
+
const candidates = [];
|
|
699
|
+
this.state.entities.forEach(other => {
|
|
700
|
+
if (other.id === entityId) return;
|
|
701
|
+
|
|
702
|
+
const ints = this.findIntersectionsBetween(entity, other);
|
|
703
|
+
candidates.push(...ints.map(int => ({ ...int, targetId: other.id })));
|
|
313
704
|
});
|
|
314
705
|
|
|
315
|
-
if (
|
|
316
|
-
const nearest = allIntersections.reduce((n, int) => {
|
|
317
|
-
const d = int.distanceTo(entity.points[entity.points.length - 1]);
|
|
318
|
-
return d < n.dist ? { point: int, dist: d } : n;
|
|
319
|
-
}, { dist: Infinity });
|
|
706
|
+
if (candidates.length === 0) return;
|
|
320
707
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
708
|
+
// Find nearest candidate to the extension point
|
|
709
|
+
const nearest = candidates.reduce((closest, cand) => {
|
|
710
|
+
const dist = cand.point.distanceTo(extendPoint);
|
|
711
|
+
return dist < closest.dist && dist > 0.1 ? { ...cand, dist } : closest;
|
|
712
|
+
}, { dist: Infinity });
|
|
325
713
|
|
|
326
|
-
|
|
327
|
-
entityIds.forEach(id => {
|
|
328
|
-
const entity = this.state.entities.find(e => e.id === id);
|
|
329
|
-
if (!entity) return;
|
|
714
|
+
if (nearest.dist === Infinity) return;
|
|
330
715
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
716
|
+
// Update entity endpoint to intersection point
|
|
717
|
+
if (entity.type === 'line') {
|
|
718
|
+
entity.points[entity.points.length - 1] = nearest.point;
|
|
719
|
+
} else if (entity.type === 'arc') {
|
|
720
|
+
entity.points[1] = nearest.point;
|
|
721
|
+
// Recalculate endAngle
|
|
722
|
+
const [, , center] = entity.points;
|
|
723
|
+
entity.data.endAngle = Math.atan2(
|
|
724
|
+
nearest.point.y - center.y,
|
|
725
|
+
nearest.point.x - center.x
|
|
726
|
+
);
|
|
727
|
+
}
|
|
345
728
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
points: offsetPoints,
|
|
349
|
-
isConstruction: entity.isConstruction
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
});
|
|
729
|
+
this.renderEntity(entity);
|
|
730
|
+
window.dispatchEvent(new CustomEvent('sketch:entityModified', { detail: { entity } }));
|
|
353
731
|
},
|
|
354
732
|
|
|
733
|
+
|
|
355
734
|
mirror(entityIds, lineId) {
|
|
356
735
|
const mirrorLine = this.state.entities.find(e => e.id === lineId);
|
|
357
736
|
if (!mirrorLine || mirrorLine.type !== 'line') return;
|
|
@@ -416,25 +795,226 @@ const SketchModule = {
|
|
|
416
795
|
});
|
|
417
796
|
},
|
|
418
797
|
|
|
419
|
-
// ===== DIMENSIONS =====
|
|
798
|
+
// ===== DIMENSIONS (DRIVING CONSTRAINTS) =====
|
|
420
799
|
|
|
421
800
|
addDimension(type, entityIds, value) {
|
|
801
|
+
/**
|
|
802
|
+
* DIMENSIONS: Driving constraints that control geometry
|
|
803
|
+
*
|
|
804
|
+
* Types:
|
|
805
|
+
* - 'linear': distance between two points or parallel lines
|
|
806
|
+
* - 'angular': angle between two lines
|
|
807
|
+
* - 'radial': radius of circle/arc (displays "R25")
|
|
808
|
+
* - 'diameter': diameter of circle (displays "⌀50")
|
|
809
|
+
* - 'vertical': vertical distance
|
|
810
|
+
* - 'horizontal': horizontal distance
|
|
811
|
+
*
|
|
812
|
+
* When dimension value changes, geometry is scaled/rotated to match.
|
|
813
|
+
* Dimension lines are rendered with arrows, extension lines, and text.
|
|
814
|
+
*/
|
|
422
815
|
const dimension = {
|
|
423
816
|
id: `dim_${Date.now()}`,
|
|
424
817
|
type,
|
|
425
818
|
entities: entityIds,
|
|
426
819
|
value,
|
|
427
|
-
driven: true
|
|
820
|
+
driven: true,
|
|
821
|
+
position: new THREE.Vector2(0, 0), // position of dimension line
|
|
822
|
+
rotation: 0, // rotation angle for text
|
|
823
|
+
isSelected: false
|
|
428
824
|
};
|
|
429
825
|
|
|
430
826
|
this.state.dimensions.push(dimension);
|
|
827
|
+
|
|
828
|
+
// Render dimension to canvas
|
|
829
|
+
this.renderDimension(dimension);
|
|
830
|
+
|
|
831
|
+
// Apply dimension constraint: modify geometry to match value
|
|
832
|
+
this.applyDimensionConstraint(dimension);
|
|
833
|
+
|
|
431
834
|
window.dispatchEvent(new CustomEvent('sketch:dimensionAdded', { detail: { dimension } }));
|
|
432
835
|
return dimension;
|
|
433
836
|
},
|
|
434
837
|
|
|
435
|
-
|
|
838
|
+
renderDimension(dimension) {
|
|
839
|
+
/**
|
|
840
|
+
* Render dimension line with arrows, extension lines, and text label.
|
|
841
|
+
*
|
|
842
|
+
* Layout:
|
|
843
|
+
* Entity geometry
|
|
844
|
+
* |
|
|
845
|
+
* Extension line
|
|
846
|
+
* |
|
|
847
|
+
* [---arrow---20mm---arrow---] <- Dimension line
|
|
848
|
+
* |
|
|
849
|
+
* Extension line
|
|
850
|
+
* |
|
|
851
|
+
* Entity geometry
|
|
852
|
+
*/
|
|
853
|
+
if (!this.state.ctx) return;
|
|
854
|
+
|
|
855
|
+
const ctx = this.state.ctx;
|
|
856
|
+
const entities = dimension.entities
|
|
857
|
+
.map(id => this.state.entities.find(e => e.id === id))
|
|
858
|
+
.filter(e => e);
|
|
859
|
+
|
|
860
|
+
if (entities.length < 2) return;
|
|
861
|
+
|
|
862
|
+
let startPoint, endPoint;
|
|
863
|
+
|
|
864
|
+
if (dimension.type === 'linear') {
|
|
865
|
+
// Linear dimension: measure distance between two points or entities
|
|
866
|
+
const e1 = entities[0], e2 = entities[1];
|
|
867
|
+
startPoint = e1.points[0];
|
|
868
|
+
endPoint = e2.points[0];
|
|
869
|
+
} else if (dimension.type === 'radial' || dimension.type === 'diameter') {
|
|
870
|
+
// Radial dimension: measure radius/diameter of circle or arc
|
|
871
|
+
const circle = entities[0];
|
|
872
|
+
if (!['circle', 'arc'].includes(circle.type)) return;
|
|
873
|
+
|
|
874
|
+
const center = circle.points[0];
|
|
875
|
+
const radius = circle.data.radius;
|
|
876
|
+
startPoint = center;
|
|
877
|
+
endPoint = new THREE.Vector2(center.x + radius, center.y);
|
|
878
|
+
} else if (dimension.type === 'angular') {
|
|
879
|
+
// Angular dimension: measure angle between two lines
|
|
880
|
+
const [e1, e2] = entities;
|
|
881
|
+
if (!e1.points || !e2.points) return;
|
|
882
|
+
startPoint = e1.points[0];
|
|
883
|
+
endPoint = e2.points[0];
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (!startPoint || !endPoint) return;
|
|
887
|
+
|
|
888
|
+
// Dimension line position (offset from geometry)
|
|
889
|
+
const dimOffset = 20; // pixels
|
|
890
|
+
const midPoint = new THREE.Vector2(
|
|
891
|
+
(startPoint.x + endPoint.x) / 2,
|
|
892
|
+
(startPoint.y + endPoint.y) / 2
|
|
893
|
+
);
|
|
894
|
+
const direction = new THREE.Vector2(endPoint.x - startPoint.x, endPoint.y - startPoint.y).normalize();
|
|
895
|
+
const perpendicular = new THREE.Vector2(-direction.y, direction.x).multiplyScalar(dimOffset);
|
|
896
|
+
|
|
897
|
+
const dimStart = new THREE.Vector2(startPoint.x + perpendicular.x, startPoint.y + perpendicular.y);
|
|
898
|
+
const dimEnd = new THREE.Vector2(endPoint.x + perpendicular.x, endPoint.y + perpendicular.y);
|
|
899
|
+
|
|
900
|
+
// Draw extension lines (from geometry to dimension line)
|
|
901
|
+
ctx.strokeStyle = '#00ff00';
|
|
902
|
+
ctx.lineWidth = 1;
|
|
903
|
+
|
|
904
|
+
ctx.beginPath();
|
|
905
|
+
ctx.moveTo(startPoint.x, startPoint.y);
|
|
906
|
+
ctx.lineTo(dimStart.x, dimStart.y);
|
|
907
|
+
ctx.stroke();
|
|
908
|
+
|
|
909
|
+
ctx.beginPath();
|
|
910
|
+
ctx.moveTo(endPoint.x, endPoint.y);
|
|
911
|
+
ctx.lineTo(dimEnd.x, dimEnd.y);
|
|
912
|
+
ctx.stroke();
|
|
913
|
+
|
|
914
|
+
// Draw dimension line with arrows
|
|
915
|
+
ctx.beginPath();
|
|
916
|
+
ctx.moveTo(dimStart.x, dimStart.y);
|
|
917
|
+
ctx.lineTo(dimEnd.x, dimEnd.y);
|
|
918
|
+
ctx.stroke();
|
|
919
|
+
|
|
920
|
+
// Draw arrows (triangles at each end)
|
|
921
|
+
const arrowSize = 4;
|
|
922
|
+
this.drawArrow(ctx, dimStart, direction, arrowSize);
|
|
923
|
+
this.drawArrow(ctx, dimEnd, direction.clone().negate(), arrowSize);
|
|
924
|
+
|
|
925
|
+
// Draw text label
|
|
926
|
+
const textValue = dimension.type === 'radial' ? `R${dimension.value}` :
|
|
927
|
+
dimension.type === 'diameter' ? `⌀${dimension.value}` :
|
|
928
|
+
`${dimension.value}mm`;
|
|
929
|
+
const textPos = new THREE.Vector2(
|
|
930
|
+
(dimStart.x + dimEnd.x) / 2,
|
|
931
|
+
(dimStart.y + dimEnd.y) / 2 - 10
|
|
932
|
+
);
|
|
933
|
+
|
|
934
|
+
ctx.font = 'bold 12px Arial';
|
|
935
|
+
ctx.fillStyle = '#00ff00';
|
|
936
|
+
ctx.textAlign = 'center';
|
|
937
|
+
ctx.textBaseline = 'bottom';
|
|
938
|
+
ctx.fillText(textValue, textPos.x, textPos.y);
|
|
939
|
+
|
|
940
|
+
dimension.position = midPoint;
|
|
941
|
+
},
|
|
942
|
+
|
|
943
|
+
drawArrow(ctx, point, direction, size) {
|
|
944
|
+
// Draw arrow head (triangle)
|
|
945
|
+
const perp = new THREE.Vector2(-direction.y, direction.x).multiplyScalar(size / 2);
|
|
946
|
+
const back = direction.clone().multiplyScalar(-size).add(point);
|
|
947
|
+
|
|
948
|
+
ctx.beginPath();
|
|
949
|
+
ctx.moveTo(point.x, point.y);
|
|
950
|
+
ctx.lineTo(back.x + perp.x, back.y + perp.y);
|
|
951
|
+
ctx.lineTo(back.x - perp.x, back.y - perp.y);
|
|
952
|
+
ctx.closePath();
|
|
953
|
+
ctx.fill();
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
applyDimensionConstraint(dimension) {
|
|
957
|
+
/**
|
|
958
|
+
* Apply dimension constraint: modify geometry to match dimension value
|
|
959
|
+
*
|
|
960
|
+
* For linear dimension: scale/move geometry
|
|
961
|
+
* For radial: change radius of circle/arc
|
|
962
|
+
* For angular: rotate one entity to match angle
|
|
963
|
+
*/
|
|
964
|
+
if (!dimension.driven) return;
|
|
965
|
+
|
|
966
|
+
const entities = dimension.entities
|
|
967
|
+
.map(id => this.state.entities.find(e => e.id === id))
|
|
968
|
+
.filter(e => e);
|
|
969
|
+
|
|
970
|
+
if (dimension.type === 'linear' && entities.length >= 2) {
|
|
971
|
+
const [e1, e2] = entities;
|
|
972
|
+
const currentDist = e1.points[0].distanceTo(e2.points[0]);
|
|
973
|
+
const scale = dimension.value / currentDist;
|
|
974
|
+
|
|
975
|
+
// Move e2 to match dimension value
|
|
976
|
+
const dir = new THREE.Vector2(e2.points[0].x - e1.points[0].x, e2.points[0].y - e1.points[0].y)
|
|
977
|
+
.normalize()
|
|
978
|
+
.multiplyScalar(dimension.value);
|
|
979
|
+
e2.points[0] = new THREE.Vector2(e1.points[0].x + dir.x, e1.points[0].y + dir.y);
|
|
980
|
+
|
|
981
|
+
this.renderEntity(e2);
|
|
982
|
+
} else if ((dimension.type === 'radial' || dimension.type === 'diameter') && entities.length > 0) {
|
|
983
|
+
const circle = entities[0];
|
|
984
|
+
if (['circle', 'arc'].includes(circle.type)) {
|
|
985
|
+
const newRadius = dimension.type === 'diameter' ? dimension.value / 2 : dimension.value;
|
|
986
|
+
circle.data.radius = newRadius;
|
|
987
|
+
this.renderEntity(circle);
|
|
988
|
+
}
|
|
989
|
+
} else if (dimension.type === 'angular' && entities.length >= 2) {
|
|
990
|
+
const [e1, e2] = entities;
|
|
991
|
+
// Rotate e2 around e1's origin to match angle
|
|
992
|
+
const angle = dimension.value * Math.PI / 180;
|
|
993
|
+
const dir = new THREE.Vector2(Math.cos(angle), Math.sin(angle));
|
|
994
|
+
|
|
995
|
+
e2.points = e2.points.map(p => {
|
|
996
|
+
const relative = new THREE.Vector2(p.x - e1.points[0].x, p.y - e1.points[0].y);
|
|
997
|
+
const rotated = new THREE.Vector2(
|
|
998
|
+
relative.x * Math.cos(angle) - relative.y * Math.sin(angle),
|
|
999
|
+
relative.x * Math.sin(angle) + relative.y * Math.cos(angle)
|
|
1000
|
+
);
|
|
1001
|
+
return new THREE.Vector2(e1.points[0].x + rotated.x, e1.points[0].y + rotated.y);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
this.renderEntity(e2);
|
|
1005
|
+
}
|
|
1006
|
+
},
|
|
1007
|
+
|
|
1008
|
+
// ===== CONSTRUCTION GEOMETRY & CONSTRAINTS =====
|
|
436
1009
|
|
|
437
1010
|
toggleConstruction(entityIds) {
|
|
1011
|
+
/**
|
|
1012
|
+
* CONSTRUCTION GEOMETRY: Reference-only entities not included in sketch profile
|
|
1013
|
+
*
|
|
1014
|
+
* Construction entities are rendered as dashed lines and not used when
|
|
1015
|
+
* extruding or revolving the sketch. Useful for reference geometry,
|
|
1016
|
+
* centerlines, and construction lines.
|
|
1017
|
+
*/
|
|
438
1018
|
entityIds.forEach(id => {
|
|
439
1019
|
const entity = this.state.entities.find(e => e.id === id);
|
|
440
1020
|
if (entity) {
|
|
@@ -444,6 +1024,82 @@ const SketchModule = {
|
|
|
444
1024
|
});
|
|
445
1025
|
},
|
|
446
1026
|
|
|
1027
|
+
offset(entityIds, distance) {
|
|
1028
|
+
/**
|
|
1029
|
+
* OFFSET CURVES: Create parallel copies at given distance
|
|
1030
|
+
*
|
|
1031
|
+
* For lines: shift perpendicular by distance
|
|
1032
|
+
* For circles: change radius (inward/outward)
|
|
1033
|
+
* For arcs: change radius, keep center and angular span
|
|
1034
|
+
* For splines: offset control points along normal direction
|
|
1035
|
+
*/
|
|
1036
|
+
entityIds.forEach(id => {
|
|
1037
|
+
const entity = this.state.entities.find(e => e.id === id);
|
|
1038
|
+
if (!entity) return;
|
|
1039
|
+
|
|
1040
|
+
if (entity.type === 'line' && entity.points.length === 2) {
|
|
1041
|
+
// Offset line: shift perpendicular to direction
|
|
1042
|
+
const [p1, p2] = entity.points;
|
|
1043
|
+
const dir = p2.clone().sub(p1).normalize();
|
|
1044
|
+
const perp = new THREE.Vector2(-dir.y, dir.x).multiplyScalar(distance);
|
|
1045
|
+
|
|
1046
|
+
this.addEntity('line', {
|
|
1047
|
+
points: [p1.clone().add(perp), p2.clone().add(perp)],
|
|
1048
|
+
isConstruction: entity.isConstruction
|
|
1049
|
+
});
|
|
1050
|
+
} else if (entity.type === 'circle') {
|
|
1051
|
+
// Offset circle: change radius
|
|
1052
|
+
const newRadius = entity.data.radius + distance;
|
|
1053
|
+
if (newRadius > 0.1) {
|
|
1054
|
+
this.addEntity('circle', {
|
|
1055
|
+
points: entity.points.map(p => p.clone()),
|
|
1056
|
+
data: { radius: newRadius },
|
|
1057
|
+
isConstruction: entity.isConstruction
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
} else if (entity.type === 'arc') {
|
|
1061
|
+
// Offset arc: change radius, keep center and angles
|
|
1062
|
+
const newRadius = entity.data.radius + distance;
|
|
1063
|
+
if (newRadius > 0.1) {
|
|
1064
|
+
this.addEntity('arc', {
|
|
1065
|
+
points: entity.points.map(p => p.clone()),
|
|
1066
|
+
data: {
|
|
1067
|
+
radius: newRadius,
|
|
1068
|
+
startAngle: entity.data.startAngle,
|
|
1069
|
+
endAngle: entity.data.endAngle
|
|
1070
|
+
},
|
|
1071
|
+
isConstruction: entity.isConstruction
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
} else if (entity.type === 'spline') {
|
|
1075
|
+
// Offset spline: offset control points along normal direction
|
|
1076
|
+
// Use perpendicular direction at each control point
|
|
1077
|
+
const offsetPoints = entity.points.map((point, i) => {
|
|
1078
|
+
if (i === 0 || i === entity.points.length - 1) {
|
|
1079
|
+
// End points: use direction to next/prev point
|
|
1080
|
+
const nextPoint = i === 0 ? entity.points[1] : entity.points[i - 1];
|
|
1081
|
+
const dir = new THREE.Vector2(nextPoint.x - point.x, nextPoint.y - point.y).normalize();
|
|
1082
|
+
const perp = new THREE.Vector2(-dir.y, dir.x).multiplyScalar(distance);
|
|
1083
|
+
return point.clone().add(perp);
|
|
1084
|
+
} else {
|
|
1085
|
+
// Interior points: average of prev and next directions
|
|
1086
|
+
const prevDir = new THREE.Vector2(point.x - entity.points[i - 1].x, point.y - entity.points[i - 1].y).normalize();
|
|
1087
|
+
const nextDir = new THREE.Vector2(entity.points[i + 1].x - point.x, entity.points[i + 1].y - point.y).normalize();
|
|
1088
|
+
const avgDir = new THREE.Vector2(prevDir.x + nextDir.x, prevDir.y + nextDir.y).normalize();
|
|
1089
|
+
const perp = new THREE.Vector2(-avgDir.y, avgDir.x).multiplyScalar(distance);
|
|
1090
|
+
return point.clone().add(perp);
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
this.addEntity('spline', {
|
|
1095
|
+
points: offsetPoints,
|
|
1096
|
+
data: { degree: entity.data.degree },
|
|
1097
|
+
isConstruction: entity.isConstruction
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
},
|
|
1102
|
+
|
|
447
1103
|
// ===== RENDERING =====
|
|
448
1104
|
|
|
449
1105
|
setupCanvasOverlay() {
|
|
@@ -479,8 +1135,12 @@ const SketchModule = {
|
|
|
479
1135
|
|
|
480
1136
|
switch (entity.type) {
|
|
481
1137
|
case 'line':
|
|
482
|
-
geometry = new THREE.BufferGeometry().setFromPoints(
|
|
483
|
-
|
|
1138
|
+
geometry = new THREE.BufferGeometry().setFromPoints(
|
|
1139
|
+
entity.points.map(p => new THREE.Vector3(p.x, p.y, 0))
|
|
1140
|
+
);
|
|
1141
|
+
material = entity.isConstruction
|
|
1142
|
+
? new THREE.LineDashedMaterial({ color, linewidth, dashSize: 2, gapSize: 2 })
|
|
1143
|
+
: new THREE.LineBasicMaterial({ color, linewidth });
|
|
484
1144
|
break;
|
|
485
1145
|
|
|
486
1146
|
case 'circle':
|
|
@@ -497,7 +1157,9 @@ const SketchModule = {
|
|
|
497
1157
|
));
|
|
498
1158
|
}
|
|
499
1159
|
geometry.setFromPoints(circlePoints);
|
|
500
|
-
material =
|
|
1160
|
+
material = entity.isConstruction
|
|
1161
|
+
? new THREE.LineDashedMaterial({ color, linewidth, dashSize: 2, gapSize: 2 })
|
|
1162
|
+
: new THREE.LineBasicMaterial({ color, linewidth });
|
|
501
1163
|
break;
|
|
502
1164
|
|
|
503
1165
|
case 'arc':
|
|
@@ -516,7 +1178,25 @@ const SketchModule = {
|
|
|
516
1178
|
));
|
|
517
1179
|
}
|
|
518
1180
|
geometry.setFromPoints(arcPoints);
|
|
519
|
-
material =
|
|
1181
|
+
material = entity.isConstruction
|
|
1182
|
+
? new THREE.LineDashedMaterial({ color, linewidth, dashSize: 2, gapSize: 2 })
|
|
1183
|
+
: new THREE.LineBasicMaterial({ color, linewidth });
|
|
1184
|
+
break;
|
|
1185
|
+
|
|
1186
|
+
case 'spline':
|
|
1187
|
+
// Render spline curve evaluated at high resolution
|
|
1188
|
+
geometry = new THREE.BufferGeometry();
|
|
1189
|
+
const splinePoints = [];
|
|
1190
|
+
const samples = 128; // High resolution curve
|
|
1191
|
+
for (let i = 0; i <= samples; i++) {
|
|
1192
|
+
const t = i / samples;
|
|
1193
|
+
const point = this.evaluateBSpline(entity.points, t, entity.data.degree);
|
|
1194
|
+
splinePoints.push(new THREE.Vector3(point.x, point.y, 0));
|
|
1195
|
+
}
|
|
1196
|
+
geometry.setFromPoints(splinePoints);
|
|
1197
|
+
material = entity.isConstruction
|
|
1198
|
+
? new THREE.LineDashedMaterial({ color, linewidth, dashSize: 2, gapSize: 2 })
|
|
1199
|
+
: new THREE.LineBasicMaterial({ color, linewidth });
|
|
520
1200
|
break;
|
|
521
1201
|
|
|
522
1202
|
default:
|
|
@@ -525,6 +1205,12 @@ const SketchModule = {
|
|
|
525
1205
|
|
|
526
1206
|
const line = new THREE.Line(geometry, material);
|
|
527
1207
|
line.userData.entityId = entity.id;
|
|
1208
|
+
|
|
1209
|
+
// Set line distance for dashed material
|
|
1210
|
+
if (material instanceof THREE.LineDashedMaterial) {
|
|
1211
|
+
line.computeLineDistances();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
528
1214
|
this.state.canvasGroup.add(line);
|
|
529
1215
|
},
|
|
530
1216
|
|
|
@@ -534,57 +1220,264 @@ const SketchModule = {
|
|
|
534
1220
|
}
|
|
535
1221
|
},
|
|
536
1222
|
|
|
537
|
-
// =====
|
|
1223
|
+
// ===== INTERSECTION ENGINE =====
|
|
1224
|
+
/**
|
|
1225
|
+
* ROBUST GEOMETRIC INTERSECTION FUNCTIONS
|
|
1226
|
+
*
|
|
1227
|
+
* Each function returns parametric values (t) along the first entity
|
|
1228
|
+
* and the actual intersection point(s). This allows trim() to know
|
|
1229
|
+
* WHERE on the entity to split.
|
|
1230
|
+
*/
|
|
1231
|
+
|
|
1232
|
+
findAllIntersectionsOnEntity(entity) {
|
|
1233
|
+
/**
|
|
1234
|
+
* Find all intersection points on a given entity with all other entities.
|
|
1235
|
+
* Returns array of { point, t, otherEntity } sorted by parametric position.
|
|
1236
|
+
*/
|
|
1237
|
+
const intersections = [];
|
|
1238
|
+
|
|
1239
|
+
this.state.entities.forEach(other => {
|
|
1240
|
+
if (other.id === entity.id) return;
|
|
1241
|
+
const ints = this.findIntersectionsBetween(entity, other);
|
|
1242
|
+
intersections.push(...ints);
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
// Sort by parametric position along entity
|
|
1246
|
+
return intersections.sort((a, b) => (a.t || 0) - (b.t || 0));
|
|
1247
|
+
},
|
|
538
1248
|
|
|
539
1249
|
findIntersections(entity) {
|
|
540
1250
|
const intersections = [];
|
|
541
1251
|
this.state.entities.forEach(other => {
|
|
542
1252
|
if (other.id !== entity.id) {
|
|
543
1253
|
const ints = this.findIntersectionsBetween(entity, other);
|
|
544
|
-
intersections.push(...ints);
|
|
1254
|
+
intersections.push(...ints.map(int => int.point));
|
|
545
1255
|
}
|
|
546
1256
|
});
|
|
547
1257
|
return intersections;
|
|
548
1258
|
},
|
|
549
1259
|
|
|
550
1260
|
findIntersectionsBetween(e1, e2) {
|
|
551
|
-
|
|
552
|
-
|
|
1261
|
+
/**
|
|
1262
|
+
* Dispatch to appropriate intersection function based on entity types.
|
|
1263
|
+
* Returns array of { point, t, t2 } for each intersection.
|
|
1264
|
+
*/
|
|
553
1265
|
const intersections = [];
|
|
554
1266
|
|
|
1267
|
+
// Line-Line
|
|
555
1268
|
if (e1.type === 'line' && e2.type === 'line') {
|
|
556
|
-
const [
|
|
557
|
-
const [p3, p4] = e2.points;
|
|
558
|
-
const int = this.lineLineIntersection(p1, p2, p3, p4);
|
|
1269
|
+
const int = this.lineLineIntersection(e1.points[0], e1.points[1], e2.points[0], e2.points[1]);
|
|
559
1270
|
if (int) intersections.push(int);
|
|
560
1271
|
}
|
|
561
1272
|
|
|
1273
|
+
// Line-Circle
|
|
1274
|
+
else if (e1.type === 'line' && e2.type === 'circle') {
|
|
1275
|
+
const ints = this.lineCircleIntersection(e1.points[0], e1.points[1], e2.points[0], e2.data.radius);
|
|
1276
|
+
intersections.push(...ints);
|
|
1277
|
+
} else if (e1.type === 'circle' && e2.type === 'line') {
|
|
1278
|
+
const ints = this.lineCircleIntersection(e2.points[0], e2.points[1], e1.points[0], e1.data.radius);
|
|
1279
|
+
intersections.push(...ints.map(int => ({ ...int, t: int.t2, t2: int.t })));
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Line-Arc
|
|
1283
|
+
else if (e1.type === 'line' && e2.type === 'arc') {
|
|
1284
|
+
const ints = this.lineArcIntersection(e1.points[0], e1.points[1], e2);
|
|
1285
|
+
intersections.push(...ints);
|
|
1286
|
+
} else if (e1.type === 'arc' && e2.type === 'line') {
|
|
1287
|
+
const ints = this.lineArcIntersection(e2.points[0], e2.points[1], e1);
|
|
1288
|
+
intersections.push(...ints.map(int => ({ ...int, t: int.t2, t2: int.t })));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Circle-Circle
|
|
1292
|
+
else if (e1.type === 'circle' && e2.type === 'circle') {
|
|
1293
|
+
const ints = this.circleCircleIntersection(e1.points[0], e1.data.radius, e2.points[0], e2.data.radius);
|
|
1294
|
+
intersections.push(...ints);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// Arc-Arc
|
|
1298
|
+
else if (e1.type === 'arc' && e2.type === 'arc') {
|
|
1299
|
+
const ints = this.circleCircleIntersection(e1.points[2], e1.data.radius, e2.points[2], e2.data.radius);
|
|
1300
|
+
intersections.push(...ints.filter(int => this.pointOnArc(int.point, e1) && this.pointOnArc(int.point, e2)));
|
|
1301
|
+
}
|
|
1302
|
+
|
|
562
1303
|
return intersections;
|
|
563
1304
|
},
|
|
564
1305
|
|
|
565
1306
|
lineLineIntersection(p1, p2, p3, p4) {
|
|
1307
|
+
/**
|
|
1308
|
+
* LINE-LINE INTERSECTION (2D)
|
|
1309
|
+
*
|
|
1310
|
+
* Parametric form:
|
|
1311
|
+
* P = p1 + t * (p2 - p1) for first line
|
|
1312
|
+
* Q = p3 + s * (p4 - p3) for second line
|
|
1313
|
+
*
|
|
1314
|
+
* At intersection: P = Q
|
|
1315
|
+
* p1 + t * (p2 - p1) = p3 + s * (p4 - p3)
|
|
1316
|
+
*
|
|
1317
|
+
* Solving using cross products and determinants.
|
|
1318
|
+
*/
|
|
566
1319
|
const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y;
|
|
567
1320
|
const x3 = p3.x, y3 = p3.y, x4 = p4.x, y4 = p4.y;
|
|
568
1321
|
|
|
569
1322
|
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
|
|
570
|
-
if (Math.abs(denom) <
|
|
1323
|
+
if (Math.abs(denom) < 1e-10) return null; // Parallel or coincident
|
|
571
1324
|
|
|
572
1325
|
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
|
|
573
|
-
|
|
1326
|
+
const s = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
|
|
574
1327
|
|
|
575
|
-
|
|
1328
|
+
// Check if intersection is within both line segments
|
|
1329
|
+
if (t >= 0 && t <= 1 && s >= 0 && s <= 1) {
|
|
1330
|
+
const point = new THREE.Vector2(x1 + t * (x2 - x1), y1 + t * (y2 - y1));
|
|
1331
|
+
return { point, t, t2: s };
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
return null;
|
|
576
1335
|
},
|
|
577
1336
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
1337
|
+
lineCircleIntersection(p1, p2, center, radius) {
|
|
1338
|
+
/**
|
|
1339
|
+
* LINE-CIRCLE INTERSECTION (2D)
|
|
1340
|
+
*
|
|
1341
|
+
* Line: P = p1 + t * (p2 - p1), t ∈ [0, 1]
|
|
1342
|
+
* Circle: |P - center| = radius
|
|
1343
|
+
*
|
|
1344
|
+
* Substitute line into circle equation and solve quadratic:
|
|
1345
|
+
* a*t² + b*t + c = 0
|
|
1346
|
+
* where:
|
|
1347
|
+
* a = |direction|²
|
|
1348
|
+
* b = 2 * (direction · (p1 - center))
|
|
1349
|
+
* c = |p1 - center|² - radius²
|
|
1350
|
+
*/
|
|
1351
|
+
const dir = new THREE.Vector2(p2.x - p1.x, p2.y - p1.y);
|
|
1352
|
+
const oc = new THREE.Vector2(p1.x - center.x, p1.y - center.y);
|
|
1353
|
+
|
|
1354
|
+
const a = dir.dot(dir);
|
|
1355
|
+
const b = 2 * dir.dot(oc);
|
|
1356
|
+
const c = oc.dot(oc) - radius * radius;
|
|
1357
|
+
|
|
1358
|
+
const discriminant = b * b - 4 * a * c;
|
|
1359
|
+
if (discriminant < 0) return []; // No intersection
|
|
1360
|
+
|
|
1361
|
+
const sqrtDisc = Math.sqrt(discriminant);
|
|
1362
|
+
const t1 = (-b - sqrtDisc) / (2 * a);
|
|
1363
|
+
const t2 = (-b + sqrtDisc) / (2 * a);
|
|
1364
|
+
|
|
1365
|
+
const intersections = [];
|
|
1366
|
+
|
|
1367
|
+
if (t1 >= 0 && t1 <= 1) {
|
|
1368
|
+
const point = new THREE.Vector2(p1.x + t1 * dir.x, p1.y + t1 * dir.y);
|
|
1369
|
+
intersections.push({ point, t: t1, t2: Math.atan2(point.y - center.y, point.x - center.x) });
|
|
1370
|
+
}
|
|
581
1371
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
entity.points.splice(idx, 0, point);
|
|
586
|
-
this.renderEntity(entity);
|
|
1372
|
+
if (t2 >= 0 && t2 <= 1 && Math.abs(t2 - t1) > 1e-6) {
|
|
1373
|
+
const point = new THREE.Vector2(p1.x + t2 * dir.x, p1.y + t2 * dir.y);
|
|
1374
|
+
intersections.push({ point, t: t2, t2: Math.atan2(point.y - center.y, point.x - center.x) });
|
|
587
1375
|
}
|
|
1376
|
+
|
|
1377
|
+
return intersections;
|
|
1378
|
+
},
|
|
1379
|
+
|
|
1380
|
+
lineArcIntersection(p1, p2, arc) {
|
|
1381
|
+
/**
|
|
1382
|
+
* LINE-ARC INTERSECTION
|
|
1383
|
+
*
|
|
1384
|
+
* Arc is part of circle, so find line-circle intersections,
|
|
1385
|
+
* then filter to only those within arc's angular span.
|
|
1386
|
+
*/
|
|
1387
|
+
const [, , center] = arc.points;
|
|
1388
|
+
const radius = arc.data.radius;
|
|
1389
|
+
|
|
1390
|
+
const ints = this.lineCircleIntersection(p1, p2, center, radius);
|
|
1391
|
+
|
|
1392
|
+
// Filter to points within arc's angular span
|
|
1393
|
+
return ints.filter(int => {
|
|
1394
|
+
const angle = Math.atan2(int.point.y - center.y, int.point.x - center.x);
|
|
1395
|
+
return this.angleInRange(angle, arc.data.startAngle, arc.data.endAngle);
|
|
1396
|
+
}).map(int => ({ ...int, t2: angle }));
|
|
1397
|
+
},
|
|
1398
|
+
|
|
1399
|
+
circleCircleIntersection(c1, r1, c2, r2) {
|
|
1400
|
+
/**
|
|
1401
|
+
* CIRCLE-CIRCLE INTERSECTION (2D)
|
|
1402
|
+
*
|
|
1403
|
+
* Two circles:
|
|
1404
|
+
* |P - c1| = r1
|
|
1405
|
+
* |P - c2| = r2
|
|
1406
|
+
*
|
|
1407
|
+
* Distance between centers: d = |c2 - c1|
|
|
1408
|
+
* If d > r1 + r2 or d < |r1 - r2|: no intersection
|
|
1409
|
+
* If d = 0 and r1 = r2: coincident (infinite intersections)
|
|
1410
|
+
* Otherwise: two intersection points
|
|
1411
|
+
*/
|
|
1412
|
+
const dx = c2.x - c1.x;
|
|
1413
|
+
const dy = c2.y - c1.y;
|
|
1414
|
+
const d = Math.sqrt(dx * dx + dy * dy);
|
|
1415
|
+
|
|
1416
|
+
// Check for no intersection or coincident circles
|
|
1417
|
+
if (d > r1 + r2 || d < Math.abs(r1 - r2) || d < 1e-10) return [];
|
|
1418
|
+
|
|
1419
|
+
// Distance from c1 to line connecting intersection points
|
|
1420
|
+
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
|
|
1421
|
+
|
|
1422
|
+
// Perpendicular distance from line to intersection points
|
|
1423
|
+
const h = Math.sqrt(r1 * r1 - a * a);
|
|
1424
|
+
|
|
1425
|
+
// Midpoint of intersection chord
|
|
1426
|
+
const mx = c1.x + a * dx / d;
|
|
1427
|
+
const my = c1.y + a * dy / d;
|
|
1428
|
+
|
|
1429
|
+
// Perpendicular vector (normalized)
|
|
1430
|
+
const px = -dy / d;
|
|
1431
|
+
const py = dx / d;
|
|
1432
|
+
|
|
1433
|
+
const p1 = new THREE.Vector2(mx + h * px, my + h * py);
|
|
1434
|
+
const p2 = new THREE.Vector2(mx - h * px, my - h * py);
|
|
1435
|
+
|
|
1436
|
+
const intersections = [];
|
|
1437
|
+
|
|
1438
|
+
if (h > 1e-6) {
|
|
1439
|
+
// Two distinct points
|
|
1440
|
+
intersections.push(
|
|
1441
|
+
{ point: p1, t: Math.atan2(p1.y - c1.y, p1.x - c1.x), t2: Math.atan2(p1.y - c2.y, p1.x - c2.x) },
|
|
1442
|
+
{ point: p2, t: Math.atan2(p2.y - c1.y, p2.x - c1.x), t2: Math.atan2(p2.y - c2.y, p2.x - c2.x) }
|
|
1443
|
+
);
|
|
1444
|
+
} else {
|
|
1445
|
+
// Single tangent point
|
|
1446
|
+
intersections.push(
|
|
1447
|
+
{ point: p1, t: Math.atan2(p1.y - c1.y, p1.x - c1.x), t2: Math.atan2(p1.y - c2.y, p1.x - c2.x) }
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
return intersections;
|
|
1452
|
+
},
|
|
1453
|
+
|
|
1454
|
+
angleInRange(angle, start, end) {
|
|
1455
|
+
/**
|
|
1456
|
+
* Check if angle falls within arc's angular span
|
|
1457
|
+
* Handles wraparound at 2π boundary
|
|
1458
|
+
*/
|
|
1459
|
+
// Normalize angles to [0, 2π)
|
|
1460
|
+
const a = ((angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
1461
|
+
let s = ((start % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
1462
|
+
let e = ((end % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
1463
|
+
|
|
1464
|
+
if (s <= e) {
|
|
1465
|
+
return a >= s && a <= e;
|
|
1466
|
+
} else {
|
|
1467
|
+
return a >= s || a <= e;
|
|
1468
|
+
}
|
|
1469
|
+
},
|
|
1470
|
+
|
|
1471
|
+
pointOnArc(point, arc) {
|
|
1472
|
+
/**
|
|
1473
|
+
* Check if a point lies on an arc (within tolerance)
|
|
1474
|
+
*/
|
|
1475
|
+
const [, , center] = arc.points;
|
|
1476
|
+
const distToCenter = point.distanceTo(center);
|
|
1477
|
+
const angle = Math.atan2(point.y - center.y, point.x - center.x);
|
|
1478
|
+
|
|
1479
|
+
return Math.abs(distToCenter - arc.data.radius) < 0.1 &&
|
|
1480
|
+
this.angleInRange(angle, arc.data.startAngle, arc.data.endAngle);
|
|
588
1481
|
},
|
|
589
1482
|
|
|
590
1483
|
getProfile() {
|
|
@@ -620,10 +1513,29 @@ const SketchModule = {
|
|
|
620
1513
|
case 'c': this.setTool('circle'); break;
|
|
621
1514
|
case 'a': this.setTool('arc'); break;
|
|
622
1515
|
case 's': this.setTool('spline'); break;
|
|
1516
|
+
case 'e':
|
|
1517
|
+
if (this.state.currentTool === 'spline') {
|
|
1518
|
+
this.finishSpline();
|
|
1519
|
+
} else {
|
|
1520
|
+
this.setTool('ellipse');
|
|
1521
|
+
}
|
|
1522
|
+
break;
|
|
1523
|
+
case 'p': this.setTool('polygon'); break;
|
|
623
1524
|
case 't': this.setTool('text'); break;
|
|
624
1525
|
case 'g': this.toggleConstruction(Array.from(this.state.selectedEntityIds)); break;
|
|
625
1526
|
case 'd': this.setTool('dimension'); break;
|
|
626
|
-
case 'escape':
|
|
1527
|
+
case 'escape':
|
|
1528
|
+
if (this.state.tempPoints.length > 0) {
|
|
1529
|
+
this.state.tempPoints = [];
|
|
1530
|
+
} else {
|
|
1531
|
+
this.finish();
|
|
1532
|
+
}
|
|
1533
|
+
break;
|
|
1534
|
+
case 'enter':
|
|
1535
|
+
if (this.state.currentTool === 'spline') {
|
|
1536
|
+
this.finishSpline();
|
|
1537
|
+
}
|
|
1538
|
+
break;
|
|
627
1539
|
}
|
|
628
1540
|
});
|
|
629
1541
|
},
|
|
@@ -688,6 +1600,36 @@ const SketchModule = {
|
|
|
688
1600
|
this.state.tempPoints = [];
|
|
689
1601
|
}
|
|
690
1602
|
break;
|
|
1603
|
+
|
|
1604
|
+
case 'spline':
|
|
1605
|
+
// Accumulate control points, finish with right-click or Escape
|
|
1606
|
+
if (this.state.tempPoints.length >= 3) {
|
|
1607
|
+
// Allow finishing on third+ point by double-clicking
|
|
1608
|
+
if (e.detail === 2 || e.shiftKey) {
|
|
1609
|
+
this.drawSpline(this.state.tempPoints);
|
|
1610
|
+
this.state.tempPoints = [];
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
break;
|
|
1614
|
+
|
|
1615
|
+
case 'trim':
|
|
1616
|
+
case 'extend':
|
|
1617
|
+
case 'offset':
|
|
1618
|
+
case 'mirror':
|
|
1619
|
+
case 'fillet':
|
|
1620
|
+
case 'chamfer':
|
|
1621
|
+
// These tools require entity selection, handled separately
|
|
1622
|
+
break;
|
|
1623
|
+
}
|
|
1624
|
+
},
|
|
1625
|
+
|
|
1626
|
+
finishSpline() {
|
|
1627
|
+
/**
|
|
1628
|
+
* Finish spline drawing (called on right-click or Enter)
|
|
1629
|
+
*/
|
|
1630
|
+
if (this.state.currentTool === 'spline' && this.state.tempPoints.length >= 3) {
|
|
1631
|
+
this.drawSpline(this.state.tempPoints);
|
|
1632
|
+
this.state.tempPoints = [];
|
|
691
1633
|
}
|
|
692
1634
|
},
|
|
693
1635
|
|