@pooder/kit 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dieline.ts CHANGED
@@ -1,871 +1,955 @@
1
- import {
2
- Extension,
3
- ExtensionContext,
4
- ContributionPointIds,
5
- CommandContribution,
6
- ConfigurationContribution,
7
- } from "@pooder/core";
8
- import { Path, Pattern } from "fabric";
9
- import CanvasService from "./CanvasService";
10
- import { ImageTracer } from "./tracer";
11
- import { Coordinate, Unit } from "./coordinate";
12
- import {
13
- generateDielinePath,
14
- generateMaskPath,
15
- generateBleedZonePath,
16
- getPathBounds,
17
- DielineFeature,
18
- } from "./geometry";
19
- import { ConstraintRegistry } from "./constraints";
20
-
21
- export interface DielineGeometry {
22
- shape: "rect" | "circle" | "ellipse" | "custom";
23
- unit: Unit;
24
- x: number;
25
- y: number;
26
- width: number;
27
- height: number;
28
- radius: number;
29
- offset: number;
30
- borderLength?: number;
31
- scale?: number;
32
- strokeWidth?: number;
33
- pathData?: string;
34
- }
35
-
36
- export interface LineStyle {
37
- width: number;
38
- color: string;
39
- dashLength: number;
40
- style: "solid" | "dashed" | "hidden";
41
- }
42
-
43
- export interface DielineState {
44
- unit: Unit;
45
- shape: "rect" | "circle" | "ellipse" | "custom";
46
- width: number;
47
- height: number;
48
- radius: number;
49
- offset: number;
50
- padding: number | string;
51
- mainLine: LineStyle;
52
- offsetLine: LineStyle;
53
- insideColor: string;
54
- outsideColor: string;
55
- showBleedLines: boolean;
56
- features: DielineFeature[];
57
- pathData?: string;
58
- }
59
-
60
- export class DielineTool implements Extension {
61
- id = "pooder.kit.dieline";
62
- public metadata = {
63
- name: "DielineTool",
64
- };
65
-
66
- private state: DielineState = {
67
- unit: "mm",
68
- shape: "rect",
69
- width: 500,
70
- height: 500,
71
- radius: 0,
72
- offset: 0,
73
- padding: 140,
74
- mainLine: {
75
- width: 2.7,
76
- color: "#FF0000",
77
- dashLength: 5,
78
- style: "solid",
79
- },
80
- offsetLine: {
81
- width: 2.7,
82
- color: "#FF0000",
83
- dashLength: 5,
84
- style: "solid",
85
- },
86
- insideColor: "rgba(0,0,0,0)",
87
- outsideColor: "#ffffff",
88
- showBleedLines: true,
89
- features: [],
90
- };
91
-
92
- private canvasService?: CanvasService;
93
- private context?: ExtensionContext;
94
-
95
- constructor(options?: Partial<DielineState>) {
96
- if (options) {
97
- // Deep merge for styles to avoid overwriting defaults with partial objects
98
- if (options.mainLine) {
99
- Object.assign(this.state.mainLine, options.mainLine);
100
- delete options.mainLine;
101
- }
102
- if (options.offsetLine) {
103
- Object.assign(this.state.offsetLine, options.offsetLine);
104
- delete options.offsetLine;
105
- }
106
- Object.assign(this.state, options);
107
- }
108
- }
109
-
110
- activate(context: ExtensionContext) {
111
- this.context = context;
112
- this.canvasService = context.services.get<CanvasService>("CanvasService");
113
- if (!this.canvasService) {
114
- console.warn("CanvasService not found for DielineTool");
115
- return;
116
- }
117
-
118
- const configService = context.services.get<any>("ConfigurationService");
119
- if (configService) {
120
- // Load initial config
121
- const s = this.state;
122
- s.unit = configService.get("dieline.unit", s.unit);
123
- s.shape = configService.get("dieline.shape", s.shape);
124
- s.width = configService.get("dieline.width", s.width);
125
- s.height = configService.get("dieline.height", s.height);
126
- s.radius = configService.get("dieline.radius", s.radius);
127
- s.padding = configService.get("dieline.padding", s.padding);
128
- s.offset = configService.get("dieline.offset", s.offset);
129
-
130
- // Main Line
131
- s.mainLine.width = configService.get("dieline.strokeWidth", s.mainLine.width);
132
- s.mainLine.color = configService.get("dieline.strokeColor", s.mainLine.color);
133
- s.mainLine.dashLength = configService.get("dieline.dashLength", s.mainLine.dashLength);
134
- s.mainLine.style = configService.get("dieline.style", s.mainLine.style);
135
-
136
- // Offset Line
137
- s.offsetLine.width = configService.get("dieline.offsetStrokeWidth", s.offsetLine.width);
138
- s.offsetLine.color = configService.get("dieline.offsetStrokeColor", s.offsetLine.color);
139
- s.offsetLine.dashLength = configService.get("dieline.offsetDashLength", s.offsetLine.dashLength);
140
- s.offsetLine.style = configService.get("dieline.offsetStyle", s.offsetLine.style);
141
-
142
- s.insideColor = configService.get("dieline.insideColor", s.insideColor);
143
- s.outsideColor = configService.get("dieline.outsideColor", s.outsideColor);
144
- s.showBleedLines = configService.get("dieline.showBleedLines", s.showBleedLines);
145
- s.features = configService.get("dieline.features", s.features);
146
- s.pathData = configService.get("dieline.pathData", s.pathData);
147
-
148
- // Listen for changes
149
- configService.onAnyChange((e: { key: string; value: any }) => {
150
- if (e.key.startsWith("dieline.")) {
151
- console.log(`[DielineTool] Config change detected: ${e.key} -> ${e.value}`);
152
-
153
- switch (e.key) {
154
- case "dieline.unit": s.unit = e.value; break;
155
- case "dieline.shape": s.shape = e.value; break;
156
- case "dieline.width": s.width = e.value; break;
157
- case "dieline.height": s.height = e.value; break;
158
- case "dieline.radius": s.radius = e.value; break;
159
- case "dieline.padding": s.padding = e.value; break;
160
- case "dieline.offset": s.offset = e.value; break;
161
-
162
- case "dieline.strokeWidth": s.mainLine.width = e.value; break;
163
- case "dieline.strokeColor": s.mainLine.color = e.value; break;
164
- case "dieline.dashLength": s.mainLine.dashLength = e.value; break;
165
- case "dieline.style": s.mainLine.style = e.value; break;
166
-
167
- case "dieline.offsetStrokeWidth": s.offsetLine.width = e.value; break;
168
- case "dieline.offsetStrokeColor": s.offsetLine.color = e.value; break;
169
- case "dieline.offsetDashLength": s.offsetLine.dashLength = e.value; break;
170
- case "dieline.offsetStyle": s.offsetLine.style = e.value; break;
171
-
172
- case "dieline.insideColor": s.insideColor = e.value; break;
173
- case "dieline.outsideColor": s.outsideColor = e.value; break;
174
- case "dieline.showBleedLines": s.showBleedLines = e.value; break;
175
- case "dieline.features": s.features = e.value; break;
176
- case "dieline.pathData": s.pathData = e.value; break;
177
- }
178
- this.updateDieline();
179
- }
180
- });
181
- }
182
-
183
- this.createLayer();
184
- this.updateDieline();
185
- }
186
-
187
- deactivate(context: ExtensionContext) {
188
- this.destroyLayer();
189
- this.canvasService = undefined;
190
- this.context = undefined;
191
- }
192
-
193
- contribute() {
194
- const s = this.state;
195
- return {
196
- [ContributionPointIds.CONFIGURATIONS]: [
197
- {
198
- id: "dieline.unit",
199
- type: "select",
200
- label: "Unit",
201
- options: ["px", "mm", "cm", "in"],
202
- default: s.unit,
203
- },
204
- {
205
- id: "dieline.shape",
206
- type: "select",
207
- label: "Shape",
208
- options: ["rect", "circle", "ellipse", "custom"],
209
- default: s.shape,
210
- },
211
- {
212
- id: "dieline.width",
213
- type: "number",
214
- label: "Width",
215
- min: 10,
216
- max: 2000,
217
- default: s.width,
218
- },
219
- {
220
- id: "dieline.height",
221
- type: "number",
222
- label: "Height",
223
- min: 10,
224
- max: 2000,
225
- default: s.height,
226
- },
227
- {
228
- id: "dieline.radius",
229
- type: "number",
230
- label: "Corner Radius",
231
- min: 0,
232
- max: 500,
233
- default: s.radius,
234
- },
235
- {
236
- id: "dieline.padding",
237
- type: "select",
238
- label: "View Padding",
239
- options: [0, 10, 20, 40, 60, 100, "2%", "5%", "10%", "15%", "20%"],
240
- default: s.padding,
241
- },
242
- {
243
- id: "dieline.offset",
244
- type: "number",
245
- label: "Bleed Offset",
246
- min: -100,
247
- max: 100,
248
- default: s.offset,
249
- },
250
- {
251
- id: "dieline.showBleedLines",
252
- type: "boolean",
253
- label: "Show Bleed Lines",
254
- default: s.showBleedLines,
255
- },
256
- {
257
- id: "dieline.strokeWidth",
258
- type: "number",
259
- label: "Line Width",
260
- min: 0.1,
261
- max: 10,
262
- step: 0.1,
263
- default: s.mainLine.width,
264
- },
265
- {
266
- id: "dieline.strokeColor",
267
- type: "color",
268
- label: "Line Color",
269
- default: s.mainLine.color,
270
- },
271
- {
272
- id: "dieline.dashLength",
273
- type: "number",
274
- label: "Dash Length",
275
- min: 1,
276
- max: 50,
277
- default: s.mainLine.dashLength,
278
- },
279
- {
280
- id: "dieline.style",
281
- type: "select",
282
- label: "Line Style",
283
- options: ["solid", "dashed", "hidden"],
284
- default: s.mainLine.style,
285
- },
286
- {
287
- id: "dieline.offsetStrokeWidth",
288
- type: "number",
289
- label: "Offset Line Width",
290
- min: 0.1,
291
- max: 10,
292
- step: 0.1,
293
- default: s.offsetLine.width,
294
- },
295
- {
296
- id: "dieline.offsetStrokeColor",
297
- type: "color",
298
- label: "Offset Line Color",
299
- default: s.offsetLine.color,
300
- },
301
- {
302
- id: "dieline.offsetDashLength",
303
- type: "number",
304
- label: "Offset Dash Length",
305
- min: 1,
306
- max: 50,
307
- default: s.offsetLine.dashLength,
308
- },
309
- {
310
- id: "dieline.offsetStyle",
311
- type: "select",
312
- label: "Offset Line Style",
313
- options: ["solid", "dashed", "hidden"],
314
- default: s.offsetLine.style,
315
- },
316
- {
317
- id: "dieline.insideColor",
318
- type: "color",
319
- label: "Inside Color",
320
- default: s.insideColor,
321
- },
322
- {
323
- id: "dieline.outsideColor",
324
- type: "color",
325
- label: "Outside Color",
326
- default: s.outsideColor,
327
- },
328
- {
329
- id: "dieline.features",
330
- type: "json",
331
- label: "Edge Features",
332
- default: s.features,
333
- },
334
- ] as ConfigurationContribution[],
335
- [ContributionPointIds.COMMANDS]: [
336
- {
337
- command: "updateFeaturePosition",
338
- title: "Update Feature Position",
339
- handler: (groupId: string, x: number, y: number) => {
340
- const configService = this.context?.services.get<any>(
341
- "ConfigurationService",
342
- );
343
- if (!configService) return;
344
-
345
- const features = configService.get("dieline.features") || [];
346
- const dielineWidth = configService.get("dieline.width") || 500;
347
- const dielineHeight = configService.get("dieline.height") || 500;
348
-
349
- let changed = false;
350
- const newFeatures = features.map((f: any) => {
351
- if (f.groupId === groupId) {
352
- const constrained = ConstraintRegistry.apply(x, y, f, {
353
- dielineWidth,
354
- dielineHeight,
355
- });
356
-
357
- if (f.x !== constrained.x || f.y !== constrained.y) {
358
- changed = true;
359
- return { ...f, x: constrained.x, y: constrained.y };
360
- }
361
- }
362
- return f;
363
- });
364
-
365
- if (changed) {
366
- configService.update("dieline.features", newFeatures);
367
- }
368
- },
369
- },
370
- {
371
- command: "getGeometry",
372
- title: "Get Geometry",
373
- handler: () => {
374
- return this.getGeometry();
375
- },
376
- },
377
- {
378
- command: "exportCutImage",
379
- title: "Export Cut Image",
380
- handler: () => {
381
- return this.exportCutImage();
382
- },
383
- },
384
- {
385
- command: "detectEdge",
386
- title: "Detect Edge from Image",
387
- handler: async (imageUrl: string, options?: any) => {
388
- try {
389
- const pathData = await ImageTracer.trace(imageUrl, options);
390
- const bounds = getPathBounds(pathData);
391
-
392
- const currentMax = Math.max(s.width, s.height);
393
- const scale = currentMax / Math.max(bounds.width, bounds.height);
394
-
395
- const newWidth = bounds.width * scale;
396
- const newHeight = bounds.height * scale;
397
-
398
- return {
399
- pathData,
400
- width: newWidth,
401
- height: newHeight,
402
- };
403
- } catch (e) {
404
- console.error("Edge detection failed", e);
405
- throw e;
406
- }
407
- },
408
- },
409
- ] as CommandContribution[],
410
- };
411
- }
412
-
413
- private getLayer() {
414
- return this.canvasService?.getLayer("dieline-overlay");
415
- }
416
-
417
- private createLayer() {
418
- if (!this.canvasService) return;
419
- const width = this.canvasService.canvas.width || 800;
420
- const height = this.canvasService.canvas.height || 600;
421
-
422
- const layer = this.canvasService.createLayer("dieline-overlay", {
423
- width,
424
- height,
425
- selectable: false,
426
- evented: false,
427
- });
428
-
429
- this.canvasService.canvas.bringObjectToFront(layer);
430
-
431
- // Ensure above user layer
432
- const userLayer = this.canvasService.getLayer("user");
433
- if (userLayer) {
434
- const userIndex = this.canvasService.canvas
435
- .getObjects()
436
- .indexOf(userLayer);
437
- this.canvasService.canvas.moveObjectTo(layer, userIndex + 1);
438
- }
439
- }
440
-
441
- private destroyLayer() {
442
- if (!this.canvasService) return;
443
- const layer = this.getLayer();
444
- if (layer) {
445
- this.canvasService.canvas.remove(layer);
446
- }
447
- }
448
-
449
- private createHatchPattern(color: string = "rgba(0, 0, 0, 0.3)") {
450
- if (typeof document === "undefined") {
451
- return undefined;
452
- }
453
- const size = 20;
454
- const canvas = document.createElement("canvas");
455
- canvas.width = size;
456
- canvas.height = size;
457
- const ctx = canvas.getContext("2d");
458
- if (ctx) {
459
- // Transparent background
460
- ctx.clearRect(0, 0, size, size);
461
-
462
- // Draw diagonal /
463
- ctx.strokeStyle = color;
464
- ctx.lineWidth = 1;
465
- ctx.beginPath();
466
- ctx.moveTo(0, size);
467
- ctx.lineTo(size, 0);
468
- ctx.stroke();
469
- }
470
- // @ts-ignore
471
- return new Pattern({ source: canvas, repetition: "repeat" });
472
- }
473
-
474
- private resolvePadding(
475
- containerWidth: number,
476
- containerHeight: number,
477
- ): number {
478
- if (typeof this.state.padding === "number") {
479
- return this.state.padding;
480
- }
481
- if (typeof this.state.padding === "string") {
482
- if (this.state.padding.endsWith("%")) {
483
- const percent = parseFloat(this.state.padding) / 100;
484
- return Math.min(containerWidth, containerHeight) * percent;
485
- }
486
- return parseFloat(this.state.padding) || 0;
487
- }
488
- return 0;
489
- }
490
-
491
- public updateDieline(emitEvent: boolean = true) {
492
- if (!this.canvasService) return;
493
- const layer = this.getLayer();
494
- if (!layer) return;
495
-
496
- const {
497
- unit,
498
- shape,
499
- radius,
500
- offset,
501
- mainLine,
502
- offsetLine,
503
- insideColor,
504
- outsideColor,
505
- showBleedLines,
506
- features,
507
- } = this.state;
508
- let { width, height } = this.state;
509
-
510
- const canvasW = this.canvasService.canvas.width || 800;
511
- const canvasH = this.canvasService.canvas.height || 600;
512
-
513
- // Calculate Layout based on Physical Dimensions and Canvas Size
514
- // Add padding to avoid edge hugging
515
- const paddingPx = this.resolvePadding(canvasW, canvasH);
516
- const layout = Coordinate.calculateLayout(
517
- { width: canvasW, height: canvasH },
518
- { width, height },
519
- paddingPx,
520
- );
521
-
522
- const scale = layout.scale;
523
- const cx = layout.offsetX + layout.width / 2;
524
- const cy = layout.offsetY + layout.height / 2;
525
-
526
- // Scaled dimensions for rendering (Pixels)
527
- const visualWidth = layout.width;
528
- const visualHeight = layout.height;
529
- const visualRadius = radius * scale;
530
- const visualOffset = offset * scale;
531
-
532
- // Clear existing objects
533
- layer.remove(...layer.getObjects());
534
-
535
- // Scale Features for Geometry Generation
536
- const absoluteFeatures = (features || []).map((f) => {
537
- // Scale current unit -> pixels (features share the same unit as the dieline)
538
- const featureScale = scale;
539
-
540
- return {
541
- ...f,
542
- x: f.x,
543
- y: f.y,
544
- width: (f.width || 0) * featureScale,
545
- height: (f.height || 0) * featureScale,
546
- radius: (f.radius || 0) * featureScale,
547
- };
548
- });
549
-
550
- // Split features into Cut (Physical) and Visual (All)
551
- const cutFeatures = absoluteFeatures.filter((f) => !f.skipCut);
552
-
553
- // 1. Draw Mask (Outside)
554
- const cutW = Math.max(0, visualWidth + visualOffset * 2);
555
- const cutH = Math.max(0, visualHeight + visualOffset * 2);
556
- const cutR =
557
- visualRadius === 0 ? 0 : Math.max(0, visualRadius + visualOffset);
558
-
559
- // Use Paper.js to generate the complex mask path
560
- const maskPathData = generateMaskPath({
561
- canvasWidth: canvasW,
562
- canvasHeight: canvasH,
563
- shape,
564
- width: cutW,
565
- height: cutH,
566
- radius: cutR,
567
- x: cx,
568
- y: cy,
569
- features: cutFeatures,
570
- pathData: this.state.pathData,
571
- });
572
-
573
- const mask = new Path(maskPathData, {
574
- fill: outsideColor,
575
- stroke: null,
576
- selectable: false,
577
- evented: false,
578
- originX: "left" as const,
579
- originY: "top" as const,
580
- left: 0,
581
- top: 0,
582
- });
583
- layer.add(mask);
584
-
585
- // 2. Draw Inside Fill (Dieline Shape itself, merged with features if needed)
586
- if (
587
- insideColor &&
588
- insideColor !== "transparent" &&
589
- insideColor !== "rgba(0,0,0,0)"
590
- ) {
591
- // Generate path for the product shape (Paper) = Dieline +/- Features
592
- const productPathData = generateDielinePath({
593
- shape,
594
- width: cutW,
595
- height: cutH,
596
- radius: cutR,
597
- x: cx,
598
- y: cy,
599
- features: cutFeatures, // Use same features as mask for consistency
600
- pathData: this.state.pathData,
601
- canvasWidth: canvasW,
602
- canvasHeight: canvasH,
603
- });
604
-
605
- const insideObj = new Path(productPathData, {
606
- fill: insideColor,
607
- stroke: null,
608
- selectable: false,
609
- evented: false,
610
- originX: "left", // paper.js paths are absolute
611
- originY: "top",
612
- });
613
- layer.add(insideObj);
614
- }
615
-
616
- // 3. Draw Bleed Zone (Hatch Fill) and Offset Border
617
- if (offset !== 0) {
618
- const bleedPathData = generateBleedZonePath(
619
- {
620
- shape,
621
- width: visualWidth,
622
- height: visualHeight,
623
- radius: visualRadius,
624
- x: cx,
625
- y: cy,
626
- features: cutFeatures,
627
- pathData: this.state.pathData,
628
- canvasWidth: canvasW,
629
- canvasHeight: canvasH,
630
- },
631
- {
632
- shape,
633
- width: cutW,
634
- height: cutH,
635
- radius: cutR,
636
- x: cx,
637
- y: cy,
638
- features: cutFeatures,
639
- pathData: this.state.pathData,
640
- canvasWidth: canvasW,
641
- canvasHeight: canvasH,
642
- },
643
- visualOffset,
644
- );
645
-
646
- // Use solid red for hatch lines to match dieline, background is transparent
647
- if (showBleedLines !== false) {
648
- const pattern = this.createHatchPattern(mainLine.color);
649
- if (pattern) {
650
- const bleedObj = new Path(bleedPathData, {
651
- fill: pattern,
652
- stroke: null,
653
- selectable: false,
654
- evented: false,
655
- objectCaching: false,
656
- originX: "left",
657
- originY: "top",
658
- });
659
- layer.add(bleedObj);
660
- }
661
- }
662
-
663
- // Offset Dieline Border
664
- const offsetPathData = generateDielinePath({
665
- shape,
666
- width: cutW,
667
- height: cutH,
668
- radius: cutR,
669
- x: cx,
670
- y: cy,
671
- features: cutFeatures,
672
- pathData: this.state.pathData,
673
- canvasWidth: canvasW,
674
- canvasHeight: canvasH,
675
- });
676
-
677
- const offsetBorderObj = new Path(offsetPathData, {
678
- fill: null,
679
- stroke: offsetLine.style === "hidden" ? null : offsetLine.color,
680
- strokeWidth: offsetLine.width,
681
- strokeDashArray:
682
- offsetLine.style === "dashed"
683
- ? [offsetLine.dashLength, offsetLine.dashLength]
684
- : undefined,
685
- selectable: false,
686
- evented: false,
687
- originX: "left",
688
- originY: "top",
689
- });
690
- layer.add(offsetBorderObj);
691
- }
692
-
693
- // 4. Draw Dieline (Visual Border)
694
- const borderPathData = generateDielinePath({
695
- shape,
696
- width: visualWidth,
697
- height: visualHeight,
698
- radius: visualRadius,
699
- x: cx,
700
- y: cy,
701
- features: absoluteFeatures,
702
- pathData: this.state.pathData,
703
- canvasWidth: canvasW,
704
- canvasHeight: canvasH,
705
- });
706
-
707
- const borderObj = new Path(borderPathData, {
708
- fill: "transparent",
709
- stroke: mainLine.style === "hidden" ? null : mainLine.color,
710
- strokeWidth: mainLine.width,
711
- strokeDashArray:
712
- mainLine.style === "dashed" ? [mainLine.dashLength, mainLine.dashLength] : undefined,
713
- selectable: false,
714
- evented: false,
715
- originX: "left",
716
- originY: "top",
717
- });
718
-
719
- layer.add(borderObj);
720
-
721
- // Enforce z-index: Dieline > User
722
- const userLayer = this.canvasService.getLayer("user");
723
- if (layer && userLayer) {
724
- const layerIndex = this.canvasService.canvas.getObjects().indexOf(layer);
725
- const userIndex = this.canvasService.canvas
726
- .getObjects()
727
- .indexOf(userLayer);
728
- if (layerIndex < userIndex) {
729
- this.canvasService.canvas.moveObjectTo(layer, userIndex + 1);
730
- }
731
- } else {
732
- // If no user layer, just bring to front (safe default)
733
- this.canvasService.canvas.bringObjectToFront(layer);
734
- }
735
-
736
- // Ensure Ruler is above Dieline if it exists
737
- const rulerLayer = this.canvasService.getLayer("ruler-overlay");
738
- if (rulerLayer) {
739
- this.canvasService.canvas.bringObjectToFront(rulerLayer);
740
- }
741
-
742
- layer.dirty = true;
743
- this.canvasService.requestRenderAll();
744
-
745
- // Emit change event so other tools (like FeatureTool) can react
746
- if (emitEvent && this.context) {
747
- const geometry = this.getGeometry();
748
- if (geometry) {
749
- this.context.eventBus.emit("dieline:geometry:change", geometry);
750
- }
751
- }
752
- }
753
-
754
- public getGeometry(): DielineGeometry | null {
755
- if (!this.canvasService) return null;
756
- const { unit, shape, width, height, radius, offset, mainLine, pathData } = this.state;
757
- const canvasW = this.canvasService.canvas.width || 800;
758
- const canvasH = this.canvasService.canvas.height || 600;
759
-
760
- const paddingPx = this.resolvePadding(canvasW, canvasH);
761
- const layout = Coordinate.calculateLayout(
762
- { width: canvasW, height: canvasH },
763
- { width, height },
764
- paddingPx,
765
- );
766
-
767
- const scale = layout.scale;
768
- const cx = layout.offsetX + layout.width / 2;
769
- const cy = layout.offsetY + layout.height / 2;
770
-
771
- const visualWidth = layout.width;
772
- const visualHeight = layout.height;
773
-
774
- return {
775
- shape,
776
- unit,
777
- x: cx,
778
- y: cy,
779
- width: visualWidth,
780
- height: visualHeight,
781
- radius: radius * scale,
782
- offset: offset * scale,
783
- // Pass scale to help other tools (like FeatureTool) convert units
784
- scale,
785
- strokeWidth: mainLine.width,
786
- pathData,
787
- } as DielineGeometry;
788
- }
789
-
790
- public async exportCutImage() {
791
- if (!this.canvasService) return null;
792
- const userLayer = this.canvasService.getLayer("user");
793
-
794
- if (!userLayer) return null;
795
-
796
- // 1. Generate Path Data
797
- const { shape, width, height, radius, features, unit, pathData } = this.state;
798
- const canvasW = this.canvasService.canvas.width || 800;
799
- const canvasH = this.canvasService.canvas.height || 600;
800
-
801
- const paddingPx = this.resolvePadding(canvasW, canvasH);
802
- const layout = Coordinate.calculateLayout(
803
- { width: canvasW, height: canvasH },
804
- { width, height },
805
- paddingPx,
806
- );
807
- const scale = layout.scale;
808
- const cx = layout.offsetX + layout.width / 2;
809
- const cy = layout.offsetY + layout.height / 2;
810
- const visualWidth = layout.width;
811
- const visualHeight = layout.height;
812
- const visualRadius = radius * scale;
813
-
814
- // Scale Features
815
- const absoluteFeatures = (features || []).map((f) => {
816
- const featureScale = scale;
817
-
818
- return {
819
- ...f,
820
- x: f.x,
821
- y: f.y,
822
- width: (f.width || 0) * featureScale,
823
- height: (f.height || 0) * featureScale,
824
- radius: (f.radius || 0) * featureScale,
825
- };
826
- });
827
-
828
- const cutFeatures = absoluteFeatures.filter((f) => !f.skipCut);
829
-
830
- const generatedPathData = generateDielinePath({
831
- shape,
832
- width: visualWidth,
833
- height: visualHeight,
834
- radius: visualRadius,
835
- x: cx,
836
- y: cy,
837
- features: cutFeatures,
838
- pathData,
839
- canvasWidth: canvasW,
840
- canvasHeight: canvasH,
841
- });
842
-
843
- // 2. Prepare for Export
844
- const clonedLayer = await userLayer.clone();
845
-
846
- const clipPath = new Path(generatedPathData, {
847
- originX: "left",
848
- originY: "top",
849
- left: 0,
850
- top: 0,
851
- absolutePositioned: true,
852
- });
853
-
854
- clonedLayer.clipPath = clipPath;
855
-
856
- // 3. Calculate Crop Area (The Dieline Bounds)
857
- const bounds = clipPath.getBoundingRect();
858
-
859
- // 4. Export
860
- const dataUrl = clonedLayer.toDataURL({
861
- format: "png",
862
- multiplier: 2,
863
- left: bounds.left,
864
- top: bounds.top,
865
- width: bounds.width,
866
- height: bounds.height,
867
- });
868
-
869
- return dataUrl;
870
- }
871
- }
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ } from "@pooder/core";
8
+ import { Path, Pattern } from "fabric";
9
+ import CanvasService from "./CanvasService";
10
+ import { ImageTracer } from "./tracer";
11
+ import { Unit } from "./coordinate";
12
+ import { parseLengthToMm } from "./units";
13
+ import {
14
+ generateDielinePath,
15
+ generateMaskPath,
16
+ generateBleedZonePath,
17
+ getPathBounds,
18
+ DielineFeature,
19
+ } from "./geometry";
20
+
21
+ export interface DielineGeometry {
22
+ shape: "rect" | "circle" | "ellipse" | "custom";
23
+ unit: "mm";
24
+ displayUnit: Unit;
25
+ x: number;
26
+ y: number;
27
+ width: number;
28
+ height: number;
29
+ radius: number;
30
+ offset: number;
31
+ borderLength?: number;
32
+ scale?: number;
33
+ strokeWidth?: number;
34
+ pathData?: string;
35
+ }
36
+
37
+ export interface LineStyle {
38
+ width: number;
39
+ color: string;
40
+ dashLength: number;
41
+ style: "solid" | "dashed" | "hidden";
42
+ }
43
+
44
+ export interface DielineState {
45
+ displayUnit: Unit;
46
+ shape: "rect" | "circle" | "ellipse" | "custom";
47
+ width: number;
48
+ height: number;
49
+ radius: number;
50
+ offset: number;
51
+ padding: number | string;
52
+ mainLine: LineStyle;
53
+ offsetLine: LineStyle;
54
+ insideColor: string;
55
+ outsideColor: string;
56
+ showBleedLines: boolean;
57
+ features: DielineFeature[];
58
+ pathData?: string;
59
+ }
60
+
61
+ export class DielineTool implements Extension {
62
+ id = "pooder.kit.dieline";
63
+ public metadata = {
64
+ name: "DielineTool",
65
+ };
66
+
67
+ private state: DielineState = {
68
+ displayUnit: "mm",
69
+ shape: "rect",
70
+ width: 500,
71
+ height: 500,
72
+ radius: 0,
73
+ offset: 0,
74
+ padding: 140,
75
+ mainLine: {
76
+ width: 2.7,
77
+ color: "#FF0000",
78
+ dashLength: 5,
79
+ style: "solid",
80
+ },
81
+ offsetLine: {
82
+ width: 2.7,
83
+ color: "#FF0000",
84
+ dashLength: 5,
85
+ style: "solid",
86
+ },
87
+ insideColor: "rgba(0,0,0,0)",
88
+ outsideColor: "#ffffff",
89
+ showBleedLines: true,
90
+ features: [],
91
+ };
92
+
93
+ private canvasService?: CanvasService;
94
+ private context?: ExtensionContext;
95
+
96
+ constructor(options?: Partial<DielineState>) {
97
+ if (options) {
98
+ // Deep merge for styles to avoid overwriting defaults with partial objects
99
+ if (options.mainLine) {
100
+ Object.assign(this.state.mainLine, options.mainLine);
101
+ delete options.mainLine;
102
+ }
103
+ if (options.offsetLine) {
104
+ Object.assign(this.state.offsetLine, options.offsetLine);
105
+ delete options.offsetLine;
106
+ }
107
+ Object.assign(this.state, options);
108
+ }
109
+ }
110
+
111
+ activate(context: ExtensionContext) {
112
+ this.context = context;
113
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
114
+ if (!this.canvasService) {
115
+ console.warn("CanvasService not found for DielineTool");
116
+ return;
117
+ }
118
+
119
+ const configService = context.services.get<any>("ConfigurationService");
120
+ if (configService) {
121
+ // Load initial config
122
+ const s = this.state;
123
+ s.displayUnit = configService.get("dieline.displayUnit", s.displayUnit);
124
+ s.shape = configService.get("dieline.shape", s.shape);
125
+ s.width = parseLengthToMm(
126
+ configService.get("dieline.width", s.width),
127
+ "mm",
128
+ );
129
+ s.height = parseLengthToMm(
130
+ configService.get("dieline.height", s.height),
131
+ "mm",
132
+ );
133
+ s.radius = parseLengthToMm(
134
+ configService.get("dieline.radius", s.radius),
135
+ "mm",
136
+ );
137
+ s.padding = configService.get("dieline.padding", s.padding);
138
+ s.offset = parseLengthToMm(
139
+ configService.get("dieline.offset", s.offset),
140
+ "mm",
141
+ );
142
+
143
+ // Main Line
144
+ s.mainLine.width = configService.get(
145
+ "dieline.strokeWidth",
146
+ s.mainLine.width,
147
+ );
148
+ s.mainLine.color = configService.get(
149
+ "dieline.strokeColor",
150
+ s.mainLine.color,
151
+ );
152
+ s.mainLine.dashLength = configService.get(
153
+ "dieline.dashLength",
154
+ s.mainLine.dashLength,
155
+ );
156
+ s.mainLine.style = configService.get("dieline.style", s.mainLine.style);
157
+
158
+ // Offset Line
159
+ s.offsetLine.width = configService.get(
160
+ "dieline.offsetStrokeWidth",
161
+ s.offsetLine.width,
162
+ );
163
+ s.offsetLine.color = configService.get(
164
+ "dieline.offsetStrokeColor",
165
+ s.offsetLine.color,
166
+ );
167
+ s.offsetLine.dashLength = configService.get(
168
+ "dieline.offsetDashLength",
169
+ s.offsetLine.dashLength,
170
+ );
171
+ s.offsetLine.style = configService.get(
172
+ "dieline.offsetStyle",
173
+ s.offsetLine.style,
174
+ );
175
+
176
+ s.insideColor = configService.get("dieline.insideColor", s.insideColor);
177
+ s.outsideColor = configService.get(
178
+ "dieline.outsideColor",
179
+ s.outsideColor,
180
+ );
181
+ s.showBleedLines = configService.get(
182
+ "dieline.showBleedLines",
183
+ s.showBleedLines,
184
+ );
185
+ s.features = configService.get("dieline.features", s.features);
186
+ s.pathData = configService.get("dieline.pathData", s.pathData);
187
+
188
+ // Listen for changes
189
+ configService.onAnyChange((e: { key: string; value: any }) => {
190
+ if (e.key.startsWith("dieline.")) {
191
+ switch (e.key) {
192
+ case "dieline.displayUnit":
193
+ s.displayUnit = e.value;
194
+ break;
195
+ case "dieline.shape":
196
+ s.shape = e.value;
197
+ break;
198
+ case "dieline.width":
199
+ s.width = parseLengthToMm(e.value, "mm");
200
+ break;
201
+ case "dieline.height":
202
+ s.height = parseLengthToMm(e.value, "mm");
203
+ break;
204
+ case "dieline.radius":
205
+ s.radius = parseLengthToMm(e.value, "mm");
206
+ break;
207
+ case "dieline.padding":
208
+ s.padding = e.value;
209
+ break;
210
+ case "dieline.offset":
211
+ s.offset = parseLengthToMm(e.value, "mm");
212
+ break;
213
+
214
+ case "dieline.strokeWidth":
215
+ s.mainLine.width = e.value;
216
+ break;
217
+ case "dieline.strokeColor":
218
+ s.mainLine.color = e.value;
219
+ break;
220
+ case "dieline.dashLength":
221
+ s.mainLine.dashLength = e.value;
222
+ break;
223
+ case "dieline.style":
224
+ s.mainLine.style = e.value;
225
+ break;
226
+
227
+ case "dieline.offsetStrokeWidth":
228
+ s.offsetLine.width = e.value;
229
+ break;
230
+ case "dieline.offsetStrokeColor":
231
+ s.offsetLine.color = e.value;
232
+ break;
233
+ case "dieline.offsetDashLength":
234
+ s.offsetLine.dashLength = e.value;
235
+ break;
236
+ case "dieline.offsetStyle":
237
+ s.offsetLine.style = e.value;
238
+ break;
239
+
240
+ case "dieline.insideColor":
241
+ s.insideColor = e.value;
242
+ break;
243
+ case "dieline.outsideColor":
244
+ s.outsideColor = e.value;
245
+ break;
246
+ case "dieline.showBleedLines":
247
+ s.showBleedLines = e.value;
248
+ break;
249
+ case "dieline.features":
250
+ s.features = e.value;
251
+ break;
252
+ case "dieline.pathData":
253
+ s.pathData = e.value;
254
+ break;
255
+ }
256
+ this.updateDieline();
257
+ }
258
+ });
259
+ }
260
+
261
+ this.createLayer();
262
+ this.updateDieline();
263
+ }
264
+
265
+ deactivate(context: ExtensionContext) {
266
+ this.destroyLayer();
267
+ this.canvasService = undefined;
268
+ this.context = undefined;
269
+ }
270
+
271
+ contribute() {
272
+ const s = this.state;
273
+ return {
274
+ [ContributionPointIds.CONFIGURATIONS]: [
275
+ {
276
+ id: "dieline.displayUnit",
277
+ type: "select",
278
+ label: "Display Unit",
279
+ options: ["mm", "cm", "in"],
280
+ default: s.displayUnit,
281
+ },
282
+ {
283
+ id: "dieline.shape",
284
+ type: "select",
285
+ label: "Shape",
286
+ options: ["rect", "circle", "ellipse", "custom"],
287
+ default: s.shape,
288
+ },
289
+ {
290
+ id: "dieline.width",
291
+ type: "number",
292
+ label: "Width (mm)",
293
+ min: 10,
294
+ max: 2000,
295
+ default: s.width,
296
+ },
297
+ {
298
+ id: "dieline.height",
299
+ type: "number",
300
+ label: "Height (mm)",
301
+ min: 10,
302
+ max: 2000,
303
+ default: s.height,
304
+ },
305
+ {
306
+ id: "dieline.radius",
307
+ type: "number",
308
+ label: "Corner Radius (mm)",
309
+ min: 0,
310
+ max: 500,
311
+ default: s.radius,
312
+ },
313
+ {
314
+ id: "dieline.padding",
315
+ type: "select",
316
+ label: "View Padding",
317
+ options: [0, 10, 20, 40, 60, 100, "2%", "5%", "10%", "15%", "20%"],
318
+ default: s.padding,
319
+ },
320
+ {
321
+ id: "dieline.offset",
322
+ type: "number",
323
+ label: "Bleed Offset (mm)",
324
+ min: -100,
325
+ max: 100,
326
+ default: s.offset,
327
+ },
328
+ {
329
+ id: "dieline.showBleedLines",
330
+ type: "boolean",
331
+ label: "Show Bleed Lines",
332
+ default: s.showBleedLines,
333
+ },
334
+ {
335
+ id: "dieline.strokeWidth",
336
+ type: "number",
337
+ label: "Line Width",
338
+ min: 0.1,
339
+ max: 10,
340
+ step: 0.1,
341
+ default: s.mainLine.width,
342
+ },
343
+ {
344
+ id: "dieline.strokeColor",
345
+ type: "color",
346
+ label: "Line Color",
347
+ default: s.mainLine.color,
348
+ },
349
+ {
350
+ id: "dieline.dashLength",
351
+ type: "number",
352
+ label: "Dash Length",
353
+ min: 1,
354
+ max: 50,
355
+ default: s.mainLine.dashLength,
356
+ },
357
+ {
358
+ id: "dieline.style",
359
+ type: "select",
360
+ label: "Line Style",
361
+ options: ["solid", "dashed", "hidden"],
362
+ default: s.mainLine.style,
363
+ },
364
+ {
365
+ id: "dieline.offsetStrokeWidth",
366
+ type: "number",
367
+ label: "Offset Line Width",
368
+ min: 0.1,
369
+ max: 10,
370
+ step: 0.1,
371
+ default: s.offsetLine.width,
372
+ },
373
+ {
374
+ id: "dieline.offsetStrokeColor",
375
+ type: "color",
376
+ label: "Offset Line Color",
377
+ default: s.offsetLine.color,
378
+ },
379
+ {
380
+ id: "dieline.offsetDashLength",
381
+ type: "number",
382
+ label: "Offset Dash Length",
383
+ min: 1,
384
+ max: 50,
385
+ default: s.offsetLine.dashLength,
386
+ },
387
+ {
388
+ id: "dieline.offsetStyle",
389
+ type: "select",
390
+ label: "Offset Line Style",
391
+ options: ["solid", "dashed", "hidden"],
392
+ default: s.offsetLine.style,
393
+ },
394
+ {
395
+ id: "dieline.insideColor",
396
+ type: "color",
397
+ label: "Inside Color",
398
+ default: s.insideColor,
399
+ },
400
+ {
401
+ id: "dieline.outsideColor",
402
+ type: "color",
403
+ label: "Outside Color",
404
+ default: s.outsideColor,
405
+ },
406
+ {
407
+ id: "dieline.features",
408
+ type: "json",
409
+ label: "Edge Features",
410
+ default: s.features,
411
+ },
412
+ ] as ConfigurationContribution[],
413
+ [ContributionPointIds.COMMANDS]: [
414
+ {
415
+ command: "updateFeaturePosition",
416
+ title: "Update Feature Position",
417
+ handler: (groupId: string, x: number, y: number) => {
418
+ const configService = this.context?.services.get<any>(
419
+ "ConfigurationService",
420
+ );
421
+ if (!configService) return;
422
+
423
+ const features = configService.get("dieline.features") || [];
424
+
425
+ let changed = false;
426
+ const newFeatures = features.map((f: any) => {
427
+ if (f.groupId === groupId) {
428
+ if (f.x !== x || f.y !== y) {
429
+ changed = true;
430
+ return { ...f, x, y };
431
+ }
432
+ }
433
+ return f;
434
+ });
435
+
436
+ if (changed) {
437
+ configService.update("dieline.features", newFeatures);
438
+ }
439
+ },
440
+ },
441
+ {
442
+ command: "getGeometry",
443
+ title: "Get Geometry",
444
+ handler: () => {
445
+ return this.getGeometry();
446
+ },
447
+ },
448
+ {
449
+ command: "exportCutImage",
450
+ title: "Export Cut Image",
451
+ handler: () => {
452
+ return this.exportCutImage();
453
+ },
454
+ },
455
+ {
456
+ command: "detectEdge",
457
+ title: "Detect Edge from Image",
458
+ handler: async (imageUrl: string, options?: any) => {
459
+ try {
460
+ const pathData = await ImageTracer.trace(imageUrl, options);
461
+ const bounds = getPathBounds(pathData);
462
+
463
+ const currentMax = Math.max(s.width, s.height);
464
+ const scale = currentMax / Math.max(bounds.width, bounds.height);
465
+
466
+ const newWidth = bounds.width * scale;
467
+ const newHeight = bounds.height * scale;
468
+
469
+ return {
470
+ pathData,
471
+ width: newWidth,
472
+ height: newHeight,
473
+ };
474
+ } catch (e) {
475
+ console.error("Edge detection failed", e);
476
+ throw e;
477
+ }
478
+ },
479
+ },
480
+ ] as CommandContribution[],
481
+ };
482
+ }
483
+
484
+ private getLayer() {
485
+ return this.canvasService?.getLayer("dieline-overlay");
486
+ }
487
+
488
+ private createLayer() {
489
+ if (!this.canvasService) return;
490
+ const width = this.canvasService.canvas.width || 800;
491
+ const height = this.canvasService.canvas.height || 600;
492
+
493
+ const layer = this.canvasService.createLayer("dieline-overlay", {
494
+ width,
495
+ height,
496
+ selectable: false,
497
+ evented: false,
498
+ });
499
+
500
+ this.canvasService.canvas.bringObjectToFront(layer);
501
+
502
+ // Ensure above user layer
503
+ const userLayer = this.canvasService.getLayer("user");
504
+ if (userLayer) {
505
+ const userIndex = this.canvasService.canvas
506
+ .getObjects()
507
+ .indexOf(userLayer);
508
+ this.canvasService.canvas.moveObjectTo(layer, userIndex + 1);
509
+ }
510
+ }
511
+
512
+ private destroyLayer() {
513
+ if (!this.canvasService) return;
514
+ const layer = this.getLayer();
515
+ if (layer) {
516
+ this.canvasService.canvas.remove(layer);
517
+ }
518
+ }
519
+
520
+ private createHatchPattern(color: string = "rgba(0, 0, 0, 0.3)") {
521
+ if (typeof document === "undefined") {
522
+ return undefined;
523
+ }
524
+ const size = 20;
525
+ const canvas = document.createElement("canvas");
526
+ canvas.width = size;
527
+ canvas.height = size;
528
+ const ctx = canvas.getContext("2d");
529
+ if (ctx) {
530
+ // Transparent background
531
+ ctx.clearRect(0, 0, size, size);
532
+
533
+ // Draw diagonal /
534
+ ctx.strokeStyle = color;
535
+ ctx.lineWidth = 1;
536
+ ctx.beginPath();
537
+ ctx.moveTo(0, size);
538
+ ctx.lineTo(size, 0);
539
+ ctx.stroke();
540
+ }
541
+ // @ts-ignore
542
+ return new Pattern({ source: canvas, repetition: "repeat" });
543
+ }
544
+
545
+ private resolvePadding(
546
+ containerWidth: number,
547
+ containerHeight: number,
548
+ ): number {
549
+ if (typeof this.state.padding === "number") {
550
+ return this.state.padding;
551
+ }
552
+ if (typeof this.state.padding === "string") {
553
+ if (this.state.padding.endsWith("%")) {
554
+ const percent = parseFloat(this.state.padding) / 100;
555
+ return Math.min(containerWidth, containerHeight) * percent;
556
+ }
557
+ return parseFloat(this.state.padding) || 0;
558
+ }
559
+ return 0;
560
+ }
561
+
562
+ public updateDieline(emitEvent: boolean = true) {
563
+ if (!this.canvasService) return;
564
+ const layer = this.getLayer();
565
+ if (!layer) return;
566
+
567
+ const {
568
+ displayUnit,
569
+ shape,
570
+ radius,
571
+ offset,
572
+ mainLine,
573
+ offsetLine,
574
+ insideColor,
575
+ outsideColor,
576
+ showBleedLines,
577
+ features,
578
+ } = this.state;
579
+ const { width, height } = this.state;
580
+
581
+ const canvasW = this.canvasService.canvas.width || 800;
582
+ const canvasH = this.canvasService.canvas.height || 600;
583
+
584
+ // Calculate Layout based on Physical Dimensions and Canvas Size
585
+ // Add padding to avoid edge hugging
586
+ const paddingPx = this.resolvePadding(canvasW, canvasH);
587
+
588
+ // Update Viewport System
589
+ this.canvasService.viewport.setPadding(paddingPx);
590
+ this.canvasService.viewport.updatePhysical(width, height);
591
+
592
+ const layout = this.canvasService.viewport.layout;
593
+
594
+ const scale = layout.scale;
595
+ const cx = layout.offsetX + layout.width / 2;
596
+ const cy = layout.offsetY + layout.height / 2;
597
+
598
+ // Scaled dimensions for rendering (Pixels)
599
+ const visualWidth = layout.width;
600
+ const visualHeight = layout.height;
601
+ const visualRadius = radius * scale;
602
+ const visualOffset = offset * scale;
603
+
604
+ // Clear existing objects
605
+ layer.remove(...layer.getObjects());
606
+
607
+ // Scale Features for Geometry Generation
608
+ const absoluteFeatures = (features || []).map((f) => {
609
+ const featureScale = scale;
610
+
611
+ return {
612
+ ...f,
613
+ x: f.x,
614
+ y: f.y,
615
+ width: (f.width || 0) * featureScale,
616
+ height: (f.height || 0) * featureScale,
617
+ radius: (f.radius || 0) * featureScale,
618
+ };
619
+ });
620
+
621
+ // Split features into Cut (Physical) and Visual (All)
622
+ const cutFeatures = absoluteFeatures.filter((f) => !f.skipCut);
623
+
624
+ // 1. Draw Mask (Outside)
625
+ const cutW = Math.max(0, visualWidth + visualOffset * 2);
626
+ const cutH = Math.max(0, visualHeight + visualOffset * 2);
627
+ const cutR =
628
+ visualRadius === 0 ? 0 : Math.max(0, visualRadius + visualOffset);
629
+
630
+ // Use Paper.js to generate the complex mask path
631
+ const maskPathData = generateMaskPath({
632
+ canvasWidth: canvasW,
633
+ canvasHeight: canvasH,
634
+ shape,
635
+ width: cutW,
636
+ height: cutH,
637
+ radius: cutR,
638
+ x: cx,
639
+ y: cy,
640
+ features: cutFeatures,
641
+ pathData: this.state.pathData,
642
+ });
643
+
644
+ const mask = new Path(maskPathData, {
645
+ fill: outsideColor,
646
+ stroke: null,
647
+ selectable: false,
648
+ evented: false,
649
+ originX: "left" as const,
650
+ originY: "top" as const,
651
+ left: 0,
652
+ top: 0,
653
+ });
654
+ layer.add(mask);
655
+
656
+ // 2. Draw Inside Fill (Dieline Shape itself, merged with features if needed)
657
+ if (
658
+ insideColor &&
659
+ insideColor !== "transparent" &&
660
+ insideColor !== "rgba(0,0,0,0)"
661
+ ) {
662
+ // Generate path for the product shape (Paper) = Dieline +/- Features
663
+ const productPathData = generateDielinePath({
664
+ shape,
665
+ width: cutW,
666
+ height: cutH,
667
+ radius: cutR,
668
+ x: cx,
669
+ y: cy,
670
+ features: cutFeatures, // Use same features as mask for consistency
671
+ pathData: this.state.pathData,
672
+ canvasWidth: canvasW,
673
+ canvasHeight: canvasH,
674
+ });
675
+
676
+ const insideObj = new Path(productPathData, {
677
+ fill: insideColor,
678
+ stroke: null,
679
+ selectable: false,
680
+ evented: false,
681
+ originX: "left", // paper.js paths are absolute
682
+ originY: "top",
683
+ });
684
+ layer.add(insideObj);
685
+ }
686
+
687
+ // 3. Draw Bleed Zone (Hatch Fill) and Offset Border
688
+ if (offset !== 0) {
689
+ const bleedPathData = generateBleedZonePath(
690
+ {
691
+ shape,
692
+ width: visualWidth,
693
+ height: visualHeight,
694
+ radius: visualRadius,
695
+ x: cx,
696
+ y: cy,
697
+ features: cutFeatures,
698
+ pathData: this.state.pathData,
699
+ canvasWidth: canvasW,
700
+ canvasHeight: canvasH,
701
+ },
702
+ {
703
+ shape,
704
+ width: cutW,
705
+ height: cutH,
706
+ radius: cutR,
707
+ x: cx,
708
+ y: cy,
709
+ features: cutFeatures,
710
+ pathData: this.state.pathData,
711
+ canvasWidth: canvasW,
712
+ canvasHeight: canvasH,
713
+ },
714
+ visualOffset,
715
+ );
716
+
717
+ // Use solid red for hatch lines to match dieline, background is transparent
718
+ if (showBleedLines !== false) {
719
+ const pattern = this.createHatchPattern(mainLine.color);
720
+ if (pattern) {
721
+ const bleedObj = new Path(bleedPathData, {
722
+ fill: pattern,
723
+ stroke: null,
724
+ selectable: false,
725
+ evented: false,
726
+ objectCaching: false,
727
+ originX: "left",
728
+ originY: "top",
729
+ });
730
+ layer.add(bleedObj);
731
+ }
732
+ }
733
+
734
+ // Offset Dieline Border
735
+ const offsetPathData = generateDielinePath({
736
+ shape,
737
+ width: cutW,
738
+ height: cutH,
739
+ radius: cutR,
740
+ x: cx,
741
+ y: cy,
742
+ features: cutFeatures,
743
+ pathData: this.state.pathData,
744
+ canvasWidth: canvasW,
745
+ canvasHeight: canvasH,
746
+ });
747
+
748
+ const offsetBorderObj = new Path(offsetPathData, {
749
+ fill: null,
750
+ stroke: offsetLine.style === "hidden" ? null : offsetLine.color,
751
+ strokeWidth: offsetLine.width,
752
+ strokeDashArray:
753
+ offsetLine.style === "dashed"
754
+ ? [offsetLine.dashLength, offsetLine.dashLength]
755
+ : undefined,
756
+ selectable: false,
757
+ evented: false,
758
+ originX: "left",
759
+ originY: "top",
760
+ });
761
+ layer.add(offsetBorderObj);
762
+ }
763
+
764
+ // 4. Draw Dieline (Visual Border)
765
+ const borderPathData = generateDielinePath({
766
+ shape,
767
+ width: visualWidth,
768
+ height: visualHeight,
769
+ radius: visualRadius,
770
+ x: cx,
771
+ y: cy,
772
+ features: absoluteFeatures,
773
+ pathData: this.state.pathData,
774
+ canvasWidth: canvasW,
775
+ canvasHeight: canvasH,
776
+ });
777
+
778
+ const borderObj = new Path(borderPathData, {
779
+ fill: "transparent",
780
+ stroke: mainLine.style === "hidden" ? null : mainLine.color,
781
+ strokeWidth: mainLine.width,
782
+ strokeDashArray:
783
+ mainLine.style === "dashed"
784
+ ? [mainLine.dashLength, mainLine.dashLength]
785
+ : undefined,
786
+ selectable: false,
787
+ evented: false,
788
+ originX: "left",
789
+ originY: "top",
790
+ });
791
+
792
+ layer.add(borderObj);
793
+
794
+ // Enforce z-index: Dieline > User
795
+ const userLayer = this.canvasService.getLayer("user");
796
+ if (layer && userLayer) {
797
+ const layerIndex = this.canvasService.canvas.getObjects().indexOf(layer);
798
+ const userIndex = this.canvasService.canvas
799
+ .getObjects()
800
+ .indexOf(userLayer);
801
+ if (layerIndex < userIndex) {
802
+ this.canvasService.canvas.moveObjectTo(layer, userIndex + 1);
803
+ }
804
+ } else {
805
+ // If no user layer, just bring to front (safe default)
806
+ this.canvasService.canvas.bringObjectToFront(layer);
807
+ }
808
+
809
+ // Ensure Ruler is above Dieline if it exists
810
+ const rulerLayer = this.canvasService.getLayer("ruler-overlay");
811
+ if (rulerLayer) {
812
+ this.canvasService.canvas.bringObjectToFront(rulerLayer);
813
+ }
814
+
815
+ layer.dirty = true;
816
+ this.canvasService.requestRenderAll();
817
+
818
+ // Emit change event so other tools (like FeatureTool) can react
819
+ if (emitEvent && this.context) {
820
+ const geometry = this.getGeometry();
821
+ if (geometry) {
822
+ this.context.eventBus.emit("dieline:geometry:change", geometry);
823
+ }
824
+ }
825
+ }
826
+
827
+ public getGeometry(): DielineGeometry | null {
828
+ if (!this.canvasService) return null;
829
+ const {
830
+ displayUnit,
831
+ shape,
832
+ width,
833
+ height,
834
+ radius,
835
+ offset,
836
+ mainLine,
837
+ pathData,
838
+ } = this.state;
839
+ const canvasW = this.canvasService.canvas.width || 800;
840
+ const canvasH = this.canvasService.canvas.height || 600;
841
+
842
+ const paddingPx = this.resolvePadding(canvasW, canvasH);
843
+
844
+ // Update Viewport System (Ensure it's up to date)
845
+ this.canvasService.viewport.setPadding(paddingPx);
846
+ this.canvasService.viewport.updatePhysical(width, height);
847
+
848
+ const layout = this.canvasService.viewport.layout;
849
+
850
+ const scale = layout.scale;
851
+ const cx = layout.offsetX + layout.width / 2;
852
+ const cy = layout.offsetY + layout.height / 2;
853
+
854
+ const visualWidth = layout.width;
855
+ const visualHeight = layout.height;
856
+
857
+ return {
858
+ shape,
859
+ unit: "mm",
860
+ displayUnit,
861
+ x: cx,
862
+ y: cy,
863
+ width: visualWidth,
864
+ height: visualHeight,
865
+ radius: radius * scale,
866
+ offset: offset * scale,
867
+ scale,
868
+ strokeWidth: mainLine.width,
869
+ pathData,
870
+ } as DielineGeometry;
871
+ }
872
+
873
+ public async exportCutImage() {
874
+ if (!this.canvasService) return null;
875
+ const userLayer = this.canvasService.getLayer("user");
876
+
877
+ if (!userLayer) return null;
878
+
879
+ // 1. Generate Path Data
880
+ const { shape, width, height, radius, features, pathData } = this.state;
881
+ const canvasW = this.canvasService.canvas.width || 800;
882
+ const canvasH = this.canvasService.canvas.height || 600;
883
+
884
+ const paddingPx = this.resolvePadding(canvasW, canvasH);
885
+
886
+ // Update Viewport System
887
+ this.canvasService.viewport.setPadding(paddingPx);
888
+ this.canvasService.viewport.updatePhysical(width, height);
889
+
890
+ const layout = this.canvasService.viewport.layout;
891
+ const scale = layout.scale;
892
+ const cx = layout.offsetX + layout.width / 2;
893
+ const cy = layout.offsetY + layout.height / 2;
894
+ const visualWidth = layout.width;
895
+ const visualHeight = layout.height;
896
+ const visualRadius = radius * scale;
897
+
898
+ // Scale Features
899
+ const absoluteFeatures = (features || []).map((f) => {
900
+ const featureScale = scale;
901
+
902
+ return {
903
+ ...f,
904
+ x: f.x,
905
+ y: f.y,
906
+ width: (f.width || 0) * featureScale,
907
+ height: (f.height || 0) * featureScale,
908
+ radius: (f.radius || 0) * featureScale,
909
+ };
910
+ });
911
+
912
+ const cutFeatures = absoluteFeatures.filter((f) => !f.skipCut);
913
+
914
+ const generatedPathData = generateDielinePath({
915
+ shape,
916
+ width: visualWidth,
917
+ height: visualHeight,
918
+ radius: visualRadius,
919
+ x: cx,
920
+ y: cy,
921
+ features: cutFeatures,
922
+ pathData,
923
+ canvasWidth: canvasW,
924
+ canvasHeight: canvasH,
925
+ });
926
+
927
+ // 2. Prepare for Export
928
+ const clonedLayer = await userLayer.clone();
929
+
930
+ const clipPath = new Path(generatedPathData, {
931
+ originX: "left",
932
+ originY: "top",
933
+ left: 0,
934
+ top: 0,
935
+ absolutePositioned: true,
936
+ });
937
+
938
+ clonedLayer.clipPath = clipPath;
939
+
940
+ // 3. Calculate Crop Area (The Dieline Bounds)
941
+ const bounds = clipPath.getBoundingRect();
942
+
943
+ // 4. Export
944
+ const dataUrl = clonedLayer.toDataURL({
945
+ format: "png",
946
+ multiplier: 2,
947
+ left: bounds.left,
948
+ top: bounds.top,
949
+ width: bounds.width,
950
+ height: bounds.height,
951
+ });
952
+
953
+ return dataUrl;
954
+ }
955
+ }