partforge 0.119.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 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
@@ -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
  ```
@@ -2419,6 +2419,45 @@ declare a probe that reads the value, run `measure`, and bake the reported
2419
2419
  number into `defaults` — the probe then keeps watching it on every regen, so a
2420
2420
  swapped import shows up as a probe delta instead of silently stale defaults.
2421
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
+
2422
2461
  **Cost.** Probes run on every `measure`/`inspect` (including quick checks — the
2423
2462
  agent loop is exactly who reads them), so keep them proportionate: a handful of
2424
2463
  thin-slab booleans is cheap; a dense sweep of whole-part XORs is not. `verify`'s
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.119.0",
3
+ "version": "0.120.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",
@@ -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; scalars/arrays/objects
26
- // pass through; a throw becomes `{ error }` — probes are instrumentation, so
27
- // they never crash the measurement and never gate `ok`.
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
+ }
@@ -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
  }