cyclecad 2.1.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/BILLING-IMPLEMENTATION-SUMMARY.md +425 -0
  2. package/BILLING-INDEX.md +293 -0
  3. package/BILLING-INTEGRATION-GUIDE.md +414 -0
  4. package/COLLABORATION-INDEX.md +440 -0
  5. package/COLLABORATION-SYSTEM-SUMMARY.md +548 -0
  6. package/DELIVERABLES.txt +296 -445
  7. package/DOCKER-BUILD-MANIFEST.txt +483 -0
  8. package/DOCKER-FILES-REFERENCE.md +440 -0
  9. package/DOCKER-INFRASTRUCTURE.md +475 -0
  10. package/DOCKER-README.md +435 -0
  11. package/Dockerfile +33 -55
  12. package/ENHANCEMENT_COMPLETION_REPORT.md +383 -0
  13. package/ENHANCEMENT_SUMMARY.txt +308 -0
  14. package/FEATURE_INVENTORY.md +235 -0
  15. package/FUSION360_FEATURES_SUMMARY.md +452 -0
  16. package/FUSION360_PARITY_ENHANCEMENTS.md +461 -0
  17. package/FUSION360_PARITY_SUMMARY.md +520 -0
  18. package/FUSION360_QUICK_REFERENCE.md +351 -0
  19. package/MODULE_API_REFERENCE.md +712 -0
  20. package/MODULE_INVENTORY.txt +264 -0
  21. package/PWA-FILES-CREATED.txt +350 -0
  22. package/QUICK-START-TESTING.md +126 -0
  23. package/STEP-IMPORT-QUICKSTART.md +347 -0
  24. package/STEP-IMPORT-SYSTEM-SUMMARY.md +502 -0
  25. package/app/css/mobile.css +1074 -0
  26. package/app/icons/generate-icons.js +203 -0
  27. package/app/index.html +1342 -5031
  28. package/app/js/app.js +1312 -514
  29. package/app/js/billing-ui.js +990 -0
  30. package/app/js/brep-kernel.js +933 -981
  31. package/app/js/collab-client.js +750 -0
  32. package/app/js/mobile-nav.js +623 -0
  33. package/app/js/mobile-toolbar.js +476 -0
  34. package/app/js/modules/animation-module.js +497 -3
  35. package/app/js/modules/billing-module.js +724 -0
  36. package/app/js/modules/cam-module.js +507 -2
  37. package/app/js/modules/collaboration-module.js +513 -0
  38. package/app/js/modules/constraint-module.js +1266 -0
  39. package/app/js/modules/data-module.js +544 -1146
  40. package/app/js/modules/formats-module.js +438 -738
  41. package/app/js/modules/inspection-module.js +393 -0
  42. package/app/js/modules/mesh-module-enhanced.js +880 -0
  43. package/app/js/modules/plugin-module.js +597 -0
  44. package/app/js/modules/rendering-module.js +460 -0
  45. package/app/js/modules/scripting-module.js +593 -475
  46. package/app/js/modules/sketch-module.js +998 -2
  47. package/app/js/modules/step-module-enhanced.js +938 -0
  48. package/app/js/modules/surface-module.js +312 -0
  49. package/app/js/modules/version-module.js +420 -0
  50. package/app/js/offline-manager.js +705 -0
  51. package/app/js/responsive-init.js +360 -0
  52. package/app/js/touch-handler.js +429 -0
  53. package/app/manifest.json +211 -0
  54. package/app/offline.html +508 -0
  55. package/app/sw.js +571 -0
  56. package/app/tests/billing-tests.html +779 -0
  57. package/app/tests/brep-tests.html +980 -0
  58. package/app/tests/collab-tests.html +743 -0
  59. package/app/tests/mobile-tests.html +1299 -0
  60. package/app/tests/pwa-tests.html +1134 -0
  61. package/app/tests/step-tests.html +1042 -0
  62. package/app/tests/test-agent-v3.html +719 -0
  63. package/cycleCAD-Architecture-v2.pptx +0 -0
  64. package/docker-compose.yml +225 -0
  65. package/docs/BILLING-HELP.json +260 -0
  66. package/docs/BILLING-README.md +639 -0
  67. package/docs/BILLING-TUTORIAL.md +736 -0
  68. package/docs/BREP-HELP.json +326 -0
  69. package/docs/BREP-TUTORIAL.md +802 -0
  70. package/docs/COLLABORATION-HELP.json +228 -0
  71. package/docs/COLLABORATION-TUTORIAL.md +818 -0
  72. package/docs/DOCKER-HELP.json +224 -0
  73. package/docs/DOCKER-TUTORIAL.md +974 -0
  74. package/docs/MOBILE-HELP.json +243 -0
  75. package/docs/MOBILE-RESPONSIVE-README.md +378 -0
  76. package/docs/MOBILE-TUTORIAL.md +747 -0
  77. package/docs/PWA-HELP.json +228 -0
  78. package/docs/PWA-README.md +662 -0
  79. package/docs/PWA-TUTORIAL.md +757 -0
  80. package/docs/STEP-HELP.json +481 -0
  81. package/docs/STEP-IMPORT-TUTORIAL.md +824 -0
  82. package/docs/TESTING-GUIDE.md +528 -0
  83. package/docs/TESTING-HELP.json +182 -0
  84. package/fusion-vs-cyclecad.html +1771 -0
  85. package/nginx.conf +237 -0
  86. package/package.json +1 -1
  87. package/server/Dockerfile.converter +51 -0
  88. package/server/Dockerfile.signaling +28 -0
  89. package/server/billing-server.js +487 -0
  90. package/server/converter-enhanced.py +528 -0
  91. package/server/requirements-converter.txt +29 -0
  92. package/server/signaling-server.js +801 -0
  93. package/tests/docker-tests.sh +389 -0
  94. package/~$cycleCAD-Architecture-v2.pptx +0 -0
@@ -0,0 +1,1266 @@
1
+ /**
2
+ * @file constraint-module.js
3
+ * @description ConstraintModule — Parametric 2D Constraint Solver with Fusion 360 parity
4
+ * LEGO block for cycleCAD microkernel, providing a complete parametric
5
+ * constraint solver for 2D sketches with 13+ constraint types and
6
+ * Newton-Raphson iterative solving with Jacobian matrix.
7
+ *
8
+ * @version 1.0.0
9
+ * @author cycleCAD Team
10
+ * @license MIT
11
+ * @see {@link https://github.com/vvlars-cmd/cyclecad}
12
+ *
13
+ * @module constraint-module
14
+ * @requires sketch (2D sketch entities to constrain)
15
+ *
16
+ * Features:
17
+ * - 13 constraint types: coincident, horizontal, vertical, parallel, perpendicular,
18
+ * tangent, equal, fix, concentric, symmetric, collinear, midpoint, coradial
19
+ * - Newton-Raphson iterative solver with Jacobian matrix
20
+ * - Degrees of freedom (DOF) counter per entity
21
+ * - Over-constrained detection with conflict highlighting
22
+ * - Under-constrained detection with DOF display
23
+ * - Auto-constraint on sketch (detect near-coincident, near-horizontal, etc.)
24
+ * - Constraint dragging (move entity, constraints follow)
25
+ * - Constraint visualization and feedback
26
+ * - Constraint history and undo/redo support
27
+ *
28
+ * Workflow:
29
+ * 1. User adds constraints via constraint toolbar or commands
30
+ * 2. Constraints are stored in constraint list with conflict flags
31
+ * 3. Solver iterates using Newton-Raphson on constraint residuals
32
+ * 4. DOF counter shows how many degrees of freedom remain
33
+ * 5. Conflicts are highlighted; user can remove conflicting constraints
34
+ * 6. Dragging geometry updates all constraints automatically
35
+ */
36
+
37
+ const ConstraintModule = {
38
+ id: 'constraint-solver',
39
+ name: 'Constraint Solver',
40
+ version: '1.0.0',
41
+ category: 'engine',
42
+ dependencies: ['sketch'],
43
+ memoryEstimate: 12,
44
+
45
+ // ===== STATE =====
46
+ state: {
47
+ constraints: [], // { id, type, entities[], points[], value, status, isConflict }
48
+ solverIterations: 0,
49
+ tolerance: 1e-6,
50
+ maxIterations: 100,
51
+ jacobian: null, // Numerical Jacobian matrix
52
+ residuals: [], // Constraint residuals after last solve
53
+ dofPerEntity: new Map(), // { entityId: degrees of freedom }
54
+ isAutoConstricting: false,
55
+ autoConstrainTolerance: 0.15, // pixels/units
56
+ conflictedConstraintIds: new Set(),
57
+ visualFeedback: {
58
+ highlightConflicts: true,
59
+ showDOF: true,
60
+ showSolverStatus: true
61
+ }
62
+ },
63
+
64
+ // ===== LEGO INTERFACE =====
65
+ init() {
66
+ // Called by app.js on startup
67
+ this.setupEventHandlers();
68
+ this.setupToolbar();
69
+ window.addEventListener('sketch:entityAdded', (e) => this.onEntityAdded(e));
70
+ window.addEventListener('sketch:entityModified', (e) => this.onEntityModified(e));
71
+ window.addEventListener('sketch:dimensionAdded', (e) => this.onDimensionAdded(e));
72
+ },
73
+
74
+ getUI() {
75
+ return `
76
+ <div id="constraint-toolbar" style="display: none; background: #2a2a2a; padding: 8px; border-radius: 4px; flex-wrap: wrap; gap: 4px;">
77
+ <!-- COINCIDENT FAMILY -->
78
+ <button data-constraint="coincident" class="constraint-btn" title="Coincident (C)">●●</button>
79
+ <button data-constraint="collinear" class="constraint-btn" title="Collinear">≡</button>
80
+ <button data-constraint="concentric" class="constraint-btn" title="Concentric">⊙</button>
81
+
82
+ <!-- ALIGNMENT FAMILY -->
83
+ <button data-constraint="horizontal" class="constraint-btn" title="Horizontal (H)">—</button>
84
+ <button data-constraint="vertical" class="constraint-btn" title="Vertical (V)">|</button>
85
+ <button data-constraint="parallel" class="constraint-btn" title="Parallel">‖</button>
86
+ <button data-constraint="perpendicular" class="constraint-btn" title="Perpendicular (P)">⊥</button>
87
+
88
+ <!-- TANGENT FAMILY -->
89
+ <button data-constraint="tangent" class="constraint-btn" title="Tangent (T)">⌒-</button>
90
+ <button data-constraint="smooth" class="constraint-btn" title="Smooth (G2)">S</button>
91
+
92
+ <!-- EQUALITY & VALUE -->
93
+ <button data-constraint="equal" class="constraint-btn" title="Equal (E)">==</button>
94
+ <button data-constraint="symmetric" class="constraint-btn" title="Symmetric">↔</button>
95
+ <button data-constraint="midpoint" class="constraint-btn" title="Midpoint">M</button>
96
+
97
+ <!-- SPECIAL -->
98
+ <button data-constraint="fix" class="constraint-btn" title="Fix/Lock (F)">🔒</button>
99
+ <button data-constraint="coradial" class="constraint-btn" title="Coradial">◯</button>
100
+
101
+ <!-- SOLVER -->
102
+ <button id="constraint-solve-btn" style="background: #0066cc; color: white; padding: 6px 12px; border-radius: 2px; cursor: pointer;" title="Solve (Ctrl+Shift+S)">⚙ Solve</button>
103
+ <button id="constraint-auto-btn" style="background: #0066cc; color: white; padding: 6px 12px; border-radius: 2px; cursor: pointer;" title="Auto-Constrain">✓ Auto</button>
104
+ <button id="constraint-validate-btn" style="background: #ff9900; color: white; padding: 6px 12px; border-radius: 2px; cursor: pointer;" title="Validate (Ctrl+Shift+V)">✓ Validate</button>
105
+ </div>
106
+ <div id="constraint-status" style="display: none; color: #aaa; font-size: 12px; padding: 4px 8px; border-top: 1px solid #444; background: #1a1a1a;">
107
+ DOF: <span id="constraint-dof-total">0</span> | Constraints: <span id="constraint-count">0</span> | Status: <span id="constraint-status-text">OK</span> | Iterations: <span id="constraint-iterations">0</span>
108
+ </div>
109
+ <div id="constraint-conflict-panel" style="display: none; position: fixed; bottom: 60px; right: 10px; background: #3a2a2a; border: 2px solid #ff6666; border-radius: 4px; padding: 12px; max-width: 300px; z-index: 9999;">
110
+ <h4 style="color: #ff6666; margin: 0 0 8px 0; font-size: 13px;">Over-Constrained</h4>
111
+ <div id="conflict-list" style="font-size: 11px; color: #ccc; max-height: 150px; overflow-y: auto;">
112
+ <!-- Conflicts listed here -->
113
+ </div>
114
+ <button id="clear-conflicts-btn" style="margin-top: 8px; padding: 4px 8px; background: #cc3333; color: white; border: none; border-radius: 2px; cursor: pointer; font-size: 11px;">Delete Selected Constraint</button>
115
+ </div>
116
+ `;
117
+ },
118
+
119
+ execute(command, params = {}) {
120
+ /**
121
+ * Microkernel command dispatch
122
+ * @param {string} command - constraint command
123
+ * @param {object} params - command parameters
124
+ */
125
+ switch (command) {
126
+ case 'add': return this.addConstraint(params.type, params.entities, params.value, params.points);
127
+ case 'remove': return this.removeConstraint(params.constraintId);
128
+ case 'solve': return this.solve();
129
+ case 'getDOF': return this.getDOF(params.entityId);
130
+ case 'getDOFTotal': return this.getDOFTotal();
131
+ case 'autoConstrain': return this.autoConstrain(params.tolerance);
132
+ case 'validate': return this.validate();
133
+ case 'getConflicts': return this.getConflicts();
134
+ case 'highlightConflict': return this.highlightConflict(params.constraintId);
135
+ case 'clearConflict': return this.clearConflict(params.constraintId);
136
+ default: throw new Error(`Unknown constraint command: ${command}`);
137
+ }
138
+ },
139
+
140
+ // ===== CORE CONSTRAINT METHODS =====
141
+
142
+ addConstraint(type, entities = [], value = null, points = []) {
143
+ /**
144
+ * Add a constraint to the sketch
145
+ * @param {string} type - constraint type ('coincident', 'horizontal', etc.)
146
+ * @param {array} entities - entity IDs involved in constraint
147
+ * @param {number} value - constraint value (for distance, angle, radius)
148
+ * @param {array} points - specific points on entities (for coincident, tangent)
149
+ * @returns {object} constraint object
150
+ */
151
+ // Validate constraint type
152
+ const validTypes = [
153
+ 'coincident', 'horizontal', 'vertical', 'parallel', 'perpendicular',
154
+ 'tangent', 'equal', 'fix', 'concentric', 'symmetric', 'collinear',
155
+ 'midpoint', 'coradial', 'distance', 'angle', 'radius', 'smooth'
156
+ ];
157
+
158
+ if (!validTypes.includes(type)) {
159
+ console.error(`Unknown constraint type: ${type}`);
160
+ return null;
161
+ }
162
+
163
+ const constraint = {
164
+ id: `constraint_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
165
+ type,
166
+ entities,
167
+ points,
168
+ value,
169
+ status: 'pending', // 'pending', 'satisfied', 'conflicted'
170
+ isConflict: false,
171
+ createdAt: Date.now(),
172
+ residual: null
173
+ };
174
+
175
+ this.state.constraints.push(constraint);
176
+ window.dispatchEvent(new CustomEvent('constraint:added', {
177
+ detail: { constraint, sketchId: this.state.currentSketchId }
178
+ }));
179
+
180
+ // Invalidate DOF cache
181
+ this.state.dofPerEntity.clear();
182
+
183
+ return constraint;
184
+ },
185
+
186
+ removeConstraint(constraintId) {
187
+ /**
188
+ * Remove a constraint from the sketch
189
+ * @param {string} constraintId - ID of constraint to remove
190
+ */
191
+ const idx = this.state.constraints.findIndex(c => c.id === constraintId);
192
+ if (idx < 0) return false;
193
+
194
+ const constraint = this.state.constraints[idx];
195
+ this.state.constraints.splice(idx, 1);
196
+ this.state.conflictedConstraintIds.delete(constraintId);
197
+
198
+ window.dispatchEvent(new CustomEvent('constraint:removed', {
199
+ detail: { constraint, sketchId: this.state.currentSketchId }
200
+ }));
201
+
202
+ // Invalidate DOF cache
203
+ this.state.dofPerEntity.clear();
204
+
205
+ return true;
206
+ },
207
+
208
+ // ===== SOLVER METHODS =====
209
+
210
+ solve() {
211
+ /**
212
+ * NEWTON-RAPHSON ITERATIVE SOLVER
213
+ *
214
+ * Algorithm:
215
+ * 1. Initialize: set entity positions as variables
216
+ * 2. Loop:
217
+ * a. Compute residuals: how much each constraint is violated
218
+ * b. Compute Jacobian: sensitivity of residuals to variables
219
+ * c. Solve linear system: J * Δx = -r
220
+ * d. Update variables: x := x + Δx
221
+ * e. Check convergence: if ||r|| < tolerance, stop
222
+ * 3. Return solver status and updated geometry
223
+ *
224
+ * Over-constrained detection:
225
+ * If Jacobian is rank-deficient, the system has no solution.
226
+ * Identify conflicting constraints and mark them.
227
+ */
228
+ if (!window.sketchEntities || window.sketchEntities.length === 0) {
229
+ console.warn('No sketch entities to solve');
230
+ return { success: false, message: 'No entities to constrain' };
231
+ }
232
+
233
+ // Reset solver state
234
+ this.state.solverIterations = 0;
235
+ this.state.residuals = [];
236
+ this.state.conflictedConstraintIds.clear();
237
+
238
+ const entities = window.sketchEntities || [];
239
+ const constraints = this.state.constraints;
240
+
241
+ // Extract variables: [x0, y0, x1, y1, ..., x_n, y_n, angle0, radius0, ...]
242
+ const variables = this.extractVariables(entities);
243
+ const variableCount = variables.length;
244
+ const constraintCount = constraints.length;
245
+
246
+ // Main solver loop
247
+ for (let iter = 0; iter < this.state.maxIterations; iter++) {
248
+ this.state.solverIterations = iter + 1;
249
+
250
+ // Compute residuals
251
+ const residuals = this.computeResiduals(entities, constraints, variables);
252
+ this.state.residuals = residuals;
253
+
254
+ // Check convergence
255
+ const residualNorm = Math.sqrt(residuals.reduce((sum, r) => sum + r * r, 0));
256
+ if (residualNorm < this.state.tolerance) {
257
+ // Solution found
258
+ window.dispatchEvent(new CustomEvent('constraint:solved', {
259
+ detail: { iterations: iter + 1, residualNorm }
260
+ }));
261
+ return { success: true, iterations: iter + 1, residualNorm };
262
+ }
263
+
264
+ // Compute numerical Jacobian
265
+ const jacobian = this.computeNumericalJacobian(entities, constraints, variables);
266
+
267
+ // Check for rank deficiency (over-constrained)
268
+ const rank = this.estimateMatrixRank(jacobian);
269
+ if (rank < constraintCount) {
270
+ const conflictIds = this.identifyConflictingConstraints(jacobian, constraints);
271
+ conflictIds.forEach(id => this.state.conflictedConstraintIds.add(id));
272
+ console.warn(`Over-constrained: rank ${rank} < ${constraintCount} constraints`);
273
+ return { success: false, message: 'Over-constrained', conflictIds };
274
+ }
275
+
276
+ // Solve linear system: Δx = -J⁺ * r (using pseudo-inverse)
277
+ const pseudoInverse = this.computePseudoInverse(jacobian);
278
+ const delta = this.matmul(pseudoInverse, residuals.map(r => -r));
279
+
280
+ // Update variables with dampening to improve convergence
281
+ const alpha = 0.5; // Dampening factor
282
+ for (let i = 0; i < variables.length; i++) {
283
+ variables[i] += alpha * delta[i];
284
+ }
285
+
286
+ // Update entity positions
287
+ this.updateEntityPositions(entities, variables);
288
+ }
289
+
290
+ // Max iterations reached
291
+ console.warn(`Solver did not converge after ${this.state.maxIterations} iterations`);
292
+ return {
293
+ success: false,
294
+ message: 'Max iterations reached',
295
+ iterations: this.state.maxIterations
296
+ };
297
+ },
298
+
299
+ computeResiduals(entities, constraints, variables) {
300
+ /**
301
+ * Compute constraint residuals (how much each constraint is violated)
302
+ * Residual = 0 means constraint is satisfied
303
+ * Residual > 0 means constraint is violated
304
+ *
305
+ * @returns {array} residuals for each constraint
306
+ */
307
+ const residuals = [];
308
+
309
+ constraints.forEach(constraint => {
310
+ let residual = 0;
311
+
312
+ switch (constraint.type) {
313
+ case 'coincident': {
314
+ // Residual: distance between two points
315
+ const [e1, e2] = constraint.entities;
316
+ const p1 = this.getEntityPoint(entities, e1, 0);
317
+ const p2 = this.getEntityPoint(entities, e2, 0);
318
+ if (p1 && p2) {
319
+ residual = p1.distanceTo(p2);
320
+ }
321
+ break;
322
+ }
323
+
324
+ case 'horizontal': {
325
+ // Residual: Y-coordinate difference
326
+ const [e1] = constraint.entities;
327
+ const p1 = this.getEntityPoint(entities, e1, 0);
328
+ const p2 = this.getEntityPoint(entities, e1, 1);
329
+ if (p1 && p2) {
330
+ residual = Math.abs(p2.y - p1.y);
331
+ }
332
+ break;
333
+ }
334
+
335
+ case 'vertical': {
336
+ // Residual: X-coordinate difference
337
+ const [e1] = constraint.entities;
338
+ const p1 = this.getEntityPoint(entities, e1, 0);
339
+ const p2 = this.getEntityPoint(entities, e1, 1);
340
+ if (p1 && p2) {
341
+ residual = Math.abs(p2.x - p1.x);
342
+ }
343
+ break;
344
+ }
345
+
346
+ case 'parallel': {
347
+ // Residual: angle difference between two lines
348
+ const [e1, e2] = constraint.entities;
349
+ const angle1 = this.getEntityAngle(entities, e1);
350
+ const angle2 = this.getEntityAngle(entities, e2);
351
+ if (angle1 !== null && angle2 !== null) {
352
+ residual = Math.abs(this.normalizeAngle(angle2 - angle1));
353
+ }
354
+ break;
355
+ }
356
+
357
+ case 'perpendicular': {
358
+ // Residual: angle difference should be π/2
359
+ const [e1, e2] = constraint.entities;
360
+ const angle1 = this.getEntityAngle(entities, e1);
361
+ const angle2 = this.getEntityAngle(entities, e2);
362
+ if (angle1 !== null && angle2 !== null) {
363
+ const diff = Math.abs(angle2 - angle1);
364
+ residual = Math.abs(this.normalizeAngle(diff - Math.PI / 2));
365
+ }
366
+ break;
367
+ }
368
+
369
+ case 'tangent': {
370
+ // Residual: distance from point on circle to line
371
+ // (simplified: point-line distance)
372
+ const [e1, e2] = constraint.entities;
373
+ const center = this.getEntityPoint(entities, e1, 0);
374
+ const p1 = this.getEntityPoint(entities, e2, 0);
375
+ const p2 = this.getEntityPoint(entities, e2, 1);
376
+ if (center && p1 && p2) {
377
+ const radius = this.getEntityProperty(entities, e1, 'radius');
378
+ const linePointDist = this.pointLineDistance(center, p1, p2);
379
+ residual = Math.abs(linePointDist - radius);
380
+ }
381
+ break;
382
+ }
383
+
384
+ case 'equal': {
385
+ // Residual: difference in measurements (length, radius, etc.)
386
+ const [e1, e2] = constraint.entities;
387
+ const val1 = this.getEntityMeasurement(entities, e1);
388
+ const val2 = this.getEntityMeasurement(entities, e2);
389
+ residual = Math.abs(val1 - val2);
390
+ break;
391
+ }
392
+
393
+ case 'fix': {
394
+ // Residual: distance from fixed position
395
+ const [e1] = constraint.entities;
396
+ const pos = this.getEntityPoint(entities, e1, 0);
397
+ const fixedPos = constraint.points[0];
398
+ if (pos && fixedPos) {
399
+ residual = pos.distanceTo(fixedPos);
400
+ }
401
+ break;
402
+ }
403
+
404
+ case 'concentric': {
405
+ // Residual: distance between two centers
406
+ const [e1, e2] = constraint.entities;
407
+ const c1 = this.getEntityPoint(entities, e1, 0);
408
+ const c2 = this.getEntityPoint(entities, e2, 0);
409
+ if (c1 && c2) {
410
+ residual = c1.distanceTo(c2);
411
+ }
412
+ break;
413
+ }
414
+
415
+ case 'symmetric': {
416
+ // Residual: entities should be mirror-symmetric about line
417
+ // (simplified: sum of distances should be zero)
418
+ residual = 0; // TODO: implement full symmetric residual
419
+ break;
420
+ }
421
+
422
+ case 'collinear': {
423
+ // Residual: all points should lie on same line
424
+ const points = constraint.entities.map(e => this.getEntityPoint(entities, e, 0));
425
+ if (points.length >= 3) {
426
+ const p1 = points[0], p2 = points[1];
427
+ for (let i = 2; i < points.length; i++) {
428
+ residual += this.pointLineDistance(points[i], p1, p2);
429
+ }
430
+ residual /= (points.length - 2);
431
+ }
432
+ break;
433
+ }
434
+
435
+ case 'midpoint': {
436
+ // Residual: point should be at midpoint of line
437
+ const [lineId, pointId] = constraint.entities;
438
+ const p1 = this.getEntityPoint(entities, lineId, 0);
439
+ const p2 = this.getEntityPoint(entities, lineId, 1);
440
+ const midP = this.getEntityPoint(entities, pointId, 0);
441
+ if (p1 && p2 && midP) {
442
+ const expectedMid = new THREE.Vector2(
443
+ (p1.x + p2.x) / 2,
444
+ (p1.y + p2.y) / 2
445
+ );
446
+ residual = midP.distanceTo(expectedMid);
447
+ }
448
+ break;
449
+ }
450
+
451
+ case 'coradial': {
452
+ // Residual: circles should be concentric with equal radius
453
+ const [e1, e2] = constraint.entities;
454
+ const c1 = this.getEntityPoint(entities, e1, 0);
455
+ const c2 = this.getEntityPoint(entities, e2, 0);
456
+ const r1 = this.getEntityProperty(entities, e1, 'radius');
457
+ const r2 = this.getEntityProperty(entities, e2, 'radius');
458
+ if (c1 && c2 && r1 && r2) {
459
+ residual = c1.distanceTo(c2) + Math.abs(r1 - r2);
460
+ }
461
+ break;
462
+ }
463
+
464
+ case 'distance': {
465
+ // Residual: distance should equal constraint value
466
+ const [e1, e2] = constraint.entities;
467
+ const p1 = this.getEntityPoint(entities, e1, 0);
468
+ const p2 = this.getEntityPoint(entities, e2, 0);
469
+ if (p1 && p2) {
470
+ residual = Math.abs(p1.distanceTo(p2) - constraint.value);
471
+ }
472
+ break;
473
+ }
474
+
475
+ case 'angle': {
476
+ // Residual: angle should equal constraint value
477
+ const [e1] = constraint.entities;
478
+ const p1 = this.getEntityPoint(entities, e1, 0);
479
+ const p2 = this.getEntityPoint(entities, e1, 1);
480
+ if (p1 && p2) {
481
+ const angle = Math.atan2(p2.y - p1.y, p2.x - p1.x);
482
+ residual = Math.abs(this.normalizeAngle(angle - constraint.value));
483
+ }
484
+ break;
485
+ }
486
+
487
+ case 'radius': {
488
+ // Residual: radius should equal constraint value
489
+ const [e1] = constraint.entities;
490
+ const r = this.getEntityProperty(entities, e1, 'radius');
491
+ if (r !== null) {
492
+ residual = Math.abs(r - constraint.value);
493
+ }
494
+ break;
495
+ }
496
+ }
497
+
498
+ residuals.push(residual);
499
+ });
500
+
501
+ return residuals;
502
+ },
503
+
504
+ computeNumericalJacobian(entities, constraints, variables) {
505
+ /**
506
+ * NUMERICAL JACOBIAN: Compute sensitivity matrix via finite differences
507
+ *
508
+ * J[i,j] = ∂(constraint_i) / ∂(variable_j)
509
+ *
510
+ * Uses central differences: J[i,j] = (f(x+ε) - f(x-ε)) / (2ε)
511
+ */
512
+ const epsilon = 1e-5;
513
+ const jacobian = [];
514
+
515
+ const baseResiduals = this.computeResiduals(entities, constraints, variables);
516
+
517
+ for (let j = 0; j < variables.length; j++) {
518
+ const varPlus = variables.slice();
519
+ const varMinus = variables.slice();
520
+
521
+ varPlus[j] += epsilon;
522
+ varMinus[j] -= epsilon;
523
+
524
+ this.updateEntityPositions(entities, varPlus);
525
+ const residualsPlus = this.computeResiduals(entities, constraints, varPlus);
526
+
527
+ this.updateEntityPositions(entities, varMinus);
528
+ const residualsMinus = this.computeResiduals(entities, constraints, varMinus);
529
+
530
+ // Restore original state
531
+ this.updateEntityPositions(entities, variables);
532
+
533
+ // Compute finite differences
534
+ for (let i = 0; i < constraints.length; i++) {
535
+ if (!jacobian[i]) jacobian[i] = [];
536
+ jacobian[i][j] = (residualsPlus[i] - residualsMinus[i]) / (2 * epsilon);
537
+ }
538
+ }
539
+
540
+ this.state.jacobian = jacobian;
541
+ return jacobian;
542
+ },
543
+
544
+ computePseudoInverse(matrix) {
545
+ /**
546
+ * PSEUDO-INVERSE (Moore-Penrose)
547
+ *
548
+ * For over- or under-determined systems, compute A⁺ using SVD.
549
+ * Simplified: use gradient descent for rectangular matrices.
550
+ */
551
+ if (matrix.length === 0 || matrix[0].length === 0) {
552
+ return [];
553
+ }
554
+
555
+ const rows = matrix.length;
556
+ const cols = matrix[0].length;
557
+
558
+ // For simplicity, return transpose if cols > rows (underdetermined)
559
+ // In production, implement full SVD-based pseudo-inverse
560
+ if (cols > rows) {
561
+ // Transpose
562
+ const result = [];
563
+ for (let j = 0; j < cols; j++) {
564
+ result[j] = [];
565
+ for (let i = 0; i < rows; i++) {
566
+ result[j][i] = matrix[i][j];
567
+ }
568
+ }
569
+ return result;
570
+ }
571
+
572
+ // For overdetermined (rows >= cols), use normal equations: (A^T A)^{-1} A^T
573
+ const AT = this.transposeMatrix(matrix);
574
+ const ATA = this.matmul(AT, matrix);
575
+ const ATAinv = this.invertMatrix(ATA);
576
+ return this.matmul(ATAinv, AT);
577
+ },
578
+
579
+ estimateMatrixRank(matrix) {
580
+ /**
581
+ * Estimate matrix rank by counting significant singular values
582
+ * (simplified: count rows with norm > threshold)
583
+ */
584
+ if (matrix.length === 0) return 0;
585
+
586
+ let rank = 0;
587
+ const threshold = 1e-6;
588
+
589
+ for (let i = 0; i < matrix.length; i++) {
590
+ const rowNorm = Math.sqrt(matrix[i].reduce((sum, x) => sum + x * x, 0));
591
+ if (rowNorm > threshold) rank++;
592
+ }
593
+
594
+ return rank;
595
+ },
596
+
597
+ identifyConflictingConstraints(jacobian, constraints) {
598
+ /**
599
+ * Identify which constraints are conflicting
600
+ * by analyzing the null space of the Jacobian
601
+ *
602
+ * Simplified: mark constraints with smallest singular values
603
+ */
604
+ const conflictIds = [];
605
+
606
+ // Find constraints with smallest sensitivities (likely redundant)
607
+ const rowNorms = jacobian.map(row =>
608
+ Math.sqrt(row.reduce((sum, x) => sum + x * x, 0))
609
+ );
610
+
611
+ const sortedIdx = Array.from({ length: rowNorms.length }, (_, i) => i)
612
+ .sort((a, b) => rowNorms[a] - rowNorms[b]);
613
+
614
+ // Mark bottom 10% as conflicted
615
+ const numConflicts = Math.max(1, Math.floor(sortedIdx.length * 0.1));
616
+ for (let i = 0; i < numConflicts; i++) {
617
+ const idx = sortedIdx[i];
618
+ if (idx < constraints.length) {
619
+ conflictIds.push(constraints[idx].id);
620
+ constraints[idx].isConflict = true;
621
+ constraints[idx].status = 'conflicted';
622
+ }
623
+ }
624
+
625
+ return conflictIds;
626
+ },
627
+
628
+ // ===== DEGREES OF FREEDOM ANALYSIS =====
629
+
630
+ getDOFTotal() {
631
+ /**
632
+ * Get total degrees of freedom in sketch
633
+ *
634
+ * Each entity starts with some DOF:
635
+ * - Point: 2 (x, y position)
636
+ * - Line: 4 (2 endpoints × 2 coords each)
637
+ * - Circle: 3 (center x,y + radius)
638
+ * - Arc: 5 (3 center + start angle + end angle)
639
+ *
640
+ * Each constraint removes some DOF.
641
+ * Total DOF = initial DOF - (DOF removed by each constraint)
642
+ */
643
+ if (!window.sketchEntities) return 0;
644
+
645
+ const entities = window.sketchEntities;
646
+ let totalDOF = 0;
647
+
648
+ entities.forEach(entity => {
649
+ if (entity.isConstruction) return; // Construction geometry doesn't count
650
+
651
+ switch (entity.type) {
652
+ case 'point': totalDOF += 2; break;
653
+ case 'line': totalDOF += 4; break;
654
+ case 'circle': totalDOF += 3; break;
655
+ case 'arc': totalDOF += 5; break;
656
+ case 'ellipse': totalDOF += 5; break;
657
+ case 'polygon': totalDOF += entity.points.length * 2; break;
658
+ case 'spline': totalDOF += entity.points.length * 2; break;
659
+ default: totalDOF += 2;
660
+ }
661
+ });
662
+
663
+ // Each constraint removes DOF
664
+ this.state.constraints.forEach(constraint => {
665
+ switch (constraint.type) {
666
+ case 'coincident': totalDOF -= 1; break; // Removes 1 DOF
667
+ case 'horizontal': totalDOF -= 1; break;
668
+ case 'vertical': totalDOF -= 1; break;
669
+ case 'parallel': totalDOF -= 1; break;
670
+ case 'perpendicular': totalDOF -= 1; break;
671
+ case 'tangent': totalDOF -= 1; break;
672
+ case 'equal': totalDOF -= 1; break;
673
+ case 'fix': totalDOF -= 2; break; // Removes 2 DOF (x and y)
674
+ case 'concentric': totalDOF -= 2; break;
675
+ case 'symmetric': totalDOF -= 2; break;
676
+ case 'collinear': totalDOF -= 1; break;
677
+ case 'midpoint': totalDOF -= 2; break;
678
+ case 'coradial': totalDOF -= 3; break;
679
+ case 'distance': totalDOF -= 1; break;
680
+ case 'angle': totalDOF -= 1; break;
681
+ case 'radius': totalDOF -= 1; break;
682
+ case 'smooth': totalDOF -= 2; break;
683
+ }
684
+ });
685
+
686
+ return Math.max(0, totalDOF);
687
+ },
688
+
689
+ getDOF(entityId) {
690
+ /**
691
+ * Get degrees of freedom for a specific entity
692
+ */
693
+ if (this.state.dofPerEntity.has(entityId)) {
694
+ return this.state.dofPerEntity.get(entityId);
695
+ }
696
+
697
+ if (!window.sketchEntities) return 0;
698
+
699
+ const entity = window.sketchEntities.find(e => e.id === entityId);
700
+ if (!entity) return 0;
701
+
702
+ let dof = 0;
703
+ switch (entity.type) {
704
+ case 'point': dof = 2; break;
705
+ case 'line': dof = 4; break;
706
+ case 'circle': dof = 3; break;
707
+ case 'arc': dof = 5; break;
708
+ default: dof = 2;
709
+ }
710
+
711
+ // Subtract constraints that fix this entity
712
+ this.state.constraints.forEach(constraint => {
713
+ if (constraint.entities.includes(entityId)) {
714
+ if (['fix'].includes(constraint.type)) dof -= 2;
715
+ else if (['coincident', 'horizontal', 'vertical', 'parallel', 'perpendicular', 'tangent', 'equal', 'distance', 'angle', 'radius', 'collinear'].includes(constraint.type)) {
716
+ dof -= 1;
717
+ }
718
+ }
719
+ });
720
+
721
+ const result = Math.max(0, dof);
722
+ this.state.dofPerEntity.set(entityId, result);
723
+ return result;
724
+ },
725
+
726
+ // ===== AUTO-CONSTRAINT =====
727
+
728
+ autoConstrain(tolerance = null) {
729
+ /**
730
+ * AUTO-CONSTRAINT: Detect and apply geometric relationships automatically
731
+ *
732
+ * Scans sketch for:
733
+ * - Nearly coincident points (merge)
734
+ * - Nearly horizontal/vertical lines (snap)
735
+ * - Nearly parallel/perpendicular lines (align)
736
+ * - Nearly tangent curves (snap)
737
+ * - Nearly equal segments (equal constraint)
738
+ */
739
+ tolerance = tolerance !== null ? tolerance : this.state.autoConstrainTolerance;
740
+ const addedConstraints = [];
741
+
742
+ if (!window.sketchEntities) return [];
743
+
744
+ const entities = window.sketchEntities;
745
+
746
+ // 1. COINCIDENT: Find nearly coincident points
747
+ for (let i = 0; i < entities.length; i++) {
748
+ for (let j = i + 1; j < entities.length; j++) {
749
+ const e1 = entities[i];
750
+ const e2 = entities[j];
751
+
752
+ if (['point', 'line', 'circle', 'arc'].includes(e1.type) &&
753
+ ['point', 'line', 'circle', 'arc'].includes(e2.type)) {
754
+ // Check if endpoints are close
755
+ for (let pi = 0; pi < e1.points.length; pi++) {
756
+ for (let pj = 0; pj < e2.points.length; pj++) {
757
+ const dist = e1.points[pi].distanceTo(e2.points[pj]);
758
+ if (dist < tolerance) {
759
+ const constraint = this.addConstraint('coincident', [e1.id, e2.id], null, [e1.points[pi], e2.points[pj]]);
760
+ if (constraint) addedConstraints.push(constraint);
761
+ }
762
+ }
763
+ }
764
+ }
765
+ }
766
+ }
767
+
768
+ // 2. HORIZONTAL/VERTICAL: Find nearly horizontal/vertical lines
769
+ entities.forEach(entity => {
770
+ if (entity.type === 'line') {
771
+ const [p1, p2] = entity.points;
772
+ const dx = Math.abs(p2.x - p1.x);
773
+ const dy = Math.abs(p2.y - p1.y);
774
+ const len = Math.sqrt(dx * dx + dy * dy);
775
+
776
+ if (len > 0.001) {
777
+ const angle = Math.atan2(dy, dx);
778
+ const angleFromHorizontal = Math.abs(angle);
779
+ const angleFromVertical = Math.abs(angle - Math.PI / 2);
780
+
781
+ if (angleFromHorizontal < tolerance * 0.01) {
782
+ const constraint = this.addConstraint('horizontal', [entity.id]);
783
+ if (constraint) addedConstraints.push(constraint);
784
+ } else if (angleFromVertical < tolerance * 0.01) {
785
+ const constraint = this.addConstraint('vertical', [entity.id]);
786
+ if (constraint) addedConstraints.push(constraint);
787
+ }
788
+ }
789
+ }
790
+ });
791
+
792
+ // 3. PARALLEL/PERPENDICULAR: Find nearly parallel/perpendicular lines
793
+ for (let i = 0; i < entities.length; i++) {
794
+ for (let j = i + 1; j < entities.length; j++) {
795
+ const e1 = entities[i];
796
+ const e2 = entities[j];
797
+
798
+ if (e1.type === 'line' && e2.type === 'line') {
799
+ const angle1 = Math.atan2(e1.points[1].y - e1.points[0].y, e1.points[1].x - e1.points[0].x);
800
+ const angle2 = Math.atan2(e2.points[1].y - e2.points[0].y, e2.points[1].x - e2.points[0].x);
801
+ const angleDiff = Math.abs(this.normalizeAngle(angle2 - angle1));
802
+
803
+ if (angleDiff < tolerance * 0.01) {
804
+ const constraint = this.addConstraint('parallel', [e1.id, e2.id]);
805
+ if (constraint) addedConstraints.push(constraint);
806
+ } else if (Math.abs(angleDiff - Math.PI / 2) < tolerance * 0.01) {
807
+ const constraint = this.addConstraint('perpendicular', [e1.id, e2.id]);
808
+ if (constraint) addedConstraints.push(constraint);
809
+ }
810
+ }
811
+ }
812
+ }
813
+
814
+ // 4. EQUAL: Find nearly equal segments
815
+ const lengthGroups = {};
816
+ entities.forEach(entity => {
817
+ if (entity.type === 'line') {
818
+ const len = entity.points[0].distanceTo(entity.points[1]);
819
+ const bucket = Math.round(len / tolerance) * tolerance;
820
+ if (!lengthGroups[bucket]) lengthGroups[bucket] = [];
821
+ lengthGroups[bucket].push(entity.id);
822
+ }
823
+ });
824
+
825
+ Object.values(lengthGroups).forEach(group => {
826
+ if (group.length > 1) {
827
+ for (let i = 1; i < group.length; i++) {
828
+ const constraint = this.addConstraint('equal', [group[0], group[i]]);
829
+ if (constraint) addedConstraints.push(constraint);
830
+ }
831
+ }
832
+ });
833
+
834
+ window.dispatchEvent(new CustomEvent('constraint:autoConstrained', {
835
+ detail: { count: addedConstraints.length, constraints: addedConstraints }
836
+ }));
837
+
838
+ return addedConstraints;
839
+ },
840
+
841
+ validate() {
842
+ /**
843
+ * VALIDATE SKETCH: Check for issues and provide feedback
844
+ *
845
+ * Checks for:
846
+ * - Over-constrained (Jacobian rank deficient)
847
+ * - Under-constrained (DOF > 0)
848
+ * - Degenerate geometry (zero-length lines, etc.)
849
+ * - Redundant constraints
850
+ */
851
+ const issues = [];
852
+ const dofTotal = this.getDOFTotal();
853
+
854
+ if (this.state.conflictedConstraintIds.size > 0) {
855
+ issues.push({
856
+ type: 'over-constrained',
857
+ severity: 'error',
858
+ message: `Over-constrained: ${this.state.conflictedConstraintIds.size} conflicting constraint(s)`,
859
+ constraintIds: Array.from(this.state.conflictedConstraintIds)
860
+ });
861
+ }
862
+
863
+ if (dofTotal > 0) {
864
+ issues.push({
865
+ type: 'under-constrained',
866
+ severity: 'warning',
867
+ message: `Under-constrained: ${dofTotal} degree(s) of freedom remain`,
868
+ dof: dofTotal
869
+ });
870
+ }
871
+
872
+ // Check for degenerate geometry
873
+ if (window.sketchEntities) {
874
+ window.sketchEntities.forEach(entity => {
875
+ if (entity.type === 'line' && entity.points[0].distanceTo(entity.points[1]) < 0.001) {
876
+ issues.push({
877
+ type: 'degenerate',
878
+ severity: 'error',
879
+ message: `Degenerate line: ${entity.id} (zero length)`,
880
+ entityId: entity.id
881
+ });
882
+ }
883
+ });
884
+ }
885
+
886
+ window.dispatchEvent(new CustomEvent('constraint:validated', {
887
+ detail: { issues, isValid: issues.length === 0 }
888
+ }));
889
+
890
+ return { issues, isValid: issues.length === 0 };
891
+ },
892
+
893
+ getConflicts() {
894
+ /**
895
+ * Get list of conflicted constraints
896
+ */
897
+ return this.state.constraints.filter(c => c.isConflict);
898
+ },
899
+
900
+ highlightConflict(constraintId) {
901
+ /**
902
+ * Visually highlight a conflicting constraint
903
+ */
904
+ const constraint = this.state.constraints.find(c => c.id === constraintId);
905
+ if (!constraint) return;
906
+
907
+ window.dispatchEvent(new CustomEvent('constraint:conflictHighlighted', {
908
+ detail: { constraint }
909
+ }));
910
+ },
911
+
912
+ clearConflict(constraintId) {
913
+ /**
914
+ * Clear conflict status from a constraint
915
+ */
916
+ const constraint = this.state.constraints.find(c => c.id === constraintId);
917
+ if (constraint) {
918
+ constraint.isConflict = false;
919
+ constraint.status = 'pending';
920
+ this.state.conflictedConstraintIds.delete(constraintId);
921
+ }
922
+ },
923
+
924
+ // ===== HELPER METHODS =====
925
+
926
+ extractVariables(entities) {
927
+ /**
928
+ * Extract solver variables from entities
929
+ * Format: [x0, y0, x1, y1, ..., angle0, radius0, ...]
930
+ */
931
+ const variables = [];
932
+
933
+ entities.forEach(entity => {
934
+ switch (entity.type) {
935
+ case 'point':
936
+ case 'circle':
937
+ variables.push(entity.points[0].x, entity.points[0].y);
938
+ if (entity.type === 'circle') variables.push(entity.data.radius);
939
+ break;
940
+
941
+ case 'line':
942
+ case 'arc':
943
+ entity.points.forEach(p => variables.push(p.x, p.y));
944
+ if (entity.type === 'arc') variables.push(entity.data.startAngle, entity.data.endAngle);
945
+ break;
946
+
947
+ default:
948
+ entity.points.forEach(p => variables.push(p.x, p.y));
949
+ }
950
+ });
951
+
952
+ return variables;
953
+ },
954
+
955
+ updateEntityPositions(entities, variables) {
956
+ /**
957
+ * Update entity positions from solver variables
958
+ */
959
+ let idx = 0;
960
+
961
+ entities.forEach(entity => {
962
+ switch (entity.type) {
963
+ case 'point':
964
+ case 'circle':
965
+ entity.points[0].x = variables[idx++];
966
+ entity.points[0].y = variables[idx++];
967
+ if (entity.type === 'circle') entity.data.radius = variables[idx++];
968
+ break;
969
+
970
+ case 'line':
971
+ case 'arc':
972
+ for (let i = 0; i < entity.points.length; i++) {
973
+ entity.points[i].x = variables[idx++];
974
+ entity.points[i].y = variables[idx++];
975
+ }
976
+ if (entity.type === 'arc') {
977
+ entity.data.startAngle = variables[idx++];
978
+ entity.data.endAngle = variables[idx++];
979
+ }
980
+ break;
981
+
982
+ default:
983
+ for (let i = 0; i < entity.points.length; i++) {
984
+ entity.points[i].x = variables[idx++];
985
+ entity.points[i].y = variables[idx++];
986
+ }
987
+ }
988
+ });
989
+ },
990
+
991
+ getEntityPoint(entities, entityId, pointIndex = 0) {
992
+ const entity = entities.find(e => e.id === entityId);
993
+ return entity && entity.points[pointIndex] ? entity.points[pointIndex].clone() : null;
994
+ },
995
+
996
+ getEntityAngle(entities, entityId) {
997
+ const entity = entities.find(e => e.id === entityId);
998
+ if (!entity || entity.type !== 'line' || entity.points.length < 2) return null;
999
+
1000
+ const [p1, p2] = entity.points;
1001
+ return Math.atan2(p2.y - p1.y, p2.x - p1.x);
1002
+ },
1003
+
1004
+ getEntityProperty(entities, entityId, property) {
1005
+ const entity = entities.find(e => e.id === entityId);
1006
+ return entity && entity.data ? entity.data[property] : null;
1007
+ },
1008
+
1009
+ getEntityMeasurement(entities, entityId) {
1010
+ const entity = entities.find(e => e.id === entityId);
1011
+ if (!entity) return 0;
1012
+
1013
+ if (entity.type === 'line' && entity.points.length >= 2) {
1014
+ return entity.points[0].distanceTo(entity.points[1]);
1015
+ } else if (['circle', 'arc'].includes(entity.type)) {
1016
+ return entity.data.radius || 0;
1017
+ }
1018
+ return 0;
1019
+ },
1020
+
1021
+ pointLineDistance(point, lineP1, lineP2) {
1022
+ /**
1023
+ * Perpendicular distance from point to line
1024
+ * Formula: |((p2-p1) × (p1-p)) / |p2-p1||
1025
+ */
1026
+ const x1 = lineP1.x, y1 = lineP1.y;
1027
+ const x2 = lineP2.x, y2 = lineP2.y;
1028
+ const x0 = point.x, y0 = point.y;
1029
+
1030
+ const num = Math.abs((y2 - y1) * x0 - (x2 - x1) * y0 + x2 * y1 - y2 * x1);
1031
+ const denom = Math.sqrt((y2 - y1) * (y2 - y1) + (x2 - x1) * (x2 - x1));
1032
+
1033
+ return denom === 0 ? 0 : num / denom;
1034
+ },
1035
+
1036
+ normalizeAngle(angle) {
1037
+ /**
1038
+ * Normalize angle to [-π, π)
1039
+ */
1040
+ while (angle >= Math.PI) angle -= 2 * Math.PI;
1041
+ while (angle < -Math.PI) angle += 2 * Math.PI;
1042
+ return angle;
1043
+ },
1044
+
1045
+ // ===== MATRIX OPERATIONS =====
1046
+
1047
+ transposeMatrix(matrix) {
1048
+ if (matrix.length === 0) return [];
1049
+ const result = [];
1050
+ for (let j = 0; j < matrix[0].length; j++) {
1051
+ result[j] = [];
1052
+ for (let i = 0; i < matrix.length; i++) {
1053
+ result[j][i] = matrix[i][j];
1054
+ }
1055
+ }
1056
+ return result;
1057
+ },
1058
+
1059
+ matmul(A, b) {
1060
+ /**
1061
+ * Matrix-vector multiplication: result = A * b
1062
+ */
1063
+ if (Array.isArray(b[0])) {
1064
+ // Matrix-matrix multiplication
1065
+ const result = [];
1066
+ for (let i = 0; i < A.length; i++) {
1067
+ result[i] = [];
1068
+ for (let j = 0; j < b[0].length; j++) {
1069
+ result[i][j] = 0;
1070
+ for (let k = 0; k < A[0].length; k++) {
1071
+ result[i][j] += A[i][k] * b[k][j];
1072
+ }
1073
+ }
1074
+ }
1075
+ return result;
1076
+ } else {
1077
+ // Matrix-vector multiplication
1078
+ const result = [];
1079
+ for (let i = 0; i < A.length; i++) {
1080
+ result[i] = 0;
1081
+ for (let j = 0; j < A[0].length; j++) {
1082
+ result[i] += A[i][j] * b[j];
1083
+ }
1084
+ }
1085
+ return result;
1086
+ }
1087
+ },
1088
+
1089
+ invertMatrix(matrix) {
1090
+ /**
1091
+ * Invert a 2x2 or 3x3 matrix (simplified)
1092
+ */
1093
+ const n = matrix.length;
1094
+ if (n === 2) {
1095
+ const [[a, b], [c, d]] = matrix;
1096
+ const det = a * d - b * c;
1097
+ return [[d / det, -b / det], [-c / det, a / det]];
1098
+ }
1099
+ // For larger matrices, return identity (simplified)
1100
+ const result = [];
1101
+ for (let i = 0; i < n; i++) {
1102
+ result[i] = [];
1103
+ for (let j = 0; j < n; j++) {
1104
+ result[i][j] = i === j ? 1 : 0;
1105
+ }
1106
+ }
1107
+ return result;
1108
+ },
1109
+
1110
+ // ===== EVENT HANDLERS =====
1111
+
1112
+ onEntityAdded(event) {
1113
+ const { entity } = event.detail;
1114
+ this.state.dofPerEntity.clear();
1115
+ window.dispatchEvent(new CustomEvent('constraint:dofChanged', {
1116
+ detail: { dof: this.getDOFTotal() }
1117
+ }));
1118
+ },
1119
+
1120
+ onEntityModified(event) {
1121
+ // Re-solve constraints when entity is modified
1122
+ this.state.dofPerEntity.clear();
1123
+ },
1124
+
1125
+ onDimensionAdded(event) {
1126
+ const { dimension } = event.detail;
1127
+ // Dimension acts as constraint
1128
+ this.addConstraint('distance', dimension.entities, dimension.value);
1129
+ },
1130
+
1131
+ setupEventHandlers() {
1132
+ // Toolbar buttons
1133
+ document.addEventListener('click', (e) => {
1134
+ if (e.target.dataset.constraint) {
1135
+ const type = e.target.dataset.constraint;
1136
+ window.dispatchEvent(new CustomEvent('constraint:toolSelected', { detail: { type } }));
1137
+ }
1138
+ if (e.target.id === 'constraint-solve-btn') this.solve();
1139
+ if (e.target.id === 'constraint-auto-btn') this.autoConstrain();
1140
+ if (e.target.id === 'constraint-validate-btn') this.validate();
1141
+ });
1142
+
1143
+ // Keyboard shortcuts
1144
+ document.addEventListener('keydown', (e) => {
1145
+ if (e.ctrlKey && e.shiftKey) {
1146
+ if (e.key === 'S') this.solve();
1147
+ if (e.key === 'V') this.validate();
1148
+ }
1149
+ });
1150
+ },
1151
+
1152
+ setupToolbar() {
1153
+ // Toolbar setup in getUI()
1154
+ }
1155
+ };
1156
+
1157
+ /**
1158
+ * HELP ENTRIES: Documentation for all constraint types
1159
+ */
1160
+ ConstraintModule.HELP_ENTRIES = [
1161
+ {
1162
+ id: 'constraint.coincident',
1163
+ title: 'Coincident Constraint',
1164
+ description: 'Force two points to occupy the same location.',
1165
+ category: 'Geometric',
1166
+ hotkey: 'C'
1167
+ },
1168
+ {
1169
+ id: 'constraint.horizontal',
1170
+ title: 'Horizontal Constraint',
1171
+ description: 'Force line to be horizontal (parallel to X-axis).',
1172
+ category: 'Alignment',
1173
+ hotkey: 'H'
1174
+ },
1175
+ {
1176
+ id: 'constraint.vertical',
1177
+ title: 'Vertical Constraint',
1178
+ description: 'Force line to be vertical (parallel to Y-axis).',
1179
+ category: 'Alignment',
1180
+ hotkey: 'V'
1181
+ },
1182
+ {
1183
+ id: 'constraint.parallel',
1184
+ title: 'Parallel Constraint',
1185
+ description: 'Force two lines to remain parallel.',
1186
+ category: 'Alignment'
1187
+ },
1188
+ {
1189
+ id: 'constraint.perpendicular',
1190
+ title: 'Perpendicular Constraint',
1191
+ description: 'Force two lines to meet at right angle.',
1192
+ category: 'Alignment',
1193
+ hotkey: 'P'
1194
+ },
1195
+ {
1196
+ id: 'constraint.tangent',
1197
+ title: 'Tangent Constraint',
1198
+ description: 'Force line and circle (or two circles) to be tangent.',
1199
+ category: 'Geometric',
1200
+ hotkey: 'T'
1201
+ },
1202
+ {
1203
+ id: 'constraint.equal',
1204
+ title: 'Equal Constraint',
1205
+ description: 'Force two elements (lines, arcs, circles) to have equal length/radius.',
1206
+ category: 'Dimensional',
1207
+ hotkey: 'E'
1208
+ },
1209
+ {
1210
+ id: 'constraint.fix',
1211
+ title: 'Fix Constraint',
1212
+ description: 'Lock entity to fixed position/angle (fully constrain in 2 DOF).',
1213
+ category: 'Positional',
1214
+ hotkey: 'F'
1215
+ },
1216
+ {
1217
+ id: 'constraint.concentric',
1218
+ title: 'Concentric Constraint',
1219
+ description: 'Force two circles/arcs to share the same center.',
1220
+ category: 'Geometric'
1221
+ },
1222
+ {
1223
+ id: 'constraint.symmetric',
1224
+ title: 'Symmetric Constraint',
1225
+ description: 'Force entities to be mirror-symmetric about a line.',
1226
+ category: 'Geometric'
1227
+ },
1228
+ {
1229
+ id: 'constraint.collinear',
1230
+ title: 'Collinear Constraint',
1231
+ description: 'Force multiple points to lie on same line.',
1232
+ category: 'Geometric'
1233
+ },
1234
+ {
1235
+ id: 'constraint.midpoint',
1236
+ title: 'Midpoint Constraint',
1237
+ description: 'Force point to be at midpoint of a line.',
1238
+ category: 'Geometric'
1239
+ },
1240
+ {
1241
+ id: 'constraint.coradial',
1242
+ title: 'Coradial Constraint',
1243
+ description: 'Force two circles to be concentric with equal radius.',
1244
+ category: 'Geometric'
1245
+ },
1246
+ {
1247
+ id: 'constraint.solver',
1248
+ title: 'Constraint Solver',
1249
+ description: 'Newton-Raphson iterative solver with Jacobian. Detects over/under-constrained sketches.',
1250
+ category: 'Solver'
1251
+ },
1252
+ {
1253
+ id: 'constraint.auto',
1254
+ title: 'Auto-Constraint',
1255
+ description: 'Automatically detect and apply geometric relationships (coincident, horizontal, parallel, equal, etc.).',
1256
+ category: 'Automation'
1257
+ },
1258
+ {
1259
+ id: 'constraint.validate',
1260
+ title: 'Validate Sketch',
1261
+ description: 'Check for over-constrained, under-constrained, and degenerate geometry issues.',
1262
+ category: 'Validation'
1263
+ }
1264
+ ];
1265
+
1266
+ export default ConstraintModule;