partforge 0.63.0 → 0.64.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 +2 -3
- package/docs/AUTHORING-PARTS.md +135 -0
- package/docs/ERROR-PATTERNS.md +29 -1
- package/docs/KERNEL-CONTRACT.md +15 -1
- package/package.json +1 -1
- package/src/app-import-demo.js +18 -0
- package/src/framework/app.css +8 -1
- package/src/framework/asset-resolve.js +71 -0
- package/src/framework/capture-build.js +12 -4
- package/src/framework/export-controller.js +12 -0
- package/src/framework/fonts.js +12 -30
- package/src/framework/geometry/kernel.js +2 -1
- package/src/framework/geometry/manifold-backend.js +41 -0
- package/src/framework/geometry/mesh-repair.js +87 -0
- package/src/framework/geometry/occt-backend.js +33 -1
- package/src/framework/geometry/stl-parse.js +45 -0
- package/src/framework/geometry/threemf-parse.js +87 -0
- package/src/framework/geometry-service.js +3 -1
- package/src/framework/imports.js +84 -0
- package/src/framework/jobs.js +20 -1
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-imports.js +115 -0
- package/src/framework/mount.js +94 -1
- package/src/framework/oracle/measure.js +28 -2
- package/src/framework/verify-metrics.js +10 -0
- package/src/framework/worker.js +11 -2
- package/src/import-demo-worker.js +3 -0
- package/src/parts/assets/import-demo-scan.stl +86 -0
- package/src/parts/import-demo.js +134 -0
- package/src/testing/assets.js +19 -0
- package/src/testing/manifold.js +14 -2
- package/src/testing/occt.js +5 -2
- package/src/testing/step-mesh-thread.js +15 -0
- package/src/testing/step-mesh.js +17 -0
- package/types/kernel.d.ts +6 -0
- package/types/part.d.ts +14 -0
package/bin/cli.js
CHANGED
|
@@ -80,10 +80,9 @@ async function loadPart(partPath, usage) {
|
|
|
80
80
|
// otherwise a part using a named font builds in the browser but dies headlessly
|
|
81
81
|
// with `text2d: unknown font …`.
|
|
82
82
|
const bootKernel = (part) => {
|
|
83
|
+
const opts = { fonts: part.fonts, imports: part.imports };
|
|
83
84
|
const backend = process.env.PARTFORGE_BACKEND || detectBackend(part); // env: crash()'s NEEDS_OCCT retry
|
|
84
|
-
return backend === "occt"
|
|
85
|
-
? bootOcctKernel({ fonts: part.fonts })
|
|
86
|
-
: bootManifoldKernel({ fonts: part.fonts });
|
|
85
|
+
return backend === "occt" ? bootOcctKernel(opts) : bootManifoldKernel(opts);
|
|
87
86
|
};
|
|
88
87
|
|
|
89
88
|
const commands = {
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -73,6 +73,7 @@ export default {
|
|
|
73
73
|
parameters, // the control-panel schema (array of sections — see below)
|
|
74
74
|
defaults, // flat { paramKey: value } — seeds params + control values
|
|
75
75
|
fonts?, // { name: source } — fonts a part's k.text2d() needs; framework preloads before build (see below)
|
|
76
|
+
imports?, // { name: source } — STEP/STL/3MF files a part's k.import() needs; same preload timing as fonts (see below)
|
|
76
77
|
derive?, // (p) => d, or { group: (p, d) => {…}, … } — dependent values computed once per build
|
|
77
78
|
parts: { // named sub-parts; each builds ONE solid
|
|
78
79
|
<name>: {
|
|
@@ -83,6 +84,7 @@ export default {
|
|
|
83
84
|
enabled?: (p) => boolean, // optional — gate a conditional sub-part
|
|
84
85
|
display?: { color?, opacity? }, // optional viewer-only override (0xRRGGBB / 0..1) — e.g. a reference/ghost part
|
|
85
86
|
export?: { name }, // filename/object name on export; defaults to the key
|
|
87
|
+
reference?: string, // name of a declared import — measure() computes a deviation fact against it (see below)
|
|
86
88
|
},
|
|
87
89
|
},
|
|
88
90
|
views: { <name>: { label, default?, animations? } }, // view tabs; a view may own animations (below)
|
|
@@ -126,6 +128,10 @@ export default {
|
|
|
126
128
|
})` can look the font up by name. See `src/framework/fonts.js` (`resolveFonts`) and
|
|
127
129
|
`k.text2d` in `docs/KERNEL-CONTRACT.md` for the full contract; fuller authoring guidance
|
|
128
130
|
(recommended font sourcing, licensing notes) lands in a follow-up pass.
|
|
131
|
+
- `imports` declares the STEP/STL/3MF files a part's `k.import()` calls need, same source
|
|
132
|
+
grammar and preload timing as `fonts` above. See "Importing geometry (STEP/STL/3MF)"
|
|
133
|
+
below for the full contract — backend matrix, units, the `reference` field + the
|
|
134
|
+
deviation gate, and caching.
|
|
129
135
|
|
|
130
136
|
---
|
|
131
137
|
|
|
@@ -1317,6 +1323,121 @@ Both backends produce watertight emboss/deboss geometry; the difference is expor
|
|
|
1317
1323
|
|
|
1318
1324
|
---
|
|
1319
1325
|
|
|
1326
|
+
## Importing geometry (STEP/STL/3MF)
|
|
1327
|
+
|
|
1328
|
+
`k.import(name)` returns a previously-registered imported file as an ordinary `Solid` — the same handle a `k.box()` or `k.loft()` call would give you. It exists for two uses: a **reference** the agent workflow measures and rebuilds a parametric part around (with a verify-time deviation gate holding the rebuild to it), or a **component** — a real body that participates in booleans, scaling, and export like any other solid. `src/parts/import-demo.js` is the worked example for both; read it alongside this section.
|
|
1329
|
+
|
|
1330
|
+
**Declaring imports (the `imports` PartDefinition field):**
|
|
1331
|
+
|
|
1332
|
+
Exactly the `fonts` grammar, one level up in the contract — a map of names to sources:
|
|
1333
|
+
|
|
1334
|
+
```js
|
|
1335
|
+
imports: {
|
|
1336
|
+
scan: new URL("./assets/import-demo-scan.stl", import.meta.url), // Vite serves it; Node reads disk
|
|
1337
|
+
lid: "https://…/signed-url.step", // URL string
|
|
1338
|
+
chip: bytesOrThunk, // ArrayBuffer/Uint8Array, or a (possibly async) thunk returning one
|
|
1339
|
+
},
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
The framework resolves these — fetch/read bytes, detect the format (filename extension when the source has one; a magic-bytes sniff otherwise — the `ISO-10303-21` STEP header, a `PK` zip signature for 3MF, else STL), and content-hash them — before the synchronous `build` runs, registering the parsed result on the kernel through an underscore-prefixed side-channel (see `docs/KERNEL-CONTRACT.md` § "Conformance classes"). Reference an import by name: `k.import("scan")`. An undeclared name throws (mirrors `text2d`'s unknown-font error) — see [ERROR-PATTERNS.md#import-unknown-name](ERROR-PATTERNS.md#import-unknown-name).
|
|
1343
|
+
|
|
1344
|
+
**Backend matrix:**
|
|
1345
|
+
|
|
1346
|
+
- **STEP on OCCT** — native: `replicad.importSTEP` builds a real B-rep, exact into STEP export.
|
|
1347
|
+
- **STEP on Manifold** — tessellated transparently: the framework routes an OCCT-worker tessellation pass behind the scenes (the "crossover" — see caching, below) and hands Manifold the resulting triangle mesh. Exactness is lost on this path; the STEP curves become facets at print quality, same as any other mesh geometry.
|
|
1348
|
+
- **STL/3MF on Manifold** — native: parsed, repaired (vertex merge + winding/orientation fix), and handed to `Manifold.ofMesh`. A mesh still non-manifold after repair throws loudly with the open-edge count — see [ERROR-PATTERNS.md#import-mesh-not-solid](ERROR-PATTERNS.md#import-mesh-not-solid).
|
|
1349
|
+
- **STL/3MF on OCCT** — never attempted: mesh-to-B-rep conversion isn't in scope for v1. Declaring a mesh import on a part (or sub-part, under per-sub-part routing) that routes to OCCT is an error — see [ERROR-PATTERNS.md#import-mesh-on-occt](ERROR-PATTERNS.md#import-mesh-on-occt).
|
|
1350
|
+
|
|
1351
|
+
`import` is not in `OCCT_ONLY_OPS` — a STEP import does not by itself force OCCT routing (the crossover exists precisely so it doesn't have to); backend selection is still driven by `fillet`/`chamfer`/`shell` on a `Solid`, or `meta.backend`.
|
|
1352
|
+
|
|
1353
|
+
**Units:** everything normalizes to millimetres at parse time — STEP units are honored by the OCCT importer, a 3MF file's `unit` attribute is converted, and **STL is assumed to already be in millimetres** (the format carries no unit metadata).
|
|
1354
|
+
|
|
1355
|
+
**Registration is total; errors are lazy.** Every declared import registers on whichever kernel runs a job, regardless of whether that kernel can actually use it — this is what keeps a mixed-format part (an OCCT sub-part and a Manifold sub-part with different import formats) from having one format's registration break the other's job. A format the running kernel can't use registers as an **error entry** instead of throwing at registration; the error throws from `k.import(name)` itself, at the point in a `build` that actually calls it. Two cases surface this way:
|
|
1356
|
+
|
|
1357
|
+
- **mesh import on OCCT** — throws immediately, every time (see the backend matrix above).
|
|
1358
|
+
- **unprimed STEP import on Manifold** — throws once, then self-heals: the framework's crossover machinery notices, arranges the OCCT-side tessellation (a `tessellate-imports` worker job in the browser, a `node:worker_threads` hop in the CLI/tests, since the two WASM kernels may never share a process), and retries the build. A build whose params never actually reach a `k.import()` call on that STEP file never triggers the crossover at all — the cost is paid only when the import is really used. If the crossover itself fails to produce a usable mesh, that surfaces as [ERROR-PATTERNS.md#import-step-tessellation-failed](ERROR-PATTERNS.md#import-step-tessellation-failed).
|
|
1359
|
+
|
|
1360
|
+
**Caching & content-stability:** import sources are **content-stable for a session** — the same rule as `fonts`. Bytes are memoized process-wide by source identity (not by digest) the first time a source resolves, and stay resident for the life of that worker/process: the raw bytes in the resolver's cache, and the parsed master (a Manifold mesh or an OCCT B-rep shape) in the kernel that parsed it. A multi-megabyte STEP or STL file is read and parsed once, not on every slider drag or view switch — but it also means a changed file on disk needs a fresh worker/process to be picked up (a rebind/remount, same as changing a font). Downstream, every op built from an import folds the file's content digest into its cache key (`h("import", name, digest)`), so an actually-changed file (a new digest) still invalidates every dependent cache node correctly. On the STEP-on-Manifold crossover, note that the file is fetched **independently by both workers** — the Manifold worker resolves it to get a digest, and the OCCT worker resolves it again to tessellate — so a large STEP file used this way is held in memory twice, once per worker.
|
|
1361
|
+
|
|
1362
|
+
**Performance:** a `reference` deviation check (below) costs one solid boolean per verify run. On a Manifold-routed part that's cheap; on an **OCCT-routed** part it's a full OpenCASCADE boolean against the entire imported B-rep, and `docs/geometry-backend-strategy.md` measures OCCT booleans at 75–1486× slower than the equivalent Manifold operation. A `reference`-bound sub-part on OCCT is a deliberate trade — exactness for STEP export vs. a slower `measure`/`verify` loop — worth knowing about before wiring one up on a large imported assembly.
|
|
1363
|
+
|
|
1364
|
+
**The `reference` field and the deviation gate:**
|
|
1365
|
+
|
|
1366
|
+
A sub-part can bind itself to an import by name; `measure()` then computes a `deviation` fact against it (symmetric-difference volume, volume delta %, and per-axis bbox-corner drift), which three `ref*` metrics in `verify.expect` can gate on — the same `SUBPART_METRICS` registry `holes`/`volume`/`bbox` live in, so they take the same assertion DSL:
|
|
1367
|
+
|
|
1368
|
+
```js
|
|
1369
|
+
parts: {
|
|
1370
|
+
body: {
|
|
1371
|
+
reference: "scan", // an import name — measure() computes s.deviation against it
|
|
1372
|
+
build: (k, p) => k.box({ min: [0, 0, 0], max: [p.scanW, p.scanD, p.scanH] }),
|
|
1373
|
+
},
|
|
1374
|
+
},
|
|
1375
|
+
verify: {
|
|
1376
|
+
expect: {
|
|
1377
|
+
body: {
|
|
1378
|
+
refXorVolume: "<=5mm3", // symmetric-difference volume — the real match check
|
|
1379
|
+
refVolumeDeltaPct: "<=1", // cheap sanity gate, % of the reference's volume
|
|
1380
|
+
refBboxDelta: "<=[0.2,0.2,0.2]", // mm, per-axis max of |min|/|max| corner deltas
|
|
1381
|
+
},
|
|
1382
|
+
},
|
|
1383
|
+
},
|
|
1384
|
+
```
|
|
1385
|
+
|
|
1386
|
+
Deviation is measured in build coordinates on the posed display solid — aligning the rebuild to the reference is the part author's job, and the ghost overlay (next) is how you check it by eye. A sub-part with no `reference` gets `deviation: null` and skips any `ref*` assertion rather than failing it; `npx partforge lint` catches the inverse mistake — a `ref*` assertion on a sub-part that declares no `reference` — statically, as `ref-metric-without-reference` (see "Linting" → Rule catalog → "Geometry imports", below).
|
|
1387
|
+
|
|
1388
|
+
**The two-view ghost pattern:** `measure()`'s `ok` gate is **view-scoped and overlap-strict** — it requires zero sub-part overlaps among whatever the *current* view shows. A translucent ghost of the raw import, shown in the same view as its parametric rebuild, is by construction coincident with that rebuild — so that view's `overlaps` would always read greater than zero, failing `verify` on a part that is otherwise exactly correct. The fix is not a bigger overlap tolerance; it's two views:
|
|
1389
|
+
|
|
1390
|
+
```js
|
|
1391
|
+
parts: {
|
|
1392
|
+
// Ghost overlay: only in "reference" — never coincides with body in "assembly".
|
|
1393
|
+
ref: {
|
|
1394
|
+
label: "Reference (ghost)",
|
|
1395
|
+
views: ["reference"],
|
|
1396
|
+
exportable: false,
|
|
1397
|
+
display: { opacity: 0.3 },
|
|
1398
|
+
build: (k) => k.import("scan"),
|
|
1399
|
+
},
|
|
1400
|
+
// The parametric rebuild — shown alone in "assembly", and against the ghost
|
|
1401
|
+
// in "reference" for visual alignment checking.
|
|
1402
|
+
body: {
|
|
1403
|
+
label: "Rebuild",
|
|
1404
|
+
views: ["assembly", "reference"],
|
|
1405
|
+
reference: "scan",
|
|
1406
|
+
build: (k, p) => k.box({ min: [0, 0, 0], max: [p.scanW, p.scanD, p.scanH] }),
|
|
1407
|
+
},
|
|
1408
|
+
},
|
|
1409
|
+
views: { assembly: { label: "Assembly" }, reference: { label: "Reference overlay" } },
|
|
1410
|
+
verify: {
|
|
1411
|
+
expect: {
|
|
1412
|
+
body: { refXorVolume: "<=5mm3", /* … */ },
|
|
1413
|
+
_view: { overlaps: 0 }, // checked against the DEFAULT ("assembly") view only
|
|
1414
|
+
},
|
|
1415
|
+
},
|
|
1416
|
+
```
|
|
1417
|
+
|
|
1418
|
+
`assembly` is listed first so `measure`/`verify`/`render` — which all default to the **first** view key, `default: true` notwithstanding — see only the real, non-overlapping parts. `reference` is the ghost-overlay view: browse it by hand in the viewer, or pass it explicitly to `measure`/`render`, to eyeball how closely the rebuild tracks the scan. Declare `ref` `exportable: false` (it's not a real part of the design) and give it a `display.opacity` well under 1 so it reads as an overlay rather than an opaque duplicate. This is exactly `src/parts/import-demo.js`'s shape — read its `parts.ref`/`parts.body`/`views`/`verify` blocks for the fully worked, commented version.
|
|
1419
|
+
|
|
1420
|
+
**Using an import as a real component** — the other use, no ghost involved — is an ordinary boolean, chainable like any `Solid`: `import-demo.js`'s `mount` sub-part cuts a through-socket shaped to the scan itself (scaled up slightly for clearance) out of a plate:
|
|
1421
|
+
|
|
1422
|
+
```js
|
|
1423
|
+
build: (k, p, d) => {
|
|
1424
|
+
const plate = k.box({
|
|
1425
|
+
min: [d.mountOffsetX - p.margin, -p.margin, -p.plateH],
|
|
1426
|
+
max: [d.mountOffsetX + p.scanW * p.fit + p.margin, p.scanD * p.fit + p.margin, 0],
|
|
1427
|
+
});
|
|
1428
|
+
const socket = k.import("scan")
|
|
1429
|
+
.scale(p.fit)
|
|
1430
|
+
.translate([d.mountOffsetX, 0, -p.plateH - 1]); // overcut past both plate faces
|
|
1431
|
+
return plate.cut(socket);
|
|
1432
|
+
},
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
**Linting:** `npx partforge lint` learns `import` as a known op and adds four static checks — `import-unknown-name`, `import-mesh-on-occt`, `reference-unknown`, `ref-metric-without-reference` — described in full under "Linting" → Rule catalog → "Geometry imports", below; this section only points there rather than repeating it.
|
|
1436
|
+
|
|
1437
|
+
**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.
|
|
1438
|
+
|
|
1439
|
+
---
|
|
1440
|
+
|
|
1320
1441
|
## Wiring a part into a runnable app
|
|
1321
1442
|
|
|
1322
1443
|
Three tiny glue files per part (copy from the demo). The worker statically imports
|
|
@@ -1795,6 +1916,20 @@ motion — translate/rotate — never a reshape) (both errors). An untrusted pro
|
|
|
1795
1916
|
nothing either way and stays silent, matching `animation-track-rebuilds`'s own
|
|
1796
1917
|
trust handling.
|
|
1797
1918
|
|
|
1919
|
+
**Geometry imports** — `import-unknown-name` (a build calls `k.import` with a
|
|
1920
|
+
name the part's `imports` field doesn't declare — this throws at build time;
|
|
1921
|
+
lint reaches it in microseconds instead), `import-mesh-on-occt` (a declared
|
|
1922
|
+
STL/3MF import on a part that routes to OCCT — mesh imports need the Manifold
|
|
1923
|
+
backend; the message names whether `meta.backend` or a CAD op forced OCCT.
|
|
1924
|
+
Only extension-detectable `imports` sources — a `URL` or string path — are
|
|
1925
|
+
checked; a bytes/thunk source's format can't be known without resolving it, so
|
|
1926
|
+
lint skips it and the lazy `k.import` error entry at build time remains the
|
|
1927
|
+
runtime authority for those cases), `reference-unknown` (a sub-part's
|
|
1928
|
+
`reference` names no declared import) (all errors); `ref-metric-without-reference`
|
|
1929
|
+
(a sub-part's `verify.expect` uses a `ref*` metric — `refXorVolume`,
|
|
1930
|
+
`refVolumeDeltaPct`, `refBboxDelta` — but the sub-part declares no `reference`,
|
|
1931
|
+
so the deviation gate always reports status "skip") (warning).
|
|
1932
|
+
|
|
1798
1933
|
A rule that itself throws yields an `internal-rule-error` **warning** and the run
|
|
1799
1934
|
continues: `lintPart` never throws and never blocks a part because of a linter bug.
|
|
1800
1935
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -545,6 +545,35 @@ exposure is a `build()` that queries a twisted solid's box itself, which is the
|
|
|
545
545
|
normal idiom for placing something relative to a solid and now silently disagrees
|
|
546
546
|
between the Manifold preview and the OCCT STEP export.
|
|
547
547
|
|
|
548
|
+
## import-mesh-not-solid
|
|
549
|
+
|
|
550
|
+
- **Symptom:** `import "<name>": mesh is not a solid after repair (<n> open edges) — repair it in a mesh tool or re-export watertight (<reason>)` thrown while registering a part's `imports` on the Manifold backend — the trailing `(<reason>)` always appears (it wraps the underlying Manifold error, e.g. `non-manifold edge` or `empty result`), never omitted.
|
|
551
|
+
- **Cause:** Basic repair (vertex merge + winding/orientation fix) couldn't close the mesh into a solid — the source STL/3MF has an open shell, missing faces, or another defect beyond what v1's repair pass attempts. The open-edge count and the wrapped `<reason>` in the message together narrow down the gap.
|
|
552
|
+
- **Fix:** Close the mesh in a dedicated mesh-repair tool, or re-export it watertight from the tool that produced it; there is no in-framework hole-filling/remeshing. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Importing geometry (STEP/STL/3MF)".
|
|
553
|
+
|
|
554
|
+
## import-unrecognized-format
|
|
555
|
+
|
|
556
|
+
- **Symptom:** `unrecognized import format for "<path>" — use a .step/.stl/.3mf extension or non-empty bytes` (the `for "<path>"` clause is omitted for a bytes/thunk source with no path) thrown while resolving a part's `imports`.
|
|
557
|
+
- **Cause:** Format detection couldn't identify the source: no `.step`/`.stp`/`.stl`/`.3mf` extension on a URL/string source, and the bytes are empty or start with neither a STEP header (`ISO-10303-21`) nor a zip signature (3MF) — the ASCII/binary STL fallback needs non-empty bytes too.
|
|
558
|
+
- **Fix:** Give the source a recognized extension, or make sure inline/fetched bytes are non-empty and actually STEP/STL/3MF content. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Importing geometry (STEP/STL/3MF)".
|
|
559
|
+
|
|
560
|
+
## import-unknown-name
|
|
561
|
+
|
|
562
|
+
- **Symptom:** `import: unknown import "<name>"` — declare it in the part's `imports` field — thrown from a build, identical text on both backends.
|
|
563
|
+
- **Cause:** `k.import(name)` was called with a name that isn't a key in the part's `imports` field — a typo, or the declaration was never added. Same failure shape as `text2d`'s unknown-font error.
|
|
564
|
+
- **Fix:** Add the name to `imports`, or fix the typo. `npx partforge lint <part>` catches this statically, in microseconds, before any kernel boots (`import-unknown-name`). See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Importing geometry (STEP/STL/3MF)" and § "Linting" (Rule catalog → Geometry imports).
|
|
565
|
+
|
|
566
|
+
## import-mesh-on-occt
|
|
567
|
+
|
|
568
|
+
- **Symptom:** `import "<name>": STL/3MF imports need the Manifold backend — this build routes to OCCT (shell or meta.backend, or a fillet/chamfer rerouted for an unsupported edge class); use the mesh import from a Manifold-routed build` thrown from a build calling `k.import(name)`.
|
|
569
|
+
- **Cause:** STL/3MF imports are mesh geometry; mesh-to-B-rep conversion is never attempted, so a declared mesh import registers as an unusable error entry wherever the routed kernel is OCCT — thrown lazily, at the `k.import()` call inside `build`, not when the import is registered (registration itself never throws — see "Registration is total; errors are lazy" in [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Importing geometry (STEP/STL/3MF)").
|
|
570
|
+
- **Fix:** Per-sub-part Manifold/OCCT coexistence is a **browser-preview-only** convenience — `npx partforge lint`/`measure`, and any single-worker export (STL/STEP/3MF), route by `detectBackend`, the **max over every sub-part**, so a mesh import still fails there even while sitting on a nominally Manifold-routed sub-part, as long as some OTHER sub-part in the same part routes to OCCT (a `shell` call or `meta.backend: "occt"` statically; since contract v3 a `fillet`/`chamfer` routes only at runtime, when its edge class falls outside the mesh blend — see mesh-fillet-unsupported-edge, below). Putting the import on a Manifold sub-part only helps the live preview; it does not clear this error. Pick one: split the mesh-importing sub-part out into its **own separate part** (a different `PartDefinition`) that has no OCCT-only ops, replace the source with a STEP file instead (STEP tessellates transparently on Manifold via the crossover — see import-step-tessellation-failed, below), or drop the CAD-only op / `meta.backend` pin so the whole part routes to Manifold. `npx partforge lint <part>` catches an extension-detectable case statically, before any kernel boots (`import-mesh-on-occt`).
|
|
571
|
+
|
|
572
|
+
## import-step-tessellation-failed
|
|
573
|
+
|
|
574
|
+
- **Symptom:** `STEP import tessellation failed to satisfy the import — see console` in the browser (a build's status/error), or `step tessellation thread exited <n>` from a failed Node CLI/test run.
|
|
575
|
+
- **Cause:** A STEP import used on the Manifold backend needs OCCT-tessellated triangles first (the "crossover" described in [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Importing geometry (STEP/STL/3MF)"). This hop is meant to self-heal — the browser worker requests a `tessellate-imports` job from the OCCT worker, Node hops through a `node:worker_threads` isolate (the two WASM kernels may never share a process) — but it surfaces here when the tessellation either delivered a mesh whose digest didn't match what Manifold now expects (a genuinely broken state, not a retry loop) or the worker/thread exited without completing.
|
|
576
|
+
- **Fix:** This should be rare and self-resolving on the next build; if it persists, confirm the STEP file parses under OCCT on its own (e.g. `meta.backend: "occt"` temporarily, or `npx partforge measure` against an OCCT-routed copy of the part) to rule out a malformed STEP file, and check the console/thread output for the underlying tessellation error being wrapped.
|
|
548
577
|
## mesh-fillet-unsupported-edge
|
|
549
578
|
|
|
550
579
|
- **Symptom:** `fillet: ` or `chamfer: ` followed by an edge-class reason — e.g. `edge curve is not circular`, `flank angle varies along the arc`, `selector matched no sharp edges`, `~180° knife edge` — thrown as a `KernelCapabilityError`, or a preview sub-part silently rebuilding on the slow OCCT worker.
|
|
@@ -556,7 +585,6 @@ between the Manifold preview and the OCCT STEP export.
|
|
|
556
585
|
- **Symptom:** A Manifold-built fillet/chamfer produces a mangled or over-cut shape (no error), where the same part on OCCT would skip the feature with a `fillet(…) failed` warning.
|
|
557
586
|
- **Cause:** The mesh fillet does not validate radius feasibility — a magnitude larger than the local geometry self-intersects its cutter solids and the booleans happily apply them.
|
|
558
587
|
- **Fix:** Clamp the magnitude against local dimensions in the part (`Math.min(p.fillet, halfWidth - 0.5, …)` — see `src/parts/filleted-box.js`), which is required practice on the mesh class per [KERNEL-CONTRACT.md](KERNEL-CONTRACT.md) § "Mesh degrade policy".
|
|
559
|
-
|
|
560
588
|
# Hardware library
|
|
561
589
|
|
|
562
590
|
Reserved for `hardware-*` patterns (issue #30). No entries yet.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -93,6 +93,15 @@ them loses sub-part caching and mesh-topology gates (`holes`, emptiness), nothin
|
|
|
93
93
|
a part (never inside a `beginSubPart`/`endSubPart` bracket), it drops cache partitions that
|
|
94
94
|
have gone unbuilt for three consecutive rebinds.
|
|
95
95
|
|
|
96
|
+
**`import`.** `kernel.import(name) → Solid` returns previously-registered imported geometry
|
|
97
|
+
(STL/STEP/3MF geometry declared in a part's `imports` field); `_registerImport`/
|
|
98
|
+
`_importDigest`/`_acceptsStep`/`_acceptsMesh` are the underscore-prefixed side-channel the
|
|
99
|
+
framework uses to feed it — not a part author's calling surface. It is a required op
|
|
100
|
+
(`KERNEL_OPS`) on both in-repo backends: the Manifold backend accepts mesh formats
|
|
101
|
+
(`_acceptsMesh: true`) and the OCCT backend accepts STEP (`_acceptsStep: true`); a format
|
|
102
|
+
neither backend accepts for the routed kernel registers as an `{error}` entry that
|
|
103
|
+
`import(name)` throws lazily at call time, not at registration.
|
|
104
|
+
|
|
96
105
|
`KernelCapabilityError` is a *routing signal*, not a failure: partforge's geometry-free
|
|
97
106
|
probe (`probe.js`) runs `build` against a fake kernel, and any use of a
|
|
98
107
|
`ROUTED_CAD_OPS` op (`shell`, since v3) **on a Solid handle** routes the build to a
|
|
@@ -248,6 +257,7 @@ above. All ops return a `Solid`.
|
|
|
248
257
|
| `hull(inputs[])` | Convex hull of all inputs (each a `Shape2D`, a curve contour, or an `[[x,y],…]` point list) → a convex `Shape2D`. Backend-agnostic: a pure-JS monotone-chain hull over the inputs' sampled points (curved inputs tessellated at a fixed LOD), lifted via `shape2d` (see the parity note below). Throws on an empty input array or a degenerate (collinear/point-count < 3) hull. |
|
|
249
258
|
| `hullChain(inputs[])` | Swept hull over an ordered sequence of ≥2 inputs (same input forms as `hull`): the union of `hull([inᵢ, inᵢ₊₁])` for each consecutive pair — e.g. a tapered link connecting a row of circles. Throws with fewer than 2 inputs. |
|
|
250
259
|
| `toSTEP(named[])` | `[{name, solid}]` → `Promise<ArrayBuffer>` of a STEP assembly. B-rep class only. |
|
|
260
|
+
| `import(name)` | Previously-registered imported geometry (STEP/STL/3MF, declared in the part's `imports` field) as an ordinary `Solid`. Required on both in-repo backends (Manifold accepts mesh formats, OCCT accepts STEP); a format the routed backend can't use throws lazily, at this call, not at registration. Fed by an underscore-prefixed side-channel, not part authors — see [Conformance classes](#conformance-classes). Additive: not in `OCCT_ONLY_OPS`; needed no `CONTRACT_VERSION` bump of its own (v3 came from the mesh fillet/chamfer change, not this op). |
|
|
251
261
|
|
|
252
262
|
`hull`/`hullChain` parity: point-list and curve-contour inputs hull bit-identically
|
|
253
263
|
across backends (pure-JS sampling, no backend materialization involved). A `Shape2D`
|
|
@@ -666,7 +676,11 @@ other posts — `progress`, `error`, `needs-occt` — are not gated and still re
|
|
|
666
676
|
that survived the rebind: a stale `error` would mark a perfectly good new part failed, and
|
|
667
677
|
a stale `needs-occt` would stickily flip the host's backend for a part that never asked for
|
|
668
678
|
it. Handling `superseded` fixes the stuck spinner but not that crosstalk, which is why
|
|
669
|
-
detaching the listener is the recommended pattern.
|
|
679
|
+
detaching the listener is the recommended pattern. `needs-import-mesh` — posted when a
|
|
680
|
+
build throws an error carrying code `NEEDS_IMPORT_MESH` (an unprimed STEP import on the
|
|
681
|
+
Manifold backend, thrown from `imports.js`'s registration policy, not `errors.js`'s
|
|
682
|
+
`KernelCapabilityError`/`NEEDS_OCCT`) — is the same message shape and the same
|
|
683
|
+
non-epoch-gated risk as `needs-occt`; a host handling one should handle both the same way.
|
|
670
684
|
|
|
671
685
|
**Cancellation granularity is the sub-part.** The guard is checked only between
|
|
672
686
|
sub-parts (one macrotask yield each), so a single long WASM op — a big boolean, an OCCT
|
package/package.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
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 importDemoPart from "./parts/import-demo.js";
|
|
7
|
+
import { mount } from "./framework/index.js";
|
|
8
|
+
|
|
9
|
+
// Dev-only example app for the import-demo part (geometry import reference
|
|
10
|
+
// example). Identical wiring to app.js — the only thing that differs per part
|
|
11
|
+
// is which definition you import and which worker entry you point at.
|
|
12
|
+
// `npm run dev`, then open /import-demo.html.
|
|
13
|
+
// Dev-only: the handle is stashed on window so scripts/check-app.mjs can drive
|
|
14
|
+
// the embedding contract (runtime.captureCurrent) the way an embedder would.
|
|
15
|
+
window.__pfRuntime = mount(importDemoPart, {
|
|
16
|
+
createWorker: (name) =>
|
|
17
|
+
new Worker(new URL("./import-demo-worker.js", import.meta.url), { type: "module", name }),
|
|
18
|
+
});
|
package/src/framework/app.css
CHANGED
|
@@ -285,7 +285,14 @@ button.action:focus-visible, .adv-toggle:focus-visible, .sec-title:focus-visible
|
|
|
285
285
|
border-radius: var(--pf-radius-pill);
|
|
286
286
|
box-shadow: var(--pf-shadow-float);
|
|
287
287
|
}
|
|
288
|
-
|
|
288
|
+
/* nowrap: `.seg button` is `flex: 1` (equal-width columns), and a part with
|
|
289
|
+
two views where one label is long (e.g. import-demo's "Reference overlay")
|
|
290
|
+
would otherwise wrap onto a second line, making this pill taller than the
|
|
291
|
+
single fixed height debug-overlay.js positions itself below (see its
|
|
292
|
+
comment) — the overlay would then overlap the now-two-row tabs. Letting the
|
|
293
|
+
label force the column wider instead keeps the pill single-row regardless
|
|
294
|
+
of label length. */
|
|
295
|
+
#topbar .seg button { min-width: 70px; padding: 7px 10px; white-space: nowrap; }
|
|
289
296
|
|
|
290
297
|
/* viewer controls. APPEARANCE is ungated: partforge-cloud re-anchors #viewbar's
|
|
291
298
|
position in sandbox.css but inherits this pill chrome, so gating it on a class
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Shared source-resolution core for fonts.js and imports.js. Both resolve a
|
|
2
|
+
// part's declared `{ name: source }` map before the synchronous build, where a
|
|
3
|
+
// source is: an ArrayBuffer/typed-array view (bytes), a URL string, a `URL`
|
|
4
|
+
// instance (fetched), or a thunk (possibly async) returning any of those —
|
|
5
|
+
// including the `{ default: … }` shape a Vite dynamic `import('./x.ttf')`
|
|
6
|
+
// yields. The two callers differ only in what they do with the resolved bytes
|
|
7
|
+
// (fonts keep the raw ArrayBuffer; imports also stamp a digest + format), so
|
|
8
|
+
// each owns its own cache Map and result shape; this module owns just the
|
|
9
|
+
// source→bytes grammar and the identity-memoization rule (a source, e.g. a
|
|
10
|
+
// thunk, is content-stable for a session — resolve it once). DOM-free and
|
|
11
|
+
// node:-free so it stays safe in the geometry worker's import closure.
|
|
12
|
+
|
|
13
|
+
export function toBuffer(v) {
|
|
14
|
+
if (v instanceof ArrayBuffer) return v;
|
|
15
|
+
// A view may not span its whole backing buffer — slice to its exact range (Node
|
|
16
|
+
// Buffer pooling makes byteOffset>0 common for small files; v.buffer alone would
|
|
17
|
+
// be garbage).
|
|
18
|
+
if (ArrayBuffer.isView(v)) return v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A source string/URL for an error message — truncated so a very long signed
|
|
23
|
+
// Storage URL (query string full of credentials-shaped junk) doesn't blow up
|
|
24
|
+
// the message.
|
|
25
|
+
function describeSource(v) {
|
|
26
|
+
const s = String(v);
|
|
27
|
+
return s.length > 200 ? `${s.slice(0, 200)}…` : s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Build a memoized `resolveOne(source)` for one caller. `finish(bytes, value,
|
|
31
|
+
// source)` turns the resolved bytes into that caller's result shape (may be
|
|
32
|
+
// async); `errorMessage` is thrown when `source` doesn't match the grammar.
|
|
33
|
+
// Results are cached on the caller-supplied `cache` Map, keyed by source
|
|
34
|
+
// identity, so a repeated declaration resolves (and fetches) only once.
|
|
35
|
+
export function makeAssetResolver(cache, finish, errorMessage) {
|
|
36
|
+
return function resolveOne(source) {
|
|
37
|
+
if (cache.has(source)) return cache.get(source);
|
|
38
|
+
const p = (async () => {
|
|
39
|
+
let v = source;
|
|
40
|
+
if (typeof v === "function") v = await v();
|
|
41
|
+
if (v && typeof v === "object" && "default" in v && !toBuffer(v) && !(v instanceof URL)) v = v.default; // dynamic-import module
|
|
42
|
+
let bytes = toBuffer(v);
|
|
43
|
+
if (!bytes) {
|
|
44
|
+
if (v instanceof URL || typeof v === "string") {
|
|
45
|
+
const res = await fetch(v);
|
|
46
|
+
// A signed Storage URL (cloud's designed source shape) expires and
|
|
47
|
+
// then 404s/403s — without this check the error body resolves as
|
|
48
|
+
// geometry/font bytes and fails downstream as a misleading parse
|
|
49
|
+
// error instead of naming the real problem.
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
throw new Error(`fetch failed (${res.status}${res.statusText ? ` ${res.statusText}` : ""}) for ${describeSource(v)}`);
|
|
52
|
+
}
|
|
53
|
+
bytes = await res.arrayBuffer();
|
|
54
|
+
}
|
|
55
|
+
else throw new Error(errorMessage);
|
|
56
|
+
}
|
|
57
|
+
return finish(bytes, v, source);
|
|
58
|
+
})();
|
|
59
|
+
cache.set(source, p);
|
|
60
|
+
return p;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve a `{ name: source }` declaration to `Map<name, result>` using a
|
|
65
|
+
// `resolveOne` built by `makeAssetResolver`.
|
|
66
|
+
export async function resolveDecl(decl, resolveOne) {
|
|
67
|
+
const out = new Map();
|
|
68
|
+
if (!decl) return out;
|
|
69
|
+
await Promise.all(Object.entries(decl).map(async ([name, src]) => out.set(name, await resolveOne(src))));
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
@@ -29,15 +29,23 @@ export function createCaptureBuild({ send }) {
|
|
|
29
29
|
// caller — mount.js's onWorkerMessage — can skip it entirely). Keyed on
|
|
30
30
|
// membership in `pending` first: the namespaced jobId guarantees another
|
|
31
31
|
// channel's message never matches, so a hit here is always ours. A failed
|
|
32
|
-
// build (the worker's shared catch posts a generic error/needs-occt
|
|
33
|
-
// intact) resolves to null rather than leaving the
|
|
34
|
-
// captureView treats null as "capture failed, skip".
|
|
32
|
+
// build (the worker's shared catch posts a generic error/needs-occt/
|
|
33
|
+
// needs-import-mesh, jobId intact) resolves to null rather than leaving the
|
|
34
|
+
// caller hanging forever — captureView treats null as "capture failed, skip".
|
|
35
|
+
// needs-import-mesh MUST be claimed here rather than falling through to
|
|
36
|
+
// mount's live-loop crossover case: this capture job never went through the
|
|
37
|
+
// regen loop, so treating its reply as a live crossover would call
|
|
38
|
+
// loop.buildDone() for a build the live loop never dispatched, and could
|
|
39
|
+
// fire a stray tessellate-imports request or flip the live importMeshState
|
|
40
|
+
// latch. A capture-generate hit on an unprimed STEP import is not itself
|
|
41
|
+
// retried — the v1 behavior is "fail this off-loop op cleanly"; the import
|
|
42
|
+
// gets primed by the next live build instead.
|
|
35
43
|
function handleMessage(data) {
|
|
36
44
|
const jobId = data?.jobId;
|
|
37
45
|
if (jobId == null || !pending.has(jobId)) return false;
|
|
38
46
|
if (data.type === "capture-meshes") {
|
|
39
47
|
pending.get(jobId)(data.meshes);
|
|
40
|
-
} else if (data.type === "error" || data.type === "needs-occt") {
|
|
48
|
+
} else if (data.type === "error" || data.type === "needs-occt" || data.type === "needs-import-mesh") {
|
|
41
49
|
pending.get(jobId)(null);
|
|
42
50
|
} else {
|
|
43
51
|
return false;
|
|
@@ -50,6 +50,18 @@ export function createExportController({ send, currentView, title, defaultBacken
|
|
|
50
50
|
}
|
|
51
51
|
if (m.type === "error") { pending.delete(m.jobId); entry.reject(new Error(m.message)); return true; }
|
|
52
52
|
if (m.type === "needs-occt") { pending.delete(m.jobId); entry.reject(new Error("needs OCCT backend")); return true; }
|
|
53
|
+
// needs-import-mesh MUST be claimed here (jobId intact from the worker's
|
|
54
|
+
// shared catch) rather than falling through to mount's live-loop crossover
|
|
55
|
+
// case: this export job never went through the regen loop, so treating its
|
|
56
|
+
// reply as a live crossover would call loop.buildDone() for a build the
|
|
57
|
+
// live loop never dispatched. v1 behavior is to fail this off-loop op
|
|
58
|
+
// cleanly rather than build a second crossover flow for it — the import
|
|
59
|
+
// gets primed by the next live build instead.
|
|
60
|
+
if (m.type === "needs-import-mesh") {
|
|
61
|
+
pending.delete(m.jobId);
|
|
62
|
+
entry.reject(new Error("STEP import needs tessellation — retry after the first preview build primes it"));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
53
65
|
return false;
|
|
54
66
|
}
|
|
55
67
|
|
package/src/framework/fonts.js
CHANGED
|
@@ -1,36 +1,18 @@
|
|
|
1
1
|
// Resolve a part's declared `fonts` ({ name: source }) to ArrayBuffers, before the
|
|
2
2
|
// synchronous build. A source is: an ArrayBuffer/Uint8Array (bytes), a URL string
|
|
3
|
-
// (fetched — a Vite `import('./x.ttf')` yields { default: url }),
|
|
4
|
-
// returning any of those (possibly async). Memoized process-wide by
|
|
5
|
-
// repeated builds don't refetch. DOM-free (uses global fetch, present in
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
function toBuffer(v) {
|
|
9
|
-
if (v instanceof ArrayBuffer) return v;
|
|
10
|
-
// A view may not span its whole backing buffer — slice to its exact range (Node Buffer
|
|
11
|
-
// pooling makes byteOffset>0 common for small files; v.buffer alone would be garbage).
|
|
12
|
-
if (ArrayBuffer.isView(v)) return v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength);
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
3
|
+
// or `URL` instance (fetched — a Vite `import('./x.ttf')` yields { default: url }),
|
|
4
|
+
// or a thunk returning any of those (possibly async). Memoized process-wide by
|
|
5
|
+
// source so repeated builds don't refetch. DOM-free (uses global fetch, present in
|
|
6
|
+
// workers). Built on the shared resolution core in asset-resolve.js.
|
|
7
|
+
import { makeAssetResolver, resolveDecl } from "./asset-resolve.js";
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const buf = toBuffer(v);
|
|
23
|
-
if (buf) return buf;
|
|
24
|
-
if (typeof v === "string") return await (await fetch(v)).arrayBuffer();
|
|
25
|
-
throw new Error("resolveFonts: a font source must be bytes, a URL string, or a thunk returning one");
|
|
26
|
-
})();
|
|
27
|
-
cache.set(source, p);
|
|
28
|
-
return p;
|
|
29
|
-
}
|
|
9
|
+
const cache = new Map(); // source (string|object) → Promise<ArrayBuffer>
|
|
10
|
+
const resolveOne = makeAssetResolver(
|
|
11
|
+
cache,
|
|
12
|
+
(bytes) => bytes,
|
|
13
|
+
"resolveFonts: a font source must be bytes, a URL, or a thunk returning one",
|
|
14
|
+
);
|
|
30
15
|
|
|
31
16
|
export async function resolveFonts(fontsDecl) {
|
|
32
|
-
|
|
33
|
-
if (!fontsDecl) return out;
|
|
34
|
-
await Promise.all(Object.entries(fontsDecl).map(async ([name, src]) => out.set(name, await resolveOne(src))));
|
|
35
|
-
return out;
|
|
17
|
+
return resolveDecl(fontsDecl, resolveOne);
|
|
36
18
|
}
|
|
@@ -22,7 +22,7 @@ export const CONTRACT_VERSION = 4;
|
|
|
22
22
|
export const KERNEL_OPS = [
|
|
23
23
|
"cylinder", "boredCylinder", "sphere", "box", "prism", "extrude", "revolve",
|
|
24
24
|
"loft", "sweep", "helixSweptTube", "screwSweep", "union", "shape2d", "text2d", "hull", "hullChain", "toSTEP",
|
|
25
|
-
"roundedCylinder", "torus", "roundedBox",
|
|
25
|
+
"roundedCylinder", "torus", "roundedBox", "import",
|
|
26
26
|
];
|
|
27
27
|
|
|
28
28
|
// Backend-optional kernel ops: the sub-part cache brackets + WASM lifetime hooks.
|
|
@@ -146,6 +146,7 @@ export const ROUTED_CAD_OPS = ["shell"];
|
|
|
146
146
|
* @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hull convex hull of all inputs → a convex Shape2D (faceted; pure-JS monotone chain)
|
|
147
147
|
* @property {(inputs: (Shape2D|number[][]|{start:number[],segments:object[]})[]) => Shape2D} hullChain swept hull over an ordered sequence (≥2): union of hull([inᵢ,inᵢ₊₁])
|
|
148
148
|
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
|
|
149
|
+
* @property {(name: string) => Solid} import imported geometry declared in the part's imports field (registered pre-build by the framework via the underscore-prefixed `_registerImport`/`_importDigest`/`_acceptsStep`/`_acceptsMesh` side-channel, not a part author's calling surface)
|
|
149
150
|
* @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (both backends)
|
|
150
151
|
* @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
|
|
151
152
|
* @property {() => void} [sweepCache] drop cache partitions idle for 3 rebinds; call once per setPart, never mid-bracket
|
|
@@ -4,6 +4,8 @@ import { sweepMesh } from "./sweep.js";
|
|
|
4
4
|
import { roundedBoxRings } from "./rounded-solids.js";
|
|
5
5
|
import { tessellateContour, tessellateProfile } from "./profile.js";
|
|
6
6
|
import { h } from "./solid-hash.js";
|
|
7
|
+
import { ensureOutward, openEdgeCount } from "./mesh-repair.js";
|
|
8
|
+
import { manifoldFromMesh } from "./mesh-build.js";
|
|
7
9
|
import { createSolidCache } from "./solid-cache.js";
|
|
8
10
|
import { addSugar } from "./solid-sugar.js";
|
|
9
11
|
import { makeShape2dFactory } from "./shape2d.js";
|
|
@@ -49,6 +51,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
49
51
|
const cache = createSolidCache();
|
|
50
52
|
const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
|
|
51
53
|
const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
|
|
54
|
+
// name -> { m, digest, hash } | { error, digest } — imported geometry the framework
|
|
55
|
+
// registers pre-build (ensureImports, Task 8). Kernel-lifetime, NOT tracked/T()'d:
|
|
56
|
+
// these masters must survive cleanup() and be read again on every subsequent build.
|
|
57
|
+
const imports = new Map();
|
|
52
58
|
// Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
|
|
53
59
|
// tracks the result, and returns the triple the cache needs to pin/dispose it.
|
|
54
60
|
const cached = (hash, computeM) => cache.lookup(hash, () => {
|
|
@@ -330,6 +336,41 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
330
336
|
union: (solids) => solids.length === 1
|
|
331
337
|
? solids[0]
|
|
332
338
|
: cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
|
|
339
|
+
// Imported geometry, registered pre-build by the framework via `_registerImport`
|
|
340
|
+
// (ensureImports, Task 8). The master Manifold is kernel-lifetime (untracked —
|
|
341
|
+
// see `imports` above); wrap() is free, so every call is cheap.
|
|
342
|
+
import: (name) => {
|
|
343
|
+
const e = imports.get(name);
|
|
344
|
+
if (!e) throw new Error(`import: unknown import "${name}" — declare it in the part's \`imports\` field`);
|
|
345
|
+
if (e.error) throw e.error; // lazy: unusable-format entries fail at use, not at registration
|
|
346
|
+
return wrap(e.m, e.hash);
|
|
347
|
+
},
|
|
348
|
+
// Side-channel (underscore = off-contract, probe-invisible). Registration is
|
|
349
|
+
// TOTAL — it never throws for an unusable format; an `{error}` entry is stored
|
|
350
|
+
// verbatim and thrown by `import(name)` above at call time (spec: "Registration
|
|
351
|
+
// is total; errors are lazy"). Re-registering the same name+digest is a no-op
|
|
352
|
+
// EXCEPT an error entry is always upgradable (the post-crossover retry depends
|
|
353
|
+
// on this — see `_importDigest`).
|
|
354
|
+
_registerImport: ({ name, digest, positions, indices, error }) => {
|
|
355
|
+
const prev = imports.get(name);
|
|
356
|
+
if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable
|
|
357
|
+
if (error) { imports.set(name, { error, digest }); return; }
|
|
358
|
+
ensureOutward(positions, indices);
|
|
359
|
+
let m;
|
|
360
|
+
try {
|
|
361
|
+
m = manifoldFromMesh(wasm, positions, indices);
|
|
362
|
+
if (m.isEmpty()) throw new Error("empty result");
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const open = openEdgeCount(positions, indices);
|
|
365
|
+
throw new Error(`import "${name}": mesh is not a solid after repair (${open} open edges) — repair it in a mesh tool or re-export watertight (${err?.message || err})`);
|
|
366
|
+
}
|
|
367
|
+
prev?.m?.delete?.(); // prev may be an error entry with no manifold
|
|
368
|
+
imports.set(name, { m, digest, hash: h("import", name, digest) });
|
|
369
|
+
},
|
|
370
|
+
// Registration memo: undefined for an error entry, so a later registration with
|
|
371
|
+
// the same digest can upgrade it rather than being treated as a no-op repeat.
|
|
372
|
+
_importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
|
|
373
|
+
_acceptsMesh: true,
|
|
333
374
|
shape2d,
|
|
334
375
|
// Backend-internal region adapter: the shared native engine (contour-offset.js)
|
|
335
376
|
// that Shape2D.offset itself runs on — published here for callers that want the
|