partforge 0.92.0 → 0.93.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.
@@ -0,0 +1,737 @@
1
+ # The `partforge-vector` format
2
+
3
+ Normative. This is the file format `k.vector2d` reads: filled 2-D outlines that
4
+ become a `Shape2D`, which a part then extrudes, revolves, offsets, cuts, or
5
+ composes with any other 2-D geometry.
6
+
7
+ There are two ways a file gets here, and this document leads with the first:
8
+
9
+ - **Authored.** Write the JSON directly — a rounded rectangle, two bolt circles,
10
+ a triangular keyway. Coordinates are millimetres, and they place exactly where
11
+ you drew them. This is the path an agent should reach for when the geometry is
12
+ *drawn* rather than computed.
13
+ - **Ingested.** Convert an existing `.svg` once, in a browser, with
14
+ `partforge/ingest`, and check the resulting JSON in beside the part. The
15
+ artwork keeps its own unitless coordinates and gets sized at every call site.
16
+
17
+ Both produce the same format, load through the same validator, and behave
18
+ identically downstream.
19
+
20
+ **Why this document is normative and not merely helpful.** Ingest needs a real
21
+ DOM (it resolves `<use>`, `<defs>`, CSS, and bakes ancestor transforms, all of
22
+ which require one), so partforge deliberately ships **no headless SVG
23
+ conversion path** — `partforge measure|render|lint` read a part's already-stored
24
+ JSON, but nothing headless can *create* it from an `.svg`. That trade was
25
+ accepted only because this file is complete enough that someone — or some agent
26
+ — with no browser and no access to partforge's source can write a compliant
27
+ converter from it alone. Everything below is written to hold that property.
28
+ `scripts/ingest-svg.mjs` (dev-only, not shipped) is the reference
29
+ implementation, described in §6.
30
+
31
+ ## 1. A worked authored example
32
+
33
+ `src/parts/assets/plate.vector.json` is hand-written — no ingest step, no source
34
+ SVG. It is checked in verbatim:
35
+
36
+ ```json
37
+ {
38
+ "format": "partforge-vector",
39
+ "version": 1,
40
+ "units": "mm",
41
+ "note": "Emblem backing plate. Drawn at 40 x 24 mm with M3 clearance holes on 28 mm centres and a keyway placed low, in the gap between the emblem artwork's disc and its bar, so it stays a real through-slot rather than getting capped by the emboss at the default emblem_w. Coordinates are millimetres and place as authored, so `body`, `holes`, and `keyway` share one frame — the cut in build lands where it is drawn.",
42
+ "shapes": {
43
+ "body": {
44
+ "role": "add",
45
+ "regions": [
46
+ { "outer": { "kind": "rect", "center": [0, 0], "width": 40, "height": 24, "radius": 4 } }
47
+ ]
48
+ },
49
+ "holes": {
50
+ "role": "subtract",
51
+ "regions": [
52
+ { "outer": { "kind": "circle", "center": [-14, 0], "r": 1.7 } },
53
+ { "outer": { "kind": "circle", "center": [14, 0], "r": 1.7 } }
54
+ ]
55
+ },
56
+ "keyway": {
57
+ "role": "subtract",
58
+ "regions": [
59
+ { "outer": { "kind": "polygon", "points": [[-3, -8], [3, -8], [0, -4]] } }
60
+ ]
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ `src/parts/emblem.js` uses it like this:
67
+
68
+ ```js
69
+ vectors: {
70
+ emblem: new URL("./assets/emblem.vector.json", import.meta.url),
71
+ plate: new URL("./assets/plate.vector.json", import.meta.url),
72
+ },
73
+ build: (k, p) => k
74
+ .vector2d("plate")
75
+ .extrude({ h: p.plate_t })
76
+ .union(k.vector2d("emblem", { width: p.emblem_w }).extrude({ h: p.emboss }).translate([0, 0, p.plate_t])),
77
+ ```
78
+
79
+ Things worth reading off that pair:
80
+
81
+ - **No coordinate in the file is derived from another.** A hole moves by editing
82
+ its `center`; the body's corners round by editing one `radius`. Nothing has to
83
+ be recomputed elsewhere to keep the file valid — which is the whole reason the
84
+ primitive `kind`s exist alongside the explicit `"path"` form.
85
+ - **`body`, `holes`, and `keyway` share one coordinate frame**, because the file
86
+ is `units: "mm"`. Millimetres place *as authored*: scale 1, no re-centring. A
87
+ hole at `[-14, 0]` lands 14 mm left of the plate's centre in the finished
88
+ solid, not somewhere a bounding box happened to put it.
89
+ - **`k.vector2d("plate")` names no shape and passes no size.** It doesn't need
90
+ to: the file's own roles compose it, and millimetre coordinates place as
91
+ drawn. The `emblem` call must pass a size (`width: p.emblem_w`) because that
92
+ document is `units: "artwork"`. Fetching `body`, `holes`, and `keyway`
93
+ separately and sizing each call would scale them against three different
94
+ bounding boxes — see §3's "Size a millimetre drawing as a whole, never shape
95
+ by shape."
96
+ - **The file states its own composition.** Reading it, you can see that `holes`
97
+ and `keyway` are subtracted. That fact does not live only in `build`.
98
+ - **There is no `bbox` and no `source`.** Both are optional. An author never
99
+ computes a bounding box for this format.
100
+
101
+ ## 2. Schema
102
+
103
+ Every field at once, in a document that has been validated against the actual
104
+ loader as written here (`partforge/geometry`'s `validateVectorDocument` accepts
105
+ it):
106
+
107
+ ```json
108
+ {
109
+ "format": "partforge-vector",
110
+ "version": 1,
111
+ "units": "mm",
112
+ "note": "free text, ignored on load",
113
+ "shapes": {
114
+ "outline": [
115
+ {
116
+ "outer": {
117
+ "kind": "path",
118
+ "start": [0, 0],
119
+ "segments": [
120
+ { "kind": "line", "to": [20, 0] },
121
+ { "kind": "arc", "to": [20, 20], "through": [24, 10] },
122
+ { "kind": "cubic", "to": [0, 0], "c1": [15, 25], "c2": [5, 25] }
123
+ ]
124
+ },
125
+ "holes": [
126
+ {
127
+ "kind": "path",
128
+ "start": [7, 7],
129
+ "segments": [
130
+ { "kind": "line", "to": [7, 13] },
131
+ { "kind": "line", "to": [13, 13] },
132
+ { "kind": "line", "to": [13, 7] }
133
+ ]
134
+ }
135
+ ]
136
+ }
137
+ ],
138
+ "notch": {
139
+ "role": "subtract",
140
+ "regions": [
141
+ { "outer": { "kind": "rect", "center": [10, 0], "width": 6, "height": 4 } }
142
+ ]
143
+ }
144
+ }
145
+ }
146
+ ```
147
+
148
+ ### 2.1 The envelope
149
+
150
+ | Field | Type | Required | Notes |
151
+ |---|---|---|---|
152
+ | `format` | `"partforge-vector"` | yes | Literal string. Anything else is refused, naming both the found and the expected value. |
153
+ | `version` | integer | yes | `1` today. Both a **floor and a ceiling**: `0`, a negative, a non-integer, and anything above what the running build understands are all refused **by name** — the error names the document's version and the build's — rather than guessed at. |
154
+ | `units` | `"mm"` \| `"artwork"` | yes | No default. See §2.2. |
155
+ | `note` | string | no | Free text for a human or agent reading the file cold. **Ignored on load** — never parsed, never validated beyond "is a string if present." Safe to omit, safe to put anything readable in. |
156
+ | `source` | string | no | Provenance only — typically the original `.svg` filename. Not used at load or build time, and **not a staleness check** (a `source` file that has since changed is not detected). Omit it in authored documents. |
157
+ | `bbox` | `{minX, minY, maxX, maxY}` | no | Optional. Validated when present, recomputed when absent — see §3. |
158
+ | `shapes` | object, ≥1 entry | yes | Name → shape. See §2.3. |
159
+
160
+ There is no `regions` array at the top level any more. A stale draft carrying one
161
+ is refused by name — `has a "regions" array, which this build does not read` —
162
+ rather than as a generic "has no shapes", so the reader is not sent hunting for a
163
+ typo.
164
+
165
+ ### 2.2 `units`
166
+
167
+ `units` is required and has no default, for the same reason `k.vector2d` refuses
168
+ to guess a size: there is nothing honest to fall back on.
169
+
170
+ | | `units: "mm"` | `units: "artwork"` |
171
+ |---|---|---|
172
+ | Coordinates mean | millimetres | nothing physical |
173
+ | Scale | `1`, unless a size option is given | exactly one of `width`/`height`/`fit`, **required** at every call site |
174
+ | Placement | as authored — no translate | the geometry's bbox centre moves to the origin |
175
+
176
+ One formula covers both: **scale uniformly about the document origin, then
177
+ translate per `align`/`valign`.** "As authored" is the no-translate case. For
178
+ `units: "artwork"` the defaults are `align: "center"`, `valign: "middle"`; for
179
+ `units: "mm"` there is no default translate, and `align`/`valign` still apply
180
+ when passed explicitly.
181
+
182
+ Ingest always writes `"artwork"` — an SVG's `viewBox` units might be "pixels at
183
+ some assumed DPI" or "arbitrary design units", and neither is a length. Scaling
184
+ an `mm` document is legitimate (a drawing reused at another size), so a size
185
+ option on an `mm` document is accepted, not refused. Passing **more than one** of
186
+ `width`/`height`/`fit` is refused in either mode, naming the ones it got.
187
+
188
+ Sizing is always against a **tight geometric bounding box**, never a `viewBox`:
189
+ `fit` sizes the longer extent, `width`/`height` the named one, and the scale is
190
+ uniform in every case (never stretched to fit both). The box is measured on the
191
+ geometry the call actually returns — for a `{ shape }` call, that shape; for the
192
+ composed call, the `add` shapes only, since anything a `subtract` shape adds to
193
+ the extent is cut away before you see it (§2.3).
194
+
195
+ ### 2.3 Shapes and roles
196
+
197
+ `shapes` maps a name to a shape. A shape takes either of two forms:
198
+
199
+ ```json
200
+ "holes": [ …regions… ] // role "add", the default
201
+ "holes": { "role": "subtract", "regions": [ … ] } // explicit
202
+ ```
203
+
204
+ - `role` is `"add"` (the default when absent) or `"subtract"`. Any other value —
205
+ including an explicit `null` — is refused; the default applies only when the
206
+ key is genuinely absent.
207
+ - **A file must declare at least one `add` shape.** A document whose every shape
208
+ subtracts composes to nothing, and an empty result would surface much later as
209
+ an empty extrude, so it is refused at load.
210
+ - A shape needs at least one region; `shapes` needs at least one entry.
211
+ - Shape names are ordinary JSON keys, with no reserved names.
212
+
213
+ How the runtime reads them:
214
+
215
+ | Call | Returns |
216
+ |---|---|
217
+ | `k.vector2d("plate")` | Every `add` shape unioned, minus every `subtract` shape unioned. |
218
+ | `k.vector2d("plate", { shape: "holes" })` | That shape's own geometry, **whatever its role**. |
219
+
220
+ Naming a shape is a request for *that* geometry; `role` governs only the default
221
+ composition. An unknown shape name throws, listing the names the document does
222
+ declare. Union is commutative and subtracting a union is order-independent, so
223
+ key order never affects the result.
224
+
225
+ The composed call places the **whole document on one transform** — so a size or
226
+ `align` option can never scale or shift the `subtract` shapes relative to the
227
+ `add` ones. That one transform is derived from the **`add` shapes**, because they
228
+ are what survives the cut: a `subtract` shape may legitimately overhang the adds
229
+ (a rect that lops a corner off, an overhanging keyway), and sizing or aligning
230
+ against an edge that is then deleted would put the visible edge somewhere you
231
+ never asked for. A `{ shape }` call is measured against that shape alone, which is
232
+ what you asked for; see §3's "Size a millimetre drawing as a whole, never shape by
233
+ shape" for when that distinction bites.
234
+
235
+ `role` is optional where `units` is required, and the difference is principled:
236
+ `"add"` is an honest default because a painted region adds material, which is
237
+ what every region in every document already means. `units` has no honest default
238
+ because artwork coordinates have no physical meaning.
239
+
240
+ Anything more than two flat groups belongs in `build`, not in the file. There is
241
+ no intersect, no ordering, no nesting, no reference from one document to another
242
+ — composition beyond add/subtract is ordinary `Shape2D` algebra:
243
+
244
+ ```js
245
+ k.vector2d("plate", { shape: "body" })
246
+ .cut(k.vector2d("plate", { shape: "holes" }))
247
+ ```
248
+
249
+ ### 2.4 Regions
250
+
251
+ A **region** is one filled area:
252
+
253
+ | Field | Type | Required | Notes |
254
+ |---|---|---|---|
255
+ | `outer` | contour | yes | The region's boundary. |
256
+ | `holes` | array of contour | no (default `[]`) | Subtracted from `outer`. |
257
+
258
+ ### 2.5 Contour kinds
259
+
260
+ Every contour carries a `kind` — the same discriminator segments use, so the
261
+ format has one tagging rule rather than two. `kind` is **required**; a contour
262
+ without one is refused.
263
+
264
+ | `kind` | Fields | Meaning |
265
+ |---|---|---|
266
+ | `"path"` | `start`, `segments` (≥2) | The explicit form: a start point and a head-to-tail segment list. |
267
+ | `"circle"` | `center`, `r` | A full circle. `r` must be finite and `> 0`. |
268
+ | `"rect"` | `center`, `width`, `height`, `radius?` | An axis-aligned rectangle, optionally with rounded corners. `width`/`height` finite and `> 0`; `radius` finite and `≥ 0`. |
269
+ | `"polygon"` | `points` (≥3) | A closed polyline through the given points. |
270
+
271
+ The three primitives are pure sugar: **they expand to exactly the internal
272
+ contour a hand-written `"path"` would produce, at the JSON boundary.** Nothing
273
+ downstream — placement, `Shape2D`, either geometry backend, the exporters —
274
+ learns that primitives exist. A converter that only ever emits `"path"` is fully
275
+ compliant.
276
+
277
+ #### Normative expansion
278
+
279
+ Let `c = center`, `hw = width / 2`, `hh = height / 2`. Coordinates are y-up. A
280
+ segment written `{to, through}` below is an `"arc"`; one written `{to}` is a
281
+ `"line"`.
282
+
283
+ **`circle`** — two 180° arcs:
284
+
285
+ ```
286
+ start = [cx + r, cy]
287
+ segments = { to: [cx − r, cy], through: [cx, cy + r] }
288
+ { to: [cx + r, cy], through: [cx, cy − r] }
289
+ ```
290
+
291
+ The last segment's `to` equals `start`, and it is **retained** — the
292
+ closure-dropping rule in §3 applies only to a final *line* — so the implicit
293
+ closing edge is zero-length.
294
+
295
+ **`rect`, with `radius` absent or `0`** — four corners, three explicit lines and
296
+ the implicit closure:
297
+
298
+ ```
299
+ start = [cx − hw, cy − hh]
300
+ segments = { to: [cx + hw, cy − hh] }
301
+ { to: [cx + hw, cy + hh] }
302
+ { to: [cx − hw, cy + hh] }
303
+ ```
304
+
305
+ **`rect`, with `radius > 0`** — eight segments, four straight edges and four 90°
306
+ corner arcs. Each corner arc's `through` point sits at 45° on that corner's own
307
+ circle, offset from the corner-arc centre by `k = radius / √2` in both axes.
308
+ With `r = radius`:
309
+
310
+ ```
311
+ start = [cx − hw + r, cy − hh]
312
+ segments = { to: [cx + hw − r, cy − hh] }
313
+ { to: [cx + hw, cy − hh + r], through: [cx + hw − r + k, cy − hh + r − k] }
314
+ { to: [cx + hw, cy + hh − r] }
315
+ { to: [cx + hw − r, cy + hh], through: [cx + hw − r + k, cy + hh − r + k] }
316
+ { to: [cx − hw + r, cy + hh] }
317
+ { to: [cx − hw, cy + hh − r], through: [cx − hw + r − k, cy + hh − r + k] }
318
+ { to: [cx − hw, cy − hh + r] }
319
+ { to: [cx − hw + r, cy − hh], through: [cx − hw + r − k, cy − hh + r − k] }
320
+ ```
321
+
322
+ `radius > min(width, height) / 2` is **refused**, naming the maximum — not
323
+ clamped. A format loader has no warning channel, and a radius past half the
324
+ shorter side is a typo, not a request. At exactly `min(width, height) / 2` two
325
+ (or four) of the straight edges are zero-length; the expansion **omits any line
326
+ segment whose endpoints coincide**, so a square with `radius = width / 2` expands
327
+ to four arcs, not four arcs and two degenerate lines.
328
+
329
+ Worked, from the plate above (`center [0,0]`, `40 × 24`, `radius 4`, so
330
+ `k = 2.828427…`): `start [−16, −12]`, then `line → [16, −12]`,
331
+ `arc → [20, −8] through [18.828427, −10.828427]`, `line → [20, 8]`,
332
+ `arc → [16, 12] through [18.828427, 10.828427]`, `line → [−16, 12]`,
333
+ `arc → [−20, 8] through [−18.828427, 10.828427]`, `line → [−20, −8]`,
334
+ `arc → [−16, −12] through [−18.828427, −10.828427]`.
335
+
336
+ **`polygon`** — `start` is `points[0]`, one `"line"` segment per remaining point,
337
+ and the closing edge is implicit. `points` must hold at least 3 finite `[x, y]`
338
+ pairs.
339
+
340
+ **Winding is not your problem.** `circle` and `rect` expand counter-clockwise by
341
+ construction and `polygon` follows the author's own point order, and none of them
342
+ needs to know whether it is filling an `outer` or a `holes` slot — see §3's
343
+ winding rule for why. There is deliberately no winding or direction field on a
344
+ primitive, and no per-contour transform: emit the coordinates you mean.
345
+
346
+ ### 2.6 Segments
347
+
348
+ A `"path"` contour is `{ kind: "path", start: [x, y], segments: [...] }`, with at
349
+ least one segment, and **at least two if they are all straight**. The rule is
350
+ "can this bound a nonzero area?", not a segment count: two straight segments plus
351
+ the implicit closure is the fewest that can — a triangle — but a single `"arc"`
352
+ or `"cubic"` bounds area against the closing chord all by itself. That is a
353
+ half-disc, a lens, a petal, and ingest emits exactly that shape. Only a lone
354
+ `"line"` is refused, because it and the closing edge are the same line. Every
355
+ segment has a `kind` and a `to`; `kind` determines what else it carries:
356
+
357
+ | `kind` | Extra fields | Meaning |
358
+ |---|---|---|
359
+ | `"line"` | — | A straight edge from the previous point to `to`. |
360
+ | `"arc"` | `through: [x, y]` | A circular arc from the previous point to `to`, **passing through `through`**. |
361
+ | `"cubic"` | `c1: [x, y]`, `c2: [x, y]` | A cubic Bézier from the previous point to `to`, with control points `c1` (near the start) and `c2` (near `to`) — the standard SVG/PostScript cubic convention. |
362
+
363
+ **`through` is a point the arc passes through — not a control point, not a
364
+ tangent handle, not a centre.** Concretely: the arc from the segment's start
365
+ point `P0` to its `to` point `P1` is the unique circular arc through the three
366
+ points `P0`, `through`, `P1`. This is the same "three points determine a circle"
367
+ construction as an SVG `A` command's endpoint parameterization, just phrased
368
+ directly in points instead of radius + flags. Two things follow, both worth
369
+ knowing before hand-writing one:
370
+
371
+ - **`through` must not be collinear with `P0` and `P1`.** Three collinear points
372
+ don't determine a circle; a degenerate arc silently falls back to a straight
373
+ line rather than throwing (`k.vector2d` will not error, but the corner you
374
+ meant to round will not be rounded — a "why does my part look wrong" bug, not a
375
+ crash).
376
+ - **Which side of the chord `through` sits on determines the sweep direction and
377
+ whether the arc is the major or minor arc.** Put `through` on the actual path
378
+ the artwork traces between `P0` and `P1`, not just "somewhere off to the side"
379
+ — for a rounded corner that means roughly on the bisector, offset toward the
380
+ outside of the turn; for a near-semicircle it means clearly on one side or the
381
+ other, not near either endpoint.
382
+
383
+ ### 2.7 Error messages
384
+
385
+ Every validation error names the file's declared `vectors` key, then the exact
386
+ position, then the problem and a fix, e.g.:
387
+
388
+ ```
389
+ vector2d: "plate" shape "holes" region 1 outer has "kind": "circle" but a non-positive r (0) — r must be a finite number greater than 0
390
+ vector2d: "plate" shape "body" region 1 outer has "kind": "rect" with radius 3.5 exceeds the maximum 3 — a corner radius cannot be more than half the shorter side
391
+ ```
392
+
393
+ Shapes are named, regions and segments are 1-indexed, and the role (`outer` /
394
+ `hole n`) is stated. There is no error that only says "invalid document."
395
+
396
+ ## 3. Rules that are not obvious from the schema
397
+
398
+ - **y points UP.** SVG (and almost every 2-D graphics format) is y-**down**:
399
+ larger y is lower on the page. This format's frame is y-**up**, matching the
400
+ CAD model frame `k.vector2d` places geometry into. Converting from SVG means
401
+ flipping y. partforge's own ingest does it by **literal negation** (`y → −y`),
402
+ applied once, after all of an SVG's own transforms have been baked into the
403
+ coordinates — not by subtracting from the artwork's height or `viewBox` extent.
404
+ For an `artwork` document either convention gives correct final geometry
405
+ (sizing and alignment work off the regions' own tight bbox, so a constant
406
+ offset in how you chose to flip is invisible after placement); negation is
407
+ simply the simplest rule to implement correctly, and it is what the worked
408
+ examples here look like. For an **`mm`** document the choice is *not* free —
409
+ the coordinates are the placement — so negate.
410
+
411
+ - **A contour is implicitly closed.** The last segment's `to` connects back to
412
+ `start`; you never write a final segment whose only job is "return to `start`."
413
+ (If you do write one anyway — a final segment whose `kind` is `"line"` and
414
+ whose `to` equals `start` exactly — it is tolerated and silently dropped on
415
+ load, so round-tripping a document that has one doesn't duplicate it. A final
416
+ *arc* that lands on `start` is kept, which is how `circle` expands. Don't rely
417
+ on the dropping path; the canonical form omits the redundant line.)
418
+
419
+ - **Winding carries no information, and you never need a shoelace sum.**
420
+ Orientation comes from the **`outer` / `holes` labels**, not from the direction
421
+ the points are written in. `k.vector2d` lowers through `k.shape2d`, whose
422
+ `liftRegions` runs `ensureRegionWinding`, and that reorients every contour
423
+ *structurally* from its label: `outer` counter-clockwise, every hole clockwise.
424
+ Stored winding is discarded before any boolean sees it.
425
+
426
+ Measured, not asserted: a 10 × 10 square with a 4 × 4 hole extruded 1 mm gives
427
+ volume **84** with the "conventional" winding (CCW outer, CW hole), **84** with
428
+ both contours reversed, **84** with both counter-clockwise, and **84** with both
429
+ clockwise. All four are the same solid.
430
+
431
+ So: put a contour in `outer` to add material and in `holes` to remove it, and
432
+ write its points in whatever order is natural. Do not compute signed areas, do
433
+ not reverse a contour to "fix" a file, and do not treat a file whose winding
434
+ looks unconventional as broken — it isn't. (Earlier revisions of this document
435
+ claimed reversed winding silently swapped outer and hole. That was false.)
436
+
437
+ - **`bbox` is optional, and it is a checksum rather than an authority.**
438
+ Placement recomputes the tight bounding box from the segment geometry on every
439
+ build regardless — analytically, including curve extrema, not just endpoints
440
+ and control points — so the stored value is never *used* for geometry.
441
+ - **Absent:** computed from the geometry. This is the authored case; an author
442
+ should not have to solve for curve extrema to satisfy a checksum.
443
+ - **Present:** validated against a fresh recomputation, to a tolerance of
444
+ `1e-3`. A disagreement is a **load-time error**, naming the offending field,
445
+ the stored value, and the actual one. This is the generated case: it is what
446
+ catches a truncated or hand-mangled ingest output.
447
+
448
+ There is no way to make a document with an intentionally wrong `bbox` load. If
449
+ you have one and don't want to compute the replacement, delete the field.
450
+
451
+ - **A stroke is never a line in this format — see §6.** There is no "stroked
452
+ path" representation here at all; every stroke an SVG declares has been
453
+ outlined into an ordinary filled `outer`/`holes` region by the time it reaches
454
+ this JSON. A thin line in the source artwork appearing here as a thin closed
455
+ ribbon is expected, not a bug.
456
+
457
+ - **A fill rule applies across one element's own subpaths, not globally.** SVG's
458
+ `fill-rule` (`nonzero` — the default — or `evenodd`) is a property of a single
459
+ `<path>`/`<circle>`/etc. element, and it resolves *that element's* subpaths
460
+ against each other — which is what turns the counter of a letter "O" into a
461
+ hole instead of a second filled disc. Two *different* elements that happen to
462
+ overlap are never resolved against each other by a fill rule; they are unioned
463
+ (every painted element adds material — see §7). One `<path d="…">` with two
464
+ subpaths and `fill-rule="evenodd"` is one region with a hole; two separate
465
+ `<circle>` elements are never a hole no matter what `fill-rule` either
466
+ declares.
467
+
468
+ - **Size a millimetre drawing as a whole, never shape by shape.** A size option
469
+ scales the geometry being placed against **that geometry's own** tight
470
+ bounding box. Sizing the composed call is therefore safe — the whole document,
471
+ `add` and `subtract` regions alike, is measured and placed on one transform.
472
+ Sizing two `{ shape }` calls on the same document is not: each gets its own
473
+ scale factor, and the moment the shapes have different extents the shared
474
+ frame that `units: "mm"` exists to provide is gone. Nothing throws, the
475
+ composed bounding box is still exactly what you asked for, and the volume
476
+ barely moves — measured on the §1 plate, sizing its three shapes separately
477
+ leaves the bbox and volume all but unchanged while dropping it from three
478
+ through-holes to one. Prefer `k.vector2d(name)` with no size at all; if a
479
+ drawing genuinely needs rescaling, compose it first and scale the finished
480
+ `Shape2D` once. See
481
+ [ERROR-PATTERNS.md#vector-mm-shapes-misscaled](ERROR-PATTERNS.md#vector-mm-shapes-misscaled).
482
+
483
+ ## 4. A worked ingested example
484
+
485
+ `src/parts/assets/emblem.svg` is partforge's own reference artwork:
486
+
487
+ ```xml
488
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
489
+ <circle cx="24" cy="24" r="10" fill="#111"/>
490
+ <polyline points="6 42 42 42" fill="none" stroke="#111" stroke-width="4" stroke-linecap="round"/>
491
+ </svg>
492
+ ```
493
+
494
+ One filled circle, and one **stroked, open** polyline — deliberately, so this one
495
+ file exercises both of ingest's geometry paths (a fill, and a stroke that has to
496
+ be outlined into a filled shape; see §6). Ingesting it
497
+ (`node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`) produces
498
+ `src/parts/assets/emblem.vector.json`, checked in beside it. Here it is with the
499
+ `note` field elided for brevity and the coordinate arrays put on one line —
500
+ nothing else is changed:
501
+
502
+ ```json
503
+ {
504
+ "format": "partforge-vector",
505
+ "version": 1,
506
+ "units": "artwork",
507
+ "source": "emblem.svg",
508
+ "bbox": { "minX": 4, "minY": -44, "maxX": 44, "maxY": -14 },
509
+ "shapes": {
510
+ "artwork": [
511
+ {
512
+ "outer": {
513
+ "kind": "path",
514
+ "start": [14, -24],
515
+ "segments": [
516
+ { "kind": "arc", "to": [31.071068, -31.071068], "through": [20.173166, -33.238795] },
517
+ { "kind": "arc", "to": [24, -14], "through": [33.238795, -20.173166] },
518
+ { "kind": "arc", "to": [14, -24], "through": [16.928932, -16.928932] }
519
+ ]
520
+ },
521
+ "holes": []
522
+ },
523
+ {
524
+ "outer": {
525
+ "kind": "path",
526
+ "start": [6, -40],
527
+ "segments": [
528
+ { "kind": "arc", "to": [6, -44], "through": [4, -42] },
529
+ { "kind": "line", "to": [42, -44] },
530
+ { "kind": "arc", "to": [42, -40], "through": [44, -42] },
531
+ { "kind": "line", "to": [6, -40] }
532
+ ]
533
+ },
534
+ "holes": []
535
+ }
536
+ ]
537
+ }
538
+ }
539
+ ```
540
+
541
+ Notice, and this is the point of quoting a real file rather than a hand-picked
542
+ one:
543
+
544
+ - **The `<circle>` survived as three arcs**, not the 4-cubic Bézier
545
+ approximation a naive converter would emit — this is arc recovery (§6) working
546
+ as intended, and it is why OCCT still gets true circular B-rep edges from an
547
+ SVG circle.
548
+ - **The stroked polyline became a closed, filled region** — two arcs (the round
549
+ caps) and two lines (the long sides) — even though the source SVG has
550
+ `fill="none"` and no closing segment.
551
+ - **y is negative where the SVG artwork sits below its own origin.** SVG's
552
+ `cy="24"` became `y: −24`: ingest flips y by literal negation, not by mirroring
553
+ within the `viewBox`.
554
+ - **Ingest emits one shape, named `artwork`, with no `role`** — so it is an `add`
555
+ shape and `k.vector2d("emblem", { width })` returns it without naming it.
556
+ - **`units` is `"artwork"` and `bbox` is written.** Both are what ingest always
557
+ does, and both are the opposite of the authored plate in §1.
558
+
559
+ ## 5. Which to reach for
560
+
561
+ - **Geometry computed from parameters** — a profile whose dimensions come from
562
+ sliders — belongs in `build`, with `pathProfile` and the polygon helpers. A
563
+ JSON file cannot see `p`.
564
+ - **Geometry that is drawn** — a logo, a faceplate outline, a decorative cutout —
565
+ belongs in an authored `partforge-vector` file, where each number means one
566
+ thing and can be edited on its own.
567
+ - **Existing artwork** — an `.svg` someone else made — goes through
568
+ `partforge/ingest` once and is then referenced like any other document.
569
+
570
+ `k.shape2d` does **not** accept this JSON dialect, and there is no inline
571
+ document form in `build`. The two vocabularies stay separated by the file
572
+ boundary; that separation is what lets this document be the only place they meet.
573
+
574
+ ## 6. Converting an SVG to this format by hand
575
+
576
+ If you're writing your own converter (no browser, no paper.js, no partforge
577
+ source), these are the steps in the order that avoids the mistakes above,
578
+ followed by the one already-written reference to check your output against.
579
+
580
+ 1. **Resolve everything the SVG defers** — `<use>`/`<defs>`/`<symbol>`
581
+ references, CSS `class=`/`<style>` rules, and every ancestor `transform`
582
+ (`<svg>`, `<g>`, and the element itself) — down to concrete, final `(x, y)`
583
+ coordinates per element. This is the step a real DOM does for you almost for
584
+ free (which is why partforge's own ingest requires a browser); doing it by
585
+ hand means implementing SVG's transform-composition and CSS cascade rules, or
586
+ using a library that already has.
587
+ 2. **For each element that paints, decide fill vs. stroke vs. both**, per SVG's
588
+ own paint model: an element with a `fill` (anything but `none`; the default is
589
+ black) contributes filled geometry; an element with a `stroke` set and a
590
+ nonzero `stroke-width` contributes stroke geometry; an element can do both, or
591
+ neither (in which case it contributes nothing and is skipped — no error, it's
592
+ just not painted).
593
+ 3. **Outline every stroke into a filled shape.** A stroke of width `w` becomes
594
+ the region swept by a `w`-wide pen along the path: offset the path by `±w/2`
595
+ on each side (for a closed path this gives an outer ring and an inner ring —
596
+ an annulus; for an open path the two offset sides are joined at the ends by
597
+ caps per `stroke-linecap` — `butt`, `round`, or `square` — and at interior
598
+ corners per `stroke-linejoin` — `miter`, `round`, or `bevel`). Do this
599
+ **before** measuring anything against the artwork's scale, and in the same
600
+ units the rest of the element's geometry is already in (i.e. after transforms
601
+ are baked in, per step 1) — outlining after a later rescale would leave the
602
+ stroke's *thickness* keyed to the wrong scale, a bug that only shows up when
603
+ someone changes the `width` the artwork is placed at.
604
+ 4. **Resolve each element's own fill under its own fill rule** — nonzero or
605
+ evenodd, defaulting to nonzero — across that element's own subpaths only (§3's
606
+ fill-rule rule). Do this per element, not globally.
607
+ 5. **Union everything** — every element's resolved fill regions and every
608
+ element's outlined stroke regions, across the whole document — into one flat
609
+ list of non-overlapping `{outer, holes}` regions. This is an ordinary planar
610
+ boolean union under nonzero winding; it is also the step that silently
611
+ discards painting order (§7).
612
+ 6. **Flip y** (§3) — after all of the above, so the flip doesn't have to be
613
+ threaded through transform composition, stroke outlining, or fill resolution.
614
+ Do this **before** any arc-recovery pass (next).
615
+ 7. **(Optional but recommended) recover circular arcs.** Every step above likely
616
+ worked in cubic Béziers (SVG's `A` command and every simple-shape element
617
+ expand to cubics in most tooling, paper.js included). You can ship pure
618
+ `"cubic"` segments and `k.vector2d` will accept them — but a circle or arc
619
+ represented as cubics tessellates to a facet approximation on export, even on
620
+ the OCCT backend, where a symbolic `"arc"` segment gives an exact circular
621
+ B-rep edge. If you want that fidelity: for each maximal run of consecutive
622
+ cubic segments, fit a circle through the run's first, middle, and last
623
+ **segment endpoints** (a three-point circle fit), then verify by sampling each
624
+ cubic's interior and checking it stays within a tight tolerance of that fitted
625
+ circle. **Do not make that tolerance relative to the fitted radius alone.**
626
+ The flatter a curve is, the larger the circle it fits, so a radius-relative
627
+ band grows without limit exactly where the drawn feature is smallest, and a
628
+ gentle asymmetric cubic — the most common curve in real logo artwork — gets
629
+ replaced by an arc that misses it by a large fraction of the curve's own
630
+ depth, silently and irreversibly. partforge's own recovery bounds it by both:
631
+ `min(1e-3 × radius, 2e-3 × chord)`, where *chord* is the straight distance
632
+ between that cubic's own two endpoints — a scale the artwork actually has,
633
+ which stays finite as the curve flattens and does not degenerate on a closed
634
+ run the way the whole run's chord does. (For calibration: a correct
635
+ kappa-handle cubic deviates from its true circle by at most about
636
+ `1.8e-4 × chord`, so `2e-3` leaves an order of magnitude of headroom.) Sample
637
+ at several interior parameters, not one — and note that for a single-cubic run
638
+ the `t = 0.5` point is exact by construction of the three-point fit, so it
639
+ constrains nothing. If the check doesn't hold for the whole run, leave it as
640
+ cubics rather than emitting a wrong arc. Split any recovered arc at 180° so
641
+ the three-point form stays unambiguous (a full circle becomes two `"arc"`
642
+ segments, not one).
643
+ 8. **Write the envelope.** Emit `"units": "artwork"` (an SVG's coordinates are
644
+ not millimetres), wrap the flat region list in a named shape —
645
+ `"shapes": { "artwork": [ …regions… ] }` is what ingest uses — and tag every
646
+ contour `"kind": "path"`. Give the document a `note` if it helps whoever reads
647
+ it next, and a `source` naming the `.svg` it came from.
648
+ 9. **Optionally write `bbox`.** It is no longer required, so the simplest correct
649
+ converter omits it. If you do emit one it must be the tight bbox of the final,
650
+ flipped regions, computed from the curve extrema rather than just endpoints
651
+ and control points, or the document will be refused. Round every coordinate to
652
+ a fixed, small number of decimal places (partforge's own ingest uses 6) so the
653
+ file stays diffable and so a stored `bbox` matches a later recomputation from
654
+ the *rounded* coordinates rather than drifting past the tolerance.
655
+
656
+ `scripts/ingest-svg.mjs` in the partforge repository is the worked reference
657
+ implementation of exactly this pipeline — it runs `partforge/ingest`'s real
658
+ `ingestSvg()` (paper.js's `importSVG` for steps 1–2 and 4–5, this repo's own
659
+ `contour-offset.js`/`stroke-outline.js` for step 3, and its own `arc-fit.js` for
660
+ step 7) inside a headless DOM (`happy-dom`, a devDependency), specifically so
661
+ that repository's own fixtures — including the worked example in §4 — are
662
+ reproducible instead of being hand-maintained blobs, and so there is a second
663
+ thing (besides this document) to check a from-scratch converter's output
664
+ against: ingest the same SVG both ways and diff the JSON.
665
+
666
+ **One narrow place where a correct converter may still disagree with it.**
667
+ Overlapping subpaths inside a single `<path>` used to lose their union —
668
+ `M0,0 L10,0 L10,10 L0,10 Z M5,0 L15,0 L15,10 L5,10 Z` returned 100 units across
669
+ a width of 10 where nonzero fills 150 across 15. That is fixed, and even-odd on
670
+ the same input used to throw outright rather than return the two 5x10 bars it
671
+ should; also fixed. What remains is narrower: where a subpath wound *against* the
672
+ others covers area that two or more same-wound subpaths already cover, true
673
+ winding-number nonzero keeps that area (2 - 1 = 1) and partforge's ingest drops
674
+ it. Even-odd has no such case — it counts crossings, not directions, and is
675
+ exact throughout. Step 4 above specifies the correct behaviour, and this
676
+ document — not the reference implementation — is normative; see
677
+ `docs/ERROR-PATTERNS.md#svg-overlapping-subpaths` for the residual case and its
678
+ symptom. Everything else in the pipeline should match.
679
+
680
+ Ingest is deterministic: the same `.svg`, ingested twice against the same
681
+ installed dependencies, produces byte-identical JSON. That is a property of the
682
+ pipeline, not of this format, and it holds for a given installed `paper` — not
683
+ across `paper` or partforge versions, which is what `version` and re-ingest exist
684
+ for.
685
+
686
+ ## 7. Painting order is not modelled
687
+
688
+ Every region in an `add` shape **adds material** — there is no concept of "this
689
+ shape is painted on top of, and therefore hides, that one." An SVG that achieves
690
+ a visual hole by painting a background-colored shape *over* another shape (rather
691
+ than actually cutting a hole via a fill rule or a second subpath) will ingest as
692
+ a **solid** shape in this format, not a shape with a hole — because at the
693
+ geometry level, two overlapping filled shapes are two overlapping filled shapes,
694
+ full stop; there is no paint order left by the time union has run (§6, step 5),
695
+ and colour itself is read only as present-or-absent, never compared between
696
+ elements.
697
+
698
+ Concretely, this SVG does **not** produce a ring:
699
+
700
+ ```xml
701
+ <circle cx="0" cy="0" r="10" fill="#111"/>
702
+ <circle cx="0" cy="0" r="6" fill="white"/> <!-- looks like a hole, isn't one -->
703
+ ```
704
+
705
+ It produces one solid disc of radius 10 — the white circle's colour is irrelevant
706
+ to the geometry; it just contributes more filled area, unioned in. If you're
707
+ converting artwork that relies on this "paint-over" trick to fake a hole (a
708
+ common pattern for hand-drawn icons, since it's how they render correctly in any
709
+ raster or vector viewer), you have three ways to fix it:
710
+
711
+ - **Make it a real hole in the source artwork** — one `<path>` element with two
712
+ subpaths (the outer boundary and the inner boundary) and `fill-rule="evenodd"`
713
+ (or subpaths wound oppositely under `nonzero`), so ingest's own fill-rule
714
+ resolution produces `{ outer, holes: [...] }` for that one element, per §3's
715
+ fill-rule rule.
716
+ - **Give the file a `subtract` shape** — put the "hole" geometry in its own
717
+ shape with `"role": "subtract"`, so the document composes correctly on its own.
718
+ This is an edit to the JSON, not to the SVG, and does not survive a re-ingest.
719
+ - **Subtract it in the part instead of the artwork**, with an ordinary `.cut()` —
720
+ bring in the "hole" shape as its own geometry (a second `vectors` entry, a
721
+ named shape, or plain kernel geometry) and cut it from the artwork's `Shape2D`
722
+ in `build`.
723
+
724
+ Note that `role` does **not** reintroduce paint order: `subtract` applies to the
725
+ whole document's composition at once, and subtracting a union is
726
+ order-independent. Two `subtract` shapes cannot be sequenced against each other.
727
+
728
+ ## 8. Versioning
729
+
730
+ `version` is a plain integer, currently `1`, and validation applies **both a
731
+ floor and a ceiling**: `0`, negatives, non-integers, and anything above the
732
+ number the running partforge build understands are all refused, by name — the
733
+ error names the document's own version and the version the running build
734
+ understands, so the fix (re-ingest with a newer partforge, or upgrade the
735
+ consuming app) is never a guess. `version` is not a feature-flag field to be
736
+ partially understood; a build either knows a version fully or refuses the whole
737
+ document. `1` is the format's first and, as of this writing, only version.