partforge 0.97.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.
package/bin/cli.js CHANGED
@@ -13,8 +13,6 @@ import { fontsFor } from "../src/framework/fonts.js";
13
13
  import { imagesFor } from "../src/framework/images.js";
14
14
  import { isNoImageSource } from "../src/framework/image-source.js";
15
15
  import { viewAnimations, evaluate, cueAt } from "../src/framework/animation.js";
16
- import { bootOcctKernel } from "../src/testing/occt.js";
17
- import { bootManifoldKernel } from "../src/testing/manifold.js";
18
16
  import { measure } from "../src/framework/oracle/measure.js";
19
17
  import { verify } from "../src/framework/oracle/verify.js";
20
18
  import { renderViews } from "../src/testing/render.js";
@@ -25,11 +23,15 @@ import {
25
23
  import { savePickToken, loadPickToken, clearPickToken, pickTokenPath } from "../src/framework/pick-request/token-store.js";
26
24
  import { matchPattern } from "../src/testing/error-patterns.js";
27
25
  import { lintPart } from "../src/lint.js";
28
- 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";
29
31
 
30
32
  const die = (msg) => { console.error(msg); process.exit(1); };
31
33
 
32
- const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick> …";
34
+ const USAGE = "usage: partforge <lint|measure|render|pick-serve|pick|ingest> …";
33
35
 
34
36
  // Crash contract (issue #27): with --json, a thrown error becomes structured
35
37
  // stdout JSON; either way the message is matched against ERROR-PATTERNS.md and
@@ -96,6 +98,24 @@ const readSources = (partPath) => {
96
98
  }
97
99
  };
98
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
+
99
119
  // Pass the part's declared fonts through, mirroring the worker path (jobs.js) —
100
120
  // otherwise a part using a named font builds in the browser but dies headlessly
101
121
  // with `text2d: unknown font …`. A function-form `fonts` is resolved against
@@ -110,14 +130,20 @@ const readSources = (partPath) => {
110
130
  // is dropped rather than handed to ensureImages, mirroring jobs.js's own filter —
111
131
  // `k.heightfield` throws unknown-image for a name never registered; a part
112
132
  // that wants to build with no image branches around the call itself.
113
- const bootKernel = (part, params = {}) => {
133
+ const bootKernel = async (part, params = {}) => {
114
134
  const p = { ...(part.defaults ?? {}), ...params };
115
135
  const imagesDecl = part.images ? (imagesFor(part, p) ?? {}) : undefined;
116
136
  const images = imagesDecl &&
117
137
  Object.fromEntries(Object.entries(imagesDecl).filter(([, src]) => !isNoImageSource(src)));
118
- const opts = { fonts: fontsFor(part, p), imports: part.imports, images, vectors: part.vectors };
138
+ const { vectorsFor } = await importVectors();
139
+ const opts = { fonts: fontsFor(part, p), imports: part.imports, images, vectors: vectorsFor(part, p) };
119
140
  const backend = process.env.PARTFORGE_BACKEND || detectBackend(part); // env: crash()'s NEEDS_OCCT retry
120
- 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);
121
147
  };
122
148
 
123
149
  const commands = {
@@ -133,7 +159,11 @@ const commands = {
133
159
  const part = await loadPart(partPath, usage);
134
160
  const params = flags.params ? JSON.parse(flags.params) : undefined;
135
161
  const sources = readSources(partPath);
136
- 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)));
137
167
  const report = lintPart(part, { params, sources, vectorDocs });
138
168
  if (!flags.json) printLint(report);
139
169
  if (flags.out) {
@@ -163,7 +193,11 @@ const commands = {
163
193
  // milliseconds with a precise message rather than after a WASM boot and a
164
194
  // downstream error that doesn't name the cause. Warnings never gate measure.
165
195
  if (!flags["no-lint"]) {
166
- 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)));
167
201
  const lint = lintPart(part, { sources: readSources(partPath), vectorDocs });
168
202
  if (!lint.ok) {
169
203
  if (flags.json) console.log(JSON.stringify({ ok: false, lint }, null, 2));
@@ -371,6 +405,78 @@ const commands = {
371
405
  console.log(formatPickResult(out));
372
406
  process.exit(out.status === "done" ? 0 : 1);
373
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
+ },
374
480
  };
375
481
 
376
482
  function printMeasure(r) {
@@ -711,8 +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` |
715
- | `"image"` | an image picker, or a URL field with no catalog | `allow` |
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 | — |
716
717
 
717
718
  Numeric controls always show the number box: drag the slider *or* type an exact
718
719
  value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
@@ -1579,6 +1580,32 @@ resolve — with a URL they stay silent until the bytes arrive. And the object i
1579
1580
  on every resolve, so a malformed one fails with the same message its fetched twin would;
1580
1581
  it is read and never written, so `build` stays pure.
1581
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
+
1582
1609
  **Sizing is against the tight geometric bounding box, not a `viewBox`.** Icon sets pad
1583
1610
  their `viewBox` inconsistently, so sizing relative to `viewBox` makes two icons declared at
1584
1611
  the same nominal size look different on the plate. `width`/`height`/`fit` instead measure
@@ -1862,7 +1889,7 @@ gates, and the geometry a regression test pins can never disagree about how a
1862
1889
  given file decodes — a second format would mean a second decode path, and a
1863
1890
  second place for the three to drift apart. The escape hatch is
1864
1891
  `imageToPng(fileOrBlob, { maxSize = 1024 }) → Promise<Blob>`, exported from
1865
- `"partforge"` (main-thread only — it draws through a `<canvas>`, never import
1892
+ `"partforge/ingest"` (main-thread only — it draws through a `<canvas>`, never import
1866
1893
  it from a part or a worker): convert any format the browser can decode into a
1867
1894
  PNG before it reaches `images`, in a host's upload handler. It downsamples to
1868
1895
  `maxSize` on the long edge on the way, since `pitch` caps useful resolution
@@ -1888,16 +1915,19 @@ OCCT time.
1888
1915
 
1889
1916
  **Bytes in params — the sandbox path.** A `type: "image"` control's value may
1890
1917
  also be raw PNG bytes (an `ArrayBuffer`/typed array) rather than a URL string —
1891
- this is how a host that cannot fetch URLs (the partforge-cloud sandbox is the
1892
- motivating case) gets an uploaded image into a part: its own trusted panel
1893
- puts the bytes straight into `params`. Byte values **bypass the `allow` check
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
1894
1921
  entirely**, for every `allow` list, including the default — not a hole, but
1895
1922
  the deliberate consequence of what a byte value in `params` can mean: a URL
1896
1923
  cannot carry megabytes, so an `ArrayBuffer` arriving there cannot have come
1897
- from a pasted link or a shared URL; it can only have been placed there by the
1898
- host's own code. `allow` exists to keep a shared link from turning into an
1899
- arbitrary fetch a concern that doesn't apply to a value the host already
1900
- has in hand.
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.
1901
1931
 
1902
1932
  **Linting:** `npx partforge lint` adds an "Image controls" group of static
1903
1933
  checks — `image-control-not-in-images`, `heightfield-unknown-image`,
@@ -1911,6 +1941,121 @@ function-form caveat: a `verify` case or animation frame that changes the
1911
1941
  image-control param still builds against the base-params source, because the
1912
1942
  CLI boots its kernel once.
1913
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
+
1914
2059
  ## Host jobs: extending the worker
1915
2060
 
1916
2061
  The worker's job loop handles a closed set of message types (`generate`, the exports,
@@ -2131,6 +2276,21 @@ yourself (e.g. to download from a different origin) instead of partforge's own D
2131
2276
  where `ImageAsset` is `{ id, label, url, width, height, thumbUrl }`. With no
2132
2277
  provider a `type: "image"` control degrades to a URL field.
2133
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
+
2134
2294
  **Showcase capture (the mount handle).** The handle can also render the user's *current*
2135
2295
  framing offscreen at a resolution independent of the window size and devicePixelRatio —
2136
2296
  for gallery/preview images, where grabbing the live canvas would be capped at the viewer
@@ -2709,11 +2869,19 @@ the named file's `units` is `"artwork"` — unlike `k.text2d`'s cap-height `size
2709
2869
  artwork units carry no physical meaning, so there is no safe default to fall
2710
2870
  back on; an `"mm"` file's coordinates already are millimetres, so a size is
2711
2871
  genuinely optional there), `vector-unknown-shape` (a `k.vector2d(name, { shape })`
2712
- 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.
2713
2881
  `vector-size-missing` and `vector-unknown-shape` need `vectorDocs` (above) to
2714
2882
  read the file's `units`/`shapes` — without it, both stay silent rather than
2715
- fire on every correct millimetre file or guess at shape names. All three judge
2716
- 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
2717
2885
  same basis `import-unknown-name` uses; a call that only goes wrong for
2718
2886
  non-default params still fails correctly at build time.
2719
2887
 
@@ -673,7 +673,7 @@ between the Manifold preview and the OCCT STEP export.
673
673
 
674
674
  - **Symptom:** `vector2d: "` followed by the declared `vectors` name and a validation complaint — a bad `format` or `version`, a contour with no `kind` or an unknown one, a malformed `"path"` contour or segment (missing `start`, too few segments, an `arc` with no `through`, a `cubic` missing `c1`/`c2`, a non-numeric coordinate), a primitive with a bad `center`/`r`/`width`/`height`, a shape that is neither a region array nor a `{ role, regions }` object, an unknown `role`, a `bbox` that disagrees with the geometry, or (a different message, same `vector2d: "<name>"` lead) `vector2d: "<name>" is not valid JSON — <parse error>` — thrown while resolving a part's `vectors`, before `build` even runs.
675
675
  - **Cause:** The stored document isn't a well-formed `partforge-vector` file. The single most common case for the "is not valid JSON" variant: `vectors` points at the raw `.svg` file instead of an ingested `.vector.json` — an SVG document is not JSON at all, so it fails to parse before validation ever gets a chance to name a more specific problem.
676
- - **Fix:** If the message says "is not valid JSON," check the source points at the ingested `<name>.vector.json`, not the original `.svg` — re-ingest with `partforge/ingest` (or `node scripts/ingest-svg.mjs <file.svg>` in this repo) if you don't have it yet. Otherwise the message names the shape, the 1-indexed region, the role (`outer` / `hole n`), and where applicable the 1-indexed segment, so the fix is a single edit. Several specific cases have their own entries below (vector-units-missing, vector-stale-regions-array, vector-rect-radius-too-large). See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) for the full schema and what each field means.
676
+ - **Fix:** If the message says "is not valid JSON," check the source points at the ingested `<name>.vector.json`, not the original `.svg` — re-ingest with `partforge/ingest` (or `npx partforge ingest <file.svg> --out <file.vector.json>`) if you don't have it yet. Otherwise the message names the shape, the 1-indexed region, the role (`outer` / `hole n`), and where applicable the 1-indexed segment, so the fix is a single edit. Several specific cases have their own entries below (vector-units-missing, vector-stale-regions-array, vector-rect-radius-too-large). See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) for the full schema and what each field means.
677
677
 
678
678
  ## vector-units-missing
679
679
 
@@ -769,9 +769,9 @@ between the Manifold preview and the OCCT STEP export.
769
769
 
770
770
  ## images-only-png-supported
771
771
 
772
- - **Symptom:** `images: only PNG is supported — convert with imageToPng() from "partforge" before storing, or have the host normalize on upload` thrown while resolving a part's `images`.
772
+ - **Symptom:** `images: only PNG is supported — convert with imageToPng() from "partforge/ingest" before storing, or have the host normalize on upload` thrown while resolving a part's `images`.
773
773
  - **Cause:** The image resolver checks the first four bytes against the PNG magic number before decoding; a JPEG, WEBP, or any other format fails that check immediately; a heightfield source is a depth map and needs a single well-defined decode path, so no other format is attempted.
774
- - **Fix:** Convert the source to PNG before it reaches `images` — call `imageToPng()` (exported from `"partforge"`, browser-only: it draws through a `<canvas>`) in the host's upload/panel handler, or pre-convert with any image tool. Bytes that are already PNG (the 4-byte signature `89 50 4E 47`) skip this check entirely.
774
+ - **Fix:** Convert the source to PNG before it reaches `images` — call `imageToPng()` (exported from `"partforge/ingest"`, browser-only: it draws through a `<canvas>`) in the host's upload/panel handler, or pre-convert with any image tool. Bytes that are already PNG (the 4-byte signature `89 50 4E 47`) skip this check entirely.
775
775
 
776
776
  ## png-interlaced-unsupported
777
777
 
@@ -10,8 +10,10 @@ There are two ways a file gets here, and this document leads with the first:
10
10
  a triangular keyway. Coordinates are millimetres, and they place exactly where
11
11
  you drew them. This is the path an agent should reach for when the geometry is
12
12
  *drawn* rather than computed.
13
- - **Ingested.** Convert an existing `.svg` once, in a browser, with
14
- `partforge/ingest`, and check the resulting JSON in beside the part. The
13
+ - **Ingested.** Convert an existing `.svg` once in a browser with
14
+ `partforge/ingest`, or headlessly with `npx partforge ingest <file.svg> --out
15
+ <file.vector.json>` (needs `happy-dom`, an optional peer dependency: `npm
16
+ install happy-dom`) — and check the resulting JSON in beside the part. The
15
17
  artwork keeps its own unitless coordinates and gets sized at every call site.
16
18
 
17
19
  Both produce the same format, load through the same validator, and behave
@@ -19,14 +21,15 @@ identically downstream.
19
21
 
20
22
  **Why this document is normative and not merely helpful.** Ingest needs a real
21
23
  DOM (it resolves `<use>`, `<defs>`, CSS, and bakes ancestor transforms, all of
22
- which require one), so partforge deliberately ships **no headless SVG
23
- conversion path** `partforge measure|render|lint` read a part's already-stored
24
- JSON, but nothing headless can *create* it from an `.svg`. That trade was
25
- accepted only because this file is complete enough that someone or some agent
26
- with no browser and no access to partforge's source can write a compliant
27
- converter from it alone. Everything below is written to hold that property.
28
- `scripts/ingest-svg.mjs` (dev-only, not shipped) is the reference
29
- implementation, described in §6.
24
+ which require one) `partforge measure|render|lint` read a part's
25
+ already-stored JSON, but neither of ingest's two entry points *creates* one
26
+ without a DOM somewhere: a real browser for `partforge/ingest`, or a headless
27
+ one (`happy-dom`) for the CLI. That trade was accepted only because this file
28
+ is complete enough that someone or some agent with neither a browser nor
29
+ happy-dom installed, and no access to partforge's source, can still write a
30
+ compliant converter from it alone. Everything below is written to hold that
31
+ property. The `ingest` verb in `bin/cli.js` is the reference implementation,
32
+ described in §6.
30
33
 
31
34
  ## 1. A worked authored example
32
35
 
@@ -494,8 +497,9 @@ Shapes are named, regions and segments are 1-indexed, and the role (`outer` /
494
497
  One filled circle, and one **stroked, open** polyline — deliberately, so this one
495
498
  file exercises both of ingest's geometry paths (a fill, and a stroke that has to
496
499
  be outlined into a filled shape; see §6). Ingesting it
497
- (`node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`) produces
498
- `src/parts/assets/emblem.vector.json`, checked in beside it. Here it is with the
500
+ (`npx partforge ingest src/parts/assets/emblem.svg --out
501
+ src/parts/assets/emblem.vector.json`) produces that file, checked in beside it.
502
+ Here it is with the
499
503
  `note` field elided for brevity and the coordinate arrays put on one line —
500
504
  nothing else is changed:
501
505
 
@@ -662,12 +666,14 @@ followed by the one already-written reference to check your output against.
662
666
  file stays diffable and so a stored `bbox` matches a later recomputation from
663
667
  the *rounded* coordinates rather than drifting past the tolerance.
664
668
 
665
- `scripts/ingest-svg.mjs` in the partforge repository is the worked reference
666
- implementation of exactly this pipeline it runs `partforge/ingest`'s real
667
- `ingestSvg()` (paper.js's `importSVG` for steps 1–2 and 4–5, this repo's own
669
+ The `ingest` verb in `bin/cli.js` (`npx partforge ingest <file.svg> --out
670
+ <file.vector.json>`) is the worked reference implementation of exactly this
671
+ pipeline — it runs `partforge/ingest`'s real `ingestSvg()` (paper.js's
672
+ `importSVG` for steps 1–2 and 4–5, this repo's own
668
673
  `contour-offset.js`/`stroke-outline.js` for step 3, and its own `arc-fit.js` for
669
- step 7) inside a headless DOM (`happy-dom`, a devDependency), specifically so
670
- that repository's own fixtures including the worked example in §4 — are
674
+ step 7) inside a headless DOM (`happy-dom`, an optional peer dependency —
675
+ `src/framework/ingest/node-dom.js` installs it), specifically so that
676
+ repository's own fixtures — including the worked example in §4 — are
671
677
  reproducible instead of being hand-maintained blobs, and so there is a second
672
678
  thing (besides this document) to check a from-scratch converter's output
673
679
  against: ingest the same SVG both ways and diff the JSON.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.97.0",
3
+ "version": "0.98.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -99,6 +99,14 @@
99
99
  "check": "node scripts/check-app.mjs",
100
100
  "offset-rates": "node scripts/offset-rates.mjs"
101
101
  },
102
+ "peerDependencies": {
103
+ "happy-dom": ">=20"
104
+ },
105
+ "peerDependenciesMeta": {
106
+ "happy-dom": {
107
+ "optional": true
108
+ }
109
+ },
102
110
  "dependencies": {
103
111
  "dompurify": "^3.4.11",
104
112
  "fflate": "^0.8.3",
@@ -3,6 +3,20 @@
3
3
  // for the other case — a value that arrived in `params`, which on a shared link
4
4
  // is attacker-controlled text that would otherwise become a fetch URL.
5
5
  //
6
+ // Bytes bypass the allow check. This file's older rule refused every non-string
7
+ // on the grounds that "bytes/thunks are never param-supplied"; that stopped
8
+ // being true when the panel gained a drop target. The replacement rule is
9
+ // sound and is the same one image-source.js states: an ArrayBuffer in params
10
+ // definitionally did not arrive via a shared link, because a URL cannot carry
11
+ // megabytes — so it can only have been placed there by the host's own panel,
12
+ // which is trusted code. That plausibility argument isn't the load-bearing
13
+ // one, though — the structural fact is that asset-resolve.js's resolver
14
+ // fetches only a `string`/`URL` source; a byte source is consumed directly
15
+ // and never becomes a request. Bytes therefore cannot reach the network no
16
+ // matter how they got into params, which is what the allow list exists to
17
+ // gate, and is what still holds even if a host someday puts a few bytes of
18
+ // base64 in a link.
19
+ //
6
20
  // DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
7
21
 
8
22
  export const FONT_ALLOW_DEFAULT = ["https"];
@@ -21,13 +35,16 @@ export const isNoFontSource = (v) => v === undefined || v === null || v === "";
21
35
  const GSTATIC_HOST = "fonts.gstatic.com";
22
36
  const ASSET_SCHEME = "pfc-asset:";
23
37
 
38
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
39
+
24
40
  // Parse once; an unparseable string is refused rather than guessed at.
25
41
  function parse(source) {
26
42
  try { return new URL(source); } catch { return null; }
27
43
  }
28
44
 
29
45
  export function fontSourceAllowed(source, allow = FONT_ALLOW_DEFAULT) {
30
- if (typeof source !== "string") return false; // bytes/thunks are never param-supplied
46
+ if (isBytes(source)) return true; // see the file header — bytes always bypass the allow check
47
+ if (typeof source !== "string") return false;
31
48
  const u = parse(source);
32
49
  if (!u) return false;
33
50
  for (const kind of allow) {
@@ -2,14 +2,18 @@
2
2
  // code and get no restriction; this file exists only for the other case — a
3
3
  // value that arrived in `params`.
4
4
  //
5
- // This deliberately diverges from font-source.js in one place. That file refuses
6
- // every non-string on the grounds that "bytes/thunks are never param-supplied".
7
- // For images they ARE: the partforge-cloud sandbox cannot fetch URLs and puts PNG
8
- // bytes straight in the param. The replacement rule is sound an ArrayBuffer in
9
- // params definitionally did not arrive via a shared link, because a URL cannot
10
- // carry megabytes, so it can only have been placed there by the host's own panel,
11
- // which is trusted code. Bytes therefore bypass the allow check entirely, for
12
- // every allow list. Do NOT copy font-source.js's non-string refusal here.
5
+ // font-source.js states and follows the same rule, and its header explains why
6
+ // it holds: an ArrayBuffer in params definitionally did not arrive via a shared
7
+ // link, because a URL cannot carry megabytes so it can only have been placed
8
+ // there by the host's own panel, which is trusted code (the partforge-cloud
9
+ // sandbox cannot fetch URLs and puts PNG bytes straight in the param, which is
10
+ // exactly this case). That plausibility argument isn't the load-bearing one,
11
+ // though the structural fact is that asset-resolve.js's resolver fetches
12
+ // only a `string`/`URL` source; a byte source is consumed directly and never
13
+ // becomes a request. Bytes therefore cannot reach the network no matter how
14
+ // they got into params, which is what the allow list exists to gate, and is
15
+ // what still holds even if a host someday puts a few bytes of base64 in a
16
+ // link. Bytes bypass the allow check entirely, for every allow list.
13
17
  //
14
18
  // DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
15
19
 
@@ -24,7 +24,7 @@ const resolveOne = makeAssetResolver(
24
24
  for (let i = 0; i < 4; i++) {
25
25
  if (u8[i] !== PNG_SIG[i]) {
26
26
  throw new Error(
27
- "images: only PNG is supported — convert with imageToPng() from \"partforge\" before storing, " +
27
+ "images: only PNG is supported — convert with imageToPng() from \"partforge/ingest\" before storing, " +
28
28
  "or have the host normalize on upload",
29
29
  );
30
30
  }