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.
@@ -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
+ }
@@ -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
- postMessage({ type: "lint-report", report: lintPart(current, { params: e.data.params }) });
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
+ }