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
package/bin/cli.js CHANGED
@@ -10,6 +10,8 @@ import { resolve, dirname, basename } from "node:path";
10
10
  import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
11
11
  import { detectBackend } from "../src/framework/backend-select.js";
12
12
  import { fontsFor } from "../src/framework/fonts.js";
13
+ import { imagesFor } from "../src/framework/images.js";
14
+ import { isNoImageSource } from "../src/framework/image-source.js";
13
15
  import { viewAnimations, evaluate, cueAt } from "../src/framework/animation.js";
14
16
  import { bootOcctKernel } from "../src/testing/occt.js";
15
17
  import { bootManifoldKernel } from "../src/testing/manifold.js";
@@ -100,9 +102,20 @@ const readSources = (partPath) => {
100
102
  // the CLI's base params; see "CLI limitation" in the design doc — a verify case
101
103
  // or animation frame that CHANGES the font param still builds with the
102
104
  // base-params face, because the kernel is booted once.
105
+ //
106
+ // Same story for `images` — the third asset sibling (fonts.js / imports.js /
107
+ // images.js), resolved with `imagesFor` exactly like `fonts` is with `fontsFor`,
108
+ // since a part's `images` is commonly function-form (that's what lets a
109
+ // `type: "image"` control drive the source). An unset control (isNoImageSource)
110
+ // is dropped rather than handed to ensureImages, mirroring jobs.js's own filter —
111
+ // `k.heightfield` throws unknown-image for a name never registered; a part
112
+ // that wants to build with no image branches around the call itself.
103
113
  const bootKernel = (part, params = {}) => {
104
114
  const p = { ...(part.defaults ?? {}), ...params };
105
- const opts = { fonts: fontsFor(part, p), imports: part.imports, vectors: part.vectors };
115
+ const imagesDecl = part.images ? (imagesFor(part, p) ?? {}) : undefined;
116
+ const images = imagesDecl &&
117
+ Object.fromEntries(Object.entries(imagesDecl).filter(([, src]) => !isNoImageSource(src)));
118
+ const opts = { fonts: fontsFor(part, p), imports: part.imports, images, vectors: part.vectors };
106
119
  const backend = process.env.PARTFORGE_BACKEND || detectBackend(part); // env: crash()'s NEEDS_OCCT retry
107
120
  return backend === "occt" ? bootOcctKernel(opts) : bootManifoldKernel(opts);
108
121
  };
@@ -712,6 +712,7 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
712
712
  | `"select"` | a dropdown | `options` |
713
713
  | `"radio"` | a segmented button row | `options` |
714
714
  | `"font"` | a typeface picker, or a URL field with no catalog | `allow`, `preview` |
715
+ | `"image"` | an image picker, or a URL field with no catalog | `allow` |
715
716
 
716
717
  Numeric controls always show the number box: drag the slider *or* type an exact
717
718
  value. Typed values may be finer than `step` and clamp to `[min, max]` on commit.
@@ -1735,6 +1736,181 @@ build: (k, p, d) => {
1735
1736
 
1736
1737
  **CLI:** `partforge measure|render|lint` work on an importing part exactly as on any other — the `imports` field resolves in the CLI's Node boot the same way `fonts` does, no extra flags.
1737
1738
 
1739
+ ## Height maps and images
1740
+
1741
+ `k.heightfield(nameOrGrid, opts)` turns a grayscale depth map into a relief
1742
+ solid: a sampled grid on top, skirt walls down the sides, a flat cap at
1743
+ `z = 0`. It exists for one thing — a printable relief plate from a picture —
1744
+ and `src/parts/relief.js` is the worked example; read it alongside this
1745
+ section.
1746
+
1747
+ **Declaring images (the `images` PartDefinition field):**
1748
+
1749
+ Same grammar as `fonts` and `imports`, one more asset sibling:
1750
+
1751
+ ```js
1752
+ images: {
1753
+ relief: new URL("./assets/relief-demo.png", import.meta.url), // Vite serves it; Node reads disk
1754
+ logo: "https://…/signed-url.png", // URL string
1755
+ scan: bytesOrThunk, // ArrayBuffer/Uint8Array, or a (possibly async) thunk returning one
1756
+ },
1757
+ ```
1758
+
1759
+ `images` may also be a **function of the resolved params** — `images: (p) => ({...})`
1760
+ — which is what lets a `type: "image"` control pick the source. `relief.js` uses
1761
+ exactly this to fall back to a bundled sample when the control is empty:
1762
+
1763
+ ```js
1764
+ images: (p) => ({
1765
+ relief: p.relief || new URL("./assets/relief-demo.png", import.meta.url),
1766
+ }),
1767
+ ```
1768
+
1769
+ **The empty-value fallback:** `p.relief` starts as `""` (its `defaults` entry),
1770
+ which reads as "no image chosen" — never a source to fetch, never a source
1771
+ `npx partforge lint`/the runtime warn about. A part is responsible for
1772
+ supplying its own fallback when a key resolves empty, exactly as above; an
1773
+ `images` entry that stays empty is simply dropped from registration (with a
1774
+ progress note, not an error), so a `build()` that still calls
1775
+ `k.heightfield(name, …)` for that name gets the same
1776
+ [`heightfield-unknown-image`](ERROR-PATTERNS.md#heightfield-unknown-image)
1777
+ throw as a typo'd name — the framework has no automatic "flat slab" behavior of
1778
+ its own; a part that wants one branches around the `k.heightfield` call itself
1779
+ when its source param is empty, the same way it would branch around any other
1780
+ optional feature.
1781
+
1782
+ **The `type: "image"` control:** the control-types table above lists `"image"`
1783
+ — an image picker with a host-supplied catalog, or a plain URL text field
1784
+ without one. `allow` restricts what a **param-supplied** value (from the
1785
+ picker, or a pasted/shared URL) may be — the same shape as `font`'s `allow`,
1786
+ but with one fewer kind, since there's no image equivalent of Google Fonts'
1787
+ CDN allowance:
1788
+
1789
+ | value | accepts |
1790
+ |---|---|
1791
+ | `"https"` | any `https:` URL. **The default** — omitting `allow` means `["https"]` |
1792
+ | `"asset"` | a `pfc-asset://` token — an image the host has stored for this part |
1793
+
1794
+ A refused param falls back to `defaults[key]`, with a build warning naming the
1795
+ key — `image-source-scheme` (lint) catches a `defaults` value the control's own
1796
+ `allow` would itself refuse. As with `fonts`, `allow` only gates values that
1797
+ arrive as **params**; a source you write into `images` yourself is code, not
1798
+ user input, and is never checked against it.
1799
+
1800
+ **`k.heightfield`'s options:**
1801
+
1802
+ ```js
1803
+ k.heightfield("relief", {
1804
+ w: 60, d: 60, // footprint, mm — REQUIRED, both > 0 (no default)
1805
+ base: 1.5, // solid slab thickness under the relief, mm (default 1; must be > 0 — zero is degenerate)
1806
+ maxZ: 3, // how far the tallest sample rises above base, mm (default 1)
1807
+ pitch: 0.5, // grid spacing, mm (default 0.5) — see "pitch" below
1808
+ invert: false, // swap high/low (default false)
1809
+ range: [0, 1], // remap the raw sample range before invert (default [0, 1] — identity)
1810
+ origin: "center", // "center" | "corner" — footprint placement in XY (default "center")
1811
+ });
1812
+ ```
1813
+
1814
+ `nameOrGrid` is either a name declared in `images`, or an inline
1815
+ `{ width, height, data: Uint16Array }` grid (bypassing `images`/PNG entirely —
1816
+ useful for procedural depth maps, as CI fixtures use).
1817
+
1818
+ - **`range` is a remap with clamped ends, not an output clamp.** `range[0]` maps
1819
+ to sample value 0, `range[1]` maps to sample value 1, and everything outside
1820
+ `[range[0], range[1]]` clamps to the nearer end — it does not pass the raw
1821
+ 0..1 sample through unclamped and then chop the *output* height. `range: [0, 1]`
1822
+ (the default) is the identity map: a raw sample stays exactly what it was.
1823
+ This is exactly the tool for a source whose luminance never reaches the
1824
+ extremes — `relief.js`'s bundled demo asset only spans roughly 39–75% of the
1825
+ 16-bit range (the ripple pattern that generated it decays toward mid-gray),
1826
+ so left at the default `range` the demo would use well under half of `maxZ`;
1827
+ it sets `range` to the asset's own measured extent to stretch that into the
1828
+ full 0..1 span. **`invert` applies after the remap**, as `1 − t` on the
1829
+ remapped value — it flips which end is raised, not which end of the source
1830
+ range is used.
1831
+ - **`origin` positions the footprint in XY only.** `"corner"` puts the minimum
1832
+ corner at `(0, 0)`; `"center"` (the default) centers the footprint on the
1833
+ origin. Either way the **base always sits at `z = 0`** — `origin` never moves
1834
+ the part vertically, only in X/Y.
1835
+ - **The image stretches to `w × d`.** Sampling maps the image's own aspect
1836
+ ratio onto whatever rectangle `w`/`d` describe — a square source on a
1837
+ non-square footprint stretches, it is not letterboxed or cropped.
1838
+ - **Axis convention:** a source PNG's row 0 (its first scanline — the visual
1839
+ top of the file in an image viewer) maps to the footprint's **−Y** edge, with
1840
+ Y increasing down the rows — the standard texture-coordinate convention, and
1841
+ not something this framework special-cases. In practice: a depth map viewed
1842
+ in the app from above (+Z) reads vertically flipped relative to the same file
1843
+ open in an image viewer. If a source contains text or a logo and that
1844
+ orientation matters, flip the source pixels before declaring it — `invert`
1845
+ will not do this for you, since it remaps sampled *height*, not pixel
1846
+ position.
1847
+ - **Vertex budget:** the sampled grid is `max(2, ceil(w/pitch))` ×
1848
+ `max(2, ceil(d/pitch))` vertices. If that product would exceed 400,000,
1849
+ `pitch` is scaled up uniformly until it fits (and, if still over, the two
1850
+ counts are shrunk in lockstep) — a build warning names the clamped pitch
1851
+ rather than the build hanging or throwing.
1852
+
1853
+ **PNG only, in core.** `images` resolves exactly one format — a source that
1854
+ doesn't start with the PNG signature throws
1855
+ [`images-only-png-supported`](ERROR-PATTERNS.md#images-only-png-supported) —
1856
+ and the decoder itself rejects Adam7-interlaced files
1857
+ ([`png-interlaced-unsupported`](ERROR-PATTERNS.md#png-interlaced-unsupported)).
1858
+ This is deliberate, not an oversight: the same pure-JS decoder
1859
+ (`src/framework/geometry/png-decode.js`) runs in the browser worker, the CLI,
1860
+ and CI alike, so the geometry a user previews, the geometry `partforge measure`
1861
+ gates, and the geometry a regression test pins can never disagree about how a
1862
+ given file decodes — a second format would mean a second decode path, and a
1863
+ second place for the three to drift apart. The escape hatch is
1864
+ `imageToPng(fileOrBlob, { maxSize = 1024 }) → Promise<Blob>`, exported from
1865
+ `"partforge"` (main-thread only — it draws through a `<canvas>`, never import
1866
+ it from a part or a worker): convert any format the browser can decode into a
1867
+ PNG before it reaches `images`, in a host's upload handler. It downsamples to
1868
+ `maxSize` on the long edge on the way, since `pitch` caps useful resolution
1869
+ anyway and downsampling avoids shipping detail no `heightfield` call will ever
1870
+ sample.
1871
+
1872
+ **`pitch` is the throttle for both triangle count and STEP size.** Every
1873
+ `w/pitch × d/pitch` grid cell becomes two triangles, plus a skirt and a cap —
1874
+ halving `pitch` roughly quadruples the triangle count. On a 60×60 mm plate,
1875
+ pitch 1.0 produces about 7,670 triangles and (on the OCCT backend) a STEP file
1876
+ around 17.6 MB; pitch 0.3 produces about 81,590 triangles and a STEP file
1877
+ around 206.5 MB, for the same footprint. STEP size is content-dependent — only
1878
+ genuinely coplanar faces merge during sewing, so a flat relief compresses far
1879
+ better than a high-frequency one at the same triangle count — but the linear
1880
+ relationship to triangle count holds regardless of content. Above 24,000
1881
+ triangles the OCCT backend's sewing step also slows down and warns on the same
1882
+ build; past a further, content-dependent point sewing can fail outright
1883
+ ([`heightfield-sew-failed`](ERROR-PATTERNS.md#heightfield-sew-failed)), fixed
1884
+ by raising `pitch` or keeping the sub-part on the Manifold backend, which never
1885
+ sews through OCCT. Manifold's own preview has no such ceiling, so a fine
1886
+ `pitch` is always safe there — it only becomes expensive at STEP-export /
1887
+ OCCT time.
1888
+
1889
+ **Bytes in params — the sandbox path.** A `type: "image"` control's value may
1890
+ also be raw PNG bytes (an `ArrayBuffer`/typed array) rather than a URL string —
1891
+ this is how a host that cannot fetch URLs (the partforge-cloud sandbox is the
1892
+ motivating case) gets an uploaded image into a part: its own trusted panel
1893
+ puts the bytes straight into `params`. Byte values **bypass the `allow` check
1894
+ entirely**, for every `allow` list, including the default — not a hole, but
1895
+ the deliberate consequence of what a byte value in `params` can mean: a URL
1896
+ cannot carry megabytes, so an `ArrayBuffer` arriving there cannot have come
1897
+ from a pasted link or a shared URL; it can only have been placed there by the
1898
+ host's own code. `allow` exists to keep a shared link from turning into an
1899
+ arbitrary fetch — a concern that doesn't apply to a value the host already
1900
+ has in hand.
1901
+
1902
+ **Linting:** `npx partforge lint` adds an "Image controls" group of static
1903
+ checks — `image-control-not-in-images`, `heightfield-unknown-image`,
1904
+ `image-source-scheme` — described in full under "Linting" → Rule catalog →
1905
+ "Image controls", below; this section only points there rather than repeating
1906
+ it.
1907
+
1908
+ **CLI:** `partforge measure|render|lint` resolve `images` in the CLI's Node
1909
+ boot exactly the way `fonts`/`imports` do — no extra flags — with the same
1910
+ function-form caveat: a `verify` case or animation frame that changes the
1911
+ image-control param still builds against the base-params source, because the
1912
+ CLI boots its kernel once.
1913
+
1738
1914
  ## Host jobs: extending the worker
1739
1915
 
1740
1916
  The worker's job loop handles a closed set of message types (`generate`, the exports,
@@ -1950,6 +2126,11 @@ yourself (e.g. to download from a different origin) instead of partforge's own D
1950
2126
  partforge ships no provider — a host supplies one, and without it every font
1951
2127
  control renders as a URL field.
1952
2128
 
2129
+ - `imageCatalog` — a provider backing every `type: "image"` control in the part:
2130
+ `{ search(query, { limit }) → Promise<ImageAsset[]>, describe?(source) → { label, width, height } | null }`,
2131
+ where `ImageAsset` is `{ id, label, url, width, height, thumbUrl }`. With no
2132
+ provider a `type: "image"` control degrades to a URL field.
2133
+
1953
2134
  **Showcase capture (the mount handle).** The handle can also render the user's *current*
1954
2135
  framing offscreen at a resolution independent of the window size and devicePixelRatio —
1955
2136
  for gallery/preview images, where grabbing the live canvas would be capped at the viewer
@@ -2477,7 +2658,24 @@ control that the control's own `allow` list would refuse — at build time it's
2477
2658
  swapped for `defaults[key]`, i.e. itself, so the part boots with no usable
2478
2659
  font; use a source `allow` accepts, or widen `allow`) (warning).
2479
2660
 
2480
- **Source rules** — the ninth group, which runs only when the caller hands over
2661
+ **Image controls** — the sibling group for `type: "image"` controls, `images`,
2662
+ and `k.heightfield()`. `image-control-not-in-images` (a `type: "image"`
2663
+ control's `key` is never returned by `images` — unlike the font rule above,
2664
+ this one actually calls a function-form `images` with the control's key set to
2665
+ a sentinel value and checks whether the sentinel comes back out, because a
2666
+ picker only silently does nothing if the function ignores that specific key,
2667
+ not just any key; a static `images` object provably can't depend on any param,
2668
+ so it is skipped there — that is a different mistake, not this rule's business)
2669
+ (error); `heightfield-unknown-image` (a build calls `k.heightfield(name, opts)`
2670
+ with a string `name` absent from a **static** `images` object — skipped when
2671
+ `images` is a function, since its keys aren't statically known; an inline
2672
+ `{width, height, data}` grid as the first argument is never flagged, since that
2673
+ is a supported call shape, not a name) (error); `image-source-scheme`
2674
+ (`defaults` holds a value for an image control that the control's own `allow`
2675
+ list would refuse — same shape as `font-source-scheme` above, including the
2676
+ empty-string and raw-bytes exemptions from `image-source.js`) (warning).
2677
+
2678
+ **Source rules** — the tenth group, which runs only when the caller hands over
2481
2679
  `sources` (above) — `control-default-not-literal` (a control's `defaults` entry is
2482
2680
  written as something other than a plain literal: an expression like `13 / 3`, an
2483
2681
  array or object, a template literal, a `0x10`/`1_000` spelling. Hosts persist a
@@ -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" 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"`, 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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.96.0",
3
+ "version": "0.97.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",
@@ -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
  };
@@ -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)