@pooder/kit 5.3.0 → 5.4.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 (73) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.d.mts +249 -36
  3. package/dist/index.d.ts +249 -36
  4. package/dist/index.js +2374 -1049
  5. package/dist/index.mjs +2375 -1050
  6. package/package.json +1 -1
  7. package/src/extensions/background.ts +178 -85
  8. package/src/extensions/dieline.ts +1149 -1030
  9. package/src/extensions/dielineShape.ts +109 -0
  10. package/src/extensions/feature.ts +482 -366
  11. package/src/extensions/film.ts +148 -76
  12. package/src/extensions/geometry.ts +210 -44
  13. package/src/extensions/image.ts +244 -114
  14. package/src/extensions/ruler.ts +471 -268
  15. package/src/extensions/sceneLayoutModel.ts +28 -6
  16. package/src/extensions/sceneVisibility.ts +3 -10
  17. package/src/extensions/tracer.ts +1019 -980
  18. package/src/extensions/white-ink.ts +284 -231
  19. package/src/services/CanvasService.ts +543 -11
  20. package/src/services/renderSpec.ts +37 -2
  21. package/.test-dist/src/CanvasService.js +0 -249
  22. package/.test-dist/src/ViewportSystem.js +0 -75
  23. package/.test-dist/src/background.js +0 -203
  24. package/.test-dist/src/bridgeSelection.js +0 -20
  25. package/.test-dist/src/constraints.js +0 -237
  26. package/.test-dist/src/coordinate.js +0 -74
  27. package/.test-dist/src/dieline.js +0 -818
  28. package/.test-dist/src/edgeScale.js +0 -12
  29. package/.test-dist/src/extensions/background.js +0 -203
  30. package/.test-dist/src/extensions/bridgeSelection.js +0 -20
  31. package/.test-dist/src/extensions/constraints.js +0 -237
  32. package/.test-dist/src/extensions/dieline.js +0 -828
  33. package/.test-dist/src/extensions/edgeScale.js +0 -12
  34. package/.test-dist/src/extensions/feature.js +0 -825
  35. package/.test-dist/src/extensions/featureComplete.js +0 -32
  36. package/.test-dist/src/extensions/film.js +0 -167
  37. package/.test-dist/src/extensions/geometry.js +0 -545
  38. package/.test-dist/src/extensions/image.js +0 -1529
  39. package/.test-dist/src/extensions/index.js +0 -30
  40. package/.test-dist/src/extensions/maskOps.js +0 -279
  41. package/.test-dist/src/extensions/mirror.js +0 -104
  42. package/.test-dist/src/extensions/ruler.js +0 -345
  43. package/.test-dist/src/extensions/sceneLayout.js +0 -96
  44. package/.test-dist/src/extensions/sceneLayoutModel.js +0 -196
  45. package/.test-dist/src/extensions/sceneVisibility.js +0 -62
  46. package/.test-dist/src/extensions/size.js +0 -331
  47. package/.test-dist/src/extensions/tracer.js +0 -538
  48. package/.test-dist/src/extensions/white-ink.js +0 -1190
  49. package/.test-dist/src/extensions/wrappedOffsets.js +0 -33
  50. package/.test-dist/src/feature.js +0 -826
  51. package/.test-dist/src/featureComplete.js +0 -32
  52. package/.test-dist/src/film.js +0 -167
  53. package/.test-dist/src/geometry.js +0 -506
  54. package/.test-dist/src/image.js +0 -1250
  55. package/.test-dist/src/index.js +0 -18
  56. package/.test-dist/src/maskOps.js +0 -270
  57. package/.test-dist/src/mirror.js +0 -104
  58. package/.test-dist/src/renderSpec.js +0 -2
  59. package/.test-dist/src/ruler.js +0 -343
  60. package/.test-dist/src/sceneLayout.js +0 -99
  61. package/.test-dist/src/sceneLayoutModel.js +0 -196
  62. package/.test-dist/src/sceneView.js +0 -40
  63. package/.test-dist/src/sceneVisibility.js +0 -42
  64. package/.test-dist/src/services/CanvasService.js +0 -249
  65. package/.test-dist/src/services/ViewportSystem.js +0 -76
  66. package/.test-dist/src/services/index.js +0 -24
  67. package/.test-dist/src/services/renderSpec.js +0 -2
  68. package/.test-dist/src/size.js +0 -332
  69. package/.test-dist/src/tracer.js +0 -544
  70. package/.test-dist/src/units.js +0 -30
  71. package/.test-dist/src/white-ink.js +0 -829
  72. package/.test-dist/src/wrappedOffsets.js +0 -33
  73. package/.test-dist/tests/run.js +0 -94
@@ -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,20 @@ 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
+ layerSpecs: {
66
+ [FILM_LAYER_ID]: this.specs,
67
+ },
68
+ }),
69
+ { priority: 500 },
70
+ );
71
+
72
+ const configService = context.services.get<ConfigurationService>(
73
+ "ConfigurationService",
74
+ );
42
75
  if (configService) {
43
76
  // Load initial config
44
77
  this.url = configService.get("film.url", this.url);
@@ -59,22 +92,21 @@ export class FilmTool implements Extension {
59
92
  });
60
93
  }
61
94
 
62
- this.initLayer();
95
+ context.eventBus.on("canvas:resized", this.onCanvasResized);
63
96
  this.updateFilm();
64
97
  }
65
98
 
66
99
  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
- }
100
+ context.eventBus.off("canvas:resized", this.onCanvasResized);
101
+ this.renderSeq += 1;
102
+ this.specs = [];
103
+ this.renderImageUrl = "";
104
+ this.renderProducerDisposable?.dispose();
105
+ this.renderProducerDisposable = undefined;
106
+ if (!this.canvasService) return;
107
+ void this.canvasService.flushRenderFromProducers();
108
+ this.canvasService.requestRenderAll();
109
+ this.canvasService = undefined;
78
110
  }
79
111
 
80
112
  contribute() {
@@ -115,80 +147,120 @@ export class FilmTool implements Extension {
115
147
  };
116
148
  }
117
149
 
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
- });
150
+ private getViewportSize(): { width: number; height: number } {
151
+ const width = Number(this.canvasService?.canvas.width || 0);
152
+ const height = Number(this.canvasService?.canvas.height || 0);
153
+ return {
154
+ width: width > 0 ? width : DEFAULT_WIDTH,
155
+ height: height > 0 ? height : DEFAULT_HEIGHT,
156
+ };
157
+ }
137
158
 
138
- this.canvasService.canvas.bringObjectToFront(layer);
139
- }
159
+ private clampOpacity(value: number): number {
160
+ return Math.max(0, Math.min(1, Number(value)));
140
161
  }
141
162
 
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;
163
+ private buildFilmSpecs(
164
+ imageUrl: string,
165
+ opacity: number,
166
+ ): RenderObjectSpec[] {
167
+ if (!imageUrl) {
168
+ return [];
148
169
  }
170
+ const { width, height } = this.getViewportSize();
171
+ const sourceSize = this.sourceSizeBySrc.get(imageUrl);
172
+ const sourceWidth = Math.max(1, Number(sourceSize?.width || width));
173
+ const sourceHeight = Math.max(1, Number(sourceSize?.height || height));
174
+ const coverScale = Math.max(width / sourceWidth, height / sourceHeight);
175
+ return [
176
+ {
177
+ id: FILM_IMAGE_ID,
178
+ type: "image",
179
+ src: imageUrl,
180
+ space: "screen",
181
+ data: {
182
+ id: FILM_IMAGE_ID,
183
+ layerId: FILM_LAYER_ID,
184
+ type: "film-image",
185
+ },
186
+ props: {
187
+ left: 0,
188
+ top: 0,
189
+ originX: "left",
190
+ originY: "top",
191
+ opacity: this.clampOpacity(opacity),
192
+ scaleX: coverScale,
193
+ scaleY: coverScale,
194
+ selectable: false,
195
+ evented: false,
196
+ excludeFromExport: true,
197
+ },
198
+ },
199
+ ];
200
+ }
149
201
 
150
- const { url, opacity } = this;
202
+ private async ensureImageSize(src: string): Promise<SourceSize | null> {
203
+ if (!src) return null;
204
+ const cached = this.sourceSizeBySrc.get(src);
205
+ if (cached) return cached;
151
206
 
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;
207
+ const pending = this.pendingSizeBySrc.get(src);
208
+ if (pending) {
209
+ return pending;
159
210
  }
160
211
 
161
- const width = this.canvasService.canvas.width || 800;
162
- const height = this.canvasService.canvas.height || 600;
212
+ const task = this.loadImageSize(src);
213
+ this.pendingSizeBySrc.set(src, task);
214
+ try {
215
+ return await task;
216
+ } finally {
217
+ if (this.pendingSizeBySrc.get(src) === task) {
218
+ this.pendingSizeBySrc.delete(src);
219
+ }
220
+ }
221
+ }
163
222
 
164
- let img = this.canvasService.getObject("film-image", "overlay") as Image;
223
+ private async loadImageSize(src: string): Promise<SourceSize | null> {
165
224
  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);
225
+ const image = await FabricImage.fromURL(src, {
226
+ crossOrigin: "anonymous",
227
+ });
228
+ const width = Number(image?.width || 0);
229
+ const height = Number(image?.height || 0);
230
+ if (width > 0 && height > 0) {
231
+ const size = { width, height };
232
+ this.sourceSizeBySrc.set(src, size);
233
+ return size;
186
234
  }
187
- this.canvasService.requestRenderAll();
188
235
  } catch (error) {
189
- console.error("[FilmTool] Failed to load film image", url, error);
236
+ console.error("[FilmTool] Failed to load film image", src, error);
190
237
  }
191
- layer.dirty = true;
238
+ return null;
239
+ }
240
+
241
+ private updateFilm() {
242
+ void this.updateFilmAsync();
243
+ }
244
+
245
+ private async updateFilmAsync() {
246
+ if (!this.canvasService) return;
247
+ const seq = ++this.renderSeq;
248
+ const nextUrl = String(this.url || "").trim();
249
+
250
+ if (!nextUrl) {
251
+ this.renderImageUrl = "";
252
+ } else if (nextUrl !== this.renderImageUrl) {
253
+ const loaded = await this.ensureImageSize(nextUrl);
254
+ if (seq !== this.renderSeq) return;
255
+ if (loaded) {
256
+ this.renderImageUrl = nextUrl;
257
+ }
258
+ }
259
+
260
+ this.specs = this.buildFilmSpecs(this.renderImageUrl, this.opacity);
261
+ await this.canvasService.flushRenderFromProducers();
262
+ if (seq !== this.renderSeq) return;
263
+ this.canvasService.bringLayerToFront(FILM_LAYER_ID);
192
264
  this.canvasService.requestRenderAll();
193
265
  }
194
266
  }
@@ -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,6 +46,9 @@ export interface GeometryOptions {
35
46
  y: number;
36
47
  features: Array<DielineFeature>;
37
48
  pathData?: string;
49
+ shapeStyle?: DielineShapeStyle;
50
+ customSourceWidthPx?: number;
51
+ customSourceHeightPx?: number;
38
52
  canvasWidth?: number;
39
53
  canvasHeight?: number;
40
54
  }
@@ -188,55 +202,207 @@ function selectOuterChain(args: {
188
202
  }
189
203
 
190
204
  /**
191
- * Creates the base dieline shape (Rect/Circle/Ellipse/Custom)
205
+ * Creates the base dieline shape (Rect/Circle/Ellipse/Heart/Custom).
192
206
  */
193
- function createBaseShape(options: GeometryOptions): paper.PathItem {
194
- const { shape, width, height, radius, x, y, pathData } = options;
207
+ type BuiltinShapeBuilder = (options: GeometryOptions) => paper.PathItem;
208
+
209
+ function fitPathItemToRect(
210
+ item: paper.PathItem,
211
+ rect: { left: number; top: number; width: number; height: number },
212
+ fitMode: ShapeFitMode,
213
+ ): paper.PathItem {
214
+ const { left, top, width, height } = rect;
215
+ const bounds = item.bounds;
216
+ if (
217
+ width <= 0 ||
218
+ height <= 0 ||
219
+ !Number.isFinite(bounds.width) ||
220
+ !Number.isFinite(bounds.height) ||
221
+ bounds.width <= 0 ||
222
+ bounds.height <= 0
223
+ ) {
224
+ item.position = new paper.Point(left + width / 2, top + height / 2);
225
+ return item;
226
+ }
227
+
228
+ item.translate(new paper.Point(-bounds.left, -bounds.top));
229
+ if (fitMode === "stretch") {
230
+ item.scale(width / bounds.width, height / bounds.height, new paper.Point(0, 0));
231
+ item.translate(new paper.Point(left, top));
232
+ return item;
233
+ }
234
+
235
+ const uniformScale = Math.min(width / bounds.width, height / bounds.height);
236
+ item.scale(uniformScale, uniformScale, new paper.Point(0, 0));
237
+ const scaledWidth = bounds.width * uniformScale;
238
+ const scaledHeight = bounds.height * uniformScale;
239
+ item.translate(
240
+ new paper.Point(
241
+ left + (width - scaledWidth) / 2,
242
+ top + (height - scaledHeight) / 2,
243
+ ),
244
+ );
245
+ return item;
246
+ }
247
+
248
+ function createNormalizedHeartPath(params: {
249
+ lobeSpread: number;
250
+ notchDepth: number;
251
+ tipSharpness: number;
252
+ }): paper.Path {
253
+ const { lobeSpread, notchDepth, tipSharpness } = params;
254
+
255
+ const halfSpread = 0.22 + lobeSpread * 0.18;
256
+ const notchY = 0.06 + notchDepth * 0.2;
257
+ const shoulderY = 0.24 + notchDepth * 0.2;
258
+ const topLift = 0.12 + (1 - notchDepth) * 0.06;
259
+ const topY = notchY - topLift;
260
+ const sideCtrlY = shoulderY - (0.18 - notchDepth * 0.08);
261
+ const lowerCtrlY = 0.58 + (1 - tipSharpness) * 0.16;
262
+ const tipCtrlX = 0.34 - tipSharpness * 0.2;
263
+ const notchCtrlX = 0.06 + lobeSpread * 0.06;
264
+ const lobeCtrlX = 0.1 + lobeSpread * 0.08;
265
+ const notchCtrlY = notchY - topLift * 0.45;
266
+
267
+ const xPeakL = 0.5 - halfSpread;
268
+ const xPeakR = 0.5 + halfSpread;
269
+
270
+ const heartPath = new paper.Path({ insert: false });
271
+ heartPath.moveTo(new paper.Point(0.5, notchY));
272
+ heartPath.cubicCurveTo(
273
+ new paper.Point(0.5 - notchCtrlX, notchCtrlY),
274
+ new paper.Point(xPeakL + lobeCtrlX, topY),
275
+ new paper.Point(xPeakL, topY),
276
+ );
277
+ heartPath.cubicCurveTo(
278
+ new paper.Point(xPeakL - lobeCtrlX, topY),
279
+ new paper.Point(0, sideCtrlY),
280
+ new paper.Point(0, shoulderY),
281
+ );
282
+ heartPath.cubicCurveTo(
283
+ new paper.Point(0, lowerCtrlY),
284
+ new paper.Point(tipCtrlX, 1),
285
+ new paper.Point(0.5, 1),
286
+ );
287
+ heartPath.cubicCurveTo(
288
+ new paper.Point(1 - tipCtrlX, 1),
289
+ new paper.Point(1, lowerCtrlY),
290
+ new paper.Point(1, shoulderY),
291
+ );
292
+ heartPath.cubicCurveTo(
293
+ new paper.Point(1, sideCtrlY),
294
+ new paper.Point(xPeakR + lobeCtrlX, topY),
295
+ new paper.Point(xPeakR, topY),
296
+ );
297
+ heartPath.cubicCurveTo(
298
+ new paper.Point(xPeakR - lobeCtrlX, topY),
299
+ new paper.Point(0.5 + notchCtrlX, notchCtrlY),
300
+ new paper.Point(0.5, notchY),
301
+ );
302
+ heartPath.closed = true;
303
+ return heartPath;
304
+ }
305
+
306
+ function createHeartBaseShape(options: GeometryOptions): paper.PathItem {
307
+ const { x, y, width, height } = options;
308
+ const w = Math.max(0, width);
309
+ const h = Math.max(0, height);
310
+ const left = x - w / 2;
311
+ const top = y - h / 2;
312
+ const fitMode = getShapeFitMode(options.shapeStyle);
313
+ const heartParams = getHeartShapeParams(options.shapeStyle);
314
+ const rawHeart = createNormalizedHeartPath(heartParams);
315
+ return fitPathItemToRect(rawHeart, { left, top, width: w, height: h }, fitMode);
316
+ }
317
+
318
+ const BUILTIN_SHAPE_BUILDERS: Record<BuiltinDielineShape, BuiltinShapeBuilder> =
319
+ {
320
+ rect: (options) => {
321
+ const { x, y, width, height, radius } = options;
322
+ return new paper.Path.Rectangle({
323
+ point: [x - width / 2, y - height / 2],
324
+ size: [Math.max(0, width), Math.max(0, height)],
325
+ radius: Math.max(0, radius),
326
+ });
327
+ },
328
+ circle: (options) => {
329
+ const { x, y, width, height } = options;
330
+ const r = Math.min(width, height) / 2;
331
+ return new paper.Path.Circle({
332
+ center: new paper.Point(x, y),
333
+ radius: Math.max(0, r),
334
+ });
335
+ },
336
+ ellipse: (options) => {
337
+ const { x, y, width, height } = options;
338
+ return new paper.Path.Ellipse({
339
+ center: new paper.Point(x, y),
340
+ radius: [Math.max(0, width / 2), Math.max(0, height / 2)],
341
+ });
342
+ },
343
+ heart: createHeartBaseShape,
344
+ };
345
+
346
+ function createCustomBaseShape(options: GeometryOptions): paper.PathItem | null {
347
+ const {
348
+ pathData,
349
+ customSourceWidthPx,
350
+ customSourceHeightPx,
351
+ x,
352
+ y,
353
+ width,
354
+ height,
355
+ } = options;
356
+ if (typeof pathData !== "string" || pathData.trim().length === 0) {
357
+ return null;
358
+ }
359
+
195
360
  const center = new paper.Point(x, y);
361
+ const hasMultipleSubPaths = ((pathData.match(/[Mm]/g) || []).length ?? 0) > 1;
362
+ const path: paper.PathItem = hasMultipleSubPaths
363
+ ? new paper.CompoundPath(pathData)
364
+ : (() => {
365
+ const single = new paper.Path();
366
+ single.pathData = pathData;
367
+ return single;
368
+ })();
369
+ const sourceWidth = Number(customSourceWidthPx ?? 0);
370
+ const sourceHeight = Number(customSourceHeightPx ?? 0);
371
+ if (
372
+ Number.isFinite(sourceWidth) &&
373
+ Number.isFinite(sourceHeight) &&
374
+ sourceWidth > 0 &&
375
+ sourceHeight > 0 &&
376
+ width > 0 &&
377
+ height > 0
378
+ ) {
379
+ // Preserve original detect-space offset/expand by mapping source image
380
+ // coordinates directly into the target dieline frame.
381
+ const targetLeft = x - width / 2;
382
+ const targetTop = y - height / 2;
383
+ path.scale(width / sourceWidth, height / sourceHeight, new paper.Point(0, 0));
384
+ path.translate(new paper.Point(targetLeft, targetTop));
385
+ return path;
386
+ }
196
387
 
197
- if (shape === "rect") {
198
- return new paper.Path.Rectangle({
199
- point: [x - width / 2, y - height / 2],
200
- size: [Math.max(0, width), Math.max(0, height)],
201
- radius: Math.max(0, radius),
202
- });
203
- } else if (shape === "circle") {
204
- const r = Math.min(width, height) / 2;
205
- return new paper.Path.Circle({
206
- center: center,
207
- radius: Math.max(0, r),
208
- });
209
- } else if (shape === "ellipse") {
210
- return new paper.Path.Ellipse({
211
- center: center,
212
- radius: [Math.max(0, width / 2), Math.max(0, height / 2)],
213
- });
214
- } else if (shape === "custom" && pathData) {
215
- const hasMultipleSubPaths = ((pathData.match(/[Mm]/g) || []).length ?? 0) > 1;
216
- const path: paper.PathItem = hasMultipleSubPaths
217
- ? new paper.CompoundPath(pathData)
218
- : (() => {
219
- const single = new paper.Path();
220
- single.pathData = pathData;
221
- return single;
222
- })();
223
- // Align center
388
+ if (width > 0 && height > 0 && path.bounds.width > 0 && path.bounds.height > 0) {
389
+ // Fallback for malformed custom-path metadata.
224
390
  path.position = center;
225
- if (
226
- width > 0 &&
227
- height > 0 &&
228
- path.bounds.width > 0 &&
229
- path.bounds.height > 0
230
- ) {
231
- path.scale(width / path.bounds.width, height / path.bounds.height);
232
- }
391
+ path.scale(width / path.bounds.width, height / path.bounds.height);
233
392
  return path;
234
- } else {
235
- return new paper.Path.Rectangle({
236
- point: [x - width / 2, y - height / 2],
237
- size: [Math.max(0, width), Math.max(0, height)],
238
- });
239
393
  }
394
+ path.position = center;
395
+ return path;
396
+ }
397
+
398
+ function createBaseShape(options: GeometryOptions): paper.PathItem {
399
+ const { shape } = options;
400
+ if (shape === "custom") {
401
+ const customShape = createCustomBaseShape(options);
402
+ if (customShape) return customShape;
403
+ return BUILTIN_SHAPE_BUILDERS[DEFAULT_DIELINE_SHAPE](options);
404
+ }
405
+ return BUILTIN_SHAPE_BUILDERS[shape](options);
240
406
  }
241
407
 
242
408
  function resolveBridgeBasePath(