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
@@ -673,7 +673,7 @@ between the Manifold preview and the OCCT STEP export.
673
673
 
674
674
  - **Symptom:** `vector2d: "` followed by the declared `vectors` name and a validation complaint — a bad `format` or `version`, a contour with no `kind` or an unknown one, a malformed `"path"` contour or segment (missing `start`, too few segments, an `arc` with no `through`, a `cubic` missing `c1`/`c2`, a non-numeric coordinate), a primitive with a bad `center`/`r`/`width`/`height`, a shape that is neither a region array nor a `{ role, regions }` object, an unknown `role`, a `bbox` that disagrees with the geometry, or (a different message, same `vector2d: "<name>"` lead) `vector2d: "<name>" is not valid JSON — <parse error>` — thrown while resolving a part's `vectors`, before `build` even runs.
675
675
  - **Cause:** The stored document isn't a well-formed `partforge-vector` file. The single most common case for the "is not valid JSON" variant: `vectors` points at the raw `.svg` file instead of an ingested `.vector.json` — an SVG document is not JSON at all, so it fails to parse before validation ever gets a chance to name a more specific problem.
676
- - **Fix:** If the message says "is not valid JSON," check the source points at the ingested `<name>.vector.json`, not the original `.svg` — re-ingest with `partforge/ingest` (or `node scripts/ingest-svg.mjs <file.svg>` in this repo) if you don't have it yet. Otherwise the message names the shape, the 1-indexed region, the role (`outer` / `hole n`), and where applicable the 1-indexed segment, so the fix is a single edit. Several specific cases have their own entries below (vector-units-missing, vector-stale-regions-array, vector-rect-radius-too-large). See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) for the full schema and what each field means.
676
+ - **Fix:** If the message says "is not valid JSON," check the source points at the ingested `<name>.vector.json`, not the original `.svg` — re-ingest with `partforge/ingest` (or `npx partforge ingest <file.svg> --out <file.vector.json>`) if you don't have it yet. Otherwise the message names the shape, the 1-indexed region, the role (`outer` / `hole n`), and where applicable the 1-indexed segment, so the fix is a single edit. Several specific cases have their own entries below (vector-units-missing, vector-stale-regions-array, vector-rect-radius-too-large). See [docs/VECTOR-FORMAT.md](VECTOR-FORMAT.md) for the full schema and what each field means.
677
677
 
678
678
  ## vector-units-missing
679
679
 
@@ -761,6 +761,30 @@ between the Manifold preview and the OCCT STEP export.
761
761
  - **Cause:** The source contains `Math.random`, `Date.now`, `performance.now`, or an argless `new Date()`. A build must be a pure function of `(k, p, d)`; the memoizing kernel hashes inputs, so an impure value silently serves stale geometry (see impure-build-stale-preview, above). The behavioral lint probe catches impurity only when it changes the recorded call sequence between two probe runs; a value stable within one pass escapes it, which is why the source scan warns on the token itself.
762
762
  - **Fix:** Replace the impure value with a parameter or a `derive()` output. `new Date(0)` and other argument-carrying forms are deterministic and not flagged; only `.js`/`.mjs` files are scanned, so the same words in a `README.md` are prose. One finding is emitted per (file, token) pair, carrying the occurrence count and the first occurrence's line. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Caching & determinism".
763
763
 
764
+ ## heightfield-unknown-image
765
+
766
+ - **Symptom:** `heightfield: unknown image "<name>"` — declare it in the part's `images` field — thrown from a build calling `k.heightfield(name, opts)`, identical text on both backends.
767
+ - **Cause:** `k.heightfield` was called with a string name that isn't a key in the part's `images` field (or `images` is missing entirely) — a typo, or the declaration was never added. Same failure shape as `k.import`'s unknown-name error and `text2d`'s unknown-font error.
768
+ - **Fix:** Add the name to `images`, or fix the typo. `npx partforge lint <part>` catches this statically when `images` is a static object (rule `heightfield-unknown-image` — a function-form `images` has no statically-knowable keys, so lint skips it there and this throw remains the runtime authority). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Linting" (Rule catalog → Image controls).
769
+
770
+ ## images-only-png-supported
771
+
772
+ - **Symptom:** `images: only PNG is supported — convert with imageToPng() from "partforge/ingest" before storing, or have the host normalize on upload` thrown while resolving a part's `images`.
773
+ - **Cause:** The image resolver checks the first four bytes against the PNG magic number before decoding; a JPEG, WEBP, or any other format fails that check immediately; a heightfield source is a depth map and needs a single well-defined decode path, so no other format is attempted.
774
+ - **Fix:** Convert the source to PNG before it reaches `images` — call `imageToPng()` (exported from `"partforge/ingest"`, browser-only: it draws through a `<canvas>`) in the host's upload/panel handler, or pre-convert with any image tool. Bytes that are already PNG (the 4-byte signature `89 50 4E 47`) skip this check entirely.
775
+
776
+ ## png-interlaced-unsupported
777
+
778
+ - **Symptom:** `decodePng: interlaced (Adam7) PNGs are not supported — re-save without interlacing` thrown while resolving a part's `images`.
779
+ - **Cause:** The bundled PNG decoder implements only the non-interlaced scanline layout (interlace method 0); an Adam7-interlaced PNG (interlace method 1 — some export tools and "optimized" PNGs default to it) stores pixels in seven interleaved passes the decoder doesn't reassemble.
780
+ - **Fix:** Re-save the PNG without interlacing (most editors/optimizers have a plain "none"/"no interlace" option), or run it back through `imageToPng()` — canvas re-encoding never interlaces.
781
+
782
+ ## heightfield-sew-failed
783
+
784
+ - **Symptom:** `heightfield: could not sew <n> triangles into a B-rep solid (<reason>). Raise \`pitch\` to reduce the triangle count, or build this sub-part on the Manifold backend.` thrown on the OCCT backend.
785
+ - **Cause:** OCCT's heightfield path triangulates the depth-map grid the same way Manifold does, then goes mesh → STL → B-rep (`StlAPI_Reader` + `ShapeUpgrade_UnifySameDomain` + `MakeSolid`) so the result can boolean/fillet/export to STEP like any other B-rep shape. That sewing step can fail outright on a large or high-frequency grid — before failure, a triangle count above the plan's measured threshold already emits a slow-sew/large-STEP warning on the same build (see `feature-skipped-warning`'s sibling channel), and this is what happens when the grid is pushed further still.
786
+ - **Fix:** Raise `pitch` on the `k.heightfield` call to coarsen the grid (fewer triangles to sew), or keep this sub-part on the Manifold backend (drop the `meta.backend`/CAD-op pin forcing OCCT) — Manifold's heightfield path never sews through OCCT, so it has no equivalent failure mode. STEP export specifically needs OCCT, so a part that must export a relief to STEP has to bring the triangle count under the sewable range rather than avoid OCCT.
787
+
764
788
  # Hardware library
765
789
 
766
790
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
@@ -289,6 +289,7 @@ above. All ops return a `Solid`.
289
289
  | `helixSweptTube({pathR, profileR, pitch, turns, z0, lefthand})` | Circle of radius `profileR` swept along a helix (e.g. a rope groove). Circular profile on a frenet frame that rolls with the helix — **not for threads**; use `screwSweep`. |
290
290
  | `screwSweep({profile, pitch, turns, lefthand})` | Screw-motion sweep of an axial lathe profile `[[r, z], …]` (r ≥ 0) — threads. The profile travels to `(r·cosθ, r·sinθ, z + pitch·θ/2π)`; `h = pitch · turns`. Axial extent must not exceed `pitch` or consecutive turns interpenetrate (throws). A profile spanning exactly `pitch` is **periodic**: first and last radius must agree, and it yields a complete threaded body needing no boolean. Compound: the polar-remapped, densified section extruded with `twist = 360 · turns`, exactly as composed in `kernel-front.js`; a backend may override only for caching, never for different geometry. Options-only. Parity: **within tolerance, not by construction** — both backends receive the identical densified polygon, but the mesh backend facets the twist at its own resolution while the B-rep backend builds an exact spline (`hull`'s parity class). |
291
291
  | `loftSmooth({sections, stations?, samples?, shading?, closed?})` | Spline-interpolated loft of ≥2 sparse control sections — loft-style ring specs `{polygon\|sides+radius\|curve contour\|Shape2D, z, rotate?, scale?, sharp?}`; vertex counts **may differ**. A point section may tag `sharp: [indices]` as true corners (integers in `0…points.length-1`, sorted/deduped silently); a curve/`Shape2D` section takes corners implicitly from its non-smooth joints (single-region, hole-free, `loftSmooth:`-prefixed `k.loft` validation) and rejects an explicit `sharp`. Every section must resolve to the **same corner count `m`** (frozen error otherwise); with `m ≥ 1` corner 0 anchors the seam (replacing vertex 0), with `m = 0` v1's vertex-0 anchor holds verbatim. Compound (`kernel-front.js` + `loft-smooth.js`): each section's outline is a closed centripetal Catmull-Rom split into `m` clamped open arcs at its corners (or one closed periodic CR when `m = 0`); the `samples` budget is apportioned across arcs by mean arc-length fraction (largest-remainder, min 1 span/arc) and each arc resampled by arc length — total ring vertex count is `samples`, identical across sections, exactly v1's invariant now corner-anchored. The cross-station direction is v1 verbatim (shared centroid-spine knots, per-vertex CR, reflection phantoms at the ends, or periodic knots when `closed: true`). What's new is emission: every station — the dense list and the sparse `stations:"controls"` list alike — is fitted back to an **all-cubic Bézier contour**, arc-by-arc, via exact 4-point CR→Bézier inversion, so **both backends receive identical curve rings**. A B-rep kernel lofts the sparse control wires with its native smooth skin (`ruled: false`) — curve-exact around each ring in STEP (the densified-*point*-wire alternative measured 23 s / WASM-abort territory, which curve wires don't hit). A mesh kernel densifies `stations` rings and lofts them through `k.loft`'s curve-mode per-segment sampling, creasing sharp/corner columns via loft's geometric corner policy. `closed: true` (default false; needs ≥3 control sections, frozen error otherwise) makes the cross-station CR periodic (no reflection phantoms, ring 0 not repeated) and is **Manifold-only**, same restriction as `loft` `closed: true`: a B-rep kernel throws `loftSmooth: closed:true loops are only supported on the Manifold backend` in the composition, before building any rings; combining `closed: true` with `stations:"controls"` is rejected as a defensive invariant (reachable only by explicitly passing the internal `stations:"controls"` value; the composition never produces the combination itself). Options-only. Defaults `stations = (n−1)·8+1` open / `n·8` closed (raised to the section count `n` when lower), `samples = max(64, largest section)` (raised to the corner count `m` when lower); clamps 2…1024 / 8…2048 — the defaults cap themselves at the ceilings, only explicit out-of-range values throw. The surface interpolates every control section exactly. Parity: **within tolerance** (`screwSweep`'s class, unchanged from v1 — ~0.4% measured on the propeller reference part, test-gated at 2%). STEP is now curve-exact around each ring (previously faceted at the `samples` LOD); the cross-station skin remains ThruSections' native fit, not the shared CR — exact cross-station B-splines are a v3 candidate. Additive: `sharp`, curve/`Shape2D` sections, and `closed` are new options on top of v1's `{sections, stations?, samples?, shading?}`; `CONTRACT_VERSION` stays 4 — the same non-bump precedent as `import` above, a refinement inside the op's already-stated tolerance class rather than a new one. |
292
+ | `heightfield(nameOrGrid, {w, d, base?, maxZ?, pitch?, invert?, range?, origin?})` | A depth map as a relief solid: a sampled grid top at `z = base + maxZ·f(v)`, skirt walls, and a flat base cap at `z = 0`. `nameOrGrid` is a name declared in the part's `images` field, or an inline `{width, height, data}` grid. Sample count per axis is `max(2, ceil(w/pitch))` and `max(2, ceil(d/pitch))`; if their product exceeds a vertex budget, `pitch` is scaled up uniformly to fit and, if still over, the two counts are shrunk in lockstep — with a `takeBuildWarnings` message rather than an error. `range` is a remap with clamped ends (`range[0]`→0, `range[1]`→1); `invert` applies after, as `1−v`. `origin` positions the footprint in XY only — the base always sits at `z = 0`. The image stretches to `w × d`; aspect is not preserved. **Axis convention:** sample row 0 (a source PNG's first scanline, i.e. its visual top in an image viewer) maps to the footprint's **−Y** edge — Y increases with row, the standard texture-coordinate mapping — so a depth map viewed from +Z looks vertically mirrored relative to the same file opened in a viewer; flip the source pixels before declaring the image if that orientation is unwanted (`invert` remaps height values, not position, and does not affect this). Fed by an underscore-prefixed side-channel (`_registerImage`), not part authors — see [Conformance classes](#conformance-classes). Required on both in-repo backends: Manifold imports the triangles directly, OCCT sews them into a faceted B-rep via `importSTL`, so STEP export carries a triangulated surface rather than an analytic one. Parity: **exact** — both backends receive byte-identical triangle data. Additive: `CONTRACT_VERSION` stays 4, the same precedent as `import` and `loftSmooth`. |
292
293
  | `union(solids[])` | Boolean union of one or more solids. |
293
294
  | `text2d(string, {size, font?, align?, valign?, lineHeight?, tracking?, kerning?})` | Outline-font text → `Shape2D`. `size` = cap height (mm). `font` = declared name / inline bytes / default. Build-time; curve-exact on OCCT, faceted on Manifold. |
294
295
  | `vector2d(name, {shape?, width\|height\|fit, align?, valign?})` | A declared vector document → `Shape2D`. `name` = a declared name in the part's `vectors` field (`partforge-vector` JSON — authored by hand or ingested from an `.svg`; never raw `.svg`). With no `shape`, the document's own composition: every `"add"` shape unioned minus every `"subtract"` shape; `shape` selects one named shape's geometry whatever its role. Sizing follows the document's required `units`: `"artwork"` requires exactly one of `width`/`height`/`fit` in millimetres, `"mm"` places as authored (scale 1, no translate) and accepts a size option optionally; more than one is refused. Uniform scale in every case (`fit` = larger extent); `align`/`valign` position it, same as `text2d`, defaulting to centre/middle for `"artwork"` and to no translate for `"mm"`. **The composed call derives ONE transform from every region in the document**, `"add"` and `"subtract"` alike, so a size or align option cannot scale the subtracts relative to the adds; a `shape` call is measured against that shape alone. **Primitive contours (`circle`/`rect`/`polygon`) expand to ordinary path contours at the format boundary, in `vector-format.js`, so this op's kernel-facing contract is unchanged by them** — nothing below the loader learns primitives exist. Conformance: **both backends** (it lowers to `shape2d` + `union`, exactly like `text2d`). Parity: **identical across backends by construction** — the regions are curve-native (arcs/cubics), so there is no sampling step for the two backends to diverge over. |
@@ -10,8 +10,10 @@ There are two ways a file gets here, and this document leads with the first:
10
10
  a triangular keyway. Coordinates are millimetres, and they place exactly where
11
11
  you drew them. This is the path an agent should reach for when the geometry is
12
12
  *drawn* rather than computed.
13
- - **Ingested.** Convert an existing `.svg` once, in a browser, with
14
- `partforge/ingest`, and check the resulting JSON in beside the part. The
13
+ - **Ingested.** Convert an existing `.svg` once in a browser with
14
+ `partforge/ingest`, or headlessly with `npx partforge ingest <file.svg> --out
15
+ <file.vector.json>` (needs `happy-dom`, an optional peer dependency: `npm
16
+ install happy-dom`) — and check the resulting JSON in beside the part. The
15
17
  artwork keeps its own unitless coordinates and gets sized at every call site.
16
18
 
17
19
  Both produce the same format, load through the same validator, and behave
@@ -19,14 +21,15 @@ identically downstream.
19
21
 
20
22
  **Why this document is normative and not merely helpful.** Ingest needs a real
21
23
  DOM (it resolves `<use>`, `<defs>`, CSS, and bakes ancestor transforms, all of
22
- which require one), so partforge deliberately ships **no headless SVG
23
- conversion path** `partforge measure|render|lint` read a part's already-stored
24
- JSON, but nothing headless can *create* it from an `.svg`. That trade was
25
- accepted only because this file is complete enough that someone or some agent
26
- with no browser and no access to partforge's source can write a compliant
27
- converter from it alone. Everything below is written to hold that property.
28
- `scripts/ingest-svg.mjs` (dev-only, not shipped) is the reference
29
- implementation, described in §6.
24
+ which require one) `partforge measure|render|lint` read a part's
25
+ already-stored JSON, but neither of ingest's two entry points *creates* one
26
+ without a DOM somewhere: a real browser for `partforge/ingest`, or a headless
27
+ one (`happy-dom`) for the CLI. That trade was accepted only because this file
28
+ is complete enough that someone or some agent with neither a browser nor
29
+ happy-dom installed, and no access to partforge's source, can still write a
30
+ compliant converter from it alone. Everything below is written to hold that
31
+ property. The `ingest` verb in `bin/cli.js` is the reference implementation,
32
+ described in §6.
30
33
 
31
34
  ## 1. A worked authored example
32
35
 
@@ -494,8 +497,9 @@ Shapes are named, regions and segments are 1-indexed, and the role (`outer` /
494
497
  One filled circle, and one **stroked, open** polyline — deliberately, so this one
495
498
  file exercises both of ingest's geometry paths (a fill, and a stroke that has to
496
499
  be outlined into a filled shape; see §6). Ingesting it
497
- (`node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`) produces
498
- `src/parts/assets/emblem.vector.json`, checked in beside it. Here it is with the
500
+ (`npx partforge ingest src/parts/assets/emblem.svg --out
501
+ src/parts/assets/emblem.vector.json`) produces that file, checked in beside it.
502
+ Here it is with the
499
503
  `note` field elided for brevity and the coordinate arrays put on one line —
500
504
  nothing else is changed:
501
505
 
@@ -662,12 +666,14 @@ followed by the one already-written reference to check your output against.
662
666
  file stays diffable and so a stored `bbox` matches a later recomputation from
663
667
  the *rounded* coordinates rather than drifting past the tolerance.
664
668
 
665
- `scripts/ingest-svg.mjs` in the partforge repository is the worked reference
666
- implementation of exactly this pipeline it runs `partforge/ingest`'s real
667
- `ingestSvg()` (paper.js's `importSVG` for steps 1–2 and 4–5, this repo's own
669
+ The `ingest` verb in `bin/cli.js` (`npx partforge ingest <file.svg> --out
670
+ <file.vector.json>`) is the worked reference implementation of exactly this
671
+ pipeline — it runs `partforge/ingest`'s real `ingestSvg()` (paper.js's
672
+ `importSVG` for steps 1–2 and 4–5, this repo's own
668
673
  `contour-offset.js`/`stroke-outline.js` for step 3, and its own `arc-fit.js` for
669
- step 7) inside a headless DOM (`happy-dom`, a devDependency), specifically so
670
- that repository's own fixtures including the worked example in §4 — are
674
+ step 7) inside a headless DOM (`happy-dom`, an optional peer dependency —
675
+ `src/framework/ingest/node-dom.js` installs it), specifically so that
676
+ repository's own fixtures — including the worked example in §4 — are
671
677
  reproducible instead of being hand-maintained blobs, and so there is a second
672
678
  thing (besides this document) to check a from-scratch converter's output
673
679
  against: ingest the same SVG both ways and diff the JSON.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.96.0",
3
+ "version": "0.98.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -99,6 +99,14 @@
99
99
  "check": "node scripts/check-app.mjs",
100
100
  "offset-rates": "node scripts/offset-rates.mjs"
101
101
  },
102
+ "peerDependencies": {
103
+ "happy-dom": ">=20"
104
+ },
105
+ "peerDependenciesMeta": {
106
+ "happy-dom": {
107
+ "optional": true
108
+ }
109
+ },
102
110
  "dependencies": {
103
111
  "dompurify": "^3.4.11",
104
112
  "fflate": "^0.8.3",
@@ -0,0 +1,16 @@
1
+ // Self-hosted Geist + Geist Mono for the dev demos, so a standalone forge looks
2
+ // like the product. Dev-only: --pf-sans/--pf-mono fall back to system stacks for
3
+ // any consumer that doesn't load them (spec §2.2).
4
+ import "@fontsource-variable/geist";
5
+ import "@fontsource-variable/geist-mono";
6
+ import reliefPart from "./parts/relief.js";
7
+ import { mount } from "./framework/index.js";
8
+
9
+ // Dev-only example app for the relief reference part. `npm run dev`, then open /relief.html.
10
+ // The `new Worker(new URL(...))` call must stay inline here or Vite will not bundle it.
11
+ // Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
12
+ // the embedding contract (runtime.captureCurrent) the way an embedder would.
13
+ window.__pfRuntime = mount(reliefPart, {
14
+ createWorker: (name) =>
15
+ new Worker(new URL("./relief-worker.js", import.meta.url), { type: "module", name }),
16
+ });
@@ -211,6 +211,36 @@ textarea.text-input { min-height: 64px; resize: vertical; }
211
211
  .font-btn .caret { flex: none; opacity: .5; }
212
212
  .text-input.warn { border-color: var(--pf-err); color: var(--pf-err); }
213
213
 
214
+ /* the `type: "image"` control — a live preview above either a URL field or a
215
+ picker button (mirrors .font-btn; a thumbnail stands in for the in-face name). */
216
+ .image-preview { display: block; width: 100%; max-height: 120px; object-fit: contain;
217
+ margin-bottom: 6px; border-radius: var(--pf-radius-control); background: var(--pf-input-bg); }
218
+ .image-btn { width: 100%; display: flex; align-items: center; gap: 8px; text-align: left; cursor: pointer;
219
+ background: var(--pf-input-bg); color: var(--pf-text-strong);
220
+ border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 7px 9px; }
221
+ .image-btn:hover { border-color: color-mix(in oklab, var(--pf-accent) 45%, var(--pf-border)); }
222
+ .image-btn:focus-visible { outline: none; border-color: var(--pf-accent);
223
+ box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent); }
224
+ .image-btn-thumb { flex: none; width: 28px; height: 28px; object-fit: cover;
225
+ border-radius: calc(var(--pf-radius-control) - 2px); background: var(--pf-surface-2); }
226
+ .image-btn .iname { flex: 1; min-width: 0; font-size: 13px; line-height: 1.25;
227
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
228
+ .image-btn .caret { flex: none; opacity: .5; }
229
+
230
+ /* the image picker's thumbnail grid — same takeover chrome as the font picker
231
+ (.picker/.pk-head/.pk-search), a grid in place of the virtualized list. */
232
+ .pk-img-grid { flex: 1; overflow-y: auto; display: grid;
233
+ grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px;
234
+ padding: 10px var(--pf-rail-pad) 12px; align-content: start; }
235
+ .pk-img-card { display: flex; flex-direction: column; gap: 4px; border: 1px solid transparent;
236
+ border-radius: var(--pf-radius-control); background: transparent; cursor: pointer; padding: 4px; }
237
+ .pk-img-card:hover { border-color: color-mix(in oklab, var(--pf-accent) 45%, var(--pf-border)); }
238
+ .pk-img-card[data-sel="true"] { border-color: var(--pf-accent); background: var(--pf-accent-soft); }
239
+ .pk-img-thumb { width: 100%; aspect-ratio: 1; object-fit: cover;
240
+ border-radius: calc(var(--pf-radius-control) - 2px); background: var(--pf-surface-2); }
241
+ .pk-img-cap { font: 9px/1.3 var(--pf-mono); color: var(--pf-hint);
242
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
243
+
214
244
  /* crafted range slider — hairline track + CAD-blue handle (the panel's signature control) */
215
245
  input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; margin: 0; background: transparent; cursor: pointer; }
216
246
  input[type="range"]::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: var(--pf-border); }
@@ -4,6 +4,7 @@
4
4
  // everything in geometry/, which is part-agnostic.
5
5
  import { createProbeKernel } from "./geometry/probe.js";
6
6
  import { isZeroMagnitudeCadOp } from "./geometry/op-options.js";
7
+ import { byteAwareReplacer } from "./geometry/solid-hash.js";
7
8
  import { resolveDerived } from "./derive.js";
8
9
 
9
10
  /**
@@ -57,8 +58,12 @@ export function detectBackend(part, params = {}) {
57
58
  export function createBackendPolicy(part, { forced = null } = {}) {
58
59
  let latchedParams = null; // JSON snapshot of the params proven at runtime to need OCCT
59
60
  let latchedNames = null; // the sub-parts that proved it (null = all of them)
61
+ // byteAwareReplacer: an image param carried as raw bytes must fingerprint by
62
+ // content here too, or a swapped image either silently reuses a stale OCCT
63
+ // latch (ArrayBuffer → the same "{}" for any image) or the reroute check
64
+ // spends its time re-stringifying megabytes of typed-array bytes every regen.
60
65
  const latched = (params, name) =>
61
- latchedParams !== null && latchedParams === JSON.stringify(params) &&
66
+ latchedParams !== null && latchedParams === JSON.stringify(params, byteAwareReplacer) &&
62
67
  (latchedNames === null || latchedNames.has(name));
63
68
  return {
64
69
  // name → backend for a preview generate; mount groups sub-parts by this.
@@ -78,7 +83,7 @@ export function createBackendPolicy(part, { forced = null } = {}) {
78
83
  // `subparts` names the failed job's sub-parts; omitted (an export job, which
79
84
  // doesn't carry them) it latches the whole part for these params.
80
85
  noteNeedsOcct(params, subparts) {
81
- latchedParams = JSON.stringify(params);
86
+ latchedParams = JSON.stringify(params, byteAwareReplacer);
82
87
  latchedNames = subparts ? new Set(subparts) : null;
83
88
  },
84
89
  };
@@ -3,6 +3,20 @@
3
3
  // for the other case — a value that arrived in `params`, which on a shared link
4
4
  // is attacker-controlled text that would otherwise become a fetch URL.
5
5
  //
6
+ // Bytes bypass the allow check. This file's older rule refused every non-string
7
+ // on the grounds that "bytes/thunks are never param-supplied"; that stopped
8
+ // being true when the panel gained a drop target. The replacement rule is
9
+ // sound and is the same one image-source.js states: an ArrayBuffer in params
10
+ // definitionally did not arrive via a shared link, because a URL cannot carry
11
+ // megabytes — so it can only have been placed there by the host's own panel,
12
+ // which is trusted code. That plausibility argument isn't the load-bearing
13
+ // one, though — the structural fact is that asset-resolve.js's resolver
14
+ // fetches only a `string`/`URL` source; a byte source is consumed directly
15
+ // and never becomes a request. Bytes therefore cannot reach the network no
16
+ // matter how they got into params, which is what the allow list exists to
17
+ // gate, and is what still holds even if a host someday puts a few bytes of
18
+ // base64 in a link.
19
+ //
6
20
  // DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
7
21
 
8
22
  export const FONT_ALLOW_DEFAULT = ["https"];
@@ -21,13 +35,16 @@ export const isNoFontSource = (v) => v === undefined || v === null || v === "";
21
35
  const GSTATIC_HOST = "fonts.gstatic.com";
22
36
  const ASSET_SCHEME = "pfc-asset:";
23
37
 
38
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
39
+
24
40
  // Parse once; an unparseable string is refused rather than guessed at.
25
41
  function parse(source) {
26
42
  try { return new URL(source); } catch { return null; }
27
43
  }
28
44
 
29
45
  export function fontSourceAllowed(source, allow = FONT_ALLOW_DEFAULT) {
30
- if (typeof source !== "string") return false; // bytes/thunks are never param-supplied
46
+ if (isBytes(source)) return true; // see the file header — bytes always bypass the allow check
47
+ if (typeof source !== "string") return false;
31
48
  const u = parse(source);
32
49
  if (!u) return false;
33
50
  for (const kind of allow) {
@@ -0,0 +1,129 @@
1
+ // Pure grid → triangle mesh for k.heightfield. Backend-agnostic by design: it
2
+ // returns plain {positions, indices} that Manifold takes via manifoldFromMesh
3
+ // and OCCT takes via meshToStl → importSTL, so both backends build from
4
+ // byte-identical triangle data. DOM-free and node:-free (worker graph).
5
+ //
6
+ // fanCap is reused verbatim from mesh-build.js (it takes a ringStart). sideQuads
7
+ // is NOT reusable here: it derives ring bases as i*ringSegs, which assumes rings
8
+ // start at V[0], and our vertex array leads with the grid. The skirt is the
9
+ // explicit loop below.
10
+ import { fanCap } from "./mesh-build.js";
11
+
12
+ // Ceiling on grid vertices, so an ambitious pitch degrades instead of hanging.
13
+ export const HEIGHTFIELD_VERTEX_BUDGET = 400000;
14
+
15
+ const U16 = 65535;
16
+
17
+ // FNV-1a over a Uint16Array's raw sample values — same fold as solid-hash.js's `h`,
18
+ // but a dedicated loop: `h`'s generic `canon()` treats a typed array as a plain
19
+ // object (Object.keys on it), which works but is wasteful for a heightfield grid
20
+ // that may hold up to HEIGHTFIELD_VERTEX_BUDGET samples. Used only to give an
21
+ // UNCACHED inline heightfield grid a real content fingerprint in its solid's own
22
+ // `_hash`, so composing it with another op afterward (union/cut) doesn't inherit
23
+ // the same "two different things, one key" collision risk the cache bypass exists
24
+ // to avoid. Lives here, beside the grid contract itself, so BOTH backends
25
+ // fingerprint an inline grid the same way (it started out module-local in
26
+ // manifold-backend.js; the OCCT backend needs the identical bypass).
27
+ export function hashGridData(data) {
28
+ let hsh = 0x811c9dc5;
29
+ for (let i = 0; i < data.length; i++) { hsh ^= data[i]; hsh = Math.imul(hsh, 0x01000193); }
30
+ return (hsh >>> 0).toString(36);
31
+ }
32
+
33
+ // Bilinear sample of a row-major Uint16 grid. u/v in 0..1 → 0..1.
34
+ export function sampleGrid(grid, u, v) {
35
+ const { width: W, height: H, data } = grid;
36
+ const x = Math.min(Math.max(u, 0), 1) * (W - 1);
37
+ const y = Math.min(Math.max(v, 0), 1) * (H - 1);
38
+ const x0 = Math.floor(x), y0 = Math.floor(y);
39
+ const x1 = Math.min(x0 + 1, W - 1), y1 = Math.min(y0 + 1, H - 1);
40
+ const fx = x - x0, fy = y - y0;
41
+ const a = data[y0 * W + x0], b = data[y0 * W + x1];
42
+ const c = data[y1 * W + x0], d = data[y1 * W + x1];
43
+ return ((a + (b - a) * fx) + ((c + (d - c) * fx) - (a + (b - a) * fx)) * fy) / U16;
44
+ }
45
+
46
+ export function heightfieldMesh(grid, opts = {}) {
47
+ const { w, d, base = 1, maxZ = 1, invert = false, range = [0, 1], origin = "center" } = opts;
48
+ let { pitch = 0.5 } = opts;
49
+ const warnings = [];
50
+
51
+ if (!(w > 0) || !(d > 0)) throw new Error("heightfield: w and d must be positive");
52
+ if (!(base > 0)) throw new Error("heightfield: base must be > 0 (a zero base is degenerate)");
53
+ if (!(pitch > 0)) throw new Error("heightfield: pitch must be > 0");
54
+
55
+ // Floor of 2 samples per axis: X()/Y()/Z() divide by (nx-1)/(ny-1) to map
56
+ // sample indices to 0..1, so nx or ny === 1 would divide by zero. 2 is also
57
+ // the minimum for a meaningful grid (one quad).
58
+ const count = (len, p) => Math.max(2, Math.ceil(len / p));
59
+ let nx = count(w, pitch), ny = count(d, pitch);
60
+ if (nx * ny > HEIGHTFIELD_VERTEX_BUDGET) {
61
+ // Scale pitch up uniformly until the grid fits, then recompute.
62
+ const scale = Math.sqrt((nx * ny) / HEIGHTFIELD_VERTEX_BUDGET);
63
+ const clamped = pitch * scale;
64
+ warnings.push(`heightfield: pitch ${pitch} clamped to ${clamped.toFixed(3)} (vertex budget ${HEIGHTFIELD_VERTEX_BUDGET})`);
65
+ pitch = clamped;
66
+ nx = count(w, pitch); ny = count(d, pitch);
67
+ // An extreme aspect ratio (e.g. d <= pitch pins ny at its floor of 2
68
+ // while w/pitch is still huge) can leave the uniform pitch-scale above
69
+ // unable to bring nx*ny under budget on its own, since a floored axis
70
+ // has nothing left to give up proportionally. Decrementing both axes
71
+ // unconditionally would then walk the pinned one straight through the
72
+ // count() floor to 1 or 0 — reintroducing the divide-by-(n-1) and empty
73
+ // top-grid failures that floor exists to prevent. Hold each axis at 2.
74
+ while (nx * ny > HEIGHTFIELD_VERTEX_BUDGET && (nx > 2 || ny > 2)) {
75
+ if (nx > 2) nx--;
76
+ if (ny > 2) ny--;
77
+ }
78
+ }
79
+
80
+ const [lo, hi] = range;
81
+ const span = hi - lo;
82
+ // range is a REMAP with clamped ends: lo→0, hi→1. invert applies after.
83
+ const f = (v) => {
84
+ const t = span === 0 ? 0 : Math.min(Math.max((v - lo) / span, 0), 1);
85
+ return invert ? 1 - t : t;
86
+ };
87
+
88
+ const x0 = origin === "corner" ? 0 : -w / 2;
89
+ const y0 = origin === "corner" ? 0 : -d / 2;
90
+ const X = (i) => x0 + (i / (nx - 1)) * w;
91
+ const Y = (j) => y0 + (j / (ny - 1)) * d;
92
+ const Z = (i, j) => base + maxZ * f(sampleGrid(grid, i / (nx - 1), j / (ny - 1)));
93
+
94
+ const V = [], Tr = [];
95
+
96
+ // 1. Top grid, row-major. CCW from +Z.
97
+ for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) V.push(X(i), Y(j), Z(i, j));
98
+ for (let j = 0; j < ny - 1; j++) for (let i = 0; i < nx - 1; i++) {
99
+ const a = j * nx + i, b = a + 1, c = a + nx, dd = c + 1;
100
+ Tr.push(a, b, dd, a, dd, c);
101
+ }
102
+
103
+ // 2. Perimeter, CCW viewed from +Z, as grid indices.
104
+ const per = [];
105
+ for (let i = 0; i < nx; i++) per.push(i);
106
+ for (let j = 1; j < ny; j++) per.push(j * nx + (nx - 1));
107
+ for (let i = nx - 2; i >= 0; i--) per.push((ny - 1) * nx + i);
108
+ for (let j = ny - 2; j >= 1; j--) per.push(j * nx);
109
+ const P = per.length;
110
+
111
+ // 3. Bottom ring: one new vertex per perimeter vertex, dropped to z = 0. The
112
+ // top ring of the skirt reuses the ORIGINAL grid perimeter indices (not a
113
+ // duplicate) — that's what makes the top-face boundary edge and the
114
+ // skirt's top edge the same index pair, so the mesh is watertight by
115
+ // index alone with no separate weld/merge step required.
116
+ const bot0 = V.length / 3;
117
+ for (const p of per) V.push(V[p * 3], V[p * 3 + 1], 0);
118
+
119
+ // 4. Skirt.
120
+ for (let k = 0; k < P; k++) {
121
+ const k2 = (k + 1) % P;
122
+ Tr.push(per[k], bot0 + k, bot0 + k2, per[k], bot0 + k2, per[k2]);
123
+ }
124
+
125
+ // 5. Bottom cap — flip=true so it faces −Z. Centre is the footprint centroid.
126
+ fanCap(V, Tr, bot0, P, [x0 + w / 2, y0 + d / 2, 0], true);
127
+
128
+ return { positions: Float32Array.from(V), indices: Uint32Array.from(Tr), warnings };
129
+ }
@@ -25,6 +25,8 @@ export const KERNEL_OPS = [
25
25
  "roundedCylinder", "torus", "roundedBox", "import",
26
26
  // Additive in 0.84 (no CONTRACT_VERSION bump — the import-op precedent).
27
27
  "loftSmooth",
28
+ // Additive in 0.92 (same precedent): both backends implement it.
29
+ "heightfield",
28
30
  ];
29
31
 
30
32
  // Backend-optional kernel ops: the sub-part cache brackets + WASM lifetime hooks.
@@ -150,6 +152,7 @@ export const ROUTED_CAD_OPS = ["shell"];
150
152
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
151
153
  * @property {(o:{profile:number[][],pitch:number,turns:number,lefthand?:boolean}) => Solid} screwSweep screw-motion sweep of an axial [[r,z]] profile — threads; options-only
152
154
  * @property {(o:{sections:object[],stations?:number,samples?:number,shading?:string,closed?:boolean}) => Solid} loftSmooth Catmull-Rom-densified loft of sparse control sections; options-only
155
+ * @property {(nameOrGrid: string|{width:number,height:number,data:Uint16Array}, opts: {w:number,d:number,base?:number,maxZ?:number,pitch?:number,invert?:boolean,range?:number[],origin?:"center"|"corner"}) => Solid} heightfield depth-map relief solid; nameOrGrid is a name declared in the part's `images` field or an inline grid
153
156
  * @property {(solids:Solid[]) => Solid} union
154
157
  * @property {(profile: number[][]|{outer:number[][],holes?:number[][][]}|{start:number[],segments:object[]}|Shape2D) => Shape2D} shape2d 2-D boolean value; one shared contour-storage implementation on both backends
155
158
  * @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
@@ -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)