@pooder/kit 3.4.0 → 4.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.
package/src/feature.ts ADDED
@@ -0,0 +1,767 @@
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ ConfigurationService,
8
+ } from "@pooder/core";
9
+ import { Circle, Group, Point, Rect } from "fabric";
10
+ import CanvasService from "./CanvasService";
11
+ import { DielineGeometry } from "./dieline";
12
+ import {
13
+ getNearestPointOnDieline,
14
+ DielineFeature,
15
+ resolveFeaturePosition,
16
+ } from "./geometry";
17
+ import { Coordinate } from "./coordinate";
18
+
19
+ export class FeatureTool implements Extension {
20
+ id = "pooder.kit.feature";
21
+
22
+ public metadata = {
23
+ name: "FeatureTool",
24
+ };
25
+
26
+ private features: DielineFeature[] = [];
27
+ private canvasService?: CanvasService;
28
+ private context?: ExtensionContext;
29
+ private isUpdatingConfig = false;
30
+ private isToolActive = false;
31
+
32
+ private handleMoving: ((e: any) => void) | null = null;
33
+ private handleModified: ((e: any) => void) | null = null;
34
+ private handleDielineChange: ((geometry: DielineGeometry) => void) | null =
35
+ null;
36
+
37
+ private currentGeometry: DielineGeometry | null = null;
38
+
39
+ constructor(
40
+ options?: Partial<{
41
+ features: DielineFeature[];
42
+ }>,
43
+ ) {
44
+ if (options) {
45
+ Object.assign(this, options);
46
+ }
47
+ }
48
+
49
+ activate(context: ExtensionContext) {
50
+ this.context = context;
51
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
52
+
53
+ if (!this.canvasService) {
54
+ console.warn("CanvasService not found for FeatureTool");
55
+ return;
56
+ }
57
+
58
+ const configService = context.services.get<ConfigurationService>(
59
+ "ConfigurationService",
60
+ );
61
+ if (configService) {
62
+ this.features = configService.get("dieline.features", []);
63
+
64
+ configService.onAnyChange((e: { key: string; value: any }) => {
65
+ if (this.isUpdatingConfig) return;
66
+
67
+ if (e.key === "dieline.features") {
68
+ this.features = e.value || [];
69
+ this.redraw();
70
+ }
71
+ });
72
+ }
73
+
74
+ // Listen to tool activation
75
+ context.eventBus.on("tool:activated", this.onToolActivated);
76
+
77
+ this.setup();
78
+ }
79
+
80
+ deactivate(context: ExtensionContext) {
81
+ context.eventBus.off("tool:activated", this.onToolActivated);
82
+ this.teardown();
83
+ this.canvasService = undefined;
84
+ this.context = undefined;
85
+ }
86
+
87
+ private onToolActivated = (event: { id: string }) => {
88
+ this.isToolActive = event.id === this.id;
89
+ this.updateVisibility();
90
+ };
91
+
92
+ private updateVisibility() {
93
+ if (!this.canvasService) return;
94
+ const canvas = this.canvasService.canvas;
95
+ const markers = canvas
96
+ .getObjects()
97
+ .filter((obj: any) => obj.data?.type === "feature-marker");
98
+
99
+ markers.forEach((marker: any) => {
100
+ // If tool active, allow selection. If not, disable selection.
101
+ // Also might want to hide them entirely or just disable interaction.
102
+ // Assuming we only want to see/edit holes when tool is active.
103
+ marker.set({
104
+ visible: this.isToolActive, // Or just selectable: false if we want them visible but locked
105
+ selectable: this.isToolActive,
106
+ evented: this.isToolActive
107
+ });
108
+ });
109
+ canvas.requestRenderAll();
110
+ }
111
+
112
+ contribute() {
113
+ return {
114
+ [ContributionPointIds.COMMANDS]: [
115
+ {
116
+ command: "addFeature",
117
+ title: "Add Edge Feature",
118
+ handler: (type: "add" | "subtract" = "subtract") => {
119
+ return this.addFeature(type);
120
+ },
121
+ },
122
+ {
123
+ command: "addHole",
124
+ title: "Add Hole",
125
+ handler: () => {
126
+ return this.addFeature("subtract");
127
+ },
128
+ },
129
+ {
130
+ command: "addDoubleLayerHole",
131
+ title: "Add Double Layer Hole",
132
+ handler: () => {
133
+ return this.addDoubleLayerHole();
134
+ },
135
+ },
136
+ {
137
+ command: "clearFeatures",
138
+ title: "Clear Features",
139
+ handler: () => {
140
+ const configService =
141
+ this.context?.services.get<ConfigurationService>(
142
+ "ConfigurationService",
143
+ );
144
+ if (configService) {
145
+ configService.update("dieline.features", []);
146
+ }
147
+ return true;
148
+ },
149
+ },
150
+ ] as CommandContribution[],
151
+ };
152
+ }
153
+
154
+ private addFeature(type: "add" | "subtract") {
155
+ if (!this.canvasService) return false;
156
+
157
+ const configService = this.context?.services.get<ConfigurationService>(
158
+ "ConfigurationService",
159
+ );
160
+ const unit = configService?.get("dieline.unit", "mm") || "mm";
161
+ const defaultSize = Coordinate.convertUnit(10, "mm", unit);
162
+
163
+ // Default to top edge center
164
+ const newFeature: DielineFeature = {
165
+ id: Date.now().toString(),
166
+ operation: type,
167
+ placement: "edge",
168
+ shape: "rect",
169
+ x: 0.5,
170
+ y: 0, // Top edge
171
+ width: defaultSize,
172
+ height: defaultSize,
173
+ rotation: 0,
174
+ };
175
+
176
+ if (configService) {
177
+ const current = configService.get(
178
+ "dieline.features",
179
+ [],
180
+ ) as DielineFeature[];
181
+ configService.update("dieline.features", [...current, newFeature]);
182
+ }
183
+ return true;
184
+ }
185
+
186
+ private addDoubleLayerHole() {
187
+ if (!this.canvasService) return false;
188
+
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
+ const groupId = Date.now().toString();
197
+ const timestamp = Date.now();
198
+
199
+ // 1. Lug (Outer) - Add
200
+ const lug: DielineFeature = {
201
+ id: `${timestamp}-lug`,
202
+ groupId,
203
+ operation: "add",
204
+ shape: "circle",
205
+ placement: "edge",
206
+ x: 0.5,
207
+ y: 0,
208
+ radius: lugRadius, // 20mm
209
+ rotation: 0,
210
+ };
211
+
212
+ // 2. Hole (Inner) - Subtract
213
+ const hole: DielineFeature = {
214
+ id: `${timestamp}-hole`,
215
+ groupId,
216
+ operation: "subtract",
217
+ shape: "circle",
218
+ placement: "edge",
219
+ x: 0.5,
220
+ y: 0,
221
+ radius: holeRadius, // 15mm
222
+ rotation: 0,
223
+ };
224
+
225
+ if (configService) {
226
+ const current = configService.get(
227
+ "dieline.features",
228
+ [],
229
+ ) as DielineFeature[];
230
+ configService.update("dieline.features", [...current, lug, hole]);
231
+ }
232
+ return true;
233
+ }
234
+
235
+ private getGeometryForFeature(
236
+ geometry: DielineGeometry,
237
+ feature?: DielineFeature,
238
+ ): DielineGeometry {
239
+ // Legacy support or specialized scaling can go here if needed
240
+ // Currently all features operate on the base geometry (or scaled version of it)
241
+ return geometry;
242
+ }
243
+
244
+ private setup() {
245
+ if (!this.canvasService || !this.context) return;
246
+ const canvas = this.canvasService.canvas;
247
+
248
+ // 1. Listen for Dieline Geometry Changes
249
+ if (!this.handleDielineChange) {
250
+ this.handleDielineChange = (geometry: DielineGeometry) => {
251
+ this.currentGeometry = geometry;
252
+ this.redraw();
253
+ this.enforceConstraints();
254
+ };
255
+ this.context.eventBus.on(
256
+ "dieline:geometry:change",
257
+ this.handleDielineChange,
258
+ );
259
+ }
260
+
261
+ // 2. Initial Fetch of Geometry
262
+ const commandService = this.context.services.get<any>("CommandService");
263
+ if (commandService) {
264
+ try {
265
+ Promise.resolve(commandService.executeCommand("getGeometry")).then(
266
+ (g) => {
267
+ if (g) {
268
+ this.currentGeometry = g as DielineGeometry;
269
+ this.redraw();
270
+ }
271
+ },
272
+ );
273
+ } catch (e) {}
274
+ }
275
+
276
+ // 3. Setup Canvas Interaction
277
+ if (!this.handleMoving) {
278
+ this.handleMoving = (e: any) => {
279
+ const target = e.target;
280
+ if (!target || target.data?.type !== "feature-marker") return;
281
+ if (!this.currentGeometry) return;
282
+
283
+ // Determine feature to use for snapping context
284
+ let feature: DielineFeature | undefined;
285
+ if (target.data?.isGroup) {
286
+ const indices = target.data?.indices as number[];
287
+ if (indices && indices.length > 0) {
288
+ feature = this.features[indices[0]];
289
+ }
290
+ } else {
291
+ const index = target.data?.index;
292
+ if (index !== undefined) {
293
+ feature = this.features[index];
294
+ }
295
+ }
296
+
297
+ const geometry = this.getGeometryForFeature(
298
+ this.currentGeometry,
299
+ feature,
300
+ );
301
+
302
+ // Snap to edge during move
303
+ // For Group, target.left/top is group center (or top-left depending on origin)
304
+ // We snap the target position itself.
305
+ const p = new Point(target.left, target.top);
306
+
307
+ // Calculate limit based on target size (min dimension / 2 ensures overlap)
308
+ // Also subtract stroke width to ensure visual overlap (not just tangent)
309
+ // target.strokeWidth for group is usually 0, need a safe default (e.g. 2 for markers)
310
+ const markerStrokeWidth = (target.strokeWidth || 2) * (target.scaleX || 1);
311
+ const minDim = Math.min(target.getScaledWidth(), target.getScaledHeight());
312
+ const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
313
+
314
+ const snapped = this.constrainPosition(p, geometry, limit, feature);
315
+
316
+ target.set({
317
+ left: snapped.x,
318
+ top: snapped.y,
319
+ });
320
+ };
321
+ canvas.on("object:moving", this.handleMoving);
322
+ }
323
+
324
+ if (!this.handleModified) {
325
+ this.handleModified = (e: any) => {
326
+ const target = e.target;
327
+ if (!target || target.data?.type !== "feature-marker") return;
328
+
329
+ // Sync changes back to config
330
+ if (target.data?.isGroup) {
331
+ // It's a Group object
332
+ const groupObj = target as Group;
333
+ // @ts-ignore
334
+ const indices = groupObj.data?.indices as number[];
335
+ if (!indices) return;
336
+
337
+ // We need to update all features in the group based on their new absolute positions.
338
+ // Fabric Group children positions are relative to group center.
339
+ // We need to calculate absolute position for each child.
340
+ // Note: groupObj has already been moved to new position (target.left, target.top)
341
+
342
+ const groupCenter = new Point(groupObj.left, groupObj.top);
343
+ // Get group matrix to transform children
344
+ // Simplified: just add relative coordinates if no rotation/scaling on group
345
+ // We locked rotation/scaling, so it's safe.
346
+
347
+ const newFeatures = [...this.features];
348
+ const { x, y } = this.currentGeometry!; // Center is same
349
+
350
+ // Fabric Group objects have .getObjects() which returns children
351
+ // But children inside group have coordinates relative to group center.
352
+ // center is (0,0) inside the group local coordinate system.
353
+
354
+ groupObj.getObjects().forEach((child, i) => {
355
+ const originalIndex = indices[i];
356
+ const feature = this.features[originalIndex];
357
+ const geometry = this.getGeometryForFeature(
358
+ this.currentGeometry!,
359
+ feature,
360
+ );
361
+ const { width, height } = geometry;
362
+ const layoutLeft = x - width / 2;
363
+ const layoutTop = y - height / 2;
364
+
365
+ // Calculate absolute position
366
+ // child.left/top are relative to group center
367
+ const absX = groupCenter.x + (child.left || 0);
368
+ const absY = groupCenter.y + (child.top || 0);
369
+
370
+ // Normalize
371
+ const normalizedX = width > 0 ? (absX - layoutLeft) / width : 0.5;
372
+ const normalizedY = height > 0 ? (absY - layoutTop) / height : 0.5;
373
+
374
+ newFeatures[originalIndex] = {
375
+ ...newFeatures[originalIndex],
376
+ x: normalizedX,
377
+ y: normalizedY,
378
+ };
379
+ });
380
+
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
+ }
395
+ } else {
396
+ // Single object
397
+ this.syncFeatureFromCanvas(target);
398
+ }
399
+ };
400
+ canvas.on("object:modified", this.handleModified);
401
+ }
402
+ }
403
+
404
+ private teardown() {
405
+ if (!this.canvasService) return;
406
+ const canvas = this.canvasService.canvas;
407
+
408
+ if (this.handleMoving) {
409
+ canvas.off("object:moving", this.handleMoving);
410
+ this.handleMoving = null;
411
+ }
412
+ if (this.handleModified) {
413
+ canvas.off("object:modified", this.handleModified);
414
+ this.handleModified = null;
415
+ }
416
+ if (this.handleDielineChange && this.context) {
417
+ this.context.eventBus.off(
418
+ "dieline:geometry:change",
419
+ this.handleDielineChange,
420
+ );
421
+ this.handleDielineChange = null;
422
+ }
423
+
424
+ const objects = canvas
425
+ .getObjects()
426
+ .filter((obj: any) => obj.data?.type === "feature-marker");
427
+ objects.forEach((obj) => canvas.remove(obj));
428
+
429
+ this.canvasService.requestRenderAll();
430
+ }
431
+
432
+ private constrainPosition(
433
+ p: Point,
434
+ geometry: DielineGeometry,
435
+ limit: number,
436
+ feature?: DielineFeature
437
+ ): { x: number; y: number } {
438
+ 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
+ };
450
+ }
451
+
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);
459
+
460
+ // Calculate vector from nearest point to current point
461
+ const dx = p.x - nearest.x;
462
+ const dy = p.y - nearest.y;
463
+ const dist = Math.sqrt(dx * dx + dy * dy);
464
+
465
+ // If within limit, allow current position (offset from edge)
466
+ if (dist <= limit) {
467
+ return { x: p.x, y: p.y };
468
+ }
469
+
470
+ // Otherwise, clamp to limit
471
+ const scale = limit / dist;
472
+ return {
473
+ x: nearest.x + dx * scale,
474
+ y: nearest.y + dy * scale,
475
+ };
476
+ }
477
+
478
+ private syncFeatureFromCanvas(target: any) {
479
+ if (!this.currentGeometry || !this.context) return;
480
+
481
+ const index = target.data?.index;
482
+ if (index === undefined || index < 0 || index >= this.features.length)
483
+ return;
484
+
485
+ const feature = this.features[index];
486
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
487
+ const { width, height, x, y } = geometry;
488
+
489
+ // Calculate Normalized Position
490
+ // The geometry x/y is the CENTER.
491
+ const left = x - width / 2;
492
+ const top = y - height / 2;
493
+
494
+ const normalizedX = width > 0 ? (target.left - left) / width : 0.5;
495
+ const normalizedY = height > 0 ? (target.top - top) / height : 0.5;
496
+
497
+ // Update feature
498
+ const updatedFeature = {
499
+ ...feature,
500
+ x: normalizedX,
501
+ y: normalizedY,
502
+ // Could also update rotation if we allowed rotating markers
503
+ };
504
+
505
+ const newFeatures = [...this.features];
506
+ 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
+ }
521
+ }
522
+
523
+ private redraw() {
524
+ if (!this.canvasService || !this.currentGeometry) return;
525
+ const canvas = this.canvasService.canvas;
526
+ const geometry = this.currentGeometry;
527
+
528
+ // Remove existing markers
529
+ const existing = canvas
530
+ .getObjects()
531
+ .filter((obj: any) => obj.data?.type === "feature-marker");
532
+ existing.forEach((obj) => canvas.remove(obj));
533
+
534
+ if (!this.features || this.features.length === 0) {
535
+ this.canvasService.requestRenderAll();
536
+ return;
537
+ }
538
+
539
+ const scale = geometry.scale || 1;
540
+ const finalScale = scale;
541
+
542
+ // Group features by groupId
543
+ const groups: { [key: string]: { feature: DielineFeature; index: number }[] } =
544
+ {};
545
+ const singles: { feature: DielineFeature; index: number }[] = [];
546
+
547
+ this.features.forEach((f, i) => {
548
+ if (f.groupId) {
549
+ if (!groups[f.groupId]) groups[f.groupId] = [];
550
+ groups[f.groupId].push({ feature: f, index: i });
551
+ } else {
552
+ singles.push({ feature: f, index: i });
553
+ }
554
+ });
555
+
556
+ // Helper to create marker shape
557
+ const createMarkerShape = (
558
+ feature: DielineFeature,
559
+ pos: { x: number; y: number },
560
+ ) => {
561
+ // Features are in the same unit as geometry.unit
562
+ const featureScale = scale;
563
+
564
+ const visualWidth = (feature.width || 10) * featureScale;
565
+ const visualHeight = (feature.height || 10) * featureScale;
566
+ const visualRadius = (feature.radius || 0) * featureScale;
567
+ const color =
568
+ feature.color ||
569
+ (feature.operation === "add" ? "#00FF00" : "#FF0000");
570
+ const strokeDash =
571
+ feature.strokeDash ||
572
+ (feature.operation === "subtract" ? [4, 4] : undefined);
573
+
574
+ let shape: any;
575
+ if (feature.shape === "rect") {
576
+ shape = new Rect({
577
+ width: visualWidth,
578
+ height: visualHeight,
579
+ rx: visualRadius,
580
+ ry: visualRadius,
581
+ fill: "transparent",
582
+ stroke: color,
583
+ strokeWidth: 2,
584
+ strokeDashArray: strokeDash,
585
+ originX: "center",
586
+ originY: "center",
587
+ left: pos.x,
588
+ top: pos.y,
589
+ });
590
+ } else {
591
+ shape = new Circle({
592
+ radius: visualRadius || 5 * finalScale,
593
+ fill: "transparent",
594
+ stroke: color,
595
+ strokeWidth: 2,
596
+ strokeDashArray: strokeDash,
597
+ originX: "center",
598
+ originY: "center",
599
+ left: pos.x,
600
+ top: pos.y,
601
+ });
602
+ }
603
+ if (feature.rotation) {
604
+ shape.rotate(feature.rotation);
605
+ }
606
+ return shape;
607
+ };
608
+
609
+ // Render Singles
610
+ singles.forEach(({ feature, index }) => {
611
+ const geometry = this.getGeometryForFeature(
612
+ this.currentGeometry!,
613
+ feature,
614
+ );
615
+ const pos = resolveFeaturePosition(feature, geometry);
616
+ const marker = createMarkerShape(feature, pos);
617
+
618
+ marker.set({
619
+ visible: this.isToolActive,
620
+ selectable: this.isToolActive,
621
+ evented: this.isToolActive,
622
+ hasControls: false,
623
+ hasBorders: false,
624
+ hoverCursor: "move",
625
+ lockRotation: true,
626
+ lockScalingX: true,
627
+ lockScalingY: true,
628
+ data: { type: "feature-marker", index, isGroup: false },
629
+ });
630
+
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
+ canvas.add(marker);
653
+ canvas.bringObjectToFront(marker);
654
+ });
655
+
656
+ // Render Groups
657
+ Object.keys(groups).forEach((groupId) => {
658
+ const members = groups[groupId];
659
+ if (members.length === 0) return;
660
+
661
+ // Calculate group center (average position) to position the group correctly
662
+ // But Fabric Group uses relative coordinates.
663
+ // Easiest way: Create shapes at absolute positions, then Group them.
664
+ // Fabric will auto-calculate group center and adjust children.
665
+
666
+ const shapes = members.map(({ feature }) => {
667
+ const geometry = this.getGeometryForFeature(
668
+ this.currentGeometry!,
669
+ feature,
670
+ );
671
+ const pos = resolveFeaturePosition(feature, geometry);
672
+ return createMarkerShape(feature, pos);
673
+ });
674
+
675
+ const groupObj = new Group(shapes, {
676
+ visible: this.isToolActive,
677
+ selectable: this.isToolActive,
678
+ evented: this.isToolActive,
679
+ hasControls: false,
680
+ hasBorders: false,
681
+ hoverCursor: "move",
682
+ lockRotation: true,
683
+ lockScalingX: true,
684
+ lockScalingY: true,
685
+ subTargetCheck: true, // Allow events to pass through if needed, but we treat as one
686
+ interactive: false, // Children not interactive
687
+ // @ts-ignore
688
+ data: {
689
+ type: "feature-marker",
690
+ isGroup: true,
691
+ groupId,
692
+ indices: members.map((m) => m.index),
693
+ },
694
+ });
695
+
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
+ canvas.add(groupObj);
718
+ canvas.bringObjectToFront(groupObj);
719
+ });
720
+
721
+ this.canvasService.requestRenderAll();
722
+ }
723
+
724
+ private enforceConstraints() {
725
+ if (!this.canvasService || !this.currentGeometry) return;
726
+ // Iterate markers and snap them if geometry changed
727
+ const canvas = this.canvasService.canvas;
728
+ const markers = canvas
729
+ .getObjects()
730
+ .filter((obj: any) => obj.data?.type === "feature-marker");
731
+
732
+ markers.forEach((marker: any) => {
733
+ // Find associated feature
734
+ let feature: DielineFeature | undefined;
735
+ if (marker.data?.isGroup) {
736
+ const indices = marker.data?.indices as number[];
737
+ if (indices && indices.length > 0) {
738
+ feature = this.features[indices[0]];
739
+ }
740
+ } else {
741
+ const index = marker.data?.index;
742
+ if (index !== undefined) {
743
+ feature = this.features[index];
744
+ }
745
+ }
746
+
747
+ const geometry = this.getGeometryForFeature(
748
+ this.currentGeometry!,
749
+ feature,
750
+ );
751
+
752
+ const markerStrokeWidth = (marker.strokeWidth || 2) * (marker.scaleX || 1);
753
+ const minDim = Math.min(marker.getScaledWidth(), marker.getScaledHeight());
754
+ const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
755
+
756
+ const snapped = this.constrainPosition(
757
+ new Point(marker.left, marker.top),
758
+ geometry,
759
+ limit,
760
+ feature
761
+ );
762
+ marker.set({ left: snapped.x, top: snapped.y });
763
+ marker.setCoords();
764
+ });
765
+ canvas.requestRenderAll();
766
+ }
767
+ }