partforge 0.92.0 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +6 -3
- package/docs/AUTHORING-PARTS.md +211 -1
- package/docs/ERROR-PATTERNS.md +97 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/docs/VECTOR-FORMAT.md +746 -0
- package/package.json +9 -1
- package/src/app-emblem.js +15 -0
- package/src/emblem-worker.js +3 -0
- package/src/framework/asset-resolve.js +29 -7
- package/src/framework/geometry/arc-fit.js +146 -0
- package/src/framework/geometry/contour-offset.js +5 -0
- package/src/framework/geometry/curve-fill.js +57 -7
- package/src/framework/geometry/kernel-front.js +46 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/probe.js +1 -1
- package/src/framework/geometry/stroke-outline.js +119 -0
- package/src/framework/geometry/vector-format.js +334 -0
- package/src/framework/geometry/vector2d.js +96 -0
- package/src/framework/ingest/svg-ingest.js +212 -0
- package/src/framework/jobs.js +11 -0
- package/src/framework/lint/index.js +28 -3
- package/src/framework/lint/rules-vector.js +112 -0
- package/src/framework/vectors.js +205 -0
- package/src/framework/worker.js +46 -1
- package/src/ingest.js +8 -0
- package/src/parts/assets/emblem.svg +10 -0
- package/src/parts/assets/emblem.vector.json +110 -0
- package/src/parts/assets/plate.vector.json +27 -0
- package/src/parts/emblem.js +114 -0
- package/src/testing/manifold.js +3 -1
- package/src/testing/occt.js +3 -1
- package/types/ingest.d.ts +118 -0
- package/types/kernel.d.ts +28 -0
- package/types/part.d.ts +65 -0
|
@@ -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
|
+
}
|