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,334 @@
1
+ // The `partforge-vector` JSON format: constants, validation, and the mapping to
2
+ // and from this engine's internal region IR.
3
+ //
4
+ // This is a PUBLISHED format — agents read it, and (since ingest needs a
5
+ // browser) may hand-write it — so it is explicit where the internal IR is
6
+ // implicit. The internal IR infers a segment's type from which keys are present
7
+ // (`c1` → cubic, `via` → arc, neither → line) and calls an arc's third point
8
+ // `via`. Both are fine for code and hostile to anyone writing a file by hand, so
9
+ // the JSON tags every segment with `kind` and names the arc point `through` —
10
+ // "the arc passes through here", which `via` does not say.
11
+ //
12
+ // This file is the ONLY place the two vocabularies meet. Upstream speaks JSON,
13
+ // downstream speaks the internal IR, and nothing else needs to know both.
14
+ //
15
+ // Pure leaf: DOM-free, node:-free. Both halves of the feature import it.
16
+ import { profileBounds } from "./contour-ops.js";
17
+
18
+ export const VECTOR_FORMAT = "partforge-vector";
19
+ export const VECTOR_VERSION = 1;
20
+
21
+ export const FORMAT_NOTE =
22
+ "Filled 2-D outlines for k.vector2d. `units` is \"mm\" (coordinates are millimetres, placed as "
23
+ + "authored) or \"artwork\" (no physical meaning; a size is required at every call site). `shapes` "
24
+ + "maps a name to a list of filled regions; each region's `outer` is its boundary and `holes` are "
25
+ + "subtracted from it. A contour is a `kind`: \"path\", \"circle\", \"rect\", or \"polygon\". Path "
26
+ + "segments run head-to-tail from `start`, and the contour closes implicitly from the last `to` "
27
+ + "back to `start`. y points UP. See docs/VECTOR-FORMAT.md.";
28
+
29
+ const BBOX_TOL = 1e-3; // mm-free: these are artwork units, and 6dp rounding is finer
30
+ const ROUND = 1e6; // 6 decimal places
31
+
32
+ const round6 = (n) => Math.round(n * ROUND) / ROUND;
33
+ const isPt = (v) => Array.isArray(v) && v.length === 2 && Number.isFinite(v[0]) && Number.isFinite(v[1]);
34
+ const num = (v) => Number.isFinite(v);
35
+
36
+ // Every message carries the vectors key and the position, because the reader is as
37
+ // likely to be an agent that generated the file as a human who wrote it.
38
+ const fail = (label, where, what, fix) => {
39
+ throw new Error(`vector2d: "${label}" ${where} ${what}${fix ? ` — ${fix}` : ""}`);
40
+ };
41
+
42
+ const EPS = 1e-12;
43
+ const CONTOUR_KINDS = '"path", "circle", "rect", or "polygon"';
44
+
45
+ // Every primitive expands to the SAME internal contour a hand-written "path"
46
+ // would produce, right here at the JSON boundary. Nothing downstream —
47
+ // placement, Shape2D, either backend, the exporters — knows primitives exist.
48
+ //
49
+ // circle and rect wind counter-clockwise by construction; polygon follows the
50
+ // author's point order. None of them needs to know whether it is an outer or a
51
+ // hole: ensureRegionWinding reorients from that label when the region is lifted
52
+ // into a Shape2D, so stored winding carries no information.
53
+ const expandCircle = ({ center: [cx, cy], r }) => ({
54
+ start: [cx + r, cy],
55
+ segments: [
56
+ { to: [cx - r, cy], via: [cx, cy + r] },
57
+ { to: [cx + r, cy], via: [cx, cy - r] },
58
+ ],
59
+ });
60
+
61
+ const expandRect = ({ center: [cx, cy], width, height, radius = 0 }) => {
62
+ const hw = width / 2, hh = height / 2;
63
+ if (!(radius > 0)) {
64
+ return { start: [cx - hw, cy - hh], segments: [
65
+ { to: [cx + hw, cy - hh] }, { to: [cx + hw, cy + hh] }, { to: [cx - hw, cy + hh] },
66
+ ] };
67
+ }
68
+ const r = radius, k = r / Math.SQRT2;
69
+ const start = [cx - hw + r, cy - hh];
70
+ const raw = [
71
+ { to: [cx + hw - r, cy - hh] },
72
+ { to: [cx + hw, cy - hh + r], via: [cx + hw - r + k, cy - hh + r - k] },
73
+ { to: [cx + hw, cy + hh - r] },
74
+ { to: [cx + hw - r, cy + hh], via: [cx + hw - r + k, cy + hh - r + k] },
75
+ { to: [cx - hw + r, cy + hh] },
76
+ { to: [cx - hw, cy + hh - r], via: [cx - hw + r - k, cy + hh - r + k] },
77
+ { to: [cx - hw, cy - hh + r] },
78
+ { to: [cx - hw + r, cy - hh], via: [cx - hw + r - k, cy - hh + r - k] },
79
+ ];
80
+ // At radius = min(w,h)/2 two (or four) edges collapse to a point. Emitting a
81
+ // zero-length line would hand a degenerate edge to the boolean engine.
82
+ const out = [];
83
+ let prev = start;
84
+ for (const seg of raw) {
85
+ if (!seg.via && Math.abs(seg.to[0] - prev[0]) < EPS && Math.abs(seg.to[1] - prev[1]) < EPS) continue;
86
+ out.push(seg);
87
+ prev = seg.to;
88
+ }
89
+ return { start, segments: out };
90
+ };
91
+
92
+ const expandPolygon = ({ points }) => ({
93
+ start: [...points[0]],
94
+ segments: points.slice(1).map((p) => ({ to: [...p] })),
95
+ });
96
+
97
+ function checkContour(label, where, c) {
98
+ if (!c || typeof c !== "object") fail(label, where, "is not an object");
99
+ if (typeof c.kind !== "string") {
100
+ fail(label, where, 'has no "kind"', `every contour needs a kind — ${CONTOUR_KINDS}`);
101
+ }
102
+ if (c.kind === "circle") {
103
+ if (!isPt(c.center)) fail(label, where, 'has "kind": "circle" but no valid "center"', "center must be an [x, y] pair of finite numbers");
104
+ if (!num(c.r) || c.r <= 0) fail(label, where, `has "kind": "circle" but a non-positive r (${JSON.stringify(c.r)})`, "r must be a finite number greater than 0");
105
+ return;
106
+ }
107
+ if (c.kind === "rect") {
108
+ if (!isPt(c.center)) fail(label, where, 'has "kind": "rect" but no valid "center"', "center must be an [x, y] pair of finite numbers");
109
+ for (const k of ["width", "height"]) {
110
+ if (!num(c[k]) || c[k] <= 0) fail(label, where, `has "kind": "rect" but a non-positive ${k} (${JSON.stringify(c[k])})`, `${k} must be a finite number greater than 0`);
111
+ }
112
+ if (c.radius != null) {
113
+ if (!num(c.radius) || c.radius < 0) fail(label, where, `has "kind": "rect" but an invalid radius (${JSON.stringify(c.radius)})`, "radius must be a finite number of 0 or more");
114
+ const max = Math.min(c.width, c.height) / 2;
115
+ if (c.radius > max) {
116
+ fail(label, where, `has "kind": "rect" with radius ${c.radius} exceeds the maximum ${round6(max)}`,
117
+ "a corner radius cannot be more than half the shorter side");
118
+ }
119
+ }
120
+ return;
121
+ }
122
+ if (c.kind === "polygon") {
123
+ if (!Array.isArray(c.points) || c.points.length < 3) {
124
+ fail(label, where, `has "kind": "polygon" with ${c.points?.length ?? 0} points`, "a polygon needs at least 3 points");
125
+ }
126
+ c.points.forEach((p, i) => { if (!isPt(p)) fail(label, `${where} point ${i + 1}`, "is not a valid [x, y] pair of finite numbers"); });
127
+ return;
128
+ }
129
+ if (c.kind !== "path") {
130
+ fail(label, where, `has unknown "kind": ${JSON.stringify(c.kind)}`, `kind must be ${CONTOUR_KINDS}`);
131
+ }
132
+ // "path" — the explicit form.
133
+ if (!isPt(c.start)) fail(label, where, 'has no valid "start"', "start must be a [x, y] pair of finite numbers");
134
+ if (!Array.isArray(c.segments) || c.segments.length === 0) {
135
+ fail(label, where, `has too few segments (${c.segments?.length ?? 0})`,
136
+ "a closed contour needs at least one segment; it closes implicitly from the last `to` back to `start`");
137
+ }
138
+ c.segments.forEach((s, i) => {
139
+ const at = `${where} segment ${i + 1}`;
140
+ if (!s || typeof s !== "object") fail(label, at, "is not an object");
141
+ if (!isPt(s.to)) fail(label, at, 'has no valid "to"', "every segment needs a `to` [x, y] pair of finite numbers");
142
+ if (s.kind === "line") return;
143
+ if (s.kind === "arc") {
144
+ if (!isPt(s.through)) {
145
+ fail(label, at, 'has "kind": "arc" but no valid "through" point',
146
+ "an arc needs a point it passes through, between the previous point and `to`");
147
+ }
148
+ return;
149
+ }
150
+ if (s.kind === "cubic") {
151
+ if (!isPt(s.c1)) fail(label, at, 'has "kind": "cubic" but no valid "c1"', "a cubic needs both control points, c1 and c2");
152
+ if (!isPt(s.c2)) fail(label, at, 'has "kind": "cubic" but no valid "c2"', "a cubic needs both control points, c1 and c2");
153
+ return;
154
+ }
155
+ fail(label, at, `has unknown "kind": ${JSON.stringify(s.kind)}`, 'kind must be "line", "arc", or "cubic"');
156
+ });
157
+ // How few segments can bound area? It depends on whether they are straight.
158
+ // Two straight edges plus the implicit closure is the fewest — a triangle. But
159
+ // ONE curved segment plus the closing chord bounds area perfectly well: that is
160
+ // a lens, a half-disc, a petal, and arc recovery produces exactly it (a filled
161
+ // half-disc arrives as two quarter-cubics, which merge into one ≤180° arc).
162
+ // A lone straight segment is the only single-segment contour that encloses
163
+ // nothing, because it and the closure are the same line.
164
+ if (c.segments.length === 1 && c.segments[0].kind === "line") {
165
+ fail(label, where, "has a single straight segment, which encloses no area",
166
+ "a straight-edged contour needs at least two segments — with the implicit closure that is a triangle, "
167
+ + "the fewest that can bound area. A single `arc` or `cubic` segment is fine: it bounds area against the closing chord");
168
+ }
169
+ }
170
+
171
+ export const VECTOR_UNITS = ["mm", "artwork"];
172
+
173
+ const ROLES = ["add", "subtract"];
174
+
175
+ // A shape is either a bare region array — the common case, role "add" — or
176
+ // { role, regions }. Two forms rather than one because "add" is an honest
177
+ // default: a painted region adds material, which is what every file written
178
+ // before roles existed already meant.
179
+ //
180
+ // The default applies when `role` is ABSENT, not merely falsy — an explicit
181
+ // `"role": null` (or any other present-but-wrong value) must fall through to
182
+ // the unknown-role check below, not silently become "add". `"role" in v` is
183
+ // safe here: the array branch has already returned, so `v` is a non-array
184
+ // object (validateVectorDocument rejects a non-object shape value before
185
+ // either caller of this function reaches a bare-object shape).
186
+ const shapeParts = (v) => (Array.isArray(v) ? { role: "add", regions: v } : { role: "role" in v ? v.role : "add", regions: v.regions });
187
+
188
+ export function validateVectorDocument(doc, label = "(unnamed)") {
189
+ if (!doc || typeof doc !== "object") fail(label, "file", "is not an object", "expected parsed JSON");
190
+ if (doc.format !== VECTOR_FORMAT) {
191
+ fail(label, "file", `has format ${JSON.stringify(doc.format)}`,
192
+ `expected ${JSON.stringify(VECTOR_FORMAT)} — this is not a partforge-vector file`);
193
+ }
194
+ // Floor as well as ceiling: version 0 and negatives used to load.
195
+ if (!Number.isInteger(doc.version) || doc.version < 1 || doc.version > VECTOR_VERSION) {
196
+ fail(label, "file", `has version ${JSON.stringify(doc.version)}`,
197
+ `this build understands version ${VECTOR_VERSION} — re-ingest the artwork, or upgrade partforge`);
198
+ }
199
+ if (!VECTOR_UNITS.includes(doc.units)) {
200
+ fail(label, "file", `has no valid \`units\` (${JSON.stringify(doc.units)})`,
201
+ '`units` must be "mm" (coordinates are millimetres, placed as authored) or "artwork" '
202
+ + "(coordinates have no physical meaning; a size is required at every call site)");
203
+ }
204
+ if (doc.note != null && typeof doc.note !== "string") fail(label, "file", "has a non-string `note`", "`note` is free text and is ignored on load");
205
+ if (doc.source != null && typeof doc.source !== "string") fail(label, "file", "has a non-string `source`", "`source` is provenance only and may be omitted");
206
+ // A stale draft in the pre-shapes envelope gets its own message rather than
207
+ // the generic "has no shapes", which would send the reader looking for a typo.
208
+ if (doc.shapes == null && Array.isArray(doc.regions)) {
209
+ fail(label, "file", 'has a "regions" array, which this build does not read',
210
+ 'regions now live under a named shape in "shapes", e.g. { "shapes": { "artwork": [ …regions… ] } }');
211
+ }
212
+ const names = doc.shapes && typeof doc.shapes === "object" && !Array.isArray(doc.shapes) ? Object.keys(doc.shapes) : [];
213
+ if (names.length === 0) {
214
+ fail(label, "file", "has no shapes", 'a vector file needs at least one named shape: { "shapes": { "artwork": [ …regions… ] } }');
215
+ }
216
+ let anyAdd = false;
217
+ for (const name of names) {
218
+ const where = `shape ${JSON.stringify(name)}`;
219
+ const raw = doc.shapes[name];
220
+ if (!raw || typeof raw !== "object") fail(label, where, "is not an array of regions or a { role, regions } object");
221
+ const { role, regions } = shapeParts(raw);
222
+ if (!ROLES.includes(role)) {
223
+ fail(label, where, `has an unknown \`role\` ${JSON.stringify(role)}`,
224
+ '`role` must be "add" (the default, may be omitted) or "subtract"');
225
+ }
226
+ if (role === "add") anyAdd = true;
227
+ if (!Array.isArray(regions)) fail(label, where, "is not an array of regions");
228
+ if (regions.length === 0) fail(label, where, "is empty", "a shape needs at least one region");
229
+ regions.forEach((rg, i) => {
230
+ const at = `${where} region ${i + 1}`;
231
+ if (!rg || typeof rg !== "object") fail(label, at, "is not an object");
232
+ checkContour(label, `${at} outer`, rg.outer);
233
+ if (rg.holes != null && !Array.isArray(rg.holes)) fail(label, at, "has a non-array `holes`");
234
+ (rg.holes ?? []).forEach((h, j) => checkContour(label, `${at} hole ${j + 1}`, h));
235
+ });
236
+ }
237
+ if (!anyAdd) {
238
+ fail(label, "file", 'has no shape with role "add"',
239
+ "a file whose every shape subtracts composes to nothing — at least one shape must add material");
240
+ }
241
+
242
+ // bbox is a CACHE, not an authority: placement recomputes it anyway. It is
243
+ // OPTIONAL — an author should not have to compute analytic curve extrema to
244
+ // satisfy a checksum — but when a generator writes one, a stale value is a
245
+ // named error rather than silently wrong sizing at build time.
246
+ if (doc.bbox == null) return;
247
+ if (!["minX", "minY", "maxX", "maxY"].every((k) => Number.isFinite(doc.bbox[k]))) {
248
+ fail(label, "file", "has an invalid `bbox`", "bbox is optional, but when present it needs finite minX, minY, maxX, maxY");
249
+ }
250
+ const actual = regionsBbox(allRegionsUnchecked(doc));
251
+ for (const k of ["minX", "minY", "maxX", "maxY"]) {
252
+ if (Math.abs(actual[k] - doc.bbox[k]) > BBOX_TOL) {
253
+ fail(label, "file", `has a bbox that disagrees with its geometry (${k}: header ${doc.bbox[k]}, actual ${round6(actual[k])})`,
254
+ "re-ingest the artwork, or omit `bbox` — it is optional and recomputed either way");
255
+ }
256
+ }
257
+ }
258
+
259
+ const toSeg = (s) =>
260
+ s.kind === "arc" ? { to: [...s.to], via: [...s.through] }
261
+ : s.kind === "cubic" ? { to: [...s.to], c1: [...s.c1], c2: [...s.c2] }
262
+ : { to: [...s.to] };
263
+
264
+ function toContour(c) {
265
+ if (c.kind === "circle") return expandCircle(c);
266
+ if (c.kind === "rect") return expandRect(c);
267
+ if (c.kind === "polygon") return expandPolygon(c);
268
+ const segments = c.segments.map(toSeg);
269
+ // A file may spell the implicit closure out. Dropping it here keeps one
270
+ // internal representation, so downstream never has to ask which form it got.
271
+ const last = segments.at(-1);
272
+ if (!last.via && !last.c1 && last.to[0] === c.start[0] && last.to[1] === c.start[1]) segments.pop();
273
+ return { start: [...c.start], segments };
274
+ }
275
+
276
+ const toRegion = (rg) => ({ outer: toContour(rg.outer), holes: (rg.holes ?? []).map(toContour) });
277
+
278
+ const allRegionsUnchecked = (doc) =>
279
+ Object.values(doc.shapes).flatMap((v) => shapeParts(v).regions).map(toRegion);
280
+
281
+ export function toInternalDocument(doc, label = "(unnamed)") {
282
+ validateVectorDocument(doc, label);
283
+ return {
284
+ units: doc.units,
285
+ shapes: new Map(Object.entries(doc.shapes).map(([name, v]) => {
286
+ const { role, regions } = shapeParts(v);
287
+ return [name, { role, regions: regions.map(toRegion) }];
288
+ })),
289
+ };
290
+ }
291
+
292
+ // Exported: vector2d.js needs the same tight bbox at build time, and two copies of
293
+ // this loop would be two places to fix a bounds bug.
294
+ //
295
+ // Built on contour-ops.js's profileBounds — an EXACT bbox (paper.js computes a
296
+ // curve's analytic extrema, not a sampled approximation) rather than the fixed
297
+ // 64-segment tessellation this used to walk. That mattered in practice, not
298
+ // just in theory: 64-segment sampling can undershoot a true arc extremum by
299
+ // roughly 1.2e-3 × radius — comfortably past BBOX_TOL below — which meant a
300
+ // hand-authored document with the mathematically CORRECT tight bbox could be
301
+ // rejected by validateVectorDocument's "disagrees with its geometry" check.
302
+ // profileBounds also folds in holes, which regions never needed excluded: a
303
+ // hole is by construction inside its own outer, so its bounds can only sit
304
+ // within the outer's and never move minX/minY/maxX/maxY.
305
+ export function regionsBbox(regions) {
306
+ if (regions.length === 0) return { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
307
+ const { min, max } = profileBounds(regions);
308
+ return { minX: min[0], minY: min[1], maxX: max[0], maxY: max[1] };
309
+ }
310
+
311
+ const fromSeg = (s) =>
312
+ s.via ? { kind: "arc", to: [round6(s.to[0]), round6(s.to[1])], through: [round6(s.via[0]), round6(s.via[1])] }
313
+ : s.c1 ? { kind: "cubic", to: [round6(s.to[0]), round6(s.to[1])],
314
+ c1: [round6(s.c1[0]), round6(s.c1[1])], c2: [round6(s.c2[0]), round6(s.c2[1])] }
315
+ : { kind: "line", to: [round6(s.to[0]), round6(s.to[1])] };
316
+
317
+ const fromContour = (c) => ({
318
+ kind: "path",
319
+ start: [round6(c.start[0]), round6(c.start[1])],
320
+ segments: c.segments.map(fromSeg),
321
+ });
322
+
323
+ export function fromInternalRegions(regions, { source = null, units = "artwork", shape = "artwork" } = {}) {
324
+ const bb = regionsBbox(regions);
325
+ return {
326
+ format: VECTOR_FORMAT,
327
+ version: VECTOR_VERSION,
328
+ units,
329
+ note: FORMAT_NOTE,
330
+ source,
331
+ bbox: { minX: round6(bb.minX), minY: round6(bb.minY), maxX: round6(bb.maxX), maxY: round6(bb.maxY) },
332
+ shapes: { [shape]: regions.map((rg) => ({ outer: fromContour(rg.outer), holes: (rg.holes ?? []).map(fromContour) })) },
333
+ };
334
+ }
@@ -0,0 +1,96 @@
1
+ // Place ingested vector regions: one uniform scale about the document origin,
2
+ // then an alignment translate. That is the entire runtime half of k.vector2d —
3
+ // everything else happened once, at ingest.
4
+ //
5
+ // Both steps default to no-ops for millimetre files: an mm file's coordinates
6
+ // already mean something, so "as authored" (scale 1, no translate) is the
7
+ // identity. Artwork units carry no physical meaning, so a size is required and
8
+ // the artwork is re-centred by default — that half is unchanged from before
9
+ // units existed at all.
10
+ //
11
+ // The transform is uniform by construction, so arcs stay arcs and the OCCT
12
+ // backend still gets true circular B-rep edges.
13
+ //
14
+ // Pure leaf: DOM-free, node:-free.
15
+ import { regionsBbox } from "./vector-format.js";
16
+
17
+ const EXTENT_EPS = 1e-9;
18
+ const ALIGN = new Set(["left", "center", "right"]);
19
+ const VALIGN = new Set(["bottom", "middle", "top"]);
20
+ const SIZE_KEYS = ["width", "height", "fit"];
21
+
22
+ // Same lead as vector-format.js's `fail()`, and for the same reason: the format
23
+ // doc promises, without qualification, that every error names the part's declared
24
+ // `vectors` key. The errors below are the ones a new author hits MOST — they are
25
+ // call-shape mistakes, not file mistakes — and they used to be the only ones with
26
+ // no name in them, leaving an agent authoring cold to bisect its own call sites.
27
+ const bad = (name, what) => { throw new Error(`vector2d: "${name}" ${what}`); };
28
+
29
+ function scaleFor(opts, units, w, h, name) {
30
+ const given = SIZE_KEYS.filter((k) => opts[k] != null);
31
+ if (given.length > 1) {
32
+ bad(name, `pass only one of width, height, or fit — got ${given.join(", ")}`);
33
+ }
34
+ if (given.length === 0) {
35
+ // Millimetre coordinates already mean something; artwork units do not, so
36
+ // there is no honest default for artwork. (k.text2d can default `size`
37
+ // because a cap height is a real measurement; an SVG viewBox unit is not.)
38
+ if (units === "mm") return 1;
39
+ bad(name, "a size is required for artwork units — pass one of { width }, { height }, or { fit } in millimetres");
40
+ }
41
+ const [key] = given;
42
+ const v = opts[key];
43
+ if (!(Number.isFinite(v) && v > 0)) bad(name, `${key} must be a positive number of millimetres`);
44
+ const extent = key === "width" ? w : key === "height" ? h : Math.max(w, h);
45
+ if (!(extent > EXTENT_EPS)) bad(name, `artwork has no ${key === "fit" ? "extent" : key} to size against`);
46
+ return v / extent;
47
+ }
48
+
49
+ const place = (c, s, dx, dy) => {
50
+ const T = ([x, y]) => [x * s + dx, y * s + dy];
51
+ return {
52
+ start: T(c.start),
53
+ segments: c.segments.map((seg) => {
54
+ const m = { to: T(seg.to) };
55
+ if (seg.via) m.via = T(seg.via);
56
+ if (seg.c1) { m.c1 = T(seg.c1); m.c2 = T(seg.c2); }
57
+ return m;
58
+ }),
59
+ };
60
+ };
61
+
62
+ // `measureAgainst` is the region set the transform is DERIVED from; `regions` is
63
+ // what it is applied to. They differ in exactly one case: a role-composed call,
64
+ // where the "add" group and the "subtract" group are placed by separate calls but
65
+ // must land on ONE transform. Measuring each group against its own bounds instead
66
+ // scales the subtracts relative to the adds — silently, since the adds still
67
+ // govern the composed bbox — which is a wrong-geometry bug, not a wrong-size one.
68
+ // Selecting a single shape with { shape } deliberately keeps the default: you
69
+ // asked for that shape, so it is sized against its own bounds.
70
+ //
71
+ // `name` is the part's declared `vectors` key, carried only so the errors above
72
+ // can say which document the bad call was for.
73
+ export function placeRegions(regions, units, opts = {}, { measureAgainst = regions, name = "(unnamed)" } = {}) {
74
+ // An mm file places where it was drawn; only artwork has to be re-centred,
75
+ // because its own coordinates mean nothing. `null` here is "no translate" —
76
+ // distinct from `align: "center"`, which does translate (to the origin).
77
+ const align = opts.align ?? (units === "mm" ? null : "center");
78
+ const valign = opts.valign ?? (units === "mm" ? null : "middle");
79
+ // No silent default for a bad value: align/valign each pick their branch by
80
+ // string equality below, and any value that fails all three comparisons
81
+ // (a typo — "centre" for "center" — or any other garbage) would otherwise
82
+ // fall through to the middle/center case with no error, placing the artwork
83
+ // somewhere the caller never asked for. Every other op in this feature
84
+ // refuses instead of guessing (scaleFor above, right on this same function);
85
+ // this closes the one silent-default gap.
86
+ if (align != null && !ALIGN.has(align)) bad(name, `align must be "left", "center", or "right" — got ${JSON.stringify(align)}`);
87
+ if (valign != null && !VALIGN.has(valign)) bad(name, `valign must be "bottom", "middle", or "top" — got ${JSON.stringify(valign)}`);
88
+ const { minX, minY, maxX, maxY } = regionsBbox(measureAgainst);
89
+ const s = scaleFor(opts, units, maxX - minX, maxY - minY, name);
90
+ const dx = align == null ? 0 : align === "left" ? -minX * s : align === "right" ? -maxX * s : -((minX + maxX) / 2) * s;
91
+ const dy = valign == null ? 0 : valign === "bottom" ? -minY * s : valign === "top" ? -maxY * s : -((minY + maxY) / 2) * s;
92
+ return regions.map((r) => ({
93
+ outer: place(r.outer, s, dx, dy),
94
+ holes: r.holes.map((c) => place(c, s, dx, dy)),
95
+ }));
96
+ }
@@ -0,0 +1,212 @@
1
+ // SVG -> the partforge-vector JSON format. The browser half of k.vector2d, run ONCE
2
+ // per artwork by the host — never at build time, never in the geometry worker.
3
+ //
4
+ // This is the ONLY DOM-dependent file in the feature, and that is the whole
5
+ // point: with a real DOM, paper.js's importSVG does the work six hand-rolled
6
+ // modules would otherwise do. It bakes ancestor transforms into coordinates,
7
+ // resolves per-item style, and handles <use>, <defs> and CSS — none of which a
8
+ // DOM-free parser could reach.
9
+ //
10
+ // Never import this from the worker graph. test/worker-layering.test.js proves
11
+ // you have not: it walks the worker's import closure and fails on any module
12
+ // that so much as names `document`.
13
+ import paper from "paper/dist/paper-core.js";
14
+ import { toContour, toOpenContour, booleanRegions } from "../geometry/paper-bridge.js";
15
+ import { resolveCurveFill } from "../geometry/curve-fill.js";
16
+ import { outlineStroke } from "../geometry/stroke-outline.js";
17
+ import { recoverArcs } from "../geometry/arc-fit.js";
18
+ import { fromInternalRegions, validateVectorDocument } from "../geometry/vector-format.js";
19
+ import { reverseContour } from "../geometry/profile.js";
20
+
21
+ // A private scope, never paper's package-global project — another consumer in
22
+ // the same page may import paper too. Same rule paper-bridge.js follows.
23
+ let _scope = null;
24
+ function scope() {
25
+ if (!_scope) { _scope = new paper.PaperScope(); _scope.setup(new _scope.Size(1, 1)); }
26
+ return _scope;
27
+ }
28
+
29
+ // SVG is y-down; the model frame is y-up. Applied after paper has baked
30
+ // transforms and before arc recovery, so everything downstream is in one frame.
31
+ const flipContourRaw = (c) => ({
32
+ start: [c.start[0], -c.start[1]],
33
+ segments: c.segments.map((s) => {
34
+ const m = { to: [s.to[0], -s.to[1]] };
35
+ if (s.via) m.via = [s.via[0], -s.via[1]];
36
+ if (s.c1) { m.c1 = [s.c1[0], -s.c1[1]]; m.c2 = [s.c2[0], -s.c2[1]]; }
37
+ return m;
38
+ }),
39
+ });
40
+
41
+ // Negating y REVERSES orientation, so every contour must also be reversed to
42
+ // restore the storage winding invariant (outer CCW, holes CW in the y-up frame)
43
+ // that contour-offset.js and contour-winding.js depend on. Without the reverse
44
+ // this silently emits outers as CW and holes as CCW, and a later offset would
45
+ // grow holes and shrink outers with no crash and no error.
46
+ const flipContour = (c) => reverseContour(flipContourRaw(c));
47
+
48
+ const flipRegion = (r) => ({ outer: flipContour(r.outer), holes: r.holes.map(flipContour) });
49
+
50
+ // A paper Path/CompoundPath -> this engine's contours, one per subpath.
51
+ function itemContours(item) {
52
+ const paths = item.className === "CompoundPath" ? item.children : [item];
53
+ return paths
54
+ .filter((p) => p.segments && p.segments.length >= 2)
55
+ .map((p) => ({ contour: p.closed ? toContour(p) : toOpenContour(p), closed: !!p.closed }));
56
+ }
57
+
58
+ const LINECAP = { butt: "butt", round: "round", square: "square" };
59
+ const LINEJOIN = { miter: "miter", round: "round", bevel: "bevel" };
60
+
61
+ const SVG_NS = "http://www.w3.org/2000/svg";
62
+ const XLINK_NS = "http://www.w3.org/1999/xlink";
63
+
64
+ // paper's <use> importer (paper-core.js's `use:` entry) resolves its target
65
+ // through `SvgElement.get(node, "href")`, which is hard-wired to read that
66
+ // attribute from the XLINK namespace ONLY (`attributeNamespace.href = xlink`
67
+ // in paper-core.js). A bare SVG2 `href="#id"` — what every modern authoring
68
+ // tool emits — lives in no namespace at all, so `getAttributeNS(xlink, href)`
69
+ // returns null and the `<use>` silently resolves to nothing. This is true in
70
+ // any DOM, real browser included; it is not a test-environment gap. Patch it
71
+ // ourselves before handing the tree to paper: mirror a bare `href` onto
72
+ // `xlink:href` on every `<use>` that doesn't already have one. Do this by
73
+ // parsing the markup into a real DOM tree (paper's importSVG accepts a node
74
+ // as readily as a string) rather than string-munging the SVG text, so this
75
+ // survives whatever quoting/whitespace the source happens to use.
76
+ function normalizeUseHref(svgText) {
77
+ const doc = new DOMParser().parseFromString(svgText, "image/svg+xml");
78
+ const uses = doc.getElementsByTagNameNS(SVG_NS, "use");
79
+ for (let i = 0; i < uses.length; i++) {
80
+ const use = uses[i];
81
+ if (use.hasAttribute("href") && !use.hasAttributeNS(XLINK_NS, "href")) {
82
+ use.setAttributeNS(XLINK_NS, "xlink:href", use.getAttribute("href"));
83
+ }
84
+ }
85
+ return doc;
86
+ }
87
+
88
+ export function ingestSvg(svgText, { strokes = "outline", source = null } = {}) {
89
+ if (typeof svgText !== "string" || !svgText.trim()) {
90
+ throw new Error("svg: ingestSvg needs the SVG document as a non-empty string");
91
+ }
92
+ const sc = scope();
93
+ let root;
94
+ try {
95
+ root = sc.project.importSVG(normalizeUseHref(svgText), { expandShapes: true, insert: false });
96
+ } catch (e) {
97
+ throw new Error(`svg: could not parse the SVG document — ${e?.message ?? e}`);
98
+ }
99
+ if (!root) throw new Error("svg: could not parse the SVG document");
100
+
101
+ const resolved = [];
102
+ const visit = (item) => {
103
+ // A <use> that resolves to a <symbol> (rather than a plain element)
104
+ // imports as a paper SymbolItem, not a Group/Path — paper keeps the
105
+ // symbol's geometry in one shared SymbolDefinition and never clones it
106
+ // per <use>, so it has no .children and no .segments of its own and
107
+ // would otherwise fall straight through to the `return` below, silently
108
+ // contributing nothing. Unwrap it: clone the definition's item and bake
109
+ // this particular <use>'s placement matrix into the clone, then recurse
110
+ // into the (now ordinary) Group/Path. Note that fill/stroke set directly
111
+ // on the <use> element does NOT carry over — paper resolves each
112
+ // element's paint from the real DOM's computed style at that element's
113
+ // own position in the document, and a <symbol>'s content is parsed once
114
+ // as a *sibling* of <use>, never as its descendant, so paint must live on
115
+ // the symbol's own shapes (or an ancestor they actually share).
116
+ if (item.className === "SymbolItem") {
117
+ const inner = item.definition.item.clone();
118
+ // A SymbolDefinition's item is kept with applyMatrix=false — its shared
119
+ // geometry stays in LOCAL coordinates and each SymbolItem carries only
120
+ // its own placement matrix, so multiple <use>s of one <symbol> reuse
121
+ // one set of segment points. item.transform(item.matrix) would only
122
+ // update the clone's own decomposed matrix, not its segments — every
123
+ // placement would then read back the same untransformed local points.
124
+ // append + apply(true, true) bakes the placement into real segment
125
+ // coordinates (recursively, and flips applyMatrix to true on the way),
126
+ // which is what itemContours/toContour below actually read.
127
+ inner.matrix.append(item.matrix);
128
+ inner.matrix.apply(true, true);
129
+ visit(inner);
130
+ return;
131
+ }
132
+ // A CompoundPath has .children too (its subpaths), but it is ONE paintable
133
+ // item — recursing into its children would split it into independent
134
+ // single-subpath items and lose the fill-rule relationship between them
135
+ // (e.g. the counter of an "O" would stop being a hole). Only Groups and
136
+ // Layers get walked; CompoundPath and Path are both handled as leaves.
137
+ if (item.className !== "CompoundPath" && item.children && item.children.length) {
138
+ item.children.forEach(visit);
139
+ return;
140
+ }
141
+ if (!item.segments && item.className !== "CompoundPath") return; // the root clip Shape lands here
142
+
143
+ const subpaths = itemContours(item);
144
+ if (subpaths.length === 0) return;
145
+
146
+ // Colour is read only as present-or-absent: this produces geometry, not paint.
147
+ if (item.fillColor) {
148
+ // Per ITEM, not per subpath: a fill rule applies across an item's own
149
+ // subpaths, which is what makes the counter of an "O" a hole.
150
+ resolved.push(...resolveCurveFill(subpaths.map((s) => s.contour),
151
+ { fillRule: item.fillRule === "evenodd" ? "evenodd" : "nonzero" }));
152
+ }
153
+ if (strokes !== "ignore" && item.strokeColor && item.strokeWidth > 0) {
154
+ const style = {
155
+ strokeWidth: item.strokeWidth,
156
+ linecap: LINECAP[item.strokeCap] ?? "butt",
157
+ linejoin: LINEJOIN[item.strokeJoin] ?? "miter",
158
+ };
159
+ // Per SUBPATH: each is stroked on its own, with its own open/closed sense.
160
+ for (const { contour, closed } of subpaths) resolved.push(...outlineStroke(contour, closed, style));
161
+ }
162
+ };
163
+ visit(root);
164
+
165
+ if (resolved.length === 0) {
166
+ throw new Error('svg: no painted geometry — every element is fill="none" with no stroke, hidden, or empty');
167
+ }
168
+
169
+ // One union across every item. `resolved` entries are already RESOLVED
170
+ // regions (each item's own fill/stroke geometry, holes included) — folding
171
+ // them together needs an actual planar boolean union, not another
172
+ // resolveCurveFill("nonzero") pass over the flattened contour list: paper's
173
+ // `compound.unite(compound)` self-unite trick (which resolveCurveFill uses
174
+ // internally to normalize crossings under a fill rule) special-cases the
175
+ // "unite with itself" call and does not correctly union two *disjoint*
176
+ // same-winding shapes — two overlapping same-fill rects came back with the
177
+ // overlap cancelled (evenodd-shaped) instead of merged, undercounting the
178
+ // union's area. booleanRegions performs a real A.unite(B) between distinct
179
+ // paper compound paths per pair, which does not hit that case, and it
180
+ // already carries the storage winding invariant on its output.
181
+ let union = [];
182
+ for (const r of resolved) union = booleanRegions(union, [r], "unite");
183
+ if (union.length === 0) throw new Error("svg: geometry cancelled to nothing under the fill rule");
184
+
185
+ const flipped = union.map(flipRegion);
186
+ const withArcs = flipped.map((r) => ({ outer: recoverArcs(r.outer), holes: r.holes.map(recoverArcs) }));
187
+ // fromInternalRegions rounds every coordinate to 6dp itself when it serializes
188
+ // the document, and computes doc.bbox (regionsBbox, vector-format.js) from
189
+ // these UNROUNDED regions first — an EXACT bbox (paper.js's analytic curve
190
+ // bounds), not the fixed-step tessellation this file used to have to
191
+ // pre-round coordinates to work around: that old sampling grid could shift
192
+ // an extremum by ~1e-3 on an unrounded-vs-rounded ULP nudge, which is gone
193
+ // now that the bbox check isn't sampling a grid at all.
194
+ const doc = fromInternalRegions(withArcs, { source, units: "artwork", shape: "artwork" });
195
+ // Ingest must never emit a document its own loader refuses. It used to: a
196
+ // filled half-disc reduced to a ONE-segment contour (the implicit chord is
197
+ // dropped, and arc recovery merged two quarter-cubics into a single ≤180°
198
+ // arc) and validateVectorDocument then required two. The file wrote fine and
199
+ // died at the first build that touched it, telling the author to hand-edit a
200
+ // generated file the docs say never to hand-edit.
201
+ //
202
+ // The rule was the wrong one and is fixed, but the CLASS of bug is closed
203
+ // here: this is the reference implementation VECTOR-FORMAT.md invites third
204
+ // parties to diff against, so the loader's own gate runs on the way out. A
205
+ // throw here is a partforge bug, not an author's — say so.
206
+ try {
207
+ validateVectorDocument(doc, source ?? "(ingested)");
208
+ } catch (e) {
209
+ throw new Error(`svg: ingest produced a document this build cannot load — that is a partforge bug, please report it with the SVG.\n ${e.message}`);
210
+ }
211
+ return doc;
212
+ }