@pooder/kit 5.3.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.test-dist/src/extensions/background.js +475 -131
  2. package/.test-dist/src/extensions/dieline.js +283 -180
  3. package/.test-dist/src/extensions/dielineShape.js +66 -0
  4. package/.test-dist/src/extensions/feature.js +388 -303
  5. package/.test-dist/src/extensions/film.js +133 -74
  6. package/.test-dist/src/extensions/geometry.js +120 -56
  7. package/.test-dist/src/extensions/image.js +296 -212
  8. package/.test-dist/src/extensions/index.js +1 -3
  9. package/.test-dist/src/extensions/maskOps.js +75 -20
  10. package/.test-dist/src/extensions/ruler.js +312 -215
  11. package/.test-dist/src/extensions/sceneLayoutModel.js +9 -3
  12. package/.test-dist/src/extensions/sceneVisibility.js +3 -10
  13. package/.test-dist/src/extensions/tracer.js +229 -58
  14. package/.test-dist/src/extensions/white-ink.js +139 -129
  15. package/.test-dist/src/services/CanvasService.js +888 -126
  16. package/.test-dist/src/services/index.js +1 -0
  17. package/.test-dist/src/services/visibility.js +54 -0
  18. package/.test-dist/tests/run.js +58 -4
  19. package/CHANGELOG.md +12 -0
  20. package/dist/index.d.mts +377 -82
  21. package/dist/index.d.ts +377 -82
  22. package/dist/index.js +3920 -2178
  23. package/dist/index.mjs +3992 -2247
  24. package/package.json +1 -1
  25. package/src/extensions/background.ts +631 -145
  26. package/src/extensions/dieline.ts +280 -187
  27. package/src/extensions/dielineShape.ts +109 -0
  28. package/src/extensions/feature.ts +485 -366
  29. package/src/extensions/film.ts +152 -76
  30. package/src/extensions/geometry.ts +203 -104
  31. package/src/extensions/image.ts +319 -238
  32. package/src/extensions/index.ts +0 -1
  33. package/src/extensions/ruler.ts +481 -268
  34. package/src/extensions/sceneLayoutModel.ts +18 -6
  35. package/src/extensions/white-ink.ts +157 -171
  36. package/src/services/CanvasService.ts +1126 -140
  37. package/src/services/index.ts +1 -0
  38. package/src/services/renderSpec.ts +69 -4
  39. package/src/services/visibility.ts +78 -0
  40. package/tests/run.ts +139 -4
  41. package/.test-dist/src/CanvasService.js +0 -249
  42. package/.test-dist/src/ViewportSystem.js +0 -75
  43. package/.test-dist/src/background.js +0 -203
  44. package/.test-dist/src/bridgeSelection.js +0 -20
  45. package/.test-dist/src/constraints.js +0 -237
  46. package/.test-dist/src/dieline.js +0 -818
  47. package/.test-dist/src/edgeScale.js +0 -12
  48. package/.test-dist/src/feature.js +0 -826
  49. package/.test-dist/src/featureComplete.js +0 -32
  50. package/.test-dist/src/film.js +0 -167
  51. package/.test-dist/src/geometry.js +0 -506
  52. package/.test-dist/src/image.js +0 -1250
  53. package/.test-dist/src/maskOps.js +0 -270
  54. package/.test-dist/src/mirror.js +0 -104
  55. package/.test-dist/src/renderSpec.js +0 -2
  56. package/.test-dist/src/ruler.js +0 -343
  57. package/.test-dist/src/sceneLayout.js +0 -99
  58. package/.test-dist/src/sceneLayoutModel.js +0 -196
  59. package/.test-dist/src/sceneView.js +0 -40
  60. package/.test-dist/src/sceneVisibility.js +0 -42
  61. package/.test-dist/src/size.js +0 -332
  62. package/.test-dist/src/tracer.js +0 -544
  63. package/.test-dist/src/white-ink.js +0 -829
  64. package/.test-dist/src/wrappedOffsets.js +0 -33
  65. package/src/extensions/sceneVisibility.ts +0 -71
@@ -6,13 +6,8 @@ import {
6
6
  ConfigurationService,
7
7
  ToolSessionService,
8
8
  } from "@pooder/core";
9
- import { Circle, Group, Point, Rect } from "fabric";
10
- import { CanvasService } from "../services";
11
- import {
12
- getNearestPointOnDieline,
13
- DielineFeature,
14
- resolveFeaturePosition,
15
- } from "./geometry";
9
+ import { CanvasService, RenderObjectSpec } from "../services";
10
+ import { resolveFeaturePosition } from "./geometry";
16
11
  import { ConstraintRegistry, ConstraintFeature } from "./constraints";
17
12
  import { completeFeaturesStrict } from "./featureComplete";
18
13
  import {
@@ -20,6 +15,41 @@ import {
20
15
  type SceneGeometrySnapshot as DielineGeometry,
21
16
  } from "./sceneLayoutModel";
22
17
 
18
+ const FEATURE_OVERLAY_LAYER_ID = "feature-overlay";
19
+ const FEATURE_STROKE_WIDTH = 2;
20
+ const DEFAULT_RECT_SIZE = 10;
21
+ const DEFAULT_CIRCLE_RADIUS = 5;
22
+
23
+ type MarkerPoint = { x: number; y: number };
24
+
25
+ interface GroupMemberOffset {
26
+ index: number;
27
+ dx: number;
28
+ dy: number;
29
+ }
30
+
31
+ interface MarkerRenderState {
32
+ feature: ConstraintFeature;
33
+ index: number;
34
+ position: MarkerPoint;
35
+ geometry: DielineGeometry;
36
+ scale: number;
37
+ }
38
+
39
+ interface MarkerData {
40
+ type: "feature-marker";
41
+ index: number;
42
+ featureId: string;
43
+ groupId?: string;
44
+ markerRole: "handle" | "member" | "indicator";
45
+ markerOffsetX: number;
46
+ markerOffsetY: number;
47
+ isGroup: boolean;
48
+ indices?: number[];
49
+ anchorIndex?: number;
50
+ memberOffsets?: GroupMemberOffset[];
51
+ }
52
+
23
53
  export class FeatureTool implements Extension {
24
54
  id = "pooder.kit.feature";
25
55
 
@@ -36,6 +66,9 @@ export class FeatureTool implements Extension {
36
66
  private sessionOriginalFeatures: ConstraintFeature[] | null = null;
37
67
  private hasWorkingChanges = false;
38
68
  private dirtyTrackerDisposable?: { dispose(): void };
69
+ private renderProducerDisposable?: { dispose: () => void };
70
+ private specs: RenderObjectSpec[] = [];
71
+ private renderSeq = 0;
39
72
 
40
73
  private handleMoving: ((e: any) => void) | null = null;
41
74
  private handleModified: ((e: any) => void) | null = null;
@@ -64,6 +97,22 @@ export class FeatureTool implements Extension {
64
97
  return;
65
98
  }
66
99
 
100
+ this.renderProducerDisposable?.dispose();
101
+ this.renderProducerDisposable = this.canvasService.registerRenderProducer(
102
+ this.id,
103
+ () => ({
104
+ passes: [
105
+ {
106
+ id: FEATURE_OVERLAY_LAYER_ID,
107
+ stack: 880,
108
+ order: 0,
109
+ objects: this.specs,
110
+ },
111
+ ],
112
+ }),
113
+ { priority: 350 },
114
+ );
115
+
67
116
  const configService = context.services.get<ConfigurationService>(
68
117
  "ConfigurationService",
69
118
  );
@@ -94,7 +143,6 @@ export class FeatureTool implements Extension {
94
143
  () => this.hasWorkingChanges,
95
144
  );
96
145
 
97
- // Listen to tool activation
98
146
  context.eventBus.on("tool:activated", this.onToolActivated);
99
147
 
100
148
  this.setup();
@@ -119,23 +167,7 @@ export class FeatureTool implements Extension {
119
167
  };
120
168
 
121
169
  private updateVisibility() {
122
- if (!this.canvasService) return;
123
- const canvas = this.canvasService.canvas;
124
- const markers = canvas
125
- .getObjects()
126
- .filter((obj: any) => obj.data?.type === "feature-marker");
127
-
128
- markers.forEach((marker: any) => {
129
- // If tool active, allow selection. If not, disable selection.
130
- // Also might want to hide them entirely or just disable interaction.
131
- // Assuming we only want to see/edit holes when tool is active.
132
- marker.set({
133
- visible: this.isToolActive, // Or just selectable: false if we want them visible but locked
134
- selectable: this.isToolActive,
135
- evented: this.isToolActive,
136
- });
137
- });
138
- canvas.requestRenderAll();
170
+ this.redraw();
139
171
  }
140
172
 
141
173
  contribute() {
@@ -237,10 +269,10 @@ export class FeatureTool implements Extension {
237
269
  await this.refreshGeometry();
238
270
  this.setWorkingFeatures(original);
239
271
  this.hasWorkingChanges = false;
272
+ this.clearFeatureSessionState();
240
273
  this.redraw();
241
274
  this.emitWorkingChange();
242
275
  this.updateCommittedFeatures(original);
243
- this.clearFeatureSessionState();
244
276
  return { ok: true };
245
277
  },
246
278
  },
@@ -384,8 +416,7 @@ export class FeatureTool implements Extension {
384
416
 
385
417
  this.setWorkingFeatures(next);
386
418
  this.hasWorkingChanges = true;
387
- this.redraw();
388
- this.enforceConstraints();
419
+ this.redraw({ enforceConstraints: true });
389
420
  this.emitWorkingChange();
390
421
 
391
422
  return { ok: true };
@@ -420,7 +451,7 @@ export class FeatureTool implements Extension {
420
451
  { dielineWidth, dielineHeight },
421
452
  (next) => {
422
453
  this.updateCommittedFeatures(next as ConstraintFeature[]);
423
- this.workingFeatures = this.cloneFeatures(next as any);
454
+ this.workingFeatures = this.cloneFeatures(next as ConstraintFeature[]);
424
455
  this.emitWorkingChange();
425
456
  },
426
457
  );
@@ -434,7 +465,6 @@ export class FeatureTool implements Extension {
434
465
 
435
466
  this.hasWorkingChanges = false;
436
467
  this.clearFeatureSessionState();
437
- // Keep feature markers above dieline overlay after config-driven redraw.
438
468
  this.redraw();
439
469
  return { ok: true };
440
470
  }
@@ -442,18 +472,16 @@ export class FeatureTool implements Extension {
442
472
  private addFeature(type: "add" | "subtract") {
443
473
  if (!this.canvasService) return false;
444
474
 
445
- // Default to top edge center
446
475
  const newFeature: ConstraintFeature = {
447
476
  id: Date.now().toString(),
448
477
  operation: type,
449
478
  shape: "rect",
450
479
  x: 0.5,
451
- y: 0, // Top edge
480
+ y: 0,
452
481
  width: 10,
453
482
  height: 10,
454
483
  rotation: 0,
455
484
  renderBehavior: "edge",
456
- // Default constraint: path (snap to edge)
457
485
  constraints: [{ type: "path" }],
458
486
  };
459
487
 
@@ -470,7 +498,6 @@ export class FeatureTool implements Extension {
470
498
  const groupId = Date.now().toString();
471
499
  const timestamp = Date.now();
472
500
 
473
- // 1. Lug (Outer) - Add
474
501
  const lug: ConstraintFeature = {
475
502
  id: `${timestamp}-lug`,
476
503
  groupId,
@@ -484,7 +511,6 @@ export class FeatureTool implements Extension {
484
511
  constraints: [{ type: "path" }],
485
512
  };
486
513
 
487
- // 2. Hole (Inner) - Subtract
488
514
  const hole: ConstraintFeature = {
489
515
  id: `${timestamp}-hole`,
490
516
  groupId,
@@ -507,10 +533,8 @@ export class FeatureTool implements Extension {
507
533
 
508
534
  private getGeometryForFeature(
509
535
  geometry: DielineGeometry,
510
- feature?: ConstraintFeature,
536
+ _feature?: ConstraintFeature,
511
537
  ): DielineGeometry {
512
- // Legacy support or specialized scaling can go here if needed
513
- // Currently all features operate on the base geometry (or scaled version of it)
514
538
  return geometry;
515
539
  }
516
540
 
@@ -518,12 +542,10 @@ export class FeatureTool implements Extension {
518
542
  if (!this.canvasService || !this.context) return;
519
543
  const canvas = this.canvasService.canvas;
520
544
 
521
- // 1. Listen for Scene Geometry Changes
522
545
  if (!this.handleSceneGeometryChange) {
523
546
  this.handleSceneGeometryChange = (geometry: DielineGeometry) => {
524
547
  this.currentGeometry = geometry;
525
- this.redraw();
526
- this.enforceConstraints();
548
+ this.redraw({ enforceConstraints: true });
527
549
  };
528
550
  this.context.eventBus.on(
529
551
  "scene:geometry:change",
@@ -531,7 +553,6 @@ export class FeatureTool implements Extension {
531
553
  );
532
554
  }
533
555
 
534
- // 2. Initial Fetch of Geometry
535
556
  const commandService = this.context.services.get<any>("CommandService");
536
557
  if (commandService) {
537
558
  try {
@@ -546,119 +567,41 @@ export class FeatureTool implements Extension {
546
567
  } catch (e) {}
547
568
  }
548
569
 
549
- // 3. Setup Canvas Interaction
550
570
  if (!this.handleMoving) {
551
571
  this.handleMoving = (e: any) => {
552
- const target = e.target;
553
- if (!target || target.data?.type !== "feature-marker") return;
554
- if (!this.currentGeometry) return;
555
-
556
- // Determine feature to use for snapping context
557
- let feature: ConstraintFeature | undefined;
558
- if (target.data?.isGroup) {
559
- const indices = target.data?.indices as number[];
560
- if (indices && indices.length > 0) {
561
- feature = this.workingFeatures[indices[0]];
562
- }
563
- } else {
564
- const index = target.data?.index;
565
- if (index !== undefined) {
566
- feature = this.workingFeatures[index];
567
- }
568
- }
569
-
570
- const geometry = this.getGeometryForFeature(
571
- this.currentGeometry,
572
+ const target = this.getDraggableMarkerTarget(e?.target);
573
+ if (!target || !this.currentGeometry) return;
574
+
575
+ const feature = this.getFeatureForMarker(target);
576
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
577
+ const snapped = this.constrainPosition(
578
+ {
579
+ x: Number(target.left || 0),
580
+ y: Number(target.top || 0),
581
+ },
582
+ geometry,
572
583
  feature,
573
584
  );
574
585
 
575
- // Snap to edge during move
576
- // For Group, target.left/top is group center (or top-left depending on origin)
577
- // We snap the target position itself.
578
- const p = new Point(target.left, target.top);
579
-
580
- // Calculate limit based on target size (min dimension / 2 ensures overlap)
581
- // Also subtract stroke width to ensure visual overlap (not just tangent)
582
- // target.strokeWidth for group is usually 0, need a safe default (e.g. 2 for markers)
583
- const markerStrokeWidth =
584
- (target.strokeWidth || 2) * (target.scaleX || 1);
585
- const minDim = Math.min(
586
- target.getScaledWidth(),
587
- target.getScaledHeight(),
588
- );
589
- const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
590
-
591
- const snapped = this.constrainPosition(p, geometry, limit, feature);
592
-
593
586
  target.set({
594
587
  left: snapped.x,
595
588
  top: snapped.y,
596
589
  });
590
+ target.setCoords();
591
+
592
+ this.syncMarkerVisualsByTarget(target, snapped);
597
593
  };
598
594
  canvas.on("object:moving", this.handleMoving);
599
595
  }
600
596
 
601
597
  if (!this.handleModified) {
602
598
  this.handleModified = (e: any) => {
603
- const target = e.target;
604
- if (!target || target.data?.type !== "feature-marker") return;
599
+ const target = this.getDraggableMarkerTarget(e?.target);
600
+ if (!target) return;
605
601
 
606
602
  if (target.data?.isGroup) {
607
- // It's a Group object
608
- const groupObj = target as Group;
609
- // @ts-ignore
610
- const indices = groupObj.data?.indices as number[];
611
- if (!indices) return;
612
-
613
- // We need to update all features in the group based on their new absolute positions.
614
- // Fabric Group children positions are relative to group center.
615
- // We need to calculate absolute position for each child.
616
- // Note: groupObj has already been moved to new position (target.left, target.top)
617
-
618
- const groupCenter = new Point(groupObj.left, groupObj.top);
619
- // Get group matrix to transform children
620
- // Simplified: just add relative coordinates if no rotation/scaling on group
621
- // We locked rotation/scaling, so it's safe.
622
-
623
- const newFeatures = [...this.workingFeatures];
624
- const { x, y } = this.currentGeometry!; // Center is same
625
-
626
- // Fabric Group objects have .getObjects() which returns children
627
- // But children inside group have coordinates relative to group center.
628
- // center is (0,0) inside the group local coordinate system.
629
-
630
- groupObj.getObjects().forEach((child, i) => {
631
- const originalIndex = indices[i];
632
- const feature = this.workingFeatures[originalIndex];
633
- const geometry = this.getGeometryForFeature(
634
- this.currentGeometry!,
635
- feature,
636
- );
637
- const { width, height } = geometry;
638
- const layoutLeft = x - width / 2;
639
- const layoutTop = y - height / 2;
640
-
641
- // Calculate absolute position
642
- // child.left/top are relative to group center
643
- const absX = groupCenter.x + (child.left || 0);
644
- const absY = groupCenter.y + (child.top || 0);
645
-
646
- // Normalize
647
- const normalizedX = width > 0 ? (absX - layoutLeft) / width : 0.5;
648
- const normalizedY = height > 0 ? (absY - layoutTop) / height : 0.5;
649
-
650
- newFeatures[originalIndex] = {
651
- ...newFeatures[originalIndex],
652
- x: normalizedX,
653
- y: normalizedY,
654
- };
655
- });
656
-
657
- this.setWorkingFeatures(newFeatures);
658
- this.hasWorkingChanges = true;
659
- this.emitWorkingChange();
603
+ this.syncGroupFromCanvas(target);
660
604
  } else {
661
- // Single object
662
605
  this.syncFeatureFromCanvas(target);
663
606
  }
664
607
  };
@@ -686,20 +629,34 @@ export class FeatureTool implements Extension {
686
629
  this.handleSceneGeometryChange = null;
687
630
  }
688
631
 
689
- const objects = canvas
690
- .getObjects()
691
- .filter((obj: any) => obj.data?.type === "feature-marker");
692
- objects.forEach((obj) => canvas.remove(obj));
632
+ this.renderSeq += 1;
633
+ this.specs = [];
634
+ this.renderProducerDisposable?.dispose();
635
+ this.renderProducerDisposable = undefined;
636
+ void this.canvasService.flushRenderFromProducers();
637
+ }
638
+
639
+ private getDraggableMarkerTarget(target: any): any | null {
640
+ if (!this.isFeatureSessionActive || !this.isToolActive) return null;
641
+ if (!target || target.data?.type !== "feature-marker") return null;
642
+ if (target.data?.markerRole !== "handle") return null;
643
+ return target;
644
+ }
693
645
 
694
- this.canvasService.requestRenderAll();
646
+ private getFeatureForMarker(target: any): ConstraintFeature | undefined {
647
+ const data = target?.data || {};
648
+ const index = data.isGroup
649
+ ? this.toFeatureIndex(data.anchorIndex)
650
+ : this.toFeatureIndex(data.index);
651
+ if (index === null) return undefined;
652
+ return this.workingFeatures[index];
695
653
  }
696
654
 
697
655
  private constrainPosition(
698
- p: Point,
656
+ p: MarkerPoint,
699
657
  geometry: DielineGeometry,
700
- limit: number,
701
658
  feature?: ConstraintFeature,
702
- ): { x: number; y: number } {
659
+ ): MarkerPoint {
703
660
  if (!feature) {
704
661
  return { x: p.x, y: p.y };
705
662
  }
@@ -707,7 +664,6 @@ export class FeatureTool implements Extension {
707
664
  const minX = geometry.x - geometry.width / 2;
708
665
  const minY = geometry.y - geometry.height / 2;
709
666
 
710
- // Normalize
711
667
  const nx = geometry.width > 0 ? (p.x - minX) / geometry.width : 0.5;
712
668
  const ny = geometry.height > 0 ? (p.y - minY) / geometry.height : 0.5;
713
669
 
@@ -715,7 +671,6 @@ export class FeatureTool implements Extension {
715
671
  const dielineWidth = geometry.width / scale;
716
672
  const dielineHeight = geometry.height / scale;
717
673
 
718
- // Filter constraints: only apply those that are NOT validateOnly
719
674
  const activeConstraints = feature.constraints?.filter(
720
675
  (c) => !c.validateOnly,
721
676
  );
@@ -732,290 +687,454 @@ export class FeatureTool implements Extension {
732
687
  activeConstraints,
733
688
  );
734
689
 
735
- // Denormalize
736
690
  return {
737
691
  x: minX + constrained.x * geometry.width,
738
692
  y: minY + constrained.y * geometry.height,
739
693
  };
740
694
  }
741
695
 
696
+ private toNormalizedPoint(
697
+ point: MarkerPoint,
698
+ geometry: DielineGeometry,
699
+ ): MarkerPoint {
700
+ const left = geometry.x - geometry.width / 2;
701
+ const top = geometry.y - geometry.height / 2;
702
+ return {
703
+ x: geometry.width > 0 ? (point.x - left) / geometry.width : 0.5,
704
+ y: geometry.height > 0 ? (point.y - top) / geometry.height : 0.5,
705
+ };
706
+ }
707
+
742
708
  private syncFeatureFromCanvas(target: any) {
743
- if (!this.currentGeometry || !this.context) return;
709
+ if (!this.currentGeometry) return;
744
710
 
745
- const index = target.data?.index;
746
- if (
747
- index === undefined ||
748
- index < 0 ||
749
- index >= this.workingFeatures.length
750
- )
751
- return;
711
+ const index = this.toFeatureIndex(target.data?.index);
712
+ if (index === null || index >= this.workingFeatures.length) return;
752
713
 
753
714
  const feature = this.workingFeatures[index];
754
715
  const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
755
- const { width, height, x, y } = geometry;
756
-
757
- // Calculate Normalized Position
758
- // The geometry x/y is the CENTER.
759
- const left = x - width / 2;
760
- const top = y - height / 2;
761
-
762
- const normalizedX = width > 0 ? (target.left - left) / width : 0.5;
763
- const normalizedY = height > 0 ? (target.top - top) / height : 0.5;
716
+ const normalized = this.toNormalizedPoint(
717
+ {
718
+ x: Number(target.left || 0),
719
+ y: Number(target.top || 0),
720
+ },
721
+ geometry,
722
+ );
764
723
 
765
- // Update feature
766
724
  const updatedFeature = {
767
725
  ...feature,
768
- x: normalizedX,
769
- y: normalizedY,
770
- // Could also update rotation if we allowed rotating markers
726
+ x: normalized.x,
727
+ y: normalized.y,
771
728
  };
772
729
 
773
- const newFeatures = [...this.workingFeatures];
774
- newFeatures[index] = updatedFeature;
775
- this.setWorkingFeatures(newFeatures);
730
+ const next = [...this.workingFeatures];
731
+ next[index] = updatedFeature;
732
+ this.setWorkingFeatures(next);
776
733
  this.hasWorkingChanges = true;
777
734
  this.emitWorkingChange();
778
735
  }
779
736
 
780
- private redraw() {
781
- if (!this.canvasService || !this.currentGeometry) return;
782
- const canvas = this.canvasService.canvas;
783
- const geometry = this.currentGeometry;
737
+ private syncGroupFromCanvas(target: any) {
738
+ if (!this.currentGeometry) return;
784
739
 
785
- // Remove existing markers
786
- const existing = canvas
787
- .getObjects()
788
- .filter((obj: any) => obj.data?.type === "feature-marker");
789
- existing.forEach((obj) => canvas.remove(obj));
740
+ const indices = this.readGroupIndices(target.data?.indices);
741
+ if (indices.length === 0) return;
742
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
790
743
 
791
- if (!this.workingFeatures || this.workingFeatures.length === 0) {
792
- this.canvasService.requestRenderAll();
793
- return;
744
+ const anchorCenter = {
745
+ x: Number(target.left || 0),
746
+ y: Number(target.top || 0),
747
+ };
748
+
749
+ const next = [...this.workingFeatures];
750
+ let changed = false;
751
+ offsets.forEach((entry) => {
752
+ const index = entry.index;
753
+ if (index < 0 || index >= next.length) return;
754
+ const feature = next[index];
755
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
756
+ const normalized = this.toNormalizedPoint(
757
+ {
758
+ x: anchorCenter.x + entry.dx,
759
+ y: anchorCenter.y + entry.dy,
760
+ },
761
+ geometry,
762
+ );
763
+
764
+ if (feature.x !== normalized.x || feature.y !== normalized.y) {
765
+ next[index] = {
766
+ ...feature,
767
+ x: normalized.x,
768
+ y: normalized.y,
769
+ };
770
+ changed = true;
771
+ }
772
+ });
773
+
774
+ if (!changed) return;
775
+ this.setWorkingFeatures(next);
776
+ this.hasWorkingChanges = true;
777
+ this.emitWorkingChange();
778
+ }
779
+
780
+ private redraw(options: { enforceConstraints?: boolean } = {}) {
781
+ void this.redrawAsync(options);
782
+ }
783
+
784
+ private async redrawAsync(options: { enforceConstraints?: boolean } = {}) {
785
+ if (!this.canvasService) return;
786
+
787
+ const seq = ++this.renderSeq;
788
+ this.specs = this.buildFeatureSpecs();
789
+ if (seq !== this.renderSeq) return;
790
+
791
+ await this.canvasService.flushRenderFromProducers();
792
+ if (seq !== this.renderSeq) return;
793
+ if (options.enforceConstraints) {
794
+ this.enforceConstraints();
794
795
  }
796
+ }
795
797
 
796
- const scale = geometry.scale || 1;
797
- const finalScale = scale;
798
-
799
- // Group features by groupId
800
- const groups: {
801
- [key: string]: { feature: ConstraintFeature; index: number }[];
802
- } = {};
803
- const singles: { feature: ConstraintFeature; index: number }[] = [];
804
-
805
- this.workingFeatures.forEach((f: ConstraintFeature, i: number) => {
806
- if (f.groupId) {
807
- if (!groups[f.groupId]) groups[f.groupId] = [];
808
- groups[f.groupId].push({ feature: f, index: i });
809
- } else {
810
- singles.push({ feature: f, index: i });
798
+ private buildFeatureSpecs(): RenderObjectSpec[] {
799
+ if (
800
+ !this.isFeatureSessionActive ||
801
+ !this.currentGeometry ||
802
+ this.workingFeatures.length === 0
803
+ ) {
804
+ return [];
805
+ }
806
+
807
+ const groups = new Map<string, MarkerRenderState[]>();
808
+ const singles: MarkerRenderState[] = [];
809
+
810
+ this.workingFeatures.forEach((feature, index) => {
811
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
812
+ const position = resolveFeaturePosition(feature, geometry);
813
+ const scale = geometry.scale || 1;
814
+ const marker: MarkerRenderState = {
815
+ feature,
816
+ index,
817
+ position,
818
+ geometry,
819
+ scale,
820
+ };
821
+
822
+ if (feature.groupId) {
823
+ const list = groups.get(feature.groupId) || [];
824
+ list.push(marker);
825
+ groups.set(feature.groupId, list);
826
+ return;
811
827
  }
828
+ singles.push(marker);
829
+ });
830
+
831
+ const specs: RenderObjectSpec[] = [];
832
+
833
+ singles.forEach((marker) => {
834
+ this.appendMarkerSpecs(specs, marker, {
835
+ markerRole: "handle",
836
+ isGroup: false,
837
+ });
838
+ });
839
+
840
+ groups.forEach((members, groupId) => {
841
+ if (!members.length) return;
842
+ const anchor = members[0];
843
+ const memberOffsets: GroupMemberOffset[] = members.map((member) => ({
844
+ index: member.index,
845
+ dx: member.position.x - anchor.position.x,
846
+ dy: member.position.y - anchor.position.y,
847
+ }));
848
+ const indices = members.map((member) => member.index);
849
+
850
+ members
851
+ .filter((member) => member.index !== anchor.index)
852
+ .forEach((member) => {
853
+ this.appendMarkerSpecs(specs, member, {
854
+ markerRole: "member",
855
+ isGroup: false,
856
+ groupId,
857
+ });
858
+ });
859
+
860
+ this.appendMarkerSpecs(specs, anchor, {
861
+ markerRole: "handle",
862
+ isGroup: true,
863
+ groupId,
864
+ indices,
865
+ anchorIndex: anchor.index,
866
+ memberOffsets,
867
+ });
812
868
  });
813
869
 
814
- // Helper to create marker shape
815
- const createMarkerShape = (
816
- feature: ConstraintFeature,
817
- pos: { x: number; y: number },
818
- ) => {
819
- const featureScale = scale;
820
-
821
- const visualWidth = (feature.width || 10) * featureScale;
822
- const visualHeight = (feature.height || 10) * featureScale;
823
- const visualRadius = (feature.radius || 0) * featureScale;
824
- const color =
825
- feature.color || (feature.operation === "add" ? "#00FF00" : "#FF0000");
826
- const strokeDash =
827
- feature.strokeDash ||
828
- (feature.operation === "subtract" ? [4, 4] : undefined);
829
-
830
- let shape: any;
831
- if (feature.shape === "rect") {
832
- shape = new Rect({
870
+ return specs;
871
+ }
872
+
873
+ private appendMarkerSpecs(
874
+ specs: RenderObjectSpec[],
875
+ marker: MarkerRenderState,
876
+ options: {
877
+ markerRole: "handle" | "member";
878
+ isGroup: boolean;
879
+ groupId?: string;
880
+ indices?: number[];
881
+ anchorIndex?: number;
882
+ memberOffsets?: GroupMemberOffset[];
883
+ },
884
+ ) {
885
+ const { feature, index, position, scale, geometry } = marker;
886
+ const baseRadius =
887
+ feature.shape === "circle"
888
+ ? (feature.radius ?? DEFAULT_CIRCLE_RADIUS)
889
+ : (feature.radius ?? 0);
890
+ const baseWidth =
891
+ feature.shape === "circle"
892
+ ? baseRadius * 2
893
+ : (feature.width ?? DEFAULT_RECT_SIZE);
894
+ const baseHeight =
895
+ feature.shape === "circle"
896
+ ? baseRadius * 2
897
+ : (feature.height ?? DEFAULT_RECT_SIZE);
898
+ const visualWidth = baseWidth * scale;
899
+ const visualHeight = baseHeight * scale;
900
+ const visualRadius = baseRadius * scale;
901
+ const color =
902
+ feature.color || (feature.operation === "add" ? "#00FF00" : "#FF0000");
903
+ const strokeDash =
904
+ feature.strokeDash ||
905
+ (feature.operation === "subtract" ? [4, 4] : undefined);
906
+
907
+ const interactive = options.markerRole === "handle";
908
+ const sessionVisible = this.isToolActive && this.isFeatureSessionActive;
909
+ const baseData = this.buildMarkerData(marker, options);
910
+ const commonProps = {
911
+ visible: sessionVisible,
912
+ selectable: interactive && sessionVisible,
913
+ evented: interactive && sessionVisible,
914
+ hasControls: false,
915
+ hasBorders: false,
916
+ hoverCursor: interactive ? "move" : "default",
917
+ lockRotation: true,
918
+ lockScalingX: true,
919
+ lockScalingY: true,
920
+ fill: "transparent",
921
+ stroke: color,
922
+ strokeWidth: FEATURE_STROKE_WIDTH,
923
+ strokeDashArray: strokeDash,
924
+ originX: "center" as const,
925
+ originY: "center" as const,
926
+ left: position.x,
927
+ top: position.y,
928
+ angle: feature.rotation || 0,
929
+ };
930
+
931
+ const markerId = this.markerId(index);
932
+ if (feature.shape === "rect") {
933
+ specs.push({
934
+ id: markerId,
935
+ type: "rect",
936
+ space: "screen",
937
+ data: baseData,
938
+ props: {
939
+ ...commonProps,
833
940
  width: visualWidth,
834
941
  height: visualHeight,
835
942
  rx: visualRadius,
836
943
  ry: visualRadius,
837
- fill: "transparent",
838
- stroke: color,
839
- strokeWidth: 2,
840
- strokeDashArray: strokeDash,
841
- originX: "center",
842
- originY: "center",
843
- left: pos.x,
844
- top: pos.y,
845
- });
846
- } else {
847
- shape = new Circle({
848
- radius: visualRadius || 5 * finalScale,
849
- fill: "transparent",
850
- stroke: color,
851
- strokeWidth: 2,
852
- strokeDashArray: strokeDash,
853
- originX: "center",
854
- originY: "center",
855
- left: pos.x,
856
- top: pos.y,
857
- });
858
- }
859
- if (feature.rotation) {
860
- shape.rotate(feature.rotation);
861
- }
944
+ },
945
+ });
946
+ } else {
947
+ specs.push({
948
+ id: markerId,
949
+ type: "rect",
950
+ space: "screen",
951
+ data: baseData,
952
+ props: {
953
+ ...commonProps,
954
+ width: visualWidth,
955
+ height: visualHeight,
956
+ rx: visualRadius,
957
+ ry: visualRadius,
958
+ },
959
+ });
960
+ }
862
961
 
863
- // Handle Indicator for Bridge
864
- if (feature.bridge && feature.bridge.type === "vertical") {
865
- // Create a visual indicator for the bridge
866
- // A dashed rectangle extending upwards
867
- const bridgeIndicator = new Rect({
962
+ if (feature.bridge?.type === "vertical") {
963
+ const featureTopY = position.y - visualHeight / 2;
964
+ const dielineTopY = geometry.y - geometry.height / 2;
965
+ const bridgeHeight = Math.max(0, featureTopY - dielineTopY);
966
+ if (bridgeHeight <= 0.001) {
967
+ return;
968
+ }
969
+ specs.push({
970
+ id: this.bridgeIndicatorId(index),
971
+ type: "rect",
972
+ space: "screen",
973
+ data: {
974
+ ...baseData,
975
+ markerRole: "indicator",
976
+ markerOffsetX: 0,
977
+ markerOffsetY: -visualHeight / 2,
978
+ } as MarkerData,
979
+ props: {
980
+ visible: sessionVisible,
981
+ selectable: false,
982
+ evented: false,
868
983
  width: visualWidth,
869
- height: 100 * featureScale, // Arbitrary long length to show direction
984
+ height: bridgeHeight,
870
985
  fill: "transparent",
871
986
  stroke: "#888",
872
987
  strokeWidth: 1,
873
988
  strokeDashArray: [2, 2],
874
- originX: "center",
875
- originY: "bottom", // Anchor at bottom so it extends up
876
- left: pos.x,
877
- top: pos.y - visualHeight / 2, // Start from top of feature
878
989
  opacity: 0.5,
879
- selectable: false,
880
- evented: false,
881
- });
882
-
883
- // We need to return a group containing both shape and indicator
884
- // But createMarkerShape is expected to return one object.
885
- // If we return a Group, Fabric handles it.
886
- // But the caller might wrap this in another Group if it's part of a feature group.
887
- // Fabric supports nested groups.
888
-
889
- const group = new Group([bridgeIndicator, shape], {
890
990
  originX: "center",
891
- originY: "center",
892
- left: pos.x,
893
- top: pos.y,
894
- });
895
- return group;
896
- }
991
+ originY: "bottom",
992
+ left: position.x,
993
+ top: position.y - visualHeight / 2,
994
+ },
995
+ });
996
+ }
997
+ }
897
998
 
898
- return shape;
999
+ private buildMarkerData(
1000
+ marker: MarkerRenderState,
1001
+ options: {
1002
+ markerRole: "handle" | "member";
1003
+ isGroup: boolean;
1004
+ groupId?: string;
1005
+ indices?: number[];
1006
+ anchorIndex?: number;
1007
+ memberOffsets?: GroupMemberOffset[];
1008
+ },
1009
+ ): MarkerData {
1010
+ const data: MarkerData = {
1011
+ type: "feature-marker",
1012
+ index: marker.index,
1013
+ featureId: marker.feature.id,
1014
+ markerRole: options.markerRole,
1015
+ markerOffsetX: 0,
1016
+ markerOffsetY: 0,
1017
+ isGroup: options.isGroup,
899
1018
  };
1019
+ if (options.groupId) data.groupId = options.groupId;
1020
+ if (options.indices) data.indices = options.indices;
1021
+ if (options.anchorIndex !== undefined) data.anchorIndex = options.anchorIndex;
1022
+ if (options.memberOffsets) data.memberOffsets = options.memberOffsets;
1023
+ return data;
1024
+ }
900
1025
 
901
- // Render Singles
902
- singles.forEach(({ feature, index }) => {
903
- const geometry = this.getGeometryForFeature(
904
- this.currentGeometry!,
905
- feature,
906
- );
907
- const pos = resolveFeaturePosition(feature, geometry);
908
- const marker = createMarkerShape(feature, pos);
1026
+ private markerId(index: number): string {
1027
+ return `feature.marker.${index}`;
1028
+ }
909
1029
 
910
- marker.set({
911
- visible: this.isToolActive,
912
- selectable: this.isToolActive,
913
- evented: this.isToolActive,
914
- hasControls: false,
915
- hasBorders: false,
916
- hoverCursor: "move",
917
- lockRotation: true,
918
- lockScalingX: true,
919
- lockScalingY: true,
920
- data: { type: "feature-marker", index, isGroup: false },
921
- });
1030
+ private bridgeIndicatorId(index: number): string {
1031
+ return `feature.marker.${index}.bridge`;
1032
+ }
922
1033
 
923
- canvas.add(marker);
924
- canvas.bringObjectToFront(marker);
925
- });
1034
+ private toFeatureIndex(value: unknown): number | null {
1035
+ const numeric = Number(value);
1036
+ if (!Number.isInteger(numeric) || numeric < 0) return null;
1037
+ return numeric;
1038
+ }
926
1039
 
927
- // Render Groups
928
- Object.keys(groups).forEach((groupId) => {
929
- const members = groups[groupId];
930
- if (members.length === 0) return;
1040
+ private readGroupIndices(raw: unknown): number[] {
1041
+ if (!Array.isArray(raw)) return [];
1042
+ return raw
1043
+ .map((value) => this.toFeatureIndex(value))
1044
+ .filter((value): value is number => value !== null);
1045
+ }
931
1046
 
932
- // Calculate group center (average position) to position the group correctly
933
- // But Fabric Group uses relative coordinates.
934
- // Easiest way: Create shapes at absolute positions, then Group them.
935
- // Fabric will auto-calculate group center and adjust children.
1047
+ private readGroupMemberOffsets(
1048
+ raw: unknown,
1049
+ fallbackIndices: number[] = [],
1050
+ ): GroupMemberOffset[] {
1051
+ if (Array.isArray(raw)) {
1052
+ const parsed = raw
1053
+ .map((entry) => {
1054
+ const index = this.toFeatureIndex((entry as any)?.index);
1055
+ const dx = Number((entry as any)?.dx);
1056
+ const dy = Number((entry as any)?.dy);
1057
+ if (index === null || !Number.isFinite(dx) || !Number.isFinite(dy)) {
1058
+ return null;
1059
+ }
1060
+ return { index, dx, dy };
1061
+ })
1062
+ .filter((value): value is GroupMemberOffset => !!value);
1063
+ if (parsed.length > 0) return parsed;
1064
+ }
936
1065
 
937
- const shapes = members.map(({ feature }) => {
938
- const geometry = this.getGeometryForFeature(
939
- this.currentGeometry!,
940
- feature,
941
- );
942
- const pos = resolveFeaturePosition(feature, geometry);
943
- return createMarkerShape(feature, pos);
944
- });
1066
+ return fallbackIndices.map((index) => ({ index, dx: 0, dy: 0 }));
1067
+ }
945
1068
 
946
- const groupObj = new Group(shapes, {
947
- visible: this.isToolActive,
948
- selectable: this.isToolActive,
949
- evented: this.isToolActive,
950
- hasControls: false,
951
- hasBorders: false,
952
- hoverCursor: "move",
953
- lockRotation: true,
954
- lockScalingX: true,
955
- lockScalingY: true,
956
- subTargetCheck: true, // Allow events to pass through if needed, but we treat as one
957
- interactive: false, // Children not interactive
958
- // @ts-ignore
959
- data: {
960
- type: "feature-marker",
961
- isGroup: true,
962
- groupId,
963
- indices: members.map((m) => m.index),
964
- },
1069
+ private syncMarkerVisualsByTarget(target: any, center: MarkerPoint) {
1070
+ if (target.data?.isGroup) {
1071
+ const indices = this.readGroupIndices(target.data?.indices);
1072
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
1073
+ offsets.forEach((entry) => {
1074
+ this.syncMarkerVisualObjectsToCenter(entry.index, {
1075
+ x: center.x + entry.dx,
1076
+ y: center.y + entry.dy,
1077
+ });
965
1078
  });
1079
+ this.canvasService?.requestRenderAll();
1080
+ return;
1081
+ }
966
1082
 
967
- canvas.add(groupObj);
968
- canvas.bringObjectToFront(groupObj);
969
- });
970
-
971
- this.canvasService.requestRenderAll();
1083
+ const index = this.toFeatureIndex(target.data?.index);
1084
+ if (index === null) return;
1085
+ this.syncMarkerVisualObjectsToCenter(index, center);
1086
+ this.canvasService?.requestRenderAll();
972
1087
  }
973
1088
 
974
- private enforceConstraints() {
975
- if (!this.canvasService || !this.currentGeometry) return;
976
- // Iterate markers and snap them if geometry changed
977
- const canvas = this.canvasService.canvas;
978
- const markers = canvas
1089
+ private syncMarkerVisualObjectsToCenter(index: number, center: MarkerPoint) {
1090
+ if (!this.canvasService) return;
1091
+ const markers = this.canvasService.canvas
979
1092
  .getObjects()
980
- .filter((obj: any) => obj.data?.type === "feature-marker");
1093
+ .filter(
1094
+ (obj: any) =>
1095
+ obj?.data?.type === "feature-marker" &&
1096
+ this.toFeatureIndex(obj?.data?.index) === index,
1097
+ );
981
1098
 
982
1099
  markers.forEach((marker: any) => {
983
- // Find associated feature
984
- let feature: ConstraintFeature | undefined;
985
- if (marker.data?.isGroup) {
986
- const indices = marker.data?.indices as number[];
987
- if (indices && indices.length > 0) {
988
- feature = this.workingFeatures[indices[0]];
989
- }
990
- } else {
991
- const index = marker.data?.index;
992
- if (index !== undefined) {
993
- feature = this.workingFeatures[index];
994
- }
995
- }
1100
+ const offsetX = Number(marker?.data?.markerOffsetX || 0);
1101
+ const offsetY = Number(marker?.data?.markerOffsetY || 0);
1102
+ marker.set({
1103
+ left: center.x + offsetX,
1104
+ top: center.y + offsetY,
1105
+ });
1106
+ marker.setCoords();
1107
+ });
1108
+ }
996
1109
 
997
- const geometry = this.getGeometryForFeature(
998
- this.currentGeometry!,
999
- feature,
1000
- );
1110
+ private enforceConstraints() {
1111
+ if (!this.canvasService || !this.currentGeometry) return;
1001
1112
 
1002
- const markerStrokeWidth =
1003
- (marker.strokeWidth || 2) * (marker.scaleX || 1);
1004
- const minDim = Math.min(
1005
- marker.getScaledWidth(),
1006
- marker.getScaledHeight(),
1113
+ const handles = this.canvasService.canvas
1114
+ .getObjects()
1115
+ .filter(
1116
+ (obj: any) =>
1117
+ obj?.data?.type === "feature-marker" &&
1118
+ obj?.data?.markerRole === "handle",
1007
1119
  );
1008
- const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
1009
1120
 
1121
+ handles.forEach((marker: any) => {
1122
+ const feature = this.getFeatureForMarker(marker);
1123
+ if (!feature) return;
1124
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
1010
1125
  const snapped = this.constrainPosition(
1011
- new Point(marker.left, marker.top),
1126
+ {
1127
+ x: Number(marker.left || 0),
1128
+ y: Number(marker.top || 0),
1129
+ },
1012
1130
  geometry,
1013
- limit,
1014
1131
  feature,
1015
1132
  );
1016
1133
  marker.set({ left: snapped.x, top: snapped.y });
1017
1134
  marker.setCoords();
1135
+ this.syncMarkerVisualsByTarget(marker, snapped);
1018
1136
  });
1019
- canvas.requestRenderAll();
1137
+
1138
+ this.canvasService.canvas.requestRenderAll();
1020
1139
  }
1021
1140
  }