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.
- package/bin/cli.js +14 -1
- package/docs/AUTHORING-PARTS.md +199 -1
- package/docs/ERROR-PATTERNS.md +24 -0
- package/docs/KERNEL-CONTRACT.md +1 -0
- package/package.json +1 -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/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-ingest.js +41 -0
- package/src/framework/image-source.js +72 -0
- package/src/framework/images.js +66 -0
- package/src/framework/jobs.js +55 -0
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-images.js +109 -0
- package/src/framework/measure/measure-mode.js +2 -1
- package/src/framework/mount.js +2 -1
- package/src/framework/oracle/verify.js +6 -1
- package/src/framework/panel/image-picker.js +152 -0
- package/src/framework/panel/render.js +1 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/image.js +164 -0
- package/src/framework/panel/widgets/index.js +9 -5
- package/src/framework/param-deps.js +7 -2
- package/src/index.js +1 -0
- package/src/parts/assets/relief-demo.png +0 -0
- 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 +13 -0
- package/types/kernel.d.ts +32 -0
- 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
|
|
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
|
};
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
-
**
|
|
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
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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.
|
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/package.json
CHANGED
|
@@ -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
|
};
|
|
@@ -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)
|