partforge 0.118.0 → 0.120.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/bin/cli.js +14 -2
- package/docs/AUTHORING-PARTS.md +58 -7
- package/docs/KERNEL-CONTRACT.md +3 -3
- package/package.json +1 -1
- package/src/framework/geometry/contour-ops.js +41 -6
- package/src/framework/lint/rules-shape.js +2 -2
- package/src/framework/oracle/measure.js +24 -5
- package/src/framework/oracle/shape-probe.js +146 -0
- package/types/geometry.d.ts +5 -3
- package/types/kernel.d.ts +19 -4
- package/types/testing.d.ts +57 -0
package/bin/cli.js
CHANGED
|
@@ -13,8 +13,6 @@ import { fontsFor } from "../src/framework/fonts.js";
|
|
|
13
13
|
import { imagesFor } from "../src/framework/images.js";
|
|
14
14
|
import { isNoImageSource } from "../src/framework/image-source.js";
|
|
15
15
|
import { viewAnimations, evaluate, cueAt } from "../src/framework/animation.js";
|
|
16
|
-
import { measure } from "../src/framework/oracle/measure.js";
|
|
17
|
-
import { verify } from "../src/framework/oracle/verify.js";
|
|
18
16
|
import { renderViews } from "../src/testing/render.js";
|
|
19
17
|
import {
|
|
20
18
|
createPickServer, requestPicks, formatPickResult,
|
|
@@ -114,7 +112,20 @@ const readSources = (partPath) => {
|
|
|
114
112
|
// `ingest` itself never touches these three. Deferring the import to the
|
|
115
113
|
// commands that actually need them keeps every other verb's behavior
|
|
116
114
|
// unchanged and keeps paper-core.js's first load in `ingest`'s hands.
|
|
115
|
+
//
|
|
116
|
+
// oracle/measure.js and oracle/verify.js join that list for the same reason,
|
|
117
|
+
// since measure.js now statically imports oracle/shape-probe.js, which reaches
|
|
118
|
+
// geometry/contour-ops.js -> geometry/paper-bridge.js -> paper-core just like
|
|
119
|
+
// vectors.js does (verify.js reaches the same path through its own static
|
|
120
|
+
// import of measure.js). A top-level `import { measure } ...`/`import { verify }
|
|
121
|
+
// ...` here would load paper-core before `ingest`'s installNodeDom() runs on
|
|
122
|
+
// every invocation, not only `measure`'s — deferring both to the one verb that
|
|
123
|
+
// calls them keeps `ingest` first in line for paper-core's environment probe.
|
|
117
124
|
const importVectors = () => import("../src/framework/vectors.js");
|
|
125
|
+
const importOracle = () => Promise.all([
|
|
126
|
+
import("../src/framework/oracle/measure.js"),
|
|
127
|
+
import("../src/framework/oracle/verify.js"),
|
|
128
|
+
]);
|
|
118
129
|
|
|
119
130
|
// Pass the part's declared fonts through, mirroring the worker path (jobs.js) —
|
|
120
131
|
// otherwise a part using a named font builds in the browser but dies headlessly
|
|
@@ -210,6 +221,7 @@ const commands = {
|
|
|
210
221
|
}
|
|
211
222
|
}
|
|
212
223
|
const kernel = await bootKernel(part);
|
|
224
|
+
const [{ measure }, { verify }] = await importOracle();
|
|
213
225
|
const report = measure(kernel, part, view);
|
|
214
226
|
if (!flags.json) printMeasure(report);
|
|
215
227
|
// Write --out right after measure succeeds, then re-write once verify has
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -89,7 +89,7 @@ export default {
|
|
|
89
89
|
},
|
|
90
90
|
},
|
|
91
91
|
views: { <name>: { label, default?, animations? } }, // view tabs; a view may own animations (below)
|
|
92
|
-
probes?, // { name: (k, p, d) => Solid | plain JSON } — measurements reported by
|
|
92
|
+
probes?, // { name: (k, p, d) => Solid | Shape2D | plain JSON } — measurements reported by
|
|
93
93
|
// measure/inspect, never rendered or exported (see "Probes" below)
|
|
94
94
|
};
|
|
95
95
|
```
|
|
@@ -1534,20 +1534,32 @@ its coordinates, and the max that would work — rather than silently clamping:
|
|
|
1534
1534
|
|---|---|
|
|
1535
1535
|
| `filletProfile(input, r, opts?)` | `r`: number, or an array matched positionally with `opts.corners.indices` |
|
|
1536
1536
|
| `chamferProfile(input, dist, opts?)` | symmetric setback, straight connector |
|
|
1537
|
-
| `profileCorners(input)` | `[{index, point, interiorAngleDeg, convex, segTypes}]` |
|
|
1537
|
+
| `profileCorners(input)` | `[{index, position, point, interiorAngleDeg, convex, segTypes}]` — `position` is the entry's place in this list, `index` the joint's vertex number within its contour |
|
|
1538
1538
|
|
|
1539
1539
|
`opts.corners` selects which corners an op touches (default `"all"`):
|
|
1540
1540
|
|
|
1541
1541
|
- `"all"` · `"convex"` · `"concave"`
|
|
1542
|
-
- `{indices: [...]}` —
|
|
1543
|
-
with an array `r`/`dist` for per-corner radii (the
|
|
1544
|
-
|
|
1545
|
-
|
|
1542
|
+
- `{indices: [...]}` — each corner's **`position`** in `profileCorners(input)`'s return
|
|
1543
|
+
list, never its `index`; pair with an array `r`/`dist` for per-corner radii (the
|
|
1544
|
+
`roundedProfile` pattern). Any entry out of range throws, naming the range.
|
|
1545
|
+
- `{near: [x,y], count?: 1, within?: mm}` — nearest-corner selection; the hook for a
|
|
1546
|
+
human pick or an agent resolving "the top-left corner" from bbox reasoning. **Without
|
|
1547
|
+
`within` the nearest corner is always selected, however far away** — `count: 4`
|
|
1548
|
+
applied around a small pocket rounds the four corners nearest it, which may all
|
|
1549
|
+
belong to the outer wall. Pass `within` when the pick must be local; a `near` with
|
|
1550
|
+
nothing inside `within` throws instead of reaching further.
|
|
1551
|
+
|
|
1552
|
+
**`index` and `position` are different numbers.** `index` is the vertex number within
|
|
1553
|
+
the contour; `position` is the corner's place in the returned list. They agree only
|
|
1554
|
+
while every joint is a corner — the moment a contour has a smooth joint (a collinear
|
|
1555
|
+
midpoint, a G1 arc-to-line join, a fillet already applied) the vertex numbers skip
|
|
1556
|
+
ahead of the positions, and `.map((c) => c.index)` fed to `{indices}` fillets the
|
|
1557
|
+
wrong corners or throws out of range. Always map to `c.position`.
|
|
1546
1558
|
|
|
1547
1559
|
```js
|
|
1548
1560
|
// Fillet only the two corners nearest the profile's top edge, 3mm and 1.5mm:
|
|
1549
1561
|
const corners = profileCorners(outline);
|
|
1550
|
-
const top = corners.filter((c) => c.point[1] > 20).map((c) => c.
|
|
1562
|
+
const top = corners.filter((c) => c.point[1] > 20).map((c) => c.position); // position, NOT index
|
|
1551
1563
|
const rounded = filletProfile(outline, [3, 1.5], { corners: { indices: top } });
|
|
1552
1564
|
|
|
1553
1565
|
// Fillet every convex corner of a Shape2D by the same amount:
|
|
@@ -2407,6 +2419,45 @@ declare a probe that reads the value, run `measure`, and bake the reported
|
|
|
2407
2419
|
number into `defaults` — the probe then keeps watching it on every regen, so a
|
|
2408
2420
|
swapped import shows up as a probe delta instead of silently stale defaults.
|
|
2409
2421
|
|
|
2422
|
+
**Reading a 2-D shape's arcs and corners.** Return the `Shape2D` itself and the
|
|
2423
|
+
report carries a summary instead of the object: a flat `rings` list — one entry
|
|
2424
|
+
per ring, each naming where it came from (`region`, `ring: "outer" | "hole"`,
|
|
2425
|
+
plus `hole` on a hole ring) — every arc as `{ center, r, from, to, sweepDeg }`
|
|
2426
|
+
(exact for a `{to, via}` arc; a cubic is fitted through its start, midpoint and
|
|
2427
|
+
end and tagged `fit: "cubic"`), every corner as `{ position, point,
|
|
2428
|
+
interiorAngleDeg, convex }`, plus `area`, `bbox`, and a straight-segment count
|
|
2429
|
+
per ring. `position` is a running count across every ring in that same order —
|
|
2430
|
+
region by region, outer then holes — which is exactly the positional index
|
|
2431
|
+
`fillet({corners: {indices}})` and `shape.corners()` select on, so a corner read
|
|
2432
|
+
here can be fed straight back into a fillet call.
|
|
2433
|
+
|
|
2434
|
+
Fillet itself never emits a cubic — paper.js has no arc primitive, so an
|
|
2435
|
+
exactly-constructed `{to, via}` arc only becomes a cubic once the shape has been
|
|
2436
|
+
through a **boolean** (union/cut/intersect), a non-uniform transform, or
|
|
2437
|
+
`simplify`. So after any of those, every arc in the summary is `fit: "cubic"`,
|
|
2438
|
+
including ones a fillet placed exactly. For an unclipped arc that cubic fit is
|
|
2439
|
+
still exact to the reporting grid; when a boolean clips an arc **mid-sweep**,
|
|
2440
|
+
the fit's centre and radius drift, and the drift grows as the kept fragment
|
|
2441
|
+
shortens. So when you need an exact centre, read it off the **unclipped**
|
|
2442
|
+
shape and use a clip only to find which arc to look at — don't trust the centre
|
|
2443
|
+
reported on the clipped fragment itself:
|
|
2444
|
+
|
|
2445
|
+
```js
|
|
2446
|
+
probes: {
|
|
2447
|
+
fullBend: (k, p, d) => trayPocket(k, p, d),
|
|
2448
|
+
// Clip only to locate the arc; read its numbers off fullBend above, not here.
|
|
2449
|
+
bendNear: (k, p, d) => trayPocket(k, p, d).intersect(k.shape2d([[18, -6], [28, -6], [28, 4], [18, 4]])),
|
|
2450
|
+
}
|
|
2451
|
+
// → probes.fullBend.rings[0].arcs: [{ center: [23.75, -3], r: 4, … }, …]
|
|
2452
|
+
```
|
|
2453
|
+
|
|
2454
|
+
This is the instrument for any question a render cannot settle to a fraction of
|
|
2455
|
+
a millimetre: whether two arcs share a centre (a bend's inner and outer radii),
|
|
2456
|
+
what radius a `fillet` actually took after clamping, whether a corner is still
|
|
2457
|
+
a corner. The summary lists at most 64 arcs and 64 corners **in total**, across
|
|
2458
|
+
every ring — not 64 each per ring — and says `truncated` when it had to cut, so
|
|
2459
|
+
keep the probe to the region in question.
|
|
2460
|
+
|
|
2410
2461
|
**Cost.** Probes run on every `measure`/`inspect` (including quick checks — the
|
|
2411
2462
|
agent loop is exactly who reads them), so keep them proportionate: a handful of
|
|
2412
2463
|
thin-slab booleans is cheap; a dense sweep of whole-part XORs is not. `verify`'s
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -614,9 +614,9 @@ backwards for the holes (see the migration note below).
|
|
|
614
614
|
| `simple()` | `toRegions()` unwrapped — throws unless the result is exactly one region. |
|
|
615
615
|
| `regions()` | Scission: each disjoint region as its own live `Shape2D[]` (each further boolean-able), vs `toRegions()` which returns raw `{outer, holes}` data. Goes through `toRegions()`, so the pieces are tessellated at the backend's LOD — curves do not survive scission. |
|
|
616
616
|
| `translate([dx,dy])` / `rotate(deg, center?)` / `scale(f\|[sx,sy], center?)` / `mirror(axis)` | Rigid/similarity transforms on the contours (curve-preserving). `center` defaults to the origin; `axis` is `"x"`, `"y"`, or `{point, dir}`. `scale`'s factor is uniform when a bare number, per-axis when `[sx,sy]`. |
|
|
617
|
-
| `fillet(r, {corners?})` / `chamfer(d, {corners?})` | Round (true arcs) or bevel (straight chords) selected corners. `corners` = `"all"` (default) / `"convex"` / `"concave"` / `{indices}` / `{near, count?}`; `r`/`d` may be an array paired positionally with `{indices}`. Throws when no corner matches. |
|
|
617
|
+
| `fillet(r, {corners?})` / `chamfer(d, {corners?})` | Round (true arcs) or bevel (straight chords) selected corners. `corners` = `"all"` (default) / `"convex"` / `"concave"` / `{indices}` / `{near, count?, within?}`; `r`/`d` may be an array paired positionally with `{indices}`. `{indices}` entries are each corner's `position` in `corners()` and any entry out of range throws (never silently dropped). `{near}` takes the `count` (default 1) nearest corners; without `within` (mm) the nearest is always selected however far away, with it a pick that finds nothing inside the radius throws. Throws when no corner matches. |
|
|
618
618
|
| `simplify(tolerance)` | Corner-preserving decimation/refit within `tolerance` mm — dense point rings become fewer segments (and refit arcs/cubics) without moving corners. |
|
|
619
|
-
| `corners()` | The corner list — `{index, point, interiorAngleDeg, convex, segTypes}[]`.
|
|
619
|
+
| `corners()` | The corner list — `{index, position, point, interiorAngleDeg, convex, segTypes}[]`. `position` is the entry's place in this list and is what `fillet`/`chamfer`'s `{indices}` selects by; `index` is the joint's vertex number within its own contour (what smooth joints are skipped from), and the two diverge past any smooth joint. |
|
|
620
620
|
| `contains([x,y])` | Point-in-shape test (inside an outer, not inside a hole). |
|
|
621
621
|
| `isEmpty()` | `true` when the shape has no regions at all — a `cut`/`intersect` legitimately removed everything. Pure JS on the stored IR, backend-identical. See "Empty shapes" below. |
|
|
622
622
|
| `extrude({h, twist?, scaleTop?})` | Sugar for `k.extrude({profile: this, …})` → `Solid`. Throws on an empty shape (see "Empty shapes"). |
|
|
@@ -766,7 +766,7 @@ being single-contour by nature, they throw on a region. The full set: `translate
|
|
|
766
766
|
| | `mirrorProfile(input, axis)` | `axis: "x" \| "y" \| {point, dir}` |
|
|
767
767
|
| Corners | `filletProfile(input, r, opts?)` | `r` may be an array paired with `{indices}` |
|
|
768
768
|
| | `chamferProfile(input, dist, opts?)` | symmetric setback, straight connector |
|
|
769
|
-
| | `profileCorners(input)` | `[{index, point, interiorAngleDeg, convex, segTypes}]` |
|
|
769
|
+
| | `profileCorners(input)` | `[{index, position, point, interiorAngleDeg, convex, segTypes}]`; `{indices}` selects by `position` |
|
|
770
770
|
| Queries | `profileLength(contour)` | mm; single contour only |
|
|
771
771
|
| | `profilePointAt(contour, {t} \| {length})` | single contour only |
|
|
772
772
|
| | `profileTangentAt(contour, {t} \| {length})` | unit vector; single contour only |
|
package/package.json
CHANGED
|
@@ -188,15 +188,23 @@ function contourCorners(contour) {
|
|
|
188
188
|
return out;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Two numbers on every corner, and they are NOT interchangeable. `index` is the joint's
|
|
192
|
+
// vertex number within its own contour (what buildCornerOpRing, simplify and the lofts
|
|
193
|
+
// key on); `position` is the corner's place in THIS returned list — the one
|
|
194
|
+
// `{corners: {indices}}` selects by. They diverge as soon as a contour has a smooth
|
|
195
|
+
// joint (a collinear midpoint, a G1 arc-line join), and on region input `index`
|
|
196
|
+
// restarts per ring while `position` runs on through the flattened order.
|
|
197
|
+
const withPositions = (corners) => corners.map((c, position) => ({ ...c, position }));
|
|
198
|
+
|
|
191
199
|
export function profileCorners(input) {
|
|
192
200
|
const { kind, regions } = liftProfile(input);
|
|
193
|
-
if (kind === "points" || kind === "contour") return contourCorners(regions[0].outer);
|
|
201
|
+
if (kind === "points" || kind === "contour") return withPositions(contourCorners(regions[0].outer));
|
|
194
202
|
const out = [];
|
|
195
203
|
regions.forEach((rg, regionIndex) => {
|
|
196
204
|
for (const c of contourCorners(rg.outer)) out.push({ regionIndex, ring: "outer", ...c });
|
|
197
205
|
rg.holes.forEach((h, hi) => { for (const c of contourCorners(h)) out.push({ regionIndex, ring: { hole: hi }, ...c }); });
|
|
198
206
|
});
|
|
199
|
-
return out;
|
|
207
|
+
return withPositions(out);
|
|
200
208
|
}
|
|
201
209
|
|
|
202
210
|
// ── Fillet / chamfer ─────────────────────────────────────────────────────────
|
|
@@ -205,6 +213,19 @@ export function profileCorners(input) {
|
|
|
205
213
|
// indexes this array POSITIONALLY, matching profileCorners' documented contract).
|
|
206
214
|
// r/dist may be an array paired positionally with {indices}; every other selector
|
|
207
215
|
// broadcasts the scalar to every match. Throws when nothing matches.
|
|
216
|
+
//
|
|
217
|
+
// {indices} REJECTS any entry outside 0…corners.length-1 rather than dropping it.
|
|
218
|
+
// It used to filter silently, and the author who wrote `.map((c) => c.index)` —
|
|
219
|
+
// a vertex number, not a position — then filleted the wrong corners with no error
|
|
220
|
+
// once the out-of-range ones fell away (partforge-cloud feedback #95: ~15 applies
|
|
221
|
+
// chasing an L-bend whose arcs would not come out concentric). A throw that names
|
|
222
|
+
// the two fields is the coaching that ends that loop on the first attempt.
|
|
223
|
+
//
|
|
224
|
+
// {near} takes an optional `within` (mm). WITHOUT it the nearest `count` corners
|
|
225
|
+
// are always selected, however far away — "round the four outer corners" applied per
|
|
226
|
+
// compartment happily rounds the four corners nearest a small pocket that touches
|
|
227
|
+
// none of them. `within` turns a far pick into the same no-match error as any
|
|
228
|
+
// other empty selector.
|
|
208
229
|
function resolveCornerSelector(corners, param, opts, label) {
|
|
209
230
|
const sel = (opts && opts.corners) ?? "all";
|
|
210
231
|
const isArrayParam = Array.isArray(param);
|
|
@@ -218,14 +239,28 @@ function resolveCornerSelector(corners, param, opts, label) {
|
|
|
218
239
|
const perCorner = isArrayParam ? param : null;
|
|
219
240
|
if (perCorner && perCorner.length !== sel.indices.length)
|
|
220
241
|
throw new Error(`${label}: per-corner radius array has ${perCorner.length} entries but {indices} has ${sel.indices.length}`);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
.
|
|
242
|
+
const n = corners.length;
|
|
243
|
+
picked = sel.indices.map((idx, j) => {
|
|
244
|
+
if (!Number.isInteger(idx))
|
|
245
|
+
throw new Error(`${label}: {indices} entry ${idx} is not an integer — {indices} takes each corner's \`position\` from profileCorners()`);
|
|
246
|
+
if (idx < 0 || idx >= n)
|
|
247
|
+
throw new Error(
|
|
248
|
+
`${label}: {indices} entry ${idx} is out of range — profileCorners() reported ${n} corner${n === 1 ? "" : "s"} (positions 0…${Math.max(0, n - 1)}). ` +
|
|
249
|
+
"{indices} takes each corner's `position` (its place in that list), not its `index` (its vertex number within the contour)",
|
|
250
|
+
);
|
|
251
|
+
return { corner: corners[idx], param: perCorner ? perCorner[j] : param };
|
|
252
|
+
});
|
|
224
253
|
} else if (sel && Array.isArray(sel.near)) {
|
|
225
254
|
const [nx, ny] = sel.near;
|
|
226
255
|
const count = sel.count ?? 1;
|
|
256
|
+
const within = sel.within;
|
|
257
|
+
if (within !== undefined && !(Number.isFinite(within) && within > 0))
|
|
258
|
+
throw new Error(`${label}: {near} within must be a positive number of mm (got ${within})`);
|
|
227
259
|
const distSq = (c) => (c.point[0] - nx) ** 2 + (c.point[1] - ny) ** 2;
|
|
228
|
-
|
|
260
|
+
const candidates = within === undefined ? corners.slice() : corners.filter((c) => distSq(c) <= within * within);
|
|
261
|
+
if (candidates.length === 0)
|
|
262
|
+
throw new Error(`${label}: no corner within ${within}mm of (${nx}, ${ny}) — the nearest is ${Math.sqrt(Math.min(...corners.map(distSq))).toFixed(3)}mm away`);
|
|
263
|
+
picked = candidates.sort((a, b) => distSq(a) - distSq(b)).slice(0, count).map((corner) => ({ corner, param }));
|
|
229
264
|
} else picked = [];
|
|
230
265
|
if (picked.length === 0) throw new Error(`${label}: no corner matched selector ${JSON.stringify(sel)}`);
|
|
231
266
|
return picked;
|
|
@@ -49,13 +49,13 @@ export const SHAPE_RULES = [
|
|
|
49
49
|
if (part?.probes === undefined) return [];
|
|
50
50
|
if (!isPlainObject(part.probes)) {
|
|
51
51
|
return [err("invalid-probes", "`probes` must be an object mapping names to functions",
|
|
52
|
-
"Declare probes as `probes: { name: (k, p, d) => Solid | plain JSON }` — each is measured into the report by `partforge measure` and the inspect job.",
|
|
52
|
+
"Declare probes as `probes: { name: (k, p, d) => Solid | Shape2D | plain JSON }` — each is measured into the report by `partforge measure` and the inspect job.",
|
|
53
53
|
"probes")];
|
|
54
54
|
}
|
|
55
55
|
return Object.entries(part.probes)
|
|
56
56
|
.filter(([, fn]) => typeof fn !== "function")
|
|
57
57
|
.map(([name]) => err("invalid-probes", `probe "${name}" is not a function`,
|
|
58
|
-
"Every entry in `probes` must be a `(k, p, d)` function returning a Solid (measured into facts) or plain JSON (reported verbatim).",
|
|
58
|
+
"Every entry in `probes` must be a `(k, p, d)` function returning a Solid (measured into facts), a Shape2D (summarised into its arcs and corners), or plain JSON (reported verbatim).",
|
|
59
59
|
`probes.${name}`));
|
|
60
60
|
},
|
|
61
61
|
},
|
|
@@ -7,6 +7,7 @@ import { bounds, meshArea, meshCentroid } from "./mesh.js";
|
|
|
7
7
|
import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
|
|
8
8
|
import { overhang } from "./overhang.js";
|
|
9
9
|
import { partGatesMinWall, partOverhangAngle } from "./gates.js";
|
|
10
|
+
import { summarizeContours } from "./shape-probe.js";
|
|
10
11
|
|
|
11
12
|
const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
|
12
13
|
const unionBounds = (list) => list.reduce(
|
|
@@ -15,20 +16,37 @@ const unionBounds = (list) => list.reduce(
|
|
|
15
16
|
);
|
|
16
17
|
|
|
17
18
|
// ── probes ──────────────────────────────────────────────────────────────────
|
|
18
|
-
// Part-declared measurements: `probes: { name: (k, p, d) => Solid | JSON }`,
|
|
19
|
+
// Part-declared measurements: `probes: { name: (k, p, d) => Solid | Shape2D | JSON }`,
|
|
19
20
|
// pure functions with build's exact contract but whose result lands in the
|
|
20
21
|
// REPORT instead of the scene. The instrument a rebuild-against-reference
|
|
21
22
|
// workflow needs — before this, getting a cross-section's numbers out of the
|
|
22
23
|
// pipeline meant authoring throwaway `exportable: false` sub-parts and fishing
|
|
23
24
|
// their facts out of the sub-part list (the "Probes" feedback report).
|
|
24
25
|
// A Solid anywhere in the return value (duck-typed on volume+toMesh, the two
|
|
25
|
-
// queries the facts need) is replaced by a fact object;
|
|
26
|
-
// pass through; a throw becomes
|
|
27
|
-
// they never crash the measurement and
|
|
26
|
+
// queries the facts need) is replaced by a fact object; a Shape2D by its arc/corner
|
|
27
|
+
// summary (shape-probe.js); scalars/arrays/objects pass through; a throw becomes
|
|
28
|
+
// `{ error }` — probes are instrumentation, so they never crash the measurement and
|
|
29
|
+
// never gate `ok`.
|
|
28
30
|
|
|
29
31
|
const isSolid = (v) => v !== null && typeof v === "object"
|
|
30
32
|
&& typeof v.volume === "function" && typeof v.toMesh === "function";
|
|
31
33
|
|
|
34
|
+
// A Shape2D is a plain object carrying a `_shape2d` marker, not a class instance, but
|
|
35
|
+
// walking it as generic JSON would still leak its storage fields (`_regions`, `_hash`)
|
|
36
|
+
// and turn its methods into `{error}` entries. Duck-typed on the two reads the summary
|
|
37
|
+
// needs, so a foreign shape-like value (same two methods, no marker) is still summarised.
|
|
38
|
+
const isShape2D = (v) => v !== null && typeof v === "object"
|
|
39
|
+
&& typeof v.toContours === "function" && typeof v.area === "function";
|
|
40
|
+
|
|
41
|
+
const shapeProbeFacts = (shape) => {
|
|
42
|
+
const empty = typeof shape.isEmpty === "function" ? shape.isEmpty() : false;
|
|
43
|
+
return summarizeContours(shape.toContours(), {
|
|
44
|
+
isEmpty: empty,
|
|
45
|
+
area: empty ? 0 : shape.area(),
|
|
46
|
+
bbox: empty ? null : (typeof shape.boundingBox === "function" ? shape.boundingBox() : null),
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
32
50
|
function solidProbeFacts(solid) {
|
|
33
51
|
const mesh = solid.toMesh();
|
|
34
52
|
// Empty = the probe's boolean found nothing (a slab that misses the part).
|
|
@@ -60,8 +78,9 @@ function solidProbeFacts(solid) {
|
|
|
60
78
|
const MAX_PROBE_VALUE_DEPTH = 4;
|
|
61
79
|
function resolveProbeValue(v, depth = 0) {
|
|
62
80
|
if (isSolid(v)) return solidProbeFacts(v);
|
|
81
|
+
if (isShape2D(v)) return shapeProbeFacts(v);
|
|
63
82
|
if (v === null || typeof v !== "object") {
|
|
64
|
-
return typeof v === "function" ? { error: "probe returned a function — return a Solid or plain JSON" } : v;
|
|
83
|
+
return typeof v === "function" ? { error: "probe returned a function — return a Solid, a Shape2D or plain JSON" } : v;
|
|
65
84
|
}
|
|
66
85
|
if (depth >= MAX_PROBE_VALUE_DEPTH) return { error: `probe value deeper than ${MAX_PROBE_VALUE_DEPTH} levels` };
|
|
67
86
|
if (Array.isArray(v)) return v.map((x) => resolveProbeValue(x, depth + 1));
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The probe-report summary of a Shape2D: its arcs as centre + radius, its corners as
|
|
2
|
+
// point + angle. A reader asking "do these two arcs share a centre?" or "what radius
|
|
3
|
+
// did that fillet actually take?" reads the numbers here instead of a render.
|
|
4
|
+
//
|
|
5
|
+
// Arcs come from the contour IR, never from tessellation: a {to, via} segment is exact
|
|
6
|
+
// (circumcircle of from/via/to). A cubic is FITTED through its start, midpoint and end
|
|
7
|
+
// and tagged `fit: "cubic"` so the reader knows it is an approximation. Fillet itself
|
|
8
|
+
// never emits a cubic — paper.js has no arc primitive, so a shape stores one only once
|
|
9
|
+
// it has been through a BOOLEAN (union/cut/intersect), a non-uniform transform, or
|
|
10
|
+
// simplify. `fit: "cubic"` therefore means "this shape passed through one of those",
|
|
11
|
+
// not "this arc came from a curve-adjacent fillet". For an unclipped arc the cubic fit
|
|
12
|
+
// is exact to the reporting grid; when a boolean clips an arc mid-sweep the fit's error
|
|
13
|
+
// grows as the kept fragment shortens (measured: a r=4 fillet corner clipped at x=18
|
|
14
|
+
// read back centre [16.0063, 16.0184] r 3.9816; a r=100 arc clipped to a 20 mm band read
|
|
15
|
+
// back centre off by 0.59 mm, r 100.586) — compare centres on the UNCLIPPED shape and
|
|
16
|
+
// use a clip only to find which arcs to look at, never to read an exact centre off the
|
|
17
|
+
// clipped fragment itself. See docs/AUTHORING-PARTS.md's probes section.
|
|
18
|
+
//
|
|
19
|
+
// Either recovery (via or cubic) can also produce a near-infinite-radius circle from
|
|
20
|
+
// residual curvature on a segment that reads as straight — most visibly after
|
|
21
|
+
// `simplify`, which can leave a nominally-straight edge with a fraction of a micron
|
|
22
|
+
// of real (not floating-point-noise) bow. A segment whose sagitta (the arc's peak
|
|
23
|
+
// deviation from its own chord, r·(1 − cos(Δ/2))) falls below STRAIGHT_SAGITTA_MM
|
|
24
|
+
// (1e-3 mm, a micron — below anything a print or a reader can act on; an r=100 arc on
|
|
25
|
+
// a 20 mm chord has 0.5 mm of sagitta and an r=1000 one 0.05 mm, so a real bend is
|
|
26
|
+
// nowhere near this floor) is reported as a line instead of a kilometre-radius arc.
|
|
27
|
+
//
|
|
28
|
+
// Corners are profileCorners' reader-facing fields, plus `position`: a running count
|
|
29
|
+
// across every ring in the summary's own order (region by region, outer then holes) —
|
|
30
|
+
// exactly the positional index `fillet({corners: {indices}})` and `shape.corners()`
|
|
31
|
+
// use (both resolve against profileCorners' own flattened order), computed by that same
|
|
32
|
+
// function here rather than re-derived. The `index` field (profileCorners' vertex
|
|
33
|
+
// number within its own contour, not a position into this list) is deliberately
|
|
34
|
+
// dropped — carrying it here is what invites the {indices} mistake.
|
|
35
|
+
//
|
|
36
|
+
// The summary is a FLAT list of rings (each one names its own `region`/`ring`/`hole?`)
|
|
37
|
+
// rather than nested `{outer, holes}` objects, and the two caps below are counted
|
|
38
|
+
// across the WHOLE summary, not per ring: partforge-cloud drops a probe value that
|
|
39
|
+
// grows past its JSON budget or nests past its depth cap. Flat, the summary bottoms
|
|
40
|
+
// out at rings[] → ring → arcs[] → arc → center[] — 4 containers below the summary
|
|
41
|
+
// object itself (5 including it), against the cloud's `DESCRIBE_MAX_DEPTH = 8`, with
|
|
42
|
+
// headroom even wrapped in the paired-probe `{pair: {mine, ref}}` form.
|
|
43
|
+
import { arcCenterAndSweep } from "../geometry/paper-bridge.js";
|
|
44
|
+
import { cubicAt, profileCorners } from "../geometry/contour-ops.js";
|
|
45
|
+
|
|
46
|
+
export const MAX_ARCS = 64;
|
|
47
|
+
export const MAX_CORNERS = 64;
|
|
48
|
+
// A micron: below anything a print or a reader can act on. An r=100 arc on a 20 mm
|
|
49
|
+
// chord has 0.5 mm of sagitta and an r=1000 one 0.05 mm, so a real bend is nowhere
|
|
50
|
+
// near this floor — only residual curvature left by simplify/boolean noise is.
|
|
51
|
+
export const STRAIGHT_SAGITTA_MM = 1e-3;
|
|
52
|
+
|
|
53
|
+
const round = (x) => {
|
|
54
|
+
const v = Math.round(x * 1e4) / 1e4;
|
|
55
|
+
return v === 0 ? 0 : v; // normalize -0 so the report never prints a bare minus sign
|
|
56
|
+
};
|
|
57
|
+
const pt = ([x, y]) => [round(x), round(y)];
|
|
58
|
+
const toDeg = (rad) => (rad * 180) / Math.PI;
|
|
59
|
+
|
|
60
|
+
// A recovered circle → an arc record, or null when its sagitta (peak deviation from
|
|
61
|
+
// its own chord) is below STRAIGHT_SAGITTA_MM — a near-straight segment reads as a
|
|
62
|
+
// line rather than a many-kilometre arc.
|
|
63
|
+
function arcFromCircle(c, from, to, fit) {
|
|
64
|
+
const sagitta = c.r * (1 - Math.cos(Math.abs(c.dA) / 2));
|
|
65
|
+
if (sagitta < STRAIGHT_SAGITTA_MM) return null;
|
|
66
|
+
const rec = { center: pt(c.center), r: round(c.r), from: pt(from), to: pt(to), sweepDeg: round(toDeg(c.dA)) };
|
|
67
|
+
if (fit) rec.fit = fit;
|
|
68
|
+
return rec;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// One segment → an arc record, or null for a straight (degenerate or near-straight) segment.
|
|
72
|
+
function arcOf(from, seg) {
|
|
73
|
+
if (seg.c1 && seg.c2) {
|
|
74
|
+
const mid = cubicAt(from, seg.c1, seg.c2, seg.to, 0.5);
|
|
75
|
+
const c = arcCenterAndSweep(from, mid, seg.to);
|
|
76
|
+
return c ? arcFromCircle(c, from, seg.to, "cubic") : null;
|
|
77
|
+
}
|
|
78
|
+
if (seg.via) {
|
|
79
|
+
const c = arcCenterAndSweep(from, seg.via, seg.to);
|
|
80
|
+
return c ? arcFromCircle(c, from, seg.to, null) : null;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// One ring's segments → { segments, arcs, lines }. `state.arcsUsed` is the
|
|
86
|
+
// WHOLE-SUMMARY arc budget (MAX_ARCS), shared across every ring's call so the cap is
|
|
87
|
+
// counted across the whole summary rather than restarting per ring; `segments`/`lines`
|
|
88
|
+
// stay exact counts regardless of truncation.
|
|
89
|
+
function ringArcs(contour, state) {
|
|
90
|
+
const arcs = [];
|
|
91
|
+
let lines = 0;
|
|
92
|
+
let from = contour.start;
|
|
93
|
+
for (const seg of contour.segments) {
|
|
94
|
+
const a = arcOf(from, seg);
|
|
95
|
+
if (a) {
|
|
96
|
+
if (state.arcsUsed < MAX_ARCS) { arcs.push(a); state.arcsUsed++; } else state.truncated = true;
|
|
97
|
+
} else lines++;
|
|
98
|
+
from = seg.to;
|
|
99
|
+
}
|
|
100
|
+
return { segments: contour.segments.length, arcs, lines };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The key a ring is filed under, shared between the rings[] list this function builds
|
|
104
|
+
// and the bucketing of profileCorners' flattened output back onto the ring it came from.
|
|
105
|
+
const ringKey = (region, kind, hole) => (kind === "outer" ? `${region}:outer` : `${region}:hole:${hole}`);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Summarise a Shape2D's stored contour regions (the `toContours()` value) for the
|
|
109
|
+
* probe report. `isEmpty`, `area` and `bbox` are read by the caller from the shape
|
|
110
|
+
* itself so this stays a pure function of plain data.
|
|
111
|
+
*/
|
|
112
|
+
export function summarizeContours(regions, { isEmpty, area, bbox }) {
|
|
113
|
+
if (isEmpty || !regions?.length) return { kind: "shape2d", empty: true, area: 0, bbox: null, rings: [], truncated: false };
|
|
114
|
+
const state = { arcsUsed: 0, truncated: false };
|
|
115
|
+
|
|
116
|
+
// profileCorners(regions) walks every ring in the SAME region-by-region,
|
|
117
|
+
// outer-then-holes order this function does, and its `position` field is already
|
|
118
|
+
// the flattened index {corners: {indices}} and shape.corners() agree on — computed
|
|
119
|
+
// once here, over the whole shape, rather than restarting the count per ring.
|
|
120
|
+
const allCorners = profileCorners(regions);
|
|
121
|
+
if (allCorners.length > MAX_CORNERS) state.truncated = true;
|
|
122
|
+
const cornersByRing = new Map();
|
|
123
|
+
for (let i = 0; i < allCorners.length && i < MAX_CORNERS; i++) {
|
|
124
|
+
const c = allCorners[i];
|
|
125
|
+
const key = c.ring === "outer" ? ringKey(c.regionIndex, "outer") : ringKey(c.regionIndex, "hole", c.ring.hole);
|
|
126
|
+
const bucket = cornersByRing.get(key) ?? [];
|
|
127
|
+
bucket.push({ position: c.position, point: pt(c.point), interiorAngleDeg: round(c.interiorAngleDeg), convex: c.convex });
|
|
128
|
+
cornersByRing.set(key, bucket);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const rings = [];
|
|
132
|
+
regions.forEach((rg, region) => {
|
|
133
|
+
const outerFacts = ringArcs(rg.outer, state);
|
|
134
|
+
rings.push({ region, ring: "outer", ...outerFacts, corners: cornersByRing.get(ringKey(region, "outer")) ?? [] });
|
|
135
|
+
(rg.holes ?? []).forEach((h, hole) => {
|
|
136
|
+
const holeFacts = ringArcs(h, state);
|
|
137
|
+
rings.push({ region, ring: "hole", hole, ...holeFacts, corners: cornersByRing.get(ringKey(region, "hole", hole)) ?? [] });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
kind: "shape2d", empty: false, area: round(area),
|
|
143
|
+
bbox: bbox ? { min: pt(bbox.min), max: pt(bbox.max) } : null,
|
|
144
|
+
rings, truncated: state.truncated,
|
|
145
|
+
};
|
|
146
|
+
}
|
package/types/geometry.d.ts
CHANGED
|
@@ -175,9 +175,11 @@ export function filletProfile(input: ProfileInput, r: number | number[], opts?:
|
|
|
175
175
|
export function chamferProfile(input: ProfileInput, dist: number | number[], opts?: { corners?: CornerSelector }): ProfileInput;
|
|
176
176
|
|
|
177
177
|
/**
|
|
178
|
-
* The corner list — `{index, point, interiorAngleDeg, convex,
|
|
179
|
-
* plus `{regionIndex, ring}` for region/regions input.
|
|
180
|
-
* is what `filletProfile`/`chamferProfile`'s
|
|
178
|
+
* The corner list — `{index, position, point, interiorAngleDeg, convex,
|
|
179
|
+
* segTypes}[]`, plus `{regionIndex, ring}` for region/regions input. `position`
|
|
180
|
+
* (the entry's place in this list) is what `filletProfile`/`chamferProfile`'s
|
|
181
|
+
* `{indices}` selects by; `index` is the joint's vertex number within its
|
|
182
|
+
* contour, and the two diverge past any smooth joint.
|
|
181
183
|
*/
|
|
182
184
|
export function profileCorners(input: ProfileInput): Corner2D[];
|
|
183
185
|
|
package/types/kernel.d.ts
CHANGED
|
@@ -103,8 +103,15 @@ export interface ContourRegion {
|
|
|
103
103
|
|
|
104
104
|
/** One corner of a `Shape2D`, as `Shape2D.corners()` reports it. */
|
|
105
105
|
export interface Corner2D {
|
|
106
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* The joint's vertex number within its own contour. NOT what `fillet`/
|
|
108
|
+
* `chamfer`'s `{ indices }` takes — that is `position`. The two diverge as
|
|
109
|
+
* soon as a contour has a smooth joint (a collinear midpoint, a G1 arc-line
|
|
110
|
+
* join), and on region input `index` restarts per ring.
|
|
111
|
+
*/
|
|
107
112
|
index: number;
|
|
113
|
+
/** This corner's place in the `corners()` list — what `{ indices }` selects by. */
|
|
114
|
+
position: number;
|
|
108
115
|
point: Point2;
|
|
109
116
|
interiorAngleDeg: number;
|
|
110
117
|
convex: boolean;
|
|
@@ -121,9 +128,17 @@ export type CornerSelector =
|
|
|
121
128
|
| "all"
|
|
122
129
|
| "convex"
|
|
123
130
|
| "concave"
|
|
124
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Each corner's `position` in `corners()` (never its `index`); a per-corner
|
|
133
|
+
* `r`/`d` array pairs with these. Any entry out of range throws.
|
|
134
|
+
*/
|
|
125
135
|
| { indices: number[] }
|
|
126
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The `count` (default 1) corners nearest `near`, optionally only those
|
|
138
|
+
* `within` mm of it. Without `within` the nearest corner is always selected,
|
|
139
|
+
* however far away it is.
|
|
140
|
+
*/
|
|
141
|
+
| { near: Point2; count?: number; within?: number };
|
|
127
142
|
|
|
128
143
|
/** A mirror line for `Shape2D.mirror`. */
|
|
129
144
|
export type MirrorAxis2 = "x" | "y" | { point: Point2; dir: Point2 };
|
|
@@ -195,7 +210,7 @@ export interface Shape2D {
|
|
|
195
210
|
chamfer(d: number | number[], opts?: { corners?: CornerSelector }): Shape2D;
|
|
196
211
|
/** Corner-preserving decimation/refit within `tolerance` mm. */
|
|
197
212
|
simplify(tolerance: number): Shape2D;
|
|
198
|
-
/** The corner list
|
|
213
|
+
/** The corner list; each entry's `position` is what `fillet`/`chamfer`'s `{ indices }` selects by. */
|
|
199
214
|
corners(): Corner2D[];
|
|
200
215
|
/** Is `[x, y]` inside the shape (inside an outer, not inside a hole)? */
|
|
201
216
|
contains(p: Point2): boolean;
|
package/types/testing.d.ts
CHANGED
|
@@ -304,6 +304,57 @@ export interface AggregateFacts {
|
|
|
304
304
|
triangleCount: number;
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/** One arc of a probed `Shape2D` ring: centre and radius from the contour IR. */
|
|
308
|
+
export interface ProbeArc {
|
|
309
|
+
center: [number, number];
|
|
310
|
+
r: number;
|
|
311
|
+
from: [number, number];
|
|
312
|
+
to: [number, number];
|
|
313
|
+
/** Signed; positive counter-clockwise. */
|
|
314
|
+
sweepDeg: number;
|
|
315
|
+
/** Present when the segment was a cubic and the circle is a fit, not a construction. */
|
|
316
|
+
fit?: "cubic";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface ProbeRing {
|
|
320
|
+
/** 0-based index into the shape's `regions` — which region this ring belongs to. */
|
|
321
|
+
region: number;
|
|
322
|
+
/** Whether this is the region's outer boundary or one of its holes. */
|
|
323
|
+
ring: "outer" | "hole";
|
|
324
|
+
/** 0-based hole index within its region; present only when `ring === "hole"`. */
|
|
325
|
+
hole?: number;
|
|
326
|
+
/** Total segment count, before any cap. */
|
|
327
|
+
segments: number;
|
|
328
|
+
/** At most 64 across the WHOLE summary (all rings combined), not per ring;
|
|
329
|
+
* `truncated` on the summary says when the list was cut. */
|
|
330
|
+
arcs: ProbeArc[];
|
|
331
|
+
/** Straight segments, count only. */
|
|
332
|
+
lines: number;
|
|
333
|
+
corners: Array<{
|
|
334
|
+
/** Running index across every ring in the summary's own order (region by region,
|
|
335
|
+
* outer then holes) — the positional index `fillet({corners: {indices}})` and
|
|
336
|
+
* `shape.corners()` select on. */
|
|
337
|
+
position: number;
|
|
338
|
+
point: [number, number];
|
|
339
|
+
interiorAngleDeg: number;
|
|
340
|
+
convex: boolean;
|
|
341
|
+
}>;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** What a `Shape2D` returned from a probe becomes in the report. */
|
|
345
|
+
export interface ShapeProbeFacts {
|
|
346
|
+
kind: "shape2d";
|
|
347
|
+
empty: boolean;
|
|
348
|
+
area: number;
|
|
349
|
+
bbox: { min: [number, number]; max: [number, number] } | null;
|
|
350
|
+
/** A flat list of every ring across every region — region by region, outer then
|
|
351
|
+
* holes — never nested `{outer, holes}` objects. */
|
|
352
|
+
rings: ProbeRing[];
|
|
353
|
+
/** At most 64 arcs and 64 corners total, across every ring; true when either
|
|
354
|
+
* budget was spent and something was cut. */
|
|
355
|
+
truncated: boolean;
|
|
356
|
+
}
|
|
357
|
+
|
|
307
358
|
export interface MeasureReport {
|
|
308
359
|
/** `part.meta.title`, falling back to the view name. */
|
|
309
360
|
part: string;
|
|
@@ -319,6 +370,12 @@ export interface MeasureReport {
|
|
|
319
370
|
gaps: Gap[];
|
|
320
371
|
/** The pairs with an unintended-looking gap under the threshold. */
|
|
321
372
|
nearMisses: Gap[];
|
|
373
|
+
/**
|
|
374
|
+
* Declared probes' values, present only when the part declares probes and this
|
|
375
|
+
* run evaluated them. A Solid in a probe's return value becomes a fact object, a
|
|
376
|
+
* Shape2D becomes a `ShapeProbeFacts`, plain JSON passes through, a throw is `{error}`.
|
|
377
|
+
*/
|
|
378
|
+
probes?: Record<string, unknown>;
|
|
322
379
|
/** Every sub-part watertight and nothing interpenetrating. Near misses never affect it. */
|
|
323
380
|
ok: boolean;
|
|
324
381
|
}
|