partforge 0.59.0 → 0.60.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.
@@ -221,33 +221,107 @@ export function booleanRegions(aRegions, bRegions, op) {
221
221
  }
222
222
  }
223
223
 
224
- // Self-resolve one region list: per-region self-unite resolves self-intersections and
225
- // winding-inverted loops inside each region; pairwise unite fold merges overlaps BETWEEN
226
- // regions. This two-stage approach avoids the cancellation issue of whole-list evenodd
227
- // self-unite (which cancelled overlapping children). Same readback and winding normalization
228
- // as booleanRegions. Empty result → []. NB paper has no arc primitive, so arcs return as
229
- // cubic approximations (arcToCubicSegments) — identical to what every boolean already does.
230
- export function resolveSelfRegions(regions) {
231
- if (regions.length === 0) return [];
224
+ export { paperScope, toContour, toOpenContour, groupPaperPaths };
225
+
226
+ // paper reports an intersection as a time on the PAPER CURVE it hit, and that is NOT this
227
+ // engine's IR parameter for the segment the curve came from. segMap fixes WHICH segment; this
228
+ // fixes WHERE ON IT. Three separate mismatches, only one of them benign:
229
+ //
230
+ // * an ARC ({to,via}) is expanded by arcToCubicSegments into up to four ≤90° cubics that all
231
+ // share ONE segMap entry, so `loc.time` is the time within whichever piece was hit —
232
+ // measured 0.404 for a point 70.3% along a 180° arc. That is the damaging one: it is not a
233
+ // small error but a different number entirely, so _splitRings trims the arc at a wildly
234
+ // wrong sweep, and two crossings on one arc can even sort backwards.
235
+ // * a LINE is a zero-handle cubic in paper, whose time satisfies 3t²−2t³ = the linear
236
+ // fraction: measured 0.560 where the IR parameter is 0.590. Benign so far only by luck —
237
+ // _splitRings overwrites a line piece's endpoints with the pooled vertices, and the map is
238
+ // monotonic so ordering survives.
239
+ // * a CUBIC ({to,c1,c2}) is the only kind that round-trips: one paper curve, the same
240
+ // parameterization trimSegment's splitCubic uses.
241
+ //
242
+ // The parameter is recovered from the intersection POINT rather than by undoing each of those.
243
+ // That inverts exactly what trimSegment does — linear in position along a line, linear in
244
+ // ANGLE about the arc's centre (trimSegment: aS = a0 + dA·tStart, off the same
245
+ // arcCenterAndSweep) — and it is exact to floating point, where reconstructing the arc case
246
+ // from the piece index as (j + tp)/k is not: (j + tp)/k is right about the PIECE (the sweep is
247
+ // split into equal angular pieces, `t0 = a0 + dA·(i/pieces)` above) but still reads a Bézier
248
+ // time as an angular fraction WITHIN that piece, leaving up to 4.5e-3 of parameter error — and
249
+ // none of it corrected on a ≤90° arc, where k is 1 and the formula degenerates to `tp`. A ≤90°
250
+ // round join is the commonest arc this engine emits.
251
+ //
252
+ // Recovering from the point is also the more robust reading for an arc: the cubic
253
+ // approximation's error is essentially RADIAL, so the point's ANGLE is right even where the
254
+ // point itself sits a fraction off the true circle.
255
+ function irTime(contour, segIdx, point, paperTime) {
256
+ const n = contour.segments.length;
257
+ // segIdx === n is the closing curve closePath() synthesizes for a contour that never returns
258
+ // to its own start (see toPaperPath's segMap note) — a straight edge back to `start`.
259
+ const seg = segIdx < n ? contour.segments[segIdx] : { to: contour.start };
260
+ const from = segIdx === 0 ? contour.start : contour.segments[(segIdx - 1) % n].to;
261
+ if (seg.c1) return paperTime;
262
+ if (seg.via) {
263
+ const c = arcCenterAndSweep(from, seg.via, seg.to);
264
+ if (c) { // null = collinear triple: a line, below
265
+ const a0 = Math.atan2(from[1] - c.center[1], from[0] - c.center[0]);
266
+ const aP = Math.atan2(point[1] - c.center[1], point[0] - c.center[0]);
267
+ const span = Math.abs(c.dA);
268
+ const twoPi = 2 * Math.PI;
269
+ let d = (c.dA >= 0 ? aP - a0 : a0 - aP) % twoPi; // angle travelled from the arc's start
270
+ if (d < 0) d += twoPi;
271
+ const t = d / span;
272
+ // A point a rounding step BEFORE the start normalizes to nearly a whole turn rather than
273
+ // to ~0, and one past the end simply exceeds 1. Snap to the nearer end either way instead
274
+ // of handing _splitRings a parameter outside [0,1].
275
+ return t <= 1 ? t : (t - 1 <= twoPi / span - t ? 1 : 0);
276
+ }
277
+ }
278
+ const dx = seg.to[0] - from[0], dy = seg.to[1] - from[1];
279
+ const L2 = dx * dx + dy * dy;
280
+ if (L2 < 1e-18) return paperTime; // degenerate segment: nothing to project on
281
+ const t = ((point[0] - from[0]) * dx + (point[1] - from[1]) * dy) / L2;
282
+ return t < 0 ? 0 : (t > 1 ? 1 : t);
283
+ }
284
+
285
+ // Every crossing among a set of contour-IR rings — self-intersections of each ring plus
286
+ // pairwise intersections — expressed back in IR terms as { ring, seg, t, point }.
287
+ //
288
+ // This deliberately borrows the half of paper.js that works. Paper implements fat-line
289
+ // Bézier clipping (Sederberg–Nishita) with convex-hull rejection: recursive subdivision
290
+ // that returns exact (curve, t) on the original curves. Paper's weakness in this engine
291
+ // was never finding intersections — it is the tracing and branch selection afterwards,
292
+ // which contour-winding.js replaces. segMap (filled by toPaperPath) maps paper's curve
293
+ // index back to our IR segment index.
294
+ //
295
+ // NB paper's addCurveIntersections bails at 40 recursion levels / 4096 calls and returns
296
+ // a PARTIAL set on pathological input. Callers must detect that downstream (an unconsumed
297
+ // piece during chaining) rather than trusting completeness here.
298
+ export function ringCrossings(rings) {
299
+ if (rings.length === 0) return [];
232
300
  const scope = paperScope();
233
301
  try {
234
- let acc = null;
235
- for (const rg of regions) {
236
- const R = regionsToCompound(scope, [rg]);
237
- const netInverted = R.area < -1e-9;
238
- const r = R.unite(R, { insert: false });
239
- if (netInverted && r.area > 1e-9) continue; // collapsed/inverted region cancels
240
- if (Math.abs(r.area) <= 1e-9) continue;
241
- acc = acc ? acc.unite(r, { insert: false }) : r;
302
+ const maps = rings.map(() => []);
303
+ const paths = rings.map((c, i) => toPaperPath(scope, c, maps[i]));
304
+ const out = [];
305
+ const push = (ringIdx, loc) => {
306
+ const seg = maps[ringIdx][loc.curve.index];
307
+ if (!Number.isInteger(seg)) return; // defensive: unmapped curve
308
+ const point = [loc.point.x, loc.point.y];
309
+ out.push({ ring: ringIdx, seg, t: irTime(rings[ringIdx], seg, point, loc.time), point });
310
+ };
311
+ for (let i = 0; i < paths.length; i++) {
312
+ for (const loc of paths[i].getIntersections()) { // self
313
+ push(i, loc);
314
+ if (loc.intersection) push(i, loc.intersection);
315
+ }
316
+ for (let j = i + 1; j < paths.length; j++) {
317
+ for (const loc of paths[i].getIntersections(paths[j])) { // pairwise
318
+ push(i, loc);
319
+ push(j, loc.intersection);
320
+ }
321
+ }
242
322
  }
243
- if (!acc) return [];
244
- const paths = (acc.className === "CompoundPath" ? acc.children : [acc])
245
- .filter((p) => p.segments && p.segments.length >= 2 && Math.abs(p.area) > 1e-9);
246
- if (!paths.length) return [];
247
- return groupPaperPathsOriented(paths);
323
+ return out;
248
324
  } finally {
249
325
  scope.project.clear();
250
326
  }
251
327
  }
252
-
253
- export { paperScope, toContour, toOpenContour, groupPaperPaths };