partforge 0.64.0 → 0.64.1

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.64.0",
3
+ "version": "0.64.1",
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",
@@ -826,14 +826,26 @@ function sourceBackedPositiveRegions(source, out, delta) {
826
826
 
827
827
  // Crossing clustering can snap both ends of a tiny curve run onto the same pool vertex.
828
828
  // The run then survives as a closed loop attached at one point (or as a whole one-segment
829
- // contour). It encloses only geometry inside the resolver's own 2*CLUSTER_TOL cluster-
830
- // diameter uncertainty, but a downstream cap triangulator may bridge that loop across the
831
- // face and expose the bridge as a bogus feature edge. Positive dilation may discard these
832
- // sub-resolution counter loops: they are collapsed negative boundaries, never new material.
833
- // Keep erosion unchanged; its tiny surviving islands have no equivalent source-domain proof.
829
+ // contour). It encloses only geometry inside the resolver's own splice uncertainty, but a
830
+ // downstream cap triangulator may bridge that loop across the face and expose the bridge
831
+ // as a bogus feature edge and a surviving zero-chord loop also breaks the NEXT offset or
832
+ // boolean over this output (its crossing chaining reads the loop as an unclosable
833
+ // arrangement: the "could not chain offset boundary" class of failure). Positive dilation
834
+ // may discard these sub-resolution counter loops: they are collapsed negative boundaries,
835
+ // never new material. Keep erosion unchanged; its tiny surviving islands have no
836
+ // equivalent source-domain proof.
837
+ //
838
+ // The radius is 10*CLUSTER_TOL, not the 2*CLUSTER_TOL cluster-diameter bound this pass
839
+ // shipped with: the CROSSINGS that got merged sit within one cluster, but the curve run
840
+ // BETWEEN them wanders further before returning — measured on offset(6, round) of
841
+ // "Scotty" size 24 (test/contour-cleanup.test.js), the surviving splice loops reached
842
+ // 0.028 mm of control extent, 5.6x the old radius, and every one of them slipped this
843
+ // pass. 10x (0.05 mm) covers them with margin while staying far below any printable
844
+ // feature; a genuine pinch-off lobe bigger than that survives as its own region ring and
845
+ // never presents as a single zero-chord segment in the first place.
834
846
  function dropSubresolutionPositiveLoops(out, delta) {
835
847
  if (delta <= 0) return out;
836
- const radius = 2 * CLUSTER_TOL;
848
+ const radius = 10 * CLUSTER_TOL;
837
849
  const clean = (contour) => {
838
850
  const segments = [];
839
851
  let from = contour.start;
@@ -656,7 +656,34 @@ function simplifyRun(scope, from, segs, tolerance) {
656
656
  return refitRunViaPaper(scope, from, segs, tolerance);
657
657
  }
658
658
 
659
- function simplifyContour(scope, contour, tolerance) {
659
+ // Sweep segments whose ENTIRE extent (endpoint and every control point) stays within the
660
+ // caller's tolerance of their own start — degenerate loop-backs and dust segments. These
661
+ // are exactly the artifacts an upstream offset/boolean can leave behind (the winding
662
+ // resolver's splice debris: zero-chord cubics with controls ~0.01-0.03 mm out), and
663
+ // without this pass they DEFEAT simplify: contourCorners reads the loop's joints as real
664
+ // corners, and the corner-preserving contract then pins the debris bit-exact, so
665
+ // `.simplify(0.03)` used to return a 0.02 mm loop untouched. Dropping a segment moves the
666
+ // path by at most its chord (≤ tolerance) — precisely the change the caller authorized.
667
+ // Everything larger than the tolerance is geometry, not debris, and passes through.
668
+ function sweepDegenerateSegments(contour, tolerance) {
669
+ const segments = [];
670
+ let from = contour.start;
671
+ for (const seg of contour.segments) {
672
+ const controls = [seg.via, seg.c1, seg.c2].filter(Boolean);
673
+ const extent = Math.max(
674
+ Math.hypot(seg.to[0] - from[0], seg.to[1] - from[1]),
675
+ ...controls.map((p) => Math.hypot(p[0] - from[0], p[1] - from[1])),
676
+ );
677
+ if (extent <= tolerance) continue; // debris: path continues from `from`
678
+ segments.push(seg);
679
+ from = seg.to;
680
+ }
681
+ if (segments.length < 2) return contour; // a ring of dust is not ours to delete
682
+ return closeContourGap({ start: [contour.start[0], contour.start[1]], segments });
683
+ }
684
+
685
+ function simplifyContour(scope, rawContour, tolerance) {
686
+ const contour = sweepDegenerateSegments(rawContour, tolerance);
660
687
  const corners = contourCorners(contour);
661
688
  if (corners.length === 0) {
662
689
  // Cornerless (smooth closed loop): simplify as ONE closed path — toPaperPath's default
@@ -118,14 +118,36 @@ export function sampleBezier(p0, c1, c2, p1, segs) {
118
118
  recurse(p0, c1, c2, p1, 0);
119
119
  if (out.length === 0) out.push([p1[0], p1[1]]);
120
120
  out[out.length - 1] = [p1[0], p1[1]]; // pin the exact endpoint
121
- return out;
121
+ // Emit-if-moved: near a cusp (a degenerate loop-back cubic — e.g. the winding
122
+ // resolver's splice debris, start === end with controls ~0.01 mm out) the curve's
123
+ // SPEED collapses, so depth-capped parameter-uniform splits cluster spatially and
124
+ // the raw list carries runs of samples nanometers apart (measured min gap 2e-9 mm).
125
+ // Those land in tessellated rings as coincident points and poison every consumer
126
+ // (sliver wall facets, mesh edge chains, ring dedup). Keep a sample only once it has
127
+ // moved SAMPLE_EPS from the last kept one; the exact endpoint stays pinned — it
128
+ // replaces a final sample that stopped short of it, so the one pair flanking the pin
129
+ // may be tighter than SAMPLE_EPS, and that is the only pair allowed to be. 1e-6 mm is
130
+ // a nanometer: far below any legitimate facet spacing (a 1 µm-radius arc at segs 96
131
+ // still spaces ~6e-5), so no real curve loses a sample.
132
+ const SAMPLE_EPS = 1e-6;
133
+ const kept = [];
134
+ let last = p0;
135
+ for (const p of out) {
136
+ if (Math.hypot(p[0] - last[0], p[1] - last[1]) < SAMPLE_EPS) continue;
137
+ kept.push(p);
138
+ last = p;
139
+ }
140
+ if (kept.length && Math.hypot(kept[kept.length - 1][0] - p1[0], kept[kept.length - 1][1] - p1[1]) < SAMPLE_EPS)
141
+ kept[kept.length - 1] = [p1[0], p1[1]];
142
+ else kept.push([p1[0], p1[1]]);
143
+ return kept;
122
144
  }
123
145
 
124
146
  // Tessellate a single contour into a CCW point ring. A legacy array is returned unchanged
125
147
  // (identical to the former path); a path contour is walked start→segment→segment, lines
126
148
  // pushing their `to`, arcs and cubics pushing their sampled points (sampleArc/sampleBezier).
127
149
  export function tessellateContour(contour, segs) {
128
- if (Array.isArray(contour)) return contour;
150
+ if (Array.isArray(contour)) return contour; // legacy point list: caller's data, bit-exact
129
151
  const ring = [[contour.start[0], contour.start[1]]];
130
152
  let prev = contour.start;
131
153
  for (const seg of contour.segments) {
@@ -134,7 +156,27 @@ export function tessellateContour(contour, segs) {
134
156
  else ring.push([seg.to[0], seg.to[1]]);
135
157
  prev = seg.to;
136
158
  }
137
- return ring;
159
+ // Coincident consecutive points never survive tessellation: a zero-length line segment
160
+ // (or a sampler edge case) would otherwise land verbatim in the ring and hand every
161
+ // consumer (extrude walls, mesh edge chains, silhouette masks) a degenerate edge. The
162
+ // very last point is kept unconditionally — it replaces a duplicate predecessor rather
163
+ // than being dropped — so the explicit-closure convention (final point lands exactly on
164
+ // `start`) survives the sweep.
165
+ const TESS_EPS = 1e-9;
166
+ const out = [ring[0]];
167
+ for (let i = 1; i < ring.length; i++) {
168
+ const p = ring[i], last = out[out.length - 1];
169
+ if (Math.hypot(p[0] - last[0], p[1] - last[1]) < TESS_EPS) {
170
+ if (i === ring.length - 1) out[out.length - 1] = p; // keep the exact closure point
171
+ continue;
172
+ }
173
+ out.push(p);
174
+ }
175
+ // A path contour always tessellates to ≥2 points (start + arrival), even when the whole
176
+ // contour is degenerate — consumers walk poly EDGES (contour-winding's pieceSamples) and
177
+ // a single-point poly has none to walk.
178
+ if (out.length < 2) return [ring[0], ring[ring.length - 1]];
179
+ return out;
138
180
  }
139
181
 
140
182
  // Normalize + tessellate a whole region to { outer:[[x,y],…], holes:[[[x,y],…],…] } of