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
|
@@ -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;
|