@pooder/kit 5.3.0 → 5.4.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 (73) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.d.mts +249 -36
  3. package/dist/index.d.ts +249 -36
  4. package/dist/index.js +2374 -1049
  5. package/dist/index.mjs +2375 -1050
  6. package/package.json +1 -1
  7. package/src/extensions/background.ts +178 -85
  8. package/src/extensions/dieline.ts +1149 -1030
  9. package/src/extensions/dielineShape.ts +109 -0
  10. package/src/extensions/feature.ts +482 -366
  11. package/src/extensions/film.ts +148 -76
  12. package/src/extensions/geometry.ts +210 -44
  13. package/src/extensions/image.ts +244 -114
  14. package/src/extensions/ruler.ts +471 -268
  15. package/src/extensions/sceneLayoutModel.ts +28 -6
  16. package/src/extensions/sceneVisibility.ts +3 -10
  17. package/src/extensions/tracer.ts +1019 -980
  18. package/src/extensions/white-ink.ts +284 -231
  19. package/src/services/CanvasService.ts +543 -11
  20. package/src/services/renderSpec.ts +37 -2
  21. package/.test-dist/src/CanvasService.js +0 -249
  22. package/.test-dist/src/ViewportSystem.js +0 -75
  23. package/.test-dist/src/background.js +0 -203
  24. package/.test-dist/src/bridgeSelection.js +0 -20
  25. package/.test-dist/src/constraints.js +0 -237
  26. package/.test-dist/src/coordinate.js +0 -74
  27. package/.test-dist/src/dieline.js +0 -818
  28. package/.test-dist/src/edgeScale.js +0 -12
  29. package/.test-dist/src/extensions/background.js +0 -203
  30. package/.test-dist/src/extensions/bridgeSelection.js +0 -20
  31. package/.test-dist/src/extensions/constraints.js +0 -237
  32. package/.test-dist/src/extensions/dieline.js +0 -828
  33. package/.test-dist/src/extensions/edgeScale.js +0 -12
  34. package/.test-dist/src/extensions/feature.js +0 -825
  35. package/.test-dist/src/extensions/featureComplete.js +0 -32
  36. package/.test-dist/src/extensions/film.js +0 -167
  37. package/.test-dist/src/extensions/geometry.js +0 -545
  38. package/.test-dist/src/extensions/image.js +0 -1529
  39. package/.test-dist/src/extensions/index.js +0 -30
  40. package/.test-dist/src/extensions/maskOps.js +0 -279
  41. package/.test-dist/src/extensions/mirror.js +0 -104
  42. package/.test-dist/src/extensions/ruler.js +0 -345
  43. package/.test-dist/src/extensions/sceneLayout.js +0 -96
  44. package/.test-dist/src/extensions/sceneLayoutModel.js +0 -196
  45. package/.test-dist/src/extensions/sceneVisibility.js +0 -62
  46. package/.test-dist/src/extensions/size.js +0 -331
  47. package/.test-dist/src/extensions/tracer.js +0 -538
  48. package/.test-dist/src/extensions/white-ink.js +0 -1190
  49. package/.test-dist/src/extensions/wrappedOffsets.js +0 -33
  50. package/.test-dist/src/feature.js +0 -826
  51. package/.test-dist/src/featureComplete.js +0 -32
  52. package/.test-dist/src/film.js +0 -167
  53. package/.test-dist/src/geometry.js +0 -506
  54. package/.test-dist/src/image.js +0 -1250
  55. package/.test-dist/src/index.js +0 -18
  56. package/.test-dist/src/maskOps.js +0 -270
  57. package/.test-dist/src/mirror.js +0 -104
  58. package/.test-dist/src/renderSpec.js +0 -2
  59. package/.test-dist/src/ruler.js +0 -343
  60. package/.test-dist/src/sceneLayout.js +0 -99
  61. package/.test-dist/src/sceneLayoutModel.js +0 -196
  62. package/.test-dist/src/sceneView.js +0 -40
  63. package/.test-dist/src/sceneVisibility.js +0 -42
  64. package/.test-dist/src/services/CanvasService.js +0 -249
  65. package/.test-dist/src/services/ViewportSystem.js +0 -76
  66. package/.test-dist/src/services/index.js +0 -24
  67. package/.test-dist/src/services/renderSpec.js +0 -2
  68. package/.test-dist/src/size.js +0 -332
  69. package/.test-dist/src/tracer.js +0 -544
  70. package/.test-dist/src/units.js +0 -30
  71. package/.test-dist/src/white-ink.js +0 -829
  72. package/.test-dist/src/wrappedOffsets.js +0 -33
  73. package/.test-dist/tests/run.js +0 -94
@@ -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,17 @@ 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
+ rootLayerSpecs: {
105
+ [FEATURE_OVERLAY_LAYER_ID]: this.specs,
106
+ },
107
+ }),
108
+ { priority: 350 },
109
+ );
110
+
67
111
  const configService = context.services.get<ConfigurationService>(
68
112
  "ConfigurationService",
69
113
  );
@@ -94,7 +138,6 @@ export class FeatureTool implements Extension {
94
138
  () => this.hasWorkingChanges,
95
139
  );
96
140
 
97
- // Listen to tool activation
98
141
  context.eventBus.on("tool:activated", this.onToolActivated);
99
142
 
100
143
  this.setup();
@@ -119,23 +162,7 @@ export class FeatureTool implements Extension {
119
162
  };
120
163
 
121
164
  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();
165
+ this.redraw();
139
166
  }
140
167
 
141
168
  contribute() {
@@ -384,8 +411,7 @@ export class FeatureTool implements Extension {
384
411
 
385
412
  this.setWorkingFeatures(next);
386
413
  this.hasWorkingChanges = true;
387
- this.redraw();
388
- this.enforceConstraints();
414
+ this.redraw({ enforceConstraints: true });
389
415
  this.emitWorkingChange();
390
416
 
391
417
  return { ok: true };
@@ -420,7 +446,7 @@ export class FeatureTool implements Extension {
420
446
  { dielineWidth, dielineHeight },
421
447
  (next) => {
422
448
  this.updateCommittedFeatures(next as ConstraintFeature[]);
423
- this.workingFeatures = this.cloneFeatures(next as any);
449
+ this.workingFeatures = this.cloneFeatures(next as ConstraintFeature[]);
424
450
  this.emitWorkingChange();
425
451
  },
426
452
  );
@@ -434,7 +460,6 @@ export class FeatureTool implements Extension {
434
460
 
435
461
  this.hasWorkingChanges = false;
436
462
  this.clearFeatureSessionState();
437
- // Keep feature markers above dieline overlay after config-driven redraw.
438
463
  this.redraw();
439
464
  return { ok: true };
440
465
  }
@@ -442,18 +467,16 @@ export class FeatureTool implements Extension {
442
467
  private addFeature(type: "add" | "subtract") {
443
468
  if (!this.canvasService) return false;
444
469
 
445
- // Default to top edge center
446
470
  const newFeature: ConstraintFeature = {
447
471
  id: Date.now().toString(),
448
472
  operation: type,
449
473
  shape: "rect",
450
474
  x: 0.5,
451
- y: 0, // Top edge
475
+ y: 0,
452
476
  width: 10,
453
477
  height: 10,
454
478
  rotation: 0,
455
479
  renderBehavior: "edge",
456
- // Default constraint: path (snap to edge)
457
480
  constraints: [{ type: "path" }],
458
481
  };
459
482
 
@@ -470,7 +493,6 @@ export class FeatureTool implements Extension {
470
493
  const groupId = Date.now().toString();
471
494
  const timestamp = Date.now();
472
495
 
473
- // 1. Lug (Outer) - Add
474
496
  const lug: ConstraintFeature = {
475
497
  id: `${timestamp}-lug`,
476
498
  groupId,
@@ -484,7 +506,6 @@ export class FeatureTool implements Extension {
484
506
  constraints: [{ type: "path" }],
485
507
  };
486
508
 
487
- // 2. Hole (Inner) - Subtract
488
509
  const hole: ConstraintFeature = {
489
510
  id: `${timestamp}-hole`,
490
511
  groupId,
@@ -507,10 +528,8 @@ export class FeatureTool implements Extension {
507
528
 
508
529
  private getGeometryForFeature(
509
530
  geometry: DielineGeometry,
510
- feature?: ConstraintFeature,
531
+ _feature?: ConstraintFeature,
511
532
  ): 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
533
  return geometry;
515
534
  }
516
535
 
@@ -518,12 +537,10 @@ export class FeatureTool implements Extension {
518
537
  if (!this.canvasService || !this.context) return;
519
538
  const canvas = this.canvasService.canvas;
520
539
 
521
- // 1. Listen for Scene Geometry Changes
522
540
  if (!this.handleSceneGeometryChange) {
523
541
  this.handleSceneGeometryChange = (geometry: DielineGeometry) => {
524
542
  this.currentGeometry = geometry;
525
- this.redraw();
526
- this.enforceConstraints();
543
+ this.redraw({ enforceConstraints: true });
527
544
  };
528
545
  this.context.eventBus.on(
529
546
  "scene:geometry:change",
@@ -531,7 +548,6 @@ export class FeatureTool implements Extension {
531
548
  );
532
549
  }
533
550
 
534
- // 2. Initial Fetch of Geometry
535
551
  const commandService = this.context.services.get<any>("CommandService");
536
552
  if (commandService) {
537
553
  try {
@@ -546,119 +562,41 @@ export class FeatureTool implements Extension {
546
562
  } catch (e) {}
547
563
  }
548
564
 
549
- // 3. Setup Canvas Interaction
550
565
  if (!this.handleMoving) {
551
566
  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,
567
+ const target = this.getDraggableMarkerTarget(e?.target);
568
+ if (!target || !this.currentGeometry) return;
569
+
570
+ const feature = this.getFeatureForMarker(target);
571
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
572
+ const snapped = this.constrainPosition(
573
+ {
574
+ x: Number(target.left || 0),
575
+ y: Number(target.top || 0),
576
+ },
577
+ geometry,
572
578
  feature,
573
579
  );
574
580
 
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
581
  target.set({
594
582
  left: snapped.x,
595
583
  top: snapped.y,
596
584
  });
585
+ target.setCoords();
586
+
587
+ this.syncMarkerVisualsByTarget(target, snapped);
597
588
  };
598
589
  canvas.on("object:moving", this.handleMoving);
599
590
  }
600
591
 
601
592
  if (!this.handleModified) {
602
593
  this.handleModified = (e: any) => {
603
- const target = e.target;
604
- if (!target || target.data?.type !== "feature-marker") return;
594
+ const target = this.getDraggableMarkerTarget(e?.target);
595
+ if (!target) return;
605
596
 
606
597
  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();
598
+ this.syncGroupFromCanvas(target);
660
599
  } else {
661
- // Single object
662
600
  this.syncFeatureFromCanvas(target);
663
601
  }
664
602
  };
@@ -686,20 +624,33 @@ export class FeatureTool implements Extension {
686
624
  this.handleSceneGeometryChange = null;
687
625
  }
688
626
 
689
- const objects = canvas
690
- .getObjects()
691
- .filter((obj: any) => obj.data?.type === "feature-marker");
692
- objects.forEach((obj) => canvas.remove(obj));
627
+ this.renderSeq += 1;
628
+ this.specs = [];
629
+ this.renderProducerDisposable?.dispose();
630
+ this.renderProducerDisposable = undefined;
631
+ void this.canvasService.flushRenderFromProducers();
632
+ }
633
+
634
+ private getDraggableMarkerTarget(target: any): any | null {
635
+ if (!target || target.data?.type !== "feature-marker") return null;
636
+ if (target.data?.markerRole !== "handle") return null;
637
+ return target;
638
+ }
693
639
 
694
- this.canvasService.requestRenderAll();
640
+ private getFeatureForMarker(target: any): ConstraintFeature | undefined {
641
+ const data = target?.data || {};
642
+ const index = data.isGroup
643
+ ? this.toFeatureIndex(data.anchorIndex)
644
+ : this.toFeatureIndex(data.index);
645
+ if (index === null) return undefined;
646
+ return this.workingFeatures[index];
695
647
  }
696
648
 
697
649
  private constrainPosition(
698
- p: Point,
650
+ p: MarkerPoint,
699
651
  geometry: DielineGeometry,
700
- limit: number,
701
652
  feature?: ConstraintFeature,
702
- ): { x: number; y: number } {
653
+ ): MarkerPoint {
703
654
  if (!feature) {
704
655
  return { x: p.x, y: p.y };
705
656
  }
@@ -707,7 +658,6 @@ export class FeatureTool implements Extension {
707
658
  const minX = geometry.x - geometry.width / 2;
708
659
  const minY = geometry.y - geometry.height / 2;
709
660
 
710
- // Normalize
711
661
  const nx = geometry.width > 0 ? (p.x - minX) / geometry.width : 0.5;
712
662
  const ny = geometry.height > 0 ? (p.y - minY) / geometry.height : 0.5;
713
663
 
@@ -715,7 +665,6 @@ export class FeatureTool implements Extension {
715
665
  const dielineWidth = geometry.width / scale;
716
666
  const dielineHeight = geometry.height / scale;
717
667
 
718
- // Filter constraints: only apply those that are NOT validateOnly
719
668
  const activeConstraints = feature.constraints?.filter(
720
669
  (c) => !c.validateOnly,
721
670
  );
@@ -732,290 +681,457 @@ export class FeatureTool implements Extension {
732
681
  activeConstraints,
733
682
  );
734
683
 
735
- // Denormalize
736
684
  return {
737
685
  x: minX + constrained.x * geometry.width,
738
686
  y: minY + constrained.y * geometry.height,
739
687
  };
740
688
  }
741
689
 
690
+ private toNormalizedPoint(
691
+ point: MarkerPoint,
692
+ geometry: DielineGeometry,
693
+ ): MarkerPoint {
694
+ const left = geometry.x - geometry.width / 2;
695
+ const top = geometry.y - geometry.height / 2;
696
+ return {
697
+ x: geometry.width > 0 ? (point.x - left) / geometry.width : 0.5,
698
+ y: geometry.height > 0 ? (point.y - top) / geometry.height : 0.5,
699
+ };
700
+ }
701
+
742
702
  private syncFeatureFromCanvas(target: any) {
743
- if (!this.currentGeometry || !this.context) return;
744
-
745
- const index = target.data?.index;
746
- if (
747
- index === undefined ||
748
- index < 0 ||
749
- index >= this.workingFeatures.length
750
- )
751
- return;
703
+ if (!this.currentGeometry) return;
704
+
705
+ const index = this.toFeatureIndex(target.data?.index);
706
+ if (index === null || index >= this.workingFeatures.length) return;
752
707
 
753
708
  const feature = this.workingFeatures[index];
754
709
  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;
710
+ const normalized = this.toNormalizedPoint(
711
+ {
712
+ x: Number(target.left || 0),
713
+ y: Number(target.top || 0),
714
+ },
715
+ geometry,
716
+ );
764
717
 
765
- // Update feature
766
718
  const updatedFeature = {
767
719
  ...feature,
768
- x: normalizedX,
769
- y: normalizedY,
770
- // Could also update rotation if we allowed rotating markers
720
+ x: normalized.x,
721
+ y: normalized.y,
771
722
  };
772
723
 
773
- const newFeatures = [...this.workingFeatures];
774
- newFeatures[index] = updatedFeature;
775
- this.setWorkingFeatures(newFeatures);
724
+ const next = [...this.workingFeatures];
725
+ next[index] = updatedFeature;
726
+ this.setWorkingFeatures(next);
776
727
  this.hasWorkingChanges = true;
777
728
  this.emitWorkingChange();
778
729
  }
779
730
 
780
- private redraw() {
781
- if (!this.canvasService || !this.currentGeometry) return;
782
- const canvas = this.canvasService.canvas;
783
- const geometry = this.currentGeometry;
731
+ private syncGroupFromCanvas(target: any) {
732
+ if (!this.currentGeometry) return;
784
733
 
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));
734
+ const indices = this.readGroupIndices(target.data?.indices);
735
+ if (indices.length === 0) return;
736
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
790
737
 
791
- if (!this.workingFeatures || this.workingFeatures.length === 0) {
792
- this.canvasService.requestRenderAll();
793
- return;
738
+ const anchorCenter = {
739
+ x: Number(target.left || 0),
740
+ y: Number(target.top || 0),
741
+ };
742
+
743
+ const next = [...this.workingFeatures];
744
+ let changed = false;
745
+ offsets.forEach((entry) => {
746
+ const index = entry.index;
747
+ if (index < 0 || index >= next.length) return;
748
+ const feature = next[index];
749
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
750
+ const normalized = this.toNormalizedPoint(
751
+ {
752
+ x: anchorCenter.x + entry.dx,
753
+ y: anchorCenter.y + entry.dy,
754
+ },
755
+ geometry,
756
+ );
757
+
758
+ if (feature.x !== normalized.x || feature.y !== normalized.y) {
759
+ next[index] = {
760
+ ...feature,
761
+ x: normalized.x,
762
+ y: normalized.y,
763
+ };
764
+ changed = true;
765
+ }
766
+ });
767
+
768
+ if (!changed) return;
769
+ this.setWorkingFeatures(next);
770
+ this.hasWorkingChanges = true;
771
+ this.emitWorkingChange();
772
+ }
773
+
774
+ private redraw(options: { enforceConstraints?: boolean } = {}) {
775
+ void this.redrawAsync(options);
776
+ }
777
+
778
+ private async redrawAsync(options: { enforceConstraints?: boolean } = {}) {
779
+ if (!this.canvasService) return;
780
+
781
+ const seq = ++this.renderSeq;
782
+ this.specs = this.buildFeatureSpecs();
783
+ if (seq !== this.renderSeq) return;
784
+
785
+ await this.canvasService.flushRenderFromProducers();
786
+ if (seq !== this.renderSeq) return;
787
+
788
+ this.syncOverlayOrder();
789
+ if (options.enforceConstraints) {
790
+ this.enforceConstraints();
794
791
  }
792
+ }
795
793
 
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 });
794
+ private syncOverlayOrder() {
795
+ if (!this.canvasService) return;
796
+ this.canvasService.bringLayerToFront(FEATURE_OVERLAY_LAYER_ID);
797
+ this.canvasService.bringLayerToFront("ruler-overlay");
798
+ }
799
+
800
+ private buildFeatureSpecs(): RenderObjectSpec[] {
801
+ if (!this.currentGeometry || this.workingFeatures.length === 0) {
802
+ return [];
803
+ }
804
+
805
+ const groups = new Map<string, MarkerRenderState[]>();
806
+ const singles: MarkerRenderState[] = [];
807
+
808
+ this.workingFeatures.forEach((feature, index) => {
809
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
810
+ const position = resolveFeaturePosition(feature, geometry);
811
+ const scale = geometry.scale || 1;
812
+ const marker: MarkerRenderState = {
813
+ feature,
814
+ index,
815
+ position,
816
+ geometry,
817
+ scale,
818
+ };
819
+
820
+ if (feature.groupId) {
821
+ const list = groups.get(feature.groupId) || [];
822
+ list.push(marker);
823
+ groups.set(feature.groupId, list);
824
+ return;
811
825
  }
826
+ singles.push(marker);
827
+ });
828
+
829
+ const specs: RenderObjectSpec[] = [];
830
+
831
+ singles.forEach((marker) => {
832
+ this.appendMarkerSpecs(specs, marker, {
833
+ markerRole: "handle",
834
+ isGroup: false,
835
+ });
836
+ });
837
+
838
+ groups.forEach((members, groupId) => {
839
+ if (!members.length) return;
840
+ const anchor = members[0];
841
+ const memberOffsets: GroupMemberOffset[] = members.map((member) => ({
842
+ index: member.index,
843
+ dx: member.position.x - anchor.position.x,
844
+ dy: member.position.y - anchor.position.y,
845
+ }));
846
+ const indices = members.map((member) => member.index);
847
+
848
+ members
849
+ .filter((member) => member.index !== anchor.index)
850
+ .forEach((member) => {
851
+ this.appendMarkerSpecs(specs, member, {
852
+ markerRole: "member",
853
+ isGroup: false,
854
+ groupId,
855
+ });
856
+ });
857
+
858
+ this.appendMarkerSpecs(specs, anchor, {
859
+ markerRole: "handle",
860
+ isGroup: true,
861
+ groupId,
862
+ indices,
863
+ anchorIndex: anchor.index,
864
+ memberOffsets,
865
+ });
812
866
  });
813
867
 
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({
868
+ return specs;
869
+ }
870
+
871
+ private appendMarkerSpecs(
872
+ specs: RenderObjectSpec[],
873
+ marker: MarkerRenderState,
874
+ options: {
875
+ markerRole: "handle" | "member";
876
+ isGroup: boolean;
877
+ groupId?: string;
878
+ indices?: number[];
879
+ anchorIndex?: number;
880
+ memberOffsets?: GroupMemberOffset[];
881
+ },
882
+ ) {
883
+ const { feature, index, position, scale, geometry } = marker;
884
+ const baseRadius =
885
+ feature.shape === "circle"
886
+ ? (feature.radius ?? DEFAULT_CIRCLE_RADIUS)
887
+ : (feature.radius ?? 0);
888
+ const baseWidth =
889
+ feature.shape === "circle"
890
+ ? baseRadius * 2
891
+ : (feature.width ?? DEFAULT_RECT_SIZE);
892
+ const baseHeight =
893
+ feature.shape === "circle"
894
+ ? baseRadius * 2
895
+ : (feature.height ?? DEFAULT_RECT_SIZE);
896
+ const visualWidth = baseWidth * scale;
897
+ const visualHeight = baseHeight * scale;
898
+ const visualRadius = baseRadius * scale;
899
+ const color =
900
+ feature.color || (feature.operation === "add" ? "#00FF00" : "#FF0000");
901
+ const strokeDash =
902
+ feature.strokeDash ||
903
+ (feature.operation === "subtract" ? [4, 4] : undefined);
904
+
905
+ const interactive = options.markerRole === "handle";
906
+ const baseData = this.buildMarkerData(marker, options);
907
+ const commonProps = {
908
+ visible: this.isToolActive,
909
+ selectable: interactive && this.isToolActive,
910
+ evented: interactive && this.isToolActive,
911
+ hasControls: false,
912
+ hasBorders: false,
913
+ hoverCursor: interactive ? "move" : "default",
914
+ lockRotation: true,
915
+ lockScalingX: true,
916
+ lockScalingY: true,
917
+ fill: "transparent",
918
+ stroke: color,
919
+ strokeWidth: FEATURE_STROKE_WIDTH,
920
+ strokeDashArray: strokeDash,
921
+ originX: "center" as const,
922
+ originY: "center" as const,
923
+ left: position.x,
924
+ top: position.y,
925
+ angle: feature.rotation || 0,
926
+ };
927
+
928
+ const markerId = this.markerId(index);
929
+ if (feature.shape === "rect") {
930
+ specs.push({
931
+ id: markerId,
932
+ type: "rect",
933
+ space: "screen",
934
+ data: baseData,
935
+ props: {
936
+ ...commonProps,
833
937
  width: visualWidth,
834
938
  height: visualHeight,
835
939
  rx: visualRadius,
836
940
  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
- }
941
+ },
942
+ });
943
+ } else {
944
+ specs.push({
945
+ id: markerId,
946
+ type: "rect",
947
+ space: "screen",
948
+ data: baseData,
949
+ props: {
950
+ ...commonProps,
951
+ width: visualWidth,
952
+ height: visualHeight,
953
+ rx: visualRadius,
954
+ ry: visualRadius,
955
+ },
956
+ });
957
+ }
862
958
 
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({
959
+ if (feature.bridge?.type === "vertical") {
960
+ const featureTopY = position.y - visualHeight / 2;
961
+ const dielineTopY = geometry.y - geometry.height / 2;
962
+ const bridgeHeight = Math.max(0, featureTopY - dielineTopY);
963
+ if (bridgeHeight <= 0.001) {
964
+ return;
965
+ }
966
+ specs.push({
967
+ id: this.bridgeIndicatorId(index),
968
+ type: "rect",
969
+ space: "screen",
970
+ data: {
971
+ ...baseData,
972
+ markerRole: "indicator",
973
+ markerOffsetX: 0,
974
+ markerOffsetY: -visualHeight / 2,
975
+ } as MarkerData,
976
+ props: {
977
+ visible: this.isToolActive,
978
+ selectable: false,
979
+ evented: false,
868
980
  width: visualWidth,
869
- height: 100 * featureScale, // Arbitrary long length to show direction
981
+ height: bridgeHeight,
870
982
  fill: "transparent",
871
983
  stroke: "#888",
872
984
  strokeWidth: 1,
873
985
  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
986
  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
987
  originX: "center",
891
- originY: "center",
892
- left: pos.x,
893
- top: pos.y,
894
- });
895
- return group;
896
- }
988
+ originY: "bottom",
989
+ left: position.x,
990
+ top: position.y - visualHeight / 2,
991
+ },
992
+ });
993
+ }
994
+ }
897
995
 
898
- return shape;
996
+ private buildMarkerData(
997
+ marker: MarkerRenderState,
998
+ options: {
999
+ markerRole: "handle" | "member";
1000
+ isGroup: boolean;
1001
+ groupId?: string;
1002
+ indices?: number[];
1003
+ anchorIndex?: number;
1004
+ memberOffsets?: GroupMemberOffset[];
1005
+ },
1006
+ ): MarkerData {
1007
+ const data: MarkerData = {
1008
+ type: "feature-marker",
1009
+ index: marker.index,
1010
+ featureId: marker.feature.id,
1011
+ markerRole: options.markerRole,
1012
+ markerOffsetX: 0,
1013
+ markerOffsetY: 0,
1014
+ isGroup: options.isGroup,
899
1015
  };
1016
+ if (options.groupId) data.groupId = options.groupId;
1017
+ if (options.indices) data.indices = options.indices;
1018
+ if (options.anchorIndex !== undefined) data.anchorIndex = options.anchorIndex;
1019
+ if (options.memberOffsets) data.memberOffsets = options.memberOffsets;
1020
+ return data;
1021
+ }
900
1022
 
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);
1023
+ private markerId(index: number): string {
1024
+ return `feature.marker.${index}`;
1025
+ }
909
1026
 
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
- });
1027
+ private bridgeIndicatorId(index: number): string {
1028
+ return `feature.marker.${index}.bridge`;
1029
+ }
922
1030
 
923
- canvas.add(marker);
924
- canvas.bringObjectToFront(marker);
925
- });
1031
+ private toFeatureIndex(value: unknown): number | null {
1032
+ const numeric = Number(value);
1033
+ if (!Number.isInteger(numeric) || numeric < 0) return null;
1034
+ return numeric;
1035
+ }
926
1036
 
927
- // Render Groups
928
- Object.keys(groups).forEach((groupId) => {
929
- const members = groups[groupId];
930
- if (members.length === 0) return;
1037
+ private readGroupIndices(raw: unknown): number[] {
1038
+ if (!Array.isArray(raw)) return [];
1039
+ return raw
1040
+ .map((value) => this.toFeatureIndex(value))
1041
+ .filter((value): value is number => value !== null);
1042
+ }
931
1043
 
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.
1044
+ private readGroupMemberOffsets(
1045
+ raw: unknown,
1046
+ fallbackIndices: number[] = [],
1047
+ ): GroupMemberOffset[] {
1048
+ if (Array.isArray(raw)) {
1049
+ const parsed = raw
1050
+ .map((entry) => {
1051
+ const index = this.toFeatureIndex((entry as any)?.index);
1052
+ const dx = Number((entry as any)?.dx);
1053
+ const dy = Number((entry as any)?.dy);
1054
+ if (index === null || !Number.isFinite(dx) || !Number.isFinite(dy)) {
1055
+ return null;
1056
+ }
1057
+ return { index, dx, dy };
1058
+ })
1059
+ .filter((value): value is GroupMemberOffset => !!value);
1060
+ if (parsed.length > 0) return parsed;
1061
+ }
936
1062
 
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
- });
1063
+ return fallbackIndices.map((index) => ({ index, dx: 0, dy: 0 }));
1064
+ }
945
1065
 
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
- },
1066
+ private syncMarkerVisualsByTarget(target: any, center: MarkerPoint) {
1067
+ if (target.data?.isGroup) {
1068
+ const indices = this.readGroupIndices(target.data?.indices);
1069
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
1070
+ offsets.forEach((entry) => {
1071
+ this.syncMarkerVisualObjectsToCenter(entry.index, {
1072
+ x: center.x + entry.dx,
1073
+ y: center.y + entry.dy,
1074
+ });
965
1075
  });
1076
+ this.canvasService?.requestRenderAll();
1077
+ return;
1078
+ }
966
1079
 
967
- canvas.add(groupObj);
968
- canvas.bringObjectToFront(groupObj);
969
- });
970
-
971
- this.canvasService.requestRenderAll();
1080
+ const index = this.toFeatureIndex(target.data?.index);
1081
+ if (index === null) return;
1082
+ this.syncMarkerVisualObjectsToCenter(index, center);
1083
+ this.canvasService?.requestRenderAll();
972
1084
  }
973
1085
 
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
1086
+ private syncMarkerVisualObjectsToCenter(index: number, center: MarkerPoint) {
1087
+ if (!this.canvasService) return;
1088
+ const markers = this.canvasService.canvas
979
1089
  .getObjects()
980
- .filter((obj: any) => obj.data?.type === "feature-marker");
1090
+ .filter(
1091
+ (obj: any) =>
1092
+ obj?.data?.type === "feature-marker" &&
1093
+ this.toFeatureIndex(obj?.data?.index) === index,
1094
+ );
981
1095
 
982
1096
  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
- }
1097
+ const offsetX = Number(marker?.data?.markerOffsetX || 0);
1098
+ const offsetY = Number(marker?.data?.markerOffsetY || 0);
1099
+ marker.set({
1100
+ left: center.x + offsetX,
1101
+ top: center.y + offsetY,
1102
+ });
1103
+ marker.setCoords();
1104
+ });
1105
+ }
996
1106
 
997
- const geometry = this.getGeometryForFeature(
998
- this.currentGeometry!,
999
- feature,
1000
- );
1107
+ private enforceConstraints() {
1108
+ if (!this.canvasService || !this.currentGeometry) return;
1001
1109
 
1002
- const markerStrokeWidth =
1003
- (marker.strokeWidth || 2) * (marker.scaleX || 1);
1004
- const minDim = Math.min(
1005
- marker.getScaledWidth(),
1006
- marker.getScaledHeight(),
1110
+ const handles = this.canvasService.canvas
1111
+ .getObjects()
1112
+ .filter(
1113
+ (obj: any) =>
1114
+ obj?.data?.type === "feature-marker" &&
1115
+ obj?.data?.markerRole === "handle",
1007
1116
  );
1008
- const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
1009
1117
 
1118
+ handles.forEach((marker: any) => {
1119
+ const feature = this.getFeatureForMarker(marker);
1120
+ if (!feature) return;
1121
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
1010
1122
  const snapped = this.constrainPosition(
1011
- new Point(marker.left, marker.top),
1123
+ {
1124
+ x: Number(marker.left || 0),
1125
+ y: Number(marker.top || 0),
1126
+ },
1012
1127
  geometry,
1013
- limit,
1014
1128
  feature,
1015
1129
  );
1016
1130
  marker.set({ left: snapped.x, top: snapped.y });
1017
1131
  marker.setCoords();
1132
+ this.syncMarkerVisualsByTarget(marker, snapped);
1018
1133
  });
1019
- canvas.requestRenderAll();
1134
+
1135
+ this.canvasService.canvas.requestRenderAll();
1020
1136
  }
1021
1137
  }