@pooder/kit 4.3.0 → 5.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 (60) hide show
  1. package/.test-dist/src/CanvasService.js +249 -0
  2. package/.test-dist/src/ViewportSystem.js +75 -0
  3. package/.test-dist/src/background.js +203 -0
  4. package/.test-dist/src/bridgeSelection.js +20 -0
  5. package/.test-dist/src/constraints.js +237 -0
  6. package/.test-dist/src/coordinate.js +74 -0
  7. package/.test-dist/src/dieline.js +723 -0
  8. package/.test-dist/src/edgeScale.js +12 -0
  9. package/.test-dist/src/feature.js +752 -0
  10. package/.test-dist/src/featureComplete.js +32 -0
  11. package/.test-dist/src/film.js +167 -0
  12. package/.test-dist/src/geometry.js +506 -0
  13. package/.test-dist/src/image.js +1234 -0
  14. package/.test-dist/src/index.js +35 -0
  15. package/.test-dist/src/maskOps.js +270 -0
  16. package/.test-dist/src/mirror.js +104 -0
  17. package/.test-dist/src/renderSpec.js +2 -0
  18. package/.test-dist/src/ruler.js +343 -0
  19. package/.test-dist/src/sceneLayout.js +99 -0
  20. package/.test-dist/src/sceneLayoutModel.js +196 -0
  21. package/.test-dist/src/sceneView.js +40 -0
  22. package/.test-dist/src/sceneVisibility.js +42 -0
  23. package/.test-dist/src/size.js +332 -0
  24. package/.test-dist/src/tracer.js +544 -0
  25. package/.test-dist/src/units.js +30 -0
  26. package/.test-dist/src/white-ink.js +829 -0
  27. package/.test-dist/src/wrappedOffsets.js +33 -0
  28. package/.test-dist/tests/run.js +94 -0
  29. package/CHANGELOG.md +17 -0
  30. package/dist/index.d.mts +339 -36
  31. package/dist/index.d.ts +339 -36
  32. package/dist/index.js +3587 -854
  33. package/dist/index.mjs +3580 -856
  34. package/package.json +2 -2
  35. package/src/CanvasService.ts +300 -96
  36. package/src/ViewportSystem.ts +92 -92
  37. package/src/background.ts +230 -230
  38. package/src/bridgeSelection.ts +17 -0
  39. package/src/coordinate.ts +106 -106
  40. package/src/dieline.ts +897 -955
  41. package/src/edgeScale.ts +19 -0
  42. package/src/feature.ts +83 -30
  43. package/src/film.ts +194 -194
  44. package/src/geometry.ts +234 -80
  45. package/src/image.ts +1582 -512
  46. package/src/index.ts +14 -10
  47. package/src/maskOps.ts +326 -0
  48. package/src/mirror.ts +128 -128
  49. package/src/renderSpec.ts +18 -0
  50. package/src/ruler.ts +449 -508
  51. package/src/sceneLayout.ts +121 -0
  52. package/src/sceneLayoutModel.ts +335 -0
  53. package/src/sceneVisibility.ts +49 -0
  54. package/src/size.ts +379 -0
  55. package/src/tracer.ts +719 -570
  56. package/src/units.ts +27 -27
  57. package/src/white-ink.ts +1018 -373
  58. package/src/wrappedOffsets.ts +33 -0
  59. package/tests/run.ts +118 -0
  60. package/tsconfig.test.json +15 -15
package/src/geometry.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import paper from "paper";
2
+ import { pickExitIndex, scoreOutsideAbove } from "./bridgeSelection";
3
+ import { sampleWrappedOffsets, wrappedDistance } from "./wrappedOffsets";
2
4
 
3
5
  export type FeatureOperation = "add" | "subtract";
4
6
  export type FeatureShape = "rect" | "circle";
@@ -71,6 +73,120 @@ function ensurePaper(width: number, height: number) {
71
73
  }
72
74
  }
73
75
 
76
+ const isBridgeDebugEnabled = () =>
77
+ Boolean((globalThis as any).__POODER_BRIDGE_DEBUG__);
78
+
79
+ function normalizePathItem(shape: paper.PathItem): paper.PathItem {
80
+ let result: any = shape;
81
+ if (typeof result.resolveCrossings === "function") result = result.resolveCrossings();
82
+ if (typeof result.reduce === "function") result = result.reduce({});
83
+ if (typeof result.reorient === "function") result = result.reorient(true, true);
84
+ if (typeof result.reduce === "function") result = result.reduce({});
85
+ return result as paper.PathItem;
86
+ }
87
+
88
+ function getBridgeDelta(itemBounds: paper.Rectangle, overlap: number) {
89
+ return Math.max(overlap, Math.min(5, Math.max(1, itemBounds.height * 0.02)));
90
+ }
91
+
92
+ function getExitHit(args: {
93
+ mainShape: paper.Path;
94
+ x: number;
95
+ bridgeBottom: number;
96
+ toY: number;
97
+ eps: number;
98
+ delta: number;
99
+ overlap: number;
100
+ op: FeatureOperation;
101
+ }) {
102
+ const { mainShape, x, bridgeBottom, toY, eps, delta, overlap, op } = args;
103
+
104
+ const ray = new paper.Path.Line({
105
+ from: [x, bridgeBottom],
106
+ to: [x, toY],
107
+ insert: false,
108
+ });
109
+
110
+ const intersections = mainShape.getIntersections(ray) || [];
111
+ ray.remove();
112
+
113
+ const validHits = intersections.filter((i) => i.point.y < bridgeBottom - eps);
114
+ if (validHits.length === 0) return null;
115
+
116
+ validHits.sort((a, b) => b.point.y - a.point.y);
117
+ const flags = validHits.map((h) => {
118
+ const above = h.point.add(new paper.Point(0, -delta));
119
+ const below = h.point.add(new paper.Point(0, delta));
120
+ return {
121
+ insideAbove: mainShape.contains(above),
122
+ insideBelow: mainShape.contains(below),
123
+ };
124
+ });
125
+
126
+ const idx = pickExitIndex(flags);
127
+ if (idx < 0) return null;
128
+
129
+ if (isBridgeDebugEnabled()) {
130
+ console.debug("Geometry: Bridge ray", {
131
+ x,
132
+ validHits: validHits.length,
133
+ idx,
134
+ delta,
135
+ overlap,
136
+ op,
137
+ });
138
+ }
139
+
140
+ const hit = validHits[idx];
141
+ return { point: hit.point, location: hit };
142
+ }
143
+
144
+ function selectOuterChain(args: {
145
+ mainShape: paper.Path;
146
+ pointsA: paper.Point[];
147
+ pointsB: paper.Point[];
148
+ delta: number;
149
+ overlap: number;
150
+ op: FeatureOperation;
151
+ }) {
152
+ const { mainShape, pointsA, pointsB, delta, overlap, op } = args;
153
+
154
+ const scoreA = scoreOutsideAbove(
155
+ pointsA.map((p) => ({
156
+ outsideAbove: !mainShape.contains(p.add(new paper.Point(0, -delta))),
157
+ })),
158
+ );
159
+ const scoreB = scoreOutsideAbove(
160
+ pointsB.map((p) => ({
161
+ outsideAbove: !mainShape.contains(p.add(new paper.Point(0, -delta))),
162
+ })),
163
+ );
164
+
165
+ const ratioA = scoreA / pointsA.length;
166
+ const ratioB = scoreB / pointsB.length;
167
+
168
+ if (isBridgeDebugEnabled()) {
169
+ console.debug("Geometry: Bridge chain", {
170
+ scoreA,
171
+ scoreB,
172
+ lenA: pointsA.length,
173
+ lenB: pointsB.length,
174
+ ratioA,
175
+ ratioB,
176
+ delta,
177
+ overlap,
178
+ op,
179
+ });
180
+ }
181
+
182
+ const ratioEps = 1e-6;
183
+ if (Math.abs(ratioA - ratioB) > ratioEps) {
184
+ return ratioA > ratioB ? pointsA : pointsB;
185
+ }
186
+ if (scoreA !== scoreB) return scoreA > scoreB ? pointsA : pointsB;
187
+ return pointsA.length <= pointsB.length ? pointsA : pointsB;
188
+ }
189
+
74
190
  /**
75
191
  * Creates the base dieline shape (Rect/Circle/Ellipse/Custom)
76
192
  */
@@ -182,91 +298,129 @@ function getPerimeterShape(options: GeometryOptions): paper.PathItem {
182
298
  const bridgeBottom = itemBounds.top;
183
299
 
184
300
  if (bridgeBottom > bridgeTop) {
185
- // 1. Create a full column up to the top of the main shape
186
- // Start slightly inside the feature to ensure overlap at the bottom
187
- const startY = bridgeBottom + 1;
188
- const bridgeRect = new paper.Path.Rectangle({
189
- from: [itemBounds.left, bridgeTop],
190
- to: [itemBounds.right, startY],
191
- insert: false,
301
+ const overlap = 2;
302
+ const rayPadding = 10;
303
+ const eps = 0.1;
304
+ const delta = getBridgeDelta(itemBounds, overlap);
305
+
306
+ const toY = bridgeTop - rayPadding;
307
+ const inset = Math.min(1, Math.max(0, itemBounds.width * 0.01));
308
+ const xLeft = itemBounds.left + inset;
309
+ const xRight = itemBounds.right - inset;
310
+
311
+ if (!(mainShape instanceof paper.Path)) {
312
+ throw new Error("Geometry: Bridge requires base shape to be a Path");
313
+ }
314
+
315
+ const leftHit = getExitHit({
316
+ mainShape,
317
+ x: xLeft,
318
+ bridgeBottom,
319
+ toY,
320
+ eps,
321
+ delta,
322
+ overlap,
323
+ op: f.operation,
192
324
  });
325
+ const rightHit = getExitHit({
326
+ mainShape,
327
+ x: xRight,
328
+ bridgeBottom,
329
+ toY,
330
+ eps,
331
+ delta,
332
+ overlap,
333
+ op: f.operation,
334
+ });
335
+
336
+ if (!leftHit || !rightHit || xRight - xLeft <= eps) {
337
+ throw new Error("Geometry: Bridge ray intersection not found");
338
+ }
193
339
 
194
- // 2. Subtract the main shape from this column
195
- // This leaves us with the parts of the column that are NOT inside the main shape (gaps)
196
- const gaps = bridgeRect.subtract(mainShape);
197
- bridgeRect.remove();
340
+ const path = mainShape as paper.Path;
341
+ const pathLength = path.length;
342
+ const leftOffset = leftHit.location.offset;
343
+ const rightOffset = rightHit.location.offset;
344
+
345
+ const distanceA = wrappedDistance(pathLength, leftOffset, rightOffset);
346
+ const distanceB = wrappedDistance(pathLength, rightOffset, leftOffset);
347
+ const countFor = (d: number) => Math.max(8, Math.min(80, Math.ceil(d / 6)));
348
+
349
+ const offsetsA = sampleWrappedOffsets(
350
+ pathLength,
351
+ leftOffset,
352
+ rightOffset,
353
+ countFor(distanceA),
354
+ );
355
+
356
+ const offsetsB = sampleWrappedOffsets(
357
+ pathLength,
358
+ rightOffset,
359
+ leftOffset,
360
+ countFor(distanceB),
361
+ );
362
+
363
+ const pointsA = offsetsA
364
+ .map((o) => path.getPointAt(o))
365
+ .filter((p): p is paper.Point => Boolean(p));
366
+ const pointsB = offsetsB
367
+ .map((o) => path.getPointAt(o))
368
+ .filter((p): p is paper.Point => Boolean(p));
369
+
370
+ if (pointsA.length < 2 || pointsB.length < 2) {
371
+ throw new Error("Geometry: Bridge contour sampling failed");
372
+ }
198
373
 
199
- // 3. Find the gap piece that connects to our feature
200
- // It should be the piece with the lowest bottom (highest Y) matching our feature top
201
- let bridgePart: paper.PathItem | null = null;
374
+ let topBase = selectOuterChain({
375
+ mainShape,
376
+ pointsA,
377
+ pointsB,
378
+ delta,
379
+ overlap,
380
+ op: f.operation,
381
+ });
202
382
 
203
- // Helper to check if a part is the bottom one
204
- const isBottomPart = (part: paper.PathItem) => {
205
- // Check if bottom aligns with feature top (allow small tolerance)
206
- return Math.abs(part.bounds.bottom - startY) < 2;
383
+ const dist2 = (a: paper.Point, b: paper.Point) => {
384
+ const dx = a.x - b.x;
385
+ const dy = a.y - b.y;
386
+ return dx * dx + dy * dy;
207
387
  };
208
388
 
209
- if (gaps instanceof paper.CompoundPath) {
210
- // Find the child that is at the bottom
211
- const children = gaps.children;
212
- let maxBottom = -Infinity;
213
- let bestChild = null;
214
-
215
- for (const child of children) {
216
- if (child.bounds.bottom > maxBottom) {
217
- maxBottom = child.bounds.bottom;
218
- bestChild = child;
219
- }
220
- }
221
-
222
- if (bestChild && isBottomPart(bestChild as paper.PathItem)) {
223
- bridgePart = (bestChild as paper.PathItem).clone();
224
- }
225
- } else if (gaps instanceof paper.Path) {
226
- if (isBottomPart(gaps)) {
227
- bridgePart = gaps.clone();
228
- }
389
+ if (dist2(topBase[0], leftHit.point) > dist2(topBase[0], rightHit.point)) {
390
+ topBase = topBase.slice().reverse();
229
391
  }
230
-
231
- gaps.remove();
232
-
233
- if (bridgePart) {
234
- // Overlap fix:
235
- // Scale the bridge up slightly from the bottom to ensure it overlaps with the main shape at the top.
236
- // This prevents hairline gaps due to perfect alignment from subtract().
237
- const bounds = bridgePart.bounds;
238
- if (bounds.height > 0) {
239
- const overlap = 1;
240
- const scaleY = (bounds.height + overlap) / bounds.height;
241
- // Scale around the bottom-center to keep the connection to the feature intact
242
- bridgePart.scale(1, scaleY, new paper.Point(bounds.center.x, bounds.bottom));
243
- }
244
-
245
- // Unite the bridge with the feature
246
- const unitedItem = item.unite(bridgePart);
247
- item.remove();
248
- bridgePart.remove();
249
-
250
- if (f.operation === "add") {
251
- adds.push(unitedItem);
252
- } else {
253
- subtracts.push(unitedItem);
254
- }
255
- } else {
256
- // No bridge needed (feature touches or intersects main shape directly)
257
- // or calculation failed. Fallback to original item.
258
- if (f.operation === "add") {
259
- adds.push(item);
260
- } else {
261
- subtracts.push(item);
262
- }
392
+
393
+ topBase = topBase.slice();
394
+ topBase[0] = leftHit.point;
395
+ topBase[topBase.length - 1] = rightHit.point;
396
+
397
+ const capShiftY = f.operation === "subtract" ? -Math.max(overlap * 2, delta) : overlap;
398
+ const topPoints = topBase.map(
399
+ (p) => p.add(new paper.Point(0, capShiftY)),
400
+ );
401
+
402
+ const bridgeBottomY = bridgeBottom + overlap * 2;
403
+ const bridgePoly = new paper.Path({ insert: false });
404
+ for (const p of topPoints) bridgePoly.add(p);
405
+ bridgePoly.add(new paper.Point(xRight, bridgeBottomY));
406
+ bridgePoly.add(new paper.Point(xLeft, bridgeBottomY));
407
+ bridgePoly.closed = true;
408
+
409
+ const unitedItem = item.unite(bridgePoly);
410
+ item.remove();
411
+ bridgePoly.remove();
412
+
413
+ if (f.operation === "add") {
414
+ adds.push(unitedItem);
415
+ } else {
416
+ subtracts.push(unitedItem);
263
417
  }
264
418
  } else {
265
- if (f.operation === "add") {
266
- adds.push(item);
267
- } else {
268
- subtracts.push(item);
269
- }
419
+ if (f.operation === "add") {
420
+ adds.push(item);
421
+ } else {
422
+ subtracts.push(item);
423
+ }
270
424
  }
271
425
  } else {
272
426
  if (f.operation === "add") {
@@ -284,7 +438,7 @@ function getPerimeterShape(options: GeometryOptions): paper.PathItem {
284
438
  const temp = mainShape.unite(item);
285
439
  mainShape.remove();
286
440
  item.remove();
287
- mainShape = temp;
441
+ mainShape = normalizePathItem(temp);
288
442
  } catch (e) {
289
443
  console.error("Geometry: Failed to unite feature", e);
290
444
  item.remove();
@@ -299,7 +453,7 @@ function getPerimeterShape(options: GeometryOptions): paper.PathItem {
299
453
  const temp = mainShape.subtract(item);
300
454
  mainShape.remove();
301
455
  item.remove();
302
- mainShape = temp;
456
+ mainShape = normalizePathItem(temp);
303
457
  } catch (e) {
304
458
  console.error("Geometry: Failed to subtract feature", e);
305
459
  item.remove();
@@ -340,12 +494,12 @@ function applySurfaceFeatures(
340
494
  const temp = result.unite(item);
341
495
  result.remove();
342
496
  item.remove();
343
- result = temp;
497
+ result = normalizePathItem(temp);
344
498
  } else {
345
499
  const temp = result.subtract(item);
346
500
  result.remove();
347
501
  item.remove();
348
- result = temp;
502
+ result = normalizePathItem(temp);
349
503
  }
350
504
  } catch (e) {
351
505
  console.error("Geometry: Failed to apply surface feature", e);