partforge 0.108.0 → 0.109.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.108.0",
3
+ "version": "0.109.0",
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",
@@ -108,7 +108,17 @@ async function scoreMatchTargets(built, targets, onProgress) {
108
108
  // this is the caller: rings are mm and the mesh masks carry mmPerPx, so a
109
109
  // profile target gets the absolute-size score (`iouScale`, contourDist in mm)
110
110
  // while an image target gets the pose-normalized one.
111
- const scoreOpts = { scaleAware: target.kind === "profile" };
111
+ //
112
+ // fillHoles splits the same way, for a reason read off the other side. An image
113
+ // reference is a photograph, and it arrives HOLE-FILLED: its segmenter closes
114
+ // interior openings so a highlight inside the object does not read as
115
+ // background. The mesh silhouette fills nothing, so compared as they come a
116
+ // spoked face scores BELOW a solid disc against its own photo, and the delta
117
+ // paints every window "missing" — pointing whoever reads it at filling in the
118
+ // openings that were the point of the part. So an image target is compared as
119
+ // outer outlines, both sides filled. A profile's rings are authored geometry
120
+ // whose inner rings mean a real hole, so a profile target keeps scoring them.
121
+ const scoreOpts = { scaleAware: target.kind === "profile", fillHoles: target.kind === "image" };
112
122
  const { best, views } = matchViews(viewMasks, reference, scoreOpts);
113
123
  if (!best) continue; // nothing scoreable — a dropped target, never a zero score
114
124
  const { delta, ...scores } = best;
@@ -15,6 +15,12 @@
15
15
  // `contourDist` is then a real millimetre distance instead of a fraction of the
16
16
  // reference's bbox diagonal.
17
17
  //
18
+ // `{fillHoles: true}` asks a different question again: BOTH masks have their enclosed
19
+ // openings closed before anything is measured, so every score compares OUTER OUTLINES and
20
+ // a through-window costs nothing either way. It is for a reference whose own openings are
21
+ // already gone — a segmented photograph, whose segmenter fills them so a highlight inside
22
+ // the object does not read as background. See fillHoles.
23
+ //
18
24
  // A mask with no foreground pixels — or no mask at all — is UNSCOREABLE, not
19
25
  // zero-scoring: matchMasks returns null and matchViews leaves the view out. 0/0 is
20
26
  // never a score.
@@ -27,9 +33,15 @@ const BAND_PX = 2; // boundary band thickness, per the Boundary IoU defin
27
33
  const BIG = 1e20; // "unreachable" seed for the distance transform's lower envelope
28
34
  const MAX_MM_FRAME = 2048; // px ceiling on the scale-aware grid; see mmFrame
29
35
 
30
- // candidate/reference: Task 1 masks. opts: {scaleAware}. → null when either is unscoreable.
36
+ // candidate/reference: Task 1 masks. opts: {scaleAware, fillHoles}. → null when either is
37
+ // unscoreable. `fillHoles` is applied to both masks first, so every score below — iou,
38
+ // boundaryIoU, contourDist, iouScale and delta — is a comparison of outer outlines.
31
39
  export function matchMasks(candidate, reference, opts = {}) {
32
- const cs = stats(candidate), rs = stats(reference);
40
+ const fill = opts.fillHoles === true;
41
+ const cand = fill ? fillHoles(candidate) : candidate;
42
+ const ref = fill ? fillHoles(reference) : reference;
43
+
44
+ const cs = stats(cand), rs = stats(ref);
33
45
  if (!cs || !rs) return null;
34
46
 
35
47
  const nc = normalize(cs), nr = normalize(rs);
@@ -37,10 +49,10 @@ export function matchMasks(candidate, reference, opts = {}) {
37
49
  const boundaryIoU = maskIoU(band(nc), band(nr));
38
50
  const delta = deltaMap(nc, nr);
39
51
 
40
- const scaleAware = opts.scaleAware === true && scaled(candidate) && scaled(reference);
52
+ const scaleAware = opts.scaleAware === true && scaled(cand) && scaled(ref);
41
53
  let contourDist, contourUnit, iouScale;
42
54
  if (scaleAware) {
43
- const [sc, sr, pitch] = mmFrame(cs, candidate.mmPerPx, rs, reference.mmPerPx);
55
+ const [sc, sr, pitch] = mmFrame(cs, cand.mmPerPx, rs, ref.mmPerPx);
44
56
  iouScale = maskIoU(sc, sr);
45
57
  contourDist = contourDistance(sc, sr) * pitch;
46
58
  contourUnit = "mm";
@@ -70,6 +82,41 @@ export function matchViews(viewMasks, reference, opts = {}) {
70
82
  return { best, views };
71
83
  }
72
84
 
85
+ // A copy of `mask` with its enclosed openings closed: background that cannot reach the
86
+ // image border by 4-connected steps becomes foreground, which is a flood fill of the
87
+ // background inward from the border and then an invert. So a bore or a spoke window fills
88
+ // and a notch cut in from the rim — background still reachable from outside — does not.
89
+ //
90
+ // Works at the mask's own resolution, before any normalization, so what the rest of this
91
+ // file measures is the filled shape itself rather than a resampled approximation of it.
92
+ // The frame (`mmPerPx`, `minX`, `minY`) rides through untouched, and the caller's mask is
93
+ // never mutated.
94
+ export function fillHoles(mask) {
95
+ const data = mask?.data, w = mask?.width | 0, h = mask?.height | 0;
96
+ if (!data || !(w > 0) || !(h > 0) || data.length < w * h) return mask;
97
+
98
+ const n = w * h;
99
+ const outside = new Uint8Array(n);
100
+ const stack = new Int32Array(n);
101
+ let top = 0;
102
+ const visit = (i) => { if (!data[i] && !outside[i]) { outside[i] = 1; stack[top++] = i; } };
103
+
104
+ for (let c = 0; c < w; c++) { visit(c); visit((h - 1) * w + c); }
105
+ for (let r = 0; r < h; r++) { visit(r * w); visit(r * w + w - 1); }
106
+ while (top > 0) {
107
+ const i = stack[--top];
108
+ const r = (i / w) | 0, c = i - r * w;
109
+ if (c > 0) visit(i - 1);
110
+ if (c < w - 1) visit(i + 1);
111
+ if (r > 0) visit(i - w);
112
+ if (r < h - 1) visit(i + w);
113
+ }
114
+
115
+ const out = new Uint8Array(n);
116
+ for (let i = 0; i < n; i++) if (!outside[i]) out[i] = 255;
117
+ return { ...mask, data: out, width: w, height: h };
118
+ }
119
+
73
120
  // Squared-then-rooted Euclidean distance (in px) from every pixel to the nearest non-zero
74
121
  // pixel of `data`, by Felzenszwalb–Huttenlocher: a 1D lower-envelope pass over rows, then
75
122
  // over columns. With no non-zero pixel at all every entry comes back astronomically large