partforge 0.108.0 → 0.109.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.
|
|
3
|
+
"version": "0.109.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",
|
|
@@ -96,6 +96,8 @@
|
|
|
96
96
|
"test": "vitest run",
|
|
97
97
|
"test:watch": "vitest",
|
|
98
98
|
"typecheck": "tsc -p tsconfig.json",
|
|
99
|
+
"lint": "eslint .",
|
|
100
|
+
"lint:dead": "knip",
|
|
99
101
|
"check": "node scripts/check-app.mjs",
|
|
100
102
|
"offset-rates": "node scripts/offset-rates.mjs"
|
|
101
103
|
},
|
|
@@ -120,9 +122,13 @@
|
|
|
120
122
|
"three": "^0.184.0"
|
|
121
123
|
},
|
|
122
124
|
"devDependencies": {
|
|
125
|
+
"@eslint/js": "^10.0.1",
|
|
123
126
|
"@fontsource-variable/geist": "^5.3.0",
|
|
124
127
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
128
|
+
"eslint": "^10.10.0",
|
|
129
|
+
"globals": "^17.12.0",
|
|
125
130
|
"happy-dom": "^20.10.6",
|
|
131
|
+
"knip": "^6.35.1",
|
|
126
132
|
"playwright": "^1.49.0",
|
|
127
133
|
"typescript": "^5.9.3",
|
|
128
134
|
"vite": "^8.0.16",
|
package/src/framework/jobs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
@@ -13,8 +13,6 @@
|
|
|
13
13
|
// Main-thread only, and deliberately import-free beyond what a lookup needs: it
|
|
14
14
|
// runs during panel construction, on every rebuild.
|
|
15
15
|
|
|
16
|
-
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
17
|
-
|
|
18
16
|
// `images`/`vectors`/`fonts` may each be a plain map or a function of params —
|
|
19
17
|
// the function form is what a control drives. Resolving it can throw (it is
|
|
20
18
|
// author code running on every panel build), and a broken declaration must not
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
import { createProbeKernel } from "./geometry/probe.js";
|
|
6
6
|
import { byteAwareReplacer } from "./geometry/solid-hash.js";
|
|
7
7
|
import { viewSubParts } from "./part-model.js";
|
|
8
|
-
import { resolveDerived } from "./derive.js";
|
|
9
8
|
|
|
10
9
|
export const RELEVANT_ALL = Symbol("relevant-all");
|
|
11
10
|
|
|
@@ -22,10 +22,7 @@ import {
|
|
|
22
22
|
isLoopbackOrigin, isLoopbackHost,
|
|
23
23
|
} from "./endpoint.js";
|
|
24
24
|
|
|
25
|
-
export {
|
|
26
|
-
PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS, PICK_SERVER_DEFAULT_HOST,
|
|
27
|
-
PICK_SERVER_DEFAULT_URL,
|
|
28
|
-
} from "./endpoint.js";
|
|
25
|
+
export { PICK_SERVER_DEFAULT_PORT, PICK_SERVER_DEFAULT_TIMEOUT_MS } from "./endpoint.js";
|
|
29
26
|
|
|
30
27
|
const MAX_BODY_BYTES = 256 * 1024; // no route needs more; anything bigger is abuse
|
|
31
28
|
const MAX_SELECTION_CHARS = 16 * 1024;
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
// running twist plus an alternating half-facet offset, so the facets zig-zag into a
|
|
4
4
|
// woven look. A second, wall-inset loft is cut from the body to hollow it (Manifold
|
|
5
5
|
// backend, so it stays fast — no OCCT). See docs/AUTHORING-PARTS.md for the conventions.
|
|
6
|
-
import { regularPolygon } from "partforge/geometry";
|
|
7
6
|
|
|
8
7
|
const RINGS = 28; // silhouette resolution (ring count up the height)
|
|
9
8
|
|