partforge 0.96.0 → 0.98.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 (52) hide show
  1. package/bin/cli.js +128 -9
  2. package/docs/AUTHORING-PARTS.md +371 -5
  3. package/docs/ERROR-PATTERNS.md +25 -1
  4. package/docs/KERNEL-CONTRACT.md +1 -0
  5. package/docs/VECTOR-FORMAT.md +23 -17
  6. package/package.json +9 -1
  7. package/src/app-relief.js +16 -0
  8. package/src/framework/app.css +30 -0
  9. package/src/framework/backend-select.js +7 -2
  10. package/src/framework/font-source.js +18 -1
  11. package/src/framework/geometry/heightfield.js +129 -0
  12. package/src/framework/geometry/kernel.js +3 -0
  13. package/src/framework/geometry/manifold-backend.js +61 -0
  14. package/src/framework/geometry/occt-backend.js +148 -1
  15. package/src/framework/geometry/op-options.js +10 -0
  16. package/src/framework/geometry/png-decode.js +107 -0
  17. package/src/framework/geometry/solid-hash.js +96 -0
  18. package/src/framework/image-source.js +76 -0
  19. package/src/framework/images.js +66 -0
  20. package/src/framework/ingest/image-ingest.js +41 -0
  21. package/src/framework/ingest/node-dom.js +69 -0
  22. package/src/framework/ingest/registry.js +57 -0
  23. package/src/framework/ingest/sniff.js +96 -0
  24. package/src/framework/jobs.js +107 -6
  25. package/src/framework/lint/index.js +2 -1
  26. package/src/framework/lint/rules-images.js +109 -0
  27. package/src/framework/lint/rules-vector.js +68 -3
  28. package/src/framework/measure/measure-mode.js +2 -1
  29. package/src/framework/mount.js +14 -1
  30. package/src/framework/oracle/verify.js +6 -1
  31. package/src/framework/panel/image-picker.js +152 -0
  32. package/src/framework/panel/render.js +2 -0
  33. package/src/framework/panel/widget-specs.js +4 -0
  34. package/src/framework/panel/widgets/file-drop.js +321 -0
  35. package/src/framework/panel/widgets/font.js +51 -12
  36. package/src/framework/panel/widgets/image.js +178 -0
  37. package/src/framework/panel/widgets/index.js +11 -5
  38. package/src/framework/panel/widgets/vector.js +77 -0
  39. package/src/framework/param-deps.js +7 -2
  40. package/src/framework/vector-source.js +127 -0
  41. package/src/framework/vectors.js +9 -0
  42. package/src/ingest.js +1 -0
  43. package/src/parts/assets/relief-demo.png +0 -0
  44. package/src/parts/emblem.js +2 -1
  45. package/src/parts/relief.js +84 -0
  46. package/src/relief-worker.js +3 -0
  47. package/src/testing/manifold.js +7 -1
  48. package/src/testing/occt.js +4 -1
  49. package/types/index.d.ts +67 -0
  50. package/types/ingest.d.ts +13 -0
  51. package/types/kernel.d.ts +32 -0
  52. package/types/part.d.ts +21 -0
@@ -0,0 +1,69 @@
1
+ // src/framework/ingest/node-dom.js
2
+ // A headless DOM for the `partforge ingest` CLI verb, promoted from the old
3
+ // scripts/ingest-svg.mjs (deleted — see bin/cli.js's `ingest` command).
4
+ // paper-core (imported by svg-ingest.js) builds a canvas and asks for a 2D
5
+ // context at MODULE LOAD time, so installNodeDom() must run and finish BEFORE
6
+ // svg-ingest.js is ever imported — the CLI installs this DOM first and only
7
+ // then dynamically imports the converter (registry.js's `convert` thunk for
8
+ // the "vector" kind).
9
+ //
10
+ // happy-dom is an OPTIONAL peer dependency (see package.json): most consumers
11
+ // never touch the SVG ingest path, and this keeps its ~17 MB out of their
12
+ // installs. It is imported dynamically, HERE, inside installNodeDom() — never
13
+ // at this module's top level — so bin/cli.js (and every other CLI verb) keeps
14
+ // loading and working when happy-dom isn't installed; the failure only
15
+ // surfaces on this path, with a message naming the install command.
16
+ //
17
+ // stubCanvas2dContext is exported separately from installNodeDom's own DOM
18
+ // setup: test/setup/happy-dom-patches.js needs the identical no-op 2D context
19
+ // for VITEST's own happy-dom test environment (a wholly different Window than
20
+ // the one installNodeDom() constructs here — vitest boots its own). Importing
21
+ // this one function from both places is what replaces what used to be two
22
+ // independently hand-typed copies of the same stub.
23
+ export function stubCanvas2dContext() {
24
+ return {
25
+ save() {}, restore() {}, beginPath() {}, closePath() {}, moveTo() {}, lineTo() {},
26
+ bezierCurveTo() {}, quadraticCurveTo() {}, arc() {}, rect() {}, fill() {}, stroke() {},
27
+ clip() {}, translate() {}, scale() {}, rotate() {}, transform() {}, setTransform() {},
28
+ clearRect() {}, fillRect() {}, strokeRect() {}, setLineDash() {},
29
+ fillText() {}, strokeText() {},
30
+ measureText: () => ({ width: 0 }),
31
+ getImageData: () => ({ data: new Uint8ClampedArray(4) }),
32
+ createImageData: (w, h) => ({ data: new Uint8ClampedArray(Math.max(0, w) * Math.max(0, h) * 4), width: w, height: h }),
33
+ putImageData() {}, drawImage() {}, isPointInPath: () => false,
34
+ createLinearGradient: () => ({ addColorStop() {} }),
35
+ canvas: { width: 1, height: 1 },
36
+ };
37
+ }
38
+
39
+ // Installs a headless DOM as globals, for a plain Node process (the CLI) that
40
+ // otherwise has none. Idempotent enough for the CLI's one-shot use — it is not
41
+ // meant to be called more than once per process.
42
+ export async function installNodeDom() {
43
+ let Window;
44
+ try {
45
+ ({ Window } = await import("happy-dom"));
46
+ } catch {
47
+ throw new Error(
48
+ "ingest: converting SVG headlessly needs happy-dom (an optional peer dependency) — " +
49
+ "install it with `npm install happy-dom`",
50
+ );
51
+ }
52
+ const window = new Window();
53
+ // Node 21+ defines a getter-only `navigator` global of its own, so a plain
54
+ // Object.assign throws ("Cannot set property navigator of #<Object> which
55
+ // has only a getter") the moment it reaches that key. Define each property
56
+ // explicitly instead, overriding whatever accessor Node already installed.
57
+ for (const [key, value] of Object.entries({
58
+ window, self: window, document: window.document, navigator: window.navigator,
59
+ DOMParser: window.DOMParser, HTMLCanvasElement: window.HTMLCanvasElement,
60
+ Image: window.Image, SVGElement: window.SVGElement,
61
+ })) {
62
+ Object.defineProperty(globalThis, key, { value, writable: true, configurable: true, enumerable: true });
63
+ }
64
+ // paper-core builds a canvas and asks for a 2D context at module load;
65
+ // happy-dom has no canvas backend, and paper never touches the raster
66
+ // context for geometry (see svg-ingest.js's own header for why a no-op
67
+ // stub is enough).
68
+ globalThis.HTMLCanvasElement.prototype.getContext = stubCanvas2dContext;
69
+ }
@@ -0,0 +1,57 @@
1
+ // src/framework/ingest/registry.js
2
+ // The one table that answers "what does this file become?" — read by the panel's
3
+ // drop widget AND by `partforge ingest`. It does NOT route to part fields: the
4
+ // declaration-function pattern (`images: (p) => ({ relief: p.relief })`) already
5
+ // does that, because a control writes into its own param key and the author's
6
+ // declaration puts that key in the right field.
7
+ //
8
+ // DOM-free, node:-free, and converter-free AT MODULE SCOPE: `convert` is a thunk
9
+ // returning a dynamic import, so reading the table costs nothing and the CLI
10
+ // never loads paper.js for a font.
11
+ import { sniffMediaType } from "./sniff.js";
12
+
13
+ export const ASSET_KINDS = ["image", "vector", "font"];
14
+
15
+ const ROWS = [
16
+ {
17
+ kind: "image",
18
+ name: "Image", // the human slot name — "Try the ${name} control" in a refusal message
19
+ label: "an image (PNG, JPG or WebP)",
20
+ accepts: ["image/png", "image/jpeg", "image/webp"],
21
+ convert: () => import("./image-ingest.js").then((m) => m.imageToPng),
22
+ },
23
+ {
24
+ kind: "vector",
25
+ name: "Artwork",
26
+ label: "artwork (SVG)",
27
+ accepts: ["image/svg+xml"],
28
+ convert: () => import("./svg-ingest.js").then((m) => m.ingestSvg),
29
+ },
30
+ {
31
+ kind: "font",
32
+ name: "Font",
33
+ label: "a font (TTF or OTF)",
34
+ accepts: ["font/ttf", "font/otf"],
35
+ convert: null, // used as-is; validated, never converted
36
+ },
37
+ ];
38
+
39
+ export const rowFor = (kind) => ROWS.find((r) => r.kind === kind);
40
+
41
+ // Which kind, if any, WOULD accept this media type — the "use the Artwork slot"
42
+ // hint. null when no slot accepts it (an unsupported format, e.g. WOFF2).
43
+ const kindAccepting = (mediaType) => ROWS.find((r) => r.accepts.includes(mediaType))?.kind ?? null;
44
+
45
+ export function classify(bytes, kind) {
46
+ const mediaType = sniffMediaType(bytes);
47
+ const row = rowFor(kind);
48
+ if (row && mediaType && row.accepts.includes(mediaType)) return { ok: true, mediaType };
49
+ return { ok: false, reason: mediaType ? "wrong-type" : "unrecognised",
50
+ mediaType, suggestKind: mediaType ? kindAccepting(mediaType) : null };
51
+ }
52
+
53
+ export async function convertFor(kind, mediaType) {
54
+ const row = rowFor(kind);
55
+ if (!row || !row.accepts.includes(mediaType)) return null;
56
+ return row.convert ? row.convert() : null;
57
+ }
@@ -0,0 +1,96 @@
1
+ // src/framework/ingest/sniff.js
2
+ // Bytes -> media type. The whole point is that a caller passes BYTES, never a
3
+ // filename: a file's claimed type is user input, and this is the path a
4
+ // mislabelled upload would travel. Nothing here trusts an extension because
5
+ // nothing here is given one.
6
+ //
7
+ // DOM-free and node:-free, and free of any converter import, so both the panel
8
+ // and the CLI can read it without loading paper.js.
9
+
10
+ const MAGIC = [
11
+ { type: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
12
+ { type: "image/jpeg", bytes: [0xff, 0xd8, 0xff] },
13
+ { type: "font/otf", bytes: [0x4f, 0x54, 0x54, 0x4f] }, // "OTTO"
14
+ { type: "font/woff2", bytes: [0x77, 0x4f, 0x46, 0x32] }, // "wOF2"
15
+ { type: "font/ttf", bytes: [0x00, 0x01, 0x00, 0x00] },
16
+ { type: "font/ttf", bytes: [0x74, 0x72, 0x75, 0x65] }, // "true"
17
+ ];
18
+
19
+ const startsWith = (u8, sig) => {
20
+ if (u8.length < sig.length) return false;
21
+ for (let i = 0; i < sig.length; i++) if (u8[i] !== sig[i]) return false;
22
+ return true;
23
+ };
24
+
25
+ // How far into a text file we look for an <svg root. Enough for an XML
26
+ // declaration, a DOCTYPE and a licence comment; bounded so a huge non-SVG text
27
+ // file is not scanned end to end. An SVG behind more than 4 KB of leading
28
+ // comments returns null rather than being recognised — an accepted bounded false
29
+ // negative.
30
+ const SVG_SCAN = 4096;
31
+
32
+ export function sniffMediaType(input) {
33
+ if (!input) return null;
34
+ const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
35
+ if (u8.length === 0) return null;
36
+
37
+ for (const m of MAGIC) if (startsWith(u8, m.bytes)) return m.type;
38
+
39
+ // WebP is RIFF????WEBP — the size field sits between the two tags.
40
+ if (u8.length >= 12 && startsWith(u8, [0x52, 0x49, 0x46, 0x46])
41
+ && u8[8] === 0x57 && u8[9] === 0x45 && u8[10] === 0x42 && u8[11] === 0x50) {
42
+ return "image/webp";
43
+ }
44
+
45
+ // SVG has no magic number. Look for an <svg ROOT element by skipping leading
46
+ // prologue (BOM, whitespace, XML declaration, comments, DOCTYPE), then
47
+ // requiring the next real tag to be <svg followed by whitespace, >, or /.
48
+ // This anchors to the document root and rejects HTML files with inline <svg>.
49
+ const decoder = new TextDecoder("utf-8", { fatal: false });
50
+ const head = decoder.decode(u8.subarray(0, SVG_SCAN));
51
+
52
+ let pos = 0;
53
+ const len = head.length;
54
+
55
+ // Skip UTF-8 BOM
56
+ if (head.charCodeAt(0) === 0xFEFF) pos = 1;
57
+
58
+ // Skip leading whitespace
59
+ while (pos < len && /\s/.test(head[pos])) pos++;
60
+
61
+ // Skip XML declaration
62
+ if (head.substring(pos).startsWith("<?xml")) {
63
+ const end = head.indexOf("?>", pos);
64
+ if (end !== -1) {
65
+ pos = end + 2;
66
+ while (pos < len && /\s/.test(head[pos])) pos++;
67
+ }
68
+ }
69
+
70
+ // Skip DOCTYPE
71
+ if (head.substring(pos).startsWith("<!DOCTYPE")) {
72
+ const end = head.indexOf(">", pos);
73
+ if (end !== -1) {
74
+ pos = end + 1;
75
+ while (pos < len && /\s/.test(head[pos])) pos++;
76
+ }
77
+ }
78
+
79
+ // Skip comments
80
+ while (head.substring(pos).startsWith("<!--")) {
81
+ const end = head.indexOf("-->", pos);
82
+ if (end !== -1) {
83
+ pos = end + 3;
84
+ while (pos < len && /\s/.test(head[pos])) pos++;
85
+ } else {
86
+ break;
87
+ }
88
+ }
89
+
90
+ // Check if the next real tag is <svg
91
+ if (head.substring(pos).match(/^<svg[\s>\/]/i)) {
92
+ return "image/svg+xml";
93
+ }
94
+
95
+ return null;
96
+ }
@@ -7,9 +7,12 @@ import { exportablePartNames } from "./export-select.js";
7
7
  import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "./font-source.js";
8
8
  import { fontsFor, resolveFonts } from "./fonts.js";
9
9
  import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
10
+ import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "./image-source.js";
11
+ import { imagesFor, ensureImages } from "./images.js";
10
12
  import { ensureImports, resolveImports } from "./imports.js";
11
13
  import { safeName } from "./safe-name.js";
12
- import { ensureVectors } from "./vectors.js";
14
+ import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./vector-source.js";
15
+ import { vectorsFor, ensureVectors } from "./vectors.js";
13
16
  import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
14
17
 
15
18
  // The oracle loads LAZILY, per job family, never at worker boot. It is the largest
@@ -146,10 +149,14 @@ export async function handle(kernel, part, msg, post, opts = {}) {
146
149
  // default.
147
150
  const { p, d } = resolveParams(part, msg.params, (params) => {
148
151
  // A param bound to a `type: "font"` control is user input — on a shared
149
- // link it is arbitrary attacker-supplied text that `fonts: (p) => …` would
150
- // turn into a fetch URL. Refuse out-of-`allow` values back to the part's
151
- // own default rather than failing the build: a bad link should show the
152
- // part, not an error page.
152
+ // link a STRING value is arbitrary attacker-supplied text that
153
+ // `fonts: (p) => …` would turn into a fetch URL. A BYTE value
154
+ // (ArrayBuffer/typed array) always passes fontSourceAllowed regardless of
155
+ // `allow` see font-source.js's header: it cannot have arrived via a
156
+ // share link (a URL can't carry megabytes), only from the host's own
157
+ // trusted panel (the drop-target path). Refuse out-of-`allow` values back
158
+ // to the part's own default rather than failing the build: a bad link
159
+ // should show the part, not an error page.
153
160
  for (const [key, allow] of fontControlAllows(part)) {
154
161
  const v = params[key];
155
162
  if (isNoFontSource(v) || fontSourceAllowed(v, allow)) continue;
@@ -158,6 +165,48 @@ export async function handle(kernel, part, msg, post, opts = {}) {
158
165
  jobWarnings.push({ part: null, message }); // …and the durable record
159
166
  params[key] = part.defaults?.[key];
160
167
  }
168
+ // Same shape for `type: "image"` controls — a param bound to one is user
169
+ // input, and on a shared link a STRING value is arbitrary attacker text
170
+ // that `images: (p) => …` would turn into a fetch URL. A BYTE value
171
+ // (ArrayBuffer/typed array) always passes imageSourceAllowed regardless of
172
+ // `allow` — see image-source.js's header: it cannot have arrived via a
173
+ // share link (a URL can't carry megabytes), only from the host's own
174
+ // trusted panel (the partforge-cloud sandbox path). Runs in this same
175
+ // sanitize hook, not after resolveParams returns, for the reason the
176
+ // comment above states: rewriting p[key] afterwards would leave derive()
177
+ // — and therefore `d` and the geometry — holding the refused value while
178
+ // build() saw the default.
179
+ for (const [key, allow] of imageControlAllows(part)) {
180
+ const v = params[key];
181
+ if (isNoImageSource(v) || imageSourceAllowed(v, allow)) continue;
182
+ const message = `image source for "${key}" is not allowed — using the default`;
183
+ onProgress(message);
184
+ jobWarnings.push({ part: null, message });
185
+ params[key] = part.defaults?.[key];
186
+ }
187
+ // Same shape again for `type: "vector"` controls — a param bound to one
188
+ // is user input, and on a shared link a STRING value is arbitrary
189
+ // attacker text that `vectors: (p) => …` would turn into a fetch URL.
190
+ // Unlike the two blocks above, a non-string value here does NOT bypass
191
+ // the check on a "can't survive a link" plausibility argument — see
192
+ // vector-source.js's header: a parsed vector document is plain JSON and
193
+ // survives a link just fine. It's permitted for the narrower, still-
194
+ // sufficient structural reason that vectorSourceAllowed applies:
195
+ // asset-resolve.js only ever calls `fetch` for a string/URL source, so
196
+ // an object (or bytes) can never become the SSRF-style request `allow`
197
+ // exists to gate. Runs in this same sanitize hook, not after
198
+ // resolveParams returns, for the reason both comments above state:
199
+ // rewriting p[key] afterwards would leave derive() — and therefore `d`
200
+ // and the geometry — holding the refused value while build() saw the
201
+ // default.
202
+ for (const [key, allow] of vectorControlAllows(part)) {
203
+ const v = params[key];
204
+ if (isNoVectorSource(v) || vectorSourceAllowed(v, allow)) continue;
205
+ const message = `vector source for "${key}" is not allowed — using the default`;
206
+ onProgress(message);
207
+ jobWarnings.push({ part: null, message });
208
+ params[key] = part.defaults?.[key];
209
+ }
161
210
  });
162
211
  // Preload any part-declared fonts into the kernel before building. A lazy
163
212
  // dynamic import because this is async context (unlike the synchronous
@@ -227,6 +276,40 @@ export async function handle(kernel, part, msg, post, opts = {}) {
227
276
  // lazy-error policy that keeps a STEP import inert until a build actually
228
277
  // calls k.import on it.
229
278
  if (part.imports) await ensureImports(kernel, part.imports, opts.importMeshes ?? null);
279
+ // Register this part's declared images on the kernel running this job — the
280
+ // third asset sibling beside fonts and imports. The allow-check already ran
281
+ // as resolveParams' sanitize hook above, so `p` here already reflects any
282
+ // refusal (reset to the part's default) — this step only resolves and
283
+ // uploads the resulting bytes.
284
+ //
285
+ // Gated on the part DECLARING `images` at all, not on this job having a
286
+ // source to resolve — the prune below has to run on the empty declaration
287
+ // too (a cleared pick), and a part with no `images` field must not touch
288
+ // the kernel's image map (a host or test harness may have seeded it
289
+ // directly via _registerImage).
290
+ if (part.images && typeof kernel._registerImage === "function") {
291
+ const imagesDecl = imagesFor(part, p) ?? {};
292
+ const declared = Object.fromEntries(
293
+ Object.entries(imagesDecl).filter(([name, src]) => {
294
+ if (!isNoImageSource(src)) return true;
295
+ onProgress(`no image source declared for "${name}" — skipping`);
296
+ return false;
297
+ }),
298
+ );
299
+ if (Object.keys(declared).length) {
300
+ onProgress("resolving images");
301
+ await ensureImages(kernel, declared);
302
+ }
303
+ // Drop every registered name this build's declaration does not supply.
304
+ // `_pruneImages` is the images twin of the `kernel._fonts` prune above:
305
+ // `heightfield(name)` looks a name up by the part's declared key, not by
306
+ // content, so a relief the user picked and then CLEARED would otherwise
307
+ // stay registered under its old name and go on rendering instead of
308
+ // whatever a missing source actually does — the unknown-image throw, or
309
+ // a branch a part's own build() takes around the call — the
310
+ // stale-registration bug of spec §5, one asset over.
311
+ kernel._pruneImages?.(new Set(Object.keys(declared)));
312
+ }
230
313
  // Vector art, the third asset family after fonts and imports. Same pre-build
231
314
  // timing; ensureVectors owns the prune, so this stays one line. Call it
232
315
  // unconditionally, even when this part has no `vectors` at all: ensureVectors
@@ -236,7 +319,25 @@ export async function handle(kernel, part, msg, post, opts = {}) {
236
319
  // prevent. Guarding this on `part.vectors` would skip exactly the case where
237
320
  // pruning matters most: a worker rebound from a part WITH artwork to one
238
321
  // WITHOUT would leave the old names resolvable forever.
239
- await ensureVectors(kernel, part.vectors);
322
+ //
323
+ // Empty sources are filtered out first, exactly as the fonts and images
324
+ // blocks above do with their own isNo*Source helper: an empty default is
325
+ // the natural shape for a drop-target control (`defaults: { art: "" }`,
326
+ // with `vectors: (p) => ({ badge: p.art })`), and passing "" through to
327
+ // ensureVectors reaches `fetch("")` and fails with "Failed to parse URL
328
+ // from" — a first-run error message that names nothing an author can act
329
+ // on. An unset source declares NO artwork for that name; a build that
330
+ // still calls k.vector2d on it gets the ordinary unknown-vector throw, and
331
+ // the progress note keeps a genuine typo visible rather than swallowed.
332
+ const vectorsDecl = vectorsFor(part, p) ?? {};
333
+ const declaredVectors = Object.fromEntries(
334
+ Object.entries(vectorsDecl).filter(([name, src]) => {
335
+ if (!isNoVectorSource(src)) return true;
336
+ onProgress(`no vector source declared for "${name}" — skipping`);
337
+ return false;
338
+ }),
339
+ );
340
+ await ensureVectors(kernel, declaredVectors);
240
341
  // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
241
342
  const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
242
343
  // Explicit selection (headless exportParts) overrides view-derived selection.
@@ -17,10 +17,11 @@ import { ANIMATION_RULES } from "./rules-animations.js";
17
17
  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
+ import { IMAGE_RULES } from "./rules-images.js";
20
21
  import { SOURCE_RULES } from "./rules-source.js";
21
22
  import { VECTOR_RULES } from "./rules-vector.js";
22
23
 
23
- export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
24
+ export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
24
25
 
25
26
  // A usable sources input, or null. Deliberately forgiving: lintPart's callers
26
27
  // include hosted paths handing over user/LLM-authored trees, so a malformed
@@ -0,0 +1,109 @@
1
+ // Group 10 — image-control well-formedness + heightfield name resolution. The
2
+ // sibling of rules-fonts.js for the `type: "image"` control / `images` field /
3
+ // `k.heightfield()` triangle: same silent-failure shapes, same source-scheme
4
+ // concern, plus one this group owns alone — a `k.heightfield(name, opts)` call
5
+ // naming an image the part never declared.
6
+ //
7
+ // image-control-not-in-images and heightfield-unknown-image are deliberately
8
+ // COMPLEMENTARY, not overlapping: each fires in exactly the case the other
9
+ // skips. A function-form `images` can depend on a param — good, but its return
10
+ // value can't be read without calling it, so image-control-not-in-images
11
+ // actually calls it (with a sentinel substituted for the control's key) rather
12
+ // than settling for font-control-not-in-fonts's cheaper "is it a function at
13
+ // all?" question; a static `images` object provably CANNOT depend on a
14
+ // param — that mistake belongs to image-control-not-in-images too, and would
15
+ // fire on every correctly-authored fixed-image part if this rule also ran
16
+ // there, so it is skipped entirely for a static `images`. Conversely, only a
17
+ // static `images` object has statically-knowable keys, so heightfield-unknown-
18
+ // image runs only there and skips whenever `images` is a function.
19
+ import { err, warn } from "./finding.js";
20
+ import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "../image-source.js";
21
+
22
+ // A URL-shaped sentinel (has a "://"), not an arbitrary string — some `images`
23
+ // functions run `new URL(v)` or similar on the value before deciding whether to
24
+ // use it, and an arbitrary string would make that throw and short-circuit the
25
+ // probe for reasons that have nothing to do with whether the key is read.
26
+ const SENTINEL = "pf-lint-sentinel://image-control-not-in-images";
27
+
28
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
+
30
+ export const IMAGE_RULES = [
31
+ {
32
+ id: "image-control-not-in-images",
33
+ run: ({ part, p }) => {
34
+ // Static-object `images` always fails this check by construction — it
35
+ // cannot read a param at all — so it is a different mistake
36
+ // (image-source-scheme's business, or none) and not this rule's.
37
+ if (typeof part?.images !== "function") return [];
38
+ const controls = imageControlAllows(part);
39
+ if (controls.size === 0) return [];
40
+ const out = [];
41
+ for (const key of controls.keys()) {
42
+ let resolved;
43
+ try { resolved = part.images({ ...p, [key]: SENTINEL }); }
44
+ catch { continue; } // can't be probed safely — not evidence either way
45
+ const reached = isPlainObject(resolved) && Object.values(resolved).includes(SENTINEL);
46
+ if (reached) continue;
47
+ out.push(err("image-control-not-in-images",
48
+ `control "${key}" is an image picker, but this part's \`images\` function never returns the picked value — the picked value is never resolved.`,
49
+ `Reference p.${key} from images, e.g. images: (p) => ({ ${key}: p.${key} }), and consume it with k.heightfield("${key}", opts).`,
50
+ "images"));
51
+ }
52
+ return out;
53
+ },
54
+ },
55
+ {
56
+ id: "heightfield-unknown-image",
57
+ run: ({ part, probe }) => {
58
+ // Only a static `images` object has statically-knowable names; a
59
+ // function's return value depends on params lint doesn't exhaustively
60
+ // enumerate, so it's skipped here (see file header).
61
+ if (typeof part?.images === "function") return [];
62
+ const known = new Set(isPlainObject(part?.images) ? Object.keys(part.images) : []);
63
+ const seen = new Set();
64
+ const out = [];
65
+ for (const call of probe().calls) {
66
+ if (call.scope !== "kernel" || call.op !== "heightfield") continue;
67
+ // k.heightfield(nameOrGrid, opts) also accepts an INLINE grid object as
68
+ // its first argument — args are recorded via JSON.stringify (probe.js's
69
+ // `describe`), so a grid parses back to an object, not a string. Only a
70
+ // string-literal first argument names a declared image; anything else
71
+ // (an inline grid, a computed/non-literal value the probe can't read)
72
+ // is silently skipped rather than flagged — flagging a supported inline
73
+ // grid as an "unknown image" would be a false positive.
74
+ let name;
75
+ try { name = JSON.parse(call.args[0]); } catch { name = null; }
76
+ if (typeof name !== "string" || known.has(name) || seen.has(name)) continue;
77
+ seen.add(name);
78
+ out.push(err("heightfield-unknown-image",
79
+ `build calls k.heightfield with name "${name}", which the part's images field does not declare: ${[...known].join(", ") || "(nothing)"}`,
80
+ "Declare the source under images: { name: source }, or fix the name to match an existing entry.",
81
+ "images"));
82
+ }
83
+ return out;
84
+ },
85
+ },
86
+ {
87
+ id: "image-source-scheme",
88
+ run: ({ part }) => {
89
+ const out = [];
90
+ for (const [key, allow] of imageControlAllows(part)) {
91
+ const v = part?.defaults?.[key];
92
+ // An empty source declares no image (jobs.js's sanitize hook drops it
93
+ // before it reaches ensureImages, and a build() that still calls
94
+ // k.heightfield for it gets the ordinary unknown-image throw — there
95
+ // is no automatic "no relief" fallback) — a legitimate way to author
96
+ // an optional relief, not a source the allow list is refusing. A
97
+ // bytes source (ArrayBuffer/typed array) always
98
+ // passes imageSourceAllowed regardless of allow — see image-source.js's
99
+ // header — so it never reaches this branch either.
100
+ if (isNoImageSource(v) || imageSourceAllowed(v, allow)) continue;
101
+ out.push(warn("image-source-scheme",
102
+ `defaults.${key} is "${String(v).slice(0, 120)}", which control "${key}" would refuse (allow: ${allow.join(", ")}).`,
103
+ `Use a source the allow list accepts, or widen \`allow\` on the control. At build time this value is replaced by defaults.${key}, so as written the part has no usable image.`,
104
+ "defaults"));
105
+ }
106
+ return out;
107
+ },
108
+ },
109
+ ];
@@ -1,6 +1,8 @@
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.
1
+ // Group 10 — vector-art call well-formedness, plus vector-control wiring. The
2
+ // three call rules catch conditions that throw at build time anyway; they move
3
+ // them ahead of the kernel boot, which is where an authoring agent wants them.
4
+ // The fourth (vector-control-not-in-vectors) catches the one shape that never
5
+ // throws at all — a picker wired to nothing.
4
6
  //
5
7
  // All read `probe().calls`, whose `args` are JSON.stringify of the RESOLVED
6
8
  // argument values under the part's default params (probe.js's `describe`), not
@@ -16,9 +18,19 @@
16
18
  // package's header), so without a supplied document neither rule can tell
17
19
  // units from shapes and both stay silent rather than guess.
18
20
  import { err } from "./finding.js";
21
+ import { vectorControlAllows } from "../vector-source.js";
19
22
 
20
23
  const declaredVectors = (part) => Object.keys(part?.vectors ?? {});
21
24
 
25
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
26
+
27
+ // A URL-shaped sentinel (has a "://"), not an arbitrary string — some `vectors`
28
+ // functions run `new URL(v)` or similar on the value before deciding whether to
29
+ // use it, and an arbitrary string would make that throw and short-circuit the
30
+ // probe for reasons that have nothing to do with whether the key is read. Same
31
+ // device, and the same reason, as rules-images.js's.
32
+ const SENTINEL = "pf-lint-sentinel://vector-control-not-in-vectors";
33
+
22
34
  // The name arrives JSON-serialized, so JSON.parse recovers it — and yields null
23
35
  // for anything that is not a string (import-unknown-name reads its name the same way).
24
36
  const literalName = (src) => {
@@ -42,6 +54,15 @@ export const VECTOR_RULES = [
42
54
  {
43
55
  id: "vector-unknown-name",
44
56
  run: ({ part, probe }) => {
57
+ // Only a STATIC `vectors` object has statically-knowable names. A
58
+ // function's return value depends on params lint doesn't enumerate, and
59
+ // `Object.keys(fn)` is `[]` — without this guard every k.vector2d call in
60
+ // a function-form part reports as unknown, and since `partforge measure`
61
+ // runs lint first, the part cannot be measured at all. Exactly the guard
62
+ // rules-fonts.js and rules-images.js already carry; the function form of
63
+ // `vectors` arrived after this rule and nobody carried it across.
64
+ // vector-control-not-in-vectors below is what covers the function form.
65
+ if (typeof part?.vectors === "function") return [];
45
66
  const known = new Set(declaredVectors(part));
46
67
  const seen = new Set();
47
68
  const out = [];
@@ -109,4 +130,48 @@ export const VECTOR_RULES = [
109
130
  return out;
110
131
  },
111
132
  },
133
+ {
134
+ // The vectors twin of image-control-not-in-images / font-control-not-in-fonts:
135
+ // a `type: "vector"` control whose key never reaches `vectors:` changes a
136
+ // param and nothing else — the artwork never moves — and without this the
137
+ // mistake only shows up at build time, or not at all.
138
+ //
139
+ // It covers BOTH shapes of the mistake, because for vectors both are real:
140
+ // a static `vectors` object provably cannot read a param, so a vector
141
+ // control beside one is inert by construction (font-control-not-in-fonts's
142
+ // question); and a function-form `vectors` that simply never returns the
143
+ // picked value is inert too, which can only be established by calling it
144
+ // with a sentinel (image-control-not-in-images's question). Together with
145
+ // vector-unknown-name — which runs only for the static form — every part
146
+ // shape is judged by exactly one of the two rules.
147
+ id: "vector-control-not-in-vectors",
148
+ run: ({ part, p }) => {
149
+ const controls = vectorControlAllows(part);
150
+ if (controls.size === 0) return [];
151
+ const isFn = typeof part?.vectors === "function";
152
+ // A function may wrap the picked value (`new URL(p.art)`) rather than
153
+ // pass it through, so match on the sentinel appearing in the value, not
154
+ // on identity.
155
+ const mentions = (v) => (typeof v === "string" || v instanceof URL) && String(v).includes(SENTINEL);
156
+ const out = [];
157
+ for (const key of controls.keys()) {
158
+ if (isFn) {
159
+ let resolved;
160
+ try { resolved = part.vectors({ ...p, [key]: SENTINEL }); }
161
+ catch { continue; } // can't be probed safely — not evidence either way
162
+ if (isPlainObject(resolved) && Object.values(resolved).some(mentions)) continue;
163
+ out.push(err("vector-control-not-in-vectors",
164
+ `control "${key}" is a vector picker, but this part's \`vectors\` function never returns the picked value — the picked value is never resolved.`,
165
+ `Reference p.${key} from vectors, e.g. vectors: (p) => ({ ${key}: p.${key} }), and consume it with k.vector2d("${key}", { width: 20 }).`,
166
+ "vectors"));
167
+ continue;
168
+ }
169
+ out.push(err("vector-control-not-in-vectors",
170
+ `control "${key}" is a vector picker, but this part's \`vectors\` is ${part?.vectors ? "a static object" : "missing"} — the picked value is never resolved.`,
171
+ `Declare vectors as a function of params, e.g. vectors: (p) => ({ ${key}: p.${key} }), and reference it with k.vector2d("${key}", { width: 20 }).`,
172
+ "vectors"));
173
+ }
174
+ return out;
175
+ },
176
+ },
112
177
  ];
@@ -13,6 +13,7 @@ import { raycastViewer } from "../selection/raycast.js";
13
13
  import { createFeatureHighlight } from "../selection/feature-highlight.js";
14
14
  import { createDragTracker } from "../selection/drag-tracker.js";
15
15
  import { subPartReadKeys, RELEVANT_ALL } from "../param-deps.js";
16
+ import { byteAwareReplacer } from "../geometry/solid-hash.js";
16
17
  import { classifyFeature, bboxSpec, unionBounds } from "./feature-dims.js";
17
18
  import { paramMatches } from "./param-link.js";
18
19
  import { createPinStore, occurrenceOf } from "./pins.js";
@@ -136,7 +137,7 @@ export function createMeasureMode(viewer, { part, getContext, revealParams, getP
136
137
  // direct test) falls back to the content hash.
137
138
  let readsKey = null, readsMap = null;
138
139
  function readsFor(view, params) {
139
- const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params)}`;
140
+ const key = `${view}|${getParamsVersion ? getParamsVersion() : JSON.stringify(params, byteAwareReplacer)}`;
140
141
  if (readsKey !== key) {
141
142
  readsKey = key;
142
143
  try { readsMap = subPartReadKeys(part, view, params); } catch { readsMap = null; }
@@ -299,11 +299,24 @@ function createCleanupStack() {
299
299
  // // message — and calls runtime.annotate.send() itself.
300
300
  // // Ignored without onAnnotationSend (there is no toolbar
301
301
  // // to place it in).
302
+ // onAssetUpload(blob, { kind, filename }) // the drop widget's (widgets/file-drop.js) upload hook:
303
+ // // async, resolves to a source string (an https: URL or a
304
+ // // host token) that gets written into the param. `blob` is
305
+ // // the CONVERTED artifact — a PNG, a partforge-vector JSON
306
+ // // blob, or the original file for a font — never the user's
307
+ // // raw drop. Omit it and the converted bytes land straight
308
+ // // in the param instead (what the partforge-cloud sandbox
309
+ // // needs, since it cannot fetch URLs — a correct destination,
310
+ // // not a degraded one). A rejection is reported through the
311
+ // // control's own onError; the widget keeps the converted blob
312
+ // // so a retry costs a network call, not a reconvert.
302
313
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
303
314
  // exactly once here — submodules take element refs and never query the document.
304
315
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
305
316
  export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
306
317
  fontCatalog,
318
+ imageCatalog,
319
+ onAssetUpload,
307
320
  viewerState,
308
321
  annotateSend = "viewbar",
309
322
  container: legacyContainer, controls: legacyControls } = {}) {
@@ -963,7 +976,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
963
976
  }, onParamsCommit
964
977
  ? (changed) => onParamsCommit({ changed, params: { ...params } })
965
978
  : undefined,
966
- { fontCatalog });
979
+ { fontCatalog, imageCatalog, onAssetUpload });
967
980
  cleanup.defer(() => panel.dispose());
968
981
  panelRef = panel;
969
982
  const updateRelevance = () => {