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 +6 -3
- package/docs/AUTHORING-PARTS.md +211 -1
- package/docs/ERROR-PATTERNS.md +97 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/docs/VECTOR-FORMAT.md +746 -0
- package/package.json +9 -1
- package/src/app-emblem.js +15 -0
- package/src/emblem-worker.js +3 -0
- package/src/framework/asset-resolve.js +29 -7
- package/src/framework/geometry/arc-fit.js +146 -0
- package/src/framework/geometry/contour-offset.js +5 -0
- package/src/framework/geometry/curve-fill.js +57 -7
- package/src/framework/geometry/kernel-front.js +46 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/probe.js +1 -1
- package/src/framework/geometry/stroke-outline.js +119 -0
- package/src/framework/geometry/vector-format.js +334 -0
- package/src/framework/geometry/vector2d.js +96 -0
- package/src/framework/ingest/svg-ingest.js +212 -0
- package/src/framework/jobs.js +11 -0
- package/src/framework/lint/index.js +28 -3
- package/src/framework/lint/rules-vector.js +112 -0
- package/src/framework/vectors.js +205 -0
- package/src/framework/worker.js +46 -1
- package/src/ingest.js +8 -0
- package/src/parts/assets/emblem.svg +10 -0
- package/src/parts/assets/emblem.vector.json +110 -0
- package/src/parts/assets/plate.vector.json +27 -0
- package/src/parts/emblem.js +114 -0
- package/src/testing/manifold.js +3 -1
- package/src/testing/occt.js +3 -1
- package/types/ingest.d.ts +118 -0
- package/types/kernel.d.ts +28 -0
- package/types/part.d.ts +65 -0
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
|
|
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
|
|
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);
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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 —
|
|
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
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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. |
|