partforge 0.92.0 → 0.94.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
@@ -23,6 +23,7 @@ import {
23
23
  import { savePickToken, loadPickToken, clearPickToken, pickTokenPath } from "../src/framework/pick-request/token-store.js";
24
24
  import { matchPattern } from "../src/testing/error-patterns.js";
25
25
  import { lintPart } from "../src/lint.js";
26
+ import { resolveVectorDocs } from "../src/framework/vectors.js";
26
27
 
27
28
  const die = (msg) => { console.error(msg); process.exit(1); };
28
29
 
@@ -101,7 +102,7 @@ const readSources = (partPath) => {
101
102
  // base-params face, because the kernel is booted once.
102
103
  const bootKernel = (part, params = {}) => {
103
104
  const p = { ...(part.defaults ?? {}), ...params };
104
- const opts = { fonts: fontsFor(part, p), imports: part.imports };
105
+ const opts = { fonts: fontsFor(part, p), imports: part.imports, vectors: part.vectors };
105
106
  const backend = process.env.PARTFORGE_BACKEND || detectBackend(part); // env: crash()'s NEEDS_OCCT retry
106
107
  return backend === "occt" ? bootOcctKernel(opts) : bootManifoldKernel(opts);
107
108
  };
@@ -119,7 +120,8 @@ const commands = {
119
120
  const part = await loadPart(partPath, usage);
120
121
  const params = flags.params ? JSON.parse(flags.params) : undefined;
121
122
  const sources = readSources(partPath);
122
- const report = lintPart(part, { params, sources });
123
+ const vectorDocs = Object.fromEntries(await resolveVectorDocs(part.vectors));
124
+ const report = lintPart(part, { params, sources, vectorDocs });
123
125
  if (!flags.json) printLint(report);
124
126
  if (flags.out) {
125
127
  mkdirSync(dirname(resolve(flags.out)), { recursive: true });
@@ -148,7 +150,8 @@ const commands = {
148
150
  // milliseconds with a precise message rather than after a WASM boot and a
149
151
  // downstream error that doesn't name the cause. Warnings never gate measure.
150
152
  if (!flags["no-lint"]) {
151
- const lint = lintPart(part, { sources: readSources(partPath) });
153
+ const vectorDocs = Object.fromEntries(await resolveVectorDocs(part.vectors));
154
+ const lint = lintPart(part, { sources: readSources(partPath), vectorDocs });
152
155
  if (!lint.ok) {
153
156
  if (flags.json) console.log(JSON.stringify({ ok: false, lint }, null, 2));
154
157
  else printLint(lint);
@@ -74,6 +74,7 @@ export default {
74
74
  defaults, // flat { paramKey: value } — seeds params + control values
75
75
  fonts?, // { name: source } — or (p) => ({ name: source }) when a control drives the typeface
76
76
  imports?, // { name: source } — STEP/STL/3MF files a part's k.import() needs; same preload timing as fonts (see below)
77
+ vectors?, // { name: source } — declared vector files k.vector2d() places: authored partforge-vector JSON or ingested SVG; same source grammar and preload timing as fonts
77
78
  derive?, // (p) => d, or { group: (p, d) => {…}, … } — dependent values computed once per build
78
79
  parts: { // named sub-parts; each builds ONE solid
79
80
  <name>: {
@@ -134,6 +135,14 @@ export default {
134
135
  grammar and preload timing as `fonts` above. See "Importing geometry (STEP/STL/3MF)"
135
136
  below for the full contract — backend matrix, units, the `reference` field + the
136
137
  deviation gate, and caching.
138
+ - `vectors` declares the vector files a part's `k.vector2d()` calls need, same source
139
+ grammar and preload timing as `fonts` above — but the source resolves to **JSON** in the
140
+ `partforge-vector` format, never to raw `.svg`. That JSON is either **authored** by hand
141
+ (millimetre coordinates, placed as drawn) or the **ingested** output of `partforge/ingest`.
142
+ A vector source may additionally be that JSON **already parsed** — the object itself,
143
+ rather than bytes or a URL pointing at it — which is the form to use when the artwork
144
+ lives beside the part and is meant to stay hand-editable.
145
+ See "Vector geometry" below for the full contract.
137
146
 
138
147
  ---
139
148
 
@@ -453,6 +462,7 @@ dumbbell past its waist) **throws** a greppable error rather than returning dege
453
462
  geometry. Being pure, it works in `derive()` as well as `build()` — the natural home for
454
463
  clearance math.
455
464
  `pathProfile(start)` is a fluent builder for a curve-native path contour (`lineTo` / `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep spline edges on the OCCT/STEP backend and facet at the mesh LOD on Manifold — the same exact-vs-faceted split as `roundedProfile` arcs.
465
+ **`pathProfile` or an authored vector file?** Reach for `pathProfile` (and the polygon helpers above) when the geometry is **computed from parameters** — a profile whose dimensions come from `p`/`d`, which a JSON file cannot see. Reach for an authored `partforge-vector` document (`k.vector2d`, see "Vector geometry" below) when the geometry is **drawn** — a logo, a faceplate outline, a decorative cutout, where each number means one thing and gets edited on its own. The two are freely composable: both produce ordinary 2-D geometry that the same booleans and editing ops accept.
456
466
  **Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
457
467
  entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
458
468
  (importing the main entry there throws `document is not defined`).
@@ -1228,7 +1238,8 @@ const wall = k.shape2d(outer).offset(-2, { corners: "sharp" }); // inset, mite
1228
1238
 
1229
1239
  ## Editing profiles
1230
1240
 
1231
- Once a profile exists — imported SVG, `pathProfile`, or the result of a boolean — the
1241
+ Once a profile exists — a vector file (`k.vector2d`, see "Vector geometry" below),
1242
+ `pathProfile`, or the result of a boolean — the
1232
1243
  **2-D editing ops** let you reshape it with named operations instead of hand-editing
1233
1244
  control points: round or bevel a corner, nudge/rotate/mirror it, measure it, simplify
1234
1245
  it, or validate it. This is deliberately the same vocabulary an LLM agent calls: pick a
@@ -1446,6 +1457,171 @@ Both backends produce watertight emboss/deboss geometry; the difference is expor
1446
1457
 
1447
1458
  ---
1448
1459
 
1460
+ ## Vector geometry
1461
+
1462
+ `k.vector2d(name, { shape?, width?, height?, fit?, align?, valign? })` places a declared
1463
+ vector document as a `Shape2D` — the same kind of value `k.text2d`, `k.shape2d`, and every
1464
+ 2-D boolean/editing op above return, so it composes exactly the same way: union it onto a
1465
+ face, cut it as a depression, `.offset()` it, extrude or revolve it, run it through the
1466
+ "Editing profiles" ops above (fillet a corner, `.simplify()` it, query its bounds).
1467
+
1468
+ A vector document is JSON in the `partforge-vector` format, and it arrives one of two ways:
1469
+
1470
+ - **Authored** — written by hand (or by an agent) in millimetres, and placed exactly as
1471
+ drawn. This is the path for geometry that is *drawn* rather than computed: a faceplate
1472
+ outline, a bolt pattern, a decorative cutout. `src/parts/assets/plate.vector.json` is
1473
+ the worked example.
1474
+ - **Ingested** — converted once from an `.svg`, in a browser, by `partforge/ingest`, and
1475
+ checked in beside the part. The artwork keeps its own unitless coordinates and is sized
1476
+ at every call site. `src/parts/assets/emblem.vector.json` is the worked example.
1477
+
1478
+ Both load through the same validator and behave identically downstream.
1479
+ **`docs/VECTOR-FORMAT.md` is the normative spec of the format** — read it before
1480
+ hand-authoring a document, hand-converting one, or debugging a validation error.
1481
+
1482
+ ```js
1483
+ vectors: {
1484
+ emblem: new URL("./assets/emblem.vector.json", import.meta.url), // ingested (units "artwork")
1485
+ plate: new URL("./assets/plate.vector.json", import.meta.url), // authored (units "mm")
1486
+ },
1487
+ build: (k, p) => k
1488
+ .vector2d("plate") // composed by the file's own roles
1489
+ .extrude({ h: p.plate_t })
1490
+ .union(k.vector2d("emblem", { width: p.emblem_w }) // artwork units: a size is REQUIRED
1491
+ .extrude({ h: p.emboss }).translate([0, 0, p.plate_t])),
1492
+ ```
1493
+
1494
+ **Units decide placement, and the file declares them.** Every document carries a required
1495
+ `units` field — there is no default, because guessing between the two would silently
1496
+ produce wrong-scaled geometry:
1497
+
1498
+ | | `units: "mm"` | `units: "artwork"` |
1499
+ |---|---|---|
1500
+ | Coordinates mean | millimetres | nothing physical |
1501
+ | Scale | `1`, unless a size option is given | exactly one of `width`/`height`/`fit`, **required** |
1502
+ | Placement | as authored — no translate | the geometry's bbox centre moves to the origin |
1503
+ | `align`/`valign` | no default; applied when passed | default `"center"` / `"middle"` |
1504
+
1505
+ One formula covers both: scale uniformly about the document origin, then translate per
1506
+ `align`/`valign`. `fit` sizes the artwork's longer bounding-box edge; scaling is always
1507
+ uniform (never stretched to fit both). Passing **more than one** of `width`/`height`/`fit`
1508
+ throws, naming the ones it got. Omitting all three on an `"artwork"` document throws — see
1509
+ [ERROR-PATTERNS.md#vector-size-required](ERROR-PATTERNS.md#vector-size-required); unlike
1510
+ `text2d`'s `size`, which defaults to a cap height of 10 mm, there is no default here,
1511
+ because a font's cap height is a well-defined physical metric and an SVG's own coordinate
1512
+ units are not.
1513
+
1514
+ **Size a millimetre drawing as a whole, never shape by shape.** A size option scales the
1515
+ geometry being placed against *that geometry's own* bounds. On the composed call
1516
+ (`k.vector2d(name)` with no `shape`) that is the whole document, measured and placed on
1517
+ one transform, so it is safe. On two `{ shape }` calls it is two different scale factors,
1518
+ which silently destroys the shared coordinate frame that made the file worth authoring in
1519
+ millimetres — the holes scale against the holes' bounding box, not the body's. Nothing
1520
+ throws, and the composed bbox still comes out the size you asked for; only a hole or
1521
+ feature count reveals it. Prefer no size option at all on an `"mm"` file; if a drawn part
1522
+ needs rescaling, compose it first and scale the finished `Shape2D` (or the extruded solid)
1523
+ once in `build`. See
1524
+ [ERROR-PATTERNS.md#vector-mm-shapes-misscaled](ERROR-PATTERNS.md#vector-mm-shapes-misscaled).
1525
+
1526
+ **Named shapes and roles.** A document's geometry lives under named shapes, and each shape
1527
+ declares a `role` of `"add"` (the default) or `"subtract"`:
1528
+
1529
+ | Call | Returns |
1530
+ |---|---|
1531
+ | `k.vector2d("plate")` | The file's own composition: every `"add"` shape unioned, minus every `"subtract"` shape. |
1532
+ | `k.vector2d("plate", { shape: "body" })` | That shape's own geometry, whatever its role. |
1533
+
1534
+ Naming a shape is a request for *that* geometry; `role` governs only the default
1535
+ composition. An unknown shape name throws, listing the ones the file does declare
1536
+ (`npx partforge lint` catches it statically — see the rule catalog below). The composed
1537
+ call places the whole document on **one** transform, derived from every region in it, so a
1538
+ size or `align` option cannot scale the subtracts relative to the adds; a `{ shape }` call
1539
+ is measured against that shape alone. Anything more than add/subtract is ordinary
1540
+ `Shape2D` algebra in `build`:
1541
+
1542
+ ```js
1543
+ k.vector2d("plate", { shape: "body" }).cut(k.vector2d("plate", { shape: "holes" }))
1544
+ ```
1545
+
1546
+ Ingested documents have a single shape (named `artwork`, role `"add"`), so ingested
1547
+ artwork never needs to mention a shape name.
1548
+
1549
+ **Declaring the source.** Sources use the same `new URL("./…", import.meta.url)` form
1550
+ `imports` and `fonts` do, for the same reason: Vite turns it into a bundled asset URL in
1551
+ the app, and in Node it resolves to a `file:` URL that `src/testing/assets.js` reads
1552
+ straight off disk — so the same declaration works unchanged in the browser, the CLI, and
1553
+ tests. A bare `() => import("./art/logo.vector.json")` dynamic import works under Vite but
1554
+ **fails in the CLI**, the same gotcha `fonts`/`imports` have: nothing bundles the dynamic
1555
+ import outside a Vite build, so `partforge lint`/`measure`/`render` can't resolve it. The
1556
+ source must resolve to the `.vector.json`, never to a raw `.svg` — `k.vector2d` does no
1557
+ SVG parsing at all.
1558
+
1559
+ **A source may also be the parsed file itself.** Alongside bytes, a URL and a thunk, a
1560
+ `vectors` entry accepts the **contents** of a `.vector.json` — the object a JSON import
1561
+ yields, or anything else that already holds it:
1562
+
1563
+ ```js
1564
+ import plate from "./assets/plate.vector.json" with { type: "json" };
1565
+ export default { vectors: { plate }, /* … */ };
1566
+ ```
1567
+
1568
+ The `with { type: "json" }` attribute is required — Node refuses a JSON import without it.
1569
+ Reach for this form when the artwork is **hand-authored and meant to stay editable**: the
1570
+ numbers sit in a file a reader can open and change, next to the part that uses them, with
1571
+ nothing to fetch in order to see them. Reach for `new URL(…)` instead when the file is
1572
+ **ingested output** — generated, large, and not read by hand. `src/parts/emblem.js`
1573
+ declares one of each, side by side, for exactly this contrast.
1574
+
1575
+ Two consequences worth knowing. `partforge/lint`'s document-aware rules can read a parsed
1576
+ source on the very first lint, before any build has run, because there is nothing to
1577
+ resolve — with a URL they stay silent until the bytes arrive. And the object is validated
1578
+ on every resolve, so a malformed one fails with the same message its fetched twin would;
1579
+ it is read and never written, so `build` stays pure.
1580
+
1581
+ **Sizing is against the tight geometric bounding box, not a `viewBox`.** Icon sets pad
1582
+ their `viewBox` inconsistently, so sizing relative to `viewBox` makes two icons declared at
1583
+ the same nominal size look different on the plate. `width`/`height`/`fit` instead measure
1584
+ the actual painted geometry, recomputed at build time — a stored `bbox` in the file (which
1585
+ is optional, and which authored documents omit) is a checksum, never the authority.
1586
+
1587
+ **Strokes are outlined into real filled geometry, at ingest — not at build time, and not
1588
+ skipped.** A stroked SVG element (`stroke` + `stroke-width`) is not a "line" anywhere in
1589
+ the format; ingest turns it into an ordinary filled `{outer, holes}` region the width of
1590
+ the stroke, caps and joins included, before it ever reaches `k.vector2d`.
1591
+ `src/parts/emblem.js` is the reference part for this — its `emblem.svg` carries one filled
1592
+ circle and one stroked open polyline, so both of ingest's geometry paths are exercised in
1593
+ one checked-in fixture.
1594
+
1595
+ **`<use>`, `<defs>`, `<symbol>`, and CSS `class=`/`<style>` all work**, because ingest runs
1596
+ inside a real browser DOM that resolves them the same way rendering the SVG directly would
1597
+ — this is the actual reason ingest requires a browser rather than running headlessly inside
1598
+ `k.vector2d` or the CLI.
1599
+
1600
+ **Painting order is not modelled.** Every region in an `"add"` shape adds material,
1601
+ unconditionally — there is no notion of one shape being painted over, and therefore
1602
+ visually hiding, another. An SVG that fakes a hole by painting a background-colored shape
1603
+ on top of another shape (rather than using an actual fill-rule hole, or two properly-wound
1604
+ subpaths) comes out **solid** through `k.vector2d`, not holed. See `docs/VECTOR-FORMAT.md`
1605
+ § "Painting order is not modelled" for the three fixes (a real hole in the source artwork,
1606
+ a `"role": "subtract"` shape in the JSON, or `.cut()` it in `build`), and
1607
+ [ERROR-PATTERNS.md#svg-painting-order](ERROR-PATTERNS.md#svg-painting-order).
1608
+
1609
+ **What this is not.** `k.shape2d` does **not** accept the JSON dialect — it takes the
1610
+ internal contour form the polygon helpers and `pathProfile` produce — and there is no
1611
+ inline document form in `build`. A parsed source (above) does not change that: it is a
1612
+ `vectors` **declaration**, resolved and validated before `build` runs, not a document
1613
+ `build` may assemble or hand to the kernel. The two vocabularies stay separated by the file boundary,
1614
+ which is what lets `docs/VECTOR-FORMAT.md` be the only place they meet. Inline authoring
1615
+ stays `pathProfile` (see § "Geometry: the kernel / `Solid` API" above, where `pathProfile` is
1616
+ introduced, for which to reach for).
1617
+
1618
+ Full contract — the JSON format itself, hand-authoring it, hand-converting an SVG without
1619
+ a browser, arc recovery, and every validation error's exact wording — lives in
1620
+ `docs/VECTOR-FORMAT.md`; `src/parts/emblem.js` is the worked reference part, built through
1621
+ the CLI and both backends.
1622
+
1623
+ ---
1624
+
1449
1625
  ## Importing geometry (STEP/STL/3MF)
1450
1626
 
1451
1627
  `k.import(name)` returns a previously-registered imported file as an ordinary `Solid` — the same handle a `k.box()` or `k.loft()` call would give you. It exists for two uses: a **reference** the agent workflow measures and rebuilds a parametric part around (with a verify-time deviation gate holding the rebuild to it), or a **component** — a real body that participates in booleans, scaling, and export like any other solid. `src/parts/import-demo.js` is the worked example for both; read it alongside this section.
@@ -2093,6 +2269,24 @@ no findings from that group. Source findings carry `file` and `line` on top of t
2093
2269
  standard shape, and `SOURCE_RULE_IDS` names them — a host that gates rendering on
2094
2270
  lint errors uses it to keep them reported but non-blocking.
2095
2271
 
2272
+ `lintPart(part, { vectorDocs })` optionally takes the RAW parsed JSON of the
2273
+ part's declared `vectors` files — `{ name: parsedDocument }` — and unlocks the
2274
+ two vector rules that need to read `units`/`shapes` (below). Lint is pure and
2275
+ synchronous by contract, so it never fetches these itself: `vectors.js`'s
2276
+ `resolveVectorDocs(part.vectors)` does the async resolve (sharing the same
2277
+ bytes memo `resolveVectors` uses, so `lint` ahead of `measure` costs no extra
2278
+ fetch) and the caller passes the result in, exactly the way `sources` already
2279
+ works. Both built-in callers do, by different routes: `bin/cli.js` awaits
2280
+ `resolveVectorDocs` for `partforge lint|measure`, while the worker's `lint` job
2281
+ uses the synchronous `cachedVectorDocs`, which reads only documents already in
2282
+ the resolver's memo and never starts a fetch. That difference is deliberate — a
2283
+ CLI run can afford to wait for a file, but the in-app lint must stay instant and
2284
+ offline, because a host runs it on every edit and `fetch` has no timeout. In
2285
+ practice a build has loaded the artwork long before anyone reads a lint report,
2286
+ so both rules are live in the app too; before the first build they are simply
2287
+ silent. Omit `vectorDocs`, or hand over something malformed, and those two rules
2288
+ just stay silent rather than guess.
2289
+
2096
2290
  `partforge/lint` has **zero runtime dependencies** and never imports a geometry
2097
2291
  kernel or the DOM viewer, so it runs unchanged in Node, a Web Worker, a sandboxed
2098
2292
  iframe, and Deno. A worker also answers `{ type: "lint", params }` with
@@ -2300,6 +2494,22 @@ first), so an impurity token inside a `${…}` interpolation is not seen. It emi
2300
2494
  one finding per (file, token) pair, carrying the occurrence count and the first
2301
2495
  occurrence's line, rather than one per occurrence.
2302
2496
 
2497
+ **Vector geometry** — `vector-unknown-name` (a build calls `k.vector2d` with a name the
2498
+ part's `vectors` field doesn't declare — this throws at build time; lint reaches
2499
+ it in microseconds instead; needs no `vectorDocs`), `vector-size-missing` (a
2500
+ `k.vector2d` call declares none of `{ width }`, `{ height }`, or `{ fit }` **and**
2501
+ the named file's `units` is `"artwork"` — unlike `k.text2d`'s cap-height `size`,
2502
+ artwork units carry no physical meaning, so there is no safe default to fall
2503
+ back on; an `"mm"` file's coordinates already are millimetres, so a size is
2504
+ genuinely optional there), `vector-unknown-shape` (a `k.vector2d(name, { shape })`
2505
+ call names a shape the file's `shapes` object doesn't contain) (all errors).
2506
+ `vector-size-missing` and `vector-unknown-shape` need `vectorDocs` (above) to
2507
+ read the file's `units`/`shapes` — without it, both stay silent rather than
2508
+ fire on every correct millimetre file or guess at shape names. All three judge
2509
+ the argument values the probe resolves under the part's default params, the
2510
+ same basis `import-unknown-name` uses; a call that only goes wrong for
2511
+ non-default params still fails correctly at build time.
2512
+
2303
2513
  A rule that itself throws yields an `internal-rule-error` **warning** and the run
2304
2514
  continues: `lintPart` never throws and never blocks a part because of a linter bug.
2305
2515
 
@@ -632,6 +632,103 @@ between the Manifold preview and the OCCT STEP export.
632
632
  - **Symptom:** `STEP import tessellation failed to satisfy the import — see console` in the browser (a build's status/error), or `step tessellation thread exited <n>` from a failed Node CLI/test run.
633
633
  - **Cause:** A STEP import used on the Manifold backend needs OCCT-tessellated triangles first (the "crossover" described in [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Importing geometry (STEP/STL/3MF)"). This hop is meant to self-heal — the browser worker requests a `tessellate-imports` job from the OCCT worker, Node hops through a `node:worker_threads` isolate (the two WASM kernels may never share a process) — but it surfaces here when the tessellation either delivered a mesh whose digest didn't match what Manifold now expects (a genuinely broken state, not a retry loop) or the worker/thread exited without completing.
634
634
  - **Fix:** This should be rare and self-resolving on the next build; if it persists, confirm the STEP file parses under OCCT on its own (e.g. `meta.backend: "occt"` temporarily, or `npx partforge measure` against an OCCT-routed copy of the part) to rule out a malformed STEP file, and check the console/thread output for the underlying tessellation error being wrapped.
635
+
636
+ ## vector-unknown-name
637
+
638
+ - **Symptom:** `vector2d: unknown vector "` followed by the name and — declare it in the part's `vectors` field — thrown from a build calling `k.vector2d(name)`.
639
+ - **Cause:** `k.vector2d(name)` was called with a name that isn't a key in the part's `vectors` field — a typo, or the declaration was never added. Same failure shape as `text2d`'s unknown-font error and `k.import`'s unknown-name error.
640
+ - **Fix:** Add the name to `vectors`, or fix the typo. `npx partforge lint <part>` catches this statically, in microseconds, before any kernel boots (`vector-unknown-name`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Vector geometry" and § "Linting" (Rule catalog → Vector geometry).
641
+
642
+ ## vector-size-required
643
+
644
+ - **Symptom:** `a size is required for artwork units` — with the declared `vectors` name in front of it (`vector2d: "logo" a size is required …`) — pass one of `{ width }`, `{ height }`, or `{ fit }` in millimetres — thrown from a build calling `k.vector2d`.
645
+ - **Cause:** The named document is `units: "artwork"` and the call passed none of `width`, `height`, or `fit`. Artwork coordinates carry no physical meaning (an SVG `viewBox` unit is not a length), so there is no safe default to fall back on — a deliberate asymmetry with `k.text2d`, whose `size` can default because a cap height is a real measurement. A `units: "mm"` document never raises this: its coordinates already are millimetres, so it places at scale 1 with no size option at all.
646
+ - **Fix:** Pass exactly one of `width`/`height`/`fit`, in millimetres — or, if the file's coordinates really are millimetres, re-author it with `"units": "mm"` and drop the size option entirely (do not do both: see vector-mm-shapes-misscaled below). `npx partforge lint <part>` catches an options-literal call statically (`vector-size-missing`) before any kernel boots. The in-app lint job catches it too, once the vector file has been fetched — it reads only already-resolved documents, so lint stays instant and offline, and this rule is silent until the first build has loaded the artwork. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Vector geometry" and [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) § "Units".
647
+
648
+ ## vector-size-options-conflict
649
+
650
+ - **Symptom:** `pass only one of width, height, or fit` — led by the declared `vectors` name (`vector2d: "logo" pass only one of …`) — followed by the ones that were passed — e.g. `— got width, fit` — thrown from a build calling `k.vector2d`.
651
+ - **Cause:** Two or more size options in one call. Scaling is always uniform, so a second option would either be ignored or contradict the first; rather than silently preferring one, the op refuses. (Earlier revisions silently preferred `width`, then `height`, then `fit`.)
652
+ - **Fix:** Keep the one you meant. `fit` sizes the longer extent of the artwork's tight bounding box, `width`/`height` the named axis; all three scale uniformly, so one is always enough.
653
+
654
+ ## vector-align-invalid
655
+
656
+ - **Symptom:** `align must be ` followed by the three legal values and the one that was passed — e.g. `vector2d: "logo" align must be "left", "center", or "right" — got "centre"`, or the vertical twin `vector2d: "logo" valign must be "bottom", "middle", or "top" — got "centre"`. Thrown from a build calling `k.vector2d`.
657
+ - **Cause:** A typo in `align` or `valign`. The British spelling `"centre"` is far and away the most common; so is reaching for `"middle"` on the horizontal axis or `"center"` on the vertical one, which are each the *other* axis's word. `k.vector2d` refuses rather than falling through to its default, because every value that fails all three comparisons would otherwise silently land the artwork where the caller never asked for it. (The Symptom literal is deliberately `align must be `, without a leading word: `valign must be …` contains it, so one entry routes both messages. Do not "fix" it to `align must be "left"`.)
658
+ - **Fix:** Use one of the values the message lists — horizontal is `"left"`/`"center"`/`"right"`, vertical is `"bottom"`/`"middle"`/`"top"`. Omit the option entirely to get the default, which differs by units: an `"artwork"` document re-centres (`center`/`middle`) because its coordinates mean nothing, and an `"mm"` document does not translate at all, because its coordinates already say where the drawing sits. See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) § "Units".
659
+
660
+ ## vector-size-not-positive
661
+
662
+ - **Symptom:** `must be a positive number of millimetres`, led by the option's own name and the declared `vectors` key — e.g. `vector2d: "logo" width must be a positive number of millimetres`. Thrown from a build calling `k.vector2d`.
663
+ - **Cause:** The `width`, `height`, or `fit` option was present but not a finite number greater than zero — `0`, a negative, `NaN`, `Infinity`, or a string. The usual source is arithmetic on a parameter that can reach zero (a slider whose `min` is `0`, or a subtraction that can go negative), not a literal.
664
+ - **Fix:** Clamp or floor the expression that produces the size, or give the driving control a `min` above zero. Note that the option is only read when it is non-`null`: to mean "no size", leave it out (or pass `null`/`undefined`) rather than passing `0` — see vector-size-required for what happens then, which depends on the document's `units`.
665
+
666
+ ## vector-artwork-no-extent
667
+
668
+ - **Symptom:** `to size against`, led by the axis — e.g. `vector2d: "logo" artwork has no width to size against`, or `… has no extent to size against` for a `fit` call. Thrown from a build calling `k.vector2d`.
669
+ - **Cause:** The geometry being sized is degenerate on the requested axis: its tight bounding box has (near) zero width, height, or overall extent, so there is nothing for the scale factor to divide by. Usually a document whose contours are collinear or coincident, or a `{ shape }` call naming a shape that is a single flat line. It is not a units problem — an `"mm"` document with a size option hits it just as readily.
670
+ - **Fix:** Size against the axis the artwork actually has (`height` instead of `width`, or `fit`, which uses the longer extent), or fix the geometry — a contour that measures zero on an axis is nearly always a drawing mistake rather than an intentional one. `npx partforge measure <part>` prints the bbox, which names the collapsed axis immediately.
671
+
672
+ ## vector-invalid-document
673
+
674
+ - **Symptom:** `vector2d: "` followed by the declared `vectors` name and a validation complaint — a bad `format` or `version`, a contour with no `kind` or an unknown one, a malformed `"path"` contour or segment (missing `start`, too few segments, an `arc` with no `through`, a `cubic` missing `c1`/`c2`, a non-numeric coordinate), a primitive with a bad `center`/`r`/`width`/`height`, a shape that is neither a region array nor a `{ role, regions }` object, an unknown `role`, a `bbox` that disagrees with the geometry, or (a different message, same `vector2d: "<name>"` lead) `vector2d: "<name>" is not valid JSON — <parse error>` — thrown while resolving a part's `vectors`, before `build` even runs.
675
+ - **Cause:** The stored document isn't a well-formed `partforge-vector` file. The single most common case for the "is not valid JSON" variant: `vectors` points at the raw `.svg` file instead of an ingested `.vector.json` — an SVG document is not JSON at all, so it fails to parse before validation ever gets a chance to name a more specific problem.
676
+ - **Fix:** If the message says "is not valid JSON," check the source points at the ingested `<name>.vector.json`, not the original `.svg` — re-ingest with `partforge/ingest` (or `node scripts/ingest-svg.mjs <file.svg>` in this repo) if you don't have it yet. Otherwise the message names the shape, the 1-indexed region, the role (`outer` / `hole n`), and where applicable the 1-indexed segment, so the fix is a single edit. Several specific cases have their own entries below (vector-units-missing, vector-stale-regions-array, vector-rect-radius-too-large). See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) for the full schema and what each field means.
677
+
678
+ ## vector-units-missing
679
+
680
+ - **Symptom:** `file has no valid ` followed by `units` and the value found — e.g. `vector2d: "logo" file has no valid \`units\` (undefined) — \`units\` must be "mm" … or "artwork" …` — thrown while resolving a part's `vectors`, before `build` runs.
681
+ - **Cause:** The document has no `units` field, or one that is neither `"mm"` nor `"artwork"`. `units` is required and has no default: millimetre coordinates place as authored, artwork coordinates have no physical meaning and need a size at every call site, and guessing between the two would silently produce wrong-scaled geometry. A file hitting this was either hand-authored without the field or written by a converter that predates it.
682
+ - **Fix:** Add `"units": "mm"` if the coordinates are millimetres and should place exactly where they are drawn, or `"units": "artwork"` if they came from an SVG (or anything else whose units are not lengths) and should be sized per call site. Ingest always writes `"artwork"`; re-ingesting the source `.svg` fixes a generated file. See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) § "Units".
683
+
684
+ ## vector-stale-regions-array
685
+
686
+ - **Symptom:** `has a "regions" array, which this build does not read` — followed by `regions now live under a named shape in "shapes"` — thrown while resolving a part's `vectors`.
687
+ - **Cause:** The document uses the old flat top-level `regions` array instead of the named-`shapes` envelope. Either a hand-written draft copied from an obsolete example, or a `.vector.json` generated by an older ingest.
688
+ - **Fix:** Wrap the regions in a named shape: `{ "shapes": { "artwork": [ …the regions… ] } }`. Nothing else about a region changes — `outer`/`holes` are unchanged — but every contour also needs its `kind` (`"path"` for the explicit `start`/`segments` form). Re-ingesting the source `.svg` produces the current envelope directly. See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) § "Shapes and roles".
689
+
690
+ ## vector-rect-radius-too-large
691
+
692
+ - **Symptom:** `has "kind": "rect" with radius` followed by the value, the maximum, and `a corner radius cannot be more than half the shorter side` — e.g. `vector2d: "plate" shape "body" region 1 outer has "kind": "rect" with radius 3.5 exceeds the maximum 3`.
693
+ - **Cause:** A `"rect"` contour's corner `radius` is greater than `min(width, height) / 2`, where the four corner arcs would overlap. The loader refuses rather than clamping: a format loader has no warning channel, and a radius past half the shorter side is a typo, not a request.
694
+ - **Fix:** Reduce `radius` to at most half the shorter side, or enlarge `width`/`height`. Exactly `min(width, height) / 2` is legal and is the fully-rounded case — a square at that radius expands to four arcs and no straight edges (the degenerate zero-length lines are omitted). See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) § "Contour kinds".
695
+
696
+ ## vector-unknown-shape
697
+
698
+ - **Symptom:** `has no shape ` followed by the requested name and the list the file does declare — e.g. `vector2d: "plate" has no shape "bodyy" — it declares: body, holes, keyway` — thrown from a build calling `k.vector2d(name, { shape })`.
699
+ - **Cause:** The `shape` option names a key the document's `shapes` object doesn't have — a typo, or a shape that was renamed in the JSON and not in `build`.
700
+ - **Fix:** Use one of the names the error lists, or drop `shape` entirely to get the file's own role-composed result (every `"add"` shape unioned, minus every `"subtract"` shape). `npx partforge lint <part>` catches this statically (`vector-unknown-shape`), and so does the in-app lint job once the file has been fetched — the in-app job reads only already-resolved documents, so the rule is silent until the first build has loaded the artwork. (A caller embedding `lintPart` directly must pass `vectorDocs` itself, or this rule stays silent.) See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Vector geometry".
701
+
702
+ ## vector-mm-shapes-misscaled
703
+
704
+ - **Symptom:** No thrown error — a `units: "mm"` document's shapes come out wrong *relative to each other*, when they are fetched one at a time with `{ shape }` and composed in `build`. Holes land off-centre or off the part, a subtracted keyway misses the body, features drawn concentric in the JSON are not concentric in the solid. The overall **bounding box is exactly what you asked for and the volume barely moves**, so the giveaway is a hole or feature count rather than a size. Measured on `src/parts/assets/plate.vector.json`: composing its `body`, `holes`, and `keyway` shapes with `{ width: 40 }` on each call yields the same bbox and a volume within 0.1% of correct, but **1 through-hole where the drawing has 3**.
705
+ - **Cause:** A size option (`width`/`height`/`fit`) scales the geometry being placed against **that geometry's own** tight bounding box. For a single shape that is right — `{ shape }` is a request for that shape, and nothing else is in the frame. But two `{ shape }` calls on one document get two *different* scale factors whenever the shapes have different extents, which destroys the shared coordinate frame `units: "mm"` exists to provide. Each call is individually well-formed; they are just no longer registered with each other, so nothing throws.
706
+ - **Fix:** Don't size a millimetre drawing per shape. In order of preference: let the file compose itself — `k.vector2d(name)` with no `shape` and no size returns every `"add"` shape minus every `"subtract"` one, placed as authored; or, if you need per-shape composition, drop the size options and let the millimetre coordinates place as drawn; or, if the drawing genuinely needs rescaling, compose it first and scale the finished `Shape2D` (or the extruded solid) once, so one transform applies to every shape together. `src/parts/emblem.js`'s `plate` build carries a comment on exactly this. Note that a size on the **composed** call is safe — the whole document is measured and placed as one — and that `units: "artwork"` documents, which have a single shape, are unaffected.
707
+
708
+ ## svg-stroke-collapsed
709
+
710
+ - **Symptom:** `svg: stroke outline collapsed — stroke-width is too large for this shape` thrown during ingest (`partforge/ingest`'s `ingestSvg`).
711
+ - **Cause:** A stroked element's `stroke-width` is large enough, relative to the shape it strokes, that outlining the stroke (offsetting the path by `±w/2` and joining the results — see [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) § "Converting an SVG to this format by hand") produces no valid ring — the offset consumed the entire shape.
712
+ - **Fix:** Reduce `stroke-width` on the offending element, or thicken the path it strokes, in the source SVG, then re-ingest. This is a property of the artwork, not something `k.vector2d` or the part can work around at build time — ingest has already discarded the original stroke by the time a build runs.
713
+
714
+ ## svg-no-geometry
715
+
716
+ - **Symptom:** `svg: no painted geometry — every element is fill="none" with no stroke, hidden, or empty` thrown during ingest.
717
+ - **Cause:** Every element in the SVG document either has no `fill` and no (`stroke` + positive `stroke-width`), or the document has no paintable elements at all (e.g. only `<defs>`, only groups with nothing visible, or the whole thing is empty). `partforge/ingest` skips unpainted elements silently — this error fires only when *nothing* in the whole document painted anything.
718
+ - **Fix:** Confirm the SVG actually has visible fill/stroke — a common cause is authoring artwork entirely inside `<defs>`/`<symbol>` with no `<use>` anywhere that references it, or a `<use>` whose `href`/`xlink:href` targets an `id` that doesn't exist in the document, or an accidental `fill="none"` with no `stroke` on every element. (Both `href` and the legacy `xlink:href` spelling are resolved — this is not a spelling issue.) Fix the source SVG and re-ingest.
719
+
720
+ ## svg-painting-order
721
+
722
+ - **Symptom:** No thrown error — a shape that looks like it has a hole in an SVG editor (or in a browser rendering the SVG directly) comes out **solid** through `k.vector2d`.
723
+ - **Cause:** The artwork fakes the hole by painting a background-colored shape *on top of* another shape, rather than actually cutting a hole (one path, two subpaths, opposite winding or `fill-rule="evenodd"`). Painting order is not modelled by this format at all — every ingested region adds material unconditionally, and colour is read only as present-or-absent, never compared between elements, so "painted over" and "not there" are indistinguishable once ingest has run. See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) § "Painting order is not modelled".
724
+ - **Fix:** Either make it a real hole in the source artwork (one `<path>` element with two subpaths and `fill-rule="evenodd"`, or two subpaths wound oppositely under `nonzero`) and re-ingest; or move the "hole" geometry into its own shape in the JSON with `"role": "subtract"`, so the document composes correctly on its own (an edit that a re-ingest overwrites); or leave the artwork as-is and subtract the "hole" shape in the part with `.cut()` instead of relying on ingest to infer it from paint order.
725
+
726
+ ## svg-overlapping-subpaths
727
+
728
+ - **Symptom:** No thrown error — an ingested artwork is missing area where shapes overlap, so it comes out slightly too small or a detail is hollow that should be solid. `doc.bbox` is correct; only the filled area is short.
729
+ - **Cause:** Ingest calls `resolveCurveFill` (`src/framework/geometry/curve-fill.js`) per-*element*, to resolve one `<path>`'s own subpaths under its fill rule. Two of the three routes through it are exact: even-odd XORs the subpaths, and nonzero over subpaths that all wind the same way takes their union. The third — nonzero over subpaths of *mixed* winding — resolves nesting with paper.js rather than evaluating winding numbers, and diverges in one case: where a subpath wound *against* the others covers area that two or more same-wound subpaths already cover, true nonzero keeps it (winding 2 − 1 = 1) and this drops it. Closing that needs a real planar arrangement, not a fold of pairwise booleans. Pinned by `test/curve-fill.test.js`'s "KNOWN DIVERGENCE" test, and bounded by the same file's glyph-by-glyph check against Manifold's own NonZero fill across the bundled charset.
730
+ - **Fix:** Give the subtractive subpath its own `<path>` element (with its own `fill`) in the source SVG and re-ingest — separate elements are combined by `booleanRegions`, a real union, which does not go through this route. Or set `fill-rule="evenodd"` on the element if that expresses the same intent, since even-odd is exact. Note this is *not* the older, much broader defect where any two overlapping same-winding subpaths lost their union outright and even-odd threw "resolved hole has no containing outer"; both of those are fixed.
731
+
635
732
  ## mesh-fillet-unsupported-edge
636
733
 
637
734
  - **Symptom:** `fillet: ` or `chamfer: ` followed by an edge-class reason — e.g. `edge curve is not circular`, `flank angle varies along the arc`, `selector matched no sharp edges`, `~180° knife edge` — thrown as a `KernelCapabilityError`, or a preview sub-part silently rebuilding on the slow OCCT worker.
@@ -291,6 +291,7 @@ above. All ops return a `Solid`.
291
291
  | `loftSmooth({sections, stations?, samples?, shading?, closed?})` | Spline-interpolated loft of ≥2 sparse control sections — loft-style ring specs `{polygon\|sides+radius\|curve contour\|Shape2D, z, rotate?, scale?, sharp?}`; vertex counts **may differ**. A point section may tag `sharp: [indices]` as true corners (integers in `0…points.length-1`, sorted/deduped silently); a curve/`Shape2D` section takes corners implicitly from its non-smooth joints (single-region, hole-free, `loftSmooth:`-prefixed `k.loft` validation) and rejects an explicit `sharp`. Every section must resolve to the **same corner count `m`** (frozen error otherwise); with `m ≥ 1` corner 0 anchors the seam (replacing vertex 0), with `m = 0` v1's vertex-0 anchor holds verbatim. Compound (`kernel-front.js` + `loft-smooth.js`): each section's outline is a closed centripetal Catmull-Rom split into `m` clamped open arcs at its corners (or one closed periodic CR when `m = 0`); the `samples` budget is apportioned across arcs by mean arc-length fraction (largest-remainder, min 1 span/arc) and each arc resampled by arc length — total ring vertex count is `samples`, identical across sections, exactly v1's invariant now corner-anchored. The cross-station direction is v1 verbatim (shared centroid-spine knots, per-vertex CR, reflection phantoms at the ends, or periodic knots when `closed: true`). What's new is emission: every station — the dense list and the sparse `stations:"controls"` list alike — is fitted back to an **all-cubic Bézier contour**, arc-by-arc, via exact 4-point CR→Bézier inversion, so **both backends receive identical curve rings**. A B-rep kernel lofts the sparse control wires with its native smooth skin (`ruled: false`) — curve-exact around each ring in STEP (the densified-*point*-wire alternative measured 23 s / WASM-abort territory, which curve wires don't hit). A mesh kernel densifies `stations` rings and lofts them through `k.loft`'s curve-mode per-segment sampling, creasing sharp/corner columns via loft's geometric corner policy. `closed: true` (default false; needs ≥3 control sections, frozen error otherwise) makes the cross-station CR periodic (no reflection phantoms, ring 0 not repeated) and is **Manifold-only**, same restriction as `loft` `closed: true`: a B-rep kernel throws `loftSmooth: closed:true loops are only supported on the Manifold backend` in the composition, before building any rings; combining `closed: true` with `stations:"controls"` is rejected as a defensive invariant (reachable only by explicitly passing the internal `stations:"controls"` value; the composition never produces the combination itself). Options-only. Defaults `stations = (n−1)·8+1` open / `n·8` closed (raised to the section count `n` when lower), `samples = max(64, largest section)` (raised to the corner count `m` when lower); clamps 2…1024 / 8…2048 — the defaults cap themselves at the ceilings, only explicit out-of-range values throw. The surface interpolates every control section exactly. Parity: **within tolerance** (`screwSweep`'s class, unchanged from v1 — ~0.4% measured on the propeller reference part, test-gated at 2%). STEP is now curve-exact around each ring (previously faceted at the `samples` LOD); the cross-station skin remains ThruSections' native fit, not the shared CR — exact cross-station B-splines are a v3 candidate. Additive: `sharp`, curve/`Shape2D` sections, and `closed` are new options on top of v1's `{sections, stations?, samples?, shading?}`; `CONTRACT_VERSION` stays 4 — the same non-bump precedent as `import` above, a refinement inside the op's already-stated tolerance class rather than a new one. |
292
292
  | `union(solids[])` | Boolean union of one or more solids. |
293
293
  | `text2d(string, {size, font?, align?, valign?, lineHeight?, tracking?, kerning?})` | Outline-font text → `Shape2D`. `size` = cap height (mm). `font` = declared name / inline bytes / default. Build-time; curve-exact on OCCT, faceted on Manifold. |
294
+ | `vector2d(name, {shape?, width\|height\|fit, align?, valign?})` | A declared vector document → `Shape2D`. `name` = a declared name in the part's `vectors` field (`partforge-vector` JSON — authored by hand or ingested from an `.svg`; never raw `.svg`). With no `shape`, the document's own composition: every `"add"` shape unioned minus every `"subtract"` shape; `shape` selects one named shape's geometry whatever its role. Sizing follows the document's required `units`: `"artwork"` requires exactly one of `width`/`height`/`fit` in millimetres, `"mm"` places as authored (scale 1, no translate) and accepts a size option optionally; more than one is refused. Uniform scale in every case (`fit` = larger extent); `align`/`valign` position it, same as `text2d`, defaulting to centre/middle for `"artwork"` and to no translate for `"mm"`. **The composed call derives ONE transform from every region in the document**, `"add"` and `"subtract"` alike, so a size or align option cannot scale the subtracts relative to the adds; a `shape` call is measured against that shape alone. **Primitive contours (`circle`/`rect`/`polygon`) expand to ordinary path contours at the format boundary, in `vector-format.js`, so this op's kernel-facing contract is unchanged by them** — nothing below the loader learns primitives exist. Conformance: **both backends** (it lowers to `shape2d` + `union`, exactly like `text2d`). Parity: **identical across backends by construction** — the regions are curve-native (arcs/cubics), so there is no sampling step for the two backends to diverge over. |
294
295
  | `hull(inputs[])` | Convex hull of all inputs (each a `Shape2D`, a curve contour, or an `[[x,y],…]` point list) → a convex `Shape2D`. Backend-agnostic: a pure-JS monotone-chain hull over the inputs' sampled points (curved inputs tessellated at a fixed LOD), lifted via `shape2d` (see the parity note below). Throws on an empty input array or a degenerate (collinear/point-count < 3) hull. |
295
296
  | `hullChain(inputs[])` | Swept hull over an ordered sequence of ≥2 inputs (same input forms as `hull`): the union of `hull([inᵢ, inᵢ₊₁])` for each consecutive pair — e.g. a tapered link connecting a row of circles. Throws with fewer than 2 inputs. |
296
297
  | `toSTEP(named[])` | `[{name, solid}]` → `Promise<ArrayBuffer>` of a STEP assembly. B-rep class only. |