@pooder/kit 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/geometry.ts CHANGED
@@ -1,98 +1,22 @@
1
1
  import paper from "paper";
2
2
 
3
- export type PositionAnchor =
4
- | "top-left"
5
- | "top-center"
6
- | "top-right"
7
- | "center-left"
8
- | "center"
9
- | "center-right"
10
- | "bottom-left"
11
- | "bottom-center"
12
- | "bottom-right";
13
-
14
- export interface HoleData {
15
- x?: number;
16
- y?: number;
17
- shape?: "circle" | "square";
18
- anchor?: PositionAnchor;
19
- offsetX?: number;
20
- offsetY?: number;
21
- innerRadius: number;
22
- outerRadius: number;
23
- }
24
-
25
- export function resolveHolePosition(
26
- hole: HoleData,
27
- geometry: { x: number; y: number; width: number; height: number },
28
- canvasSize: { width: number; height: number },
29
- ): { x: number; y: number } {
30
- if (hole.anchor) {
31
- const { x, y, width, height } = geometry;
32
- let bx = x; // center x
33
- let by = y; // center y
34
-
35
- // Calculate anchor base position based on shape bounds
36
- // Note: geometry.x/y is the CENTER of the shape
37
- const left = x - width / 2;
38
- const right = x + width / 2;
39
- const top = y - height / 2;
40
- const bottom = y + height / 2;
41
-
42
- switch (hole.anchor) {
43
- case "top-left":
44
- bx = left;
45
- by = top;
46
- break;
47
- case "top-center":
48
- bx = x;
49
- by = top;
50
- break;
51
- case "top-right":
52
- bx = right;
53
- by = top;
54
- break;
55
- case "center-left":
56
- bx = left;
57
- by = y;
58
- break;
59
- case "center":
60
- bx = x;
61
- by = y;
62
- break;
63
- case "center-right":
64
- bx = right;
65
- by = y;
66
- break;
67
- case "bottom-left":
68
- bx = left;
69
- by = bottom;
70
- break;
71
- case "bottom-center":
72
- bx = x;
73
- by = bottom;
74
- break;
75
- case "bottom-right":
76
- bx = right;
77
- by = bottom;
78
- break;
79
- }
80
-
81
- return {
82
- x: bx + (hole.offsetX || 0),
83
- y: by + (hole.offsetY || 0),
84
- };
85
- } else if (hole.x !== undefined && hole.y !== undefined) {
86
- // Legacy / Direct coordinates (Normalized relative to Dieline Geometry)
87
- // Formula: absolute = normalized * width + (center - width/2)
88
- // This handles padding correctly.
89
- const { x, width, y, height } = geometry;
90
- return {
91
- x: hole.x * width + (x - width / 2) + (hole.offsetX || 0),
92
- y: hole.y * height + (y - height / 2) + (hole.offsetY || 0),
93
- };
94
- }
95
- return { x: 0, y: 0 };
3
+ export type FeatureOperation = "add" | "subtract";
4
+ export type FeatureShape = "rect" | "circle";
5
+
6
+ export interface EdgeFeature {
7
+ id: string;
8
+ groupId?: string; // For grouping features together (e.g. double-layer hole)
9
+ operation: FeatureOperation;
10
+ shape: FeatureShape;
11
+ x: number; // Normalized 0-1 relative to geometry bounds
12
+ y: number; // Normalized 0-1 relative to geometry bounds
13
+ width?: number; // For rect (Physical units)
14
+ height?: number; // For rect (Physical units)
15
+ radius?: number; // For circle or rect corners (Physical units)
16
+ rotation?: number; // Degrees
17
+ target?: "original" | "offset" | "both";
18
+ color?: string; // Hex color for the marker
19
+ strokeDash?: number[]; // Stroke dash array for the marker
96
20
  }
97
21
 
98
22
  export interface GeometryOptions {
@@ -102,7 +26,7 @@ export interface GeometryOptions {
102
26
  radius: number;
103
27
  x: number;
104
28
  y: number;
105
- holes: Array<HoleData>;
29
+ features: Array<EdgeFeature>;
106
30
  pathData?: string;
107
31
  canvasWidth?: number;
108
32
  canvasHeight?: number;
@@ -113,6 +37,24 @@ export interface MaskGeometryOptions extends GeometryOptions {
113
37
  canvasHeight: number;
114
38
  }
115
39
 
40
+ /**
41
+ * Resolves the absolute position of a feature based on normalized coordinates.
42
+ */
43
+ export function resolveFeaturePosition(
44
+ feature: EdgeFeature,
45
+ geometry: { x: number; y: number; width: number; height: number },
46
+ ): { x: number; y: number } {
47
+ const { x, y, width, height } = geometry;
48
+ // geometry.x/y is the Center.
49
+ const left = x - width / 2;
50
+ const top = y - height / 2;
51
+
52
+ return {
53
+ x: left + feature.x * width,
54
+ y: top + feature.y * height,
55
+ };
56
+ }
57
+
116
58
  /**
117
59
  * Initializes paper.js project if not already initialized.
118
60
  */
@@ -153,9 +95,6 @@ function createBaseShape(options: GeometryOptions): paper.PathItem {
153
95
  path.pathData = pathData;
154
96
  // Align center
155
97
  path.position = center;
156
- // Scale to match width/height if needed?
157
- // For now, assume pathData is correct size, but we might want to support resizing.
158
- // If width/height are provided and different from bounds, we could scale.
159
98
  if (
160
99
  width > 0 &&
161
100
  height > 0 &&
@@ -166,7 +105,6 @@ function createBaseShape(options: GeometryOptions): paper.PathItem {
166
105
  }
167
106
  return path;
168
107
  } else {
169
- // Fallback
170
108
  return new paper.Path.Rectangle({
171
109
  point: [x - width / 2, y - height / 2],
172
110
  size: [Math.max(0, width), Math.max(0, height)],
@@ -175,215 +113,95 @@ function createBaseShape(options: GeometryOptions): paper.PathItem {
175
113
  }
176
114
 
177
115
  /**
178
- * Creates an offset version of the base shape.
179
- * For Rect/Circle, we can just adjust params.
180
- * For Custom shapes, we need a true offset algorithm (Paper.js doesn't have a robust one built-in for all cases,
181
- * but we can simulate it or use a simple scaling if offset is small, OR rely on a library like Clipper.js.
182
- * However, since we want to avoid heavy deps, let's try a simple approach:
183
- * If it's a simple shape, we re-create it.
184
- * If it's custom, we unfortunately have to scale it for now as a poor-man's offset,
185
- * UNLESS we implement a stroke expansion.
186
- *
187
- * Stroke Expansion Trick:
188
- * 1. Create path
189
- * 2. Set strokeWidth = offset * 2
190
- * 3. Convert stroke to path (paper.js has path.expand())
191
- * 4. Union original + expanded (for positive offset) or Subtract (for negative).
116
+ * Creates a Paper.js Item for a single feature.
192
117
  */
193
- function createOffsetShape(
194
- options: GeometryOptions,
195
- offset: number,
118
+ function createFeatureItem(
119
+ feature: EdgeFeature,
120
+ center: paper.Point,
196
121
  ): paper.PathItem {
197
- const { shape, width, height, radius, x, y, pathData } = options;
198
- const center = new paper.Point(x, y);
199
-
200
- if (shape === "rect" || shape === "circle" || shape === "ellipse") {
201
- // For standard shapes, we can just adjust the dimensions
202
- const offsetOptions = {
203
- ...options,
204
- width: Math.max(0, width + offset * 2),
205
- height: Math.max(0, height + offset * 2),
206
- radius: radius === 0 ? 0 : Math.max(0, radius + offset),
207
- };
208
- return createBaseShape(offsetOptions);
209
- } else if (shape === "custom" && pathData) {
210
- const original = createBaseShape(options);
211
- if (offset === 0) return original;
212
-
213
- // Use Stroke Expansion for Offset
214
- // Create a copy for stroking
215
- const stroker = original.clone() as paper.Path;
216
- stroker.strokeColor = new paper.Color("black");
217
- stroker.strokeWidth = Math.abs(offset) * 2;
218
- // Round join usually looks better for offsets
219
- stroker.strokeJoin = "round";
220
- stroker.strokeCap = "round";
221
-
222
- // Expand stroke to path
223
- // @ts-ignore - paper.js types might be missing expand depending on version, but it exists in recent versions
224
- // If expand is not available, we might fallback to scaling.
225
- // Assuming modern paper.js
226
- let expanded: paper.Item;
227
- try {
228
- // @ts-ignore
229
- expanded = stroker.expand({ stroke: true, fill: false, insert: false });
230
- } catch (e) {
231
- // Fallback if expand fails or not present
232
- stroker.remove();
233
- // Fallback to scaling (imperfect)
234
- const scaleX =
235
- (original.bounds.width + offset * 2) / original.bounds.width;
236
- const scaleY =
237
- (original.bounds.height + offset * 2) / original.bounds.height;
238
- original.scale(scaleX, scaleY);
239
- return original;
240
- }
241
-
242
- stroker.remove();
243
-
244
- // The expanded stroke is a "ring".
245
- // For positive offset: Union(Original, Ring)
246
- // For negative offset: Subtract(Original, Ring) ? No, that makes a hole.
247
- // For negative offset: We want the "inner" boundary of the ring.
248
-
249
- // Actually, expand() returns a Group or Path.
250
- // If it's a closed path, the ring has an outer and inner boundary.
251
-
252
- let result: paper.PathItem;
253
-
254
- if (offset > 0) {
255
- // @ts-ignore
256
- result = original.unite(expanded);
257
- } else {
258
- // For negative offset (shrink), we want the original MINUS the stroke?
259
- // No, the stroke is centered on the line.
260
- // So the inner edge of the stroke is at -offset.
261
- // We want the area INSIDE the inner edge.
262
- // That is Original SUBTRACT the Ring?
263
- // Yes, if we subtract the ring, we lose the border area.
264
- // @ts-ignore
265
- result = original.subtract(expanded);
266
- }
267
-
268
- // Cleanup
269
- original.remove();
270
- expanded.remove();
122
+ let item: paper.PathItem;
123
+
124
+ if (feature.shape === "rect") {
125
+ const w = feature.width || 10;
126
+ const h = feature.height || 10;
127
+ const r = feature.radius || 0;
128
+ item = new paper.Path.Rectangle({
129
+ point: [center.x - w / 2, center.y - h / 2],
130
+ size: [w, h],
131
+ radius: r,
132
+ });
133
+ } else {
134
+ // Circle
135
+ const r = feature.radius || 5;
136
+ item = new paper.Path.Circle({
137
+ center: center,
138
+ radius: r,
139
+ });
140
+ }
271
141
 
272
- return result;
142
+ if (feature.rotation) {
143
+ item.rotate(feature.rotation, center);
273
144
  }
274
145
 
275
- return createBaseShape(options);
146
+ return item;
276
147
  }
277
148
 
278
149
  /**
279
150
  * Internal helper to generate the Dieline Shape (Paper Item).
280
- * Caller is responsible for cleanup.
151
+ * Logic: (Base U Adds) - Subtracts
281
152
  */
282
153
  function getDielineShape(options: GeometryOptions): paper.PathItem {
283
154
  // 1. Create Base Shape
284
155
  let mainShape = createBaseShape(options);
285
156
 
286
- const { holes } = options;
287
-
288
- if (holes && holes.length > 0) {
289
- let lugsPath: paper.PathItem | null = null;
290
- let cutsPath: paper.PathItem | null = null;
291
-
292
- holes.forEach((hole) => {
293
- const center = new paper.Point(hole.x!, hole.y!);
294
-
295
- // Create Lug (Outer Radius)
296
- const lug =
297
- hole.shape === "square"
298
- ? new paper.Path.Rectangle({
299
- point: [
300
- center.x - hole.outerRadius,
301
- center.y - hole.outerRadius,
302
- ],
303
- size: [hole.outerRadius * 2, hole.outerRadius * 2],
304
- })
305
- : new paper.Path.Circle({
306
- center: center,
307
- radius: hole.outerRadius,
308
- });
309
-
310
- // REMOVED: Intersects check. We want to process all holes defined in config.
311
- // If a hole is completely outside, it might form an island, but that's better than missing it.
312
- // Users can remove the hole if they don't want it.
313
-
314
- // Create Cut (Inner Radius)
315
- const cut =
316
- hole.shape === "square"
317
- ? new paper.Path.Rectangle({
318
- point: [
319
- center.x - hole.innerRadius,
320
- center.y - hole.innerRadius,
321
- ],
322
- size: [hole.innerRadius * 2, hole.innerRadius * 2],
323
- })
324
- : new paper.Path.Circle({
325
- center: center,
326
- radius: hole.innerRadius,
327
- });
328
-
329
- // Union Lugs
330
- if (!lugsPath) {
331
- lugsPath = lug;
157
+ const { features } = options;
158
+
159
+ if (features && features.length > 0) {
160
+ const adds: paper.PathItem[] = [];
161
+ const subtracts: paper.PathItem[] = [];
162
+
163
+ features.forEach((f) => {
164
+ const pos = resolveFeaturePosition(f, options);
165
+ const center = new paper.Point(pos.x, pos.y);
166
+ const item = createFeatureItem(f, center);
167
+
168
+ if (f.operation === "add") {
169
+ adds.push(item);
332
170
  } else {
333
- try {
334
- const temp = lugsPath.unite(lug);
335
- lugsPath.remove();
336
- lug.remove();
337
- lugsPath = temp;
338
- } catch (e) {
339
- console.error("Geometry: Failed to unite lug", e);
340
- // Keep previous lugsPath, ignore this one to prevent crash
341
- lug.remove();
342
- }
171
+ subtracts.push(item);
343
172
  }
173
+ });
344
174
 
345
- // Union Cuts
346
- if (!cutsPath) {
347
- cutsPath = cut;
348
- } else {
175
+ // 2. Process Additions (Union)
176
+ if (adds.length > 0) {
177
+ // Unite all additions first to avoid artifacts?
178
+ // Or unite one by one to mainShape?
179
+ // Unite one by one is safer for simple logic.
180
+ for (const item of adds) {
349
181
  try {
350
- const temp = cutsPath.unite(cut);
351
- cutsPath.remove();
352
- cut.remove();
353
- cutsPath = temp;
182
+ const temp = mainShape.unite(item);
183
+ mainShape.remove();
184
+ item.remove();
185
+ mainShape = temp;
354
186
  } catch (e) {
355
- console.error("Geometry: Failed to unite cut", e);
356
- cut.remove();
187
+ console.error("Geometry: Failed to unite feature", e);
188
+ item.remove();
357
189
  }
358
190
  }
359
- });
360
-
361
- // 2. Add Lugs to Main Shape (Union) - Additive Fusion
362
- if (lugsPath) {
363
- try {
364
- const temp = mainShape.unite(lugsPath);
365
- mainShape.remove();
366
- // @ts-ignore
367
- lugsPath.remove();
368
- mainShape = temp;
369
- } catch (e) {
370
- console.error("Geometry: Failed to unite lugsPath to mainShape", e);
371
- }
372
191
  }
373
192
 
374
- // 3. Subtract Cuts from Main Shape (Difference)
375
- if (cutsPath) {
376
- try {
377
- const temp = mainShape.subtract(cutsPath);
378
- mainShape.remove();
379
- // @ts-ignore
380
- cutsPath.remove();
381
- mainShape = temp;
382
- } catch (e) {
383
- console.error(
384
- "Geometry: Failed to subtract cutsPath from mainShape",
385
- e,
386
- );
193
+ // 3. Process Subtractions (Difference)
194
+ if (subtracts.length > 0) {
195
+ for (const item of subtracts) {
196
+ try {
197
+ const temp = mainShape.subtract(item);
198
+ mainShape.remove();
199
+ item.remove();
200
+ mainShape = temp;
201
+ } catch (e) {
202
+ console.error("Geometry: Failed to subtract feature", e);
203
+ item.remove();
204
+ }
387
205
  }
388
206
  }
389
207
  }
@@ -393,7 +211,6 @@ function getDielineShape(options: GeometryOptions): paper.PathItem {
393
211
 
394
212
  /**
395
213
  * Generates the path data for the Dieline (Product Shape).
396
- * Logic: (BaseShape UNION IntersectingLugs) SUBTRACT Cuts
397
214
  */
398
215
  export function generateDielinePath(options: GeometryOptions): string {
399
216
  const paperWidth = options.canvasWidth || options.width * 2 || 2000;
@@ -419,16 +236,13 @@ export function generateMaskPath(options: MaskGeometryOptions): string {
419
236
 
420
237
  const { canvasWidth, canvasHeight } = options;
421
238
 
422
- // 1. Canvas Background
423
239
  const maskRect = new paper.Path.Rectangle({
424
240
  point: [0, 0],
425
241
  size: [canvasWidth, canvasHeight],
426
242
  });
427
243
 
428
- // 2. Re-create Product Shape
429
244
  const mainShape = getDielineShape(options);
430
245
 
431
- // 3. Subtract Product from Mask
432
246
  const finalMask = maskRect.subtract(mainShape);
433
247
 
434
248
  maskRect.remove();
@@ -441,92 +255,25 @@ export function generateMaskPath(options: MaskGeometryOptions): string {
441
255
  }
442
256
 
443
257
  /**
444
- * Generates the path data for the Bleed Zone (Area between Original and Offset).
258
+ * Generates the path data for the Bleed Zone.
445
259
  */
446
260
  export function generateBleedZonePath(
447
- options: GeometryOptions,
261
+ originalOptions: GeometryOptions,
262
+ offsetOptions: GeometryOptions,
448
263
  offset: number,
449
264
  ): string {
450
- // Ensure canvas is large enough
451
- const paperWidth = options.canvasWidth || options.width * 2 || 2000;
452
- const paperHeight = options.canvasHeight || options.height * 2 || 2000;
265
+ const paperWidth =
266
+ originalOptions.canvasWidth || originalOptions.width * 2 || 2000;
267
+ const paperHeight =
268
+ originalOptions.canvasHeight || originalOptions.height * 2 || 2000;
453
269
  ensurePaper(paperWidth, paperHeight);
454
270
  paper.project.activeLayer.removeChildren();
455
271
 
456
- // 1. Original Shape
457
- const shapeOriginal = getDielineShape(options);
458
-
459
- // 2. Offset Shape
460
- // We use createOffsetShape for more accurate offset (especially for custom shapes)
461
- // But we still need to respect holes if they exist.
462
- // getDielineShape handles holes.
463
- // The issue is: do holes shrink/expand with bleed?
464
- // Usually, bleed is only for the outer cut. Holes are internal cuts.
465
- // Internal cuts usually also have bleed if they are die-cut, but maybe different direction?
466
- // For simplicity, let's assume we offset the FINAL shape (including holes).
467
-
468
- // Actually, getDielineShape calls createBaseShape.
469
- // Let's modify generateBleedZonePath to use createOffsetShape logic if possible,
470
- // OR just perform offset on the final shape result.
471
-
472
- // The previous logic was: create base shape with adjusted width/height/radius.
473
- // This works for Rect/Circle.
474
- // For Custom, we need createOffsetShape.
475
-
476
- let shapeOffset: paper.PathItem;
477
-
478
- if (options.shape === "custom") {
479
- // For custom shape, we offset the base shape first, then apply holes?
480
- // Or offset the final result?
481
- // Bleed is usually "outside" the cut line.
482
- // If we have a donut, bleed is outside the outer circle AND inside the inner circle?
483
- // Or just outside the outer?
484
- // Let's assume bleed expands the solid area.
485
-
486
- // So we take the final shape (Original) and expand it.
487
- // We can use the same Stroke Expansion trick on the final shape.
488
-
489
- // Since shapeOriginal is already the final shape (Base - Holes),
490
- // we can try to offset it directly.
491
-
492
- const stroker = shapeOriginal.clone() as paper.Path;
493
- stroker.strokeColor = new paper.Color("black");
494
- stroker.strokeWidth = Math.abs(offset) * 2;
495
- stroker.strokeJoin = "round";
496
- stroker.strokeCap = "round";
497
-
498
- let expanded: paper.Item;
499
- try {
500
- // @ts-ignore
501
- expanded = stroker.expand({ stroke: true, fill: false, insert: false });
502
- } catch (e) {
503
- // Fallback
504
- stroker.remove();
505
- shapeOffset = shapeOriginal.clone();
506
- // scaling fallback...
507
- return shapeOffset.pathData; // Fail gracefully
508
- }
509
- stroker.remove();
510
-
511
- if (offset > 0) {
512
- // @ts-ignore
513
- shapeOffset = shapeOriginal.unite(expanded);
514
- } else {
515
- // @ts-ignore
516
- shapeOffset = shapeOriginal.subtract(expanded);
517
- }
518
- expanded.remove();
519
- } else {
520
- // Legacy logic for standard shapes (still valid and fast)
521
- // Adjust dimensions for offset
522
- const offsetOptions: GeometryOptions = {
523
- ...options,
524
- width: Math.max(0, options.width + offset * 2),
525
- height: Math.max(0, options.height + offset * 2),
526
- radius: options.radius === 0 ? 0 : Math.max(0, options.radius + offset),
527
- };
528
- shapeOffset = getDielineShape(offsetOptions);
529
- }
272
+ // 1. Generate Original Shape
273
+ const shapeOriginal = getDielineShape(originalOptions);
274
+
275
+ // 2. Generate Offset Shape
276
+ const shapeOffset = getDielineShape(offsetOptions);
530
277
 
531
278
  // 3. Calculate Difference
532
279
  let bleedZone: paper.PathItem;
@@ -538,7 +285,6 @@ export function generateBleedZonePath(
538
285
 
539
286
  const pathData = bleedZone.pathData;
540
287
 
541
- // Cleanup
542
288
  shapeOriginal.remove();
543
289
  shapeOffset.remove();
544
290
  bleedZone.remove();
@@ -547,8 +293,8 @@ export function generateBleedZonePath(
547
293
  }
548
294
 
549
295
  /**
550
- * Finds the nearest point on the Dieline geometry for a given target point.
551
- * Used for constraining hole movement.
296
+ * Finds the nearest point on the Dieline geometry (Base Shape ONLY) for a given target point.
297
+ * Used for constraining feature movement.
552
298
  */
553
299
  export function getNearestPointOnDieline(
554
300
  point: { x: number; y: number },
@@ -557,6 +303,8 @@ export function getNearestPointOnDieline(
557
303
  ensurePaper(options.width * 2, options.height * 2);
558
304
  paper.project.activeLayer.removeChildren();
559
305
 
306
+ // We constrain to the BASE shape, not including other features,
307
+ // because usually you want to snap to the main edge.
560
308
  const shape = createBaseShape(options);
561
309
 
562
310
  const p = new paper.Point(point.x, point.y);