@pooder/kit 5.3.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.test-dist/src/extensions/background.js +475 -131
  2. package/.test-dist/src/extensions/dieline.js +283 -180
  3. package/.test-dist/src/extensions/dielineShape.js +66 -0
  4. package/.test-dist/src/extensions/feature.js +388 -303
  5. package/.test-dist/src/extensions/film.js +133 -74
  6. package/.test-dist/src/extensions/geometry.js +120 -56
  7. package/.test-dist/src/extensions/image.js +296 -212
  8. package/.test-dist/src/extensions/index.js +1 -3
  9. package/.test-dist/src/extensions/maskOps.js +75 -20
  10. package/.test-dist/src/extensions/ruler.js +312 -215
  11. package/.test-dist/src/extensions/sceneLayoutModel.js +9 -3
  12. package/.test-dist/src/extensions/sceneVisibility.js +3 -10
  13. package/.test-dist/src/extensions/tracer.js +229 -58
  14. package/.test-dist/src/extensions/white-ink.js +139 -129
  15. package/.test-dist/src/services/CanvasService.js +888 -126
  16. package/.test-dist/src/services/index.js +1 -0
  17. package/.test-dist/src/services/visibility.js +54 -0
  18. package/.test-dist/tests/run.js +58 -4
  19. package/CHANGELOG.md +12 -0
  20. package/dist/index.d.mts +377 -82
  21. package/dist/index.d.ts +377 -82
  22. package/dist/index.js +3920 -2178
  23. package/dist/index.mjs +3992 -2247
  24. package/package.json +1 -1
  25. package/src/extensions/background.ts +631 -145
  26. package/src/extensions/dieline.ts +280 -187
  27. package/src/extensions/dielineShape.ts +109 -0
  28. package/src/extensions/feature.ts +485 -366
  29. package/src/extensions/film.ts +152 -76
  30. package/src/extensions/geometry.ts +203 -104
  31. package/src/extensions/image.ts +319 -238
  32. package/src/extensions/index.ts +0 -1
  33. package/src/extensions/ruler.ts +481 -268
  34. package/src/extensions/sceneLayoutModel.ts +18 -6
  35. package/src/extensions/white-ink.ts +157 -171
  36. package/src/services/CanvasService.ts +1126 -140
  37. package/src/services/index.ts +1 -0
  38. package/src/services/renderSpec.ts +69 -4
  39. package/src/services/visibility.ts +78 -0
  40. package/tests/run.ts +139 -4
  41. package/.test-dist/src/CanvasService.js +0 -249
  42. package/.test-dist/src/ViewportSystem.js +0 -75
  43. package/.test-dist/src/background.js +0 -203
  44. package/.test-dist/src/bridgeSelection.js +0 -20
  45. package/.test-dist/src/constraints.js +0 -237
  46. package/.test-dist/src/dieline.js +0 -818
  47. package/.test-dist/src/edgeScale.js +0 -12
  48. package/.test-dist/src/feature.js +0 -826
  49. package/.test-dist/src/featureComplete.js +0 -32
  50. package/.test-dist/src/film.js +0 -167
  51. package/.test-dist/src/geometry.js +0 -506
  52. package/.test-dist/src/image.js +0 -1250
  53. package/.test-dist/src/maskOps.js +0 -270
  54. package/.test-dist/src/mirror.js +0 -104
  55. package/.test-dist/src/renderSpec.js +0 -2
  56. package/.test-dist/src/ruler.js +0 -343
  57. package/.test-dist/src/sceneLayout.js +0 -99
  58. package/.test-dist/src/sceneLayoutModel.js +0 -196
  59. package/.test-dist/src/sceneView.js +0 -40
  60. package/.test-dist/src/sceneVisibility.js +0 -42
  61. package/.test-dist/src/size.js +0 -332
  62. package/.test-dist/src/tracer.js +0 -544
  63. package/.test-dist/src/white-ink.js +0 -829
  64. package/.test-dist/src/wrappedOffsets.js +0 -33
  65. package/src/extensions/sceneVisibility.ts +0 -71
@@ -4,9 +4,20 @@ import {
4
4
  ContributionPointIds,
5
5
  CommandContribution,
6
6
  ConfigurationContribution,
7
+ ConfigurationService,
7
8
  } from "@pooder/core";
8
- import { FabricImage as Image } from "fabric";
9
- import { CanvasService } from "../services";
9
+ import { FabricImage } from "fabric";
10
+ import { CanvasService, RenderObjectSpec } from "../services";
11
+
12
+ interface SourceSize {
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ const FILM_LAYER_ID = "overlay";
18
+ const FILM_IMAGE_ID = "film-image";
19
+ const DEFAULT_WIDTH = 800;
20
+ const DEFAULT_HEIGHT = 600;
10
21
 
11
22
  export class FilmTool implements Extension {
12
23
  id = "pooder.kit.film";
@@ -19,6 +30,15 @@ export class FilmTool implements Extension {
19
30
  private opacity: number = 0.5;
20
31
 
21
32
  private canvasService?: CanvasService;
33
+ private specs: RenderObjectSpec[] = [];
34
+ private renderProducerDisposable?: { dispose: () => void };
35
+ private renderSeq = 0;
36
+ private renderImageUrl = "";
37
+ private sourceSizeBySrc: Map<string, SourceSize> = new Map();
38
+ private pendingSizeBySrc: Map<string, Promise<SourceSize | null>> = new Map();
39
+ private onCanvasResized = () => {
40
+ this.updateFilm();
41
+ };
22
42
 
23
43
  constructor(
24
44
  options?: Partial<{
@@ -38,7 +58,25 @@ export class FilmTool implements Extension {
38
58
  return;
39
59
  }
40
60
 
41
- const configService = context.services.get<any>("ConfigurationService");
61
+ this.renderProducerDisposable?.dispose();
62
+ this.renderProducerDisposable = this.canvasService.registerRenderProducer(
63
+ this.id,
64
+ () => ({
65
+ passes: [
66
+ {
67
+ id: FILM_LAYER_ID,
68
+ stack: 1000,
69
+ order: 0,
70
+ objects: this.specs,
71
+ },
72
+ ],
73
+ }),
74
+ { priority: 500 },
75
+ );
76
+
77
+ const configService = context.services.get<ConfigurationService>(
78
+ "ConfigurationService",
79
+ );
42
80
  if (configService) {
43
81
  // Load initial config
44
82
  this.url = configService.get("film.url", this.url);
@@ -59,22 +97,21 @@ export class FilmTool implements Extension {
59
97
  });
60
98
  }
61
99
 
62
- this.initLayer();
100
+ context.eventBus.on("canvas:resized", this.onCanvasResized);
63
101
  this.updateFilm();
64
102
  }
65
103
 
66
104
  deactivate(context: ExtensionContext) {
67
- if (this.canvasService) {
68
- const layer = this.canvasService.getLayer("overlay");
69
- if (layer) {
70
- const img = this.canvasService.getObject("film-image", "overlay");
71
- if (img) {
72
- layer.remove(img);
73
- this.canvasService.requestRenderAll();
74
- }
75
- }
76
- this.canvasService = undefined;
77
- }
105
+ context.eventBus.off("canvas:resized", this.onCanvasResized);
106
+ this.renderSeq += 1;
107
+ this.specs = [];
108
+ this.renderImageUrl = "";
109
+ this.renderProducerDisposable?.dispose();
110
+ this.renderProducerDisposable = undefined;
111
+ if (!this.canvasService) return;
112
+ void this.canvasService.flushRenderFromProducers();
113
+ this.canvasService.requestRenderAll();
114
+ this.canvasService = undefined;
78
115
  }
79
116
 
80
117
  contribute() {
@@ -115,80 +152,119 @@ export class FilmTool implements Extension {
115
152
  };
116
153
  }
117
154
 
118
- private initLayer() {
119
- if (!this.canvasService) return;
120
- let overlayLayer = this.canvasService.getLayer("overlay");
121
- if (!overlayLayer) {
122
- const width = this.canvasService.canvas.width || 800;
123
- const height = this.canvasService.canvas.height || 600;
124
-
125
- const layer = this.canvasService.createLayer("overlay", {
126
- width,
127
- height,
128
- left: 0,
129
- top: 0,
130
- originX: "left",
131
- originY: "top",
132
- selectable: false,
133
- evented: false,
134
- subTargetCheck: false,
135
- interactive: false,
136
- });
155
+ private getViewportSize(): { width: number; height: number } {
156
+ const width = Number(this.canvasService?.canvas.width || 0);
157
+ const height = Number(this.canvasService?.canvas.height || 0);
158
+ return {
159
+ width: width > 0 ? width : DEFAULT_WIDTH,
160
+ height: height > 0 ? height : DEFAULT_HEIGHT,
161
+ };
162
+ }
137
163
 
138
- this.canvasService.canvas.bringObjectToFront(layer);
139
- }
164
+ private clampOpacity(value: number): number {
165
+ return Math.max(0, Math.min(1, Number(value)));
140
166
  }
141
167
 
142
- private async updateFilm() {
143
- if (!this.canvasService) return;
144
- const layer = this.canvasService.getLayer("overlay");
145
- if (!layer) {
146
- console.warn("[FilmTool] Overlay layer not found");
147
- return;
168
+ private buildFilmSpecs(
169
+ imageUrl: string,
170
+ opacity: number,
171
+ ): RenderObjectSpec[] {
172
+ if (!imageUrl) {
173
+ return [];
148
174
  }
175
+ const { width, height } = this.getViewportSize();
176
+ const sourceSize = this.sourceSizeBySrc.get(imageUrl);
177
+ const sourceWidth = Math.max(1, Number(sourceSize?.width || width));
178
+ const sourceHeight = Math.max(1, Number(sourceSize?.height || height));
179
+ const coverScale = Math.max(width / sourceWidth, height / sourceHeight);
180
+ return [
181
+ {
182
+ id: FILM_IMAGE_ID,
183
+ type: "image",
184
+ src: imageUrl,
185
+ space: "screen",
186
+ data: {
187
+ id: FILM_IMAGE_ID,
188
+ layerId: FILM_LAYER_ID,
189
+ type: "film-image",
190
+ },
191
+ props: {
192
+ left: 0,
193
+ top: 0,
194
+ originX: "left",
195
+ originY: "top",
196
+ opacity: this.clampOpacity(opacity),
197
+ scaleX: coverScale,
198
+ scaleY: coverScale,
199
+ selectable: false,
200
+ evented: false,
201
+ excludeFromExport: true,
202
+ },
203
+ },
204
+ ];
205
+ }
149
206
 
150
- const { url, opacity } = this;
207
+ private async ensureImageSize(src: string): Promise<SourceSize | null> {
208
+ if (!src) return null;
209
+ const cached = this.sourceSizeBySrc.get(src);
210
+ if (cached) return cached;
151
211
 
152
- if (!url) {
153
- const img = this.canvasService.getObject("film-image", "overlay");
154
- if (img) {
155
- layer.remove(img);
156
- this.canvasService.requestRenderAll();
157
- }
158
- return;
212
+ const pending = this.pendingSizeBySrc.get(src);
213
+ if (pending) {
214
+ return pending;
159
215
  }
160
216
 
161
- const width = this.canvasService.canvas.width || 800;
162
- const height = this.canvasService.canvas.height || 600;
217
+ const task = this.loadImageSize(src);
218
+ this.pendingSizeBySrc.set(src, task);
219
+ try {
220
+ return await task;
221
+ } finally {
222
+ if (this.pendingSizeBySrc.get(src) === task) {
223
+ this.pendingSizeBySrc.delete(src);
224
+ }
225
+ }
226
+ }
163
227
 
164
- let img = this.canvasService.getObject("film-image", "overlay") as Image;
228
+ private async loadImageSize(src: string): Promise<SourceSize | null> {
165
229
  try {
166
- if (img) {
167
- if (img.getSrc() !== url) {
168
- await img.setSrc(url);
169
- }
170
- img.set({ opacity });
171
- } else {
172
- img = await Image.fromURL(url, { crossOrigin: "anonymous" });
173
- img.scaleToWidth(width);
174
- if (img.getScaledHeight() < height) img.scaleToHeight(height);
175
- img.set({
176
- originX: "left",
177
- originY: "top",
178
- left: 0,
179
- top: 0,
180
- opacity,
181
- selectable: false,
182
- evented: false,
183
- data: { id: "film-image" },
184
- });
185
- layer.add(img);
230
+ const image = await FabricImage.fromURL(src, {
231
+ crossOrigin: "anonymous",
232
+ });
233
+ const width = Number(image?.width || 0);
234
+ const height = Number(image?.height || 0);
235
+ if (width > 0 && height > 0) {
236
+ const size = { width, height };
237
+ this.sourceSizeBySrc.set(src, size);
238
+ return size;
186
239
  }
187
- this.canvasService.requestRenderAll();
188
240
  } catch (error) {
189
- console.error("[FilmTool] Failed to load film image", url, error);
241
+ console.error("[FilmTool] Failed to load film image", src, error);
190
242
  }
191
- layer.dirty = true;
243
+ return null;
244
+ }
245
+
246
+ private updateFilm() {
247
+ void this.updateFilmAsync();
248
+ }
249
+
250
+ private async updateFilmAsync() {
251
+ if (!this.canvasService) return;
252
+ const seq = ++this.renderSeq;
253
+ const nextUrl = String(this.url || "").trim();
254
+
255
+ if (!nextUrl) {
256
+ this.renderImageUrl = "";
257
+ } else if (nextUrl !== this.renderImageUrl) {
258
+ const loaded = await this.ensureImageSize(nextUrl);
259
+ if (seq !== this.renderSeq) return;
260
+ if (loaded) {
261
+ this.renderImageUrl = nextUrl;
262
+ }
263
+ }
264
+
265
+ this.specs = this.buildFilmSpecs(this.renderImageUrl, this.opacity);
266
+ await this.canvasService.flushRenderFromProducers();
267
+ if (seq !== this.renderSeq) return;
192
268
  this.canvasService.requestRenderAll();
193
269
  }
194
270
  }
@@ -1,5 +1,16 @@
1
1
  import paper from "paper";
2
2
  import { pickExitIndex, scoreOutsideAbove } from "./bridgeSelection";
3
+ import {
4
+ DEFAULT_DIELINE_SHAPE,
5
+ getHeartShapeParams,
6
+ getShapeFitMode,
7
+ } from "./dielineShape";
8
+ import type {
9
+ BuiltinDielineShape,
10
+ DielineShape,
11
+ DielineShapeStyle,
12
+ ShapeFitMode,
13
+ } from "./dielineShape";
3
14
  import { sampleWrappedOffsets, wrappedDistance } from "./wrappedOffsets";
4
15
 
5
16
  export type FeatureOperation = "add" | "subtract";
@@ -27,7 +38,7 @@ export interface DielineFeature {
27
38
  }
28
39
 
29
40
  export interface GeometryOptions {
30
- shape: "rect" | "circle" | "ellipse" | "custom";
41
+ shape: DielineShape;
31
42
  width: number;
32
43
  height: number;
33
44
  radius: number;
@@ -35,17 +46,13 @@ export interface GeometryOptions {
35
46
  y: number;
36
47
  features: Array<DielineFeature>;
37
48
  pathData?: string;
49
+ shapeStyle?: DielineShapeStyle;
38
50
  customSourceWidthPx?: number;
39
51
  customSourceHeightPx?: number;
40
52
  canvasWidth?: number;
41
53
  canvasHeight?: number;
42
54
  }
43
55
 
44
- export interface MaskGeometryOptions extends GeometryOptions {
45
- canvasWidth: number;
46
- canvasHeight: number;
47
- }
48
-
49
56
  /**
50
57
  * Resolves the absolute position of a feature based on normalized coordinates.
51
58
  */
@@ -190,86 +197,207 @@ function selectOuterChain(args: {
190
197
  }
191
198
 
192
199
  /**
193
- * Creates the base dieline shape (Rect/Circle/Ellipse/Custom)
200
+ * Creates the base dieline shape (Rect/Circle/Ellipse/Heart/Custom).
194
201
  */
195
- function createBaseShape(options: GeometryOptions): paper.PathItem {
202
+ type BuiltinShapeBuilder = (options: GeometryOptions) => paper.PathItem;
203
+
204
+ function fitPathItemToRect(
205
+ item: paper.PathItem,
206
+ rect: { left: number; top: number; width: number; height: number },
207
+ fitMode: ShapeFitMode,
208
+ ): paper.PathItem {
209
+ const { left, top, width, height } = rect;
210
+ const bounds = item.bounds;
211
+ if (
212
+ width <= 0 ||
213
+ height <= 0 ||
214
+ !Number.isFinite(bounds.width) ||
215
+ !Number.isFinite(bounds.height) ||
216
+ bounds.width <= 0 ||
217
+ bounds.height <= 0
218
+ ) {
219
+ item.position = new paper.Point(left + width / 2, top + height / 2);
220
+ return item;
221
+ }
222
+
223
+ item.translate(new paper.Point(-bounds.left, -bounds.top));
224
+ if (fitMode === "stretch") {
225
+ item.scale(width / bounds.width, height / bounds.height, new paper.Point(0, 0));
226
+ item.translate(new paper.Point(left, top));
227
+ return item;
228
+ }
229
+
230
+ const uniformScale = Math.min(width / bounds.width, height / bounds.height);
231
+ item.scale(uniformScale, uniformScale, new paper.Point(0, 0));
232
+ const scaledWidth = bounds.width * uniformScale;
233
+ const scaledHeight = bounds.height * uniformScale;
234
+ item.translate(
235
+ new paper.Point(
236
+ left + (width - scaledWidth) / 2,
237
+ top + (height - scaledHeight) / 2,
238
+ ),
239
+ );
240
+ return item;
241
+ }
242
+
243
+ function createNormalizedHeartPath(params: {
244
+ lobeSpread: number;
245
+ notchDepth: number;
246
+ tipSharpness: number;
247
+ }): paper.Path {
248
+ const { lobeSpread, notchDepth, tipSharpness } = params;
249
+
250
+ const halfSpread = 0.22 + lobeSpread * 0.18;
251
+ const notchY = 0.06 + notchDepth * 0.2;
252
+ const shoulderY = 0.24 + notchDepth * 0.2;
253
+ const topLift = 0.12 + (1 - notchDepth) * 0.06;
254
+ const topY = notchY - topLift;
255
+ const sideCtrlY = shoulderY - (0.18 - notchDepth * 0.08);
256
+ const lowerCtrlY = 0.58 + (1 - tipSharpness) * 0.16;
257
+ const tipCtrlX = 0.34 - tipSharpness * 0.2;
258
+ const notchCtrlX = 0.06 + lobeSpread * 0.06;
259
+ const lobeCtrlX = 0.1 + lobeSpread * 0.08;
260
+ const notchCtrlY = notchY - topLift * 0.45;
261
+
262
+ const xPeakL = 0.5 - halfSpread;
263
+ const xPeakR = 0.5 + halfSpread;
264
+
265
+ const heartPath = new paper.Path({ insert: false });
266
+ heartPath.moveTo(new paper.Point(0.5, notchY));
267
+ heartPath.cubicCurveTo(
268
+ new paper.Point(0.5 - notchCtrlX, notchCtrlY),
269
+ new paper.Point(xPeakL + lobeCtrlX, topY),
270
+ new paper.Point(xPeakL, topY),
271
+ );
272
+ heartPath.cubicCurveTo(
273
+ new paper.Point(xPeakL - lobeCtrlX, topY),
274
+ new paper.Point(0, sideCtrlY),
275
+ new paper.Point(0, shoulderY),
276
+ );
277
+ heartPath.cubicCurveTo(
278
+ new paper.Point(0, lowerCtrlY),
279
+ new paper.Point(tipCtrlX, 1),
280
+ new paper.Point(0.5, 1),
281
+ );
282
+ heartPath.cubicCurveTo(
283
+ new paper.Point(1 - tipCtrlX, 1),
284
+ new paper.Point(1, lowerCtrlY),
285
+ new paper.Point(1, shoulderY),
286
+ );
287
+ heartPath.cubicCurveTo(
288
+ new paper.Point(1, sideCtrlY),
289
+ new paper.Point(xPeakR + lobeCtrlX, topY),
290
+ new paper.Point(xPeakR, topY),
291
+ );
292
+ heartPath.cubicCurveTo(
293
+ new paper.Point(xPeakR - lobeCtrlX, topY),
294
+ new paper.Point(0.5 + notchCtrlX, notchCtrlY),
295
+ new paper.Point(0.5, notchY),
296
+ );
297
+ heartPath.closed = true;
298
+ return heartPath;
299
+ }
300
+
301
+ function createHeartBaseShape(options: GeometryOptions): paper.PathItem {
302
+ const { x, y, width, height } = options;
303
+ const w = Math.max(0, width);
304
+ const h = Math.max(0, height);
305
+ const left = x - w / 2;
306
+ const top = y - h / 2;
307
+ const fitMode = getShapeFitMode(options.shapeStyle);
308
+ const heartParams = getHeartShapeParams(options.shapeStyle);
309
+ const rawHeart = createNormalizedHeartPath(heartParams);
310
+ return fitPathItemToRect(rawHeart, { left, top, width: w, height: h }, fitMode);
311
+ }
312
+
313
+ const BUILTIN_SHAPE_BUILDERS: Record<BuiltinDielineShape, BuiltinShapeBuilder> =
314
+ {
315
+ rect: (options) => {
316
+ const { x, y, width, height, radius } = options;
317
+ return new paper.Path.Rectangle({
318
+ point: [x - width / 2, y - height / 2],
319
+ size: [Math.max(0, width), Math.max(0, height)],
320
+ radius: Math.max(0, radius),
321
+ });
322
+ },
323
+ circle: (options) => {
324
+ const { x, y, width, height } = options;
325
+ const r = Math.min(width, height) / 2;
326
+ return new paper.Path.Circle({
327
+ center: new paper.Point(x, y),
328
+ radius: Math.max(0, r),
329
+ });
330
+ },
331
+ ellipse: (options) => {
332
+ const { x, y, width, height } = options;
333
+ return new paper.Path.Ellipse({
334
+ center: new paper.Point(x, y),
335
+ radius: [Math.max(0, width / 2), Math.max(0, height / 2)],
336
+ });
337
+ },
338
+ heart: createHeartBaseShape,
339
+ };
340
+
341
+ function createCustomBaseShape(options: GeometryOptions): paper.PathItem | null {
196
342
  const {
197
- shape,
198
- width,
199
- height,
200
- radius,
201
- x,
202
- y,
203
343
  pathData,
204
344
  customSourceWidthPx,
205
345
  customSourceHeightPx,
346
+ x,
347
+ y,
348
+ width,
349
+ height,
206
350
  } = options;
207
- const center = new paper.Point(x, y);
351
+ if (typeof pathData !== "string" || pathData.trim().length === 0) {
352
+ return null;
353
+ }
208
354
 
209
- if (shape === "rect") {
210
- return new paper.Path.Rectangle({
211
- point: [x - width / 2, y - height / 2],
212
- size: [Math.max(0, width), Math.max(0, height)],
213
- radius: Math.max(0, radius),
214
- });
215
- } else if (shape === "circle") {
216
- const r = Math.min(width, height) / 2;
217
- return new paper.Path.Circle({
218
- center: center,
219
- radius: Math.max(0, r),
220
- });
221
- } else if (shape === "ellipse") {
222
- return new paper.Path.Ellipse({
223
- center: center,
224
- radius: [Math.max(0, width / 2), Math.max(0, height / 2)],
225
- });
226
- } else if (shape === "custom" && pathData) {
227
- const hasMultipleSubPaths = ((pathData.match(/[Mm]/g) || []).length ?? 0) > 1;
228
- const path: paper.PathItem = hasMultipleSubPaths
229
- ? new paper.CompoundPath(pathData)
230
- : (() => {
231
- const single = new paper.Path();
232
- single.pathData = pathData;
233
- return single;
234
- })();
235
- const sourceWidth = Number(customSourceWidthPx ?? 0);
236
- const sourceHeight = Number(customSourceHeightPx ?? 0);
237
- if (
238
- Number.isFinite(sourceWidth) &&
239
- Number.isFinite(sourceHeight) &&
240
- sourceWidth > 0 &&
241
- sourceHeight > 0 &&
242
- width > 0 &&
243
- height > 0
244
- ) {
245
- // Preserve original detect-space offset/expand by mapping source image
246
- // coordinates directly into the target dieline frame.
247
- const targetLeft = x - width / 2;
248
- const targetTop = y - height / 2;
249
- path.scale(width / sourceWidth, height / sourceHeight, new paper.Point(0, 0));
250
- path.translate(new paper.Point(targetLeft, targetTop));
251
- return path;
252
- }
355
+ const center = new paper.Point(x, y);
356
+ const hasMultipleSubPaths = ((pathData.match(/[Mm]/g) || []).length ?? 0) > 1;
357
+ const path: paper.PathItem = hasMultipleSubPaths
358
+ ? new paper.CompoundPath(pathData)
359
+ : (() => {
360
+ const single = new paper.Path();
361
+ single.pathData = pathData;
362
+ return single;
363
+ })();
364
+ const sourceWidth = Number(customSourceWidthPx ?? 0);
365
+ const sourceHeight = Number(customSourceHeightPx ?? 0);
366
+ if (
367
+ Number.isFinite(sourceWidth) &&
368
+ Number.isFinite(sourceHeight) &&
369
+ sourceWidth > 0 &&
370
+ sourceHeight > 0 &&
371
+ width > 0 &&
372
+ height > 0
373
+ ) {
374
+ // Preserve original detect-space offset/expand by mapping source image
375
+ // coordinates directly into the target dieline frame.
376
+ const targetLeft = x - width / 2;
377
+ const targetTop = y - height / 2;
378
+ path.scale(width / sourceWidth, height / sourceHeight, new paper.Point(0, 0));
379
+ path.translate(new paper.Point(targetLeft, targetTop));
380
+ return path;
381
+ }
253
382
 
254
- if (
255
- width > 0 &&
256
- height > 0 &&
257
- path.bounds.width > 0 &&
258
- path.bounds.height > 0
259
- ) {
260
- // Fallback for malformed custom-path metadata.
261
- path.position = center;
262
- path.scale(width / path.bounds.width, height / path.bounds.height);
263
- return path;
264
- }
383
+ if (width > 0 && height > 0 && path.bounds.width > 0 && path.bounds.height > 0) {
384
+ // Fallback for malformed custom-path metadata.
265
385
  path.position = center;
386
+ path.scale(width / path.bounds.width, height / path.bounds.height);
266
387
  return path;
267
- } else {
268
- return new paper.Path.Rectangle({
269
- point: [x - width / 2, y - height / 2],
270
- size: [Math.max(0, width), Math.max(0, height)],
271
- });
272
388
  }
389
+ path.position = center;
390
+ return path;
391
+ }
392
+
393
+ function createBaseShape(options: GeometryOptions): paper.PathItem {
394
+ const { shape } = options;
395
+ if (shape === "custom") {
396
+ const customShape = createCustomBaseShape(options);
397
+ if (customShape) return customShape;
398
+ return BUILTIN_SHAPE_BUILDERS[DEFAULT_DIELINE_SHAPE](options);
399
+ }
400
+ return BUILTIN_SHAPE_BUILDERS[shape](options);
273
401
  }
274
402
 
275
403
  function resolveBridgeBasePath(
@@ -607,35 +735,6 @@ export function generateDielinePath(options: GeometryOptions): string {
607
735
  return pathData;
608
736
  }
609
737
 
610
- /**
611
- * Generates the path data for the Mask (Background Overlay).
612
- * Logic: Canvas SUBTRACT ProductShape
613
- */
614
- export function generateMaskPath(options: MaskGeometryOptions): string {
615
- ensurePaper(options.canvasWidth, options.canvasHeight);
616
- paper.project.activeLayer.removeChildren();
617
-
618
- const { canvasWidth, canvasHeight } = options;
619
-
620
- const maskRect = new paper.Path.Rectangle({
621
- point: [0, 0],
622
- size: [canvasWidth, canvasHeight],
623
- });
624
-
625
- const perimeter = getPerimeterShape(options);
626
- const mainShape = applySurfaceFeatures(perimeter, options.features, options);
627
-
628
- const finalMask = maskRect.subtract(mainShape);
629
-
630
- maskRect.remove();
631
- mainShape.remove();
632
-
633
- const pathData = finalMask.pathData;
634
- finalMask.remove();
635
-
636
- return pathData;
637
- }
638
-
639
738
  /**
640
739
  * Generates the path data for the Bleed Zone.
641
740
  */