partforge 0.96.0 → 0.97.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 (39) hide show
  1. package/bin/cli.js +14 -1
  2. package/docs/AUTHORING-PARTS.md +199 -1
  3. package/docs/ERROR-PATTERNS.md +24 -0
  4. package/docs/KERNEL-CONTRACT.md +1 -0
  5. package/package.json +1 -1
  6. package/src/app-relief.js +16 -0
  7. package/src/framework/app.css +30 -0
  8. package/src/framework/backend-select.js +7 -2
  9. package/src/framework/geometry/heightfield.js +129 -0
  10. package/src/framework/geometry/kernel.js +3 -0
  11. package/src/framework/geometry/manifold-backend.js +61 -0
  12. package/src/framework/geometry/occt-backend.js +148 -1
  13. package/src/framework/geometry/op-options.js +10 -0
  14. package/src/framework/geometry/png-decode.js +107 -0
  15. package/src/framework/geometry/solid-hash.js +96 -0
  16. package/src/framework/image-ingest.js +41 -0
  17. package/src/framework/image-source.js +72 -0
  18. package/src/framework/images.js +66 -0
  19. package/src/framework/jobs.js +55 -0
  20. package/src/framework/lint/index.js +2 -1
  21. package/src/framework/lint/rules-images.js +109 -0
  22. package/src/framework/measure/measure-mode.js +2 -1
  23. package/src/framework/mount.js +2 -1
  24. package/src/framework/oracle/verify.js +6 -1
  25. package/src/framework/panel/image-picker.js +152 -0
  26. package/src/framework/panel/render.js +1 -0
  27. package/src/framework/panel/widget-specs.js +2 -0
  28. package/src/framework/panel/widgets/image.js +164 -0
  29. package/src/framework/panel/widgets/index.js +9 -5
  30. package/src/framework/param-deps.js +7 -2
  31. package/src/index.js +1 -0
  32. package/src/parts/assets/relief-demo.png +0 -0
  33. package/src/parts/relief.js +84 -0
  34. package/src/relief-worker.js +3 -0
  35. package/src/testing/manifold.js +7 -1
  36. package/src/testing/occt.js +4 -1
  37. package/types/index.d.ts +13 -0
  38. package/types/kernel.d.ts +32 -0
  39. package/types/part.d.ts +21 -0
@@ -0,0 +1,164 @@
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
+
21
+ function el(tag, className, text) {
22
+ const node = document.createElement(tag);
23
+ if (className) node.className = className;
24
+ if (text != null) node.textContent = text;
25
+ return node;
26
+ }
27
+
28
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
29
+
30
+ // A URL source → its filename, for a label with no catalog to ask. A byte
31
+ // value has no filename — callers check `isBytes` first and never reach this
32
+ // for one. Not a source to fetch, never a source to warn about: `isNoImageSource`
33
+ // values (unset/"") read as "No image" rather than a broken link.
34
+ export function imageLabel(source) {
35
+ if (typeof source !== "string" || !source) return "No image";
36
+ let path = source;
37
+ try { path = new URL(source).pathname; } catch { /* not a URL — use the raw string */ }
38
+ const file = path.split("/").filter(Boolean).pop();
39
+ return file || source;
40
+ }
41
+
42
+ // Point (or unpoint) the live preview. A byte-valued param has no URL to hand
43
+ // the browser, so the image stays hidden rather than trying to render it or
44
+ // showing a broken-image glyph — same rule an empty/unset value gets. `onerror`
45
+ // covers the other broken-image case: a URL that 404s or that CORS refuses.
46
+ function paintPreview(img, source) {
47
+ if (typeof source === "string" && source) {
48
+ img.hidden = false;
49
+ img.src = source;
50
+ } else {
51
+ img.hidden = true;
52
+ img.removeAttribute("src");
53
+ }
54
+ }
55
+
56
+ export function makeImage(node, params, { onChange, onCommit, info, imageCatalog } = {}) {
57
+ const allow = Array.isArray(node.allow) && node.allow.length ? node.allow : IMAGE_ALLOW_DEFAULT;
58
+ const wrap = el("div", "slider");
59
+ const row = el("div", "row");
60
+ const label = el("label", "", node.label ?? node.key);
61
+ attachInfo(label, node.description, info);
62
+ row.append(label);
63
+ wrap.append(row);
64
+
65
+ const preview = document.createElement("img");
66
+ preview.className = "image-preview";
67
+ preview.alt = "";
68
+ preview.hidden = true;
69
+ // A URL that fails to load (404, CORS, revoked link) must degrade to hidden,
70
+ // not the browser's broken-image glyph.
71
+ preview.addEventListener("error", () => { preview.hidden = true; });
72
+ wrap.append(preview);
73
+
74
+ if (!imageCatalog) {
75
+ // Degraded path: a URL field. Unlike `text`, it does NOT write on every
76
+ // keystroke — a half-typed URL is a guaranteed failed fetch, and the
77
+ // rebuild loop would chase every one of them.
78
+ const field = document.createElement("input");
79
+ field.type = "text";
80
+ field.className = "text-input";
81
+ const paintField = () => {
82
+ const v = params[node.key];
83
+ // Bytes cannot round-trip through a text field — `String(arrayBuffer)`
84
+ // is "[object ArrayBuffer]", not a value anyone typed. Show an honest
85
+ // placeholder instead of that, and leave the field free to type a
86
+ // replacement URL over it.
87
+ field.value = isBytes(v) ? "" : String(v ?? "");
88
+ field.placeholder = isBytes(v) ? "Uploaded image" : "";
89
+ field.classList.remove("warn");
90
+ paintPreview(preview, v);
91
+ };
92
+ field.addEventListener("change", () => {
93
+ if (!imageSourceAllowed(field.value, allow)) { field.classList.add("warn"); return; }
94
+ field.classList.remove("warn");
95
+ params[node.key] = field.value;
96
+ onChange?.();
97
+ onCommit?.();
98
+ });
99
+ paintField();
100
+ wrap.append(field);
101
+ return { el: wrap, sync: paintField };
102
+ }
103
+
104
+ const btn = el("button", "image-btn");
105
+ btn.type = "button";
106
+ const thumb = document.createElement("img");
107
+ thumb.className = "image-btn-thumb";
108
+ thumb.alt = "";
109
+ thumb.hidden = true;
110
+ thumb.addEventListener("error", () => { thumb.hidden = true; });
111
+ const iname = el("span", "iname");
112
+ btn.append(thumb, iname);
113
+ btn.insertAdjacentHTML("beforeend",
114
+ '<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>');
115
+ wrap.append(btn);
116
+
117
+ // The value alone cannot describe a byte-valued source, and even a URL's
118
+ // filename is a guess a catalog can improve on. `describe` is optional and
119
+ // may be async, so the label is painted twice: an immediate honest guess,
120
+ // then the catalog's answer when it lands (same two-pass shape as font.js's
121
+ // `paint`, `paintSeq` included so a stale describe() can't win a race
122
+ // against a newer one).
123
+ let paintSeq = 0;
124
+ const paint = () => {
125
+ const src = params[node.key];
126
+ const seq = ++paintSeq;
127
+ paintPreview(preview, src);
128
+ paintPreview(thumb, src);
129
+ const show = ({ label: text, width, height }) => {
130
+ if (seq !== paintSeq) return; // a newer paint already won
131
+ iname.textContent = width && height ? `${text} (${width}×${height})` : text;
132
+ };
133
+ show(isBytes(src) ? { label: "Uploaded image" } : { label: imageLabel(src) });
134
+ if (typeof imageCatalog.describe !== "function") return;
135
+ Promise.resolve()
136
+ .then(() => imageCatalog.describe(src))
137
+ .then((d) => {
138
+ if (!d) return;
139
+ show({ label: d.label ?? (isBytes(src) ? "Uploaded image" : imageLabel(src)), width: d.width, height: d.height });
140
+ })
141
+ .catch(() => { /* a failed lookup keeps the immediate label */ });
142
+ };
143
+ paint();
144
+
145
+ // The picker registers itself through setImagePicker (see below); with no
146
+ // picker in the bundle the button is inert rather than broken.
147
+ //
148
+ // The handle is kept because the picker is a TAKEOVER: it appends itself to
149
+ // the rail, outside the panel root, so tearing the panel down does not take it
150
+ // with it. Without dispose() the element — and the `document` keydown listener
151
+ // that only close() unhooks — would outlive the panel holding a stale `params`.
152
+ let picker = null;
153
+ btn.addEventListener("click", () => {
154
+ picker = openImagePicker?.({ node, params, allow, imageCatalog, anchor: wrap, onPicked: () => { paint(); onChange?.(); onCommit?.(); } }) ?? null;
155
+ });
156
+
157
+ return { el: wrap, sync: paint, dispose: () => { picker?.close(); picker = null; } };
158
+ }
159
+
160
+ // Assigned by image-picker.js, which widgets/index.js imports for the side
161
+ // effect. Kept as a mutable binding rather than a static import so this file
162
+ // stays usable — and testable — without dragging the whole picker in.
163
+ export let openImagePicker = null;
164
+ export const setImagePicker = (fn) => { openImagePicker = fn; };
@@ -5,12 +5,15 @@ 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
+ // Side-effect imports: font-picker.js / image-picker.js call setFontPicker() /
10
+ // setImagePicker() at module scope, so each widget's button finds a picker to
11
+ // open. They live HERE and not in font.js/image.js because the dependency has
12
+ // to run picker widget and never back those files must stay importable
13
+ // (and testable) without dragging the whole DOM-heavy picker in. See the note
14
+ // at the bottom of font.js.
13
15
  import "../font-picker.js";
16
+ import "../image-picker.js";
14
17
 
15
18
  export const WIDGET_FACTORIES = {
16
19
  slider: makeNumeric,
@@ -21,4 +24,5 @@ export const WIDGET_FACTORIES = {
21
24
  select: makeSelect,
22
25
  radio: makeRadio,
23
26
  font: makeFont,
27
+ image: makeImage,
24
28
  };
@@ -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
  }
package/src/index.js CHANGED
@@ -4,3 +4,4 @@
4
4
  // Part build functions import geometry helpers from "partforge/geometry" instead.
5
5
  export { mount } from "./framework/index.js";
6
6
  export { viewSubParts } from "./framework/part-model.js";
7
+ export { imageToPng } from "./framework/image-ingest.js";
Binary file
@@ -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
  }
package/types/index.d.ts CHANGED
@@ -454,3 +454,16 @@ export function viewSubParts(
454
454
  view: string,
455
455
  params: Record<string, ParamValue>,
456
456
  ): string[];
457
+
458
+ export interface ImageToPngOptions {
459
+ /** Long-edge cap in px; an image already under this is not upscaled. Default 1024. */
460
+ maxSize?: number;
461
+ }
462
+
463
+ /**
464
+ * Convert any image the browser can decode into a PNG `Blob`, downsampling the
465
+ * long edge to `maxSize` on the way. Main-thread only (uses `createImageBitmap`
466
+ * and a canvas) — for a host normalising uploads before storing them, since a
467
+ * part's `images` field decodes PNG only. Never call from a part's `build`.
468
+ */
469
+ export function imageToPng(fileOrBlob: Blob | File, options?: ImageToPngOptions): Promise<Blob>;
package/types/kernel.d.ts CHANGED
@@ -481,6 +481,31 @@ export interface Vector2dOptions {
481
481
  /** Anything `k.hull`/`k.hullChain` accepts as one input. */
482
482
  export type HullInput = Shape2D | Contour;
483
483
 
484
+ /** An inline depth-map grid, row-major, 0..65535 per sample. */
485
+ export interface HeightfieldGrid {
486
+ width: number;
487
+ height: number;
488
+ data: Uint16Array;
489
+ }
490
+
491
+ /** k.heightfield — a depth map as a relief solid. */
492
+ export interface HeightfieldOptions {
493
+ w: number;
494
+ d: number;
495
+ /** Slab thickness under the relief (mm, > 0). Default 1. */
496
+ base?: number;
497
+ /** Relief height above `base` at a full-scale (1.0) sample. Default 1. */
498
+ maxZ?: number;
499
+ /** Sample spacing (mm, > 0); clamped to a vertex budget with a build warning. Default 0.5. */
500
+ pitch?: number;
501
+ /** Flip sampled value as `1 - v`, applied after `range`. */
502
+ invert?: boolean;
503
+ /** Remap with clamped ends: `range[0]` -> 0, `range[1]` -> 1. Default [0, 1]. */
504
+ range?: [number, number];
505
+ /** Footprint placement in XY only — the base always sits at z = 0. Default "center". */
506
+ origin?: "center" | "corner";
507
+ }
508
+
484
509
  // --- the kernel -------------------------------------------------------------
485
510
 
486
511
  /**
@@ -541,6 +566,13 @@ export interface GeometryKernel {
541
566
  * side-channel (not a part author's calling surface).
542
567
  */
543
568
  import(name: string): Solid;
569
+ /**
570
+ * A depth map as a relief solid. `nameOrGrid` is a name declared in the part's
571
+ * `images` field, or an inline grid — the name path is registered pre-build by
572
+ * the framework via the underscore-prefixed `_registerImage` side-channel (not
573
+ * a part author's calling surface).
574
+ */
575
+ heightfield(nameOrGrid: string | HeightfieldGrid, opts: HeightfieldOptions): Solid;
544
576
 
545
577
  // Backend-optional: the sub-part cache brackets and WASM lifetime hooks. Every
546
578
  // framework caller reaches these through `?.`, so a third-party backend may
package/types/part.d.ts CHANGED
@@ -257,6 +257,21 @@ export type FontSource =
257
257
 
258
258
  type FontSourceValue = string | ArrayBuffer | ArrayBufferView | { default: string };
259
259
 
260
+ // --- images -----------------------------------------------------------------
261
+
262
+ /**
263
+ * One entry of a part's `images` map: raw bytes, a URL string, or a thunk
264
+ * returning either (a Vite `() => import("./x.png")` resolves to
265
+ * `{ default: url }`). Resolved to a decoded luminance grid before the
266
+ * synchronous `build` runs — the source `k.heightfield()` samples.
267
+ */
268
+ export type ImageSource =
269
+ | string
270
+ | ArrayBuffer
271
+ | ArrayBufferView
272
+ | (() => ImageSourceValue | Promise<ImageSourceValue>);
273
+
274
+ type ImageSourceValue = string | ArrayBuffer | ArrayBufferView | { default: string };
260
275
  // --- imports and vectors ------------------------------------------------------
261
276
 
262
277
  /**
@@ -604,6 +619,12 @@ export interface PartDefinition<P = ResolvedParams, D = Derived> {
604
619
  defaults: Defaults;
605
620
  /** Outline fonts a part's `k.text2d()` calls need, as `{ name: source }`. */
606
621
  fonts?: Record<string, FontSource>;
622
+ /**
623
+ * Depth-map images a part's `k.heightfield()` calls need, as
624
+ * `{ name: source }` — or a function of the resolved params returning that
625
+ * map, which is what lets a `type: "image"` control drive the source.
626
+ */
627
+ images?: Record<string, ImageSource> | ((p: P) => Record<string, ImageSource>);
607
628
  /** STEP/STL/3MF files a part's `k.import()` calls need, as `{ name: source }`. */
608
629
  imports?: Record<string, ImportSource>;
609
630
  /** Vector artwork a part's `k.vector2d()` calls place, as `{ name: source }`. */