partforge 0.91.0 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bin/cli.js +6 -3
  2. package/docs/AUTHORING-PARTS.md +184 -1
  3. package/docs/ERROR-PATTERNS.md +97 -0
  4. package/docs/KERNEL-CONTRACT.md +1 -0
  5. package/docs/VECTOR-FORMAT.md +737 -0
  6. package/package.json +9 -1
  7. package/src/app-emblem.js +15 -0
  8. package/src/emblem-worker.js +3 -0
  9. package/src/framework/asset-resolve.js +5 -4
  10. package/src/framework/geometry/arc-fit.js +146 -0
  11. package/src/framework/geometry/contour-offset.js +5 -0
  12. package/src/framework/geometry/curve-fill.js +57 -7
  13. package/src/framework/geometry/kernel-front.js +46 -0
  14. package/src/framework/geometry/kernel.js +1 -1
  15. package/src/framework/geometry/probe.js +1 -1
  16. package/src/framework/geometry/stroke-outline.js +119 -0
  17. package/src/framework/geometry/vector-format.js +334 -0
  18. package/src/framework/geometry/vector2d.js +96 -0
  19. package/src/framework/ingest/svg-ingest.js +212 -0
  20. package/src/framework/jobs.js +11 -0
  21. package/src/framework/lint/index.js +28 -3
  22. package/src/framework/lint/rules-vector.js +112 -0
  23. package/src/framework/mount.js +30 -2
  24. package/src/framework/pick-flash.js +31 -0
  25. package/src/framework/selection/pick.js +16 -1
  26. package/src/framework/vectors.js +170 -0
  27. package/src/framework/viewer.js +106 -7
  28. package/src/framework/worker.js +46 -1
  29. package/src/ingest.js +8 -0
  30. package/src/parts/assets/emblem.svg +10 -0
  31. package/src/parts/assets/emblem.vector.json +110 -0
  32. package/src/parts/assets/plate.vector.json +27 -0
  33. package/src/parts/emblem.js +102 -0
  34. package/src/testing/manifold.js +3 -1
  35. package/src/testing/occt.js +3 -1
  36. package/types/index.d.ts +22 -0
  37. package/types/ingest.d.ts +118 -0
  38. package/types/kernel.d.ts +28 -0
@@ -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
+ ];
@@ -61,7 +61,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
61
61
  // carries the worker's own error text. See the correlated "error" case below.
62
62
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
63
63
 
64
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection }) {
64
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
65
65
  return {
66
66
  ready, dispose, setParams,
67
67
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -123,6 +123,16 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
123
123
  set: () => {},
124
124
  onChange: () => () => {},
125
125
  },
126
+ // The pick marker as a thing with a lifetime, for a host that hangs its own
127
+ // UI off it: hold() keeps the newest marker on screen (earlier held ones
128
+ // stay held), release() clears them all, and onAnchorChange reports where
129
+ // the newest one is on the canvas as the camera moves. Same shape
130
+ // convention as `measure`, `annotate` and `projection`.
131
+ pickMarker: pickMarker ?? {
132
+ hold: () => false,
133
+ release: () => {},
134
+ onAnchorChange: () => () => {},
135
+ },
126
136
  };
127
137
  }
128
138
 
@@ -188,6 +198,15 @@ function createCleanupStack() {
188
198
  // // { sync, hide, detach } — call sync() after you
189
199
  // // toggle a button's disabled state. Detached
190
200
  // // automatically on dispose().
201
+ // runtime.pickMarker.hold(); // keep the marker from the last pick on screen
202
+ // // (earlier held markers stay held), and start
203
+ // // reporting where it is. False when there is
204
+ // // nothing to hold — a marker's flash has a
205
+ // // lifetime, so hold promptly after a pick.
206
+ // runtime.pickMarker.onAnchorChange((a) => …); // {x, y, visible} in CSS px from the
207
+ // // canvas's top-left, as the camera moves; null when
208
+ // // nothing is held. Returns an unsubscribe.
209
+ // runtime.pickMarker.release(); // clear every held marker
191
210
  // runtime.setActive(false); // park the viewer: stop the render loop and release
192
211
  // // both large GPU allocations (the drawing buffer and
193
212
  // // the cached capture target). For a host that hides the
@@ -580,11 +599,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
580
599
  // deliberately not guarded — one is armed by an explicit dev toggle,
581
600
  // the other per agent request.
582
601
  suppressed: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
583
- onPick: (selection) => onPick({
602
+ onPick: (selection, anchor) => onPick({
584
603
  selection,
585
604
  label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
586
605
  prompt: formatSelection(selection, { style: "prompt" }),
587
606
  token: formatSelection(selection, { style: "token" }),
607
+ // Where the marker is on the canvas, in CSS px from its top-left, so
608
+ // a host can put its own UI beside the dot on the first frame rather
609
+ // than a round trip later.
610
+ anchor,
588
611
  }),
589
612
  });
590
613
  cleanup.defer(() => picker.detach());
@@ -1098,6 +1121,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1098
1121
  set: (mode) => viewer.setProjection(mode),
1099
1122
  onChange: (cb) => viewer.onProjectionChange(cb),
1100
1123
  },
1124
+ pickMarker: {
1125
+ hold: () => viewer.holdFlashPoint(),
1126
+ release: () => viewer.releaseFlashPoints(),
1127
+ onAnchorChange: (cb) => viewer.onFlashAnchorChange(cb),
1128
+ },
1101
1129
  });
1102
1130
  } catch (error) {
1103
1131
  try {
@@ -42,3 +42,34 @@ export function flashWorldRadius(
42
42
  const radius = worldPerPixel(camera, worldPoint, viewportHeightPx) * pixelRadius;
43
43
  return Number.isFinite(radius) && radius > MIN_RADIUS ? radius : MIN_RADIUS;
44
44
  }
45
+
46
+ const _projected = new THREE.Vector3();
47
+
48
+ // Where a world point lands on the canvas, in CSS px from its top-left.
49
+ //
50
+ // `visible` is the answer to "is the marker actually on screen", which is two
51
+ // questions: is it in FRONT of the camera (a point behind a perspective eye
52
+ // projects to a mirrored position with no warning — `ndc.z > 1` is what
53
+ // catches it), and is it inside the canvas. The position is reported either
54
+ // way: an off-screen anchor is information, and the caller decides what to do
55
+ // with it.
56
+ export function projectToScreen(camera, worldPoint, width, height) {
57
+ _projected.copy(worldPoint).project(camera);
58
+ const x = (_projected.x * 0.5 + 0.5) * width;
59
+ // NDC y grows upward, screen y downward — the flip is the whole reason this
60
+ // is a named function rather than two lines at each call site.
61
+ const y = (0.5 - _projected.y * 0.5) * height;
62
+ const inFront = _projected.z >= -1 && _projected.z <= 1;
63
+ const onCanvas = x >= 0 && x <= width && y >= 0 && y <= height;
64
+ return { x, y, visible: inFront && onCanvas };
65
+ }
66
+
67
+ // Is `next` different enough from `previous` to be worth telling anyone about?
68
+ // A still camera re-projects to the same pixel every frame, and publishing that
69
+ // 60 times a second across a postMessage boundary is pure noise.
70
+ export function anchorMoved(previous, next, epsilon = 0.5) {
71
+ if (!previous || !next) return previous !== next;
72
+ return previous.visible !== next.visible
73
+ || Math.abs(previous.x - next.x) >= epsilon
74
+ || Math.abs(previous.y - next.y) >= epsilon;
75
+ }
@@ -3,6 +3,7 @@
3
3
  import { raycastViewer, worldToSubPartLocal } from "./raycast.js";
4
4
  import { resolveSelection } from "./resolve.js";
5
5
  import { createDragTracker } from "./drag-tracker.js";
6
+ import { projectToScreen } from "../pick-flash.js";
6
7
 
7
8
  export { worldToSubPartLocal };
8
9
 
@@ -10,6 +11,8 @@ export { worldToSubPartLocal };
10
11
  // whose suppression condition lives elsewhere (mount passes measure mode's
11
12
  // isEnabled): while it returns true a click neither raycasts, flashes, nor
12
13
  // picks — no resync bookkeeping the way an event-driven setActive would need.
14
+ // onPick receives (selection, anchor): where the marker this click flashed
15
+ // landed on the canvas, in CSS px from its top-left.
13
16
  export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
14
17
  let active = false;
15
18
  const drag = createDragTracker();
@@ -25,7 +28,19 @@ export function attachPicker(viewer, { part, getContext, onPick, suppressed }) {
25
28
  if (!hit) return;
26
29
  const selection = resolveSelection(part, getContext(), hit);
27
30
  viewer.flashPoint([hit.pointWorld.x, hit.pointWorld.y, hit.pointWorld.z]);
28
- onPick(selection);
31
+ // The anchor is the MARKER's projection, not the pointer's position, even
32
+ // though the two coincide at this instant: the host's follow-the-camera
33
+ // stream projects the same world point through the same function every
34
+ // frame after, so the first answer is of a piece with the rest.
35
+ // Sized from the rect the raycast just used, which is what makes this
36
+ // anchor land back on the pixel the user clicked. The stream sizes from the
37
+ // renderer's last setSize instead — also CSS px, but the CONTAINER's
38
+ // integer clientWidth/Height as of the last ResizeObserver call, and 1x1
39
+ // while the viewer is parked. They agree in steady state; the divergence is
40
+ // staleness, never units.
41
+ const rect = viewer.domElement.getBoundingClientRect();
42
+ const anchor = projectToScreen(viewer.camera, hit.pointWorld, rect.width, rect.height);
43
+ onPick(selection, { x: anchor.x, y: anchor.y });
29
44
  }
30
45
 
31
46
  viewer.domElement.addEventListener("pointerdown", drag.onDown);
@@ -0,0 +1,170 @@
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 } 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
+ function parseDocument(bytes, label) {
30
+ let text;
31
+ try { text = new TextDecoder().decode(bytes); }
32
+ catch { throw new Error(`vector2d: "${label}" could not be decoded as UTF-8 text`); }
33
+ let doc;
34
+ try { doc = JSON.parse(text); }
35
+ catch (e) {
36
+ throw new Error(`vector2d: "${label}" is not valid JSON — ${e.message}. `
37
+ + "A vectors source is an ingested partforge-vector file, not an .svg file; see docs/VECTOR-FORMAT.md");
38
+ }
39
+ return toInternalDocument(doc, label);
40
+ }
41
+
42
+ // source -> resolved bytes, recorded only once a source has SUCCESSFULLY resolved.
43
+ // `cache` above cannot answer that question: makeAssetResolver stores the promise
44
+ // synchronously, so `cache.has(source)` is true the instant a fetch starts and
45
+ // stays true if it never finishes. This map is what `cachedVectorDocs` reads, and
46
+ // its whole point is that membership means "the bytes are here, now".
47
+ const bytesBySource = new Map();
48
+ // source -> raw parsed JSON, or null if it is not a JSON object. Filled LAZILY by
49
+ // cachedVectorDocs rather than at resolve time, so the build path never pays a
50
+ // JSON.parse for lint's benefit, and lint never re-parses the same file twice.
51
+ const rawBySource = new Map();
52
+
53
+ // The resolver memoizes by source identity and cannot see the declared name, so
54
+ // the name is bound per declaration below rather than baked into the resolver.
55
+ const resolveOne = makeAssetResolver(
56
+ cache,
57
+ (bytes, _value, source) => { bytesBySource.set(source, bytes); return bytes; },
58
+ "resolveVectors: a vector source must be bytes, a URL, or a thunk returning one",
59
+ );
60
+
61
+ export async function resolveVectors(vectorsDecl) {
62
+ // A function reaching here means a caller passed `part.vectors` raw, the way
63
+ // fonts.js's resolveFonts guards against the same mistake for `part.fonts`.
64
+ // `Object.entries` on a function is `[]`, not a thrown error, so without
65
+ // this check a function-valued `vectors` would silently resolve to an empty
66
+ // map and only surface much later as `vector2d: unknown vector "…"` — a name a
67
+ // part author declared correctly, that k.vector2d insists doesn't exist. No
68
+ // part currently declares `vectors` as a function (unlike fonts, which a
69
+ // `type: "font"` control already drives this way) — this exists so the
70
+ // day that form is added, it fails loudly instead of silently.
71
+ if (typeof vectorsDecl === "function") {
72
+ throw new Error("resolveVectors: `vectors` is a function — it is not resolved against params yet; pass the plain object form");
73
+ }
74
+ const raw = await resolveDecl(vectorsDecl, resolveOne);
75
+ const out = new Map();
76
+ for (const [name, bytes] of raw) {
77
+ let doc = parsed.get(bytes);
78
+ if (!doc) { doc = parseDocument(bytes, name); parsed.set(bytes, doc); }
79
+ out.set(name, doc);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // The RAW parsed JSON, before validation or conversion — what lint's
85
+ // document-dependent rules need to read `units` and `shapes`. Never throws: a
86
+ // source that will not fetch, decode, or parse (to a JSON object) maps to
87
+ // null, and the rules that depend on it stay quiet for that name only —
88
+ // resolution is PER-SOURCE, not all-or-nothing, so one broken vector among
89
+ // several does not silence document-aware lint for its siblings. Deliberately
90
+ // calls `resolveOne` itself rather than going through `resolveDecl` (whose
91
+ // `Promise.all` rejects the whole batch on a single failure) — this still
92
+ // shares `resolveOne`'s bytes memo with resolveVectors, so running `lint`
93
+ // ahead of `measure` (the CLI does both) costs no extra fetch. It does its own
94
+ // decode/parse rather than reusing the `parsed` memo, which holds the
95
+ // VALIDATED/converted document, not the raw JSON this needs.
96
+ export async function resolveVectorDocs(vectorsDecl) {
97
+ if (typeof vectorsDecl === "function") return new Map();
98
+ const decl = vectorsDecl ?? {};
99
+ const out = new Map();
100
+ await Promise.all(Object.entries(decl).map(async ([name, source]) => {
101
+ try {
102
+ const bytes = await resolveOne(source);
103
+ const doc = JSON.parse(new TextDecoder().decode(bytes));
104
+ out.set(name, doc && typeof doc === "object" ? doc : null);
105
+ } catch {
106
+ out.set(name, null);
107
+ }
108
+ }));
109
+ return out;
110
+ }
111
+
112
+ // The SYNCHRONOUS, FETCH-FREE sibling of resolveVectorDocs: the raw parsed JSON
113
+ // for every declared vector whose bytes are ALREADY resolved, and nothing for the
114
+ // rest. Never fetches, never awaits, never throws.
115
+ //
116
+ // This exists because lint is instant and offline BY CONSTRUCTION — that is the
117
+ // property that lets a browser sandbox run it on every keystroke — and calling
118
+ // the async resolver from the lint path quietly gave that away: a slow or hanging
119
+ // vector URL (asset-resolve.js's fetch has no timeout) would stall the lint reply
120
+ // forever, and a throwing `vectors` getter would reject in a floating promise,
121
+ // which surfaces as `unhandledrejection` rather than an `error` event, so a host
122
+ // waiting on a lint-report simply waits.
123
+ //
124
+ // The degradation is the one the two document-dependent rules are designed for:
125
+ // no document, no finding. In a hosted sandbox a build has almost always run
126
+ // first, so the bytes are already in the memo and both rules light up; before the
127
+ // first build they stay silent, exactly as they do today for a caller that passes
128
+ // no vectorDocs at all. Nothing regresses, and lint cannot become slower or
129
+ // hangable than it was before vectors existed.
130
+ //
131
+ // `decl` is caller data all the way down (a throwing getter, a Proxy whose
132
+ // ownKeys trap throws), so every step that touches it is guarded per-name and the
133
+ // whole walk is guarded once.
134
+ export function cachedVectorDocs(vectorsDecl) {
135
+ const out = new Map();
136
+ if (!vectorsDecl || typeof vectorsDecl !== "object") return out;
137
+ let entries;
138
+ try { entries = Object.entries(vectorsDecl); } catch { return out; }
139
+ for (const entry of entries) {
140
+ try {
141
+ const [name, source] = entry;
142
+ if (!bytesBySource.has(source)) continue; // not resolved yet — stay silent
143
+ if (!rawBySource.has(source)) {
144
+ let doc = null;
145
+ try {
146
+ const parsedJson = JSON.parse(new TextDecoder().decode(bytesBySource.get(source)));
147
+ doc = parsedJson && typeof parsedJson === "object" ? parsedJson : null;
148
+ } catch { doc = null; } // not JSON, or not decodable
149
+ rawBySource.set(source, doc);
150
+ }
151
+ out.set(name, rawBySource.get(source));
152
+ } catch { /* one hostile entry silences itself, not its siblings */ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ // Register a part's vectors on a booted kernel. Called in the async phase before
158
+ // every job's synchronous build — worker (jobs.js) and Node boots alike.
159
+ export async function ensureVectors(kernel, vectorsDecl) {
160
+ if (!kernel?._vectors) return;
161
+ const declared = vectorsDecl ?? {};
162
+ for (const [name, doc] of await resolveVectors(declared)) kernel._vectors.set(name, doc);
163
+ // Drop names this declaration does not supply. `_vectors` is the kernel's and the
164
+ // kernel outlives the job (worker-rebind, many parts), so without this a name
165
+ // from a previous part stays resolvable — the stale-registration bug jobs.js's
166
+ // font prune exists to prevent.
167
+ for (const name of [...kernel._vectors.keys()]) {
168
+ if (!Object.hasOwn(declared, name)) kernel._vectors.delete(name);
169
+ }
170
+ }