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.
Files changed (33) hide show
  1. package/IMPLEMENTATION_GUIDE.md +502 -0
  2. package/INTEGRATION-GUIDE.md +377 -0
  3. package/MODULES_PHASES_6_7.md +780 -0
  4. package/app/index.html +106 -2
  5. package/app/js/brep-kernel.js +1353 -455
  6. package/app/js/help-module.js +1437 -0
  7. package/app/js/kernel.js +364 -40
  8. package/app/js/modules/animation-module.js +967 -0
  9. package/app/js/modules/assembly-module.js +47 -3
  10. package/app/js/modules/cam-module.js +1067 -0
  11. package/app/js/modules/collaboration-module.js +1102 -0
  12. package/app/js/modules/data-module.js +1656 -0
  13. package/app/js/modules/drawing-module.js +54 -8
  14. package/app/js/modules/formats-module.js +1173 -0
  15. package/app/js/modules/inspection-module.js +937 -0
  16. package/app/js/modules/mesh-module.js +968 -0
  17. package/app/js/modules/operations-module.js +40 -7
  18. package/app/js/modules/plugin-module.js +957 -0
  19. package/app/js/modules/rendering-module.js +1306 -0
  20. package/app/js/modules/scripting-module.js +955 -0
  21. package/app/js/modules/simulation-module.js +60 -3
  22. package/app/js/modules/sketch-module.js +1032 -90
  23. package/app/js/modules/step-module.js +47 -6
  24. package/app/js/modules/surface-module.js +728 -0
  25. package/app/js/modules/version-module.js +1410 -0
  26. package/app/js/modules/viewport-module.js +95 -8
  27. package/app/test-agent-v2.html +881 -1316
  28. package/docs/ARCHITECTURE.html +838 -1408
  29. package/docs/DEVELOPER-GUIDE.md +1504 -0
  30. package/docs/TUTORIAL.md +740 -0
  31. package/package.json +1 -1
  32. package/.github/scripts/cad-diff.js +0 -590
  33. package/.github/workflows/cad-diff.yml +0 -117
@@ -1,9 +1,38 @@
1
1
  /**
2
- * SketchModule — 2D Sketching Engine (Fusion 360 parity)
3
- * LEGO block for cycleCAD microkernel
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
- * Tools: Line, Rectangle, Circle, Arc, Ellipse, Spline, Polygon, Slot, Text,
6
- * Trim, Extend, Offset, Mirror, Fillet, Chamfer, Construction, Dimension
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
- // Get face from current model
130
- // For now, simplified in real implementation, get face normal from model
131
- const face = window._selectedFace || {};
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: face.normal || new THREE.Vector3(0, 0, 1),
187
+ normal,
134
188
  origin: face.origin || new THREE.Vector3(0, 0, 0),
135
- u: face.u || new THREE.Vector3(1, 0, 0),
136
- v: face.v || new THREE.Vector3(0, 1, 0)
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: { entities: this.state.entities, profile, plane: this.state.plane }
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
- return this.addEntity('spline', {
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: { degree: 3 }
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
- // Find intersection points on entity
290
- const intersections = this.findIntersections(entity);
291
- const closestIntersection = intersections.reduce((closest, int) => {
292
- const dist = int.distanceTo(clickPoint);
293
- return dist < closest.dist ? { point: int, dist } : closest;
294
- }, { dist: Infinity });
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
- if (closestIntersection.dist < this.state.snapDistance) {
297
- // Split entity at intersection
298
- this.splitEntity(entityId, closestIntersection.point);
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
- // Find nearest intersection on other entities
307
- const allIntersections = [];
308
- this.state.entities.forEach(e => {
309
- if (e.id !== entityId) {
310
- const ints = this.findIntersectionsBetween(entity, e);
311
- allIntersections.push(...ints);
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 (allIntersections.length > 0) {
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
- entity.points[entity.points.length - 1] = nearest.point;
322
- this.renderEntity(entity);
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
- offset(entityIds, distance) {
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
- let offsetPoints = [];
332
- if (entity.type === 'line' && entity.points.length === 2) {
333
- const [p1, p2] = entity.points;
334
- const dir = p2.clone().sub(p1).normalize();
335
- const perp = new THREE.Vector2(-dir.y, dir.x).multiplyScalar(distance);
336
- offsetPoints = [p1.clone().add(perp), p2.clone().add(perp)];
337
- } else if (entity.type === 'circle') {
338
- // Offset circle by changing radius
339
- const newEntity = this.addEntity('circle', {
340
- points: entity.points,
341
- data: { radius: entity.data.radius + distance }
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
- if (offsetPoints.length > 0) {
347
- this.addEntity(entity.type, {
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
- // ===== CONSTRAINTS =====
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(entity.points);
483
- material = new THREE.LineBasicMaterial({ color, linewidth });
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 = new THREE.LineBasicMaterial({ color, linewidth });
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 = new THREE.LineBasicMaterial({ color, linewidth });
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
- // ===== HELPER METHODS =====
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
- // Simplified intersection detection
552
- // In production, use robust geometric intersection library
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 [p1, p2] = e1.points;
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) < 0.0001) return null;
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
- if (t < 0 || t > 1) return null;
1326
+ const s = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
574
1327
 
575
- return new THREE.Vector2(x1 + t * (x2 - x1), y1 + t * (y2 - y1));
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
- splitEntity(entityId, point) {
579
- const entity = this.state.entities.find(e => e.id === entityId);
580
- if (!entity) return;
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
- // Find closest point on entity to split at
583
- const idx = entity.points.findIndex(p => p.distanceTo(point) < 0.1);
584
- if (idx !== -1) {
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': this.finish(); break;
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