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,178 @@
1
+ // The `type: "image"` control. Its VALUE is an image source — either a URL
2
+ // string (the same grammar `PartDefinition.images` already accepts) or raw PNG
3
+ // bytes (an ArrayBuffer/typed array): the partforge-cloud sandbox cannot fetch
4
+ // URLs, so it puts the bytes straight in the param. Everything downstream
5
+ // (presets, undo, the params hash, `when`) works with either shape, no special
6
+ // case — this widget is the only place on the main thread that has to look at
7
+ // the difference.
8
+ //
9
+ // Two renderings, mirroring widgets/font.js exactly. With a host-supplied
10
+ // `imageCatalog` it is a button — a thumbnail + label — opening the picker.
11
+ // Without one it degrades to a URL text field, so a standalone partforge app
12
+ // (which ships no catalog) still exposes the parameter.
13
+ //
14
+ // Main-thread only: the preview is a plain `<img>` bound to the source URL —
15
+ // the browser decodes the PNG natively. Do NOT import `png-decode.js` (or
16
+ // anything from images.js) here; that decoder belongs to the worker's build
17
+ // path, not the panel.
18
+ import { attachInfo } from "../info.js";
19
+ import { IMAGE_ALLOW_DEFAULT, imageSourceAllowed } from "../../image-source.js";
20
+ import { mountDrop } from "./file-drop.js";
21
+
22
+ function el(tag, className, text) {
23
+ const node = document.createElement(tag);
24
+ if (className) node.className = className;
25
+ if (text != null) node.textContent = text;
26
+ return node;
27
+ }
28
+
29
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
30
+
31
+ // A URL source → its filename, for a label with no catalog to ask. A byte
32
+ // value has no filename — callers check `isBytes` first and never reach this
33
+ // for one. Not a source to fetch, never a source to warn about: `isNoImageSource`
34
+ // values (unset/"") read as "No image" rather than a broken link.
35
+ export function imageLabel(source) {
36
+ if (typeof source !== "string" || !source) return "No image";
37
+ let path = source;
38
+ try { path = new URL(source).pathname; } catch { /* not a URL — use the raw string */ }
39
+ const file = path.split("/").filter(Boolean).pop();
40
+ return file || source;
41
+ }
42
+
43
+ // Point (or unpoint) the live preview. A byte-valued param has no URL to hand
44
+ // the browser, so the image stays hidden rather than trying to render it or
45
+ // showing a broken-image glyph — same rule an empty/unset value gets. `onerror`
46
+ // covers the other broken-image case: a URL that 404s or that CORS refuses.
47
+ function paintPreview(img, source) {
48
+ if (typeof source === "string" && source) {
49
+ img.hidden = false;
50
+ img.src = source;
51
+ } else {
52
+ img.hidden = true;
53
+ img.removeAttribute("src");
54
+ }
55
+ }
56
+
57
+ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog, onAssetUpload } = {}) {
58
+ const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : IMAGE_ALLOW_DEFAULT;
59
+ const wrap = el("div", "slider");
60
+ const row = el("div", "row");
61
+ const label = el("label", "", node.label ?? node.key);
62
+ attachInfo(label, node.description, info);
63
+ row.append(label);
64
+ wrap.append(row);
65
+
66
+ const preview = document.createElement("img");
67
+ preview.className = "image-preview";
68
+ preview.alt = "";
69
+ preview.hidden = true;
70
+ // A URL that fails to load (404, CORS, revoked link) must degrade to hidden,
71
+ // not the browser's broken-image glyph.
72
+ preview.addEventListener("error", () => { preview.hidden = true; });
73
+ wrap.append(preview);
74
+
75
+ if (!imageCatalog) {
76
+ // Degraded path: a URL field. Unlike `text`, it does NOT write on every
77
+ // keystroke — a half-typed URL is a guaranteed failed fetch, and the
78
+ // rebuild loop would chase every one of them.
79
+ const field = document.createElement("input");
80
+ field.type = "text";
81
+ field.className = "text-input";
82
+ const paintField = () => {
83
+ const v = params[node.key];
84
+ // Bytes cannot round-trip through a text field — `String(arrayBuffer)`
85
+ // is "[object ArrayBuffer]", not a value anyone typed. Show an honest
86
+ // placeholder instead of that, and leave the field free to type a
87
+ // replacement URL over it.
88
+ field.value = isBytes(v) ? "" : String(v ?? "");
89
+ field.placeholder = isBytes(v) ? "Uploaded image" : "";
90
+ field.classList.remove("warn");
91
+ paintPreview(preview, v);
92
+ };
93
+ field.addEventListener("change", () => {
94
+ if (!imageSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
95
+ field.classList.remove("warn");
96
+ params[node.key] = field.value;
97
+ onChange?.();
98
+ onCommit?.();
99
+ });
100
+ paintField();
101
+ wrap.append(field);
102
+
103
+ const drop = mountDrop("image", {
104
+ params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
105
+ });
106
+ wrap.append(drop.el, drop.errorEl);
107
+
108
+ return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
109
+ }
110
+
111
+ const btn = el("button", "image-btn");
112
+ btn.type = "button";
113
+ const thumb = document.createElement("img");
114
+ thumb.className = "image-btn-thumb";
115
+ thumb.alt = "";
116
+ thumb.hidden = true;
117
+ thumb.addEventListener("error", () => { thumb.hidden = true; });
118
+ const iname = el("span", "iname");
119
+ btn.append(thumb, iname);
120
+ btn.insertAdjacentHTML("beforeend",
121
+ '<svg class="caret" width="8" height="7" viewBox="0 0 8 7" aria-hidden="true"><polygon points="0,0 8,0 4,7" fill="currentColor"/></svg>');
122
+ wrap.append(btn);
123
+
124
+ // The value alone cannot describe a byte-valued source, and even a URL's
125
+ // filename is a guess a catalog can improve on. `describe` is optional and
126
+ // may be async, so the label is painted twice: an immediate honest guess,
127
+ // then the catalog's answer when it lands (same two-pass shape as font.js's
128
+ // `paint`, `paintSeq` included so a stale describe() can't win a race
129
+ // against a newer one).
130
+ let paintSeq = 0;
131
+ const paint = () => {
132
+ const src = params[node.key];
133
+ const seq = ++paintSeq;
134
+ paintPreview(preview, src);
135
+ paintPreview(thumb, src);
136
+ const show = ({ label: text, width, height }) => {
137
+ if (seq !== paintSeq) return; // a newer paint already won
138
+ iname.textContent = width && height ? `${text} (${width}×${height})` : text;
139
+ };
140
+ show(isBytes(src) ? { label: "Uploaded image" } : { label: imageLabel(src) });
141
+ if (typeof imageCatalog.describe !== "function") return;
142
+ Promise.resolve()
143
+ .then(() => imageCatalog.describe(src))
144
+ .then((d) => {
145
+ if (!d) return;
146
+ show({ label: d.label ?? (isBytes(src) ? "Uploaded image" : imageLabel(src)), width: d.width, height: d.height });
147
+ })
148
+ .catch(() => { /* a failed lookup keeps the immediate label */ });
149
+ };
150
+ paint();
151
+
152
+ // The picker registers itself through setImagePicker (see below); with no
153
+ // picker in the bundle the button is inert rather than broken.
154
+ //
155
+ // The handle is kept because the picker is a TAKEOVER: it appends itself to
156
+ // the rail, outside the panel root, so tearing the panel down does not take it
157
+ // with it. Without dispose() the element — and the `document` keydown listener
158
+ // that only close() unhooks — would outlive the panel holding a stale `params`.
159
+ let picker = null;
160
+ btn.addEventListener("click", () => {
161
+ picker = openImagePicker?.({ node, params, allow, imageCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
162
+ });
163
+
164
+ const drop = mountDrop("image", { params, node, onAssetUpload, onChange, onCommit, onRender: paint });
165
+ wrap.append(drop.el, drop.errorEl);
166
+
167
+ return {
168
+ el: wrap,
169
+ sync: paint,
170
+ dispose: () => { picker?.close(); picker = null; drop.dispose(); },
171
+ };
172
+ }
173
+
174
+ // Assigned by image-picker.js, which widgets/index.js imports for the side
175
+ // effect. Kept as a mutable binding rather than a static import so this file
176
+ // stays usable — and testable — without dragging the whole picker in.
177
+ export let openImagePicker = null;
178
+ export const setImagePicker = (fn) => { openImagePicker = fn; };
@@ -5,12 +5,16 @@ import { makeText } from "./text.js";
5
5
  import { makeCheckbox } from "./checkbox.js";
6
6
  import { makeSelect, makeRadio } from "./select.js";
7
7
  import { makeFont } from "./font.js";
8
- // Side-effect import: font-picker.js calls setFontPicker() at module scope, so
9
- // the font widget's button finds a picker to open. It lives HERE and not in
10
- // font.js because the dependency has to run picker widget and never back —
11
- // font.js must stay importable (and testable) without dragging the whole
12
- // DOM-heavy picker in. See the note at the bottom of font.js.
8
+ import { makeImage } from "./image.js";
9
+ import { makeVector } from "./vector.js";
10
+ // Side-effect imports: font-picker.js / image-picker.js call setFontPicker() /
11
+ // setImagePicker() at module scope, so each widget's button finds a picker to
12
+ // open. They live HERE and not in font.js/image.js because the dependency has
13
+ // to run picker → widget and never back — those files must stay importable
14
+ // (and testable) without dragging the whole DOM-heavy picker in. See the note
15
+ // at the bottom of font.js.
13
16
  import "../font-picker.js";
17
+ import "../image-picker.js";
14
18
 
15
19
  export const WIDGET_FACTORIES = {
16
20
  slider: makeNumeric,
@@ -21,4 +25,6 @@ export const WIDGET_FACTORIES = {
21
25
  select: makeSelect,
22
26
  radio: makeRadio,
23
27
  font: makeFont,
28
+ image: makeImage,
29
+ vector: makeVector,
24
30
  };
@@ -0,0 +1,77 @@
1
+ // The `type: "vector"` control. Its VALUE is vector artwork — either a URL
2
+ // string (the same grammar `PartDefinition.vectors` already accepts) or the
3
+ // PARSED partforge-vector document object a drop/paste conversion produces
4
+ // (see file-drop.js's vector-kind handling and vectors.js's `asParsedFile`,
5
+ // "the in-tree form" — a source that IS the parsed contents of its file
6
+ // rather than a way to reach its bytes). `vectorsFor` (vectors.js) is what
7
+ // lets a `type: "vector"` control drive the artwork: a part declares
8
+ // `vectors: (p) => ({ name: p.art })` and this control writes `p.art`.
9
+ //
10
+ // Unlike font.js/image.js there is NO catalog provider for artwork — no
11
+ // `vectorCatalog` exists (see the design doc's "no vector catalog provider"
12
+ // note) — so this control has exactly ONE rendering: a URL field plus a
13
+ // drop target. There is no picker button and nothing here degrades from a
14
+ // richer form; this IS the whole control.
15
+ //
16
+ // Main-thread only: the real SVG -> partforge-vector conversion (paper.js)
17
+ // runs inside `makeFileDrop` -> the registry's "vector" convert thunk,
18
+ // never here.
19
+ import { attachInfo } from "../info.js";
20
+ import { VECTOR_ALLOW_DEFAULT, vectorSourceAllowed } from "../../vector-source.js";
21
+ import { mountDrop } from "./file-drop.js";
22
+
23
+ function el(tag, className, text) {
24
+ const node = document.createElement(tag);
25
+ if (className) node.className = className;
26
+ if (text != null) node.textContent = text;
27
+ return node;
28
+ }
29
+
30
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
31
+ // A dropped/pasted SVG with no `onAssetUpload` host hook lands as the PARSED
32
+ // document object (task-9 addendum, Ruling D) — an opaque value with nothing
33
+ // a text field can show, the same rule image.js/font.js apply to a
34
+ // byte-valued param.
35
+ const isOpaque = (v) => isBytes(v) || (v != null && typeof v === "object");
36
+
37
+ export function makeVector(node, params, { onChange, onCommit, info, onAssetUpload } = {}) {
38
+ const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : VECTOR_ALLOW_DEFAULT;
39
+ const wrap = el("div", "slider");
40
+ const row = el("div", "row");
41
+ const label = el("label", "", node.label ?? node.key);
42
+ attachInfo(label, node.description, info);
43
+ row.append(label);
44
+ wrap.append(row);
45
+
46
+ // The URL field. Unlike `text`, it does NOT write on every keystroke — a
47
+ // half-typed URL is a guaranteed failed fetch, and the rebuild loop would
48
+ // chase every one of them. Mirrors widgets/image.js's/font.js's own field.
49
+ const field = document.createElement("input");
50
+ field.type = "text";
51
+ field.className = "text-input";
52
+ const paintField = () => {
53
+ const v = params[node.key];
54
+ // A parsed document (or bytes) cannot round-trip through a text field —
55
+ // show an honest placeholder instead, and leave the field free to type a
56
+ // replacement URL over it.
57
+ field.value = isOpaque(v) ? "" : String(v ?? "");
58
+ field.placeholder = isOpaque(v) ? "Uploaded artwork" : "";
59
+ field.classList.remove("warn");
60
+ };
61
+ field.addEventListener("change", () => {
62
+ if (!vectorSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
63
+ field.classList.remove("warn");
64
+ params[node.key] = field.value;
65
+ onChange?.();
66
+ onCommit?.();
67
+ });
68
+ paintField();
69
+ wrap.append(field);
70
+
71
+ const drop = mountDrop("vector", {
72
+ params, node, onAssetUpload, onChange, onCommit, onRender: paintField,
73
+ });
74
+ wrap.append(drop.el, drop.errorEl);
75
+
76
+ return { el: wrap, sync: paintField, dispose: () => drop.dispose() };
77
+ }
@@ -3,6 +3,7 @@
3
3
  // affect what's visible. Pure — no DOM, no real geometry (reuses the geometry-free
4
4
  // probe kernel). Errs toward RELEVANT_ALL whenever it can't analyze a build.
5
5
  import { createProbeKernel } from "./geometry/probe.js";
6
+ import { byteAwareReplacer } from "./geometry/solid-hash.js";
6
7
  import { viewSubParts } from "./part-model.js";
7
8
  import { resolveDerived } from "./derive.js";
8
9
 
@@ -125,7 +126,11 @@ export function subPartReadKeys(part, view, params) {
125
126
  }
126
127
 
127
128
  // Stable string of the given param keys' current values — the cache-validity key
128
- // for one sub-part. Sorted so key order never affects the result.
129
+ // for one sub-part. Sorted so key order never affects the result. `byteAwareReplacer`
130
+ // substitutes a content fingerprint for a byte-valued param (an ArrayBuffer/typed-array
131
+ // image source — see its own header) so JSON.stringify's default handling doesn't
132
+ // collapse every image to the same "{}" (cache never invalidates) or expand a typed
133
+ // array to one JSON number per byte (see solid-hash.js).
129
134
  export function relevanceHash(keys, params) {
130
- return JSON.stringify(keys.slice().sort().map((k) => [k, params[k]]));
135
+ return JSON.stringify(keys.slice().sort().map((k) => [k, params[k]]), byteAwareReplacer);
131
136
  }
@@ -0,0 +1,127 @@
1
+ // What a PARAM-supplied vector source may be. Author-declared `vectors`
2
+ // sources are code and get no restriction; this file exists only for the
3
+ // other case — a value that arrived in `params`, which on a shared link is
4
+ // attacker-controlled input that `vectors: (p) => …` would turn into a fetch
5
+ // URL.
6
+ //
7
+ // This file's allow rule differs from its two siblings
8
+ // (font-source.js/image-source.js) in ONE place, and getting that difference
9
+ // right matters: those two exempt every non-string (bytes) source from the
10
+ // allow check on the reasoning that an ArrayBuffer cannot survive a share
11
+ // link — a URL can't carry megabytes, so bytes in params can only have been
12
+ // placed there by the host's own trusted panel. That reasoning does NOT
13
+ // transfer to vector artwork: `type: "vector"`'s drop target writes a PARSED
14
+ // partforge-vector document — plain JSON — into params when there is no upload
15
+ // hook, and unlike raw bytes, plain JSON round-trips a share link perfectly
16
+ // (it's just more of the same params payload). Do not copy the
17
+ // bytes-can't-survive-a-link justification here; it would be wrong.
18
+ //
19
+ // So the exemption this file grants is NOT "any object". The gate has to be
20
+ // read against what asset-resolve.js actually does, in its order:
21
+ // `unwrapModule(v)` runs FIRST, and only THEN does the resolver dispatch on
22
+ // shape. A `{ default: "http://169.254.169.254/…" }` wrapper is therefore
23
+ // unwrapped to a plain string and handed to `fetch` — arbitrary scheme,
24
+ // arbitrary host, in ~40 bytes of JSON a share link carries effortlessly. An
25
+ // earlier version of this file exempted every object on the claim that "an
26
+ // object never reaches the fetch branch"; that claim was FALSE for exactly
27
+ // this shape, and it is deleted rather than qualified.
28
+ //
29
+ // The rule that does hold, and what this file gates on, is: unwrap first, then
30
+ // judge the value the RESOLVER will see.
31
+ //
32
+ // - a string or `URL` — fetchable, so it gets the full `allow` treatment;
33
+ // - bytes (ArrayBuffer/view) — never handed to `fetch`, and (unlike JSON)
34
+ // genuinely cannot ride a link, so exempt;
35
+ // - a function/thunk — refused outright. Its return value is a fetch source
36
+ // that cannot be known at check time, so there is nothing to gate on;
37
+ // - a plain object that does NOT unwrap to any of the above — the genuinely
38
+ // inert case, the already-parsed partforge-vector document vectors.js's
39
+ // `asParsedFile` claims. It is validated downstream by `toInternalDocument`,
40
+ // a pure shape check that never touches the network, so it is exempt;
41
+ // - anything else (arrays, numbers, booleans) — refused. None of them is a
42
+ // valid source, and a refusal simply restores the part's default.
43
+ //
44
+ // `unwrapModule` is imported, never re-implemented: this gate is only sound
45
+ // while it unwraps by exactly the same rule the resolver does.
46
+ //
47
+ // DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
48
+ // asset-resolve.js is itself dependency-free, so importing it keeps
49
+ // partforge/lint's zero-dependency closure intact.
50
+ import { unwrapModule } from "./asset-resolve.js";
51
+
52
+ export const VECTOR_ALLOW_DEFAULT = ["https"];
53
+
54
+ const ASSET_SCHEME = "pfc-asset:";
55
+
56
+ // The "unset" vector source. An empty value declares NO artwork for that
57
+ // name — mirrors isNoFontSource/isNoImageSource exactly. Never a source to
58
+ // fetch, and never a source to refuse.
59
+ export const isNoVectorSource = (v) => v === undefined || v === null || v === "";
60
+
61
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
62
+
63
+ // An already-parsed partforge-vector document: object-shaped, and not any of
64
+ // the shapes the resolver reads as a way to REACH bytes. Deliberately the same
65
+ // structural test vectors.js's `asParsedFile` applies (arrays excluded — an
66
+ // array is never a file), so "what this gate exempts" and "what the resolver
67
+ // adopts without fetching" stay the same set.
68
+ const isParsedDocument = (v) =>
69
+ v != null && typeof v === "object" && !Array.isArray(v)
70
+ && !isBytes(v) && !(v instanceof URL);
71
+
72
+ // Parse once; an unparseable string is refused rather than guessed at.
73
+ function parse(source) {
74
+ try { return new URL(source); } catch { return null; }
75
+ }
76
+
77
+ export function vectorSourceAllowed(source, allow = VECTOR_ALLOW_DEFAULT) {
78
+ // Unwrap FIRST — asset-resolve.js does, before it dispatches on shape, so a
79
+ // `{ default: … }` wrapper must be judged by what it unwraps to. See header.
80
+ const v = unwrapModule(source);
81
+ // A thunk (before or after unwrapping) resolves to a source this check cannot
82
+ // see, so there is nothing to gate; refuse rather than trust it.
83
+ if (typeof source === "function" || typeof v === "function") return false;
84
+ if (isBytes(v)) return true; // never handed to `fetch`
85
+ if (isParsedDocument(v)) return true; // inert; validated, never fetched
86
+ // Everything the resolver would fetch — a string or a `URL` — gets the full
87
+ // allow treatment. Everything else (arrays, numbers, booleans) is refused.
88
+ if (!(typeof v === "string" || v instanceof URL)) return false;
89
+ const u = v instanceof URL ? v : parse(v);
90
+ if (!u) return false;
91
+ for (const kind of allow) {
92
+ // hostname/protocol, never a substring of the raw string — same rule
93
+ // font-source.js's header explains: a URL merely CONTAINING
94
+ // "pfc-asset://" must not pass, and neither must a lookalike host.
95
+ if (kind === "https" && u.protocol === "https:") return true;
96
+ if (kind === "asset" && u.protocol === ASSET_SCHEME) return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ // paramKey → allow list, for every `type: "vector"` control in the authored
102
+ // tree — new-shape (`controls`, including nested groups) AND legacy-shape
103
+ // (`advanced`/`toggles`/`features`, where panel/legacy.js desugars a
104
+ // descriptor's `control:` field to `type:`), mirroring
105
+ // imageControlAllows/fontControlAllows exactly. Missing the legacy arrays
106
+ // here would leave a `{key, control:"vector"}` descriptor with no entry in
107
+ // the returned map, and jobs.js's check only looks at keys present in the
108
+ // map — so a legacy-declared vector control would get silently
109
+ // unrestricted. Tolerant of any array being absent or malformed; it must
110
+ // never throw on an existing part.
111
+ export function vectorControlAllows(part) {
112
+ const out = new Map();
113
+ const visit = (nodes) => {
114
+ for (const n of nodes ?? []) {
115
+ if (!n || typeof n !== "object") continue;
116
+ if (Array.isArray(n.controls)) visit(n.controls);
117
+ if (Array.isArray(n.advanced)) visit(n.advanced);
118
+ if (Array.isArray(n.toggles)) visit(n.toggles);
119
+ if (Array.isArray(n.features)) visit(n.features);
120
+ if ((n.type === "vector" || n.control === "vector") && typeof n.key === "string") {
121
+ out.set(n.key, Array.isArray(n.allow) && n.allow.length ? n.allow : VECTOR_ALLOW_DEFAULT);
122
+ }
123
+ }
124
+ };
125
+ visit(part?.parameters);
126
+ return out;
127
+ }
@@ -83,6 +83,15 @@ const resolveOne = makeAssetResolver(
83
83
  (value) => asParsedFile(value) ?? undefined,
84
84
  );
85
85
 
86
+ // `vectors` may be a plain { name: source } map, or a function of the resolved
87
+ // params — the second form is what lets a `type: "vector"` control drive the
88
+ // artwork. Resolving it needs `p`, which is why this is a separate step from
89
+ // resolveVectors rather than folded into it. Mirrors fontsFor/imagesFor.
90
+ export function vectorsFor(part, p) {
91
+ const decl = part?.vectors;
92
+ return typeof decl === "function" ? decl(p) : decl;
93
+ }
94
+
86
95
  export async function resolveVectors(vectorsDecl) {
87
96
  // A function reaching here means a caller passed `part.vectors` raw, the way
88
97
  // fonts.js's resolveFonts guards against the same mistake for `part.fonts`.
package/src/ingest.js CHANGED
@@ -6,3 +6,4 @@
6
6
  // Deliberately NOT re-exported from `partforge` (the main entry) or from
7
7
  // `partforge/geometry`: this must stay unreachable from the geometry worker.
8
8
  export { ingestSvg } from "./framework/ingest/svg-ingest.js";
9
+ export { imageToPng } from "./framework/ingest/image-ingest.js";
Binary file
@@ -19,7 +19,8 @@
19
19
  // work under Vite and fail in the CLI.
20
20
  //
21
21
  // The source artwork lives beside it as emblem.svg, and the .json is regenerated
22
- // with `node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`. plate.vector.json
22
+ // with `npx partforge ingest src/parts/assets/emblem.svg --out
23
+ // src/parts/assets/emblem.vector.json`. plate.vector.json
23
24
  // is hand-authored — no ingest step, no source SVG — and is kept legible enough
24
25
  // to serve as documentation's worked example of a multi-shape, role-composed file.
25
26
  import plate from "./assets/plate.vector.json" with { type: "json" };
@@ -0,0 +1,84 @@
1
+ // Reference part for the `images` field / `type: "image"` control / `k.heightfield`
2
+ // (docs/AUTHORING-PARTS.md "Image controls"): a depth map becomes a printable relief
3
+ // plate. `relief` is a `type: "image"` control — pick a replacement PNG from the
4
+ // panel, or leave it empty and the bundled `assets/relief-demo.png` (a synthetic
5
+ // concentric-ripple depth map) is used, so the part builds with no network access
6
+ // and `partforge measure`/CI never need to fetch anything. `pitch` trades sampling
7
+ // detail against triangle count — and therefore STEP size, since a fine pitch on a
8
+ // high-frequency image produces many non-coplanar faces (see heightfieldMesh's own
9
+ // STEP-size warning in the OCCT backend).
10
+ //
11
+ // DEMO_RELIEF_RANGE: the bundled asset's luminance only spans ~39–75% of the
12
+ // 16-bit sample range (measured: 25443–48830 of 65535) — the ripple formula that
13
+ // generated it decays toward its 50%-gray baseline away from the first ring, so
14
+ // most pixels sit close to mid-gray. `k.heightfield`'s default `range: [0, 1]` is
15
+ // an IDENTITY map (raw sample value straight to 0..1), not an auto-normalize, so
16
+ // left alone the demo would use well under half of `maxZ`. This stretches the
17
+ // default asset's own measured extent to the full 0..1 span so the shipped demo
18
+ // shows the full relief amplitude. Applied only when the bundled default is in
19
+ // use (`p.relief` empty) — a picked custom image's tonal range is unknown ahead
20
+ // of build time, so it gets the identity range instead.
21
+ export const DEMO_RELIEF_RANGE = [25443 / 65535, 48830 / 65535];
22
+
23
+ export default {
24
+ meta: { title: "Relief plate", units: "mm", background: 0x15181d },
25
+ parameters: [
26
+ {
27
+ id: "image",
28
+ title: "Image",
29
+ description: "The depth map the relief is sampled from. Bright = high, dark = low, unless inverted.",
30
+ controls: [
31
+ { key: "relief", type: "image", label: "Depth map",
32
+ description: "Pick a PNG from the catalog or paste a URL. Empty falls back to the bundled sample ripple." },
33
+ { key: "invert", type: "checkbox", label: "Invert",
34
+ description: "Swap which end of the image is raised — bright becomes low, dark becomes high." },
35
+ ],
36
+ },
37
+ {
38
+ id: "plate",
39
+ title: "Plate",
40
+ description: "Footprint and relief depth of the printed plate.",
41
+ advanced: [
42
+ { key: "w", label: "Width", unit: "mm", min: 20, max: 200, step: 1,
43
+ description: "Plate footprint along X." },
44
+ { key: "d", label: "Depth", unit: "mm", min: 20, max: 200, step: 1,
45
+ description: "Plate footprint along Y." },
46
+ { key: "base", label: "Base", unit: "mm", min: 0.5, max: 10, step: 0.1,
47
+ description: "Solid slab thickness under the relief — keep it thick enough to print flat and stay rigid." },
48
+ { key: "maxZ", label: "Relief height", unit: "mm", min: 0.2, max: 10, step: 0.1,
49
+ description: "How far the tallest sample rises above the base." },
50
+ { key: "pitch", label: "Detail", unit: "mm", min: 0.2, max: 2, step: 0.1,
51
+ description: "Grid spacing of the height sampling. Smaller is crisper but costs more triangles — see the file header." },
52
+ ],
53
+ },
54
+ ],
55
+ defaults: { relief: "", invert: 0, w: 60, d: 60, base: 1.5, maxZ: 3, pitch: 0.5 },
56
+ // The default is the bundled asset, so the part builds offline; a picked value
57
+ // (a URL or catalog source from the `type: "image"` control) replaces it.
58
+ images: (p) => ({
59
+ relief: p.relief || new URL("./assets/relief-demo.png", import.meta.url),
60
+ }),
61
+ parts: {
62
+ plate: {
63
+ label: "Relief plate",
64
+ views: ["relief"],
65
+ export: { name: "relief" },
66
+ build: (k, p) => k.heightfield("relief", {
67
+ w: p.w, d: p.d, base: p.base, maxZ: p.maxZ, pitch: p.pitch, invert: p.invert,
68
+ ...(p.relief ? {} : { range: DEMO_RELIEF_RANGE }),
69
+ }),
70
+ },
71
+ },
72
+ views: { relief: { label: "Relief" } },
73
+ // Self-verification: a heightfield solid is watertight and hole-free by
74
+ // construction (grid + skirt + cap, no cuts) — this pins that invariant rather
75
+ // than asserting anything image-specific. bbox bounds catch a runaway parameter;
76
+ // fdm-pla opts into the bed-fit gate for a plate meant to actually be printed.
77
+ verify: {
78
+ process: "fdm-pla",
79
+ expect: {
80
+ plate: { watertight: true, holes: 0, bbox: "<=[200,200,20]" },
81
+ _view: { overlaps: 0 },
82
+ },
83
+ },
84
+ };
@@ -0,0 +1,3 @@
1
+ import part from "./parts/relief.js";
2
+ import { runWorker } from "./framework/worker.js";
3
+ runWorker(part);
@@ -6,11 +6,12 @@ import { createManifoldKernel } from "../framework/geometry/manifold-backend.js"
6
6
  import { resolveFonts } from "../framework/fonts.js";
7
7
  import { normalizeOpentype, parseFont } from "../framework/geometry/opentype-interop.js";
8
8
  import { ensureImports } from "../framework/imports.js";
9
+ import { ensureImages } from "../framework/images.js";
9
10
  import { ensureVectors } from "../framework/vectors.js";
10
11
  import { nodeAssetSources } from "./assets.js";
11
12
  import { tessellateStepAssets } from "./step-mesh.js";
12
13
 
13
- export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes, vectors } = {}) {
14
+ export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes, images, vectors } = {}) {
14
15
  const wasm = await Module();
15
16
  wasm.setup();
16
17
  const kernel = createManifoldKernel(wasm, { quality });
@@ -25,6 +26,11 @@ export async function bootManifoldKernel({ quality = "preview", fonts, imports,
25
26
  const meshes = importMeshes ?? (stepEntries.length ? await tessellateStepAssets(stepEntries) : null);
26
27
  await ensureImports(kernel, decl, meshes);
27
28
  }
29
+ // Third asset sibling: register declared images the same way as fonts/imports
30
+ // above, so a part using `k.heightfield` builds headlessly instead of hitting
31
+ // `heightfield: unknown image "…"` — file: sources need the same Node mapping
32
+ // (global fetch can't read them) that fonts/imports get from nodeAssetSources.
33
+ if (images && Object.keys(images).length) await ensureImages(kernel, nodeAssetSources(images));
28
34
  if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
29
35
  return kernel;
30
36
  }
@@ -8,10 +8,11 @@ import { createOcctKernel } from "../framework/geometry/occt-backend.js";
8
8
  import { resolveFonts } from "../framework/fonts.js";
9
9
  import { normalizeOpentype, parseFont } from "../framework/geometry/opentype-interop.js";
10
10
  import { ensureImports } from "../framework/imports.js";
11
+ import { ensureImages } from "../framework/images.js";
11
12
  import { ensureVectors } from "../framework/vectors.js";
12
13
  import { nodeAssetSources } from "./assets.js";
13
14
 
14
- export async function bootOcctKernel({ fonts, imports, importMeshes, vectors } = {}) {
15
+ export async function bootOcctKernel({ fonts, imports, importMeshes, images, vectors } = {}) {
15
16
  const require = createRequire(import.meta.url);
16
17
  globalThis.require = globalThis.require ?? require;
17
18
  globalThis.__dirname = globalThis.__dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -23,6 +24,8 @@ export async function bootOcctKernel({ fonts, imports, importMeshes, vectors } =
23
24
  if (fonts) { const opentype = normalizeOpentype(await import("opentype.js"));
24
25
  for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, parseFont(opentype, buf, name)); }
25
26
  if (imports) await ensureImports(kernel, nodeAssetSources(imports), importMeshes ?? null);
27
+ // Third asset sibling: see bootManifoldKernel's matching comment.
28
+ if (images && Object.keys(images).length) await ensureImages(kernel, nodeAssetSources(images));
26
29
  if (vectors) await ensureVectors(kernel, nodeAssetSources(vectors));
27
30
  return kernel;
28
31
  }