@pooder/kit 3.4.0 → 3.5.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/dist/index.mjs CHANGED
@@ -208,6 +208,7 @@ import {
208
208
  import { Path, Pattern } from "fabric";
209
209
 
210
210
  // src/tracer.ts
211
+ import paper from "paper";
211
212
  var ImageTracer = class {
212
213
  /**
213
214
  * Main entry point: Traces an image URL to an SVG path string.
@@ -215,7 +216,7 @@ var ImageTracer = class {
215
216
  * @param options Configuration options.
216
217
  */
217
218
  static async trace(imageUrl, options = {}) {
218
- var _a, _b, _c, _d, _e;
219
+ var _a, _b, _c, _d, _e, _f, _g;
219
220
  const img = await this.loadImage(imageUrl);
220
221
  const width = img.width;
221
222
  const height = img.height;
@@ -232,23 +233,37 @@ var ImageTracer = class {
232
233
  Math.floor(Math.max(width, height) * 0.02)
233
234
  );
234
235
  const radius = (_b = options.morphologyRadius) != null ? _b : adaptiveRadius;
235
- let mask = this.createMask(imageData, threshold);
236
+ const expand = (_c = options.expand) != null ? _c : 0;
237
+ const padding = radius + expand + 2;
238
+ const paddedWidth = width + padding * 2;
239
+ const paddedHeight = height + padding * 2;
240
+ let mask = this.createMask(imageData, threshold, padding, paddedWidth, paddedHeight);
236
241
  if (radius > 0) {
237
- mask = this.dilate(mask, width, height, radius);
238
- mask = this.erode(mask, width, height, radius);
239
- mask = this.fillHoles(mask, width, height);
242
+ mask = this.circularMorphology(mask, paddedWidth, paddedHeight, radius, "closing");
243
+ mask = this.fillHoles(mask, paddedWidth, paddedHeight);
244
+ const smoothRadius = Math.max(2, Math.floor(radius * 0.3));
245
+ mask = this.circularMorphology(mask, paddedWidth, paddedHeight, smoothRadius, "closing");
246
+ } else {
247
+ mask = this.fillHoles(mask, paddedWidth, paddedHeight);
248
+ }
249
+ if (expand > 0) {
250
+ mask = this.circularMorphology(mask, paddedWidth, paddedHeight, expand, "dilate");
240
251
  }
241
- const allContourPoints = this.traceAllContours(mask, width, height);
252
+ const allContourPoints = this.traceAllContours(mask, paddedWidth, paddedHeight);
242
253
  if (allContourPoints.length === 0) {
243
- const w = (_c = options.scaleToWidth) != null ? _c : width;
244
- const h = (_d = options.scaleToHeight) != null ? _d : height;
254
+ const w = (_d = options.scaleToWidth) != null ? _d : width;
255
+ const h = (_e = options.scaleToHeight) != null ? _e : height;
245
256
  return `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`;
246
257
  }
247
258
  const primaryContour = allContourPoints.sort(
248
259
  (a, b) => b.length - a.length
249
260
  )[0];
261
+ const unpaddedPoints = primaryContour.map((p) => ({
262
+ x: p.x - padding,
263
+ y: p.y - padding
264
+ }));
250
265
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
251
- for (const p of primaryContour) {
266
+ for (const p of unpaddedPoints) {
252
267
  if (p.x < minX) minX = p.x;
253
268
  if (p.y < minY) minY = p.y;
254
269
  if (p.x > maxX) maxX = p.x;
@@ -260,95 +275,119 @@ var ImageTracer = class {
260
275
  width: maxX - minX,
261
276
  height: maxY - minY
262
277
  };
263
- let finalPoints = primaryContour;
278
+ let finalPoints = unpaddedPoints;
264
279
  if (options.scaleToWidth && options.scaleToHeight) {
265
280
  finalPoints = this.scalePoints(
266
- primaryContour,
281
+ unpaddedPoints,
267
282
  options.scaleToWidth,
268
283
  options.scaleToHeight,
269
284
  globalBounds
270
285
  );
271
286
  }
272
- const simplifiedPoints = this.douglasPeucker(
273
- finalPoints,
274
- (_e = options.simplifyTolerance) != null ? _e : 2
275
- );
276
- return this.pointsToSVG(simplifiedPoints);
287
+ const useSmoothing = options.smoothing !== false;
288
+ if (useSmoothing) {
289
+ return this.pointsToSVGPaper(finalPoints, (_f = options.simplifyTolerance) != null ? _f : 2.5);
290
+ } else {
291
+ const simplifiedPoints = this.douglasPeucker(
292
+ finalPoints,
293
+ (_g = options.simplifyTolerance) != null ? _g : 2
294
+ );
295
+ return this.pointsToSVG(simplifiedPoints);
296
+ }
277
297
  }
278
- static createMask(imageData, threshold) {
298
+ static createMask(imageData, threshold, padding, paddedWidth, paddedHeight) {
279
299
  const { width, height, data } = imageData;
280
- const mask = new Uint8Array(width * height);
281
- for (let i = 0; i < width * height; i++) {
282
- const idx = i * 4;
283
- const r = data[idx];
284
- const g = data[idx + 1];
285
- const b = data[idx + 2];
286
- const a = data[idx + 3];
287
- if (a > threshold && !(r > 240 && g > 240 && b > 240)) {
288
- mask[i] = 1;
289
- } else {
290
- mask[i] = 0;
300
+ const mask = new Uint8Array(paddedWidth * paddedHeight);
301
+ let hasTransparency = false;
302
+ for (let i = 3; i < data.length; i += 4) {
303
+ if (data[i] < 255) {
304
+ hasTransparency = true;
305
+ break;
291
306
  }
292
307
  }
293
- return mask;
294
- }
295
- /**
296
- * Fast 1D-separable Dilation
297
- */
298
- static dilate(mask, width, height, radius) {
299
- const horizontal = new Uint8Array(width * height);
300
308
  for (let y = 0; y < height; y++) {
301
- let count = 0;
302
- for (let x = -radius; x < width; x++) {
303
- if (x + radius < width && mask[y * width + x + radius]) count++;
304
- if (x - radius - 1 >= 0 && mask[y * width + x - radius - 1]) count--;
305
- if (x >= 0) horizontal[y * width + x] = count > 0 ? 1 : 0;
306
- }
307
- }
308
- const vertical = new Uint8Array(width * height);
309
- for (let x = 0; x < width; x++) {
310
- let count = 0;
311
- for (let y = -radius; y < height; y++) {
312
- if (y + radius < height && horizontal[(y + radius) * width + x])
313
- count++;
314
- if (y - radius - 1 >= 0 && horizontal[(y - radius - 1) * width + x])
315
- count--;
316
- if (y >= 0) vertical[y * width + x] = count > 0 ? 1 : 0;
309
+ for (let x = 0; x < width; x++) {
310
+ const srcIdx = (y * width + x) * 4;
311
+ const r = data[srcIdx];
312
+ const g = data[srcIdx + 1];
313
+ const b = data[srcIdx + 2];
314
+ const a = data[srcIdx + 3];
315
+ const destIdx = (y + padding) * paddedWidth + (x + padding);
316
+ if (hasTransparency) {
317
+ if (a > threshold) {
318
+ mask[destIdx] = 1;
319
+ }
320
+ } else {
321
+ if (!(r > 240 && g > 240 && b > 240)) {
322
+ mask[destIdx] = 1;
323
+ }
324
+ }
317
325
  }
318
326
  }
319
- return vertical;
327
+ return mask;
320
328
  }
321
329
  /**
322
- * Fast 1D-separable Erosion
330
+ * Fast circular morphology using a distance-transform inspired separable approach.
331
+ * O(N * R) complexity, where R is the radius.
323
332
  */
324
- static erode(mask, width, height, radius) {
325
- const horizontal = new Uint8Array(width * height);
326
- for (let y = 0; y < height; y++) {
327
- let count = 0;
328
- for (let x = -radius; x < width; x++) {
329
- if (x + radius < width && mask[y * width + x + radius]) count++;
330
- if (x - radius - 1 >= 0 && mask[y * width + x - radius - 1]) count--;
331
- if (x >= 0) {
332
- const winWidth = Math.min(x + radius, width - 1) - Math.max(x - radius, 0) + 1;
333
- horizontal[y * width + x] = count === winWidth ? 1 : 0;
333
+ static circularMorphology(mask, width, height, radius, op) {
334
+ const dilate = (m, r) => {
335
+ const horizontalDist = new Int32Array(width * height);
336
+ for (let y = 0; y < height; y++) {
337
+ let lastSolid = -r * 2;
338
+ for (let x = 0; x < width; x++) {
339
+ if (m[y * width + x]) lastSolid = x;
340
+ horizontalDist[y * width + x] = x - lastSolid;
341
+ }
342
+ lastSolid = width + r * 2;
343
+ for (let x = width - 1; x >= 0; x--) {
344
+ if (m[y * width + x]) lastSolid = x;
345
+ horizontalDist[y * width + x] = Math.min(
346
+ horizontalDist[y * width + x],
347
+ lastSolid - x
348
+ );
334
349
  }
335
350
  }
336
- }
337
- const vertical = new Uint8Array(width * height);
338
- for (let x = 0; x < width; x++) {
339
- let count = 0;
340
- for (let y = -radius; y < height; y++) {
341
- if (y + radius < height && horizontal[(y + radius) * width + x])
342
- count++;
343
- if (y - radius - 1 >= 0 && horizontal[(y - radius - 1) * width + x])
344
- count--;
345
- if (y >= 0) {
346
- const winHeight = Math.min(y + radius, height - 1) - Math.max(y - radius, 0) + 1;
347
- vertical[y * width + x] = count === winHeight ? 1 : 0;
351
+ const result = new Uint8Array(width * height);
352
+ const r2 = r * r;
353
+ for (let x = 0; x < width; x++) {
354
+ for (let y = 0; y < height; y++) {
355
+ let found = false;
356
+ const minY = Math.max(0, y - r);
357
+ const maxY = Math.min(height - 1, y + r);
358
+ for (let dy = minY; dy <= maxY; dy++) {
359
+ const dY = dy - y;
360
+ const hDist = horizontalDist[dy * width + x];
361
+ if (hDist * hDist + dY * dY <= r2) {
362
+ found = true;
363
+ break;
364
+ }
365
+ }
366
+ if (found) result[y * width + x] = 1;
348
367
  }
349
368
  }
369
+ return result;
370
+ };
371
+ const erode = (m, r) => {
372
+ const inverted = new Uint8Array(m.length);
373
+ for (let i = 0; i < m.length; i++) inverted[i] = m[i] ? 0 : 1;
374
+ const dilatedInverted = dilate(inverted, r);
375
+ const result = new Uint8Array(m.length);
376
+ for (let i = 0; i < m.length; i++) result[i] = dilatedInverted[i] ? 0 : 1;
377
+ return result;
378
+ };
379
+ switch (op) {
380
+ case "dilate":
381
+ return dilate(mask, radius);
382
+ case "erode":
383
+ return erode(mask, radius);
384
+ case "closing":
385
+ return erode(dilate(mask, radius), radius);
386
+ case "opening":
387
+ return dilate(erode(mask, radius), radius);
388
+ default:
389
+ return mask;
350
390
  }
351
- return vertical;
352
391
  }
353
392
  /**
354
393
  * Fills internal holes in the binary mask using flood fill from edges.
@@ -548,6 +587,23 @@ var ImageTracer = class {
548
587
  const tail = points.slice(1);
549
588
  return `M ${head.x} ${head.y} ` + tail.map((p) => `L ${p.x} ${p.y}`).join(" ") + " Z";
550
589
  }
590
+ static ensurePaper() {
591
+ if (!paper.project) {
592
+ paper.setup(new paper.Size(100, 100));
593
+ }
594
+ }
595
+ static pointsToSVGPaper(points, tolerance) {
596
+ if (points.length < 3) return this.pointsToSVG(points);
597
+ this.ensurePaper();
598
+ const path = new paper.Path({
599
+ segments: points.map((p) => [p.x, p.y]),
600
+ closed: true
601
+ });
602
+ path.simplify(tolerance);
603
+ const data = path.pathData;
604
+ path.remove();
605
+ return data;
606
+ }
551
607
  };
552
608
 
553
609
  // src/coordinate.ts
@@ -622,96 +678,45 @@ var Coordinate = class {
622
678
  };
623
679
 
624
680
  // src/geometry.ts
625
- import paper from "paper";
626
- function resolveHolePosition(hole, geometry, canvasSize) {
627
- if (hole.anchor) {
628
- const { x, y, width, height } = geometry;
629
- let bx = x;
630
- let by = y;
631
- const left = x - width / 2;
632
- const right = x + width / 2;
633
- const top = y - height / 2;
634
- const bottom = y + height / 2;
635
- switch (hole.anchor) {
636
- case "top-left":
637
- bx = left;
638
- by = top;
639
- break;
640
- case "top-center":
641
- bx = x;
642
- by = top;
643
- break;
644
- case "top-right":
645
- bx = right;
646
- by = top;
647
- break;
648
- case "center-left":
649
- bx = left;
650
- by = y;
651
- break;
652
- case "center":
653
- bx = x;
654
- by = y;
655
- break;
656
- case "center-right":
657
- bx = right;
658
- by = y;
659
- break;
660
- case "bottom-left":
661
- bx = left;
662
- by = bottom;
663
- break;
664
- case "bottom-center":
665
- bx = x;
666
- by = bottom;
667
- break;
668
- case "bottom-right":
669
- bx = right;
670
- by = bottom;
671
- break;
672
- }
673
- return {
674
- x: bx + (hole.offsetX || 0),
675
- y: by + (hole.offsetY || 0)
676
- };
677
- } else if (hole.x !== void 0 && hole.y !== void 0) {
678
- const { x, width, y, height } = geometry;
679
- return {
680
- x: hole.x * width + (x - width / 2) + (hole.offsetX || 0),
681
- y: hole.y * height + (y - height / 2) + (hole.offsetY || 0)
682
- };
683
- }
684
- return { x: 0, y: 0 };
681
+ import paper2 from "paper";
682
+ function resolveFeaturePosition(feature, geometry) {
683
+ const { x, y, width, height } = geometry;
684
+ const left = x - width / 2;
685
+ const top = y - height / 2;
686
+ return {
687
+ x: left + feature.x * width,
688
+ y: top + feature.y * height
689
+ };
685
690
  }
686
691
  function ensurePaper(width, height) {
687
- if (!paper.project) {
688
- paper.setup(new paper.Size(width, height));
692
+ if (!paper2.project) {
693
+ paper2.setup(new paper2.Size(width, height));
689
694
  } else {
690
- paper.view.viewSize = new paper.Size(width, height);
695
+ paper2.view.viewSize = new paper2.Size(width, height);
691
696
  }
692
697
  }
693
698
  function createBaseShape(options) {
694
699
  const { shape, width, height, radius, x, y, pathData } = options;
695
- const center = new paper.Point(x, y);
700
+ const center = new paper2.Point(x, y);
696
701
  if (shape === "rect") {
697
- return new paper.Path.Rectangle({
702
+ return new paper2.Path.Rectangle({
698
703
  point: [x - width / 2, y - height / 2],
699
704
  size: [Math.max(0, width), Math.max(0, height)],
700
705
  radius: Math.max(0, radius)
701
706
  });
702
707
  } else if (shape === "circle") {
703
708
  const r = Math.min(width, height) / 2;
704
- return new paper.Path.Circle({
709
+ return new paper2.Path.Circle({
705
710
  center,
706
711
  radius: Math.max(0, r)
707
712
  });
708
713
  } else if (shape === "ellipse") {
709
- return new paper.Path.Ellipse({
714
+ return new paper2.Path.Ellipse({
710
715
  center,
711
716
  radius: [Math.max(0, width / 2), Math.max(0, height / 2)]
712
717
  });
713
718
  } else if (shape === "custom" && pathData) {
714
- const path = new paper.Path();
719
+ const path = new paper2.Path();
715
720
  path.pathData = pathData;
716
721
  path.position = center;
717
722
  if (width > 0 && height > 0 && path.bounds.width > 0 && path.bounds.height > 0) {
@@ -719,89 +724,76 @@ function createBaseShape(options) {
719
724
  }
720
725
  return path;
721
726
  } else {
722
- return new paper.Path.Rectangle({
727
+ return new paper2.Path.Rectangle({
723
728
  point: [x - width / 2, y - height / 2],
724
729
  size: [Math.max(0, width), Math.max(0, height)]
725
730
  });
726
731
  }
727
732
  }
733
+ function createFeatureItem(feature, center) {
734
+ let item;
735
+ if (feature.shape === "rect") {
736
+ const w = feature.width || 10;
737
+ const h = feature.height || 10;
738
+ const r = feature.radius || 0;
739
+ item = new paper2.Path.Rectangle({
740
+ point: [center.x - w / 2, center.y - h / 2],
741
+ size: [w, h],
742
+ radius: r
743
+ });
744
+ } else {
745
+ const r = feature.radius || 5;
746
+ item = new paper2.Path.Circle({
747
+ center,
748
+ radius: r
749
+ });
750
+ }
751
+ if (feature.rotation) {
752
+ item.rotate(feature.rotation, center);
753
+ }
754
+ return item;
755
+ }
728
756
  function getDielineShape(options) {
729
757
  let mainShape = createBaseShape(options);
730
- const { holes } = options;
731
- if (holes && holes.length > 0) {
732
- let lugsPath = null;
733
- let cutsPath = null;
734
- holes.forEach((hole) => {
735
- const center = new paper.Point(hole.x, hole.y);
736
- const lug = hole.shape === "square" ? new paper.Path.Rectangle({
737
- point: [
738
- center.x - hole.outerRadius,
739
- center.y - hole.outerRadius
740
- ],
741
- size: [hole.outerRadius * 2, hole.outerRadius * 2]
742
- }) : new paper.Path.Circle({
743
- center,
744
- radius: hole.outerRadius
745
- });
746
- const cut = hole.shape === "square" ? new paper.Path.Rectangle({
747
- point: [
748
- center.x - hole.innerRadius,
749
- center.y - hole.innerRadius
750
- ],
751
- size: [hole.innerRadius * 2, hole.innerRadius * 2]
752
- }) : new paper.Path.Circle({
753
- center,
754
- radius: hole.innerRadius
755
- });
756
- if (!lugsPath) {
757
- lugsPath = lug;
758
+ const { features } = options;
759
+ if (features && features.length > 0) {
760
+ const adds = [];
761
+ const subtracts = [];
762
+ features.forEach((f) => {
763
+ const pos = resolveFeaturePosition(f, options);
764
+ const center = new paper2.Point(pos.x, pos.y);
765
+ const item = createFeatureItem(f, center);
766
+ if (f.operation === "add") {
767
+ adds.push(item);
758
768
  } else {
769
+ subtracts.push(item);
770
+ }
771
+ });
772
+ if (adds.length > 0) {
773
+ for (const item of adds) {
759
774
  try {
760
- const temp = lugsPath.unite(lug);
761
- lugsPath.remove();
762
- lug.remove();
763
- lugsPath = temp;
775
+ const temp = mainShape.unite(item);
776
+ mainShape.remove();
777
+ item.remove();
778
+ mainShape = temp;
764
779
  } catch (e) {
765
- console.error("Geometry: Failed to unite lug", e);
766
- lug.remove();
780
+ console.error("Geometry: Failed to unite feature", e);
781
+ item.remove();
767
782
  }
768
783
  }
769
- if (!cutsPath) {
770
- cutsPath = cut;
771
- } else {
784
+ }
785
+ if (subtracts.length > 0) {
786
+ for (const item of subtracts) {
772
787
  try {
773
- const temp = cutsPath.unite(cut);
774
- cutsPath.remove();
775
- cut.remove();
776
- cutsPath = temp;
788
+ const temp = mainShape.subtract(item);
789
+ mainShape.remove();
790
+ item.remove();
791
+ mainShape = temp;
777
792
  } catch (e) {
778
- console.error("Geometry: Failed to unite cut", e);
779
- cut.remove();
793
+ console.error("Geometry: Failed to subtract feature", e);
794
+ item.remove();
780
795
  }
781
796
  }
782
- });
783
- if (lugsPath) {
784
- try {
785
- const temp = mainShape.unite(lugsPath);
786
- mainShape.remove();
787
- lugsPath.remove();
788
- mainShape = temp;
789
- } catch (e) {
790
- console.error("Geometry: Failed to unite lugsPath to mainShape", e);
791
- }
792
- }
793
- if (cutsPath) {
794
- try {
795
- const temp = mainShape.subtract(cutsPath);
796
- mainShape.remove();
797
- cutsPath.remove();
798
- mainShape = temp;
799
- } catch (e) {
800
- console.error(
801
- "Geometry: Failed to subtract cutsPath from mainShape",
802
- e
803
- );
804
- }
805
797
  }
806
798
  }
807
799
  return mainShape;
@@ -810,7 +802,7 @@ function generateDielinePath(options) {
810
802
  const paperWidth = options.canvasWidth || options.width * 2 || 2e3;
811
803
  const paperHeight = options.canvasHeight || options.height * 2 || 2e3;
812
804
  ensurePaper(paperWidth, paperHeight);
813
- paper.project.activeLayer.removeChildren();
805
+ paper2.project.activeLayer.removeChildren();
814
806
  const mainShape = getDielineShape(options);
815
807
  const pathData = mainShape.pathData;
816
808
  mainShape.remove();
@@ -818,9 +810,9 @@ function generateDielinePath(options) {
818
810
  }
819
811
  function generateMaskPath(options) {
820
812
  ensurePaper(options.canvasWidth, options.canvasHeight);
821
- paper.project.activeLayer.removeChildren();
813
+ paper2.project.activeLayer.removeChildren();
822
814
  const { canvasWidth, canvasHeight } = options;
823
- const maskRect = new paper.Path.Rectangle({
815
+ const maskRect = new paper2.Path.Rectangle({
824
816
  point: [0, 0],
825
817
  size: [canvasWidth, canvasHeight]
826
818
  });
@@ -832,43 +824,13 @@ function generateMaskPath(options) {
832
824
  finalMask.remove();
833
825
  return pathData;
834
826
  }
835
- function generateBleedZonePath(options, offset) {
836
- const paperWidth = options.canvasWidth || options.width * 2 || 2e3;
837
- const paperHeight = options.canvasHeight || options.height * 2 || 2e3;
827
+ function generateBleedZonePath(originalOptions, offsetOptions, offset) {
828
+ const paperWidth = originalOptions.canvasWidth || originalOptions.width * 2 || 2e3;
829
+ const paperHeight = originalOptions.canvasHeight || originalOptions.height * 2 || 2e3;
838
830
  ensurePaper(paperWidth, paperHeight);
839
- paper.project.activeLayer.removeChildren();
840
- const shapeOriginal = getDielineShape(options);
841
- let shapeOffset;
842
- if (options.shape === "custom") {
843
- const stroker = shapeOriginal.clone();
844
- stroker.strokeColor = new paper.Color("black");
845
- stroker.strokeWidth = Math.abs(offset) * 2;
846
- stroker.strokeJoin = "round";
847
- stroker.strokeCap = "round";
848
- let expanded;
849
- try {
850
- expanded = stroker.expand({ stroke: true, fill: false, insert: false });
851
- } catch (e) {
852
- stroker.remove();
853
- shapeOffset = shapeOriginal.clone();
854
- return shapeOffset.pathData;
855
- }
856
- stroker.remove();
857
- if (offset > 0) {
858
- shapeOffset = shapeOriginal.unite(expanded);
859
- } else {
860
- shapeOffset = shapeOriginal.subtract(expanded);
861
- }
862
- expanded.remove();
863
- } else {
864
- const offsetOptions = {
865
- ...options,
866
- width: Math.max(0, options.width + offset * 2),
867
- height: Math.max(0, options.height + offset * 2),
868
- radius: options.radius === 0 ? 0 : Math.max(0, options.radius + offset)
869
- };
870
- shapeOffset = getDielineShape(offsetOptions);
871
- }
831
+ paper2.project.activeLayer.removeChildren();
832
+ const shapeOriginal = getDielineShape(originalOptions);
833
+ const shapeOffset = getDielineShape(offsetOptions);
872
834
  let bleedZone;
873
835
  if (offset > 0) {
874
836
  bleedZone = shapeOffset.subtract(shapeOriginal);
@@ -883,16 +845,16 @@ function generateBleedZonePath(options, offset) {
883
845
  }
884
846
  function getNearestPointOnDieline(point, options) {
885
847
  ensurePaper(options.width * 2, options.height * 2);
886
- paper.project.activeLayer.removeChildren();
848
+ paper2.project.activeLayer.removeChildren();
887
849
  const shape = createBaseShape(options);
888
- const p = new paper.Point(point.x, point.y);
850
+ const p = new paper2.Point(point.x, point.y);
889
851
  const nearest = shape.getNearestPoint(p);
890
852
  const result = { x: nearest.x, y: nearest.y };
891
853
  shape.remove();
892
854
  return result;
893
855
  }
894
856
  function getPathBounds(pathData) {
895
- const path = new paper.Path();
857
+ const path = new paper2.Path();
896
858
  path.pathData = pathData;
897
859
  const bounds = path.bounds;
898
860
  path.remove();
@@ -911,20 +873,41 @@ var DielineTool = class {
911
873
  this.metadata = {
912
874
  name: "DielineTool"
913
875
  };
914
- this.unit = "mm";
915
- this.shape = "rect";
916
- this.width = 500;
917
- this.height = 500;
918
- this.radius = 0;
919
- this.offset = 0;
920
- this.style = "solid";
921
- this.insideColor = "rgba(0,0,0,0)";
922
- this.outsideColor = "#ffffff";
923
- this.showBleedLines = true;
924
- this.holes = [];
925
- this.padding = 140;
876
+ this.state = {
877
+ unit: "mm",
878
+ shape: "rect",
879
+ width: 500,
880
+ height: 500,
881
+ radius: 0,
882
+ offset: 0,
883
+ padding: 140,
884
+ mainLine: {
885
+ width: 2.7,
886
+ color: "#FF0000",
887
+ dashLength: 5,
888
+ style: "solid"
889
+ },
890
+ offsetLine: {
891
+ width: 2.7,
892
+ color: "#FF0000",
893
+ dashLength: 5,
894
+ style: "solid"
895
+ },
896
+ insideColor: "rgba(0,0,0,0)",
897
+ outsideColor: "#ffffff",
898
+ showBleedLines: true,
899
+ features: []
900
+ };
926
901
  if (options) {
927
- Object.assign(this, options);
902
+ if (options.mainLine) {
903
+ Object.assign(this.state.mainLine, options.mainLine);
904
+ delete options.mainLine;
905
+ }
906
+ if (options.offsetLine) {
907
+ Object.assign(this.state.offsetLine, options.offsetLine);
908
+ delete options.offsetLine;
909
+ }
910
+ Object.assign(this.state, options);
928
911
  }
929
912
  }
930
913
  activate(context) {
@@ -936,38 +919,93 @@ var DielineTool = class {
936
919
  }
937
920
  const configService = context.services.get("ConfigurationService");
938
921
  if (configService) {
939
- this.unit = configService.get("dieline.unit", this.unit);
940
- this.shape = configService.get("dieline.shape", this.shape);
941
- this.width = configService.get("dieline.width", this.width);
942
- this.height = configService.get("dieline.height", this.height);
943
- this.radius = configService.get("dieline.radius", this.radius);
944
- this.padding = configService.get("dieline.padding", this.padding);
945
- this.offset = configService.get("dieline.offset", this.offset);
946
- this.style = configService.get("dieline.style", this.style);
947
- this.insideColor = configService.get(
948
- "dieline.insideColor",
949
- this.insideColor
950
- );
951
- this.outsideColor = configService.get(
952
- "dieline.outsideColor",
953
- this.outsideColor
954
- );
955
- this.showBleedLines = configService.get(
956
- "dieline.showBleedLines",
957
- this.showBleedLines
958
- );
959
- this.holes = configService.get("dieline.holes", this.holes);
960
- this.pathData = configService.get("dieline.pathData", this.pathData);
922
+ const s = this.state;
923
+ s.unit = configService.get("dieline.unit", s.unit);
924
+ s.shape = configService.get("dieline.shape", s.shape);
925
+ s.width = configService.get("dieline.width", s.width);
926
+ s.height = configService.get("dieline.height", s.height);
927
+ s.radius = configService.get("dieline.radius", s.radius);
928
+ s.padding = configService.get("dieline.padding", s.padding);
929
+ s.offset = configService.get("dieline.offset", s.offset);
930
+ s.mainLine.width = configService.get("dieline.strokeWidth", s.mainLine.width);
931
+ s.mainLine.color = configService.get("dieline.strokeColor", s.mainLine.color);
932
+ s.mainLine.dashLength = configService.get("dieline.dashLength", s.mainLine.dashLength);
933
+ s.mainLine.style = configService.get("dieline.style", s.mainLine.style);
934
+ s.offsetLine.width = configService.get("dieline.offsetStrokeWidth", s.offsetLine.width);
935
+ s.offsetLine.color = configService.get("dieline.offsetStrokeColor", s.offsetLine.color);
936
+ s.offsetLine.dashLength = configService.get("dieline.offsetDashLength", s.offsetLine.dashLength);
937
+ s.offsetLine.style = configService.get("dieline.offsetStyle", s.offsetLine.style);
938
+ s.insideColor = configService.get("dieline.insideColor", s.insideColor);
939
+ s.outsideColor = configService.get("dieline.outsideColor", s.outsideColor);
940
+ s.showBleedLines = configService.get("dieline.showBleedLines", s.showBleedLines);
941
+ s.features = configService.get("dieline.features", s.features);
942
+ s.pathData = configService.get("dieline.pathData", s.pathData);
961
943
  configService.onAnyChange((e) => {
962
944
  if (e.key.startsWith("dieline.")) {
963
- const prop = e.key.split(".")[1];
964
- console.log(
965
- `[DielineTool] Config change detected: ${e.key} -> ${e.value}`
966
- );
967
- if (prop && prop in this) {
968
- this[prop] = e.value;
969
- this.updateDieline();
945
+ console.log(`[DielineTool] Config change detected: ${e.key} -> ${e.value}`);
946
+ switch (e.key) {
947
+ case "dieline.unit":
948
+ s.unit = e.value;
949
+ break;
950
+ case "dieline.shape":
951
+ s.shape = e.value;
952
+ break;
953
+ case "dieline.width":
954
+ s.width = e.value;
955
+ break;
956
+ case "dieline.height":
957
+ s.height = e.value;
958
+ break;
959
+ case "dieline.radius":
960
+ s.radius = e.value;
961
+ break;
962
+ case "dieline.padding":
963
+ s.padding = e.value;
964
+ break;
965
+ case "dieline.offset":
966
+ s.offset = e.value;
967
+ break;
968
+ case "dieline.strokeWidth":
969
+ s.mainLine.width = e.value;
970
+ break;
971
+ case "dieline.strokeColor":
972
+ s.mainLine.color = e.value;
973
+ break;
974
+ case "dieline.dashLength":
975
+ s.mainLine.dashLength = e.value;
976
+ break;
977
+ case "dieline.style":
978
+ s.mainLine.style = e.value;
979
+ break;
980
+ case "dieline.offsetStrokeWidth":
981
+ s.offsetLine.width = e.value;
982
+ break;
983
+ case "dieline.offsetStrokeColor":
984
+ s.offsetLine.color = e.value;
985
+ break;
986
+ case "dieline.offsetDashLength":
987
+ s.offsetLine.dashLength = e.value;
988
+ break;
989
+ case "dieline.offsetStyle":
990
+ s.offsetLine.style = e.value;
991
+ break;
992
+ case "dieline.insideColor":
993
+ s.insideColor = e.value;
994
+ break;
995
+ case "dieline.outsideColor":
996
+ s.outsideColor = e.value;
997
+ break;
998
+ case "dieline.showBleedLines":
999
+ s.showBleedLines = e.value;
1000
+ break;
1001
+ case "dieline.features":
1002
+ s.features = e.value;
1003
+ break;
1004
+ case "dieline.pathData":
1005
+ s.pathData = e.value;
1006
+ break;
970
1007
  }
1008
+ this.updateDieline();
971
1009
  }
972
1010
  });
973
1011
  }
@@ -980,6 +1018,7 @@ var DielineTool = class {
980
1018
  this.context = void 0;
981
1019
  }
982
1020
  contribute() {
1021
+ const s = this.state;
983
1022
  return {
984
1023
  [ContributionPointIds2.CONFIGURATIONS]: [
985
1024
  {
@@ -987,14 +1026,14 @@ var DielineTool = class {
987
1026
  type: "select",
988
1027
  label: "Unit",
989
1028
  options: ["px", "mm", "cm", "in"],
990
- default: this.unit
1029
+ default: s.unit
991
1030
  },
992
1031
  {
993
1032
  id: "dieline.shape",
994
1033
  type: "select",
995
1034
  label: "Shape",
996
1035
  options: ["rect", "circle", "ellipse", "custom"],
997
- default: this.shape
1036
+ default: s.shape
998
1037
  },
999
1038
  {
1000
1039
  id: "dieline.width",
@@ -1002,7 +1041,7 @@ var DielineTool = class {
1002
1041
  label: "Width",
1003
1042
  min: 10,
1004
1043
  max: 2e3,
1005
- default: this.width
1044
+ default: s.width
1006
1045
  },
1007
1046
  {
1008
1047
  id: "dieline.height",
@@ -1010,7 +1049,7 @@ var DielineTool = class {
1010
1049
  label: "Height",
1011
1050
  min: 10,
1012
1051
  max: 2e3,
1013
- default: this.height
1052
+ default: s.height
1014
1053
  },
1015
1054
  {
1016
1055
  id: "dieline.radius",
@@ -1018,20 +1057,14 @@ var DielineTool = class {
1018
1057
  label: "Corner Radius",
1019
1058
  min: 0,
1020
1059
  max: 500,
1021
- default: this.radius
1022
- },
1023
- {
1024
- id: "dieline.position",
1025
- type: "json",
1026
- label: "Position (Normalized)",
1027
- default: this.radius
1060
+ default: s.radius
1028
1061
  },
1029
1062
  {
1030
1063
  id: "dieline.padding",
1031
1064
  type: "select",
1032
1065
  label: "View Padding",
1033
1066
  options: [0, 10, 20, 40, 60, 100, "2%", "5%", "10%", "15%", "20%"],
1034
- default: this.padding
1067
+ default: s.padding
1035
1068
  },
1036
1069
  {
1037
1070
  id: "dieline.offset",
@@ -1039,38 +1072,91 @@ var DielineTool = class {
1039
1072
  label: "Bleed Offset",
1040
1073
  min: -100,
1041
1074
  max: 100,
1042
- default: this.offset
1075
+ default: s.offset
1043
1076
  },
1044
1077
  {
1045
1078
  id: "dieline.showBleedLines",
1046
1079
  type: "boolean",
1047
1080
  label: "Show Bleed Lines",
1048
- default: this.showBleedLines
1081
+ default: s.showBleedLines
1082
+ },
1083
+ {
1084
+ id: "dieline.strokeWidth",
1085
+ type: "number",
1086
+ label: "Line Width",
1087
+ min: 0.1,
1088
+ max: 10,
1089
+ step: 0.1,
1090
+ default: s.mainLine.width
1091
+ },
1092
+ {
1093
+ id: "dieline.strokeColor",
1094
+ type: "color",
1095
+ label: "Line Color",
1096
+ default: s.mainLine.color
1097
+ },
1098
+ {
1099
+ id: "dieline.dashLength",
1100
+ type: "number",
1101
+ label: "Dash Length",
1102
+ min: 1,
1103
+ max: 50,
1104
+ default: s.mainLine.dashLength
1049
1105
  },
1050
1106
  {
1051
1107
  id: "dieline.style",
1052
1108
  type: "select",
1053
1109
  label: "Line Style",
1054
- options: ["solid", "dashed"],
1055
- default: this.style
1110
+ options: ["solid", "dashed", "hidden"],
1111
+ default: s.mainLine.style
1112
+ },
1113
+ {
1114
+ id: "dieline.offsetStrokeWidth",
1115
+ type: "number",
1116
+ label: "Offset Line Width",
1117
+ min: 0.1,
1118
+ max: 10,
1119
+ step: 0.1,
1120
+ default: s.offsetLine.width
1121
+ },
1122
+ {
1123
+ id: "dieline.offsetStrokeColor",
1124
+ type: "color",
1125
+ label: "Offset Line Color",
1126
+ default: s.offsetLine.color
1127
+ },
1128
+ {
1129
+ id: "dieline.offsetDashLength",
1130
+ type: "number",
1131
+ label: "Offset Dash Length",
1132
+ min: 1,
1133
+ max: 50,
1134
+ default: s.offsetLine.dashLength
1135
+ },
1136
+ {
1137
+ id: "dieline.offsetStyle",
1138
+ type: "select",
1139
+ label: "Offset Line Style",
1140
+ options: ["solid", "dashed", "hidden"],
1141
+ default: s.offsetLine.style
1056
1142
  },
1057
1143
  {
1058
1144
  id: "dieline.insideColor",
1059
1145
  type: "color",
1060
1146
  label: "Inside Color",
1061
- default: this.insideColor
1147
+ default: s.insideColor
1062
1148
  },
1063
1149
  {
1064
1150
  id: "dieline.outsideColor",
1065
1151
  type: "color",
1066
1152
  label: "Outside Color",
1067
- default: this.outsideColor
1153
+ default: s.outsideColor
1068
1154
  },
1069
1155
  {
1070
- id: "dieline.holes",
1156
+ id: "dieline.features",
1071
1157
  type: "json",
1072
- label: "Holes",
1073
- default: this.holes
1158
+ label: "Edge Features",
1159
+ default: s.features
1074
1160
  }
1075
1161
  ],
1076
1162
  [ContributionPointIds2.COMMANDS]: [
@@ -1092,24 +1178,18 @@ var DielineTool = class {
1092
1178
  command: "detectEdge",
1093
1179
  title: "Detect Edge from Image",
1094
1180
  handler: async (imageUrl, options) => {
1095
- var _a;
1096
1181
  try {
1097
1182
  const pathData = await ImageTracer.trace(imageUrl, options);
1098
1183
  const bounds = getPathBounds(pathData);
1099
- const currentMax = Math.max(this.width, this.height);
1184
+ const currentMax = Math.max(s.width, s.height);
1100
1185
  const scale = currentMax / Math.max(bounds.width, bounds.height);
1101
1186
  const newWidth = bounds.width * scale;
1102
1187
  const newHeight = bounds.height * scale;
1103
- const configService = (_a = this.context) == null ? void 0 : _a.services.get(
1104
- "ConfigurationService"
1105
- );
1106
- if (configService) {
1107
- configService.update("dieline.width", newWidth);
1108
- configService.update("dieline.height", newHeight);
1109
- configService.update("dieline.shape", "custom");
1110
- configService.update("dieline.pathData", pathData);
1111
- }
1112
- return pathData;
1188
+ return {
1189
+ pathData,
1190
+ width: newWidth,
1191
+ height: newHeight
1192
+ };
1113
1193
  } catch (e) {
1114
1194
  console.error("Edge detection failed", e);
1115
1195
  throw e;
@@ -1168,15 +1248,15 @@ var DielineTool = class {
1168
1248
  return new Pattern({ source: canvas, repetition: "repeat" });
1169
1249
  }
1170
1250
  resolvePadding(containerWidth, containerHeight) {
1171
- if (typeof this.padding === "number") {
1172
- return this.padding;
1251
+ if (typeof this.state.padding === "number") {
1252
+ return this.state.padding;
1173
1253
  }
1174
- if (typeof this.padding === "string") {
1175
- if (this.padding.endsWith("%")) {
1176
- const percent = parseFloat(this.padding) / 100;
1254
+ if (typeof this.state.padding === "string") {
1255
+ if (this.state.padding.endsWith("%")) {
1256
+ const percent = parseFloat(this.state.padding) / 100;
1177
1257
  return Math.min(containerWidth, containerHeight) * percent;
1178
1258
  }
1179
- return parseFloat(this.padding) || 0;
1259
+ return parseFloat(this.state.padding) || 0;
1180
1260
  }
1181
1261
  return 0;
1182
1262
  }
@@ -1189,14 +1269,14 @@ var DielineTool = class {
1189
1269
  shape,
1190
1270
  radius,
1191
1271
  offset,
1192
- style,
1272
+ mainLine,
1273
+ offsetLine,
1193
1274
  insideColor,
1194
1275
  outsideColor,
1195
- position,
1196
1276
  showBleedLines,
1197
- holes
1198
- } = this;
1199
- let { width, height } = this;
1277
+ features
1278
+ } = this.state;
1279
+ let { width, height } = this.state;
1200
1280
  const canvasW = this.canvasService.canvas.width || 800;
1201
1281
  const canvasH = this.canvasService.canvas.height || 600;
1202
1282
  const paddingPx = this.resolvePadding(canvasW, canvasH);
@@ -1213,40 +1293,27 @@ var DielineTool = class {
1213
1293
  const visualRadius = radius * scale;
1214
1294
  const visualOffset = offset * scale;
1215
1295
  layer.remove(...layer.getObjects());
1216
- const geometryForHoles = {
1217
- x: cx,
1218
- y: cy,
1219
- width: visualWidth,
1220
- height: visualHeight
1221
- // Pass scale/unit context if needed by resolveHolePosition (though currently unused there)
1222
- };
1223
- const absoluteHoles = (holes || []).map((h) => {
1224
- const unitScale = Coordinate.convertUnit(1, "mm", unit);
1225
- const offsetScale = unitScale * scale;
1226
- const hWithPixelOffsets = {
1227
- ...h,
1228
- offsetX: (h.offsetX || 0) * offsetScale,
1229
- offsetY: (h.offsetY || 0) * offsetScale
1230
- };
1231
- const pos = resolveHolePosition(hWithPixelOffsets, geometryForHoles, {
1232
- width: canvasW,
1233
- height: canvasH
1234
- });
1296
+ const absoluteFeatures = (features || []).map((f) => {
1297
+ const featureScale = scale;
1235
1298
  return {
1236
- ...h,
1237
- x: pos.x,
1238
- y: pos.y,
1239
- // Scale hole radii: mm -> current unit -> pixels
1240
- innerRadius: h.innerRadius * offsetScale,
1241
- outerRadius: h.outerRadius * offsetScale,
1242
- // Store scaled offsets in the result for consistency, though pos is already resolved
1243
- offsetX: hWithPixelOffsets.offsetX,
1244
- offsetY: hWithPixelOffsets.offsetY
1299
+ ...f,
1300
+ x: f.x,
1301
+ y: f.y,
1302
+ width: (f.width || 0) * featureScale,
1303
+ height: (f.height || 0) * featureScale,
1304
+ radius: (f.radius || 0) * featureScale
1245
1305
  };
1246
1306
  });
1307
+ const originalFeatures = absoluteFeatures.filter(
1308
+ (f) => !f.target || f.target === "original" || f.target === "both"
1309
+ );
1310
+ const offsetFeatures = absoluteFeatures.filter(
1311
+ (f) => f.target === "offset" || f.target === "both"
1312
+ );
1247
1313
  const cutW = Math.max(0, visualWidth + visualOffset * 2);
1248
1314
  const cutH = Math.max(0, visualHeight + visualOffset * 2);
1249
1315
  const cutR = visualRadius === 0 ? 0 : Math.max(0, visualRadius + visualOffset);
1316
+ const maskFeatures = visualOffset !== 0 ? offsetFeatures : originalFeatures;
1250
1317
  const maskPathData = generateMaskPath({
1251
1318
  canvasWidth: canvasW,
1252
1319
  canvasHeight: canvasH,
@@ -1256,8 +1323,8 @@ var DielineTool = class {
1256
1323
  radius: cutR,
1257
1324
  x: cx,
1258
1325
  y: cy,
1259
- holes: absoluteHoles,
1260
- pathData: this.pathData
1326
+ features: maskFeatures,
1327
+ pathData: this.state.pathData
1261
1328
  });
1262
1329
  const mask = new Path(maskPathData, {
1263
1330
  fill: outsideColor,
@@ -1278,8 +1345,9 @@ var DielineTool = class {
1278
1345
  radius: cutR,
1279
1346
  x: cx,
1280
1347
  y: cy,
1281
- holes: absoluteHoles,
1282
- pathData: this.pathData,
1348
+ features: maskFeatures,
1349
+ // Use same features as mask for consistency
1350
+ pathData: this.state.pathData,
1283
1351
  canvasWidth: canvasW,
1284
1352
  canvasHeight: canvasH
1285
1353
  });
@@ -1303,15 +1371,27 @@ var DielineTool = class {
1303
1371
  radius: visualRadius,
1304
1372
  x: cx,
1305
1373
  y: cy,
1306
- holes: absoluteHoles,
1307
- pathData: this.pathData,
1374
+ features: originalFeatures,
1375
+ pathData: this.state.pathData,
1376
+ canvasWidth: canvasW,
1377
+ canvasHeight: canvasH
1378
+ },
1379
+ {
1380
+ shape,
1381
+ width: cutW,
1382
+ height: cutH,
1383
+ radius: cutR,
1384
+ x: cx,
1385
+ y: cy,
1386
+ features: offsetFeatures,
1387
+ pathData: this.state.pathData,
1308
1388
  canvasWidth: canvasW,
1309
1389
  canvasHeight: canvasH
1310
1390
  },
1311
1391
  visualOffset
1312
1392
  );
1313
1393
  if (showBleedLines !== false) {
1314
- const pattern = this.createHatchPattern("red");
1394
+ const pattern = this.createHatchPattern(mainLine.color);
1315
1395
  if (pattern) {
1316
1396
  const bleedObj = new Path(bleedPathData, {
1317
1397
  fill: pattern,
@@ -1332,18 +1412,16 @@ var DielineTool = class {
1332
1412
  radius: cutR,
1333
1413
  x: cx,
1334
1414
  y: cy,
1335
- holes: absoluteHoles,
1336
- pathData: this.pathData,
1415
+ features: offsetFeatures,
1416
+ pathData: this.state.pathData,
1337
1417
  canvasWidth: canvasW,
1338
1418
  canvasHeight: canvasH
1339
1419
  });
1340
1420
  const offsetBorderObj = new Path(offsetPathData, {
1341
1421
  fill: null,
1342
- stroke: "#666",
1343
- // Grey
1344
- strokeWidth: 1,
1345
- strokeDashArray: [4, 4],
1346
- // Dashed
1422
+ stroke: offsetLine.style === "hidden" ? null : offsetLine.color,
1423
+ strokeWidth: offsetLine.width,
1424
+ strokeDashArray: offsetLine.style === "dashed" ? [offsetLine.dashLength, offsetLine.dashLength] : void 0,
1347
1425
  selectable: false,
1348
1426
  evented: false,
1349
1427
  originX: "left",
@@ -1358,16 +1436,16 @@ var DielineTool = class {
1358
1436
  radius: visualRadius,
1359
1437
  x: cx,
1360
1438
  y: cy,
1361
- holes: absoluteHoles,
1362
- pathData: this.pathData,
1439
+ features: originalFeatures,
1440
+ pathData: this.state.pathData,
1363
1441
  canvasWidth: canvasW,
1364
1442
  canvasHeight: canvasH
1365
1443
  });
1366
1444
  const borderObj = new Path(borderPathData, {
1367
1445
  fill: "transparent",
1368
- stroke: "red",
1369
- strokeWidth: 1,
1370
- strokeDashArray: style === "dashed" ? [5, 5] : void 0,
1446
+ stroke: mainLine.style === "hidden" ? null : mainLine.color,
1447
+ strokeWidth: mainLine.width,
1448
+ strokeDashArray: mainLine.style === "dashed" ? [mainLine.dashLength, mainLine.dashLength] : void 0,
1371
1449
  selectable: false,
1372
1450
  evented: false,
1373
1451
  originX: "left",
@@ -1399,7 +1477,7 @@ var DielineTool = class {
1399
1477
  }
1400
1478
  getGeometry() {
1401
1479
  if (!this.canvasService) return null;
1402
- const { unit, shape, width, height, radius, position, offset } = this;
1480
+ const { unit, shape, width, height, radius, offset, mainLine, pathData } = this.state;
1403
1481
  const canvasW = this.canvasService.canvas.width || 800;
1404
1482
  const canvasH = this.canvasService.canvas.height || 600;
1405
1483
  const paddingPx = this.resolvePadding(canvasW, canvasH);
@@ -1422,16 +1500,17 @@ var DielineTool = class {
1422
1500
  height: visualHeight,
1423
1501
  radius: radius * scale,
1424
1502
  offset: offset * scale,
1425
- // Pass scale to help other tools (like HoleTool) convert units
1503
+ // Pass scale to help other tools (like FeatureTool) convert units
1426
1504
  scale,
1427
- pathData: this.pathData
1505
+ strokeWidth: mainLine.width,
1506
+ pathData
1428
1507
  };
1429
1508
  }
1430
1509
  async exportCutImage() {
1431
1510
  if (!this.canvasService) return null;
1432
1511
  const userLayer = this.canvasService.getLayer("user");
1433
1512
  if (!userLayer) return null;
1434
- const { shape, width, height, radius, position, holes } = this;
1513
+ const { shape, width, height, radius, features, unit, pathData } = this.state;
1435
1514
  const canvasW = this.canvasService.canvas.width || 800;
1436
1515
  const canvasH = this.canvasService.canvas.height || 600;
1437
1516
  const paddingPx = this.resolvePadding(canvasW, canvasH);
@@ -1446,55 +1525,45 @@ var DielineTool = class {
1446
1525
  const visualWidth = layout.width;
1447
1526
  const visualHeight = layout.height;
1448
1527
  const visualRadius = radius * scale;
1449
- const absoluteHoles = (holes || []).map((h) => {
1450
- const unit = this.unit || "mm";
1451
- const unitScale = Coordinate.convertUnit(1, "mm", unit);
1452
- const pos = resolveHolePosition(
1453
- {
1454
- ...h,
1455
- offsetX: (h.offsetX || 0) * unitScale * scale,
1456
- offsetY: (h.offsetY || 0) * unitScale * scale
1457
- },
1458
- { x: cx, y: cy, width: visualWidth, height: visualHeight },
1459
- { width: canvasW, height: canvasH }
1460
- );
1528
+ const absoluteFeatures = (features || []).map((f) => {
1529
+ const featureScale = scale;
1461
1530
  return {
1462
- ...h,
1463
- x: pos.x,
1464
- y: pos.y,
1465
- innerRadius: h.innerRadius * unitScale * scale,
1466
- outerRadius: h.outerRadius * unitScale * scale,
1467
- offsetX: (h.offsetX || 0) * unitScale * scale,
1468
- offsetY: (h.offsetY || 0) * unitScale * scale
1531
+ ...f,
1532
+ x: f.x,
1533
+ y: f.y,
1534
+ width: (f.width || 0) * featureScale,
1535
+ height: (f.height || 0) * featureScale,
1536
+ radius: (f.radius || 0) * featureScale
1469
1537
  };
1470
1538
  });
1471
- const pathData = generateDielinePath({
1539
+ const originalFeatures = absoluteFeatures.filter(
1540
+ (f) => !f.target || f.target === "original" || f.target === "both"
1541
+ );
1542
+ const generatedPathData = generateDielinePath({
1472
1543
  shape,
1473
1544
  width: visualWidth,
1474
1545
  height: visualHeight,
1475
1546
  radius: visualRadius,
1476
1547
  x: cx,
1477
1548
  y: cy,
1478
- holes: absoluteHoles,
1479
- pathData: this.pathData,
1549
+ features: originalFeatures,
1550
+ pathData,
1480
1551
  canvasWidth: canvasW,
1481
1552
  canvasHeight: canvasH
1482
1553
  });
1483
1554
  const clonedLayer = await userLayer.clone();
1484
- const clipPath = new Path(pathData, {
1555
+ const clipPath = new Path(generatedPathData, {
1485
1556
  originX: "left",
1486
1557
  originY: "top",
1487
1558
  left: 0,
1488
1559
  top: 0,
1489
1560
  absolutePositioned: true
1490
- // Important for groups
1491
1561
  });
1492
1562
  clonedLayer.clipPath = clipPath;
1493
1563
  const bounds = clipPath.getBoundingRect();
1494
1564
  const dataUrl = clonedLayer.toDataURL({
1495
1565
  format: "png",
1496
1566
  multiplier: 2,
1497
- // Better quality
1498
1567
  left: bounds.left,
1499
1568
  top: bounds.top,
1500
1569
  width: bounds.width,
@@ -1665,24 +1734,22 @@ var FilmTool = class {
1665
1734
  }
1666
1735
  };
1667
1736
 
1668
- // src/hole.ts
1737
+ // src/feature.ts
1669
1738
  import {
1670
1739
  ContributionPointIds as ContributionPointIds4
1671
1740
  } from "@pooder/core";
1672
1741
  import { Circle, Group, Point, Rect as Rect2 } from "fabric";
1673
- var HoleTool = class {
1742
+ var FeatureTool = class {
1674
1743
  constructor(options) {
1675
- this.id = "pooder.kit.hole";
1744
+ this.id = "pooder.kit.feature";
1676
1745
  this.metadata = {
1677
- name: "HoleTool"
1746
+ name: "FeatureTool"
1678
1747
  };
1679
- this.holes = [];
1680
- this.constraintTarget = "bleed";
1748
+ this.features = [];
1681
1749
  this.isUpdatingConfig = false;
1682
1750
  this.handleMoving = null;
1683
1751
  this.handleModified = null;
1684
1752
  this.handleDielineChange = null;
1685
- // Cache geometry to enforce constraints during drag
1686
1753
  this.currentGeometry = null;
1687
1754
  if (options) {
1688
1755
  Object.assign(this, options);
@@ -1692,26 +1759,18 @@ var HoleTool = class {
1692
1759
  this.context = context;
1693
1760
  this.canvasService = context.services.get("CanvasService");
1694
1761
  if (!this.canvasService) {
1695
- console.warn("CanvasService not found for HoleTool");
1762
+ console.warn("CanvasService not found for FeatureTool");
1696
1763
  return;
1697
1764
  }
1698
1765
  const configService = context.services.get(
1699
1766
  "ConfigurationService"
1700
1767
  );
1701
1768
  if (configService) {
1702
- this.constraintTarget = configService.get(
1703
- "hole.constraintTarget",
1704
- this.constraintTarget
1705
- );
1706
- this.holes = configService.get("dieline.holes", []);
1769
+ this.features = configService.get("dieline.features", []);
1707
1770
  configService.onAnyChange((e) => {
1708
1771
  if (this.isUpdatingConfig) return;
1709
- if (e.key === "hole.constraintTarget") {
1710
- this.constraintTarget = e.value;
1711
- this.enforceConstraints();
1712
- }
1713
- if (e.key === "dieline.holes") {
1714
- this.holes = e.value || [];
1772
+ if (e.key === "dieline.features") {
1773
+ this.features = e.value || [];
1715
1774
  this.redraw();
1716
1775
  }
1717
1776
  });
@@ -1725,102 +1784,38 @@ var HoleTool = class {
1725
1784
  }
1726
1785
  contribute() {
1727
1786
  return {
1728
- [ContributionPointIds4.CONFIGURATIONS]: [
1729
- {
1730
- id: "hole.constraintTarget",
1731
- type: "select",
1732
- label: "Constraint Target",
1733
- options: ["original", "bleed"],
1734
- default: "bleed"
1735
- }
1736
- ],
1737
1787
  [ContributionPointIds4.COMMANDS]: [
1738
1788
  {
1739
- command: "resetHoles",
1740
- title: "Reset Holes",
1741
- handler: () => {
1742
- var _a;
1743
- if (!this.canvasService) return false;
1744
- let defaultPos = { x: this.canvasService.canvas.width / 2, y: 50 };
1745
- if (this.currentGeometry) {
1746
- const g = this.currentGeometry;
1747
- const topCenter = { x: g.x, y: g.y - g.height / 2 };
1748
- defaultPos = getNearestPointOnDieline(topCenter, {
1749
- ...g,
1750
- holes: []
1751
- });
1752
- }
1753
- const { width, height } = this.canvasService.canvas;
1754
- const normalizedHole = Coordinate.normalizePoint(defaultPos, {
1755
- width: width || 800,
1756
- height: height || 600
1757
- });
1758
- const configService = (_a = this.context) == null ? void 0 : _a.services.get(
1759
- "ConfigurationService"
1760
- );
1761
- if (configService) {
1762
- configService.update("dieline.holes", [
1763
- {
1764
- x: normalizedHole.x,
1765
- y: normalizedHole.y,
1766
- innerRadius: 15,
1767
- outerRadius: 25
1768
- }
1769
- ]);
1770
- }
1771
- return true;
1789
+ command: "addFeature",
1790
+ title: "Add Edge Feature",
1791
+ handler: (type = "subtract") => {
1792
+ return this.addFeature(type);
1772
1793
  }
1773
1794
  },
1774
1795
  {
1775
1796
  command: "addHole",
1776
1797
  title: "Add Hole",
1777
- handler: (x, y) => {
1778
- var _a, _b, _c, _d;
1779
- if (!this.canvasService) return false;
1780
- let normalizedX = 0.5;
1781
- let normalizedY = 0.5;
1782
- if (this.currentGeometry) {
1783
- const { x: gx, y: gy, width: gw, height: gh } = this.currentGeometry;
1784
- const left = gx - gw / 2;
1785
- const top = gy - gh / 2;
1786
- normalizedX = gw > 0 ? (x - left) / gw : 0.5;
1787
- normalizedY = gh > 0 ? (y - top) / gh : 0.5;
1788
- } else {
1789
- const { width, height } = this.canvasService.canvas;
1790
- normalizedX = Coordinate.toNormalized(x, width || 800);
1791
- normalizedY = Coordinate.toNormalized(y, height || 600);
1792
- }
1793
- const configService = (_a = this.context) == null ? void 0 : _a.services.get(
1794
- "ConfigurationService"
1795
- );
1796
- if (configService) {
1797
- const currentHoles = configService.get("dieline.holes", []);
1798
- const lastHole = currentHoles[currentHoles.length - 1];
1799
- const innerRadius = (_b = lastHole == null ? void 0 : lastHole.innerRadius) != null ? _b : 15;
1800
- const outerRadius = (_c = lastHole == null ? void 0 : lastHole.outerRadius) != null ? _c : 25;
1801
- const shape = (_d = lastHole == null ? void 0 : lastHole.shape) != null ? _d : "circle";
1802
- const newHole = {
1803
- x: normalizedX,
1804
- y: normalizedY,
1805
- shape,
1806
- innerRadius,
1807
- outerRadius
1808
- };
1809
- configService.update("dieline.holes", [...currentHoles, newHole]);
1810
- }
1811
- return true;
1798
+ handler: () => {
1799
+ return this.addFeature("subtract");
1800
+ }
1801
+ },
1802
+ {
1803
+ command: "addDoubleLayerHole",
1804
+ title: "Add Double Layer Hole",
1805
+ handler: () => {
1806
+ return this.addDoubleLayerHole();
1812
1807
  }
1813
1808
  },
1814
1809
  {
1815
- command: "clearHoles",
1816
- title: "Clear Holes",
1810
+ command: "clearFeatures",
1811
+ title: "Clear Features",
1817
1812
  handler: () => {
1818
1813
  var _a;
1819
1814
  const configService = (_a = this.context) == null ? void 0 : _a.services.get(
1820
1815
  "ConfigurationService"
1821
1816
  );
1822
1817
  if (configService) {
1823
- configService.update("dieline.holes", []);
1818
+ configService.update("dieline.features", []);
1824
1819
  }
1825
1820
  return true;
1826
1821
  }
@@ -1828,6 +1823,88 @@ var HoleTool = class {
1828
1823
  ]
1829
1824
  };
1830
1825
  }
1826
+ addFeature(type) {
1827
+ var _a;
1828
+ if (!this.canvasService) return false;
1829
+ const configService = (_a = this.context) == null ? void 0 : _a.services.get(
1830
+ "ConfigurationService"
1831
+ );
1832
+ const unit = (configService == null ? void 0 : configService.get("dieline.unit", "mm")) || "mm";
1833
+ const defaultSize = Coordinate.convertUnit(10, "mm", unit);
1834
+ const newFeature = {
1835
+ id: Date.now().toString(),
1836
+ operation: type,
1837
+ target: "original",
1838
+ shape: "rect",
1839
+ x: 0.5,
1840
+ y: 0,
1841
+ // Top edge
1842
+ width: defaultSize,
1843
+ height: defaultSize,
1844
+ rotation: 0
1845
+ };
1846
+ if (configService) {
1847
+ const current = configService.get(
1848
+ "dieline.features",
1849
+ []
1850
+ );
1851
+ configService.update("dieline.features", [...current, newFeature]);
1852
+ }
1853
+ return true;
1854
+ }
1855
+ addDoubleLayerHole() {
1856
+ var _a;
1857
+ if (!this.canvasService) return false;
1858
+ const configService = (_a = this.context) == null ? void 0 : _a.services.get(
1859
+ "ConfigurationService"
1860
+ );
1861
+ const unit = (configService == null ? void 0 : configService.get("dieline.unit", "mm")) || "mm";
1862
+ const lugRadius = Coordinate.convertUnit(20, "mm", unit);
1863
+ const holeRadius = Coordinate.convertUnit(15, "mm", unit);
1864
+ const groupId = Date.now().toString();
1865
+ const timestamp = Date.now();
1866
+ const lug = {
1867
+ id: `${timestamp}-lug`,
1868
+ groupId,
1869
+ operation: "add",
1870
+ shape: "circle",
1871
+ x: 0.5,
1872
+ y: 0,
1873
+ radius: lugRadius,
1874
+ // 20mm
1875
+ rotation: 0
1876
+ };
1877
+ const hole = {
1878
+ id: `${timestamp}-hole`,
1879
+ groupId,
1880
+ operation: "subtract",
1881
+ shape: "circle",
1882
+ x: 0.5,
1883
+ y: 0,
1884
+ radius: holeRadius,
1885
+ // 15mm
1886
+ rotation: 0
1887
+ };
1888
+ if (configService) {
1889
+ const current = configService.get(
1890
+ "dieline.features",
1891
+ []
1892
+ );
1893
+ configService.update("dieline.features", [...current, lug, hole]);
1894
+ }
1895
+ return true;
1896
+ }
1897
+ getGeometryForFeature(geometry, feature) {
1898
+ if ((feature == null ? void 0 : feature.target) === "offset" && geometry.offset !== 0) {
1899
+ return {
1900
+ ...geometry,
1901
+ width: geometry.width + geometry.offset * 2,
1902
+ height: geometry.height + geometry.offset * 2,
1903
+ radius: geometry.radius === 0 ? 0 : Math.max(0, geometry.radius + geometry.offset)
1904
+ };
1905
+ }
1906
+ return geometry;
1907
+ }
1831
1908
  setup() {
1832
1909
  if (!this.canvasService || !this.context) return;
1833
1910
  const canvas = this.canvasService.canvas;
@@ -1835,10 +1912,7 @@ var HoleTool = class {
1835
1912
  this.handleDielineChange = (geometry) => {
1836
1913
  this.currentGeometry = geometry;
1837
1914
  this.redraw();
1838
- const changed = this.enforceConstraints();
1839
- if (changed) {
1840
- this.syncHolesToDieline();
1841
- }
1915
+ this.enforceConstraints();
1842
1916
  };
1843
1917
  this.context.eventBus.on(
1844
1918
  "dieline:geometry:change",
@@ -1848,69 +1922,101 @@ var HoleTool = class {
1848
1922
  const commandService = this.context.services.get("CommandService");
1849
1923
  if (commandService) {
1850
1924
  try {
1851
- const geometry = commandService.executeCommand("getGeometry");
1852
- if (geometry) {
1853
- Promise.resolve(geometry).then((g) => {
1925
+ Promise.resolve(commandService.executeCommand("getGeometry")).then(
1926
+ (g) => {
1854
1927
  if (g) {
1855
1928
  this.currentGeometry = g;
1856
- this.enforceConstraints();
1857
- this.initializeHoles();
1929
+ this.redraw();
1858
1930
  }
1859
- });
1860
- }
1931
+ }
1932
+ );
1861
1933
  } catch (e) {
1862
1934
  }
1863
1935
  }
1864
1936
  if (!this.handleMoving) {
1865
1937
  this.handleMoving = (e) => {
1866
- var _a, _b, _c, _d, _e;
1938
+ var _a, _b, _c, _d;
1867
1939
  const target = e.target;
1868
- if (!target || ((_a = target.data) == null ? void 0 : _a.type) !== "hole-marker") return;
1940
+ if (!target || ((_a = target.data) == null ? void 0 : _a.type) !== "feature-marker") return;
1869
1941
  if (!this.currentGeometry) return;
1870
- const index = (_c = (_b = target.data) == null ? void 0 : _b.index) != null ? _c : -1;
1871
- const holeData = this.holes[index];
1872
- const effectiveOffset = this.constraintTarget === "original" ? 0 : this.currentGeometry.offset;
1873
- const constraintGeometry = {
1874
- ...this.currentGeometry,
1875
- width: Math.max(0, this.currentGeometry.width + effectiveOffset * 2),
1876
- height: Math.max(
1877
- 0,
1878
- this.currentGeometry.height + effectiveOffset * 2
1879
- ),
1880
- radius: Math.max(0, this.currentGeometry.radius + effectiveOffset)
1881
- };
1882
- const p = new Point(target.left, target.top);
1883
- const newPos = this.calculateConstrainedPosition(
1884
- p,
1885
- constraintGeometry,
1886
- (_d = holeData == null ? void 0 : holeData.innerRadius) != null ? _d : 15,
1887
- (_e = holeData == null ? void 0 : holeData.outerRadius) != null ? _e : 25
1942
+ let feature;
1943
+ if ((_b = target.data) == null ? void 0 : _b.isGroup) {
1944
+ const indices = (_c = target.data) == null ? void 0 : _c.indices;
1945
+ if (indices && indices.length > 0) {
1946
+ feature = this.features[indices[0]];
1947
+ }
1948
+ } else {
1949
+ const index = (_d = target.data) == null ? void 0 : _d.index;
1950
+ if (index !== void 0) {
1951
+ feature = this.features[index];
1952
+ }
1953
+ }
1954
+ const geometry = this.getGeometryForFeature(
1955
+ this.currentGeometry,
1956
+ feature
1888
1957
  );
1958
+ const p = new Point(target.left, target.top);
1959
+ const markerStrokeWidth = (target.strokeWidth || 2) * (target.scaleX || 1);
1960
+ const minDim = Math.min(target.getScaledWidth(), target.getScaledHeight());
1961
+ const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
1962
+ const snapped = this.constrainPosition(p, geometry, limit);
1889
1963
  target.set({
1890
- left: newPos.x,
1891
- top: newPos.y
1964
+ left: snapped.x,
1965
+ top: snapped.y
1892
1966
  });
1893
1967
  };
1894
1968
  canvas.on("object:moving", this.handleMoving);
1895
1969
  }
1896
1970
  if (!this.handleModified) {
1897
1971
  this.handleModified = (e) => {
1898
- var _a;
1972
+ var _a, _b, _c, _d;
1899
1973
  const target = e.target;
1900
- if (!target || ((_a = target.data) == null ? void 0 : _a.type) !== "hole-marker") return;
1901
- const changed = this.enforceConstraints();
1902
- if (!changed) {
1903
- this.syncHolesFromCanvas();
1974
+ if (!target || ((_a = target.data) == null ? void 0 : _a.type) !== "feature-marker") return;
1975
+ if ((_b = target.data) == null ? void 0 : _b.isGroup) {
1976
+ const groupObj = target;
1977
+ const indices = (_c = groupObj.data) == null ? void 0 : _c.indices;
1978
+ if (!indices) return;
1979
+ const groupCenter = new Point(groupObj.left, groupObj.top);
1980
+ const newFeatures = [...this.features];
1981
+ const { x, y } = this.currentGeometry;
1982
+ groupObj.getObjects().forEach((child, i) => {
1983
+ const originalIndex = indices[i];
1984
+ const feature = this.features[originalIndex];
1985
+ const geometry = this.getGeometryForFeature(
1986
+ this.currentGeometry,
1987
+ feature
1988
+ );
1989
+ const { width, height } = geometry;
1990
+ const layoutLeft = x - width / 2;
1991
+ const layoutTop = y - height / 2;
1992
+ const absX = groupCenter.x + (child.left || 0);
1993
+ const absY = groupCenter.y + (child.top || 0);
1994
+ const normalizedX = width > 0 ? (absX - layoutLeft) / width : 0.5;
1995
+ const normalizedY = height > 0 ? (absY - layoutTop) / height : 0.5;
1996
+ newFeatures[originalIndex] = {
1997
+ ...newFeatures[originalIndex],
1998
+ x: normalizedX,
1999
+ y: normalizedY
2000
+ };
2001
+ });
2002
+ this.features = newFeatures;
2003
+ const configService = (_d = this.context) == null ? void 0 : _d.services.get(
2004
+ "ConfigurationService"
2005
+ );
2006
+ if (configService) {
2007
+ this.isUpdatingConfig = true;
2008
+ try {
2009
+ configService.update("dieline.features", this.features);
2010
+ } finally {
2011
+ this.isUpdatingConfig = false;
2012
+ }
2013
+ }
2014
+ } else {
2015
+ this.syncFeatureFromCanvas(target);
1904
2016
  }
1905
2017
  };
1906
2018
  canvas.on("object:modified", this.handleModified);
1907
2019
  }
1908
- this.initializeHoles();
1909
- }
1910
- initializeHoles() {
1911
- if (!this.canvasService) return;
1912
- this.redraw();
1913
- this.syncHolesToDieline();
1914
2020
  }
1915
2021
  teardown() {
1916
2022
  if (!this.canvasService) return;
@@ -1932,357 +2038,259 @@ var HoleTool = class {
1932
2038
  }
1933
2039
  const objects = canvas.getObjects().filter((obj) => {
1934
2040
  var _a;
1935
- return ((_a = obj.data) == null ? void 0 : _a.type) === "hole-marker";
2041
+ return ((_a = obj.data) == null ? void 0 : _a.type) === "feature-marker";
1936
2042
  });
1937
2043
  objects.forEach((obj) => canvas.remove(obj));
1938
- if (this.context) {
1939
- const commandService = this.context.services.get("CommandService");
1940
- if (commandService) {
1941
- try {
1942
- commandService.executeCommand("setHoles", []);
1943
- } catch (e) {
1944
- }
1945
- }
1946
- }
1947
2044
  this.canvasService.requestRenderAll();
1948
2045
  }
1949
- syncHolesFromCanvas() {
1950
- if (!this.canvasService) return;
1951
- const objects = this.canvasService.canvas.getObjects().filter(
1952
- (obj) => {
1953
- var _a;
1954
- return ((_a = obj.data) == null ? void 0 : _a.type) === "hole-marker" || obj.name === "hole-marker";
1955
- }
1956
- );
1957
- if (objects.length === 0 && this.holes.length > 0) {
1958
- console.warn("HoleTool: No markers found on canvas to sync from");
1959
- return;
1960
- }
1961
- objects.sort(
1962
- (a, b) => {
1963
- var _a, _b, _c, _d;
1964
- return ((_b = (_a = a.data) == null ? void 0 : _a.index) != null ? _b : 0) - ((_d = (_c = b.data) == null ? void 0 : _c.index) != null ? _d : 0);
1965
- }
1966
- );
1967
- const newHoles = objects.map((obj, i) => {
1968
- var _a, _b, _c, _d;
1969
- const original = this.holes[i];
1970
- const newAbsX = obj.left;
1971
- const newAbsY = obj.top;
1972
- if (isNaN(newAbsX) || isNaN(newAbsY)) {
1973
- console.error("HoleTool: Invalid marker coordinates", {
1974
- newAbsX,
1975
- newAbsY
1976
- });
1977
- return original;
1978
- }
1979
- const scale = ((_a = this.currentGeometry) == null ? void 0 : _a.scale) || 1;
1980
- const unit = ((_b = this.currentGeometry) == null ? void 0 : _b.unit) || "mm";
1981
- const unitScale = Coordinate.convertUnit(1, "mm", unit);
1982
- if (original && original.anchor && this.currentGeometry) {
1983
- const { x, y, width, height } = this.currentGeometry;
1984
- let bx = x;
1985
- let by = y;
1986
- const left = x - width / 2;
1987
- const right = x + width / 2;
1988
- const top = y - height / 2;
1989
- const bottom = y + height / 2;
1990
- switch (original.anchor) {
1991
- case "top-left":
1992
- bx = left;
1993
- by = top;
1994
- break;
1995
- case "top-center":
1996
- bx = x;
1997
- by = top;
1998
- break;
1999
- case "top-right":
2000
- bx = right;
2001
- by = top;
2002
- break;
2003
- case "center-left":
2004
- bx = left;
2005
- by = y;
2006
- break;
2007
- case "center":
2008
- bx = x;
2009
- by = y;
2010
- break;
2011
- case "center-right":
2012
- bx = right;
2013
- by = y;
2014
- break;
2015
- case "bottom-left":
2016
- bx = left;
2017
- by = bottom;
2018
- break;
2019
- case "bottom-center":
2020
- bx = x;
2021
- by = bottom;
2022
- break;
2023
- case "bottom-right":
2024
- bx = right;
2025
- by = bottom;
2026
- break;
2027
- }
2028
- return {
2029
- ...original,
2030
- // Denormalize offset back to physical units (mm)
2031
- offsetX: (newAbsX - bx) / scale / unitScale,
2032
- offsetY: (newAbsY - by) / scale / unitScale,
2033
- // Clear direct coordinates if we use anchor
2034
- x: void 0,
2035
- y: void 0,
2036
- // Ensure other properties are preserved
2037
- innerRadius: original.innerRadius,
2038
- outerRadius: original.outerRadius,
2039
- shape: original.shape || "circle"
2040
- };
2041
- }
2042
- let normalizedX = 0.5;
2043
- let normalizedY = 0.5;
2044
- if (this.currentGeometry) {
2045
- const { x, y, width, height } = this.currentGeometry;
2046
- const left = x - width / 2;
2047
- const top = y - height / 2;
2048
- normalizedX = width > 0 ? (newAbsX - left) / width : 0.5;
2049
- normalizedY = height > 0 ? (newAbsY - top) / height : 0.5;
2050
- } else {
2051
- const { width, height } = this.canvasService.canvas;
2052
- normalizedX = Coordinate.toNormalized(newAbsX, width || 800);
2053
- normalizedY = Coordinate.toNormalized(newAbsY, height || 600);
2054
- }
2055
- return {
2056
- ...original,
2057
- x: normalizedX,
2058
- y: normalizedY,
2059
- // Clear offsets if we are using direct normalized coordinates
2060
- offsetX: void 0,
2061
- offsetY: void 0,
2062
- // Ensure other properties are preserved
2063
- innerRadius: (_c = original == null ? void 0 : original.innerRadius) != null ? _c : 15,
2064
- outerRadius: (_d = original == null ? void 0 : original.outerRadius) != null ? _d : 25,
2065
- shape: (original == null ? void 0 : original.shape) || "circle"
2066
- };
2046
+ constrainPosition(p, geometry, limit) {
2047
+ const nearest = getNearestPointOnDieline({ x: p.x, y: p.y }, {
2048
+ ...geometry,
2049
+ features: []
2067
2050
  });
2068
- this.holes = newHoles;
2069
- this.syncHolesToDieline();
2051
+ const dx = p.x - nearest.x;
2052
+ const dy = p.y - nearest.y;
2053
+ const dist = Math.sqrt(dx * dx + dy * dy);
2054
+ if (dist <= limit) {
2055
+ return { x: p.x, y: p.y };
2056
+ }
2057
+ const scale = limit / dist;
2058
+ return {
2059
+ x: nearest.x + dx * scale,
2060
+ y: nearest.y + dy * scale
2061
+ };
2070
2062
  }
2071
- syncHolesToDieline() {
2072
- if (!this.context || !this.canvasService) return;
2063
+ syncFeatureFromCanvas(target) {
2064
+ var _a;
2065
+ if (!this.currentGeometry || !this.context) return;
2066
+ const index = (_a = target.data) == null ? void 0 : _a.index;
2067
+ if (index === void 0 || index < 0 || index >= this.features.length)
2068
+ return;
2069
+ const feature = this.features[index];
2070
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
2071
+ const { width, height, x, y } = geometry;
2072
+ const left = x - width / 2;
2073
+ const top = y - height / 2;
2074
+ const normalizedX = width > 0 ? (target.left - left) / width : 0.5;
2075
+ const normalizedY = height > 0 ? (target.top - top) / height : 0.5;
2076
+ const updatedFeature = {
2077
+ ...feature,
2078
+ x: normalizedX,
2079
+ y: normalizedY
2080
+ // Could also update rotation if we allowed rotating markers
2081
+ };
2082
+ const newFeatures = [...this.features];
2083
+ newFeatures[index] = updatedFeature;
2084
+ this.features = newFeatures;
2073
2085
  const configService = this.context.services.get(
2074
2086
  "ConfigurationService"
2075
2087
  );
2076
2088
  if (configService) {
2077
2089
  this.isUpdatingConfig = true;
2078
2090
  try {
2079
- configService.update("dieline.holes", this.holes);
2091
+ configService.update("dieline.features", this.features);
2080
2092
  } finally {
2081
2093
  this.isUpdatingConfig = false;
2082
2094
  }
2083
2095
  }
2084
2096
  }
2085
2097
  redraw() {
2086
- if (!this.canvasService) return;
2098
+ if (!this.canvasService || !this.currentGeometry) return;
2087
2099
  const canvas = this.canvasService.canvas;
2088
- const { width, height } = canvas;
2100
+ const geometry = this.currentGeometry;
2089
2101
  const existing = canvas.getObjects().filter((obj) => {
2090
2102
  var _a;
2091
- return ((_a = obj.data) == null ? void 0 : _a.type) === "hole-marker";
2103
+ return ((_a = obj.data) == null ? void 0 : _a.type) === "feature-marker";
2092
2104
  });
2093
2105
  existing.forEach((obj) => canvas.remove(obj));
2094
- const holes = this.holes;
2095
- if (!holes || holes.length === 0) {
2106
+ if (!this.features || this.features.length === 0) {
2096
2107
  this.canvasService.requestRenderAll();
2097
2108
  return;
2098
2109
  }
2099
- const geometry = this.currentGeometry || {
2100
- x: (width || 800) / 2,
2101
- y: (height || 600) / 2,
2102
- width: width || 800,
2103
- height: height || 600,
2104
- scale: 1
2105
- // Default scale if no geometry loaded
2110
+ const scale = geometry.scale || 1;
2111
+ const finalScale = scale;
2112
+ const groups = {};
2113
+ const singles = [];
2114
+ this.features.forEach((f, i) => {
2115
+ if (f.groupId) {
2116
+ if (!groups[f.groupId]) groups[f.groupId] = [];
2117
+ groups[f.groupId].push({ feature: f, index: i });
2118
+ } else {
2119
+ singles.push({ feature: f, index: i });
2120
+ }
2121
+ });
2122
+ const createMarkerShape = (feature, pos) => {
2123
+ const featureScale = scale;
2124
+ const visualWidth = (feature.width || 10) * featureScale;
2125
+ const visualHeight = (feature.height || 10) * featureScale;
2126
+ const visualRadius = (feature.radius || 0) * featureScale;
2127
+ const color = feature.color || (feature.operation === "add" ? "#00FF00" : "#FF0000");
2128
+ const strokeDash = feature.strokeDash || (feature.operation === "subtract" ? [4, 4] : void 0);
2129
+ let shape;
2130
+ if (feature.shape === "rect") {
2131
+ shape = new Rect2({
2132
+ width: visualWidth,
2133
+ height: visualHeight,
2134
+ rx: visualRadius,
2135
+ ry: visualRadius,
2136
+ fill: "transparent",
2137
+ stroke: color,
2138
+ strokeWidth: 2,
2139
+ strokeDashArray: strokeDash,
2140
+ originX: "center",
2141
+ originY: "center",
2142
+ left: pos.x,
2143
+ top: pos.y
2144
+ });
2145
+ } else {
2146
+ shape = new Circle({
2147
+ radius: visualRadius || 5 * finalScale,
2148
+ fill: "transparent",
2149
+ stroke: color,
2150
+ strokeWidth: 2,
2151
+ strokeDashArray: strokeDash,
2152
+ originX: "center",
2153
+ originY: "center",
2154
+ left: pos.x,
2155
+ top: pos.y
2156
+ });
2157
+ }
2158
+ if (feature.rotation) {
2159
+ shape.rotate(feature.rotation);
2160
+ }
2161
+ return shape;
2106
2162
  };
2107
- holes.forEach((hole, index) => {
2108
- const scale = geometry.scale || 1;
2109
- const unit = geometry.unit || "mm";
2110
- const unitScale = Coordinate.convertUnit(1, "mm", unit);
2111
- const visualInnerRadius = hole.innerRadius * unitScale * scale;
2112
- const visualOuterRadius = hole.outerRadius * unitScale * scale;
2113
- const pos = resolveHolePosition(
2114
- {
2115
- ...hole,
2116
- offsetX: (hole.offsetX || 0) * unitScale * scale,
2117
- offsetY: (hole.offsetY || 0) * unitScale * scale
2118
- },
2119
- geometry,
2120
- { width: geometry.width, height: geometry.height }
2121
- // Use geometry dims instead of canvas
2163
+ singles.forEach(({ feature, index }) => {
2164
+ const geometry2 = this.getGeometryForFeature(
2165
+ this.currentGeometry,
2166
+ feature
2122
2167
  );
2123
- const isSquare = hole.shape === "square";
2124
- const innerMarker = isSquare ? new Rect2({
2125
- width: visualInnerRadius * 2,
2126
- height: visualInnerRadius * 2,
2127
- fill: "transparent",
2128
- stroke: "red",
2129
- strokeWidth: 2,
2130
- originX: "center",
2131
- originY: "center"
2132
- }) : new Circle({
2133
- radius: visualInnerRadius,
2134
- fill: "transparent",
2135
- stroke: "red",
2136
- strokeWidth: 2,
2137
- originX: "center",
2138
- originY: "center"
2168
+ const pos = resolveFeaturePosition(feature, geometry2);
2169
+ const marker = createMarkerShape(feature, pos);
2170
+ marker.set({
2171
+ selectable: true,
2172
+ hasControls: false,
2173
+ hasBorders: false,
2174
+ hoverCursor: "move",
2175
+ lockRotation: true,
2176
+ lockScalingX: true,
2177
+ lockScalingY: true,
2178
+ data: { type: "feature-marker", index, isGroup: false }
2139
2179
  });
2140
- const outerMarker = isSquare ? new Rect2({
2141
- width: visualOuterRadius * 2,
2142
- height: visualOuterRadius * 2,
2143
- fill: "transparent",
2144
- stroke: "#666",
2145
- strokeWidth: 1,
2146
- strokeDashArray: [5, 5],
2147
- originX: "center",
2148
- originY: "center"
2149
- }) : new Circle({
2150
- radius: visualOuterRadius,
2151
- fill: "transparent",
2152
- stroke: "#666",
2153
- strokeWidth: 1,
2154
- strokeDashArray: [5, 5],
2155
- originX: "center",
2156
- originY: "center"
2180
+ marker.set("opacity", 0);
2181
+ marker.on("mouseover", () => {
2182
+ marker.set("opacity", 1);
2183
+ canvas.requestRenderAll();
2157
2184
  });
2158
- const holeGroup = new Group([outerMarker, innerMarker], {
2159
- left: pos.x,
2160
- top: pos.y,
2161
- originX: "center",
2162
- originY: "center",
2185
+ marker.on("mouseout", () => {
2186
+ if (canvas.getActiveObject() !== marker) {
2187
+ marker.set("opacity", 0);
2188
+ canvas.requestRenderAll();
2189
+ }
2190
+ });
2191
+ marker.on("selected", () => {
2192
+ marker.set("opacity", 1);
2193
+ canvas.requestRenderAll();
2194
+ });
2195
+ marker.on("deselected", () => {
2196
+ marker.set("opacity", 0);
2197
+ canvas.requestRenderAll();
2198
+ });
2199
+ canvas.add(marker);
2200
+ canvas.bringObjectToFront(marker);
2201
+ });
2202
+ Object.keys(groups).forEach((groupId) => {
2203
+ const members = groups[groupId];
2204
+ if (members.length === 0) return;
2205
+ const shapes = members.map(({ feature }) => {
2206
+ const geometry2 = this.getGeometryForFeature(
2207
+ this.currentGeometry,
2208
+ feature
2209
+ );
2210
+ const pos = resolveFeaturePosition(feature, geometry2);
2211
+ return createMarkerShape(feature, pos);
2212
+ });
2213
+ const groupObj = new Group(shapes, {
2163
2214
  selectable: true,
2164
2215
  hasControls: false,
2165
- // Don't allow resizing/rotating
2166
2216
  hasBorders: false,
2167
- subTargetCheck: false,
2168
- opacity: 0,
2169
- // Default hidden
2170
2217
  hoverCursor: "move",
2171
- data: { type: "hole-marker", index }
2218
+ lockRotation: true,
2219
+ lockScalingX: true,
2220
+ lockScalingY: true,
2221
+ subTargetCheck: true,
2222
+ // Allow events to pass through if needed, but we treat as one
2223
+ interactive: false,
2224
+ // Children not interactive
2225
+ // @ts-ignore
2226
+ data: {
2227
+ type: "feature-marker",
2228
+ isGroup: true,
2229
+ groupId,
2230
+ indices: members.map((m) => m.index)
2231
+ }
2172
2232
  });
2173
- holeGroup.name = "hole-marker";
2174
- holeGroup.on("mouseover", () => {
2175
- holeGroup.set("opacity", 1);
2233
+ groupObj.set("opacity", 0);
2234
+ groupObj.on("mouseover", () => {
2235
+ groupObj.set("opacity", 1);
2176
2236
  canvas.requestRenderAll();
2177
2237
  });
2178
- holeGroup.on("mouseout", () => {
2179
- if (canvas.getActiveObject() !== holeGroup) {
2180
- holeGroup.set("opacity", 0);
2238
+ groupObj.on("mouseout", () => {
2239
+ if (canvas.getActiveObject() !== groupObj) {
2240
+ groupObj.set("opacity", 0);
2181
2241
  canvas.requestRenderAll();
2182
2242
  }
2183
2243
  });
2184
- holeGroup.on("selected", () => {
2185
- holeGroup.set("opacity", 1);
2244
+ groupObj.on("selected", () => {
2245
+ groupObj.set("opacity", 1);
2186
2246
  canvas.requestRenderAll();
2187
2247
  });
2188
- holeGroup.on("deselected", () => {
2189
- holeGroup.set("opacity", 0);
2248
+ groupObj.on("deselected", () => {
2249
+ groupObj.set("opacity", 0);
2190
2250
  canvas.requestRenderAll();
2191
2251
  });
2192
- canvas.add(holeGroup);
2193
- canvas.bringObjectToFront(holeGroup);
2252
+ canvas.add(groupObj);
2253
+ canvas.bringObjectToFront(groupObj);
2194
2254
  });
2195
- const markers = canvas.getObjects().filter((o) => {
2196
- var _a;
2197
- return ((_a = o.data) == null ? void 0 : _a.type) === "hole-marker";
2198
- });
2199
- markers.forEach((m) => canvas.bringObjectToFront(m));
2200
2255
  this.canvasService.requestRenderAll();
2201
2256
  }
2202
2257
  enforceConstraints() {
2203
- const geometry = this.currentGeometry;
2204
- if (!geometry || !this.canvasService) {
2205
- return false;
2206
- }
2207
- const effectiveOffset = this.constraintTarget === "original" ? 0 : geometry.offset;
2208
- const constraintGeometry = {
2209
- ...geometry,
2210
- width: Math.max(0, geometry.width + effectiveOffset * 2),
2211
- height: Math.max(0, geometry.height + effectiveOffset * 2),
2212
- radius: Math.max(0, geometry.radius + effectiveOffset)
2213
- };
2214
- const objects = this.canvasService.canvas.getObjects().filter((obj) => {
2258
+ if (!this.canvasService || !this.currentGeometry) return;
2259
+ const canvas = this.canvasService.canvas;
2260
+ const markers = canvas.getObjects().filter((obj) => {
2215
2261
  var _a;
2216
- return ((_a = obj.data) == null ? void 0 : _a.type) === "hole-marker";
2262
+ return ((_a = obj.data) == null ? void 0 : _a.type) === "feature-marker";
2217
2263
  });
2218
- let changed = false;
2219
- objects.sort(
2220
- (a, b) => {
2221
- var _a, _b, _c, _d;
2222
- return ((_b = (_a = a.data) == null ? void 0 : _a.index) != null ? _b : 0) - ((_d = (_c = b.data) == null ? void 0 : _c.index) != null ? _d : 0);
2264
+ markers.forEach((marker) => {
2265
+ var _a, _b, _c;
2266
+ let feature;
2267
+ if ((_a = marker.data) == null ? void 0 : _a.isGroup) {
2268
+ const indices = (_b = marker.data) == null ? void 0 : _b.indices;
2269
+ if (indices && indices.length > 0) {
2270
+ feature = this.features[indices[0]];
2271
+ }
2272
+ } else {
2273
+ const index = (_c = marker.data) == null ? void 0 : _c.index;
2274
+ if (index !== void 0) {
2275
+ feature = this.features[index];
2276
+ }
2223
2277
  }
2224
- );
2225
- const newHoles = [];
2226
- objects.forEach((obj, i) => {
2227
- var _a, _b;
2228
- const currentPos = new Point(obj.left, obj.top);
2229
- const holeData = this.holes[i];
2230
- const scale = geometry.scale || 1;
2231
- const unit = geometry.unit || "mm";
2232
- const unitScale = Coordinate.convertUnit(1, "mm", unit);
2233
- const innerR = ((_a = holeData == null ? void 0 : holeData.innerRadius) != null ? _a : 15) * unitScale * scale;
2234
- const outerR = ((_b = holeData == null ? void 0 : holeData.outerRadius) != null ? _b : 25) * unitScale * scale;
2235
- const newPos = this.calculateConstrainedPosition(
2236
- currentPos,
2237
- constraintGeometry,
2238
- innerR,
2239
- outerR
2278
+ const geometry = this.getGeometryForFeature(
2279
+ this.currentGeometry,
2280
+ feature
2240
2281
  );
2241
- if (currentPos.distanceFrom(newPos) > 0.1) {
2242
- obj.set({
2243
- left: newPos.x,
2244
- top: newPos.y
2245
- });
2246
- obj.setCoords();
2247
- changed = true;
2248
- }
2282
+ const markerStrokeWidth = (marker.strokeWidth || 2) * (marker.scaleX || 1);
2283
+ const minDim = Math.min(marker.getScaledWidth(), marker.getScaledHeight());
2284
+ const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
2285
+ const snapped = this.constrainPosition(
2286
+ new Point(marker.left, marker.top),
2287
+ geometry,
2288
+ limit
2289
+ );
2290
+ marker.set({ left: snapped.x, top: snapped.y });
2291
+ marker.setCoords();
2249
2292
  });
2250
- if (changed) {
2251
- this.syncHolesFromCanvas();
2252
- return true;
2253
- }
2254
- return false;
2255
- }
2256
- calculateConstrainedPosition(p, g, innerRadius, outerRadius) {
2257
- const options = {
2258
- ...g,
2259
- holes: []
2260
- // We don't need holes for boundary calculation
2261
- };
2262
- const nearest = getNearestPointOnDieline(
2263
- { x: p.x, y: p.y },
2264
- options
2265
- );
2266
- const nearestP = new Point(nearest.x, nearest.y);
2267
- const dist = p.distanceFrom(nearestP);
2268
- const v = p.subtract(nearestP);
2269
- const center = new Point(g.x, g.y);
2270
- const distToCenter = p.distanceFrom(center);
2271
- const nearestDistToCenter = nearestP.distanceFrom(center);
2272
- let signedDist = dist;
2273
- if (distToCenter < nearestDistToCenter) {
2274
- signedDist = -dist;
2275
- }
2276
- let clampedDist = signedDist;
2277
- if (signedDist > 0) {
2278
- clampedDist = Math.min(signedDist, innerRadius);
2279
- } else {
2280
- clampedDist = Math.max(signedDist, -outerRadius);
2281
- }
2282
- if (dist < 1e-3) return nearestP;
2283
- const scale = Math.abs(clampedDist) / (dist || 1);
2284
- const offset = v.scalarMultiply(scale);
2285
- return nearestP.add(offset);
2293
+ canvas.requestRenderAll();
2286
2294
  }
2287
2295
  };
2288
2296
 
@@ -3495,8 +3503,8 @@ export {
3495
3503
  BackgroundTool,
3496
3504
  CanvasService,
3497
3505
  DielineTool,
3506
+ FeatureTool,
3498
3507
  FilmTool,
3499
- HoleTool,
3500
3508
  ImageTool,
3501
3509
  MirrorTool,
3502
3510
  RulerTool,