cyclecad 2.0.1 → 3.0.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/DELIVERABLES.txt +296 -445
- package/ENHANCEMENT_COMPLETION_REPORT.md +383 -0
- package/ENHANCEMENT_SUMMARY.txt +308 -0
- package/FEATURE_INVENTORY.md +235 -0
- package/FUSION360_FEATURES_SUMMARY.md +452 -0
- package/FUSION360_PARITY_ENHANCEMENTS.md +461 -0
- package/FUSION360_PARITY_SUMMARY.md +520 -0
- package/FUSION360_QUICK_REFERENCE.md +351 -0
- package/IMPLEMENTATION_GUIDE.md +502 -0
- package/INTEGRATION-GUIDE.md +377 -0
- package/MODULES_PHASES_6_7.md +780 -0
- package/MODULE_API_REFERENCE.md +712 -0
- package/MODULE_INVENTORY.txt +264 -0
- package/app/index.html +1345 -4930
- package/app/js/app.js +1312 -514
- 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 +1461 -0
- package/app/js/modules/assembly-module.js +47 -3
- package/app/js/modules/cam-module.js +1572 -0
- package/app/js/modules/collaboration-module.js +1615 -0
- package/app/js/modules/constraint-module.js +1266 -0
- package/app/js/modules/data-module.js +1054 -0
- package/app/js/modules/drawing-module.js +54 -8
- package/app/js/modules/formats-module.js +873 -0
- package/app/js/modules/inspection-module.js +1330 -0
- package/app/js/modules/mesh-module-enhanced.js +880 -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 +1554 -0
- package/app/js/modules/rendering-module.js +1766 -0
- package/app/js/modules/scripting-module.js +1073 -0
- package/app/js/modules/simulation-module.js +60 -3
- package/app/js/modules/sketch-module.js +2029 -91
- package/app/js/modules/step-module.js +47 -6
- package/app/js/modules/surface-module.js +1040 -0
- package/app/js/modules/version-module.js +1830 -0
- package/app/js/modules/viewport-module.js +95 -8
- package/app/test-agent-v2.html +881 -1316
- package/cycleCAD-Architecture-v2.pptx +0 -0
- 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/~$cycleCAD-Architecture-v2.pptx +0 -0
- 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 = {
|
|
@@ -45,27 +74,62 @@ const SketchModule = {
|
|
|
45
74
|
getUI() {
|
|
46
75
|
return `
|
|
47
76
|
<div id="sketch-toolbar" style="display: none; background: #2a2a2a; padding: 8px; border-radius: 4px; flex-wrap: wrap; gap: 4px;">
|
|
77
|
+
<!-- BASIC TOOLS -->
|
|
48
78
|
<button data-tool="line" class="sketch-tool-btn" title="Line (L)">—</button>
|
|
49
79
|
<button data-tool="rectangle" class="sketch-tool-btn" title="Rectangle (R)">▭</button>
|
|
50
80
|
<button data-tool="circle" class="sketch-tool-btn" title="Circle (C)">●</button>
|
|
51
81
|
<button data-tool="arc" class="sketch-tool-btn" title="Arc (A)">⌒</button>
|
|
52
82
|
<button data-tool="ellipse" class="sketch-tool-btn" title="Ellipse (E)">⬭</button>
|
|
53
83
|
<button data-tool="spline" class="sketch-tool-btn" title="Spline (S)">✓</button>
|
|
84
|
+
<button data-tool="spline_fit" class="sketch-tool-btn" title="Fit Point Spline">↪</button>
|
|
54
85
|
<button data-tool="polygon" class="sketch-tool-btn" title="Polygon (P)">⬡</button>
|
|
55
|
-
|
|
86
|
+
|
|
87
|
+
<!-- SLOT TOOLS -->
|
|
88
|
+
<button data-tool="slot" class="sketch-tool-btn" title="Slot (Center-Point)">⊟</button>
|
|
89
|
+
<button data-tool="slot_3point" class="sketch-tool-btn" title="Slot (3-Point)">⊟*</button>
|
|
90
|
+
<button data-tool="slot_ctc" class="sketch-tool-btn" title="Slot (Center-to-Center)">⊟**</button>
|
|
91
|
+
|
|
92
|
+
<!-- CONIC & TEXT -->
|
|
93
|
+
<button data-tool="conic" class="sketch-tool-btn" title="Conic (Parabola/Hyperbola)">∿</button>
|
|
56
94
|
<button data-tool="text" class="sketch-tool-btn" title="Text (T)">T</button>
|
|
95
|
+
<button data-tool="text_path" class="sketch-tool-btn" title="Text Along Path">T↷</button>
|
|
96
|
+
|
|
97
|
+
<!-- REFERENCE -->
|
|
98
|
+
<button data-tool="point" class="sketch-tool-btn" title="Point (standalone)">•</button>
|
|
99
|
+
<button data-tool="midpoint" class="sketch-tool-btn" title="Midpoint">◈</button>
|
|
100
|
+
|
|
101
|
+
<!-- EDITING TOOLS -->
|
|
57
102
|
<button data-tool="trim" class="sketch-tool-btn" title="Trim">✂</button>
|
|
103
|
+
<button data-tool="power_trim" class="sketch-tool-btn" title="Power Trim (drag)">✂✂</button>
|
|
104
|
+
<button data-tool="break" class="sketch-tool-btn" title="Break at Point">⊥</button>
|
|
58
105
|
<button data-tool="extend" class="sketch-tool-btn" title="Extend">→</button>
|
|
59
106
|
<button data-tool="offset" class="sketch-tool-btn" title="Offset">⟿</button>
|
|
60
107
|
<button data-tool="mirror" class="sketch-tool-btn" title="Mirror">⇄</button>
|
|
61
108
|
<button data-tool="fillet" class="sketch-tool-btn" title="Fillet">⌢</button>
|
|
62
109
|
<button data-tool="chamfer" class="sketch-tool-btn" title="Chamfer">/</button>
|
|
110
|
+
|
|
111
|
+
<!-- PATTERN TOOLS -->
|
|
112
|
+
<button data-tool="rect_pattern" class="sketch-tool-btn" title="Rectangular Pattern">▦</button>
|
|
113
|
+
<button data-tool="circ_pattern" class="sketch-tool-btn" title="Circular Pattern">⊙</button>
|
|
114
|
+
<button data-tool="path_pattern" class="sketch-tool-btn" title="Pattern Along Path">▦→</button>
|
|
115
|
+
|
|
116
|
+
<!-- CONSTRUCTION & GEOMETRY -->
|
|
63
117
|
<button data-tool="construction" class="sketch-tool-btn" title="Toggle Construction (G)">⋯</button>
|
|
118
|
+
<button data-tool="project" class="sketch-tool-btn" title="Project Edge">⌜</button>
|
|
119
|
+
<button data-tool="include" class="sketch-tool-btn" title="Include From Sketch">⊂</button>
|
|
120
|
+
<button data-tool="intersection" class="sketch-tool-btn" title="Intersection Curve">✕</button>
|
|
121
|
+
|
|
122
|
+
<!-- DIMENSIONS -->
|
|
64
123
|
<button id="sketch-dimension-btn" class="sketch-tool-btn" title="Add Dimension (D)">📏</button>
|
|
124
|
+
<button data-tool="ordinate" class="sketch-tool-btn" title="Ordinate Dimension">📍</button>
|
|
125
|
+
<button data-tool="reference" class="sketch-tool-btn" title="Reference Dimension">⌀</button>
|
|
126
|
+
<button data-tool="auto_dim" class="sketch-tool-btn" title="Auto Dimension">✓📏</button>
|
|
127
|
+
|
|
128
|
+
<!-- FINISH -->
|
|
65
129
|
<button id="sketch-finish-btn" style="margin-left: 16px; background: #00aa00; color: white;" title="Finish Sketch (Esc)">✓ Finish</button>
|
|
66
130
|
</div>
|
|
67
131
|
<div id="sketch-status-bar" style="display: none; color: #aaa; font-size: 12px; padding: 4px 8px; border-top: 1px solid #444; background: #1a1a1a;">
|
|
68
|
-
Tool: <span id="sketch-tool-name">Line</span> | Grid: <span id="sketch-grid-size">5mm</span> | Entities: <span id="sketch-entity-count">0</span>
|
|
132
|
+
Tool: <span id="sketch-tool-name">Line</span> | Grid: <span id="sketch-grid-size">5mm</span> | Entities: <span id="sketch-entity-count">0</span> | DOF: <span id="sketch-dof">0</span>
|
|
69
133
|
</div>
|
|
70
134
|
<div id="sketch-dimension-input" style="display: none; position: fixed; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; padding: 12px; z-index: 10000;">
|
|
71
135
|
<label style="display: block; font-size: 12px; color: #aaa; margin-bottom: 4px;">Dimension Value (mm)</label>
|
|
@@ -126,18 +190,93 @@ const SketchModule = {
|
|
|
126
190
|
},
|
|
127
191
|
|
|
128
192
|
startOnFace(faceId) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
193
|
+
/**
|
|
194
|
+
* SKETCH ON FACE: Start sketch on a 3D model face
|
|
195
|
+
*
|
|
196
|
+
* Algorithm:
|
|
197
|
+
* 1. Get face normal and center from 3D mesh
|
|
198
|
+
* 2. Compute local U/V axes (perpendicular to normal)
|
|
199
|
+
* 3. Set sketch plane to face's coordinate system
|
|
200
|
+
* 4. All drawn entities transform to world coords when sketch ends
|
|
201
|
+
*/
|
|
202
|
+
const face = this.getFaceData(faceId);
|
|
203
|
+
if (!face) {
|
|
204
|
+
console.warn('Could not find face:', faceId);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Compute orthonormal basis for the face
|
|
209
|
+
const normal = face.normal.clone().normalize();
|
|
210
|
+
let u = new THREE.Vector3(1, 0, 0);
|
|
211
|
+
|
|
212
|
+
// If normal is nearly parallel to X axis, use Y instead
|
|
213
|
+
if (Math.abs(normal.dot(u)) > 0.9) {
|
|
214
|
+
u = new THREE.Vector3(0, 1, 0);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// u = normal × reference, then v = normal × u
|
|
218
|
+
u = new THREE.Vector3().crossVectors(normal, u).normalize();
|
|
219
|
+
const v = new THREE.Vector3().crossVectors(normal, u).normalize();
|
|
220
|
+
|
|
132
221
|
const plane = {
|
|
133
|
-
normal
|
|
222
|
+
normal,
|
|
134
223
|
origin: face.origin || new THREE.Vector3(0, 0, 0),
|
|
135
|
-
u
|
|
136
|
-
v
|
|
224
|
+
u,
|
|
225
|
+
v,
|
|
226
|
+
faceId // Store for reference
|
|
137
227
|
};
|
|
228
|
+
|
|
229
|
+
this.state.sketchOnFace = true;
|
|
230
|
+
this.state.faceId = faceId;
|
|
138
231
|
this.start(plane);
|
|
139
232
|
},
|
|
140
233
|
|
|
234
|
+
getFaceData(faceId) {
|
|
235
|
+
/**
|
|
236
|
+
* Get face data from 3D scene (simplified version)
|
|
237
|
+
* In production, ray-cast scene or use model's face database
|
|
238
|
+
*/
|
|
239
|
+
if (!window._scene) return null;
|
|
240
|
+
|
|
241
|
+
// This is a simplified approach — would need real face selection
|
|
242
|
+
// For now, return default XY plane
|
|
243
|
+
return {
|
|
244
|
+
normal: new THREE.Vector3(0, 0, 1),
|
|
245
|
+
origin: new THREE.Vector3(0, 0, 0),
|
|
246
|
+
// Real implementation would compute actual face normal
|
|
247
|
+
};
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
transformSketchToWorld() {
|
|
251
|
+
/**
|
|
252
|
+
* Transform all sketch entities from face-local to world coordinates
|
|
253
|
+
* Called when finishing sketch on a face
|
|
254
|
+
*/
|
|
255
|
+
if (!this.state.sketchOnFace || !this.state.plane) return;
|
|
256
|
+
|
|
257
|
+
const plane = this.state.plane;
|
|
258
|
+
const matrix = new THREE.Matrix4();
|
|
259
|
+
|
|
260
|
+
// Build transformation matrix: local to world
|
|
261
|
+
const localX = plane.u;
|
|
262
|
+
const localY = plane.v;
|
|
263
|
+
const localZ = plane.normal;
|
|
264
|
+
|
|
265
|
+
matrix.makeBasis(localX, localY, localZ);
|
|
266
|
+
matrix.setPosition(plane.origin);
|
|
267
|
+
|
|
268
|
+
// Transform all entities
|
|
269
|
+
this.state.entities.forEach(entity => {
|
|
270
|
+
entity.points = entity.points.map(p => {
|
|
271
|
+
const v3 = new THREE.Vector3(p.x, p.y, 0); // 2D → 3D in local space
|
|
272
|
+
v3.applyMatrix4(matrix);
|
|
273
|
+
return new THREE.Vector2(v3.x, v3.y); // Back to 2D in world space
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
this.state.sketchOnFace = false;
|
|
278
|
+
},
|
|
279
|
+
|
|
141
280
|
finish() {
|
|
142
281
|
if (!this.state.isActive) return null;
|
|
143
282
|
|
|
@@ -146,6 +285,11 @@ const SketchModule = {
|
|
|
146
285
|
document.getElementById('sketch-status-bar').style.display = 'none';
|
|
147
286
|
if (this.state.canvas) this.state.canvas.style.display = 'none';
|
|
148
287
|
|
|
288
|
+
// If sketching on a face, transform entities to world coordinates
|
|
289
|
+
if (this.state.sketchOnFace) {
|
|
290
|
+
this.transformSketchToWorld();
|
|
291
|
+
}
|
|
292
|
+
|
|
149
293
|
// Remove 3D group
|
|
150
294
|
if (this.state.canvasGroup && window._scene) {
|
|
151
295
|
window._scene.remove(this.state.canvasGroup);
|
|
@@ -153,7 +297,12 @@ const SketchModule = {
|
|
|
153
297
|
|
|
154
298
|
const profile = this.getProfile();
|
|
155
299
|
window.dispatchEvent(new CustomEvent('sketch:finished', {
|
|
156
|
-
detail: {
|
|
300
|
+
detail: {
|
|
301
|
+
entities: this.state.entities,
|
|
302
|
+
profile,
|
|
303
|
+
plane: this.state.plane,
|
|
304
|
+
faceId: this.state.faceId || null
|
|
305
|
+
}
|
|
157
306
|
}));
|
|
158
307
|
|
|
159
308
|
return { entities: this.state.entities, profile };
|
|
@@ -243,10 +392,135 @@ const SketchModule = {
|
|
|
243
392
|
},
|
|
244
393
|
|
|
245
394
|
drawSpline(controlPoints) {
|
|
246
|
-
|
|
395
|
+
/**
|
|
396
|
+
* SPLINE TOOL: Cubic B-spline with draggable control points
|
|
397
|
+
*
|
|
398
|
+
* Uses De Boor's algorithm to evaluate curve at arbitrary parameter.
|
|
399
|
+
* Requires minimum 3 control points.
|
|
400
|
+
* Curve passes near (not through) control points unless clamped.
|
|
401
|
+
*/
|
|
402
|
+
if (controlPoints.length < 3) {
|
|
403
|
+
console.warn('Spline requires at least 3 control points');
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const spline = this.addEntity('spline', {
|
|
247
408
|
points: controlPoints,
|
|
248
|
-
data: {
|
|
409
|
+
data: {
|
|
410
|
+
degree: 3,
|
|
411
|
+
knotVector: this.generateBSplineKnots(controlPoints.length, 3)
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Render control polygon (dashed line connecting control points)
|
|
416
|
+
this.renderSplineControlPolygon(spline);
|
|
417
|
+
|
|
418
|
+
return spline;
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
generateBSplineKnots(n, degree) {
|
|
422
|
+
/**
|
|
423
|
+
* Generate clamped B-spline knot vector.
|
|
424
|
+
* Knot vector has n + degree + 1 values.
|
|
425
|
+
* For clamped spline: first and last (degree+1) knots are at 0 and 1.
|
|
426
|
+
*/
|
|
427
|
+
const knots = [];
|
|
428
|
+
const knotCount = n + degree + 1;
|
|
429
|
+
|
|
430
|
+
// Clamp at start
|
|
431
|
+
for (let i = 0; i <= degree; i++) {
|
|
432
|
+
knots.push(0);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Interior knots distributed uniformly
|
|
436
|
+
const interior = knotCount - 2 * (degree + 1);
|
|
437
|
+
for (let i = 1; i <= interior; i++) {
|
|
438
|
+
knots.push(i / (interior + 1));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Clamp at end
|
|
442
|
+
for (let i = 0; i <= degree; i++) {
|
|
443
|
+
knots.push(1);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return knots;
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
evaluateBSpline(controlPoints, t, degree = 3) {
|
|
450
|
+
/**
|
|
451
|
+
* DE BOOR'S ALGORITHM for cubic B-spline evaluation
|
|
452
|
+
*
|
|
453
|
+
* Given control points P0..Pn, knot vector U, and parameter t,
|
|
454
|
+
* compute point on curve at parameter t.
|
|
455
|
+
*
|
|
456
|
+
* Algorithm:
|
|
457
|
+
* 1. Find knot span k such that U[k] <= t < U[k+1]
|
|
458
|
+
* 2. For d = 1 to degree:
|
|
459
|
+
* 3. For i = k-degree+d to k:
|
|
460
|
+
* 4. Compute intermediate points using affine combination
|
|
461
|
+
* 5. Return the single intermediate point
|
|
462
|
+
*/
|
|
463
|
+
if (controlPoints.length < degree + 1) {
|
|
464
|
+
throw new Error('Not enough control points for degree');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Generate knot vector if not provided
|
|
468
|
+
const knots = this.generateBSplineKnots(controlPoints.length, degree);
|
|
469
|
+
|
|
470
|
+
// Find knot span k such that knots[k] <= t < knots[k+1]
|
|
471
|
+
let k = 0;
|
|
472
|
+
for (let i = 0; i < knots.length - 1; i++) {
|
|
473
|
+
if (knots[i] <= t && t < knots[i + 1]) {
|
|
474
|
+
k = i;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Handle edge case: t == 1.0
|
|
479
|
+
if (t === 1.0) k = knots.length - degree - 2;
|
|
480
|
+
|
|
481
|
+
// Initialize with control points
|
|
482
|
+
const d = [];
|
|
483
|
+
for (let j = k - degree; j <= k; j++) {
|
|
484
|
+
d[j] = controlPoints[j] ? controlPoints[j].clone() : new THREE.Vector2(0, 0);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// De Boor recurrence
|
|
488
|
+
for (let r = 1; r <= degree; r++) {
|
|
489
|
+
for (let j = k; j >= k - degree + r; j--) {
|
|
490
|
+
const alpha = (t - knots[j]) / (knots[j + degree - r + 1] - knots[j]);
|
|
491
|
+
if (isNaN(alpha) || !isFinite(alpha)) {
|
|
492
|
+
continue; // Skip degenerate knot spans
|
|
493
|
+
}
|
|
494
|
+
// d[j] = (1-alpha) * d[j-1] + alpha * d[j]
|
|
495
|
+
const d_prev = d[j - 1] || new THREE.Vector2(0, 0);
|
|
496
|
+
d[j] = new THREE.Vector2(
|
|
497
|
+
(1 - alpha) * d_prev.x + alpha * d[j].x,
|
|
498
|
+
(1 - alpha) * d_prev.y + alpha * d[j].y
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return d[k];
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
renderSplineControlPolygon(spline) {
|
|
507
|
+
// Render dashed line connecting control points
|
|
508
|
+
if (!this.state.canvasGroup) return;
|
|
509
|
+
|
|
510
|
+
const existing = this.state.canvasGroup.children.find(c => c.userData.entityId === spline.id + '_polygon');
|
|
511
|
+
if (existing) this.state.canvasGroup.remove(existing);
|
|
512
|
+
|
|
513
|
+
const geometry = new THREE.BufferGeometry().setFromPoints(spline.points);
|
|
514
|
+
const material = new THREE.LineDashedMaterial({
|
|
515
|
+
color: 0x888888,
|
|
516
|
+
dashSize: 0.5,
|
|
517
|
+
gapSize: 0.3,
|
|
518
|
+
linewidth: 0.05
|
|
249
519
|
});
|
|
520
|
+
const line = new THREE.Line(geometry, material);
|
|
521
|
+
line.userData.entityId = spline.id + '_polygon';
|
|
522
|
+
line.computeLineDistances();
|
|
523
|
+
this.state.canvasGroup.add(line);
|
|
250
524
|
},
|
|
251
525
|
|
|
252
526
|
drawPolygon(center, sides, radius, circumscribed = true) {
|
|
@@ -280,78 +554,645 @@ const SketchModule = {
|
|
|
280
554
|
});
|
|
281
555
|
},
|
|
282
556
|
|
|
283
|
-
|
|
557
|
+
drawSlotCenterPoint(center, width, height, rotation = 0) {
|
|
558
|
+
/**
|
|
559
|
+
* CENTER-POINT SLOT: Slot defined by center, width, and height
|
|
560
|
+
*
|
|
561
|
+
* A slot is a rounded rectangle with semicircular ends.
|
|
562
|
+
* Specified by center point, width, and height of the slot.
|
|
563
|
+
*/
|
|
564
|
+
return this.addEntity('slot_center', {
|
|
565
|
+
points: [center],
|
|
566
|
+
data: { width, height, rotation },
|
|
567
|
+
constraints: [{ type: 'fixed', point: center }]
|
|
568
|
+
});
|
|
569
|
+
},
|
|
284
570
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
571
|
+
drawSlot3Point(p1, p2, radius) {
|
|
572
|
+
/**
|
|
573
|
+
* 3-POINT SLOT: Define by two endpoints and radius (arc radius)
|
|
574
|
+
*
|
|
575
|
+
* Creates a slot with semicircular ends of given radius,
|
|
576
|
+
* connecting two specified endpoints.
|
|
577
|
+
*/
|
|
578
|
+
const center = new THREE.Vector2(
|
|
579
|
+
(p1.x + p2.x) / 2,
|
|
580
|
+
(p1.y + p2.y) / 2
|
|
581
|
+
);
|
|
582
|
+
const length = p1.distanceTo(p2);
|
|
583
|
+
return this.addEntity('slot_3point', {
|
|
584
|
+
points: [p1, p2],
|
|
585
|
+
data: { radius, length, center }
|
|
586
|
+
});
|
|
587
|
+
},
|
|
288
588
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
589
|
+
drawSlotCenterToCenter(center1, center2, radius) {
|
|
590
|
+
/**
|
|
591
|
+
* CENTER-TO-CENTER SLOT: Define by arc centers and radius
|
|
592
|
+
*
|
|
593
|
+
* Slot with circular centers at two specified points,
|
|
594
|
+
* connected by tangent lines.
|
|
595
|
+
*/
|
|
596
|
+
return this.addEntity('slot_ctc', {
|
|
597
|
+
points: [center1, center2],
|
|
598
|
+
data: { radius }
|
|
599
|
+
});
|
|
600
|
+
},
|
|
295
601
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
602
|
+
drawConic(type, params) {
|
|
603
|
+
/**
|
|
604
|
+
* CONIC SECTION DRAWING: Parabola, Hyperbola, or Ellipse
|
|
605
|
+
*
|
|
606
|
+
* @param {string} type - 'parabola' or 'hyperbola'
|
|
607
|
+
* @param {object} params - { focus, directrix, ... } or { foci, a, ... }
|
|
608
|
+
*
|
|
609
|
+
* Parabola: locus of points equidistant from focus and directrix
|
|
610
|
+
* Hyperbola: locus of points where |PF1| - |PF2| = 2a
|
|
611
|
+
*/
|
|
612
|
+
if (type === 'parabola') {
|
|
613
|
+
const { focus, directrixLine } = params;
|
|
614
|
+
return this.addEntity('parabola', {
|
|
615
|
+
points: [focus],
|
|
616
|
+
data: { directrixLine, type: 'parabola' }
|
|
617
|
+
});
|
|
618
|
+
} else if (type === 'hyperbola') {
|
|
619
|
+
const { focus1, focus2, a } = params;
|
|
620
|
+
return this.addEntity('hyperbola', {
|
|
621
|
+
points: [focus1, focus2],
|
|
622
|
+
data: { a, type: 'hyperbola' }
|
|
623
|
+
});
|
|
299
624
|
}
|
|
625
|
+
return null;
|
|
300
626
|
},
|
|
301
627
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
628
|
+
drawRectangularPattern(entityIds, columns, rows, spacingX, spacingY) {
|
|
629
|
+
/**
|
|
630
|
+
* RECTANGULAR PATTERN: Array entity copies in grid
|
|
631
|
+
*
|
|
632
|
+
* Creates copies of selected entities in a column×row grid
|
|
633
|
+
* with specified spacing between copies.
|
|
634
|
+
*/
|
|
635
|
+
if (entityIds.length === 0) return [];
|
|
636
|
+
|
|
637
|
+
const patterns = [];
|
|
638
|
+
const baseEntities = this.state.entities.filter(e => entityIds.includes(e.id));
|
|
639
|
+
|
|
640
|
+
for (let row = 0; row < rows; row++) {
|
|
641
|
+
for (let col = 0; col < columns; col++) {
|
|
642
|
+
if (row === 0 && col === 0) continue; // Skip original
|
|
643
|
+
|
|
644
|
+
const offset = new THREE.Vector2(col * spacingX, row * spacingY);
|
|
645
|
+
|
|
646
|
+
baseEntities.forEach(entity => {
|
|
647
|
+
const copiedPoints = entity.points.map(p => p.clone().add(offset));
|
|
648
|
+
const patternEntity = this.addEntity(entity.type, {
|
|
649
|
+
points: copiedPoints,
|
|
650
|
+
data: { ...entity.data, isPattern: true, baseId: entity.id },
|
|
651
|
+
isConstruction: entity.isConstruction
|
|
652
|
+
});
|
|
653
|
+
patterns.push(patternEntity);
|
|
654
|
+
});
|
|
312
655
|
}
|
|
313
|
-
}
|
|
656
|
+
}
|
|
314
657
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const d = int.distanceTo(entity.points[entity.points.length - 1]);
|
|
318
|
-
return d < n.dist ? { point: int, dist: d } : n;
|
|
319
|
-
}, { dist: Infinity });
|
|
658
|
+
return patterns;
|
|
659
|
+
},
|
|
320
660
|
|
|
321
|
-
|
|
322
|
-
|
|
661
|
+
drawCircularPattern(entityIds, center, count, angleSpan = Math.PI * 2) {
|
|
662
|
+
/**
|
|
663
|
+
* CIRCULAR PATTERN: Array entity copies around center
|
|
664
|
+
*
|
|
665
|
+
* Creates copies of selected entities arranged radially
|
|
666
|
+
* around a center point.
|
|
667
|
+
*/
|
|
668
|
+
if (entityIds.length === 0) return [];
|
|
669
|
+
|
|
670
|
+
const patterns = [];
|
|
671
|
+
const baseEntities = this.state.entities.filter(e => entityIds.includes(e.id));
|
|
672
|
+
const angleStep = angleSpan / count;
|
|
673
|
+
|
|
674
|
+
for (let i = 1; i < count; i++) {
|
|
675
|
+
const angle = i * angleStep;
|
|
676
|
+
const cos = Math.cos(angle);
|
|
677
|
+
const sin = Math.sin(angle);
|
|
678
|
+
|
|
679
|
+
baseEntities.forEach(entity => {
|
|
680
|
+
const copiedPoints = entity.points.map(p => {
|
|
681
|
+
const relative = new THREE.Vector2(p.x - center.x, p.y - center.y);
|
|
682
|
+
const rotated = new THREE.Vector2(
|
|
683
|
+
relative.x * cos - relative.y * sin,
|
|
684
|
+
relative.x * sin + relative.y * cos
|
|
685
|
+
);
|
|
686
|
+
return rotated.add(center);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const patternEntity = this.addEntity(entity.type, {
|
|
690
|
+
points: copiedPoints,
|
|
691
|
+
data: { ...entity.data, isPattern: true, baseId: entity.id },
|
|
692
|
+
isConstruction: entity.isConstruction
|
|
693
|
+
});
|
|
694
|
+
patterns.push(patternEntity);
|
|
695
|
+
});
|
|
323
696
|
}
|
|
697
|
+
|
|
698
|
+
return patterns;
|
|
324
699
|
},
|
|
325
700
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
701
|
+
drawPatternAlongPath(entityIds, pathEntityId, count, spacing = null) {
|
|
702
|
+
/**
|
|
703
|
+
* PATTERN ALONG PATH: Array entity copies along a curve
|
|
704
|
+
*
|
|
705
|
+
* Creates copies of selected entities distributed along
|
|
706
|
+
* a line, arc, or spline path.
|
|
707
|
+
*/
|
|
708
|
+
const pathEntity = this.state.entities.find(e => e.id === pathEntityId);
|
|
709
|
+
if (!pathEntity || !['line', 'arc', 'spline'].includes(pathEntity.type)) {
|
|
710
|
+
console.warn('Path must be line, arc, or spline');
|
|
711
|
+
return [];
|
|
712
|
+
}
|
|
330
713
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
714
|
+
const baseEntities = this.state.entities.filter(e => entityIds.includes(e.id));
|
|
715
|
+
const patterns = [];
|
|
716
|
+
|
|
717
|
+
for (let i = 1; i < count; i++) {
|
|
718
|
+
const t = i / count; // Parameter along path [0, 1]
|
|
719
|
+
const pathPoint = this.evaluateEntityAtParameter(pathEntity, t);
|
|
720
|
+
const pathTangent = this.evaluateEntityTangentAtParameter(pathEntity, t);
|
|
721
|
+
const angle = Math.atan2(pathTangent.y, pathTangent.x);
|
|
722
|
+
|
|
723
|
+
baseEntities.forEach(entity => {
|
|
724
|
+
const copiedPoints = entity.points.map(p => {
|
|
725
|
+
const relative = new THREE.Vector2(p.x - entity.points[0].x, p.y - entity.points[0].y);
|
|
726
|
+
const rotated = new THREE.Vector2(
|
|
727
|
+
relative.x * Math.cos(angle) - relative.y * Math.sin(angle),
|
|
728
|
+
relative.x * Math.sin(angle) + relative.y * Math.cos(angle)
|
|
729
|
+
);
|
|
730
|
+
return pathPoint.clone().add(rotated);
|
|
342
731
|
});
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
732
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
733
|
+
const patternEntity = this.addEntity(entity.type, {
|
|
734
|
+
points: copiedPoints,
|
|
735
|
+
data: { ...entity.data, isPattern: true, baseId: entity.id },
|
|
349
736
|
isConstruction: entity.isConstruction
|
|
350
737
|
});
|
|
738
|
+
patterns.push(patternEntity);
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return patterns;
|
|
743
|
+
},
|
|
744
|
+
|
|
745
|
+
projectEdgeOntoSketch(edgeId) {
|
|
746
|
+
/**
|
|
747
|
+
* PROJECT 3D EDGE ONTO SKETCH PLANE
|
|
748
|
+
*
|
|
749
|
+
* Takes a 3D edge from the model and projects it orthogonally
|
|
750
|
+
* onto the current sketch plane. Useful for alignment.
|
|
751
|
+
*/
|
|
752
|
+
// In production, would ray-cast edge with sketch plane
|
|
753
|
+
// For now, return a projected line entity
|
|
754
|
+
return {
|
|
755
|
+
message: 'Project edge ' + edgeId + ' onto sketch plane',
|
|
756
|
+
isConstruction: true
|
|
757
|
+
};
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
includeGeometryFromSketch(sourceSketchId) {
|
|
761
|
+
/**
|
|
762
|
+
* INCLUDE GEOMETRY FROM ANOTHER SKETCH
|
|
763
|
+
*
|
|
764
|
+
* References entities from another sketch in the current sketch.
|
|
765
|
+
* Changes to source sketch automatically update references.
|
|
766
|
+
*/
|
|
767
|
+
return {
|
|
768
|
+
message: 'Include geometry from sketch ' + sourceSketchId,
|
|
769
|
+
linkedSketchId: sourceSketchId
|
|
770
|
+
};
|
|
771
|
+
},
|
|
772
|
+
|
|
773
|
+
drawIntersectionCurve(body1Id, body2Id, surface1Id, surface2Id) {
|
|
774
|
+
/**
|
|
775
|
+
* INTERSECTION CURVE: Sketch curve from intersecting surfaces
|
|
776
|
+
*
|
|
777
|
+
* Computes the intersection of two 3D surfaces/bodies and
|
|
778
|
+
* projects it onto the sketch plane as a construction curve.
|
|
779
|
+
*/
|
|
780
|
+
return this.addEntity('intersection_curve', {
|
|
781
|
+
data: {
|
|
782
|
+
body1Id,
|
|
783
|
+
body2Id,
|
|
784
|
+
surface1Id,
|
|
785
|
+
surface2Id,
|
|
786
|
+
isConstruction: true
|
|
351
787
|
}
|
|
352
788
|
});
|
|
353
789
|
},
|
|
354
790
|
|
|
791
|
+
drawTextAlongPath(text, pathEntityId, fontSize = 10) {
|
|
792
|
+
/**
|
|
793
|
+
* TEXT ALONG PATH: Text that follows a curve
|
|
794
|
+
*
|
|
795
|
+
* Distributes text characters along a line, arc, or spline.
|
|
796
|
+
*/
|
|
797
|
+
const pathEntity = this.state.entities.find(e => e.id === pathEntityId);
|
|
798
|
+
if (!pathEntity) return null;
|
|
799
|
+
|
|
800
|
+
return this.addEntity('text_along_path', {
|
|
801
|
+
data: { text, fontSize, pathEntityId, isConstruction: false }
|
|
802
|
+
});
|
|
803
|
+
},
|
|
804
|
+
|
|
805
|
+
drawFitPointSpline(points) {
|
|
806
|
+
/**
|
|
807
|
+
* FIT POINT SPLINE (through-point B-spline)
|
|
808
|
+
*
|
|
809
|
+
* Creates a spline that passes THROUGH all specified points
|
|
810
|
+
* (unlike control-point spline which passes near control points).
|
|
811
|
+
* Uses automatic knot vector generation for smooth interpolation.
|
|
812
|
+
*/
|
|
813
|
+
if (points.length < 2) {
|
|
814
|
+
console.warn('Fit point spline requires at least 2 points');
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return this.addEntity('spline_fit', {
|
|
819
|
+
points,
|
|
820
|
+
data: {
|
|
821
|
+
degree: Math.min(3, points.length - 1),
|
|
822
|
+
isFitPoint: true,
|
|
823
|
+
knotVector: this.generateFitPointKnots(points.length)
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
},
|
|
827
|
+
|
|
828
|
+
generateFitPointKnots(n) {
|
|
829
|
+
/**
|
|
830
|
+
* Generate knot vector for fit-point (interpolating) spline
|
|
831
|
+
* Uses Centripetal Catmull-Rom parameterization
|
|
832
|
+
*/
|
|
833
|
+
const knots = [0, 0, 0, 0];
|
|
834
|
+
for (let i = 1; i <= n - 2; i++) {
|
|
835
|
+
knots.push(i);
|
|
836
|
+
}
|
|
837
|
+
knots.push(n - 1, n - 1, n - 1, n - 1);
|
|
838
|
+
return knots;
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
drawMidpoint(entityId) {
|
|
842
|
+
/**
|
|
843
|
+
* MIDPOINT: Create a point at midpoint of any edge
|
|
844
|
+
*
|
|
845
|
+
* Adds a construction point at the midpoint of a line, arc, or spline.
|
|
846
|
+
* Useful as reference for other constraints.
|
|
847
|
+
*/
|
|
848
|
+
const entity = this.state.entities.find(e => e.id === entityId);
|
|
849
|
+
if (!entity || !['line', 'arc', 'spline'].includes(entity.type)) {
|
|
850
|
+
console.warn('Midpoint tool requires line, arc, or spline');
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
let midpoint;
|
|
855
|
+
if (entity.type === 'line') {
|
|
856
|
+
const [p1, p2] = entity.points;
|
|
857
|
+
midpoint = new THREE.Vector2(
|
|
858
|
+
(p1.x + p2.x) / 2,
|
|
859
|
+
(p1.y + p2.y) / 2
|
|
860
|
+
);
|
|
861
|
+
} else if (entity.type === 'arc') {
|
|
862
|
+
const [start, end] = entity.points;
|
|
863
|
+
midpoint = new THREE.Vector2(
|
|
864
|
+
(start.x + end.x) / 2,
|
|
865
|
+
(start.y + end.y) / 2
|
|
866
|
+
);
|
|
867
|
+
} else if (entity.type === 'spline') {
|
|
868
|
+
// Evaluate spline at t=0.5
|
|
869
|
+
midpoint = this.evaluateBSpline(entity.points, 0.5, entity.data.degree);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return this.addEntity('point', {
|
|
873
|
+
points: [midpoint],
|
|
874
|
+
data: { linkedEntityId: entityId, type: 'midpoint' },
|
|
875
|
+
isConstruction: true
|
|
876
|
+
});
|
|
877
|
+
},
|
|
878
|
+
|
|
879
|
+
drawPoint(point, isConstruction = true) {
|
|
880
|
+
/**
|
|
881
|
+
* POINT TOOL: Standalone point or center mark
|
|
882
|
+
*
|
|
883
|
+
* Creates a construction point (small circle) at specified location.
|
|
884
|
+
* Useful for reference geometry and constraint anchors.
|
|
885
|
+
*/
|
|
886
|
+
return this.addEntity('point', {
|
|
887
|
+
points: [point],
|
|
888
|
+
data: { type: 'standalone' },
|
|
889
|
+
isConstruction
|
|
890
|
+
});
|
|
891
|
+
},
|
|
892
|
+
|
|
893
|
+
// ===== EDITING TOOLS =====
|
|
894
|
+
|
|
895
|
+
trim(entityId, clickPoint) {
|
|
896
|
+
/**
|
|
897
|
+
* TRIM TOOL: Remove segment between two intersections or endpoints
|
|
898
|
+
*
|
|
899
|
+
* Algorithm:
|
|
900
|
+
* 1. Find all intersection points on entity with other entities
|
|
901
|
+
* 2. Sort intersections along entity's parametric direction
|
|
902
|
+
* 3. Find which segment the click point falls into
|
|
903
|
+
* 4. Remove that segment, keeping others
|
|
904
|
+
* 5. For lines: creates two new lines. For arcs: splits arc.
|
|
905
|
+
*/
|
|
906
|
+
const entity = this.state.entities.find(e => e.id === entityId);
|
|
907
|
+
if (!entity || !['line', 'arc', 'spline'].includes(entity.type)) return;
|
|
908
|
+
|
|
909
|
+
// Find all intersection points on this entity with other entities
|
|
910
|
+
const intersections = this.findAllIntersectionsOnEntity(entity);
|
|
911
|
+
if (intersections.length === 0) return;
|
|
912
|
+
|
|
913
|
+
// Sort intersections by parametric position along entity
|
|
914
|
+
const sortedInts = intersections.sort((a, b) => a.t - b.t);
|
|
915
|
+
|
|
916
|
+
// Find which segment the click falls into
|
|
917
|
+
let segmentStart = null, segmentEnd = null;
|
|
918
|
+
for (let i = 0; i < sortedInts.length - 1; i++) {
|
|
919
|
+
const midPoint = new THREE.Vector2(
|
|
920
|
+
(sortedInts[i].point.x + sortedInts[i + 1].point.x) / 2,
|
|
921
|
+
(sortedInts[i].point.y + sortedInts[i + 1].point.y) / 2
|
|
922
|
+
);
|
|
923
|
+
if (midPoint.distanceTo(clickPoint) < this.state.snapDistance) {
|
|
924
|
+
segmentStart = sortedInts[i];
|
|
925
|
+
segmentEnd = sortedInts[i + 1];
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (!segmentStart || !segmentEnd) return;
|
|
931
|
+
|
|
932
|
+
// Remove segment and keep remaining pieces
|
|
933
|
+
if (entity.type === 'line') {
|
|
934
|
+
this.splitLineAtTrim(entity, segmentStart, segmentEnd);
|
|
935
|
+
} else if (entity.type === 'arc') {
|
|
936
|
+
this.splitArcAtTrim(entity, segmentStart, segmentEnd);
|
|
937
|
+
} else if (entity.type === 'spline') {
|
|
938
|
+
this.splitSplineAtTrim(entity, segmentStart, segmentEnd);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
this.renderEntity(entity);
|
|
942
|
+
window.dispatchEvent(new CustomEvent('sketch:entityModified', { detail: { entity } }));
|
|
943
|
+
},
|
|
944
|
+
|
|
945
|
+
powerTrim(clickPoints) {
|
|
946
|
+
/**
|
|
947
|
+
* POWER TRIM: Drag to trim multiple entities at once
|
|
948
|
+
*
|
|
949
|
+
* Click and drag along entities to remove all segments
|
|
950
|
+
* that the drag line crosses. Works with line, arc, spline.
|
|
951
|
+
*/
|
|
952
|
+
if (clickPoints.length < 2) return [];
|
|
953
|
+
|
|
954
|
+
const trimmedEntities = [];
|
|
955
|
+
const dragLine = { p1: clickPoints[0], p2: clickPoints[clickPoints.length - 1] };
|
|
956
|
+
|
|
957
|
+
this.state.entities.forEach(entity => {
|
|
958
|
+
if (!['line', 'arc', 'spline'].includes(entity.type)) return;
|
|
959
|
+
|
|
960
|
+
const crossings = this.findEntityDragCrossings(entity, dragLine);
|
|
961
|
+
if (crossings.length >= 2) {
|
|
962
|
+
// Remove segments between pairs of crossings
|
|
963
|
+
crossings.sort((a, b) => a.t - b.t);
|
|
964
|
+
for (let i = 0; i < crossings.length - 1; i++) {
|
|
965
|
+
this.trim(entity.id, new THREE.Vector2(
|
|
966
|
+
(crossings[i].point.x + crossings[i + 1].point.x) / 2,
|
|
967
|
+
(crossings[i].point.y + crossings[i + 1].point.y) / 2
|
|
968
|
+
));
|
|
969
|
+
trimmedEntities.push(entity);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
return trimmedEntities;
|
|
975
|
+
},
|
|
976
|
+
|
|
977
|
+
trimToIntersection(entityId, otherEntityId) {
|
|
978
|
+
/**
|
|
979
|
+
* TRIM TO NEAREST INTERSECTION
|
|
980
|
+
*
|
|
981
|
+
* Automatically trims entity to its nearest intersection
|
|
982
|
+
* with another specific entity.
|
|
983
|
+
*/
|
|
984
|
+
const entity = this.state.entities.find(e => e.id === entityId);
|
|
985
|
+
const other = this.state.entities.find(e => e.id === otherEntityId);
|
|
986
|
+
if (!entity || !other) return;
|
|
987
|
+
|
|
988
|
+
const ints = this.findIntersection(entity, other);
|
|
989
|
+
if (ints.length === 0) return;
|
|
990
|
+
|
|
991
|
+
// Trim to nearest intersection
|
|
992
|
+
const nearest = ints.reduce((a, b) =>
|
|
993
|
+
a.t < b.t ? a : b
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
this.trim(entityId, nearest.point);
|
|
997
|
+
},
|
|
998
|
+
|
|
999
|
+
breakAtPoint(entityId, point) {
|
|
1000
|
+
/**
|
|
1001
|
+
* BREAK AT POINT: Split entity at specified point
|
|
1002
|
+
*
|
|
1003
|
+
* Breaks a line or arc into two segments at the given point
|
|
1004
|
+
* (useful for adding construction references).
|
|
1005
|
+
*/
|
|
1006
|
+
const entity = this.state.entities.find(e => e.id === entityId);
|
|
1007
|
+
if (!entity || !['line', 'arc', 'spline'].includes(entity.type)) return;
|
|
1008
|
+
|
|
1009
|
+
const t = this.findParameterAlongEntity(entity, point);
|
|
1010
|
+
if (t === null) return;
|
|
1011
|
+
|
|
1012
|
+
if (entity.type === 'line') {
|
|
1013
|
+
const [p1, p2] = entity.points;
|
|
1014
|
+
this.addEntity('line', { points: [p1, point] });
|
|
1015
|
+
this.addEntity('line', { points: [point, p2] });
|
|
1016
|
+
const idx = this.state.entities.indexOf(entity);
|
|
1017
|
+
if (idx > -1) this.state.entities.splice(idx, 1);
|
|
1018
|
+
} else if (entity.type === 'arc') {
|
|
1019
|
+
const [start, end, center] = entity.points;
|
|
1020
|
+
const angle1 = Math.atan2(point.y - center.y, point.x - center.x);
|
|
1021
|
+
this.addEntity('arc', {
|
|
1022
|
+
points: [start, point, center],
|
|
1023
|
+
data: { ...entity.data, endAngle: angle1 }
|
|
1024
|
+
});
|
|
1025
|
+
this.addEntity('arc', {
|
|
1026
|
+
points: [point, end, center],
|
|
1027
|
+
data: { ...entity.data, startAngle: angle1 }
|
|
1028
|
+
});
|
|
1029
|
+
const idx = this.state.entities.indexOf(entity);
|
|
1030
|
+
if (idx > -1) this.state.entities.splice(idx, 1);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
window.dispatchEvent(new CustomEvent('sketch:entityModified', { detail: { entity } }));
|
|
1034
|
+
},
|
|
1035
|
+
|
|
1036
|
+
splitLineAtTrim(entity, intStart, intEnd) {
|
|
1037
|
+
// For line: keep segments before intStart and after intEnd, discard middle
|
|
1038
|
+
const [p1, p2] = entity.points;
|
|
1039
|
+
|
|
1040
|
+
// Create first segment: p1 to intStart
|
|
1041
|
+
if (intStart.t > 0.01) {
|
|
1042
|
+
this.addEntity('line', {
|
|
1043
|
+
points: [p1, intStart.point],
|
|
1044
|
+
isConstruction: entity.isConstruction
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// Create second segment: intEnd to p2
|
|
1049
|
+
if (intEnd.t < 0.99) {
|
|
1050
|
+
this.addEntity('line', {
|
|
1051
|
+
points: [intEnd.point, p2],
|
|
1052
|
+
isConstruction: entity.isConstruction
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Remove original line
|
|
1057
|
+
const idx = this.state.entities.indexOf(entity);
|
|
1058
|
+
if (idx > -1) this.state.entities.splice(idx, 1);
|
|
1059
|
+
},
|
|
1060
|
+
|
|
1061
|
+
splitArcAtTrim(entity, intStart, intEnd) {
|
|
1062
|
+
// For arc: keep segments before intStart and after intEnd
|
|
1063
|
+
const { radius, startAngle, endAngle } = entity.data;
|
|
1064
|
+
const [center] = entity.points;
|
|
1065
|
+
|
|
1066
|
+
// Calculate angles at intersection points
|
|
1067
|
+
const intStartAngle = Math.atan2(intStart.point.y - center.y, intStart.point.x - center.x);
|
|
1068
|
+
const intEndAngle = Math.atan2(intEnd.point.y - center.y, intEnd.point.x - center.x);
|
|
1069
|
+
|
|
1070
|
+
// Create first arc: startAngle to intStartAngle
|
|
1071
|
+
if (Math.abs(intStartAngle - startAngle) > 0.01) {
|
|
1072
|
+
this.addEntity('arc', {
|
|
1073
|
+
points: [
|
|
1074
|
+
new THREE.Vector2(center.x + Math.cos(startAngle) * radius, center.y + Math.sin(startAngle) * radius),
|
|
1075
|
+
intStart.point,
|
|
1076
|
+
center
|
|
1077
|
+
],
|
|
1078
|
+
data: { radius, startAngle, endAngle: intStartAngle },
|
|
1079
|
+
isConstruction: entity.isConstruction
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Create second arc: intEndAngle to endAngle
|
|
1084
|
+
if (Math.abs(endAngle - intEndAngle) > 0.01) {
|
|
1085
|
+
this.addEntity('arc', {
|
|
1086
|
+
points: [
|
|
1087
|
+
intEnd.point,
|
|
1088
|
+
new THREE.Vector2(center.x + Math.cos(endAngle) * radius, center.y + Math.sin(endAngle) * radius),
|
|
1089
|
+
center
|
|
1090
|
+
],
|
|
1091
|
+
data: { radius, startAngle: intEndAngle, endAngle },
|
|
1092
|
+
isConstruction: entity.isConstruction
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Remove original arc
|
|
1097
|
+
const idx = this.state.entities.indexOf(entity);
|
|
1098
|
+
if (idx > -1) this.state.entities.splice(idx, 1);
|
|
1099
|
+
},
|
|
1100
|
+
|
|
1101
|
+
splitSplineAtTrim(entity, intStart, intEnd) {
|
|
1102
|
+
// For spline: split at parametric values and keep segments
|
|
1103
|
+
const points = entity.points;
|
|
1104
|
+
|
|
1105
|
+
// Evaluate spline at intStart.t and intEnd.t to get split points
|
|
1106
|
+
const p1 = this.evaluateBSpline(points, intStart.t);
|
|
1107
|
+
const p2 = this.evaluateBSpline(points, intEnd.t);
|
|
1108
|
+
|
|
1109
|
+
// First segment: start to intStart
|
|
1110
|
+
const segmentCount = Math.ceil(intStart.t * points.length);
|
|
1111
|
+
const firstSegmentPoints = points.slice(0, segmentCount);
|
|
1112
|
+
firstSegmentPoints.push(p1);
|
|
1113
|
+
this.addEntity('spline', {
|
|
1114
|
+
points: firstSegmentPoints,
|
|
1115
|
+
data: { degree: entity.data.degree },
|
|
1116
|
+
isConstruction: entity.isConstruction
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// Second segment: intEnd to end
|
|
1120
|
+
const startCount = Math.ceil(intEnd.t * points.length);
|
|
1121
|
+
const secondSegmentPoints = [p2, ...points.slice(startCount)];
|
|
1122
|
+
this.addEntity('spline', {
|
|
1123
|
+
points: secondSegmentPoints,
|
|
1124
|
+
data: { degree: entity.data.degree },
|
|
1125
|
+
isConstruction: entity.isConstruction
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
// Remove original spline
|
|
1129
|
+
const idx = this.state.entities.indexOf(entity);
|
|
1130
|
+
if (idx > -1) this.state.entities.splice(idx, 1);
|
|
1131
|
+
},
|
|
1132
|
+
|
|
1133
|
+
extend(entityId) {
|
|
1134
|
+
/**
|
|
1135
|
+
* EXTEND TOOL: Extend line or arc to nearest intersection with other geometry
|
|
1136
|
+
*
|
|
1137
|
+
* Algorithm:
|
|
1138
|
+
* 1. Identify the endpoint to extend (the one furthest from everything)
|
|
1139
|
+
* 2. Find all potential intersection targets (other lines, circles, arcs)
|
|
1140
|
+
* 3. Compute intersection point with each target
|
|
1141
|
+
* 4. Pick nearest intersection
|
|
1142
|
+
* 5. Move endpoint to intersection point
|
|
1143
|
+
*/
|
|
1144
|
+
const entity = this.state.entities.find(e => e.id === entityId);
|
|
1145
|
+
if (!entity || !['line', 'arc'].includes(entity.type)) return;
|
|
1146
|
+
|
|
1147
|
+
// Identify which endpoint to extend (the one that's not connected to anything)
|
|
1148
|
+
let extendPoint = null;
|
|
1149
|
+
if (entity.type === 'line') {
|
|
1150
|
+
const [p1, p2] = entity.points;
|
|
1151
|
+
// Heuristic: extend from the endpoint closer to mouse (or the second one if ambiguous)
|
|
1152
|
+
extendPoint = p2;
|
|
1153
|
+
} else if (entity.type === 'arc') {
|
|
1154
|
+
extendPoint = entity.points[1]; // endAngle point
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (!extendPoint) return;
|
|
1158
|
+
|
|
1159
|
+
// Find all intersection points with other entities
|
|
1160
|
+
const candidates = [];
|
|
1161
|
+
this.state.entities.forEach(other => {
|
|
1162
|
+
if (other.id === entityId) return;
|
|
1163
|
+
|
|
1164
|
+
const ints = this.findIntersectionsBetween(entity, other);
|
|
1165
|
+
candidates.push(...ints.map(int => ({ ...int, targetId: other.id })));
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
if (candidates.length === 0) return;
|
|
1169
|
+
|
|
1170
|
+
// Find nearest candidate to the extension point
|
|
1171
|
+
const nearest = candidates.reduce((closest, cand) => {
|
|
1172
|
+
const dist = cand.point.distanceTo(extendPoint);
|
|
1173
|
+
return dist < closest.dist && dist > 0.1 ? { ...cand, dist } : closest;
|
|
1174
|
+
}, { dist: Infinity });
|
|
1175
|
+
|
|
1176
|
+
if (nearest.dist === Infinity) return;
|
|
1177
|
+
|
|
1178
|
+
// Update entity endpoint to intersection point
|
|
1179
|
+
if (entity.type === 'line') {
|
|
1180
|
+
entity.points[entity.points.length - 1] = nearest.point;
|
|
1181
|
+
} else if (entity.type === 'arc') {
|
|
1182
|
+
entity.points[1] = nearest.point;
|
|
1183
|
+
// Recalculate endAngle
|
|
1184
|
+
const [, , center] = entity.points;
|
|
1185
|
+
entity.data.endAngle = Math.atan2(
|
|
1186
|
+
nearest.point.y - center.y,
|
|
1187
|
+
nearest.point.x - center.x
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
this.renderEntity(entity);
|
|
1192
|
+
window.dispatchEvent(new CustomEvent('sketch:entityModified', { detail: { entity } }));
|
|
1193
|
+
},
|
|
1194
|
+
|
|
1195
|
+
|
|
355
1196
|
mirror(entityIds, lineId) {
|
|
356
1197
|
const mirrorLine = this.state.entities.find(e => e.id === lineId);
|
|
357
1198
|
if (!mirrorLine || mirrorLine.type !== 'line') return;
|
|
@@ -416,25 +1257,226 @@ const SketchModule = {
|
|
|
416
1257
|
});
|
|
417
1258
|
},
|
|
418
1259
|
|
|
419
|
-
// ===== DIMENSIONS =====
|
|
1260
|
+
// ===== DIMENSIONS (DRIVING CONSTRAINTS) =====
|
|
420
1261
|
|
|
421
1262
|
addDimension(type, entityIds, value) {
|
|
1263
|
+
/**
|
|
1264
|
+
* DIMENSIONS: Driving constraints that control geometry
|
|
1265
|
+
*
|
|
1266
|
+
* Types:
|
|
1267
|
+
* - 'linear': distance between two points or parallel lines
|
|
1268
|
+
* - 'angular': angle between two lines
|
|
1269
|
+
* - 'radial': radius of circle/arc (displays "R25")
|
|
1270
|
+
* - 'diameter': diameter of circle (displays "⌀50")
|
|
1271
|
+
* - 'vertical': vertical distance
|
|
1272
|
+
* - 'horizontal': horizontal distance
|
|
1273
|
+
*
|
|
1274
|
+
* When dimension value changes, geometry is scaled/rotated to match.
|
|
1275
|
+
* Dimension lines are rendered with arrows, extension lines, and text.
|
|
1276
|
+
*/
|
|
422
1277
|
const dimension = {
|
|
423
1278
|
id: `dim_${Date.now()}`,
|
|
424
1279
|
type,
|
|
425
1280
|
entities: entityIds,
|
|
426
1281
|
value,
|
|
427
|
-
driven: true
|
|
1282
|
+
driven: true,
|
|
1283
|
+
position: new THREE.Vector2(0, 0), // position of dimension line
|
|
1284
|
+
rotation: 0, // rotation angle for text
|
|
1285
|
+
isSelected: false
|
|
428
1286
|
};
|
|
429
1287
|
|
|
430
1288
|
this.state.dimensions.push(dimension);
|
|
1289
|
+
|
|
1290
|
+
// Render dimension to canvas
|
|
1291
|
+
this.renderDimension(dimension);
|
|
1292
|
+
|
|
1293
|
+
// Apply dimension constraint: modify geometry to match value
|
|
1294
|
+
this.applyDimensionConstraint(dimension);
|
|
1295
|
+
|
|
431
1296
|
window.dispatchEvent(new CustomEvent('sketch:dimensionAdded', { detail: { dimension } }));
|
|
432
1297
|
return dimension;
|
|
433
1298
|
},
|
|
434
1299
|
|
|
435
|
-
|
|
1300
|
+
renderDimension(dimension) {
|
|
1301
|
+
/**
|
|
1302
|
+
* Render dimension line with arrows, extension lines, and text label.
|
|
1303
|
+
*
|
|
1304
|
+
* Layout:
|
|
1305
|
+
* Entity geometry
|
|
1306
|
+
* |
|
|
1307
|
+
* Extension line
|
|
1308
|
+
* |
|
|
1309
|
+
* [---arrow---20mm---arrow---] <- Dimension line
|
|
1310
|
+
* |
|
|
1311
|
+
* Extension line
|
|
1312
|
+
* |
|
|
1313
|
+
* Entity geometry
|
|
1314
|
+
*/
|
|
1315
|
+
if (!this.state.ctx) return;
|
|
1316
|
+
|
|
1317
|
+
const ctx = this.state.ctx;
|
|
1318
|
+
const entities = dimension.entities
|
|
1319
|
+
.map(id => this.state.entities.find(e => e.id === id))
|
|
1320
|
+
.filter(e => e);
|
|
1321
|
+
|
|
1322
|
+
if (entities.length < 2) return;
|
|
1323
|
+
|
|
1324
|
+
let startPoint, endPoint;
|
|
1325
|
+
|
|
1326
|
+
if (dimension.type === 'linear') {
|
|
1327
|
+
// Linear dimension: measure distance between two points or entities
|
|
1328
|
+
const e1 = entities[0], e2 = entities[1];
|
|
1329
|
+
startPoint = e1.points[0];
|
|
1330
|
+
endPoint = e2.points[0];
|
|
1331
|
+
} else if (dimension.type === 'radial' || dimension.type === 'diameter') {
|
|
1332
|
+
// Radial dimension: measure radius/diameter of circle or arc
|
|
1333
|
+
const circle = entities[0];
|
|
1334
|
+
if (!['circle', 'arc'].includes(circle.type)) return;
|
|
1335
|
+
|
|
1336
|
+
const center = circle.points[0];
|
|
1337
|
+
const radius = circle.data.radius;
|
|
1338
|
+
startPoint = center;
|
|
1339
|
+
endPoint = new THREE.Vector2(center.x + radius, center.y);
|
|
1340
|
+
} else if (dimension.type === 'angular') {
|
|
1341
|
+
// Angular dimension: measure angle between two lines
|
|
1342
|
+
const [e1, e2] = entities;
|
|
1343
|
+
if (!e1.points || !e2.points) return;
|
|
1344
|
+
startPoint = e1.points[0];
|
|
1345
|
+
endPoint = e2.points[0];
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
if (!startPoint || !endPoint) return;
|
|
1349
|
+
|
|
1350
|
+
// Dimension line position (offset from geometry)
|
|
1351
|
+
const dimOffset = 20; // pixels
|
|
1352
|
+
const midPoint = new THREE.Vector2(
|
|
1353
|
+
(startPoint.x + endPoint.x) / 2,
|
|
1354
|
+
(startPoint.y + endPoint.y) / 2
|
|
1355
|
+
);
|
|
1356
|
+
const direction = new THREE.Vector2(endPoint.x - startPoint.x, endPoint.y - startPoint.y).normalize();
|
|
1357
|
+
const perpendicular = new THREE.Vector2(-direction.y, direction.x).multiplyScalar(dimOffset);
|
|
1358
|
+
|
|
1359
|
+
const dimStart = new THREE.Vector2(startPoint.x + perpendicular.x, startPoint.y + perpendicular.y);
|
|
1360
|
+
const dimEnd = new THREE.Vector2(endPoint.x + perpendicular.x, endPoint.y + perpendicular.y);
|
|
1361
|
+
|
|
1362
|
+
// Draw extension lines (from geometry to dimension line)
|
|
1363
|
+
ctx.strokeStyle = '#00ff00';
|
|
1364
|
+
ctx.lineWidth = 1;
|
|
1365
|
+
|
|
1366
|
+
ctx.beginPath();
|
|
1367
|
+
ctx.moveTo(startPoint.x, startPoint.y);
|
|
1368
|
+
ctx.lineTo(dimStart.x, dimStart.y);
|
|
1369
|
+
ctx.stroke();
|
|
1370
|
+
|
|
1371
|
+
ctx.beginPath();
|
|
1372
|
+
ctx.moveTo(endPoint.x, endPoint.y);
|
|
1373
|
+
ctx.lineTo(dimEnd.x, dimEnd.y);
|
|
1374
|
+
ctx.stroke();
|
|
1375
|
+
|
|
1376
|
+
// Draw dimension line with arrows
|
|
1377
|
+
ctx.beginPath();
|
|
1378
|
+
ctx.moveTo(dimStart.x, dimStart.y);
|
|
1379
|
+
ctx.lineTo(dimEnd.x, dimEnd.y);
|
|
1380
|
+
ctx.stroke();
|
|
1381
|
+
|
|
1382
|
+
// Draw arrows (triangles at each end)
|
|
1383
|
+
const arrowSize = 4;
|
|
1384
|
+
this.drawArrow(ctx, dimStart, direction, arrowSize);
|
|
1385
|
+
this.drawArrow(ctx, dimEnd, direction.clone().negate(), arrowSize);
|
|
1386
|
+
|
|
1387
|
+
// Draw text label
|
|
1388
|
+
const textValue = dimension.type === 'radial' ? `R${dimension.value}` :
|
|
1389
|
+
dimension.type === 'diameter' ? `⌀${dimension.value}` :
|
|
1390
|
+
`${dimension.value}mm`;
|
|
1391
|
+
const textPos = new THREE.Vector2(
|
|
1392
|
+
(dimStart.x + dimEnd.x) / 2,
|
|
1393
|
+
(dimStart.y + dimEnd.y) / 2 - 10
|
|
1394
|
+
);
|
|
1395
|
+
|
|
1396
|
+
ctx.font = 'bold 12px Arial';
|
|
1397
|
+
ctx.fillStyle = '#00ff00';
|
|
1398
|
+
ctx.textAlign = 'center';
|
|
1399
|
+
ctx.textBaseline = 'bottom';
|
|
1400
|
+
ctx.fillText(textValue, textPos.x, textPos.y);
|
|
1401
|
+
|
|
1402
|
+
dimension.position = midPoint;
|
|
1403
|
+
},
|
|
1404
|
+
|
|
1405
|
+
drawArrow(ctx, point, direction, size) {
|
|
1406
|
+
// Draw arrow head (triangle)
|
|
1407
|
+
const perp = new THREE.Vector2(-direction.y, direction.x).multiplyScalar(size / 2);
|
|
1408
|
+
const back = direction.clone().multiplyScalar(-size).add(point);
|
|
1409
|
+
|
|
1410
|
+
ctx.beginPath();
|
|
1411
|
+
ctx.moveTo(point.x, point.y);
|
|
1412
|
+
ctx.lineTo(back.x + perp.x, back.y + perp.y);
|
|
1413
|
+
ctx.lineTo(back.x - perp.x, back.y - perp.y);
|
|
1414
|
+
ctx.closePath();
|
|
1415
|
+
ctx.fill();
|
|
1416
|
+
},
|
|
1417
|
+
|
|
1418
|
+
applyDimensionConstraint(dimension) {
|
|
1419
|
+
/**
|
|
1420
|
+
* Apply dimension constraint: modify geometry to match dimension value
|
|
1421
|
+
*
|
|
1422
|
+
* For linear dimension: scale/move geometry
|
|
1423
|
+
* For radial: change radius of circle/arc
|
|
1424
|
+
* For angular: rotate one entity to match angle
|
|
1425
|
+
*/
|
|
1426
|
+
if (!dimension.driven) return;
|
|
1427
|
+
|
|
1428
|
+
const entities = dimension.entities
|
|
1429
|
+
.map(id => this.state.entities.find(e => e.id === id))
|
|
1430
|
+
.filter(e => e);
|
|
1431
|
+
|
|
1432
|
+
if (dimension.type === 'linear' && entities.length >= 2) {
|
|
1433
|
+
const [e1, e2] = entities;
|
|
1434
|
+
const currentDist = e1.points[0].distanceTo(e2.points[0]);
|
|
1435
|
+
const scale = dimension.value / currentDist;
|
|
1436
|
+
|
|
1437
|
+
// Move e2 to match dimension value
|
|
1438
|
+
const dir = new THREE.Vector2(e2.points[0].x - e1.points[0].x, e2.points[0].y - e1.points[0].y)
|
|
1439
|
+
.normalize()
|
|
1440
|
+
.multiplyScalar(dimension.value);
|
|
1441
|
+
e2.points[0] = new THREE.Vector2(e1.points[0].x + dir.x, e1.points[0].y + dir.y);
|
|
1442
|
+
|
|
1443
|
+
this.renderEntity(e2);
|
|
1444
|
+
} else if ((dimension.type === 'radial' || dimension.type === 'diameter') && entities.length > 0) {
|
|
1445
|
+
const circle = entities[0];
|
|
1446
|
+
if (['circle', 'arc'].includes(circle.type)) {
|
|
1447
|
+
const newRadius = dimension.type === 'diameter' ? dimension.value / 2 : dimension.value;
|
|
1448
|
+
circle.data.radius = newRadius;
|
|
1449
|
+
this.renderEntity(circle);
|
|
1450
|
+
}
|
|
1451
|
+
} else if (dimension.type === 'angular' && entities.length >= 2) {
|
|
1452
|
+
const [e1, e2] = entities;
|
|
1453
|
+
// Rotate e2 around e1's origin to match angle
|
|
1454
|
+
const angle = dimension.value * Math.PI / 180;
|
|
1455
|
+
const dir = new THREE.Vector2(Math.cos(angle), Math.sin(angle));
|
|
1456
|
+
|
|
1457
|
+
e2.points = e2.points.map(p => {
|
|
1458
|
+
const relative = new THREE.Vector2(p.x - e1.points[0].x, p.y - e1.points[0].y);
|
|
1459
|
+
const rotated = new THREE.Vector2(
|
|
1460
|
+
relative.x * Math.cos(angle) - relative.y * Math.sin(angle),
|
|
1461
|
+
relative.x * Math.sin(angle) + relative.y * Math.cos(angle)
|
|
1462
|
+
);
|
|
1463
|
+
return new THREE.Vector2(e1.points[0].x + rotated.x, e1.points[0].y + rotated.y);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
this.renderEntity(e2);
|
|
1467
|
+
}
|
|
1468
|
+
},
|
|
1469
|
+
|
|
1470
|
+
// ===== CONSTRUCTION GEOMETRY & CONSTRAINTS =====
|
|
436
1471
|
|
|
437
1472
|
toggleConstruction(entityIds) {
|
|
1473
|
+
/**
|
|
1474
|
+
* CONSTRUCTION GEOMETRY: Reference-only entities not included in sketch profile
|
|
1475
|
+
*
|
|
1476
|
+
* Construction entities are rendered as dashed lines and not used when
|
|
1477
|
+
* extruding or revolving the sketch. Useful for reference geometry,
|
|
1478
|
+
* centerlines, and construction lines.
|
|
1479
|
+
*/
|
|
438
1480
|
entityIds.forEach(id => {
|
|
439
1481
|
const entity = this.state.entities.find(e => e.id === id);
|
|
440
1482
|
if (entity) {
|
|
@@ -444,6 +1486,82 @@ const SketchModule = {
|
|
|
444
1486
|
});
|
|
445
1487
|
},
|
|
446
1488
|
|
|
1489
|
+
offset(entityIds, distance) {
|
|
1490
|
+
/**
|
|
1491
|
+
* OFFSET CURVES: Create parallel copies at given distance
|
|
1492
|
+
*
|
|
1493
|
+
* For lines: shift perpendicular by distance
|
|
1494
|
+
* For circles: change radius (inward/outward)
|
|
1495
|
+
* For arcs: change radius, keep center and angular span
|
|
1496
|
+
* For splines: offset control points along normal direction
|
|
1497
|
+
*/
|
|
1498
|
+
entityIds.forEach(id => {
|
|
1499
|
+
const entity = this.state.entities.find(e => e.id === id);
|
|
1500
|
+
if (!entity) return;
|
|
1501
|
+
|
|
1502
|
+
if (entity.type === 'line' && entity.points.length === 2) {
|
|
1503
|
+
// Offset line: shift perpendicular to direction
|
|
1504
|
+
const [p1, p2] = entity.points;
|
|
1505
|
+
const dir = p2.clone().sub(p1).normalize();
|
|
1506
|
+
const perp = new THREE.Vector2(-dir.y, dir.x).multiplyScalar(distance);
|
|
1507
|
+
|
|
1508
|
+
this.addEntity('line', {
|
|
1509
|
+
points: [p1.clone().add(perp), p2.clone().add(perp)],
|
|
1510
|
+
isConstruction: entity.isConstruction
|
|
1511
|
+
});
|
|
1512
|
+
} else if (entity.type === 'circle') {
|
|
1513
|
+
// Offset circle: change radius
|
|
1514
|
+
const newRadius = entity.data.radius + distance;
|
|
1515
|
+
if (newRadius > 0.1) {
|
|
1516
|
+
this.addEntity('circle', {
|
|
1517
|
+
points: entity.points.map(p => p.clone()),
|
|
1518
|
+
data: { radius: newRadius },
|
|
1519
|
+
isConstruction: entity.isConstruction
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
} else if (entity.type === 'arc') {
|
|
1523
|
+
// Offset arc: change radius, keep center and angles
|
|
1524
|
+
const newRadius = entity.data.radius + distance;
|
|
1525
|
+
if (newRadius > 0.1) {
|
|
1526
|
+
this.addEntity('arc', {
|
|
1527
|
+
points: entity.points.map(p => p.clone()),
|
|
1528
|
+
data: {
|
|
1529
|
+
radius: newRadius,
|
|
1530
|
+
startAngle: entity.data.startAngle,
|
|
1531
|
+
endAngle: entity.data.endAngle
|
|
1532
|
+
},
|
|
1533
|
+
isConstruction: entity.isConstruction
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
} else if (entity.type === 'spline') {
|
|
1537
|
+
// Offset spline: offset control points along normal direction
|
|
1538
|
+
// Use perpendicular direction at each control point
|
|
1539
|
+
const offsetPoints = entity.points.map((point, i) => {
|
|
1540
|
+
if (i === 0 || i === entity.points.length - 1) {
|
|
1541
|
+
// End points: use direction to next/prev point
|
|
1542
|
+
const nextPoint = i === 0 ? entity.points[1] : entity.points[i - 1];
|
|
1543
|
+
const dir = new THREE.Vector2(nextPoint.x - point.x, nextPoint.y - point.y).normalize();
|
|
1544
|
+
const perp = new THREE.Vector2(-dir.y, dir.x).multiplyScalar(distance);
|
|
1545
|
+
return point.clone().add(perp);
|
|
1546
|
+
} else {
|
|
1547
|
+
// Interior points: average of prev and next directions
|
|
1548
|
+
const prevDir = new THREE.Vector2(point.x - entity.points[i - 1].x, point.y - entity.points[i - 1].y).normalize();
|
|
1549
|
+
const nextDir = new THREE.Vector2(entity.points[i + 1].x - point.x, entity.points[i + 1].y - point.y).normalize();
|
|
1550
|
+
const avgDir = new THREE.Vector2(prevDir.x + nextDir.x, prevDir.y + nextDir.y).normalize();
|
|
1551
|
+
const perp = new THREE.Vector2(-avgDir.y, avgDir.x).multiplyScalar(distance);
|
|
1552
|
+
return point.clone().add(perp);
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
this.addEntity('spline', {
|
|
1557
|
+
points: offsetPoints,
|
|
1558
|
+
data: { degree: entity.data.degree },
|
|
1559
|
+
isConstruction: entity.isConstruction
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
},
|
|
1564
|
+
|
|
447
1565
|
// ===== RENDERING =====
|
|
448
1566
|
|
|
449
1567
|
setupCanvasOverlay() {
|
|
@@ -479,8 +1597,12 @@ const SketchModule = {
|
|
|
479
1597
|
|
|
480
1598
|
switch (entity.type) {
|
|
481
1599
|
case 'line':
|
|
482
|
-
geometry = new THREE.BufferGeometry().setFromPoints(
|
|
483
|
-
|
|
1600
|
+
geometry = new THREE.BufferGeometry().setFromPoints(
|
|
1601
|
+
entity.points.map(p => new THREE.Vector3(p.x, p.y, 0))
|
|
1602
|
+
);
|
|
1603
|
+
material = entity.isConstruction
|
|
1604
|
+
? new THREE.LineDashedMaterial({ color, linewidth, dashSize: 2, gapSize: 2 })
|
|
1605
|
+
: new THREE.LineBasicMaterial({ color, linewidth });
|
|
484
1606
|
break;
|
|
485
1607
|
|
|
486
1608
|
case 'circle':
|
|
@@ -497,7 +1619,9 @@ const SketchModule = {
|
|
|
497
1619
|
));
|
|
498
1620
|
}
|
|
499
1621
|
geometry.setFromPoints(circlePoints);
|
|
500
|
-
material =
|
|
1622
|
+
material = entity.isConstruction
|
|
1623
|
+
? new THREE.LineDashedMaterial({ color, linewidth, dashSize: 2, gapSize: 2 })
|
|
1624
|
+
: new THREE.LineBasicMaterial({ color, linewidth });
|
|
501
1625
|
break;
|
|
502
1626
|
|
|
503
1627
|
case 'arc':
|
|
@@ -516,7 +1640,25 @@ const SketchModule = {
|
|
|
516
1640
|
));
|
|
517
1641
|
}
|
|
518
1642
|
geometry.setFromPoints(arcPoints);
|
|
519
|
-
material =
|
|
1643
|
+
material = entity.isConstruction
|
|
1644
|
+
? new THREE.LineDashedMaterial({ color, linewidth, dashSize: 2, gapSize: 2 })
|
|
1645
|
+
: new THREE.LineBasicMaterial({ color, linewidth });
|
|
1646
|
+
break;
|
|
1647
|
+
|
|
1648
|
+
case 'spline':
|
|
1649
|
+
// Render spline curve evaluated at high resolution
|
|
1650
|
+
geometry = new THREE.BufferGeometry();
|
|
1651
|
+
const splinePoints = [];
|
|
1652
|
+
const samples = 128; // High resolution curve
|
|
1653
|
+
for (let i = 0; i <= samples; i++) {
|
|
1654
|
+
const t = i / samples;
|
|
1655
|
+
const point = this.evaluateBSpline(entity.points, t, entity.data.degree);
|
|
1656
|
+
splinePoints.push(new THREE.Vector3(point.x, point.y, 0));
|
|
1657
|
+
}
|
|
1658
|
+
geometry.setFromPoints(splinePoints);
|
|
1659
|
+
material = entity.isConstruction
|
|
1660
|
+
? new THREE.LineDashedMaterial({ color, linewidth, dashSize: 2, gapSize: 2 })
|
|
1661
|
+
: new THREE.LineBasicMaterial({ color, linewidth });
|
|
520
1662
|
break;
|
|
521
1663
|
|
|
522
1664
|
default:
|
|
@@ -525,6 +1667,12 @@ const SketchModule = {
|
|
|
525
1667
|
|
|
526
1668
|
const line = new THREE.Line(geometry, material);
|
|
527
1669
|
line.userData.entityId = entity.id;
|
|
1670
|
+
|
|
1671
|
+
// Set line distance for dashed material
|
|
1672
|
+
if (material instanceof THREE.LineDashedMaterial) {
|
|
1673
|
+
line.computeLineDistances();
|
|
1674
|
+
}
|
|
1675
|
+
|
|
528
1676
|
this.state.canvasGroup.add(line);
|
|
529
1677
|
},
|
|
530
1678
|
|
|
@@ -534,57 +1682,431 @@ const SketchModule = {
|
|
|
534
1682
|
}
|
|
535
1683
|
},
|
|
536
1684
|
|
|
537
|
-
// =====
|
|
1685
|
+
// ===== INTERSECTION ENGINE =====
|
|
1686
|
+
/**
|
|
1687
|
+
* ROBUST GEOMETRIC INTERSECTION FUNCTIONS
|
|
1688
|
+
*
|
|
1689
|
+
* Each function returns parametric values (t) along the first entity
|
|
1690
|
+
* and the actual intersection point(s). This allows trim() to know
|
|
1691
|
+
* WHERE on the entity to split.
|
|
1692
|
+
*/
|
|
1693
|
+
|
|
1694
|
+
findAllIntersectionsOnEntity(entity) {
|
|
1695
|
+
/**
|
|
1696
|
+
* Find all intersection points on a given entity with all other entities.
|
|
1697
|
+
* Returns array of { point, t, otherEntity } sorted by parametric position.
|
|
1698
|
+
*/
|
|
1699
|
+
const intersections = [];
|
|
1700
|
+
|
|
1701
|
+
this.state.entities.forEach(other => {
|
|
1702
|
+
if (other.id === entity.id) return;
|
|
1703
|
+
const ints = this.findIntersectionsBetween(entity, other);
|
|
1704
|
+
intersections.push(...ints);
|
|
1705
|
+
});
|
|
1706
|
+
|
|
1707
|
+
// Sort by parametric position along entity
|
|
1708
|
+
return intersections.sort((a, b) => (a.t || 0) - (b.t || 0));
|
|
1709
|
+
},
|
|
538
1710
|
|
|
539
1711
|
findIntersections(entity) {
|
|
540
1712
|
const intersections = [];
|
|
541
1713
|
this.state.entities.forEach(other => {
|
|
542
1714
|
if (other.id !== entity.id) {
|
|
543
1715
|
const ints = this.findIntersectionsBetween(entity, other);
|
|
544
|
-
intersections.push(...ints);
|
|
1716
|
+
intersections.push(...ints.map(int => int.point));
|
|
545
1717
|
}
|
|
546
1718
|
});
|
|
547
1719
|
return intersections;
|
|
548
1720
|
},
|
|
549
1721
|
|
|
550
1722
|
findIntersectionsBetween(e1, e2) {
|
|
551
|
-
|
|
552
|
-
|
|
1723
|
+
/**
|
|
1724
|
+
* Dispatch to appropriate intersection function based on entity types.
|
|
1725
|
+
* Returns array of { point, t, t2 } for each intersection.
|
|
1726
|
+
*/
|
|
553
1727
|
const intersections = [];
|
|
554
1728
|
|
|
1729
|
+
// Line-Line
|
|
555
1730
|
if (e1.type === 'line' && e2.type === 'line') {
|
|
556
|
-
const [
|
|
557
|
-
const [p3, p4] = e2.points;
|
|
558
|
-
const int = this.lineLineIntersection(p1, p2, p3, p4);
|
|
1731
|
+
const int = this.lineLineIntersection(e1.points[0], e1.points[1], e2.points[0], e2.points[1]);
|
|
559
1732
|
if (int) intersections.push(int);
|
|
560
1733
|
}
|
|
561
1734
|
|
|
1735
|
+
// Line-Circle
|
|
1736
|
+
else if (e1.type === 'line' && e2.type === 'circle') {
|
|
1737
|
+
const ints = this.lineCircleIntersection(e1.points[0], e1.points[1], e2.points[0], e2.data.radius);
|
|
1738
|
+
intersections.push(...ints);
|
|
1739
|
+
} else if (e1.type === 'circle' && e2.type === 'line') {
|
|
1740
|
+
const ints = this.lineCircleIntersection(e2.points[0], e2.points[1], e1.points[0], e1.data.radius);
|
|
1741
|
+
intersections.push(...ints.map(int => ({ ...int, t: int.t2, t2: int.t })));
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// Line-Arc
|
|
1745
|
+
else if (e1.type === 'line' && e2.type === 'arc') {
|
|
1746
|
+
const ints = this.lineArcIntersection(e1.points[0], e1.points[1], e2);
|
|
1747
|
+
intersections.push(...ints);
|
|
1748
|
+
} else if (e1.type === 'arc' && e2.type === 'line') {
|
|
1749
|
+
const ints = this.lineArcIntersection(e2.points[0], e2.points[1], e1);
|
|
1750
|
+
intersections.push(...ints.map(int => ({ ...int, t: int.t2, t2: int.t })));
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// Circle-Circle
|
|
1754
|
+
else if (e1.type === 'circle' && e2.type === 'circle') {
|
|
1755
|
+
const ints = this.circleCircleIntersection(e1.points[0], e1.data.radius, e2.points[0], e2.data.radius);
|
|
1756
|
+
intersections.push(...ints);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// Arc-Arc
|
|
1760
|
+
else if (e1.type === 'arc' && e2.type === 'arc') {
|
|
1761
|
+
const ints = this.circleCircleIntersection(e1.points[2], e1.data.radius, e2.points[2], e2.data.radius);
|
|
1762
|
+
intersections.push(...ints.filter(int => this.pointOnArc(int.point, e1) && this.pointOnArc(int.point, e2)));
|
|
1763
|
+
}
|
|
1764
|
+
|
|
562
1765
|
return intersections;
|
|
563
1766
|
},
|
|
564
1767
|
|
|
565
1768
|
lineLineIntersection(p1, p2, p3, p4) {
|
|
1769
|
+
/**
|
|
1770
|
+
* LINE-LINE INTERSECTION (2D)
|
|
1771
|
+
*
|
|
1772
|
+
* Parametric form:
|
|
1773
|
+
* P = p1 + t * (p2 - p1) for first line
|
|
1774
|
+
* Q = p3 + s * (p4 - p3) for second line
|
|
1775
|
+
*
|
|
1776
|
+
* At intersection: P = Q
|
|
1777
|
+
* p1 + t * (p2 - p1) = p3 + s * (p4 - p3)
|
|
1778
|
+
*
|
|
1779
|
+
* Solving using cross products and determinants.
|
|
1780
|
+
*/
|
|
566
1781
|
const x1 = p1.x, y1 = p1.y, x2 = p2.x, y2 = p2.y;
|
|
567
1782
|
const x3 = p3.x, y3 = p3.y, x4 = p4.x, y4 = p4.y;
|
|
568
1783
|
|
|
569
1784
|
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
|
|
570
|
-
if (Math.abs(denom) <
|
|
1785
|
+
if (Math.abs(denom) < 1e-10) return null; // Parallel or coincident
|
|
571
1786
|
|
|
572
1787
|
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
|
|
573
|
-
|
|
1788
|
+
const s = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
|
|
1789
|
+
|
|
1790
|
+
// Check if intersection is within both line segments
|
|
1791
|
+
if (t >= 0 && t <= 1 && s >= 0 && s <= 1) {
|
|
1792
|
+
const point = new THREE.Vector2(x1 + t * (x2 - x1), y1 + t * (y2 - y1));
|
|
1793
|
+
return { point, t, t2: s };
|
|
1794
|
+
}
|
|
574
1795
|
|
|
575
|
-
return
|
|
1796
|
+
return null;
|
|
576
1797
|
},
|
|
577
1798
|
|
|
578
|
-
|
|
1799
|
+
lineCircleIntersection(p1, p2, center, radius) {
|
|
1800
|
+
/**
|
|
1801
|
+
* LINE-CIRCLE INTERSECTION (2D)
|
|
1802
|
+
*
|
|
1803
|
+
* Line: P = p1 + t * (p2 - p1), t ∈ [0, 1]
|
|
1804
|
+
* Circle: |P - center| = radius
|
|
1805
|
+
*
|
|
1806
|
+
* Substitute line into circle equation and solve quadratic:
|
|
1807
|
+
* a*t² + b*t + c = 0
|
|
1808
|
+
* where:
|
|
1809
|
+
* a = |direction|²
|
|
1810
|
+
* b = 2 * (direction · (p1 - center))
|
|
1811
|
+
* c = |p1 - center|² - radius²
|
|
1812
|
+
*/
|
|
1813
|
+
const dir = new THREE.Vector2(p2.x - p1.x, p2.y - p1.y);
|
|
1814
|
+
const oc = new THREE.Vector2(p1.x - center.x, p1.y - center.y);
|
|
1815
|
+
|
|
1816
|
+
const a = dir.dot(dir);
|
|
1817
|
+
const b = 2 * dir.dot(oc);
|
|
1818
|
+
const c = oc.dot(oc) - radius * radius;
|
|
1819
|
+
|
|
1820
|
+
const discriminant = b * b - 4 * a * c;
|
|
1821
|
+
if (discriminant < 0) return []; // No intersection
|
|
1822
|
+
|
|
1823
|
+
const sqrtDisc = Math.sqrt(discriminant);
|
|
1824
|
+
const t1 = (-b - sqrtDisc) / (2 * a);
|
|
1825
|
+
const t2 = (-b + sqrtDisc) / (2 * a);
|
|
1826
|
+
|
|
1827
|
+
const intersections = [];
|
|
1828
|
+
|
|
1829
|
+
if (t1 >= 0 && t1 <= 1) {
|
|
1830
|
+
const point = new THREE.Vector2(p1.x + t1 * dir.x, p1.y + t1 * dir.y);
|
|
1831
|
+
intersections.push({ point, t: t1, t2: Math.atan2(point.y - center.y, point.x - center.x) });
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
if (t2 >= 0 && t2 <= 1 && Math.abs(t2 - t1) > 1e-6) {
|
|
1835
|
+
const point = new THREE.Vector2(p1.x + t2 * dir.x, p1.y + t2 * dir.y);
|
|
1836
|
+
intersections.push({ point, t: t2, t2: Math.atan2(point.y - center.y, point.x - center.x) });
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
return intersections;
|
|
1840
|
+
},
|
|
1841
|
+
|
|
1842
|
+
lineArcIntersection(p1, p2, arc) {
|
|
1843
|
+
/**
|
|
1844
|
+
* LINE-ARC INTERSECTION
|
|
1845
|
+
*
|
|
1846
|
+
* Arc is part of circle, so find line-circle intersections,
|
|
1847
|
+
* then filter to only those within arc's angular span.
|
|
1848
|
+
*/
|
|
1849
|
+
const [, , center] = arc.points;
|
|
1850
|
+
const radius = arc.data.radius;
|
|
1851
|
+
|
|
1852
|
+
const ints = this.lineCircleIntersection(p1, p2, center, radius);
|
|
1853
|
+
|
|
1854
|
+
// Filter to points within arc's angular span
|
|
1855
|
+
return ints.filter(int => {
|
|
1856
|
+
const angle = Math.atan2(int.point.y - center.y, int.point.x - center.x);
|
|
1857
|
+
return this.angleInRange(angle, arc.data.startAngle, arc.data.endAngle);
|
|
1858
|
+
}).map(int => ({ ...int, t2: angle }));
|
|
1859
|
+
},
|
|
1860
|
+
|
|
1861
|
+
circleCircleIntersection(c1, r1, c2, r2) {
|
|
1862
|
+
/**
|
|
1863
|
+
* CIRCLE-CIRCLE INTERSECTION (2D)
|
|
1864
|
+
*
|
|
1865
|
+
* Two circles:
|
|
1866
|
+
* |P - c1| = r1
|
|
1867
|
+
* |P - c2| = r2
|
|
1868
|
+
*
|
|
1869
|
+
* Distance between centers: d = |c2 - c1|
|
|
1870
|
+
* If d > r1 + r2 or d < |r1 - r2|: no intersection
|
|
1871
|
+
* If d = 0 and r1 = r2: coincident (infinite intersections)
|
|
1872
|
+
* Otherwise: two intersection points
|
|
1873
|
+
*/
|
|
1874
|
+
const dx = c2.x - c1.x;
|
|
1875
|
+
const dy = c2.y - c1.y;
|
|
1876
|
+
const d = Math.sqrt(dx * dx + dy * dy);
|
|
1877
|
+
|
|
1878
|
+
// Check for no intersection or coincident circles
|
|
1879
|
+
if (d > r1 + r2 || d < Math.abs(r1 - r2) || d < 1e-10) return [];
|
|
1880
|
+
|
|
1881
|
+
// Distance from c1 to line connecting intersection points
|
|
1882
|
+
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
|
|
1883
|
+
|
|
1884
|
+
// Perpendicular distance from line to intersection points
|
|
1885
|
+
const h = Math.sqrt(r1 * r1 - a * a);
|
|
1886
|
+
|
|
1887
|
+
// Midpoint of intersection chord
|
|
1888
|
+
const mx = c1.x + a * dx / d;
|
|
1889
|
+
const my = c1.y + a * dy / d;
|
|
1890
|
+
|
|
1891
|
+
// Perpendicular vector (normalized)
|
|
1892
|
+
const px = -dy / d;
|
|
1893
|
+
const py = dx / d;
|
|
1894
|
+
|
|
1895
|
+
const p1 = new THREE.Vector2(mx + h * px, my + h * py);
|
|
1896
|
+
const p2 = new THREE.Vector2(mx - h * px, my - h * py);
|
|
1897
|
+
|
|
1898
|
+
const intersections = [];
|
|
1899
|
+
|
|
1900
|
+
if (h > 1e-6) {
|
|
1901
|
+
// Two distinct points
|
|
1902
|
+
intersections.push(
|
|
1903
|
+
{ point: p1, t: Math.atan2(p1.y - c1.y, p1.x - c1.x), t2: Math.atan2(p1.y - c2.y, p1.x - c2.x) },
|
|
1904
|
+
{ point: p2, t: Math.atan2(p2.y - c1.y, p2.x - c1.x), t2: Math.atan2(p2.y - c2.y, p2.x - c2.x) }
|
|
1905
|
+
);
|
|
1906
|
+
} else {
|
|
1907
|
+
// Single tangent point
|
|
1908
|
+
intersections.push(
|
|
1909
|
+
{ point: p1, t: Math.atan2(p1.y - c1.y, p1.x - c1.x), t2: Math.atan2(p1.y - c2.y, p1.x - c2.x) }
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
return intersections;
|
|
1914
|
+
},
|
|
1915
|
+
|
|
1916
|
+
angleInRange(angle, start, end) {
|
|
1917
|
+
/**
|
|
1918
|
+
* Check if angle falls within arc's angular span
|
|
1919
|
+
* Handles wraparound at 2π boundary
|
|
1920
|
+
*/
|
|
1921
|
+
// Normalize angles to [0, 2π)
|
|
1922
|
+
const a = ((angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
1923
|
+
let s = ((start % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
1924
|
+
let e = ((end % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
1925
|
+
|
|
1926
|
+
if (s <= e) {
|
|
1927
|
+
return a >= s && a <= e;
|
|
1928
|
+
} else {
|
|
1929
|
+
return a >= s || a <= e;
|
|
1930
|
+
}
|
|
1931
|
+
},
|
|
1932
|
+
|
|
1933
|
+
pointOnArc(point, arc) {
|
|
1934
|
+
/**
|
|
1935
|
+
* Check if a point lies on an arc (within tolerance)
|
|
1936
|
+
*/
|
|
1937
|
+
const [, , center] = arc.points;
|
|
1938
|
+
const distToCenter = point.distanceTo(center);
|
|
1939
|
+
const angle = Math.atan2(point.y - center.y, point.x - center.x);
|
|
1940
|
+
|
|
1941
|
+
return Math.abs(distToCenter - arc.data.radius) < 0.1 &&
|
|
1942
|
+
this.angleInRange(angle, arc.data.startAngle, arc.data.endAngle);
|
|
1943
|
+
},
|
|
1944
|
+
|
|
1945
|
+
// ===== ADVANCED DIMENSIONS =====
|
|
1946
|
+
|
|
1947
|
+
addOrdinateDimension(entityId, baselineEntity, direction = 'X') {
|
|
1948
|
+
/**
|
|
1949
|
+
* ORDINATE DIMENSION: Baseline reference dimension system
|
|
1950
|
+
*
|
|
1951
|
+
* Creates a series of dimensions measured from a baseline
|
|
1952
|
+
* entity. All dimensions reference the same baseline (e.g., left edge).
|
|
1953
|
+
* More compact than individual linear dimensions.
|
|
1954
|
+
*/
|
|
579
1955
|
const entity = this.state.entities.find(e => e.id === entityId);
|
|
580
|
-
|
|
1956
|
+
const baseline = this.state.entities.find(e => e.id === baselineEntity.id);
|
|
1957
|
+
|
|
1958
|
+
if (!entity || !baseline) return null;
|
|
1959
|
+
|
|
1960
|
+
const dim = {
|
|
1961
|
+
id: `dim_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
1962
|
+
type: 'ordinate',
|
|
1963
|
+
entities: [entity.id, baseline.id],
|
|
1964
|
+
direction,
|
|
1965
|
+
driven: true,
|
|
1966
|
+
value: this.computeOrdinateMeasurement(entity, baseline, direction)
|
|
1967
|
+
};
|
|
1968
|
+
|
|
1969
|
+
this.state.dimensions.push(dim);
|
|
1970
|
+
window.dispatchEvent(new CustomEvent('sketch:dimensionAdded', { detail: { dimension: dim } }));
|
|
1971
|
+
return dim;
|
|
1972
|
+
},
|
|
1973
|
+
|
|
1974
|
+
autoDimension() {
|
|
1975
|
+
/**
|
|
1976
|
+
* AUTO-DIMENSION: Detect and apply minimal sufficient dimension set
|
|
1977
|
+
*
|
|
1978
|
+
* Analyzes sketch geometry and constraints to determine
|
|
1979
|
+
* the minimum number of dimensions needed to fully constrain the sketch.
|
|
1980
|
+
* Uses a greedy algorithm to select important dimensions.
|
|
1981
|
+
*/
|
|
1982
|
+
const essentialDims = [];
|
|
1983
|
+
const profile = this.getProfile();
|
|
581
1984
|
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
1985
|
+
// Count degrees of freedom
|
|
1986
|
+
let dof = profile.entities.length * 3; // Each entity: 2 position + 1 orientation
|
|
1987
|
+
dof -= profile.entities.filter(e => e.constraints.some(c => c.type === 'fixed')).length * 3;
|
|
1988
|
+
|
|
1989
|
+
// Add dimensions for unconstrained entities
|
|
1990
|
+
profile.entities.forEach(entity => {
|
|
1991
|
+
if (dof <= 0) return;
|
|
1992
|
+
|
|
1993
|
+
const isConstrained = entity.constraints && entity.constraints.length > 0;
|
|
1994
|
+
if (!isConstrained) {
|
|
1995
|
+
let dimType = null;
|
|
1996
|
+
let value = null;
|
|
1997
|
+
|
|
1998
|
+
if (entity.type === 'line') {
|
|
1999
|
+
dimType = 'distance';
|
|
2000
|
+
value = entity.points[0].distanceTo(entity.points[1]);
|
|
2001
|
+
dof -= 1;
|
|
2002
|
+
} else if (entity.type === 'circle') {
|
|
2003
|
+
dimType = 'radius';
|
|
2004
|
+
value = entity.data.radius;
|
|
2005
|
+
dof -= 1;
|
|
2006
|
+
} else if (entity.type === 'arc') {
|
|
2007
|
+
dimType = 'radius';
|
|
2008
|
+
value = entity.data.radius;
|
|
2009
|
+
dof -= 1;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
if (dimType) {
|
|
2013
|
+
essentialDims.push({
|
|
2014
|
+
id: `auto_dim_${Date.now()}_${essentialDims.length}`,
|
|
2015
|
+
type: dimType,
|
|
2016
|
+
entities: [entity.id],
|
|
2017
|
+
value,
|
|
2018
|
+
driven: true
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
});
|
|
2023
|
+
|
|
2024
|
+
this.state.dimensions.push(...essentialDims);
|
|
2025
|
+
return essentialDims;
|
|
2026
|
+
},
|
|
2027
|
+
|
|
2028
|
+
addReferenceDimension(entityId, type = 'distance', value = null) {
|
|
2029
|
+
/**
|
|
2030
|
+
* REFERENCE DIMENSION: Non-driving dimension for display
|
|
2031
|
+
*
|
|
2032
|
+
* Creates a dimension that displays the measurement but
|
|
2033
|
+
* does not constrain the geometry. Useful for documenting
|
|
2034
|
+
* features and creating assembly notes.
|
|
2035
|
+
*/
|
|
2036
|
+
const entity = this.state.entities.find(e => e.id === entityId);
|
|
2037
|
+
if (!entity) return null;
|
|
2038
|
+
|
|
2039
|
+
const computedValue = value !== null ? value : this.computeEntityMeasurement(entity, type);
|
|
2040
|
+
|
|
2041
|
+
const dim = {
|
|
2042
|
+
id: `ref_dim_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
2043
|
+
type,
|
|
2044
|
+
entities: [entityId],
|
|
2045
|
+
value: computedValue,
|
|
2046
|
+
driven: false, // Key: reference dimensions are NOT driven
|
|
2047
|
+
isReference: true
|
|
2048
|
+
};
|
|
2049
|
+
|
|
2050
|
+
this.state.dimensions.push(dim);
|
|
2051
|
+
window.dispatchEvent(new CustomEvent('sketch:dimensionAdded', { detail: { dimension: dim } }));
|
|
2052
|
+
return dim;
|
|
2053
|
+
},
|
|
2054
|
+
|
|
2055
|
+
computeEntityMeasurement(entity, measurementType) {
|
|
2056
|
+
/**
|
|
2057
|
+
* Compute measurement value for an entity
|
|
2058
|
+
* @param {object} entity - sketch entity
|
|
2059
|
+
* @param {string} measurementType - 'distance', 'radius', 'diameter', 'angle', etc.
|
|
2060
|
+
* @returns {number} measurement value in current units
|
|
2061
|
+
*/
|
|
2062
|
+
switch (measurementType) {
|
|
2063
|
+
case 'distance':
|
|
2064
|
+
if (entity.type === 'line') {
|
|
2065
|
+
return entity.points[0].distanceTo(entity.points[1]);
|
|
2066
|
+
}
|
|
2067
|
+
return 0;
|
|
2068
|
+
|
|
2069
|
+
case 'radius':
|
|
2070
|
+
if (entity.type === 'circle' || entity.type === 'arc') {
|
|
2071
|
+
return entity.data.radius || 0;
|
|
2072
|
+
}
|
|
2073
|
+
return 0;
|
|
2074
|
+
|
|
2075
|
+
case 'diameter':
|
|
2076
|
+
if (entity.type === 'circle' || entity.type === 'arc') {
|
|
2077
|
+
return (entity.data.radius || 0) * 2;
|
|
2078
|
+
}
|
|
2079
|
+
return 0;
|
|
2080
|
+
|
|
2081
|
+
case 'angle':
|
|
2082
|
+
if (entity.type === 'arc') {
|
|
2083
|
+
const { startAngle, endAngle } = entity.data;
|
|
2084
|
+
return (endAngle - startAngle) * (180 / Math.PI);
|
|
2085
|
+
}
|
|
2086
|
+
return 0;
|
|
2087
|
+
|
|
2088
|
+
default:
|
|
2089
|
+
return 0;
|
|
2090
|
+
}
|
|
2091
|
+
},
|
|
2092
|
+
|
|
2093
|
+
computeOrdinateMeasurement(entity, baseline, direction) {
|
|
2094
|
+
/**
|
|
2095
|
+
* Compute ordinate measurement (distance from baseline)
|
|
2096
|
+
* @param {object} entity - sketch entity to measure
|
|
2097
|
+
* @param {object} baseline - reference baseline entity
|
|
2098
|
+
* @param {string} direction - 'X' or 'Y'
|
|
2099
|
+
* @returns {number} measurement value
|
|
2100
|
+
*/
|
|
2101
|
+
const baselinePos = baseline.points[0];
|
|
2102
|
+
const entityPos = entity.points[0];
|
|
2103
|
+
|
|
2104
|
+
if (direction === 'X') {
|
|
2105
|
+
return Math.abs(entityPos.x - baselinePos.x);
|
|
2106
|
+
} else if (direction === 'Y') {
|
|
2107
|
+
return Math.abs(entityPos.y - baselinePos.y);
|
|
587
2108
|
}
|
|
2109
|
+
return 0;
|
|
588
2110
|
},
|
|
589
2111
|
|
|
590
2112
|
getProfile() {
|
|
@@ -596,6 +2118,121 @@ const SketchModule = {
|
|
|
596
2118
|
};
|
|
597
2119
|
},
|
|
598
2120
|
|
|
2121
|
+
// ===== HELPER METHODS FOR NEW TOOLS =====
|
|
2122
|
+
|
|
2123
|
+
evaluateEntityAtParameter(entity, t) {
|
|
2124
|
+
/**
|
|
2125
|
+
* Evaluate entity position at parameter t ∈ [0, 1]
|
|
2126
|
+
*/
|
|
2127
|
+
if (entity.type === 'line') {
|
|
2128
|
+
const [p1, p2] = entity.points;
|
|
2129
|
+
return new THREE.Vector2(
|
|
2130
|
+
p1.x + t * (p2.x - p1.x),
|
|
2131
|
+
p1.y + t * (p2.y - p1.y)
|
|
2132
|
+
);
|
|
2133
|
+
} else if (entity.type === 'arc') {
|
|
2134
|
+
const [start, end] = entity.points;
|
|
2135
|
+
const [, , center] = entity.points;
|
|
2136
|
+
const angle = entity.data.startAngle + t * (entity.data.endAngle - entity.data.startAngle);
|
|
2137
|
+
return new THREE.Vector2(
|
|
2138
|
+
center.x + entity.data.radius * Math.cos(angle),
|
|
2139
|
+
center.y + entity.data.radius * Math.sin(angle)
|
|
2140
|
+
);
|
|
2141
|
+
} else if (entity.type === 'spline') {
|
|
2142
|
+
return this.evaluateBSpline(entity.points, t, entity.data.degree);
|
|
2143
|
+
}
|
|
2144
|
+
return entity.points[0];
|
|
2145
|
+
},
|
|
2146
|
+
|
|
2147
|
+
evaluateEntityTangentAtParameter(entity, t) {
|
|
2148
|
+
/**
|
|
2149
|
+
* Evaluate entity tangent vector at parameter t ∈ [0, 1]
|
|
2150
|
+
*/
|
|
2151
|
+
const delta = 0.001;
|
|
2152
|
+
const p1 = this.evaluateEntityAtParameter(entity, Math.max(0, t - delta));
|
|
2153
|
+
const p2 = this.evaluateEntityAtParameter(entity, Math.min(1, t + delta));
|
|
2154
|
+
return new THREE.Vector2(p2.x - p1.x, p2.y - p1.y).normalize();
|
|
2155
|
+
},
|
|
2156
|
+
|
|
2157
|
+
findParameterAlongEntity(entity, point) {
|
|
2158
|
+
/**
|
|
2159
|
+
* Find parameter t along entity closest to given point
|
|
2160
|
+
* @returns {number|null} parameter t ∈ [0, 1], or null if not found
|
|
2161
|
+
*/
|
|
2162
|
+
if (entity.type === 'line') {
|
|
2163
|
+
const [p1, p2] = entity.points;
|
|
2164
|
+
const dx = p2.x - p1.x;
|
|
2165
|
+
const dy = p2.y - p1.y;
|
|
2166
|
+
const len2 = dx * dx + dy * dy;
|
|
2167
|
+
if (len2 === 0) return 0;
|
|
2168
|
+
|
|
2169
|
+
const t = ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / len2;
|
|
2170
|
+
return Math.max(0, Math.min(1, t));
|
|
2171
|
+
}
|
|
2172
|
+
return null;
|
|
2173
|
+
},
|
|
2174
|
+
|
|
2175
|
+
findEntityDragCrossings(entity, dragLine) {
|
|
2176
|
+
/**
|
|
2177
|
+
* Find all points where entity crosses a drag line
|
|
2178
|
+
*/
|
|
2179
|
+
const crossings = [];
|
|
2180
|
+
|
|
2181
|
+
if (entity.type === 'line') {
|
|
2182
|
+
const int = this.lineLineIntersection(entity.points[0], entity.points[1], dragLine.p1, dragLine.p2);
|
|
2183
|
+
if (int) crossings.push(int);
|
|
2184
|
+
} else if (entity.type === 'arc') {
|
|
2185
|
+
const ints = this.lineArcIntersection(dragLine.p1, dragLine.p2, entity);
|
|
2186
|
+
crossings.push(...ints);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
return crossings;
|
|
2190
|
+
},
|
|
2191
|
+
|
|
2192
|
+
findAllIntersectionsOnEntity(entity) {
|
|
2193
|
+
/**
|
|
2194
|
+
* Find all intersections of an entity with other entities
|
|
2195
|
+
*/
|
|
2196
|
+
const intersections = [];
|
|
2197
|
+
|
|
2198
|
+
this.state.entities.forEach(other => {
|
|
2199
|
+
if (other.id === entity.id) return;
|
|
2200
|
+
const ints = this.findIntersection(entity, other);
|
|
2201
|
+
intersections.push(...ints);
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
return intersections;
|
|
2205
|
+
},
|
|
2206
|
+
|
|
2207
|
+
findIntersection(entity1, entity2) {
|
|
2208
|
+
/**
|
|
2209
|
+
* Find all intersections between two entities
|
|
2210
|
+
*/
|
|
2211
|
+
const ints = [];
|
|
2212
|
+
|
|
2213
|
+
if (entity1.type === 'line' && entity2.type === 'line') {
|
|
2214
|
+
const int = this.lineLineIntersection(
|
|
2215
|
+
entity1.points[0], entity1.points[1],
|
|
2216
|
+
entity2.points[0], entity2.points[1]
|
|
2217
|
+
);
|
|
2218
|
+
if (int) ints.push(int);
|
|
2219
|
+
} else if (entity1.type === 'line' && entity2.type === 'circle') {
|
|
2220
|
+
const circleInts = this.lineCircleIntersection(
|
|
2221
|
+
entity1.points[0], entity1.points[1],
|
|
2222
|
+
entity2.points[0], entity2.data.radius
|
|
2223
|
+
);
|
|
2224
|
+
ints.push(...circleInts);
|
|
2225
|
+
} else if (entity1.type === 'circle' && entity2.type === 'circle') {
|
|
2226
|
+
const circleInts = this.circleCircleIntersection(
|
|
2227
|
+
entity1.points[0], entity1.data.radius,
|
|
2228
|
+
entity2.points[0], entity2.data.radius
|
|
2229
|
+
);
|
|
2230
|
+
ints.push(...circleInts);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
return ints;
|
|
2234
|
+
},
|
|
2235
|
+
|
|
599
2236
|
setupEventHandlers() {
|
|
600
2237
|
// Tool button clicks
|
|
601
2238
|
document.addEventListener('click', (e) => {
|
|
@@ -620,10 +2257,29 @@ const SketchModule = {
|
|
|
620
2257
|
case 'c': this.setTool('circle'); break;
|
|
621
2258
|
case 'a': this.setTool('arc'); break;
|
|
622
2259
|
case 's': this.setTool('spline'); break;
|
|
2260
|
+
case 'e':
|
|
2261
|
+
if (this.state.currentTool === 'spline') {
|
|
2262
|
+
this.finishSpline();
|
|
2263
|
+
} else {
|
|
2264
|
+
this.setTool('ellipse');
|
|
2265
|
+
}
|
|
2266
|
+
break;
|
|
2267
|
+
case 'p': this.setTool('polygon'); break;
|
|
623
2268
|
case 't': this.setTool('text'); break;
|
|
624
2269
|
case 'g': this.toggleConstruction(Array.from(this.state.selectedEntityIds)); break;
|
|
625
2270
|
case 'd': this.setTool('dimension'); break;
|
|
626
|
-
case 'escape':
|
|
2271
|
+
case 'escape':
|
|
2272
|
+
if (this.state.tempPoints.length > 0) {
|
|
2273
|
+
this.state.tempPoints = [];
|
|
2274
|
+
} else {
|
|
2275
|
+
this.finish();
|
|
2276
|
+
}
|
|
2277
|
+
break;
|
|
2278
|
+
case 'enter':
|
|
2279
|
+
if (this.state.currentTool === 'spline') {
|
|
2280
|
+
this.finishSpline();
|
|
2281
|
+
}
|
|
2282
|
+
break;
|
|
627
2283
|
}
|
|
628
2284
|
});
|
|
629
2285
|
},
|
|
@@ -688,6 +2344,36 @@ const SketchModule = {
|
|
|
688
2344
|
this.state.tempPoints = [];
|
|
689
2345
|
}
|
|
690
2346
|
break;
|
|
2347
|
+
|
|
2348
|
+
case 'spline':
|
|
2349
|
+
// Accumulate control points, finish with right-click or Escape
|
|
2350
|
+
if (this.state.tempPoints.length >= 3) {
|
|
2351
|
+
// Allow finishing on third+ point by double-clicking
|
|
2352
|
+
if (e.detail === 2 || e.shiftKey) {
|
|
2353
|
+
this.drawSpline(this.state.tempPoints);
|
|
2354
|
+
this.state.tempPoints = [];
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
break;
|
|
2358
|
+
|
|
2359
|
+
case 'trim':
|
|
2360
|
+
case 'extend':
|
|
2361
|
+
case 'offset':
|
|
2362
|
+
case 'mirror':
|
|
2363
|
+
case 'fillet':
|
|
2364
|
+
case 'chamfer':
|
|
2365
|
+
// These tools require entity selection, handled separately
|
|
2366
|
+
break;
|
|
2367
|
+
}
|
|
2368
|
+
},
|
|
2369
|
+
|
|
2370
|
+
finishSpline() {
|
|
2371
|
+
/**
|
|
2372
|
+
* Finish spline drawing (called on right-click or Enter)
|
|
2373
|
+
*/
|
|
2374
|
+
if (this.state.currentTool === 'spline' && this.state.tempPoints.length >= 3) {
|
|
2375
|
+
this.drawSpline(this.state.tempPoints);
|
|
2376
|
+
this.state.tempPoints = [];
|
|
691
2377
|
}
|
|
692
2378
|
},
|
|
693
2379
|
|
|
@@ -717,4 +2403,256 @@ const SketchModule = {
|
|
|
717
2403
|
}
|
|
718
2404
|
};
|
|
719
2405
|
|
|
2406
|
+
/**
|
|
2407
|
+
* HELP ENTRIES: Documentation for all sketch tools and features
|
|
2408
|
+
* Exported for Help System integration
|
|
2409
|
+
*/
|
|
2410
|
+
SketchModule.HELP_ENTRIES = [
|
|
2411
|
+
// Basic Drawing Tools
|
|
2412
|
+
{
|
|
2413
|
+
id: 'sketch.line',
|
|
2414
|
+
title: 'Line Tool',
|
|
2415
|
+
description: 'Draw a straight line between two points. Click to set start, click again to set end.',
|
|
2416
|
+
category: 'Drawing',
|
|
2417
|
+
hotkey: 'L'
|
|
2418
|
+
},
|
|
2419
|
+
{
|
|
2420
|
+
id: 'sketch.rectangle',
|
|
2421
|
+
title: 'Rectangle Tool',
|
|
2422
|
+
description: 'Draw axis-aligned rectangle by two corner points.',
|
|
2423
|
+
category: 'Drawing',
|
|
2424
|
+
hotkey: 'R'
|
|
2425
|
+
},
|
|
2426
|
+
{
|
|
2427
|
+
id: 'sketch.circle',
|
|
2428
|
+
title: 'Circle Tool',
|
|
2429
|
+
description: 'Draw circle by center point and radius point.',
|
|
2430
|
+
category: 'Drawing',
|
|
2431
|
+
hotkey: 'C'
|
|
2432
|
+
},
|
|
2433
|
+
{
|
|
2434
|
+
id: 'sketch.arc',
|
|
2435
|
+
title: 'Arc Tool',
|
|
2436
|
+
description: 'Draw arc by start, end, and center points.',
|
|
2437
|
+
category: 'Drawing',
|
|
2438
|
+
hotkey: 'A'
|
|
2439
|
+
},
|
|
2440
|
+
{
|
|
2441
|
+
id: 'sketch.ellipse',
|
|
2442
|
+
title: 'Ellipse Tool',
|
|
2443
|
+
description: 'Draw ellipse by center and two axis endpoints.',
|
|
2444
|
+
category: 'Drawing',
|
|
2445
|
+
hotkey: 'E'
|
|
2446
|
+
},
|
|
2447
|
+
{
|
|
2448
|
+
id: 'sketch.spline',
|
|
2449
|
+
title: 'Control Point Spline',
|
|
2450
|
+
description: 'Draw cubic B-spline by control points. Double-click or Enter to finish.',
|
|
2451
|
+
category: 'Drawing',
|
|
2452
|
+
hotkey: 'S'
|
|
2453
|
+
},
|
|
2454
|
+
{
|
|
2455
|
+
id: 'sketch.spline_fit',
|
|
2456
|
+
title: 'Fit Point Spline',
|
|
2457
|
+
description: 'Draw interpolating spline through specified points (unlike control-point splines).',
|
|
2458
|
+
category: 'Drawing'
|
|
2459
|
+
},
|
|
2460
|
+
{
|
|
2461
|
+
id: 'sketch.polygon',
|
|
2462
|
+
title: 'Polygon Tool',
|
|
2463
|
+
description: 'Draw regular polygon by center and corner point.',
|
|
2464
|
+
category: 'Drawing',
|
|
2465
|
+
hotkey: 'P'
|
|
2466
|
+
},
|
|
2467
|
+
|
|
2468
|
+
// Slot Tools
|
|
2469
|
+
{
|
|
2470
|
+
id: 'sketch.slot',
|
|
2471
|
+
title: 'Slot (Center-Point)',
|
|
2472
|
+
description: 'Draw slot by center point, width, and height.',
|
|
2473
|
+
category: 'Drawing'
|
|
2474
|
+
},
|
|
2475
|
+
{
|
|
2476
|
+
id: 'sketch.slot_3point',
|
|
2477
|
+
title: 'Slot (3-Point)',
|
|
2478
|
+
description: 'Draw slot by two endpoints and arc radius.',
|
|
2479
|
+
category: 'Drawing'
|
|
2480
|
+
},
|
|
2481
|
+
{
|
|
2482
|
+
id: 'sketch.slot_ctc',
|
|
2483
|
+
title: 'Slot (Center-to-Center)',
|
|
2484
|
+
description: 'Draw slot by arc centers and radius.',
|
|
2485
|
+
category: 'Drawing'
|
|
2486
|
+
},
|
|
2487
|
+
|
|
2488
|
+
// Conic Sections
|
|
2489
|
+
{
|
|
2490
|
+
id: 'sketch.conic',
|
|
2491
|
+
title: 'Conic Sections',
|
|
2492
|
+
description: 'Draw parabola or hyperbola using focus/directrix (parabola) or foci (hyperbola).',
|
|
2493
|
+
category: 'Drawing'
|
|
2494
|
+
},
|
|
2495
|
+
|
|
2496
|
+
// Text Tools
|
|
2497
|
+
{
|
|
2498
|
+
id: 'sketch.text',
|
|
2499
|
+
title: 'Text Tool',
|
|
2500
|
+
description: 'Place text at a specific point in the sketch.',
|
|
2501
|
+
category: 'Drawing',
|
|
2502
|
+
hotkey: 'T'
|
|
2503
|
+
},
|
|
2504
|
+
{
|
|
2505
|
+
id: 'sketch.text_path',
|
|
2506
|
+
title: 'Text Along Path',
|
|
2507
|
+
description: 'Place text that follows a line, arc, or spline curve.',
|
|
2508
|
+
category: 'Drawing'
|
|
2509
|
+
},
|
|
2510
|
+
|
|
2511
|
+
// Reference Geometry
|
|
2512
|
+
{
|
|
2513
|
+
id: 'sketch.point',
|
|
2514
|
+
title: 'Point Tool',
|
|
2515
|
+
description: 'Create a standalone construction point for reference.',
|
|
2516
|
+
category: 'Reference'
|
|
2517
|
+
},
|
|
2518
|
+
{
|
|
2519
|
+
id: 'sketch.midpoint',
|
|
2520
|
+
title: 'Midpoint',
|
|
2521
|
+
description: 'Create a point at the midpoint of a line, arc, or spline.',
|
|
2522
|
+
category: 'Reference'
|
|
2523
|
+
},
|
|
2524
|
+
|
|
2525
|
+
// Editing Tools
|
|
2526
|
+
{
|
|
2527
|
+
id: 'sketch.trim',
|
|
2528
|
+
title: 'Trim Tool',
|
|
2529
|
+
description: 'Remove segments between intersections. Click on segment to trim.',
|
|
2530
|
+
category: 'Editing'
|
|
2531
|
+
},
|
|
2532
|
+
{
|
|
2533
|
+
id: 'sketch.power_trim',
|
|
2534
|
+
title: 'Power Trim',
|
|
2535
|
+
description: 'Click and drag to trim multiple entities at once.',
|
|
2536
|
+
category: 'Editing'
|
|
2537
|
+
},
|
|
2538
|
+
{
|
|
2539
|
+
id: 'sketch.break',
|
|
2540
|
+
title: 'Break at Point',
|
|
2541
|
+
description: 'Split a line or arc into two segments at a specified point.',
|
|
2542
|
+
category: 'Editing'
|
|
2543
|
+
},
|
|
2544
|
+
{
|
|
2545
|
+
id: 'sketch.extend',
|
|
2546
|
+
title: 'Extend Tool',
|
|
2547
|
+
description: 'Extend a line toward other geometry.',
|
|
2548
|
+
category: 'Editing'
|
|
2549
|
+
},
|
|
2550
|
+
{
|
|
2551
|
+
id: 'sketch.offset',
|
|
2552
|
+
title: 'Offset Tool',
|
|
2553
|
+
description: 'Create offset copies of lines and curves.',
|
|
2554
|
+
category: 'Editing'
|
|
2555
|
+
},
|
|
2556
|
+
{
|
|
2557
|
+
id: 'sketch.mirror',
|
|
2558
|
+
title: 'Mirror Tool',
|
|
2559
|
+
description: 'Mirror selected entities across a line.',
|
|
2560
|
+
category: 'Editing'
|
|
2561
|
+
},
|
|
2562
|
+
{
|
|
2563
|
+
id: 'sketch.fillet',
|
|
2564
|
+
title: 'Fillet Tool',
|
|
2565
|
+
description: 'Round corners between intersecting lines.',
|
|
2566
|
+
category: 'Editing'
|
|
2567
|
+
},
|
|
2568
|
+
{
|
|
2569
|
+
id: 'sketch.chamfer',
|
|
2570
|
+
title: 'Chamfer Tool',
|
|
2571
|
+
description: 'Create beveled edges between intersecting lines.',
|
|
2572
|
+
category: 'Editing'
|
|
2573
|
+
},
|
|
2574
|
+
|
|
2575
|
+
// Pattern Tools
|
|
2576
|
+
{
|
|
2577
|
+
id: 'sketch.rect_pattern',
|
|
2578
|
+
title: 'Rectangular Pattern',
|
|
2579
|
+
description: 'Array selected entities in a grid with specified spacing.',
|
|
2580
|
+
category: 'Pattern'
|
|
2581
|
+
},
|
|
2582
|
+
{
|
|
2583
|
+
id: 'sketch.circ_pattern',
|
|
2584
|
+
title: 'Circular Pattern',
|
|
2585
|
+
description: 'Array selected entities radially around a center point.',
|
|
2586
|
+
category: 'Pattern'
|
|
2587
|
+
},
|
|
2588
|
+
{
|
|
2589
|
+
id: 'sketch.path_pattern',
|
|
2590
|
+
title: 'Pattern Along Path',
|
|
2591
|
+
description: 'Array selected entities along a line, arc, or spline.',
|
|
2592
|
+
category: 'Pattern'
|
|
2593
|
+
},
|
|
2594
|
+
|
|
2595
|
+
// Geometry Operations
|
|
2596
|
+
{
|
|
2597
|
+
id: 'sketch.project',
|
|
2598
|
+
title: 'Project Edge',
|
|
2599
|
+
description: 'Project a 3D edge orthogonally onto the sketch plane.',
|
|
2600
|
+
category: 'Geometry'
|
|
2601
|
+
},
|
|
2602
|
+
{
|
|
2603
|
+
id: 'sketch.include',
|
|
2604
|
+
title: 'Include Geometry',
|
|
2605
|
+
description: 'Reference geometry from another sketch (linked, updates automatically).',
|
|
2606
|
+
category: 'Geometry'
|
|
2607
|
+
},
|
|
2608
|
+
{
|
|
2609
|
+
id: 'sketch.intersection',
|
|
2610
|
+
title: 'Intersection Curve',
|
|
2611
|
+
description: 'Create sketch curve from intersection of two 3D surfaces.',
|
|
2612
|
+
category: 'Geometry'
|
|
2613
|
+
},
|
|
2614
|
+
{
|
|
2615
|
+
id: 'sketch.construction',
|
|
2616
|
+
title: 'Construction Geometry',
|
|
2617
|
+
description: 'Toggle selected entities as construction (reference-only). (G)',
|
|
2618
|
+
category: 'Geometry',
|
|
2619
|
+
hotkey: 'G'
|
|
2620
|
+
},
|
|
2621
|
+
|
|
2622
|
+
// Dimension Tools
|
|
2623
|
+
{
|
|
2624
|
+
id: 'sketch.dimension',
|
|
2625
|
+
title: 'Add Dimension',
|
|
2626
|
+
description: 'Add linear, radial, angular, or diameter dimension to constrain geometry.',
|
|
2627
|
+
category: 'Dimensions',
|
|
2628
|
+
hotkey: 'D'
|
|
2629
|
+
},
|
|
2630
|
+
{
|
|
2631
|
+
id: 'sketch.ordinate',
|
|
2632
|
+
title: 'Ordinate Dimension',
|
|
2633
|
+
description: 'Create baseline-based ordinate dimensions for aligned measurements.',
|
|
2634
|
+
category: 'Dimensions'
|
|
2635
|
+
},
|
|
2636
|
+
{
|
|
2637
|
+
id: 'sketch.reference',
|
|
2638
|
+
title: 'Reference Dimension',
|
|
2639
|
+
description: 'Add non-driving dimension for documentation (does not constrain).',
|
|
2640
|
+
category: 'Dimensions'
|
|
2641
|
+
},
|
|
2642
|
+
{
|
|
2643
|
+
id: 'sketch.auto_dim',
|
|
2644
|
+
title: 'Auto Dimension',
|
|
2645
|
+
description: 'Automatically detect and apply minimal sufficient dimension set.',
|
|
2646
|
+
category: 'Dimensions'
|
|
2647
|
+
},
|
|
2648
|
+
|
|
2649
|
+
// Constraint System
|
|
2650
|
+
{
|
|
2651
|
+
id: 'sketch.constraints',
|
|
2652
|
+
title: 'Constraints Overview',
|
|
2653
|
+
description: 'Sketch constraints: coincident, horizontal, vertical, parallel, perpendicular, tangent, equal, fix, concentric, symmetric, collinear, midpoint, coradial.',
|
|
2654
|
+
category: 'Constraints'
|
|
2655
|
+
}
|
|
2656
|
+
];
|
|
2657
|
+
|
|
720
2658
|
export default SketchModule;
|