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
@@ -19,6 +19,7 @@ import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
19
19
  import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
20
20
  import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
21
21
  import { KernelCapabilityError } from "./errors.js";
22
+ import { heightfieldMesh, hashGridData } from "./heightfield.js";
22
23
 
23
24
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
24
25
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
@@ -89,6 +90,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
89
90
  // registers pre-build (ensureImports, Task 8). Kernel-lifetime, NOT tracked/T()'d:
90
91
  // these masters must survive cleanup() and be read again on every subsequent build.
91
92
  const imports = new Map();
93
+ // name -> { digest, width, height, data } — depth-map grids the framework registers
94
+ // pre-build via `_registerImage` (ensureImages, Task 4). Kernel-lifetime like
95
+ // `imports` above: plain data, not WASM, so there is nothing to T()/dispose.
96
+ const images = new Map();
92
97
  // Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
93
98
  // tracks the result, and returns the triple the cache needs to pin/dispose it.
94
99
  const cached = (hash, computeM) => cache.lookup(hash, () => {
@@ -614,6 +619,45 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
614
619
  // X and drives Y to 0, squishing the top to a line). Broadcast for a uniform taper.
615
620
  return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
616
621
  }),
622
+ // Height-map relief. The grid → triangle conversion is a pure leaf shared with
623
+ // the OCCT backend (heightfield.js), so both kernels build from identical
624
+ // triangle data. `manifoldFromMesh`'s output is NOT self-tracked (see its own
625
+ // header comment — "caller tracks `out`") so, unlike `import`'s master, this
626
+ // build wraps it in T() itself: a heightfield solid is an ordinary per-build
627
+ // result, not a kernel-lifetime master.
628
+ heightfield: (src, opts = {}) => {
629
+ const grid = typeof src === "string" ? images.get(src) : src;
630
+ if (!grid) throw new Error(`heightfield: unknown image "${src}" — declare it in the part's \`images\` field`);
631
+ const build = () => {
632
+ const { positions, indices, warnings } = heightfieldMesh(grid, opts);
633
+ // Only runs on a cache MISS for a registered image (every call for an
634
+ // inline grid, which always takes the bypass below). A repeat build of
635
+ // the same registered image at the same options is a cache HIT and never
636
+ // re-enters this closure, so a pitch-clamp warning is NOT re-emitted on
637
+ // a warm rebuild — intentional dedup (same call, same warning, once),
638
+ // not a missed re-warn.
639
+ for (const w of warnings) recordWarning(w);
640
+ return T(manifoldFromMesh(wasm, positions, indices));
641
+ };
642
+ // Only a registered image carries a content digest to key the cache on. An
643
+ // inline {width,height,data} grid has no identity of its own — keying it on a
644
+ // literal string like "inline" would let two DIFFERENT inline grids at the
645
+ // same options collide on the same cache key, and the second call would
646
+ // silently get back the FIRST call's solid. Skip the cache for those; the
647
+ // inline path is the test/low-level path and isn't performance-sensitive.
648
+ // (The returned solid still gets a real content-fingerprint hash below, so a
649
+ // downstream union/cut composing two different inline heightfields doesn't
650
+ // inherit the same collision risk one level up.)
651
+ if (typeof src !== "string") {
652
+ return wrap(build(), h("heightfield-inline", grid.width, grid.height, hashGridData(grid.data),
653
+ opts.w, opts.d, opts.base, opts.maxZ, opts.pitch, opts.invert, opts.range, opts.origin));
654
+ }
655
+ return cached(
656
+ h("heightfield", grid.digest, opts.w, opts.d, opts.base, opts.maxZ,
657
+ opts.pitch, opts.invert, opts.range, opts.origin),
658
+ build,
659
+ );
660
+ },
617
661
  // Polygon-with-holes extrude in one op: even/odd fill turns the extra contours into
618
662
  // holes regardless of their winding (outer + holes, no per-hole boolean cut).
619
663
  // A Shape2D `profile` (curve-native, possibly multi-region) materializes through
@@ -716,6 +760,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
716
760
  // Registration memo: undefined for an error entry, so a later registration with
717
761
  // the same digest can upgrade it rather than being treated as a no-op repeat.
718
762
  _importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
763
+ // Depth-map grids, registered pre-build by the framework via `_registerImage`
764
+ // (ensureImages, Task 4). Unlike imports there is no per-format error entry:
765
+ // every backend can consume a normalized grid, so registration never fails.
766
+ _registerImage: ({ name, digest, width, height, data }) => {
767
+ images.set(name, { digest, width, height, data });
768
+ },
769
+ _imageDigest: (name) => images.get(name)?.digest,
770
+ // Drop every registered name NOT in `keep` (a Set) — the images-map twin of
771
+ // fonts' kernel._fonts prune in jobs.js. `images` is keyed on the part's
772
+ // declared name (e.g. "relief"), not on content, so without this a name
773
+ // the user cleared — or a different part that reuses the same key across a
774
+ // worker-rebind — would silently keep resolving to a prior build's grid.
775
+ // A method rather than exposing `images` itself: jobs.js only ever needs
776
+ // "keep exactly this set", never raw Map access.
777
+ _pruneImages: (keep) => {
778
+ for (const name of [...images.keys()]) if (!keep.has(name)) images.delete(name);
779
+ },
719
780
  _acceptsMesh: true,
720
781
  shape2d,
721
782
  // Backend-internal region adapter: the shared native engine (contour-offset.js)
@@ -33,8 +33,80 @@ import { createSolidCache } from "./solid-cache.js";
33
33
  import { composePose, transformPositions, rotateNormals } from "./pose.js";
34
34
  import { filterBrepEdges } from "./brep-edges.js";
35
35
  import { meshToStl } from "./mesh-stl.js";
36
+ import { heightfieldMesh, hashGridData } from "./heightfield.js";
36
37
  const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.25 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
37
38
 
39
+ // Heightfield sew-time warning threshold, in triangles. Measured by the plan's
40
+ // Task 1 probe against this exact sequence: 24,182 triangles sewed in 8.2-8.6s
41
+ // (the largest size that stayed under ~10s), 29,750 took 10.4-10.6s. 24000 is a
42
+ // round number just under the last confirmed-under-10s measurement, leaving
43
+ // headroom for slower hardware than the probe ran on.
44
+ const STEP_TRIANGLE_WARN = 24000;
45
+ // STEP bytes per input triangle, from the same probe: 2.3-2.6 KB/triangle across
46
+ // 7,670 -> 81,590 triangles (~37 STEP entities per triangle), linear in triangle
47
+ // count. Only used to put an order-of-magnitude figure in the warning below.
48
+ const STEP_KB_PER_TRIANGLE = 2.4;
49
+ // Monotonic scratch-filename counter for the emscripten FS round trip in
50
+ // stlBufferToShape. A counter, not Date.now()/Math.random(): `build` must be a
51
+ // pure function of (k, p, d), and the name never reaches geometry or a cache key.
52
+ // Collision-free within a process, which is all that matters — the file is
53
+ // written and unlinked inside one synchronous call.
54
+ let stlSeq = 0;
55
+
56
+ // Binary STL ArrayBuffer -> replicad Solid, SYNCHRONOUSLY. This is replicad's own
57
+ // `importSTL` body with the `await blob.arrayBuffer()` removed (we already hold an
58
+ // ArrayBuffer from meshToStl) — verified against replicad 0.23.1 by the plan's
59
+ // Task 1 probe at every grid size up to 81,590 triangles. Synchronous matters:
60
+ // `build(k, p, d)` is a synchronous pure function, so a part author cannot await,
61
+ // and the Manifold backend's `heightfield` is synchronous too.
62
+ //
63
+ // `getOC`, `localGC` and `cast` are all public replicad exports. Every
64
+ // intermediate native object is registered with localGC's `r()` and freed by
65
+ // `gc()` in a `finally` — unconditional, unlike the real importSTL, which frees
66
+ // only on its two known exit paths and would leak if e.g. `Build()` threw.
67
+ // `cast()` hands back a fresh JS handle onto the same reference-counted OCCT
68
+ // TShape, so freeing `asSolid` does not invalidate the returned shape.
69
+ function stlBufferToShape(replicad, arrayBuffer) {
70
+ const oc = replicad.getOC();
71
+ const [r, gc] = replicad.localGC();
72
+ const fileName = `hf-${stlSeq++}.stl`;
73
+ try {
74
+ oc.FS.writeFile(`/${fileName}`, new Uint8Array(arrayBuffer));
75
+ const reader = r(new oc.StlAPI_Reader());
76
+ const readShape = r(new oc.TopoDS_Shell());
77
+ if (!reader.Read(readShape, fileName)) throw new Error("StlAPI_Reader rejected the mesh");
78
+ const shapeUpgrader = r(new oc.ShapeUpgrade_UnifySameDomain_2(readShape, true, true, false));
79
+ shapeUpgrader.Build();
80
+ const upgradedShape = r(shapeUpgrader.Shape());
81
+ // Watertightness gate. MakeSolid will happily build a "solid" from an OPEN
82
+ // shell and report nothing wrong, which is the silent-corruption mode this
83
+ // guards. Two tempting alternatives were probed on the installed OCCT build
84
+ // and BOTH are inadequate — do not swap this for either:
85
+ // - `BRepBuilderAPI_MakeSolid.IsDone()` returns true on a freshly
86
+ // constructed maker before any Add(), and true for a holed shell. It
87
+ // reports "a maker exists", not "the shell closed".
88
+ // - A positive-volume check passes too: a 20,790-triangle shell with 40
89
+ // triangles removed still measured positive volume, only ~3% low.
90
+ // `BRep_Tool.IsClosed_1` is a topological free-edge pass with no geometry —
91
+ // measured at 0 ms (below timer resolution) against a 6.9 s sew. It must be
92
+ // applied to the UPGRADED SHELL, BEFORE MakeSolid: on the resulting solid it
93
+ // falls through to a never-set Closed() flag and answers false even when the
94
+ // shape is watertight. Throwing here lands in heightfield's catch, so the
95
+ // author gets the triangle-count-and-`pitch` message, not a raw OCCT error.
96
+ if (!oc.BRep_Tool.IsClosed_1(upgradedShape)) throw new Error("the sewn shell is not watertight (it has free edges)");
97
+ const solidSTL = r(new oc.BRepBuilderAPI_MakeSolid_1());
98
+ // Shell_1 returns a distinct embind handle with its own .delete(), so it is
99
+ // registered like every other intermediate. Safe: Add() copies the shell into
100
+ // the builder before gc() runs.
101
+ solidSTL.Add(r(oc.TopoDS.Shell_1(upgradedShape)));
102
+ const asSolid = r(solidSTL.Solid());
103
+ return replicad.cast(asSolid);
104
+ } finally {
105
+ try { oc.FS.unlink(`/${fileName}`); } catch { /* writeFile may never have run */ }
106
+ gc();
107
+ }
108
+ }
109
+
38
110
  export function createOcctKernel(replicad) {
39
111
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
40
112
  loft, draw, exportSTEP, importSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
@@ -53,6 +125,11 @@ export function createOcctKernel(replicad) {
53
125
  // registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
54
126
  // solid cache: imports are the framework's own memo, keyed by name+digest).
55
127
  const imports = new Map();
128
+ // name -> { digest, width, height, data } — depth-map grids the framework
129
+ // registers pre-build via `_registerImage` (ensureImages). Kernel-lifetime like
130
+ // `imports` above, and plain data rather than OCCT objects, so there is nothing
131
+ // to free.
132
+ const images = new Map();
56
133
 
57
134
  const cache = createSolidCache();
58
135
  // Boundary ops route through cache.lookup. pin is unused here (no cleanup() —
@@ -486,11 +563,69 @@ export function createOcctKernel(replicad) {
486
563
  });
487
564
  };
488
565
 
566
+ // Height-map relief. The grid -> triangle conversion is the SAME pure leaf the
567
+ // Manifold backend uses (heightfield.js), so both kernels build from identical
568
+ // triangle data; the difference is only what happens next. Here the triangles go
569
+ // out through meshToStl and back in through OCCT's own mesh->B-rep path
570
+ // (StlAPI_Reader + ShapeUpgrade_UnifySameDomain + MakeSolid — see
571
+ // stlBufferToShape above). The result booleans, fillets and exports to STEP like
572
+ // any other B-rep shape; its surface is triangulated rather than analytic, which
573
+ // is the accepted trade for STEP support on a depth map.
574
+ const heightfield = (src, opts = {}) => {
575
+ const grid = typeof src === "string" ? images.get(src) : src;
576
+ if (!grid) throw new Error(`heightfield: unknown image "${src}" — declare it in the part's \`images\` field`);
577
+ const build = (key) => {
578
+ const { positions, indices, warnings } = heightfieldMesh(grid, opts);
579
+ // Only runs on a cache MISS for a registered image (and on every call for an
580
+ // inline grid, which always takes the bypass below), so a pitch clamp is not
581
+ // re-warned on a warm rebuild — the same intentional dedup as Manifold's.
582
+ for (const w of warnings) recordWarning(w);
583
+ const tris = indices.length / 3;
584
+ // ONE warning carrying both facts, because sew time and STEP size are both
585
+ // linear in triangle count — a second threshold would buy nothing. The size
586
+ // figure is deliberately hedged: ShapeUpgrade_UnifySameDomain merges only
587
+ // genuinely coplanar faces, so a flat relief compresses far better than a
588
+ // high-frequency one at the same triangle count.
589
+ if (tris > STEP_TRIANGLE_WARN) {
590
+ const mb = (tris * STEP_KB_PER_TRIANGLE) / 1024;
591
+ recordWarning(`heightfield: ${tris} triangles on the B-rep backend — sewing is slow above ${STEP_TRIANGLE_WARN} and STEP export will be roughly ${mb.toFixed(0)} MB (a content-dependent estimate: only coplanar faces merge, so a flat relief exports far smaller than a high-frequency one). Raise \`pitch\` to reduce the triangle count.`);
592
+ }
593
+ // Written OUTSIDE the try below: an allocation failure here is a mesh-size
594
+ // problem, not a sewing failure, and must not be reported as one.
595
+ const stl = meshToStl(positions, indices);
596
+ let shape;
597
+ try {
598
+ shape = stlBufferToShape(replicad, stl);
599
+ } catch (e) {
600
+ throw new Error(
601
+ `heightfield: could not sew ${tris} triangles into a B-rep solid (${e.message}). ` +
602
+ "Raise `pitch` to reduce the triangle count, or build this sub-part on the Manifold backend.",
603
+ );
604
+ }
605
+ return wrap(shape, [], key);
606
+ };
607
+ // Only a REGISTERED image carries a content digest to key the cache on. An
608
+ // inline {width,height,data} grid has no identity of its own, so keying it on
609
+ // a literal would let two DIFFERENT inline grids at the same options collide
610
+ // and hand the second caller the first one's solid. Bypass the cache for those
611
+ // — the inline path is the test/low-level path and isn't performance-sensitive
612
+ // — but still give the solid a real content fingerprint in its `_hash`, since
613
+ // that hash feeds DOWNSTREAM boolean cache keys (cut/union). Matches the
614
+ // Manifold backend exactly.
615
+ if (typeof src !== "string") {
616
+ return build(h("heightfield-inline", grid.width, grid.height, hashGridData(grid.data),
617
+ opts.w, opts.d, opts.base, opts.maxZ, opts.pitch, opts.invert, opts.range, opts.origin));
618
+ }
619
+ const key = h("heightfield", grid.digest, opts.w, opts.d, opts.base, opts.maxZ,
620
+ opts.pitch, opts.invert, opts.range, opts.origin);
621
+ return cached(key, () => build(key));
622
+ };
623
+
489
624
  const kernel = finishKernel({
490
625
  cylinder, // boredCylinder: the kernel front's default composition is exactly right here
491
626
  box: (min, max) => cached(h("box", min, max), () => wrap(makeBox(min, max), [], h("box", min, max))),
492
627
  roundedBox,
493
- prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube,
628
+ prism, extrude, revolve, loft: loftOp, sweep, helixSweptTube, heightfield,
494
629
  sphere: (r) => cached(h("sphere", r), () => wrap(makeSphere(r), [], h("sphere", r))),
495
630
  union: (solids) => {
496
631
  const key = h("union", solids.map((s) => s._hash));
@@ -536,6 +671,18 @@ export function createOcctKernel(replicad) {
536
671
  // Registration memo: undefined for an error entry, so a later registration with
537
672
  // the same digest can upgrade it rather than being treated as a no-op repeat.
538
673
  _importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
674
+ // Depth-map grids, registered pre-build by the framework via `_registerImage`
675
+ // (ensureImages). Unlike imports there is no per-format error entry: every
676
+ // backend can consume a normalized grid, so registration never fails.
677
+ _registerImage: ({ name, digest, width, height, data }) => {
678
+ images.set(name, { digest, width, height, data });
679
+ },
680
+ _imageDigest: (name) => images.get(name)?.digest,
681
+ // Drop every registered name NOT in `keep` (a Set) — see the identical
682
+ // comment on the Manifold backend's `_pruneImages` for why this exists.
683
+ _pruneImages: (keep) => {
684
+ for (const name of [...images.keys()]) if (!keep.has(name)) images.delete(name);
685
+ },
539
686
  _acceptsStep: true,
540
687
  beginSubPart: (name) => cache.begin(name),
541
688
  endSubPart: () => cache.end(),
@@ -281,6 +281,16 @@ export const KERNEL_OP_SPECS = {
281
281
  },
282
282
  // loftSmooth: range checks live in loft-smooth.js, next to the defaults they guard.
283
283
  loftSmooth: { toArgs: passThrough("loftSmooth", ["sections", "stations", "samples", "shading", "closed"], ["sections"]) },
284
+ // heightfield: (nameOrGrid, opts) is a two-positional call, not the options-object
285
+ // convention — toArgs only normalizes a bare single-arg call (kernel-front.js's
286
+ // wrapper invokes it as `toArgs(a[0], k._recordWarning)`, so the second
287
+ // parameter here is always the kernel's warning recorder, never a real `opts`
288
+ // — do not thread it through as one). The rest of the range/positivity checks
289
+ // (base, pitch) live in heightfield.js next to the defaults they guard, same
290
+ // split as loftSmooth above.
291
+ heightfield: { toArgs: (a) => [a, {}], check: (_src, o = {}) => {
292
+ if (!(o.w > 0) || !(o.d > 0)) throw new Error("heightfield: w and d must be positive");
293
+ } },
284
294
  roundedBox: { toArgs: roundedBoxArgs },
285
295
  roundedCylinder: { toArgs: roundedCylinderArgs },
286
296
  torus: { toArgs: torusArgs },
@@ -0,0 +1,107 @@
1
+ // Pure-JS PNG → luminance grid. Lives in the worker graph, so it must be DOM-free
2
+ // and node:-free: no createImageBitmap/OffscreenCanvas (browser-only), no pngjs
3
+ // (Node-only). One decoder in one place is what keeps the browser, the CLI and CI
4
+ // from disagreeing about geometry. Inflate comes from fflate, already in this
5
+ // closure via threemf-parse.js.
6
+ import { unzlibSync } from "fflate";
7
+
8
+ const SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
9
+ const U16 = 65535;
10
+
11
+ // Rec. 709 luma, the same weighting a viewer would show.
12
+ const luma = (r, g, b) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
13
+
14
+ function paeth(a, b, c) {
15
+ const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
16
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
17
+ }
18
+
19
+ export function decodePng(input) {
20
+ const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
21
+ for (let i = 0; i < 8; i++) if (u8[i] !== SIG[i]) throw new Error("decodePng: not a PNG (bad signature)");
22
+
23
+ const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
24
+ let off = 8, width = 0, height = 0, depth = 8, colorType = 6, interlace = 0;
25
+ let palette = null;
26
+ const idat = [];
27
+
28
+ while (off + 8 <= u8.length) {
29
+ const len = dv.getUint32(off);
30
+ const type = String.fromCharCode(u8[off + 4], u8[off + 5], u8[off + 6], u8[off + 7]);
31
+ const body = off + 8;
32
+ if (body + len > u8.length) throw new Error(`decodePng: truncated file (chunk ${type} runs past the end)`);
33
+ if (type === "IHDR") {
34
+ width = dv.getUint32(body); height = dv.getUint32(body + 4);
35
+ depth = u8[body + 8]; colorType = u8[body + 9]; interlace = u8[body + 12];
36
+ } else if (type === "PLTE") palette = u8.subarray(body, body + len);
37
+ // tRNS (alpha) is intentionally not parsed: ignoring alpha is correct for a
38
+ // depth map, and this chunk still falls through to the unconditional
39
+ // `off` advance below like any other chunk type we don't care about.
40
+ else if (type === "IDAT") idat.push(u8.subarray(body, body + len));
41
+ else if (type === "IEND") break;
42
+ off = body + len + 4; // + CRC
43
+ }
44
+
45
+ if (!width || !height) throw new Error("decodePng: truncated file (no IHDR)");
46
+ if (interlace) throw new Error("decodePng: interlaced (Adam7) PNGs are not supported — re-save without interlacing");
47
+
48
+ const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
49
+ if (!CHANNELS) throw new Error(`decodePng: unsupported colour type ${colorType}`);
50
+ if (colorType === 3 && !palette) throw new Error("decodePng: palette image with no PLTE chunk");
51
+
52
+ // Concatenate IDAT then inflate.
53
+ let total = 0; for (const c of idat) total += c.length;
54
+ if (!total) throw new Error("decodePng: truncated file (no IDAT)");
55
+ const z = new Uint8Array(total);
56
+ { let p = 0; for (const c of idat) { z.set(c, p); p += c.length; } }
57
+ const raw = unzlibSync(z);
58
+
59
+ const bpp = Math.max(1, (CHANNELS * depth) >> 3);
60
+ const rowBytes = Math.ceil((CHANNELS * depth * width) / 8);
61
+ if (raw.length < (rowBytes + 1) * height) throw new Error("decodePng: truncated file (short image data)");
62
+
63
+ // Un-filter in place, row by row.
64
+ const img = new Uint8Array(rowBytes * height);
65
+ for (let y = 0; y < height; y++) {
66
+ const ft = raw[y * (rowBytes + 1)];
67
+ const src = y * (rowBytes + 1) + 1;
68
+ const dst = y * rowBytes, up = dst - rowBytes;
69
+ for (let x = 0; x < rowBytes; x++) {
70
+ const v = raw[src + x];
71
+ const a = x >= bpp ? img[dst + x - bpp] : 0;
72
+ const b = y > 0 ? img[up + x] : 0;
73
+ const c = x >= bpp && y > 0 ? img[up + x - bpp] : 0;
74
+ img[dst + x] = (ft === 0 ? v : ft === 1 ? v + a : ft === 2 ? v + b
75
+ : ft === 3 ? v + ((a + b) >> 1) : v + paeth(a, b, c)) & 0xff;
76
+ }
77
+ }
78
+
79
+ // Read samples → luminance, scaled to 0..65535.
80
+ const out = new Uint16Array(width * height);
81
+ const maxIn = depth === 16 ? 65535 : (1 << depth) - 1;
82
+ const readSample = (row, i) => {
83
+ if (depth === 16) return (img[row + i * 2] << 8) | img[row + i * 2 + 1];
84
+ if (depth === 8) return img[row + i];
85
+ const per = 8 / depth, byte = img[row + ((i / per) | 0)];
86
+ const shift = 8 - depth * ((i % per) + 1);
87
+ return (byte >> shift) & maxIn;
88
+ };
89
+
90
+ for (let y = 0; y < height; y++) {
91
+ const row = y * rowBytes;
92
+ for (let x = 0; x < width; x++) {
93
+ let v;
94
+ if (colorType === 3) {
95
+ const idx = readSample(row, x) * 3;
96
+ v = luma(palette[idx], palette[idx + 1], palette[idx + 2]) / 255;
97
+ } else if (colorType === 0 || colorType === 4) {
98
+ v = readSample(row, x * CHANNELS) / maxIn;
99
+ } else {
100
+ const b0 = x * CHANNELS;
101
+ v = luma(readSample(row, b0), readSample(row, b0 + 1), readSample(row, b0 + 2)) / maxIn;
102
+ }
103
+ out[y * width + x] = Math.round(Math.min(Math.max(v, 0), 1) * U16);
104
+ }
105
+ }
106
+ return { width, height, data: out };
107
+ }
@@ -19,3 +19,99 @@ function fnv(s) {
19
19
  for (let i = 0; i < s.length; i++) { hsh ^= s.charCodeAt(i); hsh = Math.imul(hsh, 0x01000193); }
20
20
  return (hsh >>> 0).toString(36);
21
21
  }
22
+
23
+ // ── byte-aware JSON.stringify support ───────────────────────────────────────
24
+ // A shared home for param-deps.js's relevanceHash, backend-select.js's
25
+ // reroute-latch snapshot, and oracle/verify.js's memo signature — none of
26
+ // which should import one another's concerns. All three wholesale-hash a
27
+ // params object with JSON.stringify, and a byte-valued param — an
28
+ // ArrayBuffer, a SharedArrayBuffer, or a typed-array/DataView view
29
+ // (Buffer included — the shape a param-supplied image takes on the
30
+ // partforge-cloud sandbox path, which cannot fetch URLs and puts PNG bytes
31
+ // straight into a param, and the shape `fs.readFileSync` hands the CLI) —
32
+ // breaks JSON.stringify's default handling two different ways: an
33
+ // ArrayBuffer/SharedArrayBuffer has no enumerable own properties, so it
34
+ // always serializes as the same "{}" regardless of content (two different
35
+ // images collide on one cache/route key); a typed-array VIEW goes the other
36
+ // way, expanding to one JSON number per byte (tens of thousands of
37
+ // characters for a small PNG, on every hash). `byteAwareReplacer` is a
38
+ // JSON.stringify replacer — pass it as the second argument — that
39
+ // substitutes a stable content fingerprint for any of these shapes instead.
40
+ //
41
+ // MUST be a plain function, not an arrow: JSON.stringify calls the replacer
42
+ // with `this` bound to the holder (the object/array currently being
43
+ // serialized), and `this[key]` is how a replacer recovers the RAW value —
44
+ // before `Buffer.prototype.toJSON()` (or any other .toJSON) has already run
45
+ // on it. `value`, the second argument, has ALREADY been through that
46
+ // conversion: for a Node Buffer, `value` here is
47
+ // `{type:"Buffer",data:[...]}`, JSON.stringify's default expansion, one
48
+ // number per byte — exactly the pathology this function exists to avoid,
49
+ // reached through a different door. `this[key]` bypasses toJSON entirely, an
50
+ // ordinary property read of the holder returns whatever object is actually
51
+ // stored there.
52
+ export function byteAwareReplacer(key, value) {
53
+ const raw = this ? this[key] : value;
54
+ return isByteish(raw) ? fingerprintBytes(raw) : value;
55
+ }
56
+
57
+ // Object.prototype.toString.call(...), not `instanceof`/typed-array
58
+ // constructor checks: `instanceof ArrayBuffer` is false for an ArrayBuffer
59
+ // from another realm (a different vm context/iframe/worker — Node's `vm`
60
+ // module and Electron both make this reachable), and it silently falls back
61
+ // to the original bug ("{}") rather than throwing. The [[Class]] internal
62
+ // slot Object.prototype.toString reads is realm-independent.
63
+ // `ArrayBuffer.isView` itself IS already realm-independent (it checks for a
64
+ // [[ViewedArrayBuffer]] internal slot, not a prototype chain), so it is kept
65
+ // as-is for the view case — including a Buffer, which is a Uint8Array
66
+ // subclass and so already passes it.
67
+ function isByteish(v) {
68
+ if (v === null || typeof v !== "object") return false;
69
+ if (ArrayBuffer.isView(v)) return true;
70
+ const tag = Object.prototype.toString.call(v);
71
+ return tag === "[object ArrayBuffer]" || tag === "[object SharedArrayBuffer]";
72
+ }
73
+
74
+ // Memoized per buffer/view IDENTITY (not content) in a WeakMap, mirroring
75
+ // kernel-front.js's byteCache: the params object these callers hash is
76
+ // mutated in place and re-hashed on every regen (mesh-cache.js's hashFor, a
77
+ // backend-select reroute check), so re-walking megabytes of image bytes on
78
+ // every slider tick that touches some OTHER param would be its own
79
+ // regression. A given buffer is fingerprinted once and the string reused for
80
+ // as long as that identity survives.
81
+ const byteFingerprints = new WeakMap(); // ArrayBuffer|SharedArrayBuffer|ArrayBufferView -> fingerprint string
82
+
83
+ // Two independent FNV-1a streams over the same bytes (different offset
84
+ // bases, same prime), concatenated — a cheap ~64-bit fold. A single 32-bit
85
+ // fold (what h() uses, and what this used through fix round 1) collides at
86
+ // ~2⁻³² for two same-length buffers, which is fine for h(): that graph is
87
+ // rebuilt fresh every build, nothing accumulates. This fingerprint is
88
+ // different — it is retained in the mesh cache for a whole session — so a
89
+ // collision here means silently stale geometry, the exact failure class
90
+ // fix round 1 existed to eliminate. Widening makes it negligible instead of
91
+ // merely unlikely.
92
+ function fnvBytes64(u8) {
93
+ let h1 = 0x811c9dc5; // FNV-1a's standard 32-bit offset basis
94
+ let h2 = 0x9e3779b9; // a distinct, unrelated 32-bit constant (golden-ratio mix constant)
95
+ for (let i = 0; i < u8.length; i++) {
96
+ const b = u8[i];
97
+ h1 ^= b; h1 = Math.imul(h1, 0x01000193);
98
+ h2 ^= b; h2 = Math.imul(h2, 0x01000193);
99
+ }
100
+ return (h1 >>> 0).toString(36) + (h2 >>> 0).toString(36);
101
+ }
102
+
103
+ function fingerprintBytes(value) {
104
+ let cached = byteFingerprints.get(value);
105
+ if (cached !== undefined) return cached;
106
+ const u8 = ArrayBuffer.isView(value)
107
+ ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
108
+ : new Uint8Array(value); // ArrayBuffer or SharedArrayBuffer
109
+ // "bytes:" prefix plus the raw byte length folded in alongside the ~64-bit
110
+ // digest. No ordinary control value is a string shaped exactly like this
111
+ // (the panel's own control types are numbers/booleans/strings/enums), so
112
+ // this fingerprint only ever competes with OTHER buffers' fingerprints,
113
+ // not a genuine string param's value.
114
+ cached = `bytes:${fnvBytes64(u8)}:${u8.length}`;
115
+ byteFingerprints.set(value, cached);
116
+ return cached;
117
+ }
@@ -0,0 +1,41 @@
1
+ // MAIN-THREAD ONLY. Converts any image the browser can decode into a PNG a part's
2
+ // `images` field can consume, downsampling on the way.
3
+ //
4
+ // This file uses createImageBitmap and a canvas, so it must NEVER be reachable
5
+ // from the geometry worker's import closure — test/worker-layering.test.js
6
+ // enforces that. It is exported from src/index.js, the DOM entry documented as
7
+ // one a part's `build` must never import.
8
+ //
9
+ // Why PNG and not the source format: core decodes PNG only, in pure JS, so one
10
+ // decoder produces the geometry in the browser, the CLI and CI alike. Converting
11
+ // once at ingest keeps that single decoder authoritative — the browser's codec
12
+ // output is baked into an immutable PNG rather than racing ours at build time.
13
+ //
14
+ // Why downsample instead of switching to JPEG: pitch caps useful resolution
15
+ // anyway (a 60mm plate at 0.3mm pitch samples 200x200), and JPEG is 8-bit and
16
+ // DCT-ringing — in a depth map those are geometric artifacts, height terracing
17
+ // and 8x8 block bumps, not cosmetic ones.
18
+
19
+ export async function imageToPng(fileOrBlob, { maxSize = 1024 } = {}) {
20
+ const bmp = await createImageBitmap(fileOrBlob);
21
+ try {
22
+ const scale = Math.min(1, maxSize / Math.max(bmp.width, bmp.height));
23
+ const w = Math.max(1, Math.round(bmp.width * scale));
24
+ const h = Math.max(1, Math.round(bmp.height * scale));
25
+
26
+ if (typeof OffscreenCanvas === "function") {
27
+ const c = new OffscreenCanvas(w, h);
28
+ c.getContext("2d").drawImage(bmp, 0, 0, w, h);
29
+ return await c.convertToBlob({ type: "image/png" });
30
+ }
31
+ // Safari and older engines: fall back to a detached <canvas>.
32
+ const c = document.createElement("canvas");
33
+ c.width = w;
34
+ c.height = h;
35
+ c.getContext("2d").drawImage(bmp, 0, 0, w, h);
36
+ return await new Promise((res, rej) =>
37
+ c.toBlob((b) => (b ? res(b) : rej(new Error("imageToPng: canvas encoding failed"))), "image/png"));
38
+ } finally {
39
+ bmp.close?.();
40
+ }
41
+ }
@@ -0,0 +1,72 @@
1
+ // What a PARAM-supplied image source may be. Author-declared `images` sources are
2
+ // code and get no restriction; this file exists only for the other case — a
3
+ // value that arrived in `params`.
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.
13
+ //
14
+ // DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
15
+
16
+ export const IMAGE_ALLOW_DEFAULT = ["https"];
17
+
18
+ const ASSET_SCHEME = "pfc-asset:";
19
+
20
+ // The "unset" image source. An empty value declares NO image — the documented
21
+ // way to leave a relief off; the op itself has no fallback, so a build() that
22
+ // still calls k.heightfield for an unset name gets the ordinary unknown-image
23
+ // throw. Never a source to fetch, and never a source to refuse.
24
+ export const isNoImageSource = (v) => v === undefined || v === null || v === "";
25
+
26
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
27
+
28
+ // Parse once; an unparseable string is refused rather than guessed at.
29
+ function parse(source) {
30
+ try { return new URL(source); } catch { return null; }
31
+ }
32
+
33
+ export function imageSourceAllowed(source, allow = IMAGE_ALLOW_DEFAULT) {
34
+ if (isBytes(source)) return true; // see the file header — bytes always bypass the allow check
35
+ if (typeof source !== "string") return false;
36
+ const u = parse(source);
37
+ if (!u) return false;
38
+ for (const kind of allow) {
39
+ // hostname/protocol, never a substring of the raw string: a URL merely
40
+ // CONTAINING "pfc-asset://" (e.g. in its path) must not pass, and neither
41
+ // must a lookalike host like `pfc-asset.evil.test` — see font-source.js's
42
+ // header comment, which explains the two bugs this rule has already
43
+ // caused when a check compared the raw string instead of the parsed URL.
44
+ if (kind === "https" && u.protocol === "https:") return true;
45
+ if (kind === "asset" && u.protocol === ASSET_SCHEME) return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ // paramKey → allow list, for every `type: "image"` control in the authored tree,
51
+ // new-shape (`controls`, including nested groups) and legacy-shape
52
+ // (`advanced`/`toggles`/`features`, where panel/legacy.js desugars `control:` to
53
+ // `type:`). Missing the legacy arrays would leave such a control silently
54
+ // unrestricted. Tolerant of any array being absent or malformed — it must never
55
+ // throw on an existing part.
56
+ export function imageControlAllows(part) {
57
+ const out = new Map();
58
+ const visit = (nodes) => {
59
+ for (const n of nodes ?? []) {
60
+ if (!n || typeof n !== "object") continue;
61
+ if (Array.isArray(n.controls)) visit(n.controls);
62
+ if (Array.isArray(n.advanced)) visit(n.advanced);
63
+ if (Array.isArray(n.toggles)) visit(n.toggles);
64
+ if (Array.isArray(n.features)) visit(n.features);
65
+ if ((n.type === "image" || n.control === "image") && typeof n.key === "string") {
66
+ out.set(n.key, Array.isArray(n.allow) && n.allow.length ? n.allow : IMAGE_ALLOW_DEFAULT);
67
+ }
68
+ }
69
+ };
70
+ visit(part?.parameters);
71
+ return out;
72
+ }