partforge 0.79.0 → 0.80.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.
@@ -1596,17 +1596,18 @@ different next actions for whoever is rebuilding the part.
1596
1596
  arcs, holes, dress-ups, sweeps, patterns, symmetry — covers the tool's full detection
1597
1597
  vocabulary; a feature can be *reported* there regardless of shape. The RECONSTRUCTION
1598
1598
  score (`explainedVolumeFraction`, and every accepted feature's own `volumeShare`) is
1599
- narrower: `toCandidate` only proposes box and cylinder footprints as acceptance
1600
- candidates today, so it currently reconstructs prismatic parts built from roughly fewer
1601
- than eight box/cylinder features well, and does not yet reconstruct round bosses,
1599
+ narrower: `toCandidate` proposes prismatic candidates from each feature's own measured
1600
+ footprint the cap's boundary loops extruded directly, arbitrary polygon outlines and
1601
+ interior holes included, alongside the circle/cylinder path so ordinary prismatic
1602
+ parts now reconstruct well regardless of footprint shape. It does not yet reconstruct
1602
1603
  revolves, fillets, chamfers, or shells — those are detected and reported (with
1603
1604
  `volumeShareReason: "not-proposed"`) but never turned into a candidate that could win
1604
1605
  volume back. Measured directly on this repo's own reference parts: `demo.js`
1605
- reconstructs 65.7% of its volume, `filleted-box.js` 36.2%, `bracket.js` 23.6%, a plain
1606
- tube 0.0%, and a hollow box 1.9%. The low-coverage banner fires on every one of these, so
1607
- nothing here is misreported but a low `explainedVolumeFraction` on a turned or
1608
- feature-dense part means **"not yet reconstructable by this tool"**, not "not
1609
- understood" or "broken." Read the FACTS (features, surfaces, patterns) as the ground
1606
+ reconstructs 100% of its volume, `filleted-box.js` 94.9%, `bracket.js` 100%; a plain
1607
+ tube and a hollow box still score ~0%, because curved-wall extrusions and shells remain
1608
+ unproposed. The low-coverage banner fires wherever the worse score is low, so nothing
1609
+ here is misreported but a low `explainedVolumeFraction` on a turned or shelled part
1610
+ means **"not yet reconstructable by this tool"**, not "not understood" or "broken." Read the FACTS (features, surfaces, patterns) as the ground
1610
1611
  truth regardless of the volume score; read the volume score as a measure of how much of
1611
1612
  that ground truth also comes with a working, boolean-verified rebuild recipe.
1612
1613
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.79.0",
3
+ "version": "0.80.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",
@@ -180,6 +180,66 @@ function surfaceVertices(topo, surfById, ids, faceScope) {
180
180
  return out;
181
181
  }
182
182
 
183
+ // A deterministic unit vector perpendicular to `w`: orthogonalize the world axis
184
+ // with the smallest |component| along `w`. Deterministic matters — describe is memoed
185
+ // by content digest, so a candidate's frame must be a pure function of the mesh.
186
+ const perpTo = (w) => {
187
+ const ax = Math.abs(w[0]) <= Math.abs(w[1]) && Math.abs(w[0]) <= Math.abs(w[2]) ? [1, 0, 0]
188
+ : Math.abs(w[1]) <= Math.abs(w[2]) ? [0, 1, 0] : [0, 0, 1];
189
+ return orthogonalize(ax, w);
190
+ };
191
+
192
+ // The cap's own measured boundary loops as ABSOLUTE (u,v) coordinates in the cap's
193
+ // plane — the real footprint, where the box branch below can only offer the
194
+ // footprint's bounding rectangle (mostly air on an L-bracket or a sword-shaped
195
+ // bookmark, so the candidate loses on xor-gain and the feature reconstructs
196
+ // nothing). `surface-graph.js` already chained these loops per surface; a merged
197
+ // surface's list can carry OTHER islands' rims too (mergeCoFamily joins co-planar
198
+ // patches that never touch), so when the feature carries a `faceScope` the loops are
199
+ // filtered to those whose every vertex belongs to this island's own triangles.
200
+ // The largest-area survivor is the outer contour; the rest are holes (an annular
201
+ // cap's second loop — exactly the signal the hole/pocket rules already read).
202
+ // Winding is normalized to CCW for both, the orientation `kernel.extrude` expects
203
+ // of a contour. Returns null when no loop survives — callers fall back to the
204
+ // bounding-box candidate, honest about being one.
205
+ function footprintLoops(topo, cap, scopeFaces, u, v, claimedVerts) {
206
+ let allowed = null;
207
+ if (scopeFaces) {
208
+ allowed = new Set();
209
+ for (const t of scopeFaces) for (let c = 0; c < 3; c++) allowed.add(topo.tris[3 * t + c]);
210
+ }
211
+ const loops = [];
212
+ for (const loop of cap.loops ?? []) {
213
+ if (loop.length < 3) continue;
214
+ if (allowed && !loop.every((vi) => allowed.has(vi))) continue;
215
+ const pts = loop.map((vi) => {
216
+ const pnt = [topo.verts[3 * vi], topo.verts[3 * vi + 1], topo.verts[3 * vi + 2]];
217
+ return [dot3(pnt, u), dot3(pnt, v)];
218
+ });
219
+ let a2 = 0; // shoelace, signed — sign is the winding, magnitude ranks outer vs holes
220
+ for (let i = 0; i < pts.length; i++) {
221
+ const a = pts[i], q = pts[(i + 1) % pts.length];
222
+ a2 += a[0] * q[1] - a[1] * q[0];
223
+ }
224
+ loops.push({ pts, vis: loop, area: Math.abs(a2) / 2, ccw: a2 > 0 });
225
+ }
226
+ if (!loops.length) return null;
227
+ loops.sort((a, b) => b.area - a.area);
228
+ const ccw = (l) => (l.ccw ? l.pts : [...l.pts].reverse());
229
+ // An interior loop whose rim another feature CLAIMS (a detected hole's bore — the
230
+ // rim ring is shared between the cap and the bore wall, so every loop vertex sits
231
+ // in the bore surface's own vertex set) is left OUT of the footprint: that hole's
232
+ // own cut candidate owns it. Without this, an annular cap rebuilt hole-included
233
+ // leaves the hole feature nothing to explain — measured on the washer fixture,
234
+ // whose through-hole's volumeShare went to null the moment footprints landed,
235
+ // exactly the double-explanation this guard prevents. A parametric author would
236
+ // decompose it the same way: base profile, then a bore with its own diameter.
237
+ // Unclaimed interior loops (a square cutout no hole rule recognizes) stay in the
238
+ // footprint — better an honest hole in the prism than 12.5% unexplained volume.
239
+ const unclaimed = (l) => !claimedVerts || !l.vis.every((vi) => claimedVerts.has(vi));
240
+ return { outer: ccw(loops[0]), holes: loops.slice(1).filter(unclaimed).map(ccw) };
241
+ }
242
+
183
243
  export function describe(kernel, solid, opts = {}) {
184
244
  // A live Solid in, not a mesh. The kernel exposes no public mesh->solid constructor —
185
245
  // geometry only enters through `_registerImport` + `import(name)` — and acceptance needs
@@ -270,8 +330,21 @@ export function describe(kernel, solid, opts = {}) {
270
330
  // world Z, or — round 2 review's CRITICAL finding — reading the whole mesh's bounds
271
331
  // for every feature and building every candidate the same full-part size.
272
332
  const surfById = new Map(graph.surfaces.map((s) => [s.id, s]));
333
+ // Every vertex belonging to a surface some HOLE feature claims — the set
334
+ // footprintLoops consults so a footprint never re-explains a rim a hole's own cut
335
+ // candidate owns (see its comment for the washer measurement that forced this).
336
+ const claimedVerts = new Set();
337
+ for (const f of features) {
338
+ if (f.type !== "throughHole" && f.type !== "blindHole") continue;
339
+ for (const id of f.surfaces ?? []) {
340
+ const surf = surfById.get(id);
341
+ for (const t of surf?.faces ?? []) {
342
+ for (let c = 0; c < 3; c++) claimedVerts.add(topo.tris[3 * t + c]);
343
+ }
344
+ }
345
+ }
273
346
  const candidates = features
274
- .map((f) => toCandidate(kernel, f, b, { surfById, topo }))
347
+ .map((f) => toCandidate(kernel, f, b, { surfById, topo, claimedVerts }))
275
348
  .filter(Boolean);
276
349
 
277
350
  const graded = acceptCandidates(kernel, solid, candidates, { budget: opts.budget });
@@ -497,6 +570,38 @@ function toCandidate(kernel, f, b, ctx) {
497
570
  // projected onto that exact (u, v, direction) frame — `projectedBounds`, this
498
571
  // file's general-frame twin of mesh.js's `bounds()` — rather than the world-axis
499
572
  // bbox `size`/`f.depth` used above for the (rotation-insensitive) circle case.
573
+ // Loop-based candidate first: the cap's own measured boundary loops ARE the
574
+ // footprint (footprintLoops above), so when they are available the candidate is
575
+ // the real prism — outer contour extruded, hole contours honoured — rather than
576
+ // either rectangle below. The frame's in-plane axis is arbitrary (perpTo): the
577
+ // loop coordinates are absolute projections onto (u, v), so any orthonormal pair
578
+ // perpendicular to `direction` reproduces the same world-space solid after
579
+ // orientOnto. Depth still comes from THIS FEATURE'S OWN vertices projected onto
580
+ // `direction` (the same projectedBounds discipline as the box branch, round 2
581
+ // review's CRITICAL finding). A candidate whose loops were mis-chained or span a
582
+ // merged surface's other island simply scores a poor xor-gain and is rejected —
583
+ // the same honesty the box fallback has always leaned on.
584
+ const cap = ctx?.surfById?.get(f.floorFace);
585
+ if (cap?.loops?.length && ctx?.topo) {
586
+ const u = perpTo(direction);
587
+ const v = cross3(direction, u);
588
+ const fp = footprintLoops(ctx.topo, cap, f.faceScope?.[f.floorFace], u, v, ctx.claimedVerts);
589
+ if (fp) {
590
+ const ownSurfaces = [f.floorFace, ...(f.wallFaces ?? [])].filter(Boolean);
591
+ return {
592
+ key: f.key, featureKey: f.key, op, explains: [f.id],
593
+ dimension: f.depth, paramName: "height", hintOp: f.type === "boss" ? "union" : f.type === "pocket" ? "cut" : "box",
594
+ hintArgs: { shape: f.profile.kind, depth: f.depth },
595
+ build: () => {
596
+ const verts = surfaceVertices(ctx.topo, ctx.surfById, ownSurfaces, f.faceScope);
597
+ const bnd = projectedBounds(verts, [u, v, direction]);
598
+ const local = kernel.extrude({ profile: { outer: fp.outer, holes: fp.holes }, h: bnd.max[2] - bnd.min[2] });
599
+ const oriented = orientOnto(local, direction, u);
600
+ return oriented.translate(scale3(direction, bnd.min[2]));
601
+ },
602
+ };
603
+ }
604
+ }
500
605
  const wallPlane = ctx?.surfById && f.wallFaces
501
606
  ? f.wallFaces.map((id) => ctx.surfById.get(id)).find((s) => s?.type === "plane")
502
607
  : null;