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.
@@ -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
+ }