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
|
@@ -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
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|