@pooder/kit 4.3.1 → 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 +11 -0
  30. package/dist/index.d.mts +339 -36
  31. package/dist/index.d.ts +339 -36
  32. package/dist/index.js +3572 -850
  33. package/dist/index.mjs +3565 -852
  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 -973
  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 +242 -84
  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,87 +298,129 @@ function getPerimeterShape(options: GeometryOptions): paper.PathItem {
182
298
  const bridgeBottom = itemBounds.top;
183
299
 
184
300
  if (bridgeBottom > bridgeTop) {
185
- // Ray Casting Approach:
186
- // 1. Create a vertical ray from the center of the feature upwards
187
- const centerX = itemBounds.center.x;
188
- const ray = new paper.Path.Line({
189
- from: [centerX, bridgeBottom],
190
- to: [centerX, bridgeTop - 10], // Extend slightly past top to ensure intersection
191
- insert: false
192
- });
193
-
194
- // 2. Find intersections with the main shape
195
- const intersections = mainShape.getIntersections(ray);
196
-
197
- // 3. Find the lowest intersection point (highest Y)
198
- // Intersections are usually sorted by offset, but we want to be safe.
199
- // We want the point with the largest Y that is still <= bridgeBottom.
200
- let targetY = bridgeTop; // Default to top if no intersection (shouldn't happen if overlapping)
201
- let found = false;
202
-
203
- if (intersections && intersections.length > 0) {
204
- // Filter intersections that are strictly above the feature start
205
- // (allow small tolerance for touching)
206
- const validHits = intersections.filter(i => i.point.y < bridgeBottom - 0.1);
207
-
208
- if (validHits.length > 0) {
209
- // We want the HIT that is CLOSEST to the feature (Largest Y)
210
- validHits.sort((a, b) => b.point.y - a.point.y);
211
- targetY = validHits[0].point.y;
212
- found = true;
213
- }
214
- }
215
-
216
- ray.remove();
217
-
218
- // 4. Create the bridge rect
219
- // If we found a hit, targetY is the surface of the main shape.
220
- // We want to overlap slightly to ensure union.
221
- const overlap = 2; // Overlap by 2 units
222
- const rectBottom = bridgeBottom; // Start at feature top
223
- let rectTop = found ? targetY + overlap : bridgeTop; // If not found, go all the way? Or maybe fail safe.
224
-
225
- // If we didn't find an intersection, it might mean the feature is completely below the shape (no X overlap).
226
- // In that case, maybe we shouldn't bridge? Or bridge to the bounding box bottom?
227
- // For now, if found, use it. If not, use mainBounds.bottom?
228
- // Let's assume if !found, we try to project to mainBounds.bottom if it's above us.
229
- if (!found) {
230
- if (mainBounds.bottom < bridgeBottom) {
231
- targetY = mainBounds.bottom;
232
- rectTop = targetY - overlap; // Penetrate up
233
- }
234
- }
235
-
236
- if (rectTop < rectBottom) {
237
- const bridgeRect = new paper.Path.Rectangle({
238
- from: [itemBounds.left, rectTop],
239
- to: [itemBounds.right, rectBottom],
240
- insert: false
241
- });
242
-
243
- const unitedItem = item.unite(bridgeRect);
244
- item.remove();
245
- bridgeRect.remove();
246
-
247
- if (f.operation === "add") {
248
- adds.push(unitedItem);
249
- } else {
250
- subtracts.push(unitedItem);
251
- }
252
- } else {
253
- // Bridge height is negative or zero, just use item
254
- if (f.operation === "add") {
255
- adds.push(item);
256
- } else {
257
- subtracts.push(item);
258
- }
259
- }
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,
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
+ }
339
+
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
+ }
373
+
374
+ let topBase = selectOuterChain({
375
+ mainShape,
376
+ pointsA,
377
+ pointsB,
378
+ delta,
379
+ overlap,
380
+ op: f.operation,
381
+ });
382
+
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;
387
+ };
388
+
389
+ if (dist2(topBase[0], leftHit.point) > dist2(topBase[0], rightHit.point)) {
390
+ topBase = topBase.slice().reverse();
391
+ }
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);
417
+ }
418
+ } else {
419
+ if (f.operation === "add") {
420
+ adds.push(item);
260
421
  } else {
261
- if (f.operation === "add") {
262
- adds.push(item);
263
- } else {
264
- subtracts.push(item);
265
- }
422
+ subtracts.push(item);
423
+ }
266
424
  }
267
425
  } else {
268
426
  if (f.operation === "add") {
@@ -280,7 +438,7 @@ function getPerimeterShape(options: GeometryOptions): paper.PathItem {
280
438
  const temp = mainShape.unite(item);
281
439
  mainShape.remove();
282
440
  item.remove();
283
- mainShape = temp;
441
+ mainShape = normalizePathItem(temp);
284
442
  } catch (e) {
285
443
  console.error("Geometry: Failed to unite feature", e);
286
444
  item.remove();
@@ -295,7 +453,7 @@ function getPerimeterShape(options: GeometryOptions): paper.PathItem {
295
453
  const temp = mainShape.subtract(item);
296
454
  mainShape.remove();
297
455
  item.remove();
298
- mainShape = temp;
456
+ mainShape = normalizePathItem(temp);
299
457
  } catch (e) {
300
458
  console.error("Geometry: Failed to subtract feature", e);
301
459
  item.remove();
@@ -336,12 +494,12 @@ function applySurfaceFeatures(
336
494
  const temp = result.unite(item);
337
495
  result.remove();
338
496
  item.remove();
339
- result = temp;
497
+ result = normalizePathItem(temp);
340
498
  } else {
341
499
  const temp = result.subtract(item);
342
500
  result.remove();
343
501
  item.remove();
344
- result = temp;
502
+ result = normalizePathItem(temp);
345
503
  }
346
504
  } catch (e) {
347
505
  console.error("Geometry: Failed to apply surface feature", e);