partforge 0.92.0 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +6 -3
- package/docs/AUTHORING-PARTS.md +211 -1
- package/docs/ERROR-PATTERNS.md +97 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/docs/VECTOR-FORMAT.md +746 -0
- package/package.json +9 -1
- package/src/app-emblem.js +15 -0
- package/src/emblem-worker.js +3 -0
- package/src/framework/asset-resolve.js +29 -7
- package/src/framework/geometry/arc-fit.js +146 -0
- package/src/framework/geometry/contour-offset.js +5 -0
- package/src/framework/geometry/curve-fill.js +57 -7
- package/src/framework/geometry/kernel-front.js +46 -0
- package/src/framework/geometry/kernel.js +1 -1
- package/src/framework/geometry/probe.js +1 -1
- package/src/framework/geometry/stroke-outline.js +119 -0
- package/src/framework/geometry/vector-format.js +334 -0
- package/src/framework/geometry/vector2d.js +96 -0
- package/src/framework/ingest/svg-ingest.js +212 -0
- package/src/framework/jobs.js +11 -0
- package/src/framework/lint/index.js +28 -3
- package/src/framework/lint/rules-vector.js +112 -0
- package/src/framework/vectors.js +205 -0
- package/src/framework/worker.js +46 -1
- package/src/ingest.js +8 -0
- package/src/parts/assets/emblem.svg +10 -0
- package/src/parts/assets/emblem.vector.json +110 -0
- package/src/parts/assets/plate.vector.json +27 -0
- package/src/parts/emblem.js +114 -0
- package/src/testing/manifold.js +3 -1
- package/src/testing/occt.js +3 -1
- package/types/ingest.d.ts +118 -0
- package/types/kernel.d.ts +28 -0
- package/types/part.d.ts +65 -0
package/src/framework/jobs.js
CHANGED
|
@@ -9,6 +9,7 @@ import { fontsFor, resolveFonts } from "./fonts.js";
|
|
|
9
9
|
import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
|
|
10
10
|
import { ensureImports, resolveImports } from "./imports.js";
|
|
11
11
|
import { safeName } from "./safe-name.js";
|
|
12
|
+
import { ensureVectors } from "./vectors.js";
|
|
12
13
|
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
13
14
|
|
|
14
15
|
// The oracle loads LAZILY, per job family, never at worker boot. It is the largest
|
|
@@ -226,6 +227,16 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
226
227
|
// lazy-error policy that keeps a STEP import inert until a build actually
|
|
227
228
|
// calls k.import on it.
|
|
228
229
|
if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
|
|
230
|
+
// Vector art, the third asset family after fonts and imports. Same pre-build
|
|
231
|
+
// timing; ensureVectors owns the prune, so this stays one line. Call it
|
|
232
|
+
// unconditionally, even when this part has no `vectors` at all: ensureVectors
|
|
233
|
+
// treats a nullish declaration as `{}` and its prune loop is what drops
|
|
234
|
+
// names a *previous* part (on a rebound worker) registered — the same
|
|
235
|
+
// stale-registration bug the unconditional fonts prune above exists to
|
|
236
|
+
// prevent. Guarding this on `part.vectors` would skip exactly the case where
|
|
237
|
+
// pruning matters most: a worker rebound from a part WITH artwork to one
|
|
238
|
+
// WITHOUT would leave the old names resolvable forever.
|
|
239
|
+
await ensureVectors(kernel, part.vectors);
|
|
229
240
|
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
230
241
|
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
231
242
|
// Explicit selection (headless exportParts) overrides view-derived selection.
|
|
@@ -18,8 +18,9 @@ import { PLACE_RULES } from "./rules-place.js";
|
|
|
18
18
|
import { IMPORT_RULES } from "./rules-imports.js";
|
|
19
19
|
import { FONT_RULES } from "./rules-fonts.js";
|
|
20
20
|
import { SOURCE_RULES } from "./rules-source.js";
|
|
21
|
+
import { VECTOR_RULES } from "./rules-vector.js";
|
|
21
22
|
|
|
22
|
-
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES];
|
|
23
|
+
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
|
|
23
24
|
|
|
24
25
|
// A usable sources input, or null. Deliberately forgiving: lintPart's callers
|
|
25
26
|
// include hosted paths handing over user/LLM-authored trees, so a malformed
|
|
@@ -44,6 +45,24 @@ function normalizeSources(sources) {
|
|
|
44
45
|
return { files, entrypoint };
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// The caller's parsed vector files, or null. Deliberately forgiving for the same
|
|
49
|
+
// reason normalizeSources is: hosted callers hand over user- and agent-authored
|
|
50
|
+
// trees, and a malformed input must mean "no document-dependent findings",
|
|
51
|
+
// never a throw. Lint itself never reads a file — it is pure and synchronous by
|
|
52
|
+
// contract (see this file's header); the caller does the I/O and passes the
|
|
53
|
+
// result in.
|
|
54
|
+
function normalizeVectorDocs(docs) {
|
|
55
|
+
if (!docs || typeof docs !== "object") return null;
|
|
56
|
+
const out = Object.create(null);
|
|
57
|
+
let any = false;
|
|
58
|
+
for (const [name, doc] of Object.entries(docs)) {
|
|
59
|
+
if (!doc || typeof doc !== "object") continue;
|
|
60
|
+
out[name] = doc;
|
|
61
|
+
any = true;
|
|
62
|
+
}
|
|
63
|
+
return any ? out : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
// Every rule runs inside a guard. lintPart is called on a user-facing hosted path
|
|
48
67
|
// (partforge-cloud's sandbox), and a linter that takes down the preview it exists to
|
|
49
68
|
// protect is worse than no linter — so a throwing rule becomes a WARNING, never an
|
|
@@ -98,9 +117,12 @@ export function lintContext(part, params) {
|
|
|
98
117
|
/**
|
|
99
118
|
* Lint a PartDefinition. Never throws.
|
|
100
119
|
* @param {object} part the default-exported PartDefinition
|
|
101
|
-
* @param {{params?: object, sources?: {files?: Record<string, string>, entrypoint?: string}}} [opts]
|
|
120
|
+
* @param {{params?: object, sources?: {files?: Record<string, string>, entrypoint?: string}, vectorDocs?: Record<string, object>}} [opts]
|
|
102
121
|
* `params` are layered over part.defaults for the probe pass; `sources` is the part's own
|
|
103
122
|
* source text, which unlocks the source rules (Group 9) — omit it and lint behaves as before.
|
|
123
|
+
* `vectorDocs` is `{ name: parsedDocument }`, the raw parsed JSON of the part's declared
|
|
124
|
+
* vector files (see vectors.js's resolveVectorDocs) — omit it and the document-dependent
|
|
125
|
+
* vector rules (vector-size-missing, vector-unknown-shape) stay silent rather than guess.
|
|
104
126
|
* @returns {{ok: boolean, errors: object[], warnings: object[], notes: object[]}}
|
|
105
127
|
*/
|
|
106
128
|
export function lintPart(part, opts) {
|
|
@@ -108,7 +130,7 @@ export function lintPart(part, opts) {
|
|
|
108
130
|
// parameter only fires on `undefined` — a caller passing `lintPart(part, null)`
|
|
109
131
|
// (a plausible downstream-harness call) would otherwise throw destructuring
|
|
110
132
|
// `{ params }` out of `null` before this function's body ever runs.
|
|
111
|
-
const { params, sources } = opts ?? {};
|
|
133
|
+
const { params, sources, vectorDocs } = opts ?? {};
|
|
112
134
|
// lintContext already guards its own internals (see its comment above), but it
|
|
113
135
|
// is user-authored data all the way down — wrap the call itself too, so a
|
|
114
136
|
// failure mode neither of us has thought of still degrades to a report instead
|
|
@@ -136,6 +158,9 @@ export function lintPart(part, opts) {
|
|
|
136
158
|
// host filtering source findings out to keep them non-blocking would instead
|
|
137
159
|
// refuse to render a part that builds fine.
|
|
138
160
|
try { ctx.sources = normalizeSources(sources); } catch { ctx.sources = null; }
|
|
161
|
+
// Same deliberate own-guard as sources above: a malformed vectorDocs input
|
|
162
|
+
// means "the document-dependent vector rules stay quiet", never a broken part.
|
|
163
|
+
try { ctx.vectorDocs = normalizeVectorDocs(vectorDocs); } catch { ctx.vectorDocs = null; }
|
|
139
164
|
const findings = runRules(RULES, ctx);
|
|
140
165
|
// `p` (params merged from `defaults`) failed to build — every rule still ran
|
|
141
166
|
// against the `{}` fallback (each guarded individually by runRules), but the
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Group 10 — vector-art call well-formedness. All three conditions throw at
|
|
2
|
+
// build time anyway; these rules move them ahead of the kernel boot, which is
|
|
3
|
+
// where an authoring agent wants them.
|
|
4
|
+
//
|
|
5
|
+
// All read `probe().calls`, whose `args` are JSON.stringify of the RESOLVED
|
|
6
|
+
// argument values under the part's default params (probe.js's `describe`), not
|
|
7
|
+
// source text. So these judge what the part actually builds by default — the
|
|
8
|
+
// same basis rules-imports.js's import-unknown-name already uses. A call that
|
|
9
|
+
// only goes wrong for non-default params is not caught here and still fails
|
|
10
|
+
// correctly at build time; this catches the common case early, it does not
|
|
11
|
+
// replace that authority.
|
|
12
|
+
//
|
|
13
|
+
// vector-size-missing and vector-unknown-shape additionally need `ctx.vectorDocs`
|
|
14
|
+
// — the caller's parsed vector files (see lint/index.js's normalizeVectorDocs,
|
|
15
|
+
// vectors.js's resolveVectorDocs). Lint itself never reads a file (this
|
|
16
|
+
// package's header), so without a supplied document neither rule can tell
|
|
17
|
+
// units from shapes and both stay silent rather than guess.
|
|
18
|
+
import { err } from "./finding.js";
|
|
19
|
+
|
|
20
|
+
const declaredVectors = (part) => Object.keys(part?.vectors ?? {});
|
|
21
|
+
|
|
22
|
+
// The name arrives JSON-serialized, so JSON.parse recovers it — and yields null
|
|
23
|
+
// for anything that is not a string (import-unknown-name reads its name the same way).
|
|
24
|
+
const literalName = (src) => {
|
|
25
|
+
try { const v = JSON.parse(src); return typeof v === "string" ? v : null; } catch { return null; }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Probe args are JSON-serialized resolved VALUES (probe.js's `describe`), not
|
|
29
|
+
// source text — so an options object arrives as `{"width":10,"shape":"body"}`.
|
|
30
|
+
// Parsing it back is exact for the literal cases these rules judge; anything
|
|
31
|
+
// that does not parse to a plain object means "cannot tell", and the rule stays
|
|
32
|
+
// quiet rather than guessing. A missing argument (`src == null`) is not
|
|
33
|
+
// malformed — it means "no options object" — so it reads as `{}`, not "unknown".
|
|
34
|
+
const optsOf = (src) => {
|
|
35
|
+
if (src == null) return {};
|
|
36
|
+
try { const v = JSON.parse(src); return v && typeof v === "object" && !Array.isArray(v) ? v : null; } catch { return null; }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const vectorCalls = (probe) => probe().calls.filter((c) => c.scope === "kernel" && c.op === "vector2d");
|
|
40
|
+
|
|
41
|
+
export const VECTOR_RULES = [
|
|
42
|
+
{
|
|
43
|
+
id: "vector-unknown-name",
|
|
44
|
+
run: ({ part, probe }) => {
|
|
45
|
+
const known = new Set(declaredVectors(part));
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const call of vectorCalls(probe)) {
|
|
49
|
+
const name = literalName(call.args[0]);
|
|
50
|
+
if (name == null || known.has(name) || seen.has(name)) continue;
|
|
51
|
+
seen.add(name);
|
|
52
|
+
out.push(err("vector-unknown-name",
|
|
53
|
+
`build calls k.vector2d with name "${name}", which the part's vectors field does not declare: ${[...known].join(", ") || "(nothing)"}`,
|
|
54
|
+
"Declare the ingested artwork under vectors: { name: source }, or fix the name to match an existing entry.",
|
|
55
|
+
"vectors"));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "vector-size-missing",
|
|
62
|
+
run: ({ probe, vectorDocs }) => {
|
|
63
|
+
if (!vectorDocs) return []; // caller supplied nothing — cannot judge units
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const call of vectorCalls(probe)) {
|
|
66
|
+
const name = literalName(call.args[0]);
|
|
67
|
+
if (name == null) continue;
|
|
68
|
+
const doc = Object.hasOwn(vectorDocs, name) ? vectorDocs[name] : null;
|
|
69
|
+
if (doc?.units !== "artwork") continue; // mm files place as authored; a size is optional
|
|
70
|
+
const opts = optsOf(call.args[1]);
|
|
71
|
+
if (opts == null) continue;
|
|
72
|
+
if (opts.width != null || opts.height != null || opts.fit != null) continue;
|
|
73
|
+
out.push(err("vector-size-missing",
|
|
74
|
+
`k.vector2d("${name}", …) declares no size, and "${name}" has units "artwork" — one of { width }, { height }, or { fit } is required, in millimetres`,
|
|
75
|
+
"Artwork units have no physical meaning, so there is no safe default to fall back on (unlike k.text2d's cap-height `size`). "
|
|
76
|
+
+ `Add one, e.g. k.vector2d("${name}", { width: 20 }) — or re-author the file with "units": "mm" if its coordinates really are millimetres.`,
|
|
77
|
+
"build"));
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "vector-unknown-shape",
|
|
84
|
+
run: ({ probe, vectorDocs }) => {
|
|
85
|
+
if (!vectorDocs) return [];
|
|
86
|
+
// Keyed by JSON.stringify([name, shape]) — a JSON array literal cannot be
|
|
87
|
+
// produced by any other (name, shape) pairing, so two distinct pairings can
|
|
88
|
+
// never collide even though both halves are arbitrary author-chosen strings.
|
|
89
|
+
// Same pairing repeated across calls reports once; a DIFFERENT bad shape
|
|
90
|
+
// name on the same vector still reports separately.
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const call of vectorCalls(probe)) {
|
|
94
|
+
const name = literalName(call.args[0]);
|
|
95
|
+
const opts = optsOf(call.args[1]);
|
|
96
|
+
if (name == null || opts == null || typeof opts.shape !== "string") continue;
|
|
97
|
+
const key = JSON.stringify([name, opts.shape]);
|
|
98
|
+
if (seen.has(key)) continue;
|
|
99
|
+
const doc = Object.hasOwn(vectorDocs, name) ? vectorDocs[name] : null;
|
|
100
|
+
const shapes = doc?.shapes;
|
|
101
|
+
if (!shapes || typeof shapes !== "object" || Array.isArray(shapes)) continue;
|
|
102
|
+
if (Object.hasOwn(shapes, opts.shape)) continue;
|
|
103
|
+
seen.add(key);
|
|
104
|
+
out.push(err("vector-unknown-shape",
|
|
105
|
+
`k.vector2d("${name}", { shape: "${opts.shape}" }) names a shape "${opts.shape}" that "${name}" does not contain: ${Object.keys(shapes).join(", ") || "(none)"}`,
|
|
106
|
+
"Fix the shape name to match one the file declares, or omit `shape` to use the role-composed result — every \"add\" shape unioned, minus every \"subtract\" shape.",
|
|
107
|
+
"build"));
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
];
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Resolve a part's declared `vectors` ({ name: source }) to internal regions before
|
|
2
|
+
// the synchronous build — the vector-art sibling of fonts.js and imports.js:
|
|
3
|
+
// same source grammar and identity-memoization rule, built on the shared core in
|
|
4
|
+
// asset-resolve.js. The source resolves to JSON in the partforge-vector format,
|
|
5
|
+
// not to SVG; conversion happened once, in a browser, at ingest.
|
|
6
|
+
//
|
|
7
|
+
// No content digest, deliberately. It looks like a missing piece next to
|
|
8
|
+
// imports.js and is not: k.vector2d lowers to k.shape2d(regions) and the Shape2D
|
|
9
|
+
// hash keys on the actual coordinates, so different artwork gives a different
|
|
10
|
+
// cache entry automatically. Imports need a digest because a Solid master is
|
|
11
|
+
// registered by NAME and is opaque to that hash; parsed regions are not. Same
|
|
12
|
+
// argument kernel-front.js:117-121 records for text2d.
|
|
13
|
+
//
|
|
14
|
+
// DOM-free and node:-free.
|
|
15
|
+
import { makeAssetResolver, resolveDecl, unwrapModule } from "./asset-resolve.js";
|
|
16
|
+
import { toInternalDocument } from "./geometry/vector-format.js";
|
|
17
|
+
|
|
18
|
+
const cache = new Map(); // source → Promise<Uint8Array> (raw bytes)
|
|
19
|
+
// source → { units, shapes }. The bytes memo above stops a refetch; this stops a re-PARSE
|
|
20
|
+
// (UTF-8 decode + JSON.parse + validation + a bbox recomputation that tessellates
|
|
21
|
+
// every contour). Without it every regen of a part with artwork redoes all of that.
|
|
22
|
+
// Fonts solve the same problem with kernel._fontsBySource (jobs.js:206-212) and
|
|
23
|
+
// imports with a digest comparison; this is the third pipeline's version of it.
|
|
24
|
+
// Keyed on the SOURCE, not the name: one worker outlives many parts, and a name is
|
|
25
|
+
// not an identity. Only successes are cached — a parse failure must throw again
|
|
26
|
+
// under the next name that declares it, with that name in the message.
|
|
27
|
+
const parsed = new Map();
|
|
28
|
+
|
|
29
|
+
// A source that IS the parsed contents of a partforge-vector file, rather than a
|
|
30
|
+
// way to reach its bytes — the in-tree form, `import doc from "./x.vector.json"`.
|
|
31
|
+
// Returns that object, or null for every other source form.
|
|
32
|
+
//
|
|
33
|
+
// `unwrapModule` first, so a dynamic `import("./x.vector.json")` namespace reads
|
|
34
|
+
// the same as the static default import, matching the rule the resolver applies
|
|
35
|
+
// to bytes and URLs.
|
|
36
|
+
//
|
|
37
|
+
// Deliberately STRUCTURAL, not a format check: anything object-shaped is claimed
|
|
38
|
+
// here and judged afterwards by toInternalDocument, so an object that is not
|
|
39
|
+
// artwork draws the validator's specific complaint (`has format "svg"`) rather
|
|
40
|
+
// than the source grammar's generic one. Arrays are not claimed — an array is
|
|
41
|
+
// never a file, and for it the grammar error names the real mistake.
|
|
42
|
+
function asParsedFile(source) {
|
|
43
|
+
const v = unwrapModule(source);
|
|
44
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
|
45
|
+
if (v instanceof ArrayBuffer || ArrayBuffer.isView(v) || v instanceof URL) return null;
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `payload` is what resolveOne produced: the resolved bytes, or — for a source
|
|
50
|
+
// `asParsedFile` claimed — the file's contents themselves, already parsed.
|
|
51
|
+
function parseDocument(payload, label) {
|
|
52
|
+
if (!(payload instanceof ArrayBuffer)) return toInternalDocument(payload, label);
|
|
53
|
+
let text;
|
|
54
|
+
try { text = new TextDecoder().decode(payload); }
|
|
55
|
+
catch { throw new Error(`vector2d: "${label}" could not be decoded as UTF-8 text`); }
|
|
56
|
+
let doc;
|
|
57
|
+
try { doc = JSON.parse(text); }
|
|
58
|
+
catch (e) {
|
|
59
|
+
throw new Error(`vector2d: "${label}" is not valid JSON — ${e.message}. `
|
|
60
|
+
+ "A vectors source is an ingested partforge-vector file, not an .svg file; see docs/VECTOR-FORMAT.md");
|
|
61
|
+
}
|
|
62
|
+
return toInternalDocument(doc, label);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// source -> resolved bytes, recorded only once a source has SUCCESSFULLY resolved.
|
|
66
|
+
// `cache` above cannot answer that question: makeAssetResolver stores the promise
|
|
67
|
+
// synchronously, so `cache.has(source)` is true the instant a fetch starts and
|
|
68
|
+
// stays true if it never finishes. This map is what `cachedVectorDocs` reads, and
|
|
69
|
+
// its whole point is that membership means "the bytes are here, now".
|
|
70
|
+
const bytesBySource = new Map();
|
|
71
|
+
// source -> raw parsed JSON, or null if it is not a JSON object. Filled LAZILY by
|
|
72
|
+
// cachedVectorDocs rather than at resolve time, so the build path never pays a
|
|
73
|
+
// JSON.parse for lint's benefit, and lint never re-parses the same file twice.
|
|
74
|
+
const rawBySource = new Map();
|
|
75
|
+
|
|
76
|
+
// The resolver memoizes by source identity and cannot see the declared name, so
|
|
77
|
+
// the name is bound per declaration below rather than baked into the resolver.
|
|
78
|
+
const resolveOne = makeAssetResolver(
|
|
79
|
+
cache,
|
|
80
|
+
(bytes, _value, source) => { bytesBySource.set(source, bytes); return bytes; },
|
|
81
|
+
"resolveVectors: a vector source must be bytes, a URL, a thunk returning one, "
|
|
82
|
+
+ "or the already-parsed contents of a partforge-vector file",
|
|
83
|
+
(value) => asParsedFile(value) ?? undefined,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
export async function resolveVectors(vectorsDecl) {
|
|
87
|
+
// A function reaching here means a caller passed `part.vectors` raw, the way
|
|
88
|
+
// fonts.js's resolveFonts guards against the same mistake for `part.fonts`.
|
|
89
|
+
// `Object.entries` on a function is `[]`, not a thrown error, so without
|
|
90
|
+
// this check a function-valued `vectors` would silently resolve to an empty
|
|
91
|
+
// map and only surface much later as `vector2d: unknown vector "…"` — a name a
|
|
92
|
+
// part author declared correctly, that k.vector2d insists doesn't exist. No
|
|
93
|
+
// part currently declares `vectors` as a function (unlike fonts, which a
|
|
94
|
+
// `type: "font"` control already drives this way) — this exists so the
|
|
95
|
+
// day that form is added, it fails loudly instead of silently.
|
|
96
|
+
if (typeof vectorsDecl === "function") {
|
|
97
|
+
throw new Error("resolveVectors: `vectors` is a function — it is not resolved against params yet; pass the plain object form");
|
|
98
|
+
}
|
|
99
|
+
const raw = await resolveDecl(vectorsDecl, resolveOne);
|
|
100
|
+
const out = new Map();
|
|
101
|
+
for (const [name, payload] of raw) {
|
|
102
|
+
let doc = parsed.get(payload);
|
|
103
|
+
if (!doc) { doc = parseDocument(payload, name); parsed.set(payload, doc); }
|
|
104
|
+
out.set(name, doc);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The RAW parsed JSON, before validation or conversion — what lint's
|
|
110
|
+
// document-dependent rules need to read `units` and `shapes`. Never throws: a
|
|
111
|
+
// source that will not fetch, decode, or parse (to a JSON object) maps to
|
|
112
|
+
// null, and the rules that depend on it stay quiet for that name only —
|
|
113
|
+
// resolution is PER-SOURCE, not all-or-nothing, so one broken vector among
|
|
114
|
+
// several does not silence document-aware lint for its siblings. Deliberately
|
|
115
|
+
// calls `resolveOne` itself rather than going through `resolveDecl` (whose
|
|
116
|
+
// `Promise.all` rejects the whole batch on a single failure) — this still
|
|
117
|
+
// shares `resolveOne`'s bytes memo with resolveVectors, so running `lint`
|
|
118
|
+
// ahead of `measure` (the CLI does both) costs no extra fetch. It does its own
|
|
119
|
+
// decode/parse rather than reusing the `parsed` memo, which holds the
|
|
120
|
+
// VALIDATED/converted document, not the raw JSON this needs.
|
|
121
|
+
export async function resolveVectorDocs(vectorsDecl) {
|
|
122
|
+
if (typeof vectorsDecl === "function") return new Map();
|
|
123
|
+
const decl = vectorsDecl ?? {};
|
|
124
|
+
const out = new Map();
|
|
125
|
+
await Promise.all(Object.entries(decl).map(async ([name, source]) => {
|
|
126
|
+
try {
|
|
127
|
+
const payload = await resolveOne(source);
|
|
128
|
+
// An adopted source resolves to the raw JSON itself — there is nothing to
|
|
129
|
+
// decode, and running it through TextDecoder would map a perfectly good
|
|
130
|
+
// file to null.
|
|
131
|
+
const doc = payload instanceof ArrayBuffer
|
|
132
|
+
? JSON.parse(new TextDecoder().decode(payload))
|
|
133
|
+
: payload;
|
|
134
|
+
out.set(name, doc && typeof doc === "object" ? doc : null);
|
|
135
|
+
} catch {
|
|
136
|
+
out.set(name, null);
|
|
137
|
+
}
|
|
138
|
+
}));
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The SYNCHRONOUS, FETCH-FREE sibling of resolveVectorDocs: the raw parsed JSON
|
|
143
|
+
// for every declared vector whose bytes are ALREADY resolved, and nothing for the
|
|
144
|
+
// rest. Never fetches, never awaits, never throws.
|
|
145
|
+
//
|
|
146
|
+
// This exists because lint is instant and offline BY CONSTRUCTION — that is the
|
|
147
|
+
// property that lets a browser sandbox run it on every keystroke — and calling
|
|
148
|
+
// the async resolver from the lint path quietly gave that away: a slow or hanging
|
|
149
|
+
// vector URL (asset-resolve.js's fetch has no timeout) would stall the lint reply
|
|
150
|
+
// forever, and a throwing `vectors` getter would reject in a floating promise,
|
|
151
|
+
// which surfaces as `unhandledrejection` rather than an `error` event, so a host
|
|
152
|
+
// waiting on a lint-report simply waits.
|
|
153
|
+
//
|
|
154
|
+
// The degradation is the one the two document-dependent rules are designed for:
|
|
155
|
+
// no document, no finding. In a hosted sandbox a build has almost always run
|
|
156
|
+
// first, so the bytes are already in the memo and both rules light up; before the
|
|
157
|
+
// first build they stay silent, exactly as they do today for a caller that passes
|
|
158
|
+
// no vectorDocs at all. Nothing regresses, and lint cannot become slower or
|
|
159
|
+
// hangable than it was before vectors existed.
|
|
160
|
+
//
|
|
161
|
+
// `decl` is caller data all the way down (a throwing getter, a Proxy whose
|
|
162
|
+
// ownKeys trap throws), so every step that touches it is guarded per-name and the
|
|
163
|
+
// whole walk is guarded once.
|
|
164
|
+
export function cachedVectorDocs(vectorsDecl) {
|
|
165
|
+
const out = new Map();
|
|
166
|
+
if (!vectorsDecl || typeof vectorsDecl !== "object") return out;
|
|
167
|
+
let entries;
|
|
168
|
+
try { entries = Object.entries(vectorsDecl); } catch { return out; }
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
try {
|
|
171
|
+
const [name, source] = entry;
|
|
172
|
+
// An already-parsed source has nothing to resolve, so unlike bytes and URLs
|
|
173
|
+
// it is readable on the very first lint — before any build has run. That is
|
|
174
|
+
// the state a hosted editor spends most of its time in.
|
|
175
|
+
const inline = asParsedFile(source);
|
|
176
|
+
if (inline) { out.set(name, inline); continue; }
|
|
177
|
+
if (!bytesBySource.has(source)) continue; // not resolved yet — stay silent
|
|
178
|
+
if (!rawBySource.has(source)) {
|
|
179
|
+
let doc = null;
|
|
180
|
+
try {
|
|
181
|
+
const parsedJson = JSON.parse(new TextDecoder().decode(bytesBySource.get(source)));
|
|
182
|
+
doc = parsedJson && typeof parsedJson === "object" ? parsedJson : null;
|
|
183
|
+
} catch { doc = null; } // not JSON, or not decodable
|
|
184
|
+
rawBySource.set(source, doc);
|
|
185
|
+
}
|
|
186
|
+
out.set(name, rawBySource.get(source));
|
|
187
|
+
} catch { /* one hostile entry silences itself, not its siblings */ }
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Register a part's vectors on a booted kernel. Called in the async phase before
|
|
193
|
+
// every job's synchronous build — worker (jobs.js) and Node boots alike.
|
|
194
|
+
export async function ensureVectors(kernel, vectorsDecl) {
|
|
195
|
+
if (!kernel?._vectors) return;
|
|
196
|
+
const declared = vectorsDecl ?? {};
|
|
197
|
+
for (const [name, doc] of await resolveVectors(declared)) kernel._vectors.set(name, doc);
|
|
198
|
+
// Drop names this declaration does not supply. `_vectors` is the kernel's and the
|
|
199
|
+
// kernel outlives the job (worker-rebind, many parts), so without this a name
|
|
200
|
+
// from a previous part stays resolvable — the stale-registration bug jobs.js's
|
|
201
|
+
// font prune exists to prevent.
|
|
202
|
+
for (const name of [...kernel._vectors.keys()]) {
|
|
203
|
+
if (!Object.hasOwn(declared, name)) kernel._vectors.delete(name);
|
|
204
|
+
}
|
|
205
|
+
}
|
package/src/framework/worker.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// in docs/KERNEL-CONTRACT.md.
|
|
11
11
|
import { handle } from "./jobs.js";
|
|
12
12
|
import { lintPart } from "../lint.js";
|
|
13
|
+
import { cachedVectorDocs } from "./vectors.js";
|
|
13
14
|
|
|
14
15
|
async function manifoldKernels() {
|
|
15
16
|
const [{ default: Module }, { createManifoldKernel }] = await Promise.all([
|
|
@@ -140,7 +141,51 @@ export function runWorker(part, opts = {}) {
|
|
|
140
141
|
// the pump awaits that boot, so routing lint through the queue would drag in
|
|
141
142
|
// OCCT's ~11 MB WASM to run a check that never calls the kernel at all.
|
|
142
143
|
if (e.data?.type === "lint") {
|
|
143
|
-
|
|
144
|
+
// `vectorDocs` is what the two document-dependent vector rules need —
|
|
145
|
+
// vector-size-missing (does this file's `units` require a size?) and
|
|
146
|
+
// vector-unknown-shape (does it declare that shape name?). Without it both
|
|
147
|
+
// stay silent, which is how they are designed to degrade.
|
|
148
|
+
//
|
|
149
|
+
// CACHED ONLY, and deliberately so. This handler must stay synchronous:
|
|
150
|
+
// lint is instant and offline by construction, which is the property that
|
|
151
|
+
// lets a sandbox run it on every keystroke. Awaiting the async resolver
|
|
152
|
+
// here made lint hangable (asset-resolve.js's fetch has no timeout) and
|
|
153
|
+
// made the reply order depend on how fast each part's vectors fetched.
|
|
154
|
+
// cachedVectorDocs reads only bytes that are already resolved and never
|
|
155
|
+
// initiates a fetch, so a hosted sandbox gets both rules as soon as one
|
|
156
|
+
// build has run — the common case — and never waits for them.
|
|
157
|
+
//
|
|
158
|
+
// Guarded even so: lintPart's never-throws contract is worth nothing to a
|
|
159
|
+
// host if the handler around it can throw first and post NOTHING at all —
|
|
160
|
+
// in a real worker that surfaces as `unhandledrejection`, not an `error`
|
|
161
|
+
// event, so a host waiting on a lint-report just waits.
|
|
162
|
+
//
|
|
163
|
+
// Two levels, because they mean different things. The inner one covers a
|
|
164
|
+
// hostile `vectors` (a throwing getter, a Proxy whose ownKeys trap throws):
|
|
165
|
+
// that means "no documents", exactly as if the caller passed none, so the
|
|
166
|
+
// report is the same one lintPart alone would produce. The outer one is the
|
|
167
|
+
// last resort for anything neither of us has thought of.
|
|
168
|
+
let report;
|
|
169
|
+
try {
|
|
170
|
+
let vectorDocs;
|
|
171
|
+
try { vectorDocs = Object.fromEntries(cachedVectorDocs(current?.vectors)); }
|
|
172
|
+
catch { vectorDocs = undefined; }
|
|
173
|
+
report = lintPart(current, { params: e.data.params, vectorDocs });
|
|
174
|
+
} catch (cause) {
|
|
175
|
+
report = {
|
|
176
|
+
ok: false,
|
|
177
|
+
errors: [{
|
|
178
|
+
rule: "lint-context-error",
|
|
179
|
+
severity: "error",
|
|
180
|
+
message: `partforge/lint could not run: ${cause?.message || String(cause)}`,
|
|
181
|
+
hint: "The part or the lint request is too malformed to analyze — make sure `vectors`, `defaults` and `params` are plain, side-effect-free data rather than throwing getters or hostile Proxies.",
|
|
182
|
+
path: "",
|
|
183
|
+
}],
|
|
184
|
+
warnings: [],
|
|
185
|
+
notes: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
postMessage({ type: "lint-report", report });
|
|
144
189
|
return;
|
|
145
190
|
}
|
|
146
191
|
// Only generates supersede each other; exports/inspect always run (cancelling
|
package/src/ingest.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The published `partforge/ingest` entry: SVG -> the partforge-vector JSON
|
|
2
|
+
// format. DOM-required and main-thread only — a host runs it once per artwork
|
|
3
|
+
// and stores the result, the same division of labour as `fontCatalog`.
|
|
4
|
+
// partforge does not write files.
|
|
5
|
+
//
|
|
6
|
+
// Deliberately NOT re-exported from `partforge` (the main entry) or from
|
|
7
|
+
// `partforge/geometry`: this must stay unreachable from the geometry worker.
|
|
8
|
+
export { ingestSvg } from "./framework/ingest/svg-ingest.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
|
2
|
+
<!-- A filled shape and a stroked OPEN shape, so this one file exercises both
|
|
3
|
+
of ingest's geometry paths. The union's tight bbox is 40 x 30 artwork
|
|
4
|
+
units: the circle spans 14..34 in both axes, and the stroked bar spans
|
|
5
|
+
4..44 in x (round caps add half the 4-unit width at each end) and 40..44
|
|
6
|
+
in y. Deliberately NOT centred in the viewBox, so anything that sizes from
|
|
7
|
+
the viewBox instead of the geometry gets a visibly wrong answer. -->
|
|
8
|
+
<circle cx="24" cy="24" r="10" fill="#111"/>
|
|
9
|
+
<polyline points="6 42 42 42" fill="none" stroke="#111" stroke-width="4" stroke-linecap="round"/>
|
|
10
|
+
</svg>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "partforge-vector",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"units": "artwork",
|
|
5
|
+
"note": "Filled 2-D outlines for k.vector2d. `units` is \"mm\" (coordinates are millimetres, placed as authored) or \"artwork\" (no physical meaning; a size is required at every call site). `shapes` maps a name to a list of filled regions; each region's `outer` is its boundary and `holes` are subtracted from it. A contour is a `kind`: \"path\", \"circle\", \"rect\", or \"polygon\". Path segments run head-to-tail from `start`, and the contour closes implicitly from the last `to` back to `start`. y points UP. See docs/VECTOR-FORMAT.md.",
|
|
6
|
+
"source": "emblem.svg",
|
|
7
|
+
"bbox": {
|
|
8
|
+
"minX": 4,
|
|
9
|
+
"minY": -44,
|
|
10
|
+
"maxX": 44,
|
|
11
|
+
"maxY": -14
|
|
12
|
+
},
|
|
13
|
+
"shapes": {
|
|
14
|
+
"artwork": [
|
|
15
|
+
{
|
|
16
|
+
"outer": {
|
|
17
|
+
"kind": "path",
|
|
18
|
+
"start": [
|
|
19
|
+
14,
|
|
20
|
+
-24
|
|
21
|
+
],
|
|
22
|
+
"segments": [
|
|
23
|
+
{
|
|
24
|
+
"kind": "arc",
|
|
25
|
+
"to": [
|
|
26
|
+
31.071068,
|
|
27
|
+
-31.071068
|
|
28
|
+
],
|
|
29
|
+
"through": [
|
|
30
|
+
20.173166,
|
|
31
|
+
-33.238795
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"kind": "arc",
|
|
36
|
+
"to": [
|
|
37
|
+
24,
|
|
38
|
+
-14
|
|
39
|
+
],
|
|
40
|
+
"through": [
|
|
41
|
+
33.238795,
|
|
42
|
+
-20.173166
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"kind": "arc",
|
|
47
|
+
"to": [
|
|
48
|
+
14,
|
|
49
|
+
-24
|
|
50
|
+
],
|
|
51
|
+
"through": [
|
|
52
|
+
16.928932,
|
|
53
|
+
-16.928932
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"holes": []
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"outer": {
|
|
62
|
+
"kind": "path",
|
|
63
|
+
"start": [
|
|
64
|
+
6,
|
|
65
|
+
-40
|
|
66
|
+
],
|
|
67
|
+
"segments": [
|
|
68
|
+
{
|
|
69
|
+
"kind": "arc",
|
|
70
|
+
"to": [
|
|
71
|
+
6,
|
|
72
|
+
-44
|
|
73
|
+
],
|
|
74
|
+
"through": [
|
|
75
|
+
4,
|
|
76
|
+
-42
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"kind": "line",
|
|
81
|
+
"to": [
|
|
82
|
+
42,
|
|
83
|
+
-44
|
|
84
|
+
]
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"kind": "arc",
|
|
88
|
+
"to": [
|
|
89
|
+
42,
|
|
90
|
+
-40
|
|
91
|
+
],
|
|
92
|
+
"through": [
|
|
93
|
+
44,
|
|
94
|
+
-42
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"kind": "line",
|
|
99
|
+
"to": [
|
|
100
|
+
6,
|
|
101
|
+
-40
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
"holes": []
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"format": "partforge-vector",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"units": "mm",
|
|
5
|
+
"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.",
|
|
6
|
+
"shapes": {
|
|
7
|
+
"body": {
|
|
8
|
+
"role": "add",
|
|
9
|
+
"regions": [
|
|
10
|
+
{ "outer": { "kind": "rect", "center": [0, 0], "width": 40, "height": 24, "radius": 4 } }
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"holes": {
|
|
14
|
+
"role": "subtract",
|
|
15
|
+
"regions": [
|
|
16
|
+
{ "outer": { "kind": "circle", "center": [-14, 0], "r": 1.7 } },
|
|
17
|
+
{ "outer": { "kind": "circle", "center": [14, 0], "r": 1.7 } }
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"keyway": {
|
|
21
|
+
"role": "subtract",
|
|
22
|
+
"regions": [
|
|
23
|
+
{ "outer": { "kind": "polygon", "points": [[-3, -8], [3, -8], [0, -4]] } }
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|