cyclecad 0.1.4 → 0.1.7
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/CLAUDE.md +20 -9
- package/app/index.html +451 -3
- package/app/js/advanced-ops.js +762 -0
- package/app/js/assembly.js +1102 -0
- package/app/js/constraint-solver.js +1046 -0
- package/app/js/dxf-export.js +1173 -0
- package/app/js/viewport.js +83 -0
- package/app/mobile.html +1276 -0
- package/package.json +1 -1
- package/DUO-MANIFEST-README.md +0 -233
- package/app/duo-manifest-demo.html +0 -337
- package/app/duo-manifest.json +0 -7375
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constraint Solver for 2D Sketch Engine
|
|
3
|
+
* Iterative relaxation solver for geometric constraints in cycleCAD
|
|
4
|
+
*
|
|
5
|
+
* Supported constraints:
|
|
6
|
+
* - Coincident, Horizontal, Vertical, Parallel, Perpendicular
|
|
7
|
+
* - Tangent, Equal, Fixed, Concentric, Symmetric
|
|
8
|
+
* - Distance, Angle
|
|
9
|
+
*
|
|
10
|
+
* @module constraint-solver
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// CONSTRAINT STORE & STATE
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
let constraintIdCounter = 1000;
|
|
20
|
+
const constraintStore = new Map(); // id -> constraint object
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Constraint object structure
|
|
24
|
+
* @typedef {Object} Constraint
|
|
25
|
+
* @property {number} id - Unique constraint ID
|
|
26
|
+
* @property {string} type - 'coincident'|'horizontal'|'vertical'|'parallel'|'perpendicular'|'tangent'|'equal'|'fixed'|'concentric'|'symmetric'|'distance'|'angle'
|
|
27
|
+
* @property {number[]} entities - Array of entity IDs involved
|
|
28
|
+
* @property {number} [value] - Constraint value (distance, angle, radius, etc.)
|
|
29
|
+
* @property {number} [priority=1] - Priority weight (higher = stricter). Used in weighted least-squares solving.
|
|
30
|
+
* @property {boolean} [enabled=true] - Whether this constraint is active
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate unique constraint ID
|
|
35
|
+
* @returns {number}
|
|
36
|
+
*/
|
|
37
|
+
function nextConstraintId() {
|
|
38
|
+
return constraintIdCounter++;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// MATH UTILITIES
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 2D vector operations
|
|
47
|
+
*/
|
|
48
|
+
const Vec2 = {
|
|
49
|
+
/**
|
|
50
|
+
* @param {number} x
|
|
51
|
+
* @param {number} y
|
|
52
|
+
* @returns {{x: number, y: number}}
|
|
53
|
+
*/
|
|
54
|
+
make(x, y) {
|
|
55
|
+
return { x, y };
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {Object} a
|
|
60
|
+
* @param {Object} b
|
|
61
|
+
* @returns {{x: number, y: number}}
|
|
62
|
+
*/
|
|
63
|
+
add(a, b) {
|
|
64
|
+
return { x: a.x + b.x, y: a.y + b.y };
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {Object} a
|
|
69
|
+
* @param {Object} b
|
|
70
|
+
* @returns {{x: number, y: number}}
|
|
71
|
+
*/
|
|
72
|
+
sub(a, b) {
|
|
73
|
+
return { x: a.x - b.x, y: a.y - b.y };
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Object} v
|
|
78
|
+
* @param {number} s
|
|
79
|
+
* @returns {{x: number, y: number}}
|
|
80
|
+
*/
|
|
81
|
+
scale(v, s) {
|
|
82
|
+
return { x: v.x * s, y: v.y * s };
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {Object} a
|
|
87
|
+
* @param {Object} b
|
|
88
|
+
* @returns {number}
|
|
89
|
+
*/
|
|
90
|
+
dot(a, b) {
|
|
91
|
+
return a.x * b.x + a.y * b.y;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 2D cross product (scalar in 3D sense)
|
|
96
|
+
* @param {Object} a
|
|
97
|
+
* @param {Object} b
|
|
98
|
+
* @returns {number}
|
|
99
|
+
*/
|
|
100
|
+
cross(a, b) {
|
|
101
|
+
return a.x * b.y - a.y * b.x;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {Object} v
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
length(v) {
|
|
109
|
+
return Math.sqrt(v.x * v.x + v.y * v.y);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {Object} v
|
|
114
|
+
* @returns {{x: number, y: number}}
|
|
115
|
+
*/
|
|
116
|
+
normalize(v) {
|
|
117
|
+
const len = Vec2.length(v);
|
|
118
|
+
if (len < 1e-10) return { x: 0, y: 0 };
|
|
119
|
+
return { x: v.x / len, y: v.y / len };
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Perpendicular vector (rotate 90° CCW)
|
|
124
|
+
* @param {Object} v
|
|
125
|
+
* @returns {{x: number, y: number}}
|
|
126
|
+
*/
|
|
127
|
+
perp(v) {
|
|
128
|
+
return { x: -v.y, x: v.x };
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Distance between two points
|
|
133
|
+
* @param {Object} a
|
|
134
|
+
* @param {Object} b
|
|
135
|
+
* @returns {number}
|
|
136
|
+
*/
|
|
137
|
+
distance(a, b) {
|
|
138
|
+
const dx = a.x - b.x;
|
|
139
|
+
const dy = a.y - b.y;
|
|
140
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Angle of vector in radians [-π, π]
|
|
145
|
+
* @param {Object} v
|
|
146
|
+
* @returns {number}
|
|
147
|
+
*/
|
|
148
|
+
angle(v) {
|
|
149
|
+
return Math.atan2(v.y, v.x);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Signed angle from v1 to v2 in radians [-π, π]
|
|
154
|
+
* @param {Object} v1
|
|
155
|
+
* @param {Object} v2
|
|
156
|
+
* @returns {number}
|
|
157
|
+
*/
|
|
158
|
+
signedAngle(v1, v2) {
|
|
159
|
+
const angle = Math.atan2(Vec2.cross(v1, v2), Vec2.dot(v1, v2));
|
|
160
|
+
return angle;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Closest point on line (p1, p2) to point p
|
|
165
|
+
* @param {Object} p
|
|
166
|
+
* @param {Object} p1
|
|
167
|
+
* @param {Object} p2
|
|
168
|
+
* @returns {{point: Object, t: number}}
|
|
169
|
+
*/
|
|
170
|
+
closestPointOnLine(p, p1, p2) {
|
|
171
|
+
const line = Vec2.sub(p2, p1);
|
|
172
|
+
const toP = Vec2.sub(p, p1);
|
|
173
|
+
const lineLenSq = Vec2.dot(line, line);
|
|
174
|
+
if (lineLenSq < 1e-10) return { point: p1, t: 0 };
|
|
175
|
+
const t = Vec2.dot(toP, line) / lineLenSq;
|
|
176
|
+
const closest = Vec2.add(p1, Vec2.scale(line, t));
|
|
177
|
+
return { point: closest, t };
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Distance from point to line
|
|
182
|
+
* @param {Object} p
|
|
183
|
+
* @param {Object} p1
|
|
184
|
+
* @param {Object} p2
|
|
185
|
+
* @returns {number}
|
|
186
|
+
*/
|
|
187
|
+
distanceToLine(p, p1, p2) {
|
|
188
|
+
const { point } = Vec2.closestPointOnLine(p, p1, p2);
|
|
189
|
+
return Vec2.distance(p, point);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Distance from point to circle
|
|
194
|
+
* @param {Object} p
|
|
195
|
+
* @param {Object} center
|
|
196
|
+
* @param {number} radius
|
|
197
|
+
* @returns {number}
|
|
198
|
+
*/
|
|
199
|
+
distanceToCircle(p, center, radius) {
|
|
200
|
+
return Math.abs(Vec2.distance(p, center) - radius);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// ============================================================================
|
|
205
|
+
// CONSTRAINT ANALYSIS & ERROR COMPUTATION
|
|
206
|
+
// ============================================================================
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get point from entity by index (first, last, or center)
|
|
210
|
+
* @param {Object} entity
|
|
211
|
+
* @param {number} pointIdx - 0=first, 1=last, -1=center
|
|
212
|
+
* @returns {Object} {x, y}
|
|
213
|
+
*/
|
|
214
|
+
function getEntityPoint(entity, pointIdx) {
|
|
215
|
+
if (!entity || !entity.points || entity.points.length === 0) {
|
|
216
|
+
return { x: 0, y: 0 };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (pointIdx === -1) {
|
|
220
|
+
// Center (for circles/arcs)
|
|
221
|
+
if (entity.type === 'circle' || entity.type === 'arc') {
|
|
222
|
+
return entity.points[0]; // Center is first point
|
|
223
|
+
}
|
|
224
|
+
// Centroid for other types
|
|
225
|
+
let sumX = 0, sumY = 0;
|
|
226
|
+
entity.points.forEach(p => {
|
|
227
|
+
sumX += p.x;
|
|
228
|
+
sumY += p.y;
|
|
229
|
+
});
|
|
230
|
+
return { x: sumX / entity.points.length, y: sumY / entity.points.length };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (pointIdx === 1 && entity.points.length > 1) {
|
|
234
|
+
return entity.points[entity.points.length - 1]; // Last point
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return entity.points[0]; // First point
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get line direction vector
|
|
242
|
+
* @param {Object} entity
|
|
243
|
+
* @returns {Object} {x, y}
|
|
244
|
+
*/
|
|
245
|
+
function getLineDirection(entity) {
|
|
246
|
+
if (entity.points.length < 2) {
|
|
247
|
+
return { x: 1, y: 0 };
|
|
248
|
+
}
|
|
249
|
+
return Vec2.normalize(Vec2.sub(entity.points[1], entity.points[0]));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get radius of circle/arc
|
|
254
|
+
* @param {Object} entity
|
|
255
|
+
* @returns {number}
|
|
256
|
+
*/
|
|
257
|
+
function getRadius(entity) {
|
|
258
|
+
if (entity.type === 'circle' || entity.type === 'arc') {
|
|
259
|
+
if (entity.dimensions && entity.dimensions.radius !== undefined) {
|
|
260
|
+
return entity.dimensions.radius;
|
|
261
|
+
}
|
|
262
|
+
if (entity.points.length >= 2) {
|
|
263
|
+
// Radius = distance from center (first point) to any point on circle
|
|
264
|
+
return Vec2.distance(entity.points[0], entity.points[1]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return 0;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Get line length
|
|
272
|
+
* @param {Object} entity
|
|
273
|
+
* @returns {number}
|
|
274
|
+
*/
|
|
275
|
+
function getLineLength(entity) {
|
|
276
|
+
if (entity.dimensions && entity.dimensions.length !== undefined) {
|
|
277
|
+
return entity.dimensions.length;
|
|
278
|
+
}
|
|
279
|
+
if (entity.points.length < 2) return 0;
|
|
280
|
+
return Vec2.distance(entity.points[0], entity.points[1]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Compute constraint error and correction
|
|
285
|
+
* Returns {error: number, correction: {[entityId]: {[pointIdx]: {x, y}}} }
|
|
286
|
+
*
|
|
287
|
+
* @param {Map} entityMap - id -> entity
|
|
288
|
+
* @param {Constraint} constraint
|
|
289
|
+
* @returns {Object}
|
|
290
|
+
*/
|
|
291
|
+
function computeConstraintError(entityMap, constraint) {
|
|
292
|
+
const correction = {};
|
|
293
|
+
let error = 0;
|
|
294
|
+
|
|
295
|
+
const type = constraint.type;
|
|
296
|
+
const [e1Id, e2Id] = constraint.entities;
|
|
297
|
+
const e1 = entityMap.get(e1Id);
|
|
298
|
+
const e2 = e2Id ? entityMap.get(e2Id) : null;
|
|
299
|
+
|
|
300
|
+
if (!e1) return { error: 0, correction };
|
|
301
|
+
|
|
302
|
+
switch (type) {
|
|
303
|
+
case 'coincident': {
|
|
304
|
+
// Two points should be at the same location
|
|
305
|
+
const p1 = getEntityPoint(e1, 0);
|
|
306
|
+
if (!e2) break;
|
|
307
|
+
const p2 = getEntityPoint(e2, 0);
|
|
308
|
+
const delta = Vec2.sub(p2, p1);
|
|
309
|
+
error = Vec2.length(delta);
|
|
310
|
+
const correction_amt = Vec2.scale(delta, 0.5); // Move both points toward midpoint
|
|
311
|
+
|
|
312
|
+
if (!correction[e1Id]) correction[e1Id] = { 0: Vec2.scale(delta, 0.5) };
|
|
313
|
+
else correction[e1Id][0] = Vec2.scale(delta, 0.5);
|
|
314
|
+
|
|
315
|
+
if (!correction[e2Id]) correction[e2Id] = { 0: Vec2.scale(delta, -0.5) };
|
|
316
|
+
else correction[e2Id][0] = Vec2.scale(delta, -0.5);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case 'horizontal': {
|
|
321
|
+
// Line should be horizontal (dy=0)
|
|
322
|
+
const p1 = e1.points[0];
|
|
323
|
+
const p2 = e1.points[1];
|
|
324
|
+
if (!p2) break;
|
|
325
|
+
const dy = p2.y - p1.y;
|
|
326
|
+
error = Math.abs(dy);
|
|
327
|
+
const correction_amt = dy * 0.5;
|
|
328
|
+
|
|
329
|
+
if (!correction[e1Id]) correction[e1Id] = {};
|
|
330
|
+
correction[e1Id][0] = { x: 0, y: -correction_amt };
|
|
331
|
+
correction[e1Id][1] = { x: 0, y: correction_amt };
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
case 'vertical': {
|
|
336
|
+
// Line should be vertical (dx=0)
|
|
337
|
+
const p1 = e1.points[0];
|
|
338
|
+
const p2 = e1.points[1];
|
|
339
|
+
if (!p2) break;
|
|
340
|
+
const dx = p2.x - p1.x;
|
|
341
|
+
error = Math.abs(dx);
|
|
342
|
+
const correction_amt = dx * 0.5;
|
|
343
|
+
|
|
344
|
+
if (!correction[e1Id]) correction[e1Id] = {};
|
|
345
|
+
correction[e1Id][0] = { x: -correction_amt, y: 0 };
|
|
346
|
+
correction[e1Id][1] = { x: correction_amt, y: 0 };
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
case 'parallel': {
|
|
351
|
+
// Two lines should have parallel direction
|
|
352
|
+
if (!e2) break;
|
|
353
|
+
const dir1 = getLineDirection(e1);
|
|
354
|
+
const dir2 = getLineDirection(e2);
|
|
355
|
+
// Cross product should be ~0
|
|
356
|
+
const cross = Vec2.cross(dir1, dir2);
|
|
357
|
+
error = Math.abs(cross);
|
|
358
|
+
|
|
359
|
+
// Rotate each line slightly to align
|
|
360
|
+
const angle = Math.asin(Math.max(-1, Math.min(1, cross)));
|
|
361
|
+
const perp1 = Vec2.perp(dir1);
|
|
362
|
+
|
|
363
|
+
const correction_angle = angle * 0.25;
|
|
364
|
+
if (!correction[e1Id]) correction[e1Id] = {};
|
|
365
|
+
if (e1.points.length >= 2) {
|
|
366
|
+
const correction_amt = Vec2.scale(perp1, correction_angle * 5);
|
|
367
|
+
correction[e1Id][1] = correction_amt;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!correction[e2Id]) correction[e2Id] = {};
|
|
371
|
+
if (e2.points.length >= 2) {
|
|
372
|
+
const perp2 = Vec2.perp(dir2);
|
|
373
|
+
const correction_amt = Vec2.scale(perp2, -correction_angle * 5);
|
|
374
|
+
correction[e2Id][1] = correction_amt;
|
|
375
|
+
}
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
case 'perpendicular': {
|
|
380
|
+
// Two lines should be perpendicular (dot product ~0)
|
|
381
|
+
if (!e2) break;
|
|
382
|
+
const dir1 = getLineDirection(e1);
|
|
383
|
+
const dir2 = getLineDirection(e2);
|
|
384
|
+
const dot = Vec2.dot(dir1, dir2);
|
|
385
|
+
error = Math.abs(dot);
|
|
386
|
+
|
|
387
|
+
// Rotate line 2 to be perpendicular to line 1
|
|
388
|
+
const perp1 = Vec2.perp(dir1);
|
|
389
|
+
const angle = Math.acos(Math.max(-1, Math.min(1, Math.abs(Vec2.dot(dir2, perp1)))));
|
|
390
|
+
const correction_angle = (Math.PI / 2 - angle) * 0.25;
|
|
391
|
+
|
|
392
|
+
if (!correction[e2Id]) correction[e2Id] = {};
|
|
393
|
+
if (e2.points.length >= 2) {
|
|
394
|
+
const p1 = e2.points[0];
|
|
395
|
+
const p2 = e2.points[1];
|
|
396
|
+
const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
|
397
|
+
const len = Vec2.distance(p1, p2);
|
|
398
|
+
const newDir = { x: Math.cos(Math.atan2(dir2.y, dir2.x) + correction_angle), y: Math.sin(Math.atan2(dir2.y, dir2.x) + correction_angle) };
|
|
399
|
+
const newP1 = Vec2.add(mid, Vec2.scale(newDir, -len / 2));
|
|
400
|
+
const newP2 = Vec2.add(mid, Vec2.scale(newDir, len / 2));
|
|
401
|
+
correction[e2Id][0] = Vec2.sub(newP1, p1);
|
|
402
|
+
correction[e2Id][1] = Vec2.sub(newP2, p2);
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
case 'tangent': {
|
|
408
|
+
// Line is tangent to circle/arc
|
|
409
|
+
if (!e2) break;
|
|
410
|
+
const isE1Circle = e1.type === 'circle' || e1.type === 'arc';
|
|
411
|
+
const isE2Circle = e2.type === 'circle' || e2.type === 'arc';
|
|
412
|
+
|
|
413
|
+
if (isE1Circle && !isE2Circle) {
|
|
414
|
+
// e1 is circle, e2 is line
|
|
415
|
+
const center = e1.points[0];
|
|
416
|
+
const radius = getRadius(e1);
|
|
417
|
+
const dist = Vec2.distanceToLine(center, e2.points[0], e2.points[1]);
|
|
418
|
+
error = Math.abs(dist - radius);
|
|
419
|
+
|
|
420
|
+
// Move line to be tangent
|
|
421
|
+
const { point: closest } = Vec2.closestPointOnLine(center, e2.points[0], e2.points[1]);
|
|
422
|
+
const dir = Vec2.normalize(Vec2.sub(closest, center));
|
|
423
|
+
const targetPoint = Vec2.add(center, Vec2.scale(dir, radius));
|
|
424
|
+
const delta = Vec2.sub(targetPoint, closest);
|
|
425
|
+
const correction_amt = Vec2.scale(delta, 0.25);
|
|
426
|
+
|
|
427
|
+
if (!correction[e2Id]) correction[e2Id] = {};
|
|
428
|
+
correction[e2Id][0] = correction_amt;
|
|
429
|
+
correction[e2Id][1] = correction_amt;
|
|
430
|
+
} else if (!isE1Circle && isE2Circle) {
|
|
431
|
+
// e1 is line, e2 is circle
|
|
432
|
+
const center = e2.points[0];
|
|
433
|
+
const radius = getRadius(e2);
|
|
434
|
+
const dist = Vec2.distanceToLine(center, e1.points[0], e1.points[1]);
|
|
435
|
+
error = Math.abs(dist - radius);
|
|
436
|
+
|
|
437
|
+
const { point: closest } = Vec2.closestPointOnLine(center, e1.points[0], e1.points[1]);
|
|
438
|
+
const dir = Vec2.normalize(Vec2.sub(closest, center));
|
|
439
|
+
const targetPoint = Vec2.add(center, Vec2.scale(dir, radius));
|
|
440
|
+
const delta = Vec2.sub(targetPoint, closest);
|
|
441
|
+
const correction_amt = Vec2.scale(delta, 0.25);
|
|
442
|
+
|
|
443
|
+
if (!correction[e1Id]) correction[e1Id] = {};
|
|
444
|
+
correction[e1Id][0] = correction_amt;
|
|
445
|
+
correction[e1Id][1] = correction_amt;
|
|
446
|
+
}
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
case 'equal': {
|
|
451
|
+
// Two entities have equal length/radius
|
|
452
|
+
if (!e2) break;
|
|
453
|
+
const len1 = e1.type === 'circle' || e1.type === 'arc' ? getRadius(e1) : getLineLength(e1);
|
|
454
|
+
const len2 = e2.type === 'circle' || e2.type === 'arc' ? getRadius(e2) : getLineLength(e2);
|
|
455
|
+
error = Math.abs(len1 - len2);
|
|
456
|
+
|
|
457
|
+
// Scale e2 to match e1
|
|
458
|
+
const scale = len1 / (len2 + 1e-10);
|
|
459
|
+
if (e2.type === 'circle' || e2.type === 'arc') {
|
|
460
|
+
const center = e2.points[0];
|
|
461
|
+
if (e2.points.length >= 2) {
|
|
462
|
+
const oldRadius = Vec2.distance(center, e2.points[1]);
|
|
463
|
+
const newRadius = oldRadius * scale;
|
|
464
|
+
const dir = Vec2.normalize(Vec2.sub(e2.points[1], center));
|
|
465
|
+
const newP = Vec2.add(center, Vec2.scale(dir, newRadius));
|
|
466
|
+
if (!correction[e2Id]) correction[e2Id] = {};
|
|
467
|
+
correction[e2Id][1] = Vec2.sub(newP, e2.points[1]);
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
if (e2.points.length >= 2) {
|
|
471
|
+
const mid = { x: (e2.points[0].x + e2.points[1].x) / 2, y: (e2.points[0].y + e2.points[1].y) / 2 };
|
|
472
|
+
const dir = getLineDirection(e2);
|
|
473
|
+
const halfLen = len1 / 2;
|
|
474
|
+
const p1 = Vec2.add(mid, Vec2.scale(dir, -halfLen));
|
|
475
|
+
const p2 = Vec2.add(mid, Vec2.scale(dir, halfLen));
|
|
476
|
+
if (!correction[e2Id]) correction[e2Id] = {};
|
|
477
|
+
correction[e2Id][0] = Vec2.sub(p1, e2.points[0]);
|
|
478
|
+
correction[e2Id][1] = Vec2.sub(p2, e2.points[1]);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
case 'fixed': {
|
|
485
|
+
// Point is locked at specific position
|
|
486
|
+
if (!constraint.value || !constraint.value.x !== undefined) break;
|
|
487
|
+
const p = getEntityPoint(e1, 0);
|
|
488
|
+
const target = constraint.value;
|
|
489
|
+
const delta = Vec2.sub(target, p);
|
|
490
|
+
error = Vec2.length(delta);
|
|
491
|
+
|
|
492
|
+
if (!correction[e1Id]) correction[e1Id] = {};
|
|
493
|
+
correction[e1Id][0] = delta;
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
case 'concentric': {
|
|
498
|
+
// Two circles/arcs share center
|
|
499
|
+
if (!e2) break;
|
|
500
|
+
const c1 = getEntityPoint(e1, -1);
|
|
501
|
+
const c2 = getEntityPoint(e2, -1);
|
|
502
|
+
const delta = Vec2.sub(c2, c1);
|
|
503
|
+
error = Vec2.length(delta);
|
|
504
|
+
|
|
505
|
+
if (!correction[e1Id]) correction[e1Id] = {};
|
|
506
|
+
correction[e1Id][0] = Vec2.scale(delta, 0.5);
|
|
507
|
+
|
|
508
|
+
if (!correction[e2Id]) correction[e2Id] = {};
|
|
509
|
+
correction[e2Id][0] = Vec2.scale(delta, -0.5);
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
case 'symmetric': {
|
|
514
|
+
// Two points are symmetric about a line (axis)
|
|
515
|
+
if (!e2) break;
|
|
516
|
+
// constraint.value should be {axisPt1, axisPt2} or similar
|
|
517
|
+
// For now, assume symmetric about origin
|
|
518
|
+
const p1 = getEntityPoint(e1, 0);
|
|
519
|
+
const p2 = getEntityPoint(e2, 0);
|
|
520
|
+
const sym_p1 = { x: -p1.x, y: -p1.y };
|
|
521
|
+
const delta = Vec2.sub(sym_p1, p2);
|
|
522
|
+
error = Vec2.length(delta);
|
|
523
|
+
|
|
524
|
+
if (!correction[e2Id]) correction[e2Id] = {};
|
|
525
|
+
correction[e2Id][0] = Vec2.scale(delta, 0.5);
|
|
526
|
+
|
|
527
|
+
if (!correction[e1Id]) correction[e1Id] = {};
|
|
528
|
+
correction[e1Id][0] = Vec2.scale(delta, -0.5);
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
case 'distance': {
|
|
533
|
+
// Two points at specified distance
|
|
534
|
+
if (!e2 || !constraint.value) break;
|
|
535
|
+
const p1 = getEntityPoint(e1, 0);
|
|
536
|
+
const p2 = getEntityPoint(e2, 0);
|
|
537
|
+
const actual = Vec2.distance(p1, p2);
|
|
538
|
+
const target = constraint.value;
|
|
539
|
+
error = Math.abs(actual - target);
|
|
540
|
+
|
|
541
|
+
const delta = Vec2.sub(p2, p1);
|
|
542
|
+
const scale = (target / (actual + 1e-10)) - 1;
|
|
543
|
+
const correction_amt = Vec2.scale(delta, scale * 0.25);
|
|
544
|
+
|
|
545
|
+
if (!correction[e1Id]) correction[e1Id] = {};
|
|
546
|
+
correction[e1Id][0] = Vec2.scale(correction_amt, -1);
|
|
547
|
+
|
|
548
|
+
if (!correction[e2Id]) correction[e2Id] = {};
|
|
549
|
+
correction[e2Id][0] = correction_amt;
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
case 'angle': {
|
|
554
|
+
// Two lines at specified angle
|
|
555
|
+
if (!e2 || constraint.value === undefined) break;
|
|
556
|
+
const dir1 = getLineDirection(e1);
|
|
557
|
+
const dir2 = getLineDirection(e2);
|
|
558
|
+
const angle1 = Vec2.angle(dir1);
|
|
559
|
+
const angle2 = Vec2.angle(dir2);
|
|
560
|
+
let actualAngle = angle2 - angle1;
|
|
561
|
+
while (actualAngle > Math.PI) actualAngle -= 2 * Math.PI;
|
|
562
|
+
while (actualAngle < -Math.PI) actualAngle += 2 * Math.PI;
|
|
563
|
+
|
|
564
|
+
const targetAngle = constraint.value * Math.PI / 180; // Convert deg to rad
|
|
565
|
+
let angleDelta = targetAngle - actualAngle;
|
|
566
|
+
while (angleDelta > Math.PI) angleDelta -= 2 * Math.PI;
|
|
567
|
+
while (angleDelta < -Math.PI) angleDelta += 2 * Math.PI;
|
|
568
|
+
|
|
569
|
+
error = Math.abs(angleDelta);
|
|
570
|
+
|
|
571
|
+
// Rotate e2
|
|
572
|
+
if (e2.points.length >= 2) {
|
|
573
|
+
const correction_angle = angleDelta * 0.125;
|
|
574
|
+
const p1 = e2.points[0];
|
|
575
|
+
const p2 = e2.points[1];
|
|
576
|
+
const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
|
577
|
+
const len = Vec2.distance(p1, p2);
|
|
578
|
+
const newAngle = angle2 + correction_angle;
|
|
579
|
+
const newDir = { x: Math.cos(newAngle), y: Math.sin(newAngle) };
|
|
580
|
+
const newP1 = Vec2.add(mid, Vec2.scale(newDir, -len / 2));
|
|
581
|
+
const newP2 = Vec2.add(mid, Vec2.scale(newDir, len / 2));
|
|
582
|
+
|
|
583
|
+
if (!correction[e2Id]) correction[e2Id] = {};
|
|
584
|
+
correction[e2Id][0] = Vec2.sub(newP1, p1);
|
|
585
|
+
correction[e2Id][1] = Vec2.sub(newP2, p2);
|
|
586
|
+
}
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
default:
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return { error, correction };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ============================================================================
|
|
598
|
+
// MAIN SOLVER
|
|
599
|
+
// ============================================================================
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Solve all active constraints using iterative relaxation
|
|
603
|
+
*
|
|
604
|
+
* @param {Object} sketchState - {entities: Map<id, entity>, constraints: Constraint[]}
|
|
605
|
+
* @param {Object} options - {maxIterations: 100, tolerance: 0.001, damping: 0.5}
|
|
606
|
+
* @returns {Object} {converged: boolean, totalError: number, iterations: number}
|
|
607
|
+
*/
|
|
608
|
+
export function solveConstraints(sketchState, options = {}) {
|
|
609
|
+
const { maxIterations = 100, tolerance = 0.001, damping = 0.5 } = options;
|
|
610
|
+
|
|
611
|
+
const { entities, constraints } = sketchState;
|
|
612
|
+
if (!entities || !constraints) return { converged: false, totalError: 0, iterations: 0 };
|
|
613
|
+
|
|
614
|
+
let converged = false;
|
|
615
|
+
let iteration = 0;
|
|
616
|
+
let totalError = 0;
|
|
617
|
+
|
|
618
|
+
for (iteration = 0; iteration < maxIterations; iteration++) {
|
|
619
|
+
totalError = 0;
|
|
620
|
+
const allCorrections = {}; // id -> accumulated corrections
|
|
621
|
+
|
|
622
|
+
// Compute errors and corrections for all active constraints
|
|
623
|
+
constraints.forEach(constraint => {
|
|
624
|
+
if (!constraint.enabled) return;
|
|
625
|
+
|
|
626
|
+
const { error, correction } = computeConstraintError(entities, constraint);
|
|
627
|
+
const priority = constraint.priority || 1;
|
|
628
|
+
totalError += error * error * priority; // Weighted error
|
|
629
|
+
|
|
630
|
+
// Accumulate corrections
|
|
631
|
+
Object.keys(correction).forEach(entityId => {
|
|
632
|
+
if (!allCorrections[entityId]) allCorrections[entityId] = {};
|
|
633
|
+
const pointCorr = correction[entityId];
|
|
634
|
+
Object.keys(pointCorr).forEach(pointIdx => {
|
|
635
|
+
if (!allCorrections[entityId][pointIdx]) {
|
|
636
|
+
allCorrections[entityId][pointIdx] = { x: 0, y: 0 };
|
|
637
|
+
}
|
|
638
|
+
allCorrections[entityId][pointIdx].x += pointCorr[pointIdx].x * damping * priority;
|
|
639
|
+
allCorrections[entityId][pointIdx].y += pointCorr[pointIdx].y * damping * priority;
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
totalError = Math.sqrt(totalError / Math.max(1, constraints.filter(c => c.enabled).length));
|
|
645
|
+
|
|
646
|
+
// Apply corrections to entities
|
|
647
|
+
Object.keys(allCorrections).forEach(entityId => {
|
|
648
|
+
const entity = entities.get(parseInt(entityId));
|
|
649
|
+
if (!entity || !entity.points) return;
|
|
650
|
+
|
|
651
|
+
const pointCorr = allCorrections[entityId];
|
|
652
|
+
Object.keys(pointCorr).forEach(pointIdx => {
|
|
653
|
+
const idx = parseInt(pointIdx);
|
|
654
|
+
if (idx >= 0 && idx < entity.points.length) {
|
|
655
|
+
entity.points[idx].x += pointCorr[idx].x;
|
|
656
|
+
entity.points[idx].y += pointCorr[idx].y;
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
if (totalError < tolerance) {
|
|
662
|
+
converged = true;
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return { converged, totalError, iterations: iteration + 1 };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Add a new constraint
|
|
672
|
+
*
|
|
673
|
+
* @param {string} type - Constraint type
|
|
674
|
+
* @param {number[]} entityIds - Entity IDs involved
|
|
675
|
+
* @param {number|Object} [value] - Constraint value (distance, angle, position, etc.)
|
|
676
|
+
* @param {number} [priority=1] - Priority weight
|
|
677
|
+
* @returns {Constraint}
|
|
678
|
+
*/
|
|
679
|
+
export function addConstraint(type, entityIds, value = null, priority = 1) {
|
|
680
|
+
const constraint = {
|
|
681
|
+
id: nextConstraintId(),
|
|
682
|
+
type,
|
|
683
|
+
entities: entityIds,
|
|
684
|
+
value,
|
|
685
|
+
priority,
|
|
686
|
+
enabled: true,
|
|
687
|
+
};
|
|
688
|
+
constraintStore.set(constraint.id, constraint);
|
|
689
|
+
return constraint;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Remove constraint by ID
|
|
694
|
+
*
|
|
695
|
+
* @param {number} constraintId
|
|
696
|
+
* @returns {boolean}
|
|
697
|
+
*/
|
|
698
|
+
export function removeConstraint(constraintId) {
|
|
699
|
+
return constraintStore.delete(constraintId);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Toggle constraint enabled state
|
|
704
|
+
*
|
|
705
|
+
* @param {number} constraintId
|
|
706
|
+
* @param {boolean} enabled
|
|
707
|
+
*/
|
|
708
|
+
export function setConstraintEnabled(constraintId, enabled) {
|
|
709
|
+
const constraint = constraintStore.get(constraintId);
|
|
710
|
+
if (constraint) {
|
|
711
|
+
constraint.enabled = enabled;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Get all constraints from store
|
|
717
|
+
*
|
|
718
|
+
* @returns {Constraint[]}
|
|
719
|
+
*/
|
|
720
|
+
export function getAllConstraints() {
|
|
721
|
+
return Array.from(constraintStore.values());
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Get constraint by ID
|
|
726
|
+
*
|
|
727
|
+
* @param {number} constraintId
|
|
728
|
+
* @returns {Constraint|undefined}
|
|
729
|
+
*/
|
|
730
|
+
export function getConstraint(constraintId) {
|
|
731
|
+
return constraintStore.get(constraintId);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Compute total error across all constraints
|
|
736
|
+
*
|
|
737
|
+
* @param {Object} sketchState - {entities: Map<id, entity>, constraints: Constraint[]}
|
|
738
|
+
* @returns {Object} {totalError: number, errorsByConstraint: {[constraintId]: error}}
|
|
739
|
+
*/
|
|
740
|
+
export function getConstraintErrors(sketchState) {
|
|
741
|
+
const { entities, constraints } = sketchState;
|
|
742
|
+
if (!entities || !constraints) return { totalError: 0, errorsByConstraint: {} };
|
|
743
|
+
|
|
744
|
+
let totalError = 0;
|
|
745
|
+
const errorsByConstraint = {};
|
|
746
|
+
|
|
747
|
+
constraints.forEach(constraint => {
|
|
748
|
+
if (!constraint.enabled) return;
|
|
749
|
+
|
|
750
|
+
const { error } = computeConstraintError(entities, constraint);
|
|
751
|
+
errorsByConstraint[constraint.id] = error;
|
|
752
|
+
totalError += error * error;
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
totalError = Math.sqrt(totalError / Math.max(1, constraints.filter(c => c.enabled).length));
|
|
756
|
+
|
|
757
|
+
return { totalError, errorsByConstraint };
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ============================================================================
|
|
761
|
+
// AUTO-DETECTION & ANALYSIS
|
|
762
|
+
// ============================================================================
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Automatically detect and suggest constraints for entities
|
|
766
|
+
* Looks for nearly-aligned, nearly-coincident, or nearly-tangent geometry
|
|
767
|
+
*
|
|
768
|
+
* @param {Map} entities - id -> entity
|
|
769
|
+
* @param {Object} options - {coincidentTol: 0.5, horizontalTol: 3, parallelTol: 5, tangentTol: 0.5}
|
|
770
|
+
* @returns {Object} {suggestions: Constraint[], appliedConstraints: number}
|
|
771
|
+
*/
|
|
772
|
+
export function autoDetectConstraints(entities, options = {}) {
|
|
773
|
+
const {
|
|
774
|
+
coincidentTol = 0.5, // mm
|
|
775
|
+
horizontalTol = 3, // degrees
|
|
776
|
+
parallelTol = 5, // degrees
|
|
777
|
+
tangentTol = 0.5, // mm
|
|
778
|
+
} = options;
|
|
779
|
+
|
|
780
|
+
const suggestions = [];
|
|
781
|
+
const entityArray = Array.from(entities.values());
|
|
782
|
+
|
|
783
|
+
// Coincident: two endpoints very close
|
|
784
|
+
for (let i = 0; i < entityArray.length; i++) {
|
|
785
|
+
for (let j = i + 1; j < entityArray.length; j++) {
|
|
786
|
+
const e1 = entityArray[i];
|
|
787
|
+
const e2 = entityArray[j];
|
|
788
|
+
|
|
789
|
+
const p1 = getEntityPoint(e1, 0);
|
|
790
|
+
const p2 = getEntityPoint(e2, 0);
|
|
791
|
+
const dist = Vec2.distance(p1, p2);
|
|
792
|
+
if (dist < coincidentTol && dist > 1e-6) {
|
|
793
|
+
suggestions.push({
|
|
794
|
+
type: 'coincident',
|
|
795
|
+
entities: [e1.id, e2.id],
|
|
796
|
+
reason: `Points within ${dist.toFixed(2)}mm`,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Last point to first point
|
|
801
|
+
const p1Last = getEntityPoint(e1, 1);
|
|
802
|
+
const p2First = getEntityPoint(e2, 0);
|
|
803
|
+
const distLastFirst = Vec2.distance(p1Last, p2First);
|
|
804
|
+
if (distLastFirst < coincidentTol && distLastFirst > 1e-6) {
|
|
805
|
+
suggestions.push({
|
|
806
|
+
type: 'coincident',
|
|
807
|
+
entities: [e1.id, e2.id],
|
|
808
|
+
reason: `Points within ${distLastFirst.toFixed(2)}mm`,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Horizontal/Vertical: lines nearly axis-aligned
|
|
815
|
+
entityArray.forEach(e => {
|
|
816
|
+
if (e.type === 'line' || (e.type === 'polyline' && e.points.length >= 2)) {
|
|
817
|
+
const dir = getLineDirection(e);
|
|
818
|
+
const angle = Vec2.angle(dir);
|
|
819
|
+
const angleDeg = angle * 180 / Math.PI;
|
|
820
|
+
|
|
821
|
+
// Horizontal?
|
|
822
|
+
if (Math.abs(angleDeg) < horizontalTol || Math.abs(angleDeg - 180) < horizontalTol) {
|
|
823
|
+
suggestions.push({
|
|
824
|
+
type: 'horizontal',
|
|
825
|
+
entities: [e.id],
|
|
826
|
+
reason: `Line within ${Math.abs(angleDeg).toFixed(1)}° of horizontal`,
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Vertical?
|
|
831
|
+
if (Math.abs(angleDeg - 90) < horizontalTol || Math.abs(angleDeg + 90) < horizontalTol) {
|
|
832
|
+
suggestions.push({
|
|
833
|
+
type: 'vertical',
|
|
834
|
+
entities: [e.id],
|
|
835
|
+
reason: `Line within ${(Math.abs(angleDeg) - 90).toFixed(1)}° of vertical`,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// Parallel: two lines nearly parallel
|
|
842
|
+
for (let i = 0; i < entityArray.length; i++) {
|
|
843
|
+
for (let j = i + 1; j < entityArray.length; j++) {
|
|
844
|
+
const e1 = entityArray[i];
|
|
845
|
+
const e2 = entityArray[j];
|
|
846
|
+
|
|
847
|
+
if ((e1.type === 'line' || e1.type === 'polyline') &&
|
|
848
|
+
(e2.type === 'line' || e2.type === 'polyline')) {
|
|
849
|
+
const dir1 = getLineDirection(e1);
|
|
850
|
+
const dir2 = getLineDirection(e2);
|
|
851
|
+
const angle1 = Vec2.angle(dir1);
|
|
852
|
+
const angle2 = Vec2.angle(dir2);
|
|
853
|
+
let angleDelta = angle2 - angle1;
|
|
854
|
+
while (angleDelta > Math.PI / 2) angleDelta -= Math.PI;
|
|
855
|
+
const angleDeltaDeg = Math.abs(angleDelta) * 180 / Math.PI;
|
|
856
|
+
|
|
857
|
+
if (angleDeltaDeg < parallelTol) {
|
|
858
|
+
suggestions.push({
|
|
859
|
+
type: 'parallel',
|
|
860
|
+
entities: [e1.id, e2.id],
|
|
861
|
+
reason: `Lines within ${angleDeltaDeg.toFixed(1)}° of parallel`,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Perpendicular: two lines nearly perpendicular
|
|
869
|
+
for (let i = 0; i < entityArray.length; i++) {
|
|
870
|
+
for (let j = i + 1; j < entityArray.length; j++) {
|
|
871
|
+
const e1 = entityArray[i];
|
|
872
|
+
const e2 = entityArray[j];
|
|
873
|
+
|
|
874
|
+
if ((e1.type === 'line' || e1.type === 'polyline') &&
|
|
875
|
+
(e2.type === 'line' || e2.type === 'polyline')) {
|
|
876
|
+
const dir1 = getLineDirection(e1);
|
|
877
|
+
const dir2 = getLineDirection(e2);
|
|
878
|
+
const dot = Vec2.dot(dir1, dir2);
|
|
879
|
+
const angle = Math.acos(Math.max(-1, Math.min(1, Math.abs(dot))));
|
|
880
|
+
const angleDeg = Math.abs(angle - Math.PI / 2) * 180 / Math.PI;
|
|
881
|
+
|
|
882
|
+
if (angleDeg < parallelTol) {
|
|
883
|
+
suggestions.push({
|
|
884
|
+
type: 'perpendicular',
|
|
885
|
+
entities: [e1.id, e2.id],
|
|
886
|
+
reason: `Lines within ${angleDeg.toFixed(1)}° of perpendicular`,
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Tangent: line tangent to circle
|
|
894
|
+
for (let i = 0; i < entityArray.length; i++) {
|
|
895
|
+
for (let j = 0; j < entityArray.length; j++) {
|
|
896
|
+
if (i === j) continue;
|
|
897
|
+
const e1 = entityArray[i];
|
|
898
|
+
const e2 = entityArray[j];
|
|
899
|
+
|
|
900
|
+
const isE1Circle = e1.type === 'circle' || e1.type === 'arc';
|
|
901
|
+
const isE2Line = e2.type === 'line' || e2.type === 'polyline';
|
|
902
|
+
|
|
903
|
+
if (isE1Circle && isE2Line) {
|
|
904
|
+
const center = e1.points[0];
|
|
905
|
+
const radius = getRadius(e1);
|
|
906
|
+
const dist = Vec2.distanceToLine(center, e2.points[0], e2.points[1]);
|
|
907
|
+
const error = Math.abs(dist - radius);
|
|
908
|
+
|
|
909
|
+
if (error < tangentTol) {
|
|
910
|
+
suggestions.push({
|
|
911
|
+
type: 'tangent',
|
|
912
|
+
entities: [e1.id, e2.id],
|
|
913
|
+
reason: `Line tangent to circle (error ${error.toFixed(2)}mm)`,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Concentric: two circles close centers
|
|
921
|
+
for (let i = 0; i < entityArray.length; i++) {
|
|
922
|
+
for (let j = i + 1; j < entityArray.length; j++) {
|
|
923
|
+
const e1 = entityArray[i];
|
|
924
|
+
const e2 = entityArray[j];
|
|
925
|
+
|
|
926
|
+
if ((e1.type === 'circle' || e1.type === 'arc') &&
|
|
927
|
+
(e2.type === 'circle' || e2.type === 'arc')) {
|
|
928
|
+
const c1 = getEntityPoint(e1, -1);
|
|
929
|
+
const c2 = getEntityPoint(e2, -1);
|
|
930
|
+
const dist = Vec2.distance(c1, c2);
|
|
931
|
+
|
|
932
|
+
if (dist < coincidentTol) {
|
|
933
|
+
suggestions.push({
|
|
934
|
+
type: 'concentric',
|
|
935
|
+
entities: [e1.id, e2.id],
|
|
936
|
+
reason: `Circle centers within ${dist.toFixed(2)}mm`,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return { suggestions };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Check if sketch is fully constrained
|
|
948
|
+
* A fully constrained sketch has 0 degrees of freedom (DOF)
|
|
949
|
+
*
|
|
950
|
+
* For a 2D sketch:
|
|
951
|
+
* - Each point has 2 DOF (x, y)
|
|
952
|
+
* - Each constraint typically removes 1 DOF
|
|
953
|
+
* - Horizontal/Vertical/Distance/Angle remove 1
|
|
954
|
+
* - Coincident removes 2 (both x and y)
|
|
955
|
+
*
|
|
956
|
+
* @param {Object} sketchState - {entities: Map<id, entity>, constraints: Constraint[]}
|
|
957
|
+
* @returns {Object} {isFullyConstrained: boolean, degreesOfFreedom: number}
|
|
958
|
+
*/
|
|
959
|
+
export function isFullyConstrained(sketchState) {
|
|
960
|
+
const { entities, constraints } = sketchState;
|
|
961
|
+
if (!entities || !constraints) return { isFullyConstrained: false, degreesOfFreedom: 0 };
|
|
962
|
+
|
|
963
|
+
const entityArray = Array.from(entities.values());
|
|
964
|
+
let totalPoints = 0;
|
|
965
|
+
|
|
966
|
+
// Count unique points (some entities share endpoints)
|
|
967
|
+
const uniquePoints = new Set();
|
|
968
|
+
entityArray.forEach(e => {
|
|
969
|
+
if (e.points) {
|
|
970
|
+
e.points.forEach((p, idx) => {
|
|
971
|
+
uniquePoints.add(`${e.id}:${idx}`);
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
totalPoints = uniquePoints.size;
|
|
977
|
+
let dof = totalPoints * 2; // Each point has 2 DOF
|
|
978
|
+
|
|
979
|
+
// Subtract constraints
|
|
980
|
+
const enabledConstraints = constraints.filter(c => c.enabled);
|
|
981
|
+
enabledConstraints.forEach(c => {
|
|
982
|
+
switch (c.type) {
|
|
983
|
+
case 'coincident':
|
|
984
|
+
case 'concentric':
|
|
985
|
+
dof -= 2;
|
|
986
|
+
break;
|
|
987
|
+
case 'horizontal':
|
|
988
|
+
case 'vertical':
|
|
989
|
+
case 'distance':
|
|
990
|
+
case 'angle':
|
|
991
|
+
case 'fixed':
|
|
992
|
+
case 'equal':
|
|
993
|
+
case 'parallel':
|
|
994
|
+
case 'perpendicular':
|
|
995
|
+
case 'tangent':
|
|
996
|
+
case 'symmetric':
|
|
997
|
+
dof -= 1;
|
|
998
|
+
break;
|
|
999
|
+
default:
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// Fully constrained if DOF <= 0 (over-constrained if < 0, which indicates conflict)
|
|
1005
|
+
return {
|
|
1006
|
+
isFullyConstrained: dof <= 0,
|
|
1007
|
+
degreesOfFreedom: Math.max(0, dof),
|
|
1008
|
+
overConstrained: dof < 0,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Clear all constraints
|
|
1014
|
+
*/
|
|
1015
|
+
export function clearAllConstraints() {
|
|
1016
|
+
constraintStore.clear();
|
|
1017
|
+
constraintIdCounter = 1000;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Export all constraints to JSON
|
|
1022
|
+
*
|
|
1023
|
+
* @returns {string}
|
|
1024
|
+
*/
|
|
1025
|
+
export function exportConstraints() {
|
|
1026
|
+
const constraints = Array.from(constraintStore.values());
|
|
1027
|
+
return JSON.stringify(constraints, null, 2);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Import constraints from JSON
|
|
1032
|
+
*
|
|
1033
|
+
* @param {string} json
|
|
1034
|
+
*/
|
|
1035
|
+
export function importConstraints(json) {
|
|
1036
|
+
try {
|
|
1037
|
+
const constraints = JSON.parse(json);
|
|
1038
|
+
clearAllConstraints();
|
|
1039
|
+
constraints.forEach(c => {
|
|
1040
|
+
constraintStore.set(c.id, c);
|
|
1041
|
+
constraintIdCounter = Math.max(constraintIdCounter, c.id + 1);
|
|
1042
|
+
});
|
|
1043
|
+
} catch (e) {
|
|
1044
|
+
console.error('Failed to import constraints:', e);
|
|
1045
|
+
}
|
|
1046
|
+
}
|