@pooder/kit 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.test-dist/src/CanvasService.js +83 -0
  2. package/.test-dist/src/ViewportSystem.js +75 -0
  3. package/.test-dist/src/background.js +203 -0
  4. package/.test-dist/src/constraints.js +153 -0
  5. package/.test-dist/src/coordinate.js +74 -0
  6. package/.test-dist/src/dieline.js +758 -0
  7. package/.test-dist/src/feature.js +687 -0
  8. package/.test-dist/src/featureComplete.js +31 -0
  9. package/.test-dist/src/featureDraft.js +31 -0
  10. package/.test-dist/src/film.js +167 -0
  11. package/.test-dist/src/geometry.js +292 -0
  12. package/.test-dist/src/image.js +421 -0
  13. package/.test-dist/src/index.js +31 -0
  14. package/.test-dist/src/mirror.js +104 -0
  15. package/.test-dist/src/ruler.js +383 -0
  16. package/.test-dist/src/tracer.js +448 -0
  17. package/.test-dist/src/units.js +30 -0
  18. package/.test-dist/src/white-ink.js +310 -0
  19. package/.test-dist/tests/run.js +60 -0
  20. package/CHANGELOG.md +12 -0
  21. package/dist/index.d.mts +54 -5
  22. package/dist/index.d.ts +54 -5
  23. package/dist/index.js +584 -190
  24. package/dist/index.mjs +581 -189
  25. package/package.json +3 -2
  26. package/src/CanvasService.ts +7 -0
  27. package/src/ViewportSystem.ts +92 -0
  28. package/src/background.ts +230 -230
  29. package/src/constraints.ts +207 -0
  30. package/src/coordinate.ts +106 -106
  31. package/src/dieline.ts +194 -75
  32. package/src/feature.ts +239 -147
  33. package/src/featureComplete.ts +45 -0
  34. package/src/film.ts +194 -194
  35. package/src/geometry.ts +4 -0
  36. package/src/image.ts +512 -512
  37. package/src/index.ts +1 -0
  38. package/src/mirror.ts +128 -128
  39. package/src/ruler.ts +508 -500
  40. package/src/tracer.ts +570 -570
  41. package/src/units.ts +27 -0
  42. package/src/white-ink.ts +373 -373
  43. package/tests/run.ts +81 -0
  44. package/tsconfig.test.json +15 -0
package/src/feature.ts CHANGED
@@ -3,7 +3,6 @@ import {
3
3
  ExtensionContext,
4
4
  ContributionPointIds,
5
5
  CommandContribution,
6
- ConfigurationContribution,
7
6
  ConfigurationService,
8
7
  } from "@pooder/core";
9
8
  import { Circle, Group, Point, Rect } from "fabric";
@@ -14,7 +13,11 @@ import {
14
13
  DielineFeature,
15
14
  resolveFeaturePosition,
16
15
  } from "./geometry";
17
- import { Coordinate } from "./coordinate";
16
+ import { ConstraintRegistry } from "./constraints";
17
+ import {
18
+ completeFeaturesStrict,
19
+ } from "./featureComplete";
20
+ import { parseLengthToMm } from "./units";
18
21
 
19
22
  export class FeatureTool implements Extension {
20
23
  id = "pooder.kit.feature";
@@ -23,7 +26,7 @@ export class FeatureTool implements Extension {
23
26
  name: "FeatureTool",
24
27
  };
25
28
 
26
- private features: DielineFeature[] = [];
29
+ private workingFeatures: DielineFeature[] = [];
27
30
  private canvasService?: CanvasService;
28
31
  private context?: ExtensionContext;
29
32
  private isUpdatingConfig = false;
@@ -59,14 +62,18 @@ export class FeatureTool implements Extension {
59
62
  "ConfigurationService",
60
63
  );
61
64
  if (configService) {
62
- this.features = configService.get("dieline.features", []);
65
+ const features = (configService.get("dieline.features", []) ||
66
+ []) as DielineFeature[];
67
+ this.workingFeatures = this.cloneFeatures(features);
63
68
 
64
69
  configService.onAnyChange((e: { key: string; value: any }) => {
65
70
  if (this.isUpdatingConfig) return;
66
71
 
67
72
  if (e.key === "dieline.features") {
68
- this.features = e.value || [];
73
+ const next = (e.value || []) as DielineFeature[];
74
+ this.workingFeatures = this.cloneFeatures(next);
69
75
  this.redraw();
76
+ this.emitWorkingChange();
70
77
  }
71
78
  });
72
79
  }
@@ -137,28 +144,175 @@ export class FeatureTool implements Extension {
137
144
  command: "clearFeatures",
138
145
  title: "Clear Features",
139
146
  handler: () => {
140
- const configService =
141
- this.context?.services.get<ConfigurationService>(
142
- "ConfigurationService",
143
- );
144
- if (configService) {
145
- configService.update("dieline.features", []);
146
- }
147
+ this.setWorkingFeatures([]);
148
+ this.redraw();
149
+ this.emitWorkingChange();
147
150
  return true;
148
151
  },
149
152
  },
153
+ {
154
+ command: "getWorkingFeatures",
155
+ title: "Get Working Features",
156
+ handler: () => {
157
+ return this.cloneFeatures(this.workingFeatures);
158
+ },
159
+ },
160
+ {
161
+ command: "setWorkingFeatures",
162
+ title: "Set Working Features",
163
+ handler: async (features: DielineFeature[]) => {
164
+ await this.refreshGeometry();
165
+ this.setWorkingFeatures(this.cloneFeatures(features || []));
166
+ this.redraw();
167
+ this.emitWorkingChange();
168
+ return { ok: true };
169
+ },
170
+ },
171
+ {
172
+ command: "updateWorkingGroupPosition",
173
+ title: "Update Working Group Position",
174
+ handler: (groupId: string, x: number, y: number) => {
175
+ return this.updateWorkingGroupPosition(groupId, x, y);
176
+ },
177
+ },
178
+ {
179
+ command: "completeFeatures",
180
+ title: "Complete Features",
181
+ handler: () => {
182
+ return this.completeFeatures();
183
+ },
184
+ },
150
185
  ] as CommandContribution[],
151
186
  };
152
187
  }
153
188
 
154
- private addFeature(type: "add" | "subtract") {
155
- if (!this.canvasService) return false;
189
+ private cloneFeatures(features: DielineFeature[]): DielineFeature[] {
190
+ return JSON.parse(JSON.stringify(features || [])) as DielineFeature[];
191
+ }
156
192
 
157
- const configService = this.context?.services.get<ConfigurationService>(
158
- "ConfigurationService",
193
+ private emitWorkingChange() {
194
+ this.context?.eventBus.emit("feature:working:change", {
195
+ features: this.cloneFeatures(this.workingFeatures),
196
+ });
197
+ }
198
+
199
+ private async refreshGeometry() {
200
+ if (!this.context) return;
201
+ const commandService = this.context.services.get<any>("CommandService");
202
+ if (!commandService) return;
203
+ try {
204
+ const g = await Promise.resolve(commandService.executeCommand("getGeometry"));
205
+ if (g) this.currentGeometry = g as DielineGeometry;
206
+ } catch (e) {}
207
+ }
208
+
209
+ private setWorkingFeatures(next: DielineFeature[]) {
210
+ this.workingFeatures = next;
211
+ }
212
+
213
+ private updateWorkingGroupPosition(groupId: string, x: number, y: number) {
214
+ if (!groupId) return { ok: false };
215
+
216
+ const configService =
217
+ this.context?.services.get<ConfigurationService>("ConfigurationService");
218
+ if (!configService) return { ok: false };
219
+
220
+ const dielineWidth = parseLengthToMm(
221
+ configService.get("dieline.width") ?? 500,
222
+ "mm",
223
+ );
224
+ const dielineHeight = parseLengthToMm(
225
+ configService.get("dieline.height") ?? 500,
226
+ "mm",
159
227
  );
160
- const unit = configService?.get("dieline.unit", "mm") || "mm";
161
- const defaultSize = Coordinate.convertUnit(10, "mm", unit);
228
+
229
+ let changed = false;
230
+ const next = this.workingFeatures.map((f) => {
231
+ if (f.groupId !== groupId) return f;
232
+ let nx = x;
233
+ let ny = y;
234
+ if (f.constraints && dielineWidth > 0 && dielineHeight > 0) {
235
+ const constrained = ConstraintRegistry.apply(nx, ny, f, {
236
+ dielineWidth,
237
+ dielineHeight,
238
+ });
239
+ nx = constrained.x;
240
+ ny = constrained.y;
241
+ }
242
+
243
+ if (f.x !== nx || f.y !== ny) {
244
+ changed = true;
245
+ return { ...f, x: nx, y: ny };
246
+ }
247
+ return f;
248
+ });
249
+
250
+ if (!changed) return { ok: true };
251
+
252
+ this.setWorkingFeatures(next);
253
+ this.redraw();
254
+ this.enforceConstraints();
255
+ this.emitWorkingChange();
256
+
257
+ return { ok: true };
258
+ }
259
+
260
+ private completeFeatures(): {
261
+ ok: boolean;
262
+ issues?: Array<{
263
+ featureId: string;
264
+ groupId?: string;
265
+ reason: string;
266
+ }>;
267
+ } {
268
+ const configService =
269
+ this.context?.services.get<ConfigurationService>("ConfigurationService");
270
+ if (!configService) {
271
+ return {
272
+ ok: false,
273
+ issues: [
274
+ { featureId: "unknown", reason: "ConfigurationService not found" },
275
+ ],
276
+ };
277
+ }
278
+
279
+ const dielineWidth = parseLengthToMm(
280
+ configService.get("dieline.width") ?? 500,
281
+ "mm",
282
+ );
283
+ const dielineHeight = parseLengthToMm(
284
+ configService.get("dieline.height") ?? 500,
285
+ "mm",
286
+ );
287
+
288
+ const result = completeFeaturesStrict(
289
+ this.workingFeatures,
290
+ { dielineWidth, dielineHeight },
291
+ (next) => {
292
+ this.isUpdatingConfig = true;
293
+ try {
294
+ configService.update("dieline.features", next);
295
+ } finally {
296
+ this.isUpdatingConfig = false;
297
+ }
298
+
299
+ this.workingFeatures = this.cloneFeatures(next as any);
300
+ this.emitWorkingChange();
301
+ },
302
+ );
303
+
304
+ if (!result.ok) {
305
+ return {
306
+ ok: false,
307
+ issues: result.issues,
308
+ };
309
+ }
310
+
311
+ return { ok: true };
312
+ }
313
+
314
+ private addFeature(type: "add" | "subtract") {
315
+ if (!this.canvasService) return false;
162
316
 
163
317
  // Default to top edge center
164
318
  const newFeature: DielineFeature = {
@@ -168,31 +322,20 @@ export class FeatureTool implements Extension {
168
322
  shape: "rect",
169
323
  x: 0.5,
170
324
  y: 0, // Top edge
171
- width: defaultSize,
172
- height: defaultSize,
325
+ width: 10,
326
+ height: 10,
173
327
  rotation: 0,
174
328
  };
175
329
 
176
- if (configService) {
177
- const current = configService.get(
178
- "dieline.features",
179
- [],
180
- ) as DielineFeature[];
181
- configService.update("dieline.features", [...current, newFeature]);
182
- }
330
+ this.setWorkingFeatures([...(this.workingFeatures || []), newFeature]);
331
+ this.redraw();
332
+ this.emitWorkingChange();
183
333
  return true;
184
334
  }
185
335
 
186
336
  private addDoubleLayerHole() {
187
337
  if (!this.canvasService) return false;
188
338
 
189
- const configService = this.context?.services.get<ConfigurationService>(
190
- "ConfigurationService",
191
- );
192
- const unit = configService?.get("dieline.unit", "mm") || "mm";
193
- const lugRadius = Coordinate.convertUnit(20, "mm", unit);
194
- const holeRadius = Coordinate.convertUnit(15, "mm", unit);
195
-
196
339
  const groupId = Date.now().toString();
197
340
  const timestamp = Date.now();
198
341
 
@@ -205,7 +348,7 @@ export class FeatureTool implements Extension {
205
348
  placement: "edge",
206
349
  x: 0.5,
207
350
  y: 0,
208
- radius: lugRadius, // 20mm
351
+ radius: 20,
209
352
  rotation: 0,
210
353
  };
211
354
 
@@ -218,17 +361,13 @@ export class FeatureTool implements Extension {
218
361
  placement: "edge",
219
362
  x: 0.5,
220
363
  y: 0,
221
- radius: holeRadius, // 15mm
364
+ radius: 15,
222
365
  rotation: 0,
223
366
  };
224
367
 
225
- if (configService) {
226
- const current = configService.get(
227
- "dieline.features",
228
- [],
229
- ) as DielineFeature[];
230
- configService.update("dieline.features", [...current, lug, hole]);
231
- }
368
+ this.setWorkingFeatures([...(this.workingFeatures || []), lug, hole]);
369
+ this.redraw();
370
+ this.emitWorkingChange();
232
371
  return true;
233
372
  }
234
373
 
@@ -285,12 +424,12 @@ export class FeatureTool implements Extension {
285
424
  if (target.data?.isGroup) {
286
425
  const indices = target.data?.indices as number[];
287
426
  if (indices && indices.length > 0) {
288
- feature = this.features[indices[0]];
427
+ feature = this.workingFeatures[indices[0]];
289
428
  }
290
429
  } else {
291
430
  const index = target.data?.index;
292
431
  if (index !== undefined) {
293
- feature = this.features[index];
432
+ feature = this.workingFeatures[index];
294
433
  }
295
434
  }
296
435
 
@@ -326,7 +465,6 @@ export class FeatureTool implements Extension {
326
465
  const target = e.target;
327
466
  if (!target || target.data?.type !== "feature-marker") return;
328
467
 
329
- // Sync changes back to config
330
468
  if (target.data?.isGroup) {
331
469
  // It's a Group object
332
470
  const groupObj = target as Group;
@@ -344,7 +482,7 @@ export class FeatureTool implements Extension {
344
482
  // Simplified: just add relative coordinates if no rotation/scaling on group
345
483
  // We locked rotation/scaling, so it's safe.
346
484
 
347
- const newFeatures = [...this.features];
485
+ const newFeatures = [...this.workingFeatures];
348
486
  const { x, y } = this.currentGeometry!; // Center is same
349
487
 
350
488
  // Fabric Group objects have .getObjects() which returns children
@@ -353,7 +491,7 @@ export class FeatureTool implements Extension {
353
491
 
354
492
  groupObj.getObjects().forEach((child, i) => {
355
493
  const originalIndex = indices[i];
356
- const feature = this.features[originalIndex];
494
+ const feature = this.workingFeatures[originalIndex];
357
495
  const geometry = this.getGeometryForFeature(
358
496
  this.currentGeometry!,
359
497
  feature,
@@ -378,20 +516,8 @@ export class FeatureTool implements Extension {
378
516
  };
379
517
  });
380
518
 
381
- this.features = newFeatures;
382
-
383
- const configService =
384
- this.context?.services.get<ConfigurationService>(
385
- "ConfigurationService",
386
- );
387
- if (configService) {
388
- this.isUpdatingConfig = true;
389
- try {
390
- configService.update("dieline.features", this.features);
391
- } finally {
392
- this.isUpdatingConfig = false;
393
- }
394
- }
519
+ this.setWorkingFeatures(newFeatures);
520
+ this.emitWorkingChange();
395
521
  } else {
396
522
  // Single object
397
523
  this.syncFeatureFromCanvas(target);
@@ -435,39 +561,56 @@ export class FeatureTool implements Extension {
435
561
  limit: number,
436
562
  feature?: DielineFeature
437
563
  ): { x: number; y: number } {
564
+ if (feature && feature.constraints) {
565
+ const minX = geometry.x - geometry.width / 2;
566
+ const minY = geometry.y - geometry.height / 2;
567
+
568
+ const nx = geometry.width > 0 ? (p.x - minX) / geometry.width : 0.5;
569
+ const ny = geometry.height > 0 ? (p.y - minY) / geometry.height : 0.5;
570
+
571
+ const scale = geometry.scale || 1;
572
+ const dielineWidth = geometry.width / scale;
573
+ const dielineHeight = geometry.height / scale;
574
+
575
+ const constrained = ConstraintRegistry.apply(nx, ny, feature, {
576
+ dielineWidth,
577
+ dielineHeight,
578
+ });
579
+
580
+ return {
581
+ x: minX + constrained.x * geometry.width,
582
+ y: minY + constrained.y * geometry.height,
583
+ };
584
+ }
585
+
438
586
  if (feature && feature.placement === "internal") {
439
- // Constrain to bounds
440
- // geometry.x/y is center
441
- const minX = geometry.x - geometry.width / 2;
442
- const maxX = geometry.x + geometry.width / 2;
443
- const minY = geometry.y - geometry.height / 2;
444
- const maxY = geometry.y + geometry.height / 2;
445
-
446
- return {
447
- x: Math.max(minX, Math.min(maxX, p.x)),
448
- y: Math.max(minY, Math.min(maxY, p.y))
449
- };
587
+ const minX = geometry.x - geometry.width / 2;
588
+ const maxX = geometry.x + geometry.width / 2;
589
+ const minY = geometry.y - geometry.height / 2;
590
+ const maxY = geometry.y + geometry.height / 2;
591
+
592
+ return {
593
+ x: Math.max(minX, Math.min(maxX, p.x)),
594
+ y: Math.max(minY, Math.min(maxY, p.y)),
595
+ };
450
596
  }
451
597
 
452
- // Use geometry helper to find nearest point on Base Shape
453
- // geometry object matches GeometryOptions structure required by getNearestPointOnDieline
454
- // except for 'features' which we don't need for base shape snapping
455
- const nearest = getNearestPointOnDieline({ x: p.x, y: p.y }, {
456
- ...geometry,
457
- features: [],
458
- } as any);
598
+ const nearest = getNearestPointOnDieline(
599
+ { x: p.x, y: p.y },
600
+ {
601
+ ...geometry,
602
+ features: [],
603
+ } as any,
604
+ );
459
605
 
460
- // Calculate vector from nearest point to current point
461
606
  const dx = p.x - nearest.x;
462
607
  const dy = p.y - nearest.y;
463
608
  const dist = Math.sqrt(dx * dx + dy * dy);
464
609
 
465
- // If within limit, allow current position (offset from edge)
466
610
  if (dist <= limit) {
467
611
  return { x: p.x, y: p.y };
468
612
  }
469
613
 
470
- // Otherwise, clamp to limit
471
614
  const scale = limit / dist;
472
615
  return {
473
616
  x: nearest.x + dx * scale,
@@ -479,10 +622,14 @@ export class FeatureTool implements Extension {
479
622
  if (!this.currentGeometry || !this.context) return;
480
623
 
481
624
  const index = target.data?.index;
482
- if (index === undefined || index < 0 || index >= this.features.length)
625
+ if (
626
+ index === undefined ||
627
+ index < 0 ||
628
+ index >= this.workingFeatures.length
629
+ )
483
630
  return;
484
631
 
485
- const feature = this.features[index];
632
+ const feature = this.workingFeatures[index];
486
633
  const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
487
634
  const { width, height, x, y } = geometry;
488
635
 
@@ -502,22 +649,10 @@ export class FeatureTool implements Extension {
502
649
  // Could also update rotation if we allowed rotating markers
503
650
  };
504
651
 
505
- const newFeatures = [...this.features];
652
+ const newFeatures = [...this.workingFeatures];
506
653
  newFeatures[index] = updatedFeature;
507
- this.features = newFeatures;
508
-
509
- // Save to config
510
- const configService = this.context.services.get<ConfigurationService>(
511
- "ConfigurationService",
512
- );
513
- if (configService) {
514
- this.isUpdatingConfig = true;
515
- try {
516
- configService.update("dieline.features", this.features);
517
- } finally {
518
- this.isUpdatingConfig = false;
519
- }
520
- }
654
+ this.setWorkingFeatures(newFeatures);
655
+ this.emitWorkingChange();
521
656
  }
522
657
 
523
658
  private redraw() {
@@ -531,7 +666,7 @@ export class FeatureTool implements Extension {
531
666
  .filter((obj: any) => obj.data?.type === "feature-marker");
532
667
  existing.forEach((obj) => canvas.remove(obj));
533
668
 
534
- if (!this.features || this.features.length === 0) {
669
+ if (!this.workingFeatures || this.workingFeatures.length === 0) {
535
670
  this.canvasService.requestRenderAll();
536
671
  return;
537
672
  }
@@ -544,7 +679,7 @@ export class FeatureTool implements Extension {
544
679
  {};
545
680
  const singles: { feature: DielineFeature; index: number }[] = [];
546
681
 
547
- this.features.forEach((f, i) => {
682
+ this.workingFeatures.forEach((f: DielineFeature, i: number) => {
548
683
  if (f.groupId) {
549
684
  if (!groups[f.groupId]) groups[f.groupId] = [];
550
685
  groups[f.groupId].push({ feature: f, index: i });
@@ -558,7 +693,6 @@ export class FeatureTool implements Extension {
558
693
  feature: DielineFeature,
559
694
  pos: { x: number; y: number },
560
695
  ) => {
561
- // Features are in the same unit as geometry.unit
562
696
  const featureScale = scale;
563
697
 
564
698
  const visualWidth = (feature.width || 10) * featureScale;
@@ -628,27 +762,6 @@ export class FeatureTool implements Extension {
628
762
  data: { type: "feature-marker", index, isGroup: false },
629
763
  });
630
764
 
631
- // Auto-hide logic
632
- marker.set("opacity", 0);
633
- marker.on("mouseover", () => {
634
- marker.set("opacity", 1);
635
- canvas.requestRenderAll();
636
- });
637
- marker.on("mouseout", () => {
638
- if (canvas.getActiveObject() !== marker) {
639
- marker.set("opacity", 0);
640
- canvas.requestRenderAll();
641
- }
642
- });
643
- marker.on("selected", () => {
644
- marker.set("opacity", 1);
645
- canvas.requestRenderAll();
646
- });
647
- marker.on("deselected", () => {
648
- marker.set("opacity", 0);
649
- canvas.requestRenderAll();
650
- });
651
-
652
765
  canvas.add(marker);
653
766
  canvas.bringObjectToFront(marker);
654
767
  });
@@ -693,27 +806,6 @@ export class FeatureTool implements Extension {
693
806
  },
694
807
  });
695
808
 
696
- // Auto-hide logic for group
697
- groupObj.set("opacity", 0);
698
- groupObj.on("mouseover", () => {
699
- groupObj.set("opacity", 1);
700
- canvas.requestRenderAll();
701
- });
702
- groupObj.on("mouseout", () => {
703
- if (canvas.getActiveObject() !== groupObj) {
704
- groupObj.set("opacity", 0);
705
- canvas.requestRenderAll();
706
- }
707
- });
708
- groupObj.on("selected", () => {
709
- groupObj.set("opacity", 1);
710
- canvas.requestRenderAll();
711
- });
712
- groupObj.on("deselected", () => {
713
- groupObj.set("opacity", 0);
714
- canvas.requestRenderAll();
715
- });
716
-
717
809
  canvas.add(groupObj);
718
810
  canvas.bringObjectToFront(groupObj);
719
811
  });
@@ -735,12 +827,12 @@ export class FeatureTool implements Extension {
735
827
  if (marker.data?.isGroup) {
736
828
  const indices = marker.data?.indices as number[];
737
829
  if (indices && indices.length > 0) {
738
- feature = this.features[indices[0]];
830
+ feature = this.workingFeatures[indices[0]];
739
831
  }
740
832
  } else {
741
833
  const index = marker.data?.index;
742
834
  if (index !== undefined) {
743
- feature = this.features[index];
835
+ feature = this.workingFeatures[index];
744
836
  }
745
837
  }
746
838
 
@@ -0,0 +1,45 @@
1
+ import { ConstraintContext, ConstraintFeature, ConstraintRegistry } from "./constraints";
2
+
3
+ export type FeatureCompleteIssue = {
4
+ featureId: string;
5
+ groupId?: string;
6
+ reason: string;
7
+ };
8
+
9
+ export function validateFeaturesStrict(
10
+ features: ConstraintFeature[],
11
+ context: ConstraintContext,
12
+ ): { ok: boolean; issues?: FeatureCompleteIssue[] } {
13
+ const eps = 1e-6;
14
+ const issues: FeatureCompleteIssue[] = [];
15
+
16
+ for (const f of features) {
17
+ if (!f.constraints?.type) continue;
18
+ const constrained = ConstraintRegistry.apply(f.x, f.y, f, context);
19
+ if (
20
+ Math.abs(constrained.x - f.x) > eps ||
21
+ Math.abs(constrained.y - f.y) > eps
22
+ ) {
23
+ issues.push({
24
+ featureId: f.id,
25
+ groupId: f.groupId,
26
+ reason: "Position violates constraint strategy",
27
+ });
28
+ }
29
+ }
30
+
31
+ return { ok: issues.length === 0, issues: issues.length ? issues : undefined };
32
+ }
33
+
34
+ export function completeFeaturesStrict(
35
+ features: ConstraintFeature[],
36
+ context: ConstraintContext,
37
+ update: (nextFeatures: ConstraintFeature[]) => void,
38
+ ): { ok: boolean; issues?: FeatureCompleteIssue[] } {
39
+ const validation = validateFeaturesStrict(features, context);
40
+ if (!validation.ok) return validation;
41
+ const next = JSON.parse(JSON.stringify(features || [])) as ConstraintFeature[];
42
+ update(next);
43
+ return { ok: true };
44
+ }
45
+