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.
- package/bin/cli.js +128 -9
- package/docs/AUTHORING-PARTS.md +371 -5
- package/docs/ERROR-PATTERNS.md +25 -1
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/docs/VECTOR-FORMAT.md +23 -17
- package/package.json +9 -1
- package/src/app-relief.js +16 -0
- package/src/framework/app.css +30 -0
- package/src/framework/backend-select.js +7 -2
- package/src/framework/font-source.js +18 -1
- package/src/framework/geometry/heightfield.js +129 -0
- package/src/framework/geometry/kernel.js +3 -0
- package/src/framework/geometry/manifold-backend.js +61 -0
- package/src/framework/geometry/occt-backend.js +148 -1
- package/src/framework/geometry/op-options.js +10 -0
- package/src/framework/geometry/png-decode.js +107 -0
- package/src/framework/geometry/solid-hash.js +96 -0
- package/src/framework/image-source.js +76 -0
- package/src/framework/images.js +66 -0
- package/src/framework/ingest/image-ingest.js +41 -0
- package/src/framework/ingest/node-dom.js +69 -0
- package/src/framework/ingest/registry.js +57 -0
- package/src/framework/ingest/sniff.js +96 -0
- package/src/framework/jobs.js +107 -6
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-images.js +109 -0
- package/src/framework/lint/rules-vector.js +68 -3
- package/src/framework/measure/measure-mode.js +2 -1
- package/src/framework/mount.js +14 -1
- package/src/framework/oracle/verify.js +6 -1
- package/src/framework/panel/image-picker.js +152 -0
- package/src/framework/panel/render.js +2 -0
- package/src/framework/panel/widget-specs.js +4 -0
- package/src/framework/panel/widgets/file-drop.js +321 -0
- package/src/framework/panel/widgets/font.js +51 -12
- package/src/framework/panel/widgets/image.js +178 -0
- package/src/framework/panel/widgets/index.js +11 -5
- package/src/framework/panel/widgets/vector.js +77 -0
- package/src/framework/param-deps.js +7 -2
- package/src/framework/vector-source.js +127 -0
- package/src/framework/vectors.js +9 -0
- package/src/ingest.js +1 -0
- package/src/parts/assets/relief-demo.png +0 -0
- package/src/parts/emblem.js +2 -1
- package/src/parts/relief.js +84 -0
- package/src/relief-worker.js +3 -0
- package/src/testing/manifold.js +7 -1
- package/src/testing/occt.js +4 -1
- package/types/index.d.ts +67 -0
- package/types/ingest.d.ts +13 -0
- package/types/kernel.d.ts +32 -0
- package/types/part.d.ts +21 -0
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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 `
|
|
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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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/docs/VECTOR-FORMAT.md
CHANGED
|
@@ -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
|
|
14
|
-
`partforge/ingest`,
|
|
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)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
(`
|
|
498
|
-
|
|
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
|
-
`
|
|
666
|
-
|
|
667
|
-
`ingestSvg()` (paper.js's
|
|
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`,
|
|
670
|
-
|
|
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.
|
|
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
|
+
});
|
package/src/framework/app.css
CHANGED
|
@@ -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 (
|
|
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)
|