partforge 0.60.0 → 0.60.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.60.0",
3
+ "version": "0.60.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",
@@ -824,6 +824,35 @@ function sourceBackedPositiveRegions(source, out, delta) {
824
824
  return result;
825
825
  }
826
826
 
827
+ // Crossing clustering can snap both ends of a tiny curve run onto the same pool vertex.
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.
834
+ function dropSubresolutionPositiveLoops(out, delta) {
835
+ if (delta <= 0) return out;
836
+ const radius = 2 * CLUSTER_TOL;
837
+ const clean = (contour) => {
838
+ const segments = [];
839
+ let from = contour.start;
840
+ for (const seg of contour.segments) {
841
+ const controls = [seg.via, seg.c1, seg.c2].filter(Boolean);
842
+ const clusteredLoop = dist(from, seg.to) <= 1e-12
843
+ && controls.every((p) => dist(from, p) <= radius);
844
+ if (!clusteredLoop) segments.push(seg);
845
+ from = seg.to;
846
+ }
847
+ return segments.length ? { start: contour.start, segments } : null;
848
+ };
849
+
850
+ return out.flatMap((rg) => {
851
+ const outer = clean(rg.outer);
852
+ return outer ? [{ outer, holes: rg.holes.map(clean).filter(Boolean) }] : [];
853
+ });
854
+ }
855
+
827
856
  // Region-in / region-out offset: the engine behind Shape2D.offset on BOTH backends.
828
857
  // Fast path: raw per-ring offsets that validate cleanly are returned as-is (lines/arcs
829
858
  // exact). Cleanup path: anything dirty or invalid goes through resolveOffsetWinding
@@ -850,6 +879,7 @@ export function offsetRegions(regions, delta, { corners = "round" } = {}) {
850
879
  if (out === null) throw err; // pinned message, unchanged, when nothing works
851
880
  }
852
881
  out = sourceBackedPositiveRegions(regions, out, delta);
882
+ out = dropSubresolutionPositiveLoops(out, delta);
853
883
  if (out.length === 0) throw new Error("Shape2D.offset: offset collapses the shape (reduce |delta|)");
854
884
  return out;
855
885
  }
@@ -95,10 +95,14 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
95
95
  if (prev === undefined) { seenEdge.set(key, t); continue; }
96
96
  seenEdge.delete(key);
97
97
  const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
98
- // policy-gated same-surface lines; seam rule unchanged
99
- const hard = triOID[prev] === triOID[t]
98
+ // A multi-hole cap triangulation can contain an opposite-wound bridge:
99
+ // its two normals disagree by 180 degrees even though both triangles lie
100
+ // in the same plane. Gate on the unoriented supporting-plane angle first
101
+ // so that triangulation seam never becomes a feature line.
102
+ const bends = Math.abs(dot) < COPLANAR_COS;
103
+ const hard = bends && (triOID[prev] === triOID[t]
100
104
  ? polFor(triOID[t]).sameSurfaceLines && dot < cosFor(triOID[t])
101
- : dot < COPLANAR_COS;
105
+ : true);
102
106
  if (hard) {
103
107
  const ai = i * np, bj = j * np;
104
108
  const dx = vp[ai] - vp[bj], dy = vp[ai + 1] - vp[bj + 1], dz = vp[ai + 2] - vp[bj + 2];