@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
@@ -2,11 +2,14 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FeatureTool = void 0;
4
4
  const core_1 = require("@pooder/core");
5
- const fabric_1 = require("fabric");
6
5
  const geometry_1 = require("./geometry");
7
6
  const constraints_1 = require("./constraints");
8
7
  const featureComplete_1 = require("./featureComplete");
9
8
  const sceneLayoutModel_1 = require("./sceneLayoutModel");
9
+ const FEATURE_OVERLAY_LAYER_ID = "feature-overlay";
10
+ const FEATURE_STROKE_WIDTH = 2;
11
+ const DEFAULT_RECT_SIZE = 10;
12
+ const DEFAULT_CIRCLE_RADIUS = 5;
10
13
  class FeatureTool {
11
14
  constructor(options) {
12
15
  this.id = "pooder.kit.feature";
@@ -19,6 +22,8 @@ class FeatureTool {
19
22
  this.isFeatureSessionActive = false;
20
23
  this.sessionOriginalFeatures = null;
21
24
  this.hasWorkingChanges = false;
25
+ this.specs = [];
26
+ this.renderSeq = 0;
22
27
  this.handleMoving = null;
23
28
  this.handleModified = null;
24
29
  this.handleSceneGeometryChange = null;
@@ -41,6 +46,17 @@ class FeatureTool {
41
46
  console.warn("CanvasService not found for FeatureTool");
42
47
  return;
43
48
  }
49
+ this.renderProducerDisposable?.dispose();
50
+ this.renderProducerDisposable = this.canvasService.registerRenderProducer(this.id, () => ({
51
+ passes: [
52
+ {
53
+ id: FEATURE_OVERLAY_LAYER_ID,
54
+ stack: 880,
55
+ order: 0,
56
+ objects: this.specs,
57
+ },
58
+ ],
59
+ }), { priority: 350 });
44
60
  const configService = context.services.get("ConfigurationService");
45
61
  if (configService) {
46
62
  const features = (configService.get("dieline.features", []) ||
@@ -63,7 +79,6 @@ class FeatureTool {
63
79
  }
64
80
  const toolSessionService = context.services.get("ToolSessionService");
65
81
  this.dirtyTrackerDisposable = toolSessionService?.registerDirtyTracker(this.id, () => this.hasWorkingChanges);
66
- // Listen to tool activation
67
82
  context.eventBus.on("tool:activated", this.onToolActivated);
68
83
  this.setup();
69
84
  }
@@ -77,23 +92,7 @@ class FeatureTool {
77
92
  this.context = undefined;
78
93
  }
79
94
  updateVisibility() {
80
- if (!this.canvasService)
81
- return;
82
- const canvas = this.canvasService.canvas;
83
- const markers = canvas
84
- .getObjects()
85
- .filter((obj) => obj.data?.type === "feature-marker");
86
- markers.forEach((marker) => {
87
- // If tool active, allow selection. If not, disable selection.
88
- // Also might want to hide them entirely or just disable interaction.
89
- // Assuming we only want to see/edit holes when tool is active.
90
- marker.set({
91
- visible: this.isToolActive, // Or just selectable: false if we want them visible but locked
92
- selectable: this.isToolActive,
93
- evented: this.isToolActive,
94
- });
95
- });
96
- canvas.requestRenderAll();
95
+ this.redraw();
97
96
  }
98
97
  contribute() {
99
98
  return {
@@ -192,10 +191,10 @@ class FeatureTool {
192
191
  await this.refreshGeometry();
193
192
  this.setWorkingFeatures(original);
194
193
  this.hasWorkingChanges = false;
194
+ this.clearFeatureSessionState();
195
195
  this.redraw();
196
196
  this.emitWorkingChange();
197
197
  this.updateCommittedFeatures(original);
198
- this.clearFeatureSessionState();
199
198
  return { ok: true };
200
199
  },
201
200
  },
@@ -323,8 +322,7 @@ class FeatureTool {
323
322
  return { ok: true };
324
323
  this.setWorkingFeatures(next);
325
324
  this.hasWorkingChanges = true;
326
- this.redraw();
327
- this.enforceConstraints();
325
+ this.redraw({ enforceConstraints: true });
328
326
  this.emitWorkingChange();
329
327
  return { ok: true };
330
328
  }
@@ -354,25 +352,22 @@ class FeatureTool {
354
352
  }
355
353
  this.hasWorkingChanges = false;
356
354
  this.clearFeatureSessionState();
357
- // Keep feature markers above dieline overlay after config-driven redraw.
358
355
  this.redraw();
359
356
  return { ok: true };
360
357
  }
361
358
  addFeature(type) {
362
359
  if (!this.canvasService)
363
360
  return false;
364
- // Default to top edge center
365
361
  const newFeature = {
366
362
  id: Date.now().toString(),
367
363
  operation: type,
368
364
  shape: "rect",
369
365
  x: 0.5,
370
- y: 0, // Top edge
366
+ y: 0,
371
367
  width: 10,
372
368
  height: 10,
373
369
  rotation: 0,
374
370
  renderBehavior: "edge",
375
- // Default constraint: path (snap to edge)
376
371
  constraints: [{ type: "path" }],
377
372
  };
378
373
  this.setWorkingFeatures([...(this.workingFeatures || []), newFeature]);
@@ -386,7 +381,6 @@ class FeatureTool {
386
381
  return false;
387
382
  const groupId = Date.now().toString();
388
383
  const timestamp = Date.now();
389
- // 1. Lug (Outer) - Add
390
384
  const lug = {
391
385
  id: `${timestamp}-lug`,
392
386
  groupId,
@@ -399,7 +393,6 @@ class FeatureTool {
399
393
  renderBehavior: "edge",
400
394
  constraints: [{ type: "path" }],
401
395
  };
402
- // 2. Hole (Inner) - Subtract
403
396
  const hole = {
404
397
  id: `${timestamp}-hole`,
405
398
  groupId,
@@ -418,25 +411,20 @@ class FeatureTool {
418
411
  this.emitWorkingChange();
419
412
  return true;
420
413
  }
421
- getGeometryForFeature(geometry, feature) {
422
- // Legacy support or specialized scaling can go here if needed
423
- // Currently all features operate on the base geometry (or scaled version of it)
414
+ getGeometryForFeature(geometry, _feature) {
424
415
  return geometry;
425
416
  }
426
417
  setup() {
427
418
  if (!this.canvasService || !this.context)
428
419
  return;
429
420
  const canvas = this.canvasService.canvas;
430
- // 1. Listen for Scene Geometry Changes
431
421
  if (!this.handleSceneGeometryChange) {
432
422
  this.handleSceneGeometryChange = (geometry) => {
433
423
  this.currentGeometry = geometry;
434
- this.redraw();
435
- this.enforceConstraints();
424
+ this.redraw({ enforceConstraints: true });
436
425
  };
437
426
  this.context.eventBus.on("scene:geometry:change", this.handleSceneGeometryChange);
438
427
  }
439
- // 2. Initial Fetch of Geometry
440
428
  const commandService = this.context.services.get("CommandService");
441
429
  if (commandService) {
442
430
  try {
@@ -449,98 +437,35 @@ class FeatureTool {
449
437
  }
450
438
  catch (e) { }
451
439
  }
452
- // 3. Setup Canvas Interaction
453
440
  if (!this.handleMoving) {
454
441
  this.handleMoving = (e) => {
455
- const target = e.target;
456
- if (!target || target.data?.type !== "feature-marker")
442
+ const target = this.getDraggableMarkerTarget(e?.target);
443
+ if (!target || !this.currentGeometry)
457
444
  return;
458
- if (!this.currentGeometry)
459
- return;
460
- // Determine feature to use for snapping context
461
- let feature;
462
- if (target.data?.isGroup) {
463
- const indices = target.data?.indices;
464
- if (indices && indices.length > 0) {
465
- feature = this.workingFeatures[indices[0]];
466
- }
467
- }
468
- else {
469
- const index = target.data?.index;
470
- if (index !== undefined) {
471
- feature = this.workingFeatures[index];
472
- }
473
- }
445
+ const feature = this.getFeatureForMarker(target);
474
446
  const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
475
- // Snap to edge during move
476
- // For Group, target.left/top is group center (or top-left depending on origin)
477
- // We snap the target position itself.
478
- const p = new fabric_1.Point(target.left, target.top);
479
- // Calculate limit based on target size (min dimension / 2 ensures overlap)
480
- // Also subtract stroke width to ensure visual overlap (not just tangent)
481
- // target.strokeWidth for group is usually 0, need a safe default (e.g. 2 for markers)
482
- const markerStrokeWidth = (target.strokeWidth || 2) * (target.scaleX || 1);
483
- const minDim = Math.min(target.getScaledWidth(), target.getScaledHeight());
484
- const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
485
- const snapped = this.constrainPosition(p, geometry, limit, feature);
447
+ const snapped = this.constrainPosition({
448
+ x: Number(target.left || 0),
449
+ y: Number(target.top || 0),
450
+ }, geometry, feature);
486
451
  target.set({
487
452
  left: snapped.x,
488
453
  top: snapped.y,
489
454
  });
455
+ target.setCoords();
456
+ this.syncMarkerVisualsByTarget(target, snapped);
490
457
  };
491
458
  canvas.on("object:moving", this.handleMoving);
492
459
  }
493
460
  if (!this.handleModified) {
494
461
  this.handleModified = (e) => {
495
- const target = e.target;
496
- if (!target || target.data?.type !== "feature-marker")
462
+ const target = this.getDraggableMarkerTarget(e?.target);
463
+ if (!target)
497
464
  return;
498
465
  if (target.data?.isGroup) {
499
- // It's a Group object
500
- const groupObj = target;
501
- // @ts-ignore
502
- const indices = groupObj.data?.indices;
503
- if (!indices)
504
- return;
505
- // We need to update all features in the group based on their new absolute positions.
506
- // Fabric Group children positions are relative to group center.
507
- // We need to calculate absolute position for each child.
508
- // Note: groupObj has already been moved to new position (target.left, target.top)
509
- const groupCenter = new fabric_1.Point(groupObj.left, groupObj.top);
510
- // Get group matrix to transform children
511
- // Simplified: just add relative coordinates if no rotation/scaling on group
512
- // We locked rotation/scaling, so it's safe.
513
- const newFeatures = [...this.workingFeatures];
514
- const { x, y } = this.currentGeometry; // Center is same
515
- // Fabric Group objects have .getObjects() which returns children
516
- // But children inside group have coordinates relative to group center.
517
- // center is (0,0) inside the group local coordinate system.
518
- groupObj.getObjects().forEach((child, i) => {
519
- const originalIndex = indices[i];
520
- const feature = this.workingFeatures[originalIndex];
521
- const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
522
- const { width, height } = geometry;
523
- const layoutLeft = x - width / 2;
524
- const layoutTop = y - height / 2;
525
- // Calculate absolute position
526
- // child.left/top are relative to group center
527
- const absX = groupCenter.x + (child.left || 0);
528
- const absY = groupCenter.y + (child.top || 0);
529
- // Normalize
530
- const normalizedX = width > 0 ? (absX - layoutLeft) / width : 0.5;
531
- const normalizedY = height > 0 ? (absY - layoutTop) / height : 0.5;
532
- newFeatures[originalIndex] = {
533
- ...newFeatures[originalIndex],
534
- x: normalizedX,
535
- y: normalizedY,
536
- };
537
- });
538
- this.setWorkingFeatures(newFeatures);
539
- this.hasWorkingChanges = true;
540
- this.emitWorkingChange();
466
+ this.syncGroupFromCanvas(target);
541
467
  }
542
468
  else {
543
- // Single object
544
469
  this.syncFeatureFromCanvas(target);
545
470
  }
546
471
  };
@@ -563,263 +488,423 @@ class FeatureTool {
563
488
  this.context.eventBus.off("scene:geometry:change", this.handleSceneGeometryChange);
564
489
  this.handleSceneGeometryChange = null;
565
490
  }
566
- const objects = canvas
567
- .getObjects()
568
- .filter((obj) => obj.data?.type === "feature-marker");
569
- objects.forEach((obj) => canvas.remove(obj));
570
- this.canvasService.requestRenderAll();
491
+ this.renderSeq += 1;
492
+ this.specs = [];
493
+ this.renderProducerDisposable?.dispose();
494
+ this.renderProducerDisposable = undefined;
495
+ void this.canvasService.flushRenderFromProducers();
496
+ }
497
+ getDraggableMarkerTarget(target) {
498
+ if (!this.isFeatureSessionActive || !this.isToolActive)
499
+ return null;
500
+ if (!target || target.data?.type !== "feature-marker")
501
+ return null;
502
+ if (target.data?.markerRole !== "handle")
503
+ return null;
504
+ return target;
571
505
  }
572
- constrainPosition(p, geometry, limit, feature) {
506
+ getFeatureForMarker(target) {
507
+ const data = target?.data || {};
508
+ const index = data.isGroup
509
+ ? this.toFeatureIndex(data.anchorIndex)
510
+ : this.toFeatureIndex(data.index);
511
+ if (index === null)
512
+ return undefined;
513
+ return this.workingFeatures[index];
514
+ }
515
+ constrainPosition(p, geometry, feature) {
573
516
  if (!feature) {
574
517
  return { x: p.x, y: p.y };
575
518
  }
576
519
  const minX = geometry.x - geometry.width / 2;
577
520
  const minY = geometry.y - geometry.height / 2;
578
- // Normalize
579
521
  const nx = geometry.width > 0 ? (p.x - minX) / geometry.width : 0.5;
580
522
  const ny = geometry.height > 0 ? (p.y - minY) / geometry.height : 0.5;
581
523
  const scale = geometry.scale || 1;
582
524
  const dielineWidth = geometry.width / scale;
583
525
  const dielineHeight = geometry.height / scale;
584
- // Filter constraints: only apply those that are NOT validateOnly
585
526
  const activeConstraints = feature.constraints?.filter((c) => !c.validateOnly);
586
527
  const constrained = constraints_1.ConstraintRegistry.apply(nx, ny, feature, {
587
528
  dielineWidth,
588
529
  dielineHeight,
589
530
  geometry,
590
531
  }, activeConstraints);
591
- // Denormalize
592
532
  return {
593
533
  x: minX + constrained.x * geometry.width,
594
534
  y: minY + constrained.y * geometry.height,
595
535
  };
596
536
  }
537
+ toNormalizedPoint(point, geometry) {
538
+ const left = geometry.x - geometry.width / 2;
539
+ const top = geometry.y - geometry.height / 2;
540
+ return {
541
+ x: geometry.width > 0 ? (point.x - left) / geometry.width : 0.5,
542
+ y: geometry.height > 0 ? (point.y - top) / geometry.height : 0.5,
543
+ };
544
+ }
597
545
  syncFeatureFromCanvas(target) {
598
- if (!this.currentGeometry || !this.context)
546
+ if (!this.currentGeometry)
599
547
  return;
600
- const index = target.data?.index;
601
- if (index === undefined ||
602
- index < 0 ||
603
- index >= this.workingFeatures.length)
548
+ const index = this.toFeatureIndex(target.data?.index);
549
+ if (index === null || index >= this.workingFeatures.length)
604
550
  return;
605
551
  const feature = this.workingFeatures[index];
606
552
  const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
607
- const { width, height, x, y } = geometry;
608
- // Calculate Normalized Position
609
- // The geometry x/y is the CENTER.
610
- const left = x - width / 2;
611
- const top = y - height / 2;
612
- const normalizedX = width > 0 ? (target.left - left) / width : 0.5;
613
- const normalizedY = height > 0 ? (target.top - top) / height : 0.5;
614
- // Update feature
553
+ const normalized = this.toNormalizedPoint({
554
+ x: Number(target.left || 0),
555
+ y: Number(target.top || 0),
556
+ }, geometry);
615
557
  const updatedFeature = {
616
558
  ...feature,
617
- x: normalizedX,
618
- y: normalizedY,
619
- // Could also update rotation if we allowed rotating markers
559
+ x: normalized.x,
560
+ y: normalized.y,
620
561
  };
621
- const newFeatures = [...this.workingFeatures];
622
- newFeatures[index] = updatedFeature;
623
- this.setWorkingFeatures(newFeatures);
562
+ const next = [...this.workingFeatures];
563
+ next[index] = updatedFeature;
564
+ this.setWorkingFeatures(next);
624
565
  this.hasWorkingChanges = true;
625
566
  this.emitWorkingChange();
626
567
  }
627
- redraw() {
628
- if (!this.canvasService || !this.currentGeometry)
568
+ syncGroupFromCanvas(target) {
569
+ if (!this.currentGeometry)
629
570
  return;
630
- const canvas = this.canvasService.canvas;
631
- const geometry = this.currentGeometry;
632
- // Remove existing markers
633
- const existing = canvas
634
- .getObjects()
635
- .filter((obj) => obj.data?.type === "feature-marker");
636
- existing.forEach((obj) => canvas.remove(obj));
637
- if (!this.workingFeatures || this.workingFeatures.length === 0) {
638
- this.canvasService.requestRenderAll();
571
+ const indices = this.readGroupIndices(target.data?.indices);
572
+ if (indices.length === 0)
639
573
  return;
574
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
575
+ const anchorCenter = {
576
+ x: Number(target.left || 0),
577
+ y: Number(target.top || 0),
578
+ };
579
+ const next = [...this.workingFeatures];
580
+ let changed = false;
581
+ offsets.forEach((entry) => {
582
+ const index = entry.index;
583
+ if (index < 0 || index >= next.length)
584
+ return;
585
+ const feature = next[index];
586
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
587
+ const normalized = this.toNormalizedPoint({
588
+ x: anchorCenter.x + entry.dx,
589
+ y: anchorCenter.y + entry.dy,
590
+ }, geometry);
591
+ if (feature.x !== normalized.x || feature.y !== normalized.y) {
592
+ next[index] = {
593
+ ...feature,
594
+ x: normalized.x,
595
+ y: normalized.y,
596
+ };
597
+ changed = true;
598
+ }
599
+ });
600
+ if (!changed)
601
+ return;
602
+ this.setWorkingFeatures(next);
603
+ this.hasWorkingChanges = true;
604
+ this.emitWorkingChange();
605
+ }
606
+ redraw(options = {}) {
607
+ void this.redrawAsync(options);
608
+ }
609
+ async redrawAsync(options = {}) {
610
+ if (!this.canvasService)
611
+ return;
612
+ const seq = ++this.renderSeq;
613
+ this.specs = this.buildFeatureSpecs();
614
+ if (seq !== this.renderSeq)
615
+ return;
616
+ await this.canvasService.flushRenderFromProducers();
617
+ if (seq !== this.renderSeq)
618
+ return;
619
+ if (options.enforceConstraints) {
620
+ this.enforceConstraints();
640
621
  }
641
- const scale = geometry.scale || 1;
642
- const finalScale = scale;
643
- // Group features by groupId
644
- const groups = {};
622
+ }
623
+ buildFeatureSpecs() {
624
+ if (!this.isFeatureSessionActive ||
625
+ !this.currentGeometry ||
626
+ this.workingFeatures.length === 0) {
627
+ return [];
628
+ }
629
+ const groups = new Map();
645
630
  const singles = [];
646
- this.workingFeatures.forEach((f, i) => {
647
- if (f.groupId) {
648
- if (!groups[f.groupId])
649
- groups[f.groupId] = [];
650
- groups[f.groupId].push({ feature: f, index: i });
651
- }
652
- else {
653
- singles.push({ feature: f, index: i });
631
+ this.workingFeatures.forEach((feature, index) => {
632
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
633
+ const position = (0, geometry_1.resolveFeaturePosition)(feature, geometry);
634
+ const scale = geometry.scale || 1;
635
+ const marker = {
636
+ feature,
637
+ index,
638
+ position,
639
+ geometry,
640
+ scale,
641
+ };
642
+ if (feature.groupId) {
643
+ const list = groups.get(feature.groupId) || [];
644
+ list.push(marker);
645
+ groups.set(feature.groupId, list);
646
+ return;
654
647
  }
648
+ singles.push(marker);
655
649
  });
656
- // Helper to create marker shape
657
- const createMarkerShape = (feature, pos) => {
658
- const featureScale = scale;
659
- const visualWidth = (feature.width || 10) * featureScale;
660
- const visualHeight = (feature.height || 10) * featureScale;
661
- const visualRadius = (feature.radius || 0) * featureScale;
662
- const color = feature.color || (feature.operation === "add" ? "#00FF00" : "#FF0000");
663
- const strokeDash = feature.strokeDash ||
664
- (feature.operation === "subtract" ? [4, 4] : undefined);
665
- let shape;
666
- if (feature.shape === "rect") {
667
- shape = new fabric_1.Rect({
650
+ const specs = [];
651
+ singles.forEach((marker) => {
652
+ this.appendMarkerSpecs(specs, marker, {
653
+ markerRole: "handle",
654
+ isGroup: false,
655
+ });
656
+ });
657
+ groups.forEach((members, groupId) => {
658
+ if (!members.length)
659
+ return;
660
+ const anchor = members[0];
661
+ const memberOffsets = members.map((member) => ({
662
+ index: member.index,
663
+ dx: member.position.x - anchor.position.x,
664
+ dy: member.position.y - anchor.position.y,
665
+ }));
666
+ const indices = members.map((member) => member.index);
667
+ members
668
+ .filter((member) => member.index !== anchor.index)
669
+ .forEach((member) => {
670
+ this.appendMarkerSpecs(specs, member, {
671
+ markerRole: "member",
672
+ isGroup: false,
673
+ groupId,
674
+ });
675
+ });
676
+ this.appendMarkerSpecs(specs, anchor, {
677
+ markerRole: "handle",
678
+ isGroup: true,
679
+ groupId,
680
+ indices,
681
+ anchorIndex: anchor.index,
682
+ memberOffsets,
683
+ });
684
+ });
685
+ return specs;
686
+ }
687
+ appendMarkerSpecs(specs, marker, options) {
688
+ const { feature, index, position, scale, geometry } = marker;
689
+ const baseRadius = feature.shape === "circle"
690
+ ? (feature.radius ?? DEFAULT_CIRCLE_RADIUS)
691
+ : (feature.radius ?? 0);
692
+ const baseWidth = feature.shape === "circle"
693
+ ? baseRadius * 2
694
+ : (feature.width ?? DEFAULT_RECT_SIZE);
695
+ const baseHeight = feature.shape === "circle"
696
+ ? baseRadius * 2
697
+ : (feature.height ?? DEFAULT_RECT_SIZE);
698
+ const visualWidth = baseWidth * scale;
699
+ const visualHeight = baseHeight * scale;
700
+ const visualRadius = baseRadius * scale;
701
+ const color = feature.color || (feature.operation === "add" ? "#00FF00" : "#FF0000");
702
+ const strokeDash = feature.strokeDash ||
703
+ (feature.operation === "subtract" ? [4, 4] : undefined);
704
+ const interactive = options.markerRole === "handle";
705
+ const sessionVisible = this.isToolActive && this.isFeatureSessionActive;
706
+ const baseData = this.buildMarkerData(marker, options);
707
+ const commonProps = {
708
+ visible: sessionVisible,
709
+ selectable: interactive && sessionVisible,
710
+ evented: interactive && sessionVisible,
711
+ hasControls: false,
712
+ hasBorders: false,
713
+ hoverCursor: interactive ? "move" : "default",
714
+ lockRotation: true,
715
+ lockScalingX: true,
716
+ lockScalingY: true,
717
+ fill: "transparent",
718
+ stroke: color,
719
+ strokeWidth: FEATURE_STROKE_WIDTH,
720
+ strokeDashArray: strokeDash,
721
+ originX: "center",
722
+ originY: "center",
723
+ left: position.x,
724
+ top: position.y,
725
+ angle: feature.rotation || 0,
726
+ };
727
+ const markerId = this.markerId(index);
728
+ if (feature.shape === "rect") {
729
+ specs.push({
730
+ id: markerId,
731
+ type: "rect",
732
+ space: "screen",
733
+ data: baseData,
734
+ props: {
735
+ ...commonProps,
668
736
  width: visualWidth,
669
737
  height: visualHeight,
670
738
  rx: visualRadius,
671
739
  ry: visualRadius,
672
- fill: "transparent",
673
- stroke: color,
674
- strokeWidth: 2,
675
- strokeDashArray: strokeDash,
676
- originX: "center",
677
- originY: "center",
678
- left: pos.x,
679
- top: pos.y,
680
- });
681
- }
682
- else {
683
- shape = new fabric_1.Circle({
684
- radius: visualRadius || 5 * finalScale,
685
- fill: "transparent",
686
- stroke: color,
687
- strokeWidth: 2,
688
- strokeDashArray: strokeDash,
689
- originX: "center",
690
- originY: "center",
691
- left: pos.x,
692
- top: pos.y,
693
- });
694
- }
695
- if (feature.rotation) {
696
- shape.rotate(feature.rotation);
740
+ },
741
+ });
742
+ }
743
+ else {
744
+ specs.push({
745
+ id: markerId,
746
+ type: "rect",
747
+ space: "screen",
748
+ data: baseData,
749
+ props: {
750
+ ...commonProps,
751
+ width: visualWidth,
752
+ height: visualHeight,
753
+ rx: visualRadius,
754
+ ry: visualRadius,
755
+ },
756
+ });
757
+ }
758
+ if (feature.bridge?.type === "vertical") {
759
+ const featureTopY = position.y - visualHeight / 2;
760
+ const dielineTopY = geometry.y - geometry.height / 2;
761
+ const bridgeHeight = Math.max(0, featureTopY - dielineTopY);
762
+ if (bridgeHeight <= 0.001) {
763
+ return;
697
764
  }
698
- // Handle Indicator for Bridge
699
- if (feature.bridge && feature.bridge.type === "vertical") {
700
- // Create a visual indicator for the bridge
701
- // A dashed rectangle extending upwards
702
- const bridgeIndicator = new fabric_1.Rect({
765
+ specs.push({
766
+ id: this.bridgeIndicatorId(index),
767
+ type: "rect",
768
+ space: "screen",
769
+ data: {
770
+ ...baseData,
771
+ markerRole: "indicator",
772
+ markerOffsetX: 0,
773
+ markerOffsetY: -visualHeight / 2,
774
+ },
775
+ props: {
776
+ visible: sessionVisible,
777
+ selectable: false,
778
+ evented: false,
703
779
  width: visualWidth,
704
- height: 100 * featureScale, // Arbitrary long length to show direction
780
+ height: bridgeHeight,
705
781
  fill: "transparent",
706
782
  stroke: "#888",
707
783
  strokeWidth: 1,
708
784
  strokeDashArray: [2, 2],
709
- originX: "center",
710
- originY: "bottom", // Anchor at bottom so it extends up
711
- left: pos.x,
712
- top: pos.y - visualHeight / 2, // Start from top of feature
713
785
  opacity: 0.5,
714
- selectable: false,
715
- evented: false,
716
- });
717
- // We need to return a group containing both shape and indicator
718
- // But createMarkerShape is expected to return one object.
719
- // If we return a Group, Fabric handles it.
720
- // But the caller might wrap this in another Group if it's part of a feature group.
721
- // Fabric supports nested groups.
722
- const group = new fabric_1.Group([bridgeIndicator, shape], {
723
786
  originX: "center",
724
- originY: "center",
725
- left: pos.x,
726
- top: pos.y,
727
- });
728
- return group;
729
- }
730
- return shape;
731
- };
732
- // Render Singles
733
- singles.forEach(({ feature, index }) => {
734
- const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
735
- const pos = (0, geometry_1.resolveFeaturePosition)(feature, geometry);
736
- const marker = createMarkerShape(feature, pos);
737
- marker.set({
738
- visible: this.isToolActive,
739
- selectable: this.isToolActive,
740
- evented: this.isToolActive,
741
- hasControls: false,
742
- hasBorders: false,
743
- hoverCursor: "move",
744
- lockRotation: true,
745
- lockScalingX: true,
746
- lockScalingY: true,
747
- data: { type: "feature-marker", index, isGroup: false },
787
+ originY: "bottom",
788
+ left: position.x,
789
+ top: position.y - visualHeight / 2,
790
+ },
748
791
  });
749
- canvas.add(marker);
750
- canvas.bringObjectToFront(marker);
751
- });
752
- // Render Groups
753
- Object.keys(groups).forEach((groupId) => {
754
- const members = groups[groupId];
755
- if (members.length === 0)
756
- return;
757
- // Calculate group center (average position) to position the group correctly
758
- // But Fabric Group uses relative coordinates.
759
- // Easiest way: Create shapes at absolute positions, then Group them.
760
- // Fabric will auto-calculate group center and adjust children.
761
- const shapes = members.map(({ feature }) => {
762
- const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
763
- const pos = (0, geometry_1.resolveFeaturePosition)(feature, geometry);
764
- return createMarkerShape(feature, pos);
792
+ }
793
+ }
794
+ buildMarkerData(marker, options) {
795
+ const data = {
796
+ type: "feature-marker",
797
+ index: marker.index,
798
+ featureId: marker.feature.id,
799
+ markerRole: options.markerRole,
800
+ markerOffsetX: 0,
801
+ markerOffsetY: 0,
802
+ isGroup: options.isGroup,
803
+ };
804
+ if (options.groupId)
805
+ data.groupId = options.groupId;
806
+ if (options.indices)
807
+ data.indices = options.indices;
808
+ if (options.anchorIndex !== undefined)
809
+ data.anchorIndex = options.anchorIndex;
810
+ if (options.memberOffsets)
811
+ data.memberOffsets = options.memberOffsets;
812
+ return data;
813
+ }
814
+ markerId(index) {
815
+ return `feature.marker.${index}`;
816
+ }
817
+ bridgeIndicatorId(index) {
818
+ return `feature.marker.${index}.bridge`;
819
+ }
820
+ toFeatureIndex(value) {
821
+ const numeric = Number(value);
822
+ if (!Number.isInteger(numeric) || numeric < 0)
823
+ return null;
824
+ return numeric;
825
+ }
826
+ readGroupIndices(raw) {
827
+ if (!Array.isArray(raw))
828
+ return [];
829
+ return raw
830
+ .map((value) => this.toFeatureIndex(value))
831
+ .filter((value) => value !== null);
832
+ }
833
+ readGroupMemberOffsets(raw, fallbackIndices = []) {
834
+ if (Array.isArray(raw)) {
835
+ const parsed = raw
836
+ .map((entry) => {
837
+ const index = this.toFeatureIndex(entry?.index);
838
+ const dx = Number(entry?.dx);
839
+ const dy = Number(entry?.dy);
840
+ if (index === null || !Number.isFinite(dx) || !Number.isFinite(dy)) {
841
+ return null;
842
+ }
843
+ return { index, dx, dy };
844
+ })
845
+ .filter((value) => !!value);
846
+ if (parsed.length > 0)
847
+ return parsed;
848
+ }
849
+ return fallbackIndices.map((index) => ({ index, dx: 0, dy: 0 }));
850
+ }
851
+ syncMarkerVisualsByTarget(target, center) {
852
+ if (target.data?.isGroup) {
853
+ const indices = this.readGroupIndices(target.data?.indices);
854
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
855
+ offsets.forEach((entry) => {
856
+ this.syncMarkerVisualObjectsToCenter(entry.index, {
857
+ x: center.x + entry.dx,
858
+ y: center.y + entry.dy,
859
+ });
765
860
  });
766
- const groupObj = new fabric_1.Group(shapes, {
767
- visible: this.isToolActive,
768
- selectable: this.isToolActive,
769
- evented: this.isToolActive,
770
- hasControls: false,
771
- hasBorders: false,
772
- hoverCursor: "move",
773
- lockRotation: true,
774
- lockScalingX: true,
775
- lockScalingY: true,
776
- subTargetCheck: true, // Allow events to pass through if needed, but we treat as one
777
- interactive: false, // Children not interactive
778
- // @ts-ignore
779
- data: {
780
- type: "feature-marker",
781
- isGroup: true,
782
- groupId,
783
- indices: members.map((m) => m.index),
784
- },
861
+ this.canvasService?.requestRenderAll();
862
+ return;
863
+ }
864
+ const index = this.toFeatureIndex(target.data?.index);
865
+ if (index === null)
866
+ return;
867
+ this.syncMarkerVisualObjectsToCenter(index, center);
868
+ this.canvasService?.requestRenderAll();
869
+ }
870
+ syncMarkerVisualObjectsToCenter(index, center) {
871
+ if (!this.canvasService)
872
+ return;
873
+ const markers = this.canvasService.canvas
874
+ .getObjects()
875
+ .filter((obj) => obj?.data?.type === "feature-marker" &&
876
+ this.toFeatureIndex(obj?.data?.index) === index);
877
+ markers.forEach((marker) => {
878
+ const offsetX = Number(marker?.data?.markerOffsetX || 0);
879
+ const offsetY = Number(marker?.data?.markerOffsetY || 0);
880
+ marker.set({
881
+ left: center.x + offsetX,
882
+ top: center.y + offsetY,
785
883
  });
786
- canvas.add(groupObj);
787
- canvas.bringObjectToFront(groupObj);
884
+ marker.setCoords();
788
885
  });
789
- this.canvasService.requestRenderAll();
790
886
  }
791
887
  enforceConstraints() {
792
888
  if (!this.canvasService || !this.currentGeometry)
793
889
  return;
794
- // Iterate markers and snap them if geometry changed
795
- const canvas = this.canvasService.canvas;
796
- const markers = canvas
890
+ const handles = this.canvasService.canvas
797
891
  .getObjects()
798
- .filter((obj) => obj.data?.type === "feature-marker");
799
- markers.forEach((marker) => {
800
- // Find associated feature
801
- let feature;
802
- if (marker.data?.isGroup) {
803
- const indices = marker.data?.indices;
804
- if (indices && indices.length > 0) {
805
- feature = this.workingFeatures[indices[0]];
806
- }
807
- }
808
- else {
809
- const index = marker.data?.index;
810
- if (index !== undefined) {
811
- feature = this.workingFeatures[index];
812
- }
813
- }
892
+ .filter((obj) => obj?.data?.type === "feature-marker" &&
893
+ obj?.data?.markerRole === "handle");
894
+ handles.forEach((marker) => {
895
+ const feature = this.getFeatureForMarker(marker);
896
+ if (!feature)
897
+ return;
814
898
  const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
815
- const markerStrokeWidth = (marker.strokeWidth || 2) * (marker.scaleX || 1);
816
- const minDim = Math.min(marker.getScaledWidth(), marker.getScaledHeight());
817
- const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
818
- const snapped = this.constrainPosition(new fabric_1.Point(marker.left, marker.top), geometry, limit, feature);
899
+ const snapped = this.constrainPosition({
900
+ x: Number(marker.left || 0),
901
+ y: Number(marker.top || 0),
902
+ }, geometry, feature);
819
903
  marker.set({ left: snapped.x, top: snapped.y });
820
904
  marker.setCoords();
905
+ this.syncMarkerVisualsByTarget(marker, snapped);
821
906
  });
822
- canvas.requestRenderAll();
907
+ this.canvasService.canvas.requestRenderAll();
823
908
  }
824
909
  }
825
910
  exports.FeatureTool = FeatureTool;