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
package/bin/cli.js CHANGED
@@ -10,9 +10,9 @@ import { resolve, dirname, basename } from "node:path";
10
10
  import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
11
11
  import { detectBackend } from "../src/framework/backend-select.js";
12
12
  import { fontsFor } from "../src/framework/fonts.js";
13
+ import { imagesFor } from "../src/framework/images.js";
14
+ import { isNoImageSource } from "../src/framework/image-source.js";
13
15
  import { viewAnimations, evaluate, cueAt } from "../src/framework/animation.js";
14
- import { bootOcctKernel } from "../src/testing/occt.js";
15
- import { bootManifoldKernel } from "../src/testing/manifold.js";
16
16
  import { measure } from "../src/framework/oracle/measure.js";
17
17
  import { verify } from "../src/framework/oracle/verify.js";
18
18
  import { renderViews } from "../src/testing/render.js";
@@ -23,11 +23,15 @@ import {
23
23
  import { savePickToken, loadPickToken, clearPickToken, pickTokenPath } from "../src/framework/pick-request/token-store.js";
24
24
  import { matchPattern } from "../src/testing/error-patterns.js";
25
25
  import { lintPart } from "../src/lint.js";
26
- import { resolveVectorDocs } from "../src/framework/vectors.js";
26
+ import { sniffMediaType } from "../src/framework/ingest/sniff.js";
27
+ import { ASSET_KINDS, rowFor, convertFor } from "../src/framework/ingest/registry.js";
28
+ import { installNodeDom } from "../src/framework/ingest/node-dom.js";
29
+ import * as opentypeNamespace from "opentype.js";
30
+ import { normalizeOpentype, parseFont } from "../src/framework/geometry/opentype-interop.js";
27
31
 
28
32
  const die = (msg) => { console.error(msg); process.exit(1); };
29
33
 
30
- const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
34
+ const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick|ingest> …";
31
35
 
32
36
  // Crash contract (issue #27): with --json, a thrown error becomes structured
33
37
  // stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
@@ -94,17 +98,52 @@ const readSources = (partPath) => {
94
98
  }
95
99
  };
96
100
 
101
+ // vectors.js, testing/occt.js and testing/manifold.js are imported DYNAMICALLY
102
+ // here rather than statically at the top of this file — not for laziness'
103
+ // sake, but because all three transitively reach paper-core.js (vectors.js ->
104
+ // geometry/vector-format.js -> geometry/contour-ops.js -> geometry/paper-bridge.js
105
+ // -> "paper/dist/paper-core.js"; testing/occt.js and testing/manifold.js the
106
+ // same, via their *-backend.js's own contour-ops.js use). paper-core does a
107
+ // canvas + 2D context probe at MODULE LOAD time (svg-ingest.js's header) and
108
+ // caches what it finds about its environment for the lifetime of the process —
109
+ // load it before `ingest`'s installNodeDom() has set up a DOM and every later
110
+ // SVG conversion fails deep inside paper's importSVG with an opaque "Cannot
111
+ // read properties of undefined (reading 'body')", even after the DOM exists.
112
+ // A static import here runs before `commands[cmd](args)` is ever called, no
113
+ // matter which verb was requested, so it would poison `ingest` even though
114
+ // `ingest` itself never touches these three. Deferring the import to the
115
+ // commands that actually need them keeps every other verb's behavior
116
+ // unchanged and keeps paper-core.js's first load in `ingest`'s hands.
117
+ const importVectors = () => import("../src/framework/vectors.js");
118
+
97
119
  // Pass the part's declared fonts through, mirroring the worker path (jobs.js) —
98
120
  // otherwise a part using a named font builds in the browser but dies headlessly
99
121
  // with `text2d: unknown font …`. A function-form `fonts` is resolved against
100
122
  // the CLI's base params; see "CLI limitation" in the design doc — a verify case
101
123
  // or animation frame that CHANGES the font param still builds with the
102
124
  // base-params face, because the kernel is booted once.
103
- const bootKernel = (part, params = {}) => {
125
+ //
126
+ // Same story for `images` — the third asset sibling (fonts.js / imports.js /
127
+ // images.js), resolved with `imagesFor` exactly like `fonts` is with `fontsFor`,
128
+ // since a part's `images` is commonly function-form (that's what lets a
129
+ // `type: "image"` control drive the source). An unset control (isNoImageSource)
130
+ // is dropped rather than handed to ensureImages, mirroring jobs.js's own filter —
131
+ // `k.heightfield` throws unknown-image for a name never registered; a part
132
+ // that wants to build with no image branches around the call itself.
133
+ const bootKernel = async (part, params = {}) => {
104
134
  const p = { ...(part.defaults ?? {}), ...params };
105
- const opts = { fonts: fontsFor(part, p), imports: part.imports, vectors: part.vectors };
135
+ const imagesDecl = part.images ? (imagesFor(part, p) ?? {}) : undefined;
136
+ const images = imagesDecl &&
137
+ Object.fromEntries(Object.entries(imagesDecl).filter(([, src]) => !isNoImageSource(src)));
138
+ const { vectorsFor } = await importVectors();
139
+ const opts = { fonts: fontsFor(part, p), imports: part.imports, images, vectors: vectorsFor(part, p) };
106
140
  const backend = process.env.PARTFORGE_BACKEND || detectBackend(part); // env: crash()'s NEEDS_OCCT retry
107
- return backend === "occt" ? bootOcctKernel(opts) : bootManifoldKernel(opts);
141
+ if (backend === "occt") {
142
+ const { bootOcctKernel } = await import("../src/testing/occt.js");
143
+ return bootOcctKernel(opts);
144
+ }
145
+ const { bootManifoldKernel } = await import("../src/testing/manifold.js");
146
+ return bootManifoldKernel(opts);
108
147
  };
109
148
 
110
149
  const commands = {
@@ -120,7 +159,11 @@ const commands = {
120
159
  const part = await loadPart(partPath, usage);
121
160
  const params = flags.params ? JSON.parse(flags.params) : undefined;
122
161
  const sources = readSources(partPath);
123
- const vectorDocs = Object.fromEntries(await resolveVectorDocs(part.vectors));
162
+ // Same `p` derivation as bootKernel — needed here only to resolve a
163
+ // function-form `vectors` against params before handing it to lint.
164
+ const p = { ...(part.defaults ?? {}), ...params };
165
+ const { resolveVectorDocs, vectorsFor } = await importVectors();
166
+ const vectorDocs = Object.fromEntries(await resolveVectorDocs(vectorsFor(part, p)));
124
167
  const report = lintPart(part, { params, sources, vectorDocs });
125
168
  if (!flags.json) printLint(report);
126
169
  if (flags.out) {
@@ -150,7 +193,11 @@ const commands = {
150
193
  // milliseconds with a precise message rather than after a WASM boot and a
151
194
  // downstream error that doesn't name the cause. Warnings never gate measure.
152
195
  if (!flags["no-lint"]) {
153
- const vectorDocs = Object.fromEntries(await resolveVectorDocs(part.vectors));
196
+ // Same `p` derivation as bootKernel below — measure has no --params
197
+ // flag, so this is just the part's own defaults.
198
+ const p = { ...(part.defaults ?? {}) };
199
+ const { resolveVectorDocs, vectorsFor } = await importVectors();
200
+ const vectorDocs = Object.fromEntries(await resolveVectorDocs(vectorsFor(part, p)));
154
201
  const lint = lintPart(part, { sources: readSources(partPath), vectorDocs });
155
202
  if (!lint.ok) {
156
203
  if (flags.json) console.log(JSON.stringify({ ok: false, lint }, null, 2));
@@ -358,6 +405,78 @@ const commands = {
358
405
  console.log(formatPickResult(out));
359
406
  process.exit(out.status === "done" ? 0 : 1);
360
407
  },
408
+
409
+ // Command-line surface over the same drop-target machinery the control
410
+ // panel uses (registry.js's ASSET_KINDS/rowFor/convertFor) — so an agent
411
+ // handed a raw SVG, font, or PNG can get it into a part's asset tree without
412
+ // a browser. --out is REQUIRED, not defaulted: this command writes a file,
413
+ // and for the pass-through cases (PNG, font) a computed "next to the input"
414
+ // default would land on the exact same path as the input itself — a
415
+ // surprising same-path overwrite is worse than making the destination
416
+ // explicit every time.
417
+ async ingest(args) {
418
+ const usage = "usage: partforge ingest <file> --out <output-file> [--strokes ignore]";
419
+ const { values: flags, positionals: [inPath] } = parse(args, {
420
+ out: { type: "string" },
421
+ strokes: { type: "string" },
422
+ }, usage);
423
+ try {
424
+ if (!inPath) die(usage);
425
+ if (!flags.out) die(`ingest writes a file, so it needs an explicit destination — pass --out\n${usage}`);
426
+ const resolvedIn = resolve(process.cwd(), inPath);
427
+ const bytes = readFileSync(resolvedIn);
428
+ const mediaType = sniffMediaType(bytes);
429
+ // Which slot, if any, this media type belongs to — same lookup registry.js's
430
+ // own kindAccepting() does internally, but that helper isn't exported (the
431
+ // panel never needs it: a drop target already knows its own kind).
432
+ const kind = mediaType ? ASSET_KINDS.find((k) => rowFor(k).accepts.includes(mediaType)) : undefined;
433
+ if (!kind) {
434
+ throw new Error(`ingest: unrecognised file "${inPath}"${mediaType ? ` (looks like ${mediaType}, but no asset slot accepts it)` : ""} — expected a PNG, JPEG, WebP, SVG, TTF, or OTF`);
435
+ }
436
+ const outPath = resolve(process.cwd(), flags.out);
437
+ mkdirSync(dirname(outPath), { recursive: true });
438
+
439
+ if (kind === "vector") {
440
+ // Must install the DOM BEFORE the dynamic import below: paper-core
441
+ // (loaded by svg-ingest.js, resolved through convertFor's thunk)
442
+ // builds a canvas and asks for a 2D context at MODULE LOAD time.
443
+ await installNodeDom();
444
+ const ingestSvg = await convertFor(kind, mediaType);
445
+ const doc = ingestSvg(bytes.toString("utf8"), {
446
+ strokes: flags.strokes ?? "outline",
447
+ source: basename(inPath),
448
+ });
449
+ writeFileSync(outPath, `${JSON.stringify(doc, null, 2)}\n`);
450
+ } else if (kind === "font") {
451
+ // Identity + validation, no DOM: fonts have no converter (registry.js's
452
+ // "font" row declares `convert: null`) — used as-is, so the only thing
453
+ // to do here is prove opentype.js can actually read it.
454
+ parseFont(normalizeOpentype(opentypeNamespace), bytes, basename(inPath));
455
+ writeFileSync(outPath, bytes);
456
+ } else if (mediaType === "image/png") {
457
+ // Already the target format: pass through unchanged. "Validation"
458
+ // here is the classification above (sniffMediaType's real signature
459
+ // check, the same gate the drop target uses) — NOT a full structural
460
+ // PNG decode: decodePng (src/framework/geometry/png-decode.js) rejects
461
+ // anything short of a complete IHDR/IDAT/IEND file, which is stricter
462
+ // than what a PNG-slot asset needs to satisfy and would refuse
463
+ // otherwise-fine files a real decoder tolerates (trailing junk after
464
+ // IEND, unusual-but-legal ancillary chunk ordering, ...).
465
+ writeFileSync(outPath, bytes);
466
+ } else {
467
+ // JPEG/WebP/other raster: converting to PNG needs createImageBitmap and
468
+ // a real canvas encoder (image-ingest.js's imageToPng) — happy-dom has
469
+ // no canvas raster backend, so this cannot run headlessly. Do not add
470
+ // an image-processing dependency to work around that; point at the
471
+ // browser path instead.
472
+ throw new Error(`ingest: ${mediaType} images can't be converted headlessly — happy-dom has no canvas raster backend, so imageToPng only runs in a browser. Drop "${inPath}" onto the app's Image control instead, or convert it to PNG yourself first.`);
473
+ }
474
+ console.log(`wrote ${outPath}`);
475
+ process.exit(0);
476
+ } catch (e) {
477
+ crash("ingest", e, false);
478
+ }
479
+ },
361
480
  };
362
481
 
363
482
  function printMeasure(r) {
@@ -711,7 +711,9 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
711
711
  | `"checkbox"` | an on/off box: ticked writes `on`, cleared writes `0` | `on` (default `1`) |
712
712
  | `"select"` | a dropdown | `options` |
713
713
  | `"radio"` | a segmented button row | `options` |
714
- | `"font"` | a typeface picker, or a URL field with no catalog | `allow`, `preview` |
714
+ | `"font"` | a typeface picker (or a URL field with no catalog), plus a drop target | `allow`, `preview` |
715
+ | `"image"` | an image picker (or a URL field with no catalog), plus a drop target | `allow` |
716
+ | `"vector"` | a URL field plus a drop target — no catalog exists | — |
715
717
 
716
718
  Numeric controls always show the number box: drag the slider *or* type an exact
717
719
  value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
@@ -1578,6 +1580,32 @@ resolve — with a URL they stay silent until the bytes arrive. And the object i
1578
1580
  on every resolve, so a malformed one fails with the same message its fetched twin would;
1579
1581
  it is read and never written, so `build` stays pure.
1580
1582
 
1583
+ **`vectors` may also be a function of params — and must be, for a `type: "vector"`
1584
+ control.** Exactly like `fonts` and `images`, the field takes either a static
1585
+ `{ name: source }` object (everything above) or a function called with the resolved
1586
+ params, so a picked or dropped source can reach the build:
1587
+
1588
+ ```js
1589
+ parameters: [{ id: "art", title: "Artwork", controls: [{ key: "art", type: "vector" }] }],
1590
+ defaults: { art: "" }, // empty = no artwork yet; the build must cope
1591
+ vectors: (p) => (p.art ? { badge: p.art } : {}),
1592
+ build: (k, p) => {
1593
+ const plate = k.box({ w: 60, d: 40, h: p.plate_t });
1594
+ if (!p.art) return plate; // nothing dropped yet
1595
+ return plate.union(k.vector2d("badge", { width: 30 })
1596
+ .extrude({ h: 1 }).translate([0, 0, p.plate_t]));
1597
+ },
1598
+ ```
1599
+
1600
+ A static object provably cannot read a param, so a `type: "vector"` control beside
1601
+ one is inert — the picker changes a param and the artwork never moves.
1602
+ `vector-control-not-in-vectors` (lint) catches both halves of that mistake: a static
1603
+ `vectors` beside a vector control, and a function-form `vectors` that never returns
1604
+ the picked value. An empty `p.art` declares **no** artwork for that name (the same
1605
+ "unset, not refused" rule `fonts`/`images` use) — the job skips it with a progress
1606
+ note rather than fetching `""`, so a build that guards on it, as above, still runs
1607
+ with nothing dropped.
1608
+
1581
1609
  **Sizing is against the tight geometric bounding box, not a `viewBox`.** Icon sets pad
1582
1610
  their `viewBox` inconsistently, so sizing relative to `viewBox` makes two icons declared at
1583
1611
  the same nominal size look different on the plate. `width`/`height`/`fit` instead measure
@@ -1735,6 +1763,299 @@ build: (k, p, d) => {
1735
1763
 
1736
1764
  **CLI:** `partforge measure|render|lint` work on an importing part exactly as on any other — the `imports` field resolves in the CLI's Node boot the same way `fonts` does, no extra flags.
1737
1765
 
1766
+ ## Height maps and images
1767
+
1768
+ `k.heightfield(nameOrGrid, opts)` turns a grayscale depth map into a relief
1769
+ solid: a sampled grid on top, skirt walls down the sides, a flat cap at
1770
+ `z = 0`. It exists for one thing — a printable relief plate from a picture —
1771
+ and `src/parts/relief.js` is the worked example; read it alongside this
1772
+ section.
1773
+
1774
+ **Declaring images (the `images` PartDefinition field):**
1775
+
1776
+ Same grammar as `fonts` and `imports`, one more asset sibling:
1777
+
1778
+ ```js
1779
+ images: {
1780
+ relief: new URL("./assets/relief-demo.png", import.meta.url), // Vite serves it; Node reads disk
1781
+ logo: "https://…/signed-url.png", // URL string
1782
+ scan: bytesOrThunk, // ArrayBuffer/Uint8Array, or a (possibly async) thunk returning one
1783
+ },
1784
+ ```
1785
+
1786
+ `images` may also be a **function of the resolved params** — `images: (p) => ({...})`
1787
+ — which is what lets a `type: "image"` control pick the source. `relief.js` uses
1788
+ exactly this to fall back to a bundled sample when the control is empty:
1789
+
1790
+ ```js
1791
+ images: (p) => ({
1792
+ relief: p.relief || new URL("./assets/relief-demo.png", import.meta.url),
1793
+ }),
1794
+ ```
1795
+
1796
+ **The empty-value fallback:** `p.relief` starts as `""` (its `defaults` entry),
1797
+ which reads as "no image chosen" — never a source to fetch, never a source
1798
+ `npx partforge lint`/the runtime warn about. A part is responsible for
1799
+ supplying its own fallback when a key resolves empty, exactly as above; an
1800
+ `images` entry that stays empty is simply dropped from registration (with a
1801
+ progress note, not an error), so a `build()` that still calls
1802
+ `k.heightfield(name, …)` for that name gets the same
1803
+ [`heightfield-unknown-image`](ERROR-PATTERNS.md#heightfield-unknown-image)
1804
+ throw as a typo'd name — the framework has no automatic "flat slab" behavior of
1805
+ its own; a part that wants one branches around the `k.heightfield` call itself
1806
+ when its source param is empty, the same way it would branch around any other
1807
+ optional feature.
1808
+
1809
+ **The `type: "image"` control:** the control-types table above lists `"image"`
1810
+ — an image picker with a host-supplied catalog, or a plain URL text field
1811
+ without one. `allow` restricts what a **param-supplied** value (from the
1812
+ picker, or a pasted/shared URL) may be — the same shape as `font`'s `allow`,
1813
+ but with one fewer kind, since there's no image equivalent of Google Fonts'
1814
+ CDN allowance:
1815
+
1816
+ | value | accepts |
1817
+ |---|---|
1818
+ | `"https"` | any `https:` URL. **The default** — omitting `allow` means `["https"]` |
1819
+ | `"asset"` | a `pfc-asset://` token — an image the host has stored for this part |
1820
+
1821
+ A refused param falls back to `defaults[key]`, with a build warning naming the
1822
+ key — `image-source-scheme` (lint) catches a `defaults` value the control's own
1823
+ `allow` would itself refuse. As with `fonts`, `allow` only gates values that
1824
+ arrive as **params**; a source you write into `images` yourself is code, not
1825
+ user input, and is never checked against it.
1826
+
1827
+ **`k.heightfield`'s options:**
1828
+
1829
+ ```js
1830
+ k.heightfield("relief", {
1831
+ w: 60, d: 60, // footprint, mm — REQUIRED, both > 0 (no default)
1832
+ base: 1.5, // solid slab thickness under the relief, mm (default 1; must be > 0 — zero is degenerate)
1833
+ maxZ: 3, // how far the tallest sample rises above base, mm (default 1)
1834
+ pitch: 0.5, // grid spacing, mm (default 0.5) — see "pitch" below
1835
+ invert: false, // swap high/low (default false)
1836
+ range: [0, 1], // remap the raw sample range before invert (default [0, 1] — identity)
1837
+ origin: "center", // "center" | "corner" — footprint placement in XY (default "center")
1838
+ });
1839
+ ```
1840
+
1841
+ `nameOrGrid` is either a name declared in `images`, or an inline
1842
+ `{ width, height, data: Uint16Array }` grid (bypassing `images`/PNG entirely —
1843
+ useful for procedural depth maps, as CI fixtures use).
1844
+
1845
+ - **`range` is a remap with clamped ends, not an output clamp.** `range[0]` maps
1846
+ to sample value 0, `range[1]` maps to sample value 1, and everything outside
1847
+ `[range[0], range[1]]` clamps to the nearer end — it does not pass the raw
1848
+ 0..1 sample through unclamped and then chop the *output* height. `range: [0, 1]`
1849
+ (the default) is the identity map: a raw sample stays exactly what it was.
1850
+ This is exactly the tool for a source whose luminance never reaches the
1851
+ extremes — `relief.js`'s bundled demo asset only spans roughly 39–75% of the
1852
+ 16-bit range (the ripple pattern that generated it decays toward mid-gray),
1853
+ so left at the default `range` the demo would use well under half of `maxZ`;
1854
+ it sets `range` to the asset's own measured extent to stretch that into the
1855
+ full 0..1 span. **`invert` applies after the remap**, as `1 − t` on the
1856
+ remapped value — it flips which end is raised, not which end of the source
1857
+ range is used.
1858
+ - **`origin` positions the footprint in XY only.** `"corner"` puts the minimum
1859
+ corner at `(0, 0)`; `"center"` (the default) centers the footprint on the
1860
+ origin. Either way the **base always sits at `z = 0`** — `origin` never moves
1861
+ the part vertically, only in X/Y.
1862
+ - **The image stretches to `w × d`.** Sampling maps the image's own aspect
1863
+ ratio onto whatever rectangle `w`/`d` describe — a square source on a
1864
+ non-square footprint stretches, it is not letterboxed or cropped.
1865
+ - **Axis convention:** a source PNG's row 0 (its first scanline — the visual
1866
+ top of the file in an image viewer) maps to the footprint's **−Y** edge, with
1867
+ Y increasing down the rows — the standard texture-coordinate convention, and
1868
+ not something this framework special-cases. In practice: a depth map viewed
1869
+ in the app from above (+Z) reads vertically flipped relative to the same file
1870
+ open in an image viewer. If a source contains text or a logo and that
1871
+ orientation matters, flip the source pixels before declaring it — `invert`
1872
+ will not do this for you, since it remaps sampled *height*, not pixel
1873
+ position.
1874
+ - **Vertex budget:** the sampled grid is `max(2, ceil(w/pitch))` ×
1875
+ `max(2, ceil(d/pitch))` vertices. If that product would exceed 400,000,
1876
+ `pitch` is scaled up uniformly until it fits (and, if still over, the two
1877
+ counts are shrunk in lockstep) — a build warning names the clamped pitch
1878
+ rather than the build hanging or throwing.
1879
+
1880
+ **PNG only, in core.** `images` resolves exactly one format — a source that
1881
+ doesn't start with the PNG signature throws
1882
+ [`images-only-png-supported`](ERROR-PATTERNS.md#images-only-png-supported) —
1883
+ and the decoder itself rejects Adam7-interlaced files
1884
+ ([`png-interlaced-unsupported`](ERROR-PATTERNS.md#png-interlaced-unsupported)).
1885
+ This is deliberate, not an oversight: the same pure-JS decoder
1886
+ (`src/framework/geometry/png-decode.js`) runs in the browser worker, the CLI,
1887
+ and CI alike, so the geometry a user previews, the geometry `partforge measure`
1888
+ gates, and the geometry a regression test pins can never disagree about how a
1889
+ given file decodes — a second format would mean a second decode path, and a
1890
+ second place for the three to drift apart. The escape hatch is
1891
+ `imageToPng(fileOrBlob, { maxSize = 1024 }) → Promise<Blob>`, exported from
1892
+ `"partforge/ingest"` (main-thread only — it draws through a `<canvas>`, never import
1893
+ it from a part or a worker): convert any format the browser can decode into a
1894
+ PNG before it reaches `images`, in a host's upload handler. It downsamples to
1895
+ `maxSize` on the long edge on the way, since `pitch` caps useful resolution
1896
+ anyway and downsampling avoids shipping detail no `heightfield` call will ever
1897
+ sample.
1898
+
1899
+ **`pitch` is the throttle for both triangle count and STEP size.** Every
1900
+ `w/pitch × d/pitch` grid cell becomes two triangles, plus a skirt and a cap —
1901
+ halving `pitch` roughly quadruples the triangle count. On a 60×60 mm plate,
1902
+ pitch 1.0 produces about 7,670 triangles and (on the OCCT backend) a STEP file
1903
+ around 17.6 MB; pitch 0.3 produces about 81,590 triangles and a STEP file
1904
+ around 206.5 MB, for the same footprint. STEP size is content-dependent — only
1905
+ genuinely coplanar faces merge during sewing, so a flat relief compresses far
1906
+ better than a high-frequency one at the same triangle count — but the linear
1907
+ relationship to triangle count holds regardless of content. Above 24,000
1908
+ triangles the OCCT backend's sewing step also slows down and warns on the same
1909
+ build; past a further, content-dependent point sewing can fail outright
1910
+ ([`heightfield-sew-failed`](ERROR-PATTERNS.md#heightfield-sew-failed)), fixed
1911
+ by raising `pitch` or keeping the sub-part on the Manifold backend, which never
1912
+ sews through OCCT. Manifold's own preview has no such ceiling, so a fine
1913
+ `pitch` is always safe there — it only becomes expensive at STEP-export /
1914
+ OCCT time.
1915
+
1916
+ **Bytes in params — the sandbox path.** A `type: "image"` control's value may
1917
+ also be raw PNG bytes (an `ArrayBuffer`/typed array) rather than a URL string —
1918
+ either dropped onto the control (see "Getting files into a part", below) or
1919
+ placed there directly by a host that cannot fetch URLs (the partforge-cloud
1920
+ sandbox is the motivating case). Byte values **bypass the `allow` check
1921
+ entirely**, for every `allow` list, including the default — not a hole, but
1922
+ the deliberate consequence of what a byte value in `params` can mean: a URL
1923
+ cannot carry megabytes, so an `ArrayBuffer` arriving there cannot have come
1924
+ from a pasted link or a shared URL. That plausibility argument isn't the
1925
+ load-bearing one, though — the structural fact is that `asset-resolve.js`'s
1926
+ resolver (shared by `images`/`fonts`/`vectors`) calls `fetch` only for a
1927
+ `string`/`URL` source, so bytes are consumed directly and can never become a
1928
+ request no matter how they arrived in `params`. `allow` exists to keep a
1929
+ shared link from turning into an arbitrary fetch — a concern that structurally
1930
+ cannot apply to a byte-valued param.
1931
+
1932
+ **Linting:** `npx partforge lint` adds an "Image controls" group of static
1933
+ checks — `image-control-not-in-images`, `heightfield-unknown-image`,
1934
+ `image-source-scheme` — described in full under "Linting" → Rule catalog →
1935
+ "Image controls", below; this section only points there rather than repeating
1936
+ it.
1937
+
1938
+ **CLI:** `partforge measure|render|lint` resolve `images` in the CLI's Node
1939
+ boot exactly the way `fonts`/`imports` do — no extra flags — with the same
1940
+ function-form caveat: a `verify` case or animation frame that changes the
1941
+ image-control param still builds against the base-params source, because the
1942
+ CLI boots its kernel once.
1943
+
1944
+ ## Getting files into a part
1945
+
1946
+ `"image"`, `"vector"` and `"font"` controls each carry a drop target, on top of
1947
+ the URL/catalog paths documented for them above — a file dropped, pasted, or
1948
+ picked (a native file-input dialog behind a click, for a mouse/keyboard user
1949
+ with no drag-and-drop) lands in the same param a URL or a picker selection
1950
+ would. There is no drop target for `imports` (STEP/STL/3MF) — those are
1951
+ reference geometry, checked in or fetched, not something a panel field takes a
1952
+ file for. `src/framework/panel/widgets/file-drop.js` is the shared
1953
+ implementation behind all three; this section documents its contract, not its
1954
+ code.
1955
+
1956
+ **What each control's drop target accepts.** A dropped file is classified by
1957
+ its actual bytes — never its extension or the browser's claimed MIME type —
1958
+ against a fixed sniff table (`src/framework/ingest/sniff.js`), because a
1959
+ file's claimed type is exactly the input a mislabelled or hostile upload would
1960
+ travel as:
1961
+
1962
+ | Control | Accepts | Lands as |
1963
+ |---|---|---|
1964
+ | `"image"` | PNG, JPEG, WebP | a PNG (JPEG/WebP re-encoded through a `<canvas>`; PNG passes through unchanged) |
1965
+ | `"vector"` | SVG | a parsed `partforge-vector` document (paper.js — the same conversion `partforge/ingest`'s `ingestSvg` performs) |
1966
+ | `"font"` | TTF, OTF | the file itself — nothing to convert |
1967
+
1968
+ A file that doesn't match its own control's list is refused with a message
1969
+ naming what it actually is; when another control's slot *would* take it (an
1970
+ SVG dropped on the Image control), the message names that control instead of
1971
+ just saying no. A file over 25 MB is refused before being read into memory to
1972
+ classify at all.
1973
+
1974
+ **Where the result goes — `onAssetUpload`, or straight into the param.** After
1975
+ conversion, the drop widget looks for `onAssetUpload` (a `mount()` option —
1976
+ see "Wiring a part into a runnable app", above, for its full signature):
1977
+
1978
+ - **With the hook**, it hands over the converted artifact — a PNG blob, the
1979
+ partforge-vector document serialized as a JSON blob, or the original font
1980
+ file — and writes whatever source string the hook resolves to into the
1981
+ param, exactly as if that string had been typed into the URL field or
1982
+ chosen from a catalog.
1983
+ - **Without it**, the artifact itself becomes the param value directly: raw
1984
+ bytes (an `ArrayBuffer`) for `"image"`/`"font"`, and the **parsed
1985
+ `partforge-vector` document object** — not its serialized bytes — for
1986
+ `"vector"`, because `vectors.js`'s resolver already accepts an in-tree
1987
+ parsed object directly (`asParsedFile`, see "Vector geometry", above);
1988
+ serializing it only for the resolver to re-parse would be pure waste. This
1989
+ is the path a host that cannot fetch URLs needs — the partforge-cloud
1990
+ sandbox is the motivating case — and it is a first-class destination, not a
1991
+ degraded fallback for a host that hasn't wired anything up: nothing about
1992
+ presets, undo, the params hash, or `when` cares which shape a control's
1993
+ value takes.
1994
+
1995
+ **The `allow` list, and what bypasses it.** All three controls take the same
1996
+ `allow` list already documented for `"font"` in the control-types table above
1997
+ (`"https"` — the default; `"gstatic"`, font-only, `https://fonts.gstatic.com`
1998
+ exactly; `"asset"`, a `pfc-asset://` token the host has stored for this part),
1999
+ gating what a **param-supplied** value may be. It never restricts a source an
2000
+ author writes into `images`/`fonts`/`vectors` themselves — that's code, not
2001
+ user input.
2002
+
2003
+ A value that lands with no `onAssetUpload` hook — bytes for image/font, the
2004
+ parsed document object for vector — bypasses `allow` entirely, for every allow
2005
+ list including the default. **This rests on a structural fact, not on "a URL
2006
+ can't carry megabytes."** That plausibility argument is true, but it isn't
2007
+ what the check actually rests on: `asset-resolve.js`'s resolver — the code
2008
+ every `fonts`/`images`/`vectors` declaration resolves through, no matter which
2009
+ control produced the value — calls `fetch` **only** for a `string`/`URL`
2010
+ source. Bytes and parsed objects are consumed directly and never reach that
2011
+ branch, so neither can become an outbound request no matter how it got into
2012
+ `params` — which is exactly the class of harm `allow` exists to gate, and
2013
+ still holds even if a host someday finds a way to put a few bytes of base64 on
2014
+ a share link.
2015
+
2016
+ **Read that fact in the resolver's own order, though.** The resolver unwraps a
2017
+ `{ default: … }` module namespace **before** it dispatches on shape, so
2018
+ "object ⇒ never fetched" is not true of every object: `{ default:
2019
+ "http://…" }` unwraps to a plain string and is fetched. The vector check
2020
+ therefore unwraps first and judges what the resolver will actually see — a
2021
+ string or `URL` gets the full `allow` treatment however it was wrapped, a
2022
+ thunk is refused outright (its return value cannot be known at check time),
2023
+ and only a value that genuinely does not unwrap to a fetchable source is
2024
+ exempt.
2025
+
2026
+ **`npx partforge ingest`** runs the same classify/convert step outside a
2027
+ browser, for an agent (or a script) handed a raw file with nowhere to drop it:
2028
+
2029
+ ```bash
2030
+ npx partforge ingest logo.svg --out logo.vector.json
2031
+ npx partforge ingest scan.ttf --out src/parts/assets/scan.ttf
2032
+ ```
2033
+
2034
+ `--out` is required, always — for a pass-through case (PNG, font) a computed
2035
+ "beside the input" default would land on the exact same path as the input
2036
+ itself, and a surprising same-path overwrite is worse than an explicit
2037
+ destination every time. The four cases the drop widget's classification can
2038
+ produce, resolved to a file instead of a param:
2039
+
2040
+ | Input | Result |
2041
+ |---|---|
2042
+ | SVG | converted to a `partforge-vector` JSON document (`--strokes outline`, the default, turns strokes into filled geometry; `--strokes ignore` drops them) |
2043
+ | PNG | passed through unchanged — validated by the same magic-byte sniff the drop target uses, not a full structural PNG decode |
2044
+ | JPEG / WebP / other raster | refused, naming the browser path instead |
2045
+ | TTF / OTF | parsed with opentype.js to prove it's readable, then copied through unchanged |
2046
+
2047
+ **Headless raster conversion is out of scope, not merely unimplemented.** SVG
2048
+ conversion runs headlessly by installing `happy-dom` — an **optional peer
2049
+ dependency** (`npm install happy-dom`) — as a stand-in DOM and importing the
2050
+ same paper.js converter the drop widget uses; paper.js never touches the
2051
+ raster context for its geometry work, so a no-op `getContext` stub
2052
+ (`src/framework/ingest/node-dom.js`) is enough to satisfy it. Converting
2053
+ JPEG/WebP to PNG has no equivalent escape: it needs `createImageBitmap` and a
2054
+ real `<canvas>` to encode through, and happy-dom implements no canvas raster
2055
+ backend at all — there's no stub for a missing pixel pipeline the way there is
2056
+ for missing DOM structure. Convert those in a browser (drop the file onto the
2057
+ app's Image control) or with your own tooling before ingesting.
2058
+
1738
2059
  ## Host jobs: extending the worker
1739
2060
 
1740
2061
  The worker's job loop handles a closed set of message types (`generate`, the exports,
@@ -1950,6 +2271,26 @@ yourself (e.g. to download from a different origin) instead of partforge's own D
1950
2271
  partforge ships no provider — a host supplies one, and without it every font
1951
2272
  control renders as a URL field.
1952
2273
 
2274
+ - `imageCatalog` — a provider backing every `type: "image"` control in the part:
2275
+ `{ search(query, { limit }) → Promise<ImageAsset[]>, describe?(source) → { label, width, height } | null }`,
2276
+ where `ImageAsset` is `{ id, label, url, width, height, thumbUrl }`. With no
2277
+ provider a `type: "image"` control degrades to a URL field.
2278
+
2279
+ - `onAssetUpload(blob, { kind, filename }) → Promise<string>` — the drop target
2280
+ shared by `"image"`/`"vector"`/`"font"` controls (see "Getting files into a
2281
+ part", below) calls this with the converted artifact after a drop, paste, or
2282
+ file-picker choice, and writes whatever it resolves to into the param.
2283
+ `blob` is the CONVERTED artifact — a PNG, a partforge-vector JSON blob, or the
2284
+ original file for a font — never the user's raw drop; `kind` is `"image"`,
2285
+ `"vector"`, or `"font"`. Must resolve to a non-empty source string (an
2286
+ `https:` URL or a host-defined `pfc-asset:` token); anything else is treated
2287
+ as a contract violation and reported through the control's own error line,
2288
+ not written into the param. Omit it and the converted bytes land straight in
2289
+ the param instead — the path a host that cannot fetch URLs (the
2290
+ partforge-cloud sandbox) needs, not a degraded fallback. A rejection is
2291
+ likewise reported through the control's error line, and the widget keeps the
2292
+ converted artifact so a retry costs a network call, not a re-decode/re-parse.
2293
+
1953
2294
  **Showcase capture (the mount handle).** The handle can also render the user's *current*
1954
2295
  framing offscreen at a resolution independent of the window size and devicePixelRatio —
1955
2296
  for gallery/preview images, where grabbing the live canvas would be capped at the viewer
@@ -2477,7 +2818,24 @@ control that the control's own `allow` list would refuse — at build time it's
2477
2818
  swapped for `defaults[key]`, i.e. itself, so the part boots with no usable
2478
2819
  font; use a source `allow` accepts, or widen `allow`) (warning).
2479
2820
 
2480
- **Source rules** — the ninth group, which runs only when the caller hands over
2821
+ **Image controls** — the sibling group for `type: "image"` controls, `images`,
2822
+ and `k.heightfield()`. `image-control-not-in-images` (a `type: "image"`
2823
+ control's `key` is never returned by `images` — unlike the font rule above,
2824
+ this one actually calls a function-form `images` with the control's key set to
2825
+ a sentinel value and checks whether the sentinel comes back out, because a
2826
+ picker only silently does nothing if the function ignores that specific key,
2827
+ not just any key; a static `images` object provably can't depend on any param,
2828
+ so it is skipped there — that is a different mistake, not this rule's business)
2829
+ (error); `heightfield-unknown-image` (a build calls `k.heightfield(name, opts)`
2830
+ with a string `name` absent from a **static** `images` object — skipped when
2831
+ `images` is a function, since its keys aren't statically known; an inline
2832
+ `{width, height, data}` grid as the first argument is never flagged, since that
2833
+ is a supported call shape, not a name) (error); `image-source-scheme`
2834
+ (`defaults` holds a value for an image control that the control's own `allow`
2835
+ list would refuse — same shape as `font-source-scheme` above, including the
2836
+ empty-string and raw-bytes exemptions from `image-source.js`) (warning).
2837
+
2838
+ **Source rules** — the tenth group, which runs only when the caller hands over
2481
2839
  `sources` (above) — `control-default-not-literal` (a control's `defaults` entry is
2482
2840
  written as something other than a plain literal: an expression like `13 / 3`, an
2483
2841
  array or object, a template literal, a `0x10`/`1_000` spelling. Hosts persist a
@@ -2511,11 +2869,19 @@ the named file's `units` is `"artwork"` — unlike `k.text2d`'s cap-height `size
2511
2869
  artwork units carry no physical meaning, so there is no safe default to fall
2512
2870
  back on; an `"mm"` file's coordinates already are millimetres, so a size is
2513
2871
  genuinely optional there), `vector-unknown-shape` (a `k.vector2d(name, { shape })`
2514
- call names a shape the file's `shapes` object doesn't contain) (all errors).
2872
+ call names a shape the file's `shapes` object doesn't contain), and
2873
+ `vector-control-not-in-vectors` (a `type: "vector"` control whose key never reaches
2874
+ `vectors:` — either because `vectors` is a static object, which cannot read a param
2875
+ at all, or because the function form never returns the picked value; the picker then
2876
+ changes a param and nothing else) (all errors). `vector-unknown-name` runs only for a
2877
+ static `vectors` object, whose names are statically knowable; for the function form
2878
+ `vector-control-not-in-vectors` asks the answerable question instead, by calling the
2879
+ function with a sentinel — the same complementary split `heightfield-unknown-image`
2880
+ and `image-control-not-in-images` use.
2515
2881
  `vector-size-missing` and `vector-unknown-shape` need `vectorDocs` (above) to
2516
2882
  read the file's `units`/`shapes` — without it, both stay silent rather than
2517
- fire on every correct millimetre file or guess at shape names. All three judge
2518
- the argument values the probe resolves under the part's default params, the
2883
+ fire on every correct millimetre file or guess at shape names. All three call
2884
+ rules judge the argument values the probe resolves under the part's default params, the
2519
2885
  same basis `import-unknown-name` uses; a call that only goes wrong for
2520
2886
  non-default params still fails correctly at build time.
2521
2887