partforge 0.67.3 → 0.67.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.67.3",
3
+ "version": "0.67.4",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -901,6 +901,31 @@ function dropSubresolutionPositiveLoops(out, delta) {
901
901
  });
902
902
  }
903
903
 
904
+ // Positive dilation may also discard whole SUB-SLIVER rings — the multi-segment
905
+ // sibling of the zero-chord splice loops above. The winding resolver can emit
906
+ // entire junk rings a few segments long (measured on "Scott" size 28, delta 5:
907
+ // 14 of 16 holes were resolver debris of 1e-8..1e-5 mm² beside two real ~6-8 mm²
908
+ // counters), and every one of them extrudes into a degenerate fin or sliver face
909
+ // that downstream mesh consumers trip over (the planar rim fillet's knife-edge
910
+ // refusals). The bar is the corpus oracle's own SLIVER convention (1e-3 mm² —
911
+ // test/offset-text.test.js, the fuzz suite): rings under it are "resolver
912
+ // artifacts, not features". The proof this cannot eat real geometry is
913
+ // dilation-only, in two halves: a genuine separate REGION of a dilation is at
914
+ // least the dilation disc (area ≥ π·δ²), and a genuine HOLE under that bar is a
915
+ // counter within a hair of closing — which the oracle already counts as closed.
916
+ // Erosion keeps everything, same as dropSubresolutionPositiveLoops: a tiny
917
+ // surviving island there is real geometry with no source-domain proof otherwise.
918
+ const RING_SLIVER = 1e-3; // mm² — the corpus oracle's sub-sliver bar
919
+ function dropSubSliverRings(out, delta) {
920
+ if (delta <= 0) return out;
921
+ const tiny = (ring) => Math.abs(ringArea(tessellateContour(ring, VALIDATE_SEGS))) < RING_SLIVER;
922
+ return out.flatMap((rg) => {
923
+ if (tiny(rg.outer)) return [];
924
+ const holes = rg.holes.filter((h) => !tiny(h));
925
+ return [holes.length === rg.holes.length ? rg : { outer: rg.outer, holes }];
926
+ });
927
+ }
928
+
904
929
  // Region-in / region-out offset: the engine behind Shape2D.offset on BOTH backends.
905
930
  // Fast path: raw per-ring offsets that validate cleanly are returned as-is (lines/arcs
906
931
  // exact). Cleanup path: anything dirty or invalid goes through resolveOffsetWinding
@@ -928,6 +953,7 @@ export function offsetRegions(regions, delta, { corners = "round" } = {}) {
928
953
  }
929
954
  out = sourceBackedPositiveRegions(regions, out, delta);
930
955
  out = dropSubresolutionPositiveLoops(out, delta);
956
+ out = dropSubSliverRings(out, delta);
931
957
  if (out.length === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
932
958
  return out;
933
959
  }
@@ -572,7 +572,10 @@ export function matchesSelector(chain, sel) {
572
572
  const rot2 = ([x, y], th) => [x * Math.cos(th) - y * Math.sin(th), x * Math.sin(th) + y * Math.cos(th)];
573
573
  function profile2D({ P, n1, n2, magnitude, mode, convex, segs, ext = 0 }) {
574
574
  const c = clamp1(n1[0] * n2[0] + n1[1] * n2[1]);
575
- if (1 + c < 1e-6) throw new UnsupportedEdgeError("~180° knife edge");
575
+ // `knifeEdge` marks the refusal as the anti-parallel-flank degeneracy, so the
576
+ // planar rim machinery can SKIP a noise stretch (a sliver facet's flipped
577
+ // normal) instead of failing the whole selection on it.
578
+ if (1 + c < 1e-6) throw Object.assign(new UnsupportedEdgeError("~180° knife edge"), { knifeEdge: true });
576
579
  const bl = Math.hypot(n1[0] + n2[0], n1[1] + n2[1]);
577
580
  const bis = [(n1[0] + n2[0]) / bl, (n1[1] + n2[1]) / bl];
578
581
  const delta = 0.02 * magnitude;
@@ -859,6 +862,35 @@ function collapseTightCorners(pts0, wallNs0, closed, magnitude) {
859
862
  return { pts: out, wallNs: outWalls };
860
863
  }
861
864
 
865
+ // Weld consecutive coincident chain points (the module's own 1/WELD vertex-identity
866
+ // grid, pivotKey's). collapseTightCorners can land a virtual corner V exactly ON a
867
+ // flanking chain point — an offset outline's micro-spike doubles back through the
868
+ // same vertex, so the flanking edge lines intersect AT it — and a coincident pair
869
+ // becomes a zero-length sweep path segment that k.sweep rejects, failing the whole
870
+ // fillet (the "Scott" offset-backing regression). Dropping the point drops the
871
+ // degenerate segment's WALL, keeping walls one-per-surviving-segment.
872
+ function weldChainPoints(pts, wallNs, closed) {
873
+ const eps = 1 / WELD;
874
+ const outP = [pts[0]], outW = [];
875
+ for (let i = 1; i < pts.length; i++) {
876
+ if (len(sub(pts[i], outP[outP.length - 1])) < eps) continue;
877
+ outP.push(pts[i]);
878
+ outW.push(wallNs[i - 1]); // wall of the span arriving at pts[i]
879
+ }
880
+ if (closed) {
881
+ // The closing segment's wall: the original closing span's — unless the wrap
882
+ // itself welds (last ≈ first), where the popped point's arriving wall is the
883
+ // span that now closes the loop.
884
+ let closingW = wallNs[pts.length - 1];
885
+ while (outP.length > 1 && len(sub(outP[outP.length - 1], outP[0])) < eps) {
886
+ outP.pop();
887
+ closingW = outW.pop();
888
+ }
889
+ outW.push(closingW);
890
+ }
891
+ return { pts: outP, wallNs: outW };
892
+ }
893
+
862
894
  function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null) {
863
895
  const { points, closed, convex, faceN } = chain;
864
896
  let { wallNs } = chain;
@@ -871,6 +903,10 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
871
903
  // bold outlines never hit this because the 0.4 mm round offset pads every
872
904
  // convex radius past the fold threshold).
873
905
  if (convex) ({ pts, wallNs } = collapseTightCorners(pts, wallNs, closed, magnitude));
906
+ ({ pts, wallNs } = weldChainPoints(pts, wallNs, closed));
907
+ // A chain welded below the grid (a sub-micron rim loop — offset-noise islands)
908
+ // has nothing a blend of this magnitude can attach to; skip it rather than fail.
909
+ if (pts.length < (closed ? 3 : 2)) return [];
874
910
  const m = pts.length;
875
911
  const at = (i) => pts[((i % m) + m) % m];
876
912
  const nSeg = closed ? m : m - 1;
@@ -999,9 +1035,48 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
999
1035
  return k.sweep(poly, path3D, { closed: isClosed });
1000
1036
  };
1001
1037
 
1038
+ // Sweep one open stretch; when the sweep refuses a VERTEX fold the pre-split
1039
+ // guard let through — the guard classifies bends by the LOCAL wall normals,
1040
+ // and an offset outline's micro-spike facets carry noise normals that can
1041
+ // read reflex (lenient reach) where the sweep's frame-transported measure is
1042
+ // salient (full magnitude) — split at that exact vertex and sweep the pieces.
1043
+ // That is the same treatment the guard itself would have applied with the
1044
+ // right classification: adjacent stretches mitre into each other across the
1045
+ // split via their overshoots. The sweep is the oracle, so the two can never
1046
+ // disagree into a failure.
1047
+ const buildStretch = (path, wallN, depth = 0) => {
1048
+ try {
1049
+ return [toolFor(overshoot(path), false, wallN)];
1050
+ } catch (e) {
1051
+ // A knife PROFILE here means this stretch's wall is a degenerate sliver's
1052
+ // flipped normal (anti-parallel to the face) — a real rim wall is ~90° to
1053
+ // its face and cannot produce it. The rim piece is sub-resolution noise;
1054
+ // skip it rather than fail every other stretch of the selection.
1055
+ if (e?.knifeEdge) return [];
1056
+ const v = e?.foldVertex;
1057
+ // overshoot() prepended one point, so sweep index v is path index v-1
1058
+ const i = v != null ? v - (over > 0 && path.length >= 2 ? 1 : 0) : null;
1059
+ if (i == null || depth > 16 || !(i > 0 && i < path.length - 1)) throw e;
1060
+ return [
1061
+ ...buildStretch(path.slice(0, i + 1), wallN, depth + 1),
1062
+ ...buildStretch(path.slice(i), wallN, depth + 1),
1063
+ ];
1064
+ }
1065
+ };
1066
+
1002
1067
  try {
1003
1068
  if (closed && breaks.length === 0) {
1004
- return [toolFor(pts.map((p) => [p[0], p[1], p[2]]), true, wallNs[nSeg - 1])];
1069
+ const loop = pts.map((p) => [p[0], p[1], p[2]]);
1070
+ try {
1071
+ return [toolFor(loop, true, wallNs[nSeg - 1])];
1072
+ } catch (e) {
1073
+ if (e?.knifeEdge) return []; // degenerate sliver loop — nothing to blend
1074
+ const v = e?.foldVertex;
1075
+ if (v == null) throw e;
1076
+ // the loop folds at v with no break to split on: open it there and let
1077
+ // buildStretch's splitting take over (the seam gets the overshoot mitre)
1078
+ return buildStretch([...loop.slice(v), ...loop.slice(0, v + 1)], wallNs[v % nSeg]);
1079
+ }
1005
1080
  }
1006
1081
  // Open stretches between breaks. An open chain's endpoints are implicit breaks; a
1007
1082
  // closed chain's stretches wrap from each break to the next.
@@ -1017,7 +1092,7 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
1017
1092
  const aS = arcAt(s), aE = arcAt(e);
1018
1093
  if (aS) path = pullBack(path, aS.t, false);
1019
1094
  if (aE) path = pullBack(path, aE.t, true);
1020
- tools.push(toolFor(overshoot(path), false, wallNs[s % nSeg]));
1095
+ tools.push(...buildStretch(path, wallNs[s % nSeg]));
1021
1096
  }
1022
1097
  for (const got of cornerArcs.values()) {
1023
1098
  tools.push(revolveTool(k, got.arc, magnitude, mode, segs, pSegs));
@@ -1505,7 +1580,20 @@ function apply(k, solid, mode, magnitude, { edges, segs = DEFAULT_SEGS, sharpDeg
1505
1580
  (endTins.get(kk) ?? endTins.set(kk, []).get(kk)).push(info.tin);
1506
1581
  }
1507
1582
  }
1508
- const { chains: effective, arcs, horns, pivots } = roundSalientCorners(planarized, magnitude);
1583
+ let { chains: effective, arcs, horns, pivots } = roundSalientCorners(planarized, magnitude);
1584
+ // An edge whose two flanks fold back on themselves (anti-parallel normals) is a
1585
+ // zero-thickness fin or slit rim — a self-touching offset outline extrudes these.
1586
+ // There is no wedge between the flanks for a blend to live in (profile2D's own
1587
+ // ~180° knife-edge refusal), so skip the chain rather than fail every OTHER edge
1588
+ // of the selection with it.
1589
+ // Planar variant of the same degeneracy: a zero-area sliver in the face
1590
+ // triangulation flips its facet normal, classifying as a "wall" anti-parallel
1591
+ // to the face — profile2D's projected normals then hit the same refusal.
1592
+ const knife = (ch) => ch.kind === "planar"
1593
+ ? ch.wallNs.every((wn) => dot(ch.faceN, wn) < -1 + 1e-6)
1594
+ : ch.n1 && ch.n2 && dot(ch.n1, ch.n2) < -1 + 1e-6;
1595
+ effective = effective.filter((ch) => !knife(ch));
1596
+ arcs = arcs.filter((ch) => !knife(ch));
1509
1597
  const pSegs = blendSegs(segs, magnitude);
1510
1598
  const toolsFor = (ch) =>
1511
1599
  ch.kind === "planar"
@@ -43,6 +43,13 @@ const rodrigues = (v, k, ang) => {
43
43
  const placeRing = (profile2D, center, N, B) =>
44
44
  profile2D.map(([x, y]) => add(center, add(scl(N, x), scl(B, y))));
45
45
 
46
+ // A per-VERTEX fold refusal (miter would fold / reversal is ambiguous) carries the
47
+ // offending path index as `foldVertex`, so a caller that owns the path can split it
48
+ // there and sweep the pieces instead of failing — mesh-fillet's planar chains do
49
+ // exactly that when their pre-split guard's wall-normal heuristic disagrees with
50
+ // this module's direction-aware measure (offset-outline micro-noise walls).
51
+ const foldError = (message, vtx) => Object.assign(new Error(message), { foldVertex: vtx });
52
+
46
53
  // The seed frame a sweep of `path3D` will start from — exported so a caller authoring a
47
54
  // profile FOR a sweep (mesh-fillet's planar-chain tool) can express it in exactly the
48
55
  // frame the sweep will use, instead of replicating the reference-vector pick and drifting
@@ -91,7 +98,7 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
91
98
  const axisRaw = cross(tIn, tOut), s = vlen(axisRaw);
92
99
  const cdot = Math.max(-1, Math.min(1, dot(tIn, tOut)));
93
100
  if (cdot < -1 + 1e-6)
94
- throw new Error(`sweep: 180° reversal at vertex ${vtx} is ambiguous — insert an intermediate point or use cornerRadius`);
101
+ throw foldError(`sweep: 180° reversal at vertex ${vtx} is ambiguous — insert an intermediate point or use cornerRadius`, vtx);
95
102
  if (s < EPS) { stations.push(placeRing(profile2D, center, N, B)); return; } // collinear: no turn, frame unchanged
96
103
  const axis = scl(axisRaw, 1 / s);
97
104
  const theta = Math.atan2(s, cdot); // exterior turn angle
@@ -127,7 +134,7 @@ export function resolveSweepStations(profile2D, path3D, { closed = false, corner
127
134
  let reachIn = 0;
128
135
  for (const [x, y] of profile2D) reachIn = Math.max(reachIn, x * uN + y * uB);
129
136
  if ((reachIn / cosh) * Math.tan(theta / 2) > 0.5 * Math.min(lenIn, lenOut))
130
- throw new Error(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`);
137
+ throw foldError(`sweep: profile too wide for the bend at vertex ${vtx} (turn too sharp / segment too short) — increase cornerRadius or lengthen the segment`, vtx);
131
138
  stations.push(profile2D.map(([x, y]) => {
132
139
  const p = add(scl(Nh, x), scl(Bh, y)); // profile point in the miter plane (spanned by u, axis)
133
140
  return add(center, add(scl(axis, dot(p, axis)), scl(u, dot(p, u) / cosh))); // stretch the u component