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.
- package/bin/cli.js +6 -3
- package/docs/AUTHORING-PARTS.md +184 -1
- package/docs/ERROR-PATTERNS.md +97 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/docs/VECTOR-FORMAT.md +737 -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 +5 -4
- 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 +170 -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 +102 -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
|
@@ -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.
|