partforge 0.63.0 → 0.64.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/bin/cli.js +2 -3
  2. package/docs/AUTHORING-PARTS.md +135 -0
  3. package/docs/ERROR-PATTERNS.md +29 -1
  4. package/docs/KERNEL-CONTRACT.md +15 -1
  5. package/package.json +1 -1
  6. package/src/app-import-demo.js +18 -0
  7. package/src/framework/app.css +8 -1
  8. package/src/framework/asset-resolve.js +71 -0
  9. package/src/framework/capture-build.js +12 -4
  10. package/src/framework/export-controller.js +12 -0
  11. package/src/framework/fonts.js +12 -30
  12. package/src/framework/geometry/contour-offset.js +18 -6
  13. package/src/framework/geometry/contour-ops.js +28 -1
  14. package/src/framework/geometry/kernel.js +2 -1
  15. package/src/framework/geometry/manifold-backend.js +41 -0
  16. package/src/framework/geometry/mesh-repair.js +87 -0
  17. package/src/framework/geometry/occt-backend.js +33 -1
  18. package/src/framework/geometry/profile.js +45 -3
  19. package/src/framework/geometry/stl-parse.js +45 -0
  20. package/src/framework/geometry/threemf-parse.js +87 -0
  21. package/src/framework/geometry-service.js +3 -1
  22. package/src/framework/imports.js +84 -0
  23. package/src/framework/jobs.js +20 -1
  24. package/src/framework/lint/index.js +2 -1
  25. package/src/framework/lint/rules-imports.js +115 -0
  26. package/src/framework/mount.js +94 -1
  27. package/src/framework/oracle/measure.js +28 -2
  28. package/src/framework/verify-metrics.js +10 -0
  29. package/src/framework/worker.js +11 -2
  30. package/src/import-demo-worker.js +3 -0
  31. package/src/parts/assets/import-demo-scan.stl +86 -0
  32. package/src/parts/import-demo.js +134 -0
  33. package/src/testing/assets.js +19 -0
  34. package/src/testing/manifold.js +14 -2
  35. package/src/testing/occt.js +5 -2
  36. package/src/testing/step-mesh-thread.js +15 -0
  37. package/src/testing/step-mesh.js +17 -0
  38. package/types/kernel.d.ts +6 -0
  39. 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 = {
@@ -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
 
@@ -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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.63.0",
3
+ "version": "0.64.1",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,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
+ });
@@ -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
- #topbar .seg button { min-width: 70px; padding: 7px 10px; }
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, jobId
33
- // intact) resolves to null rather than leaving the caller hanging forever —
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
 
@@ -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 }), or a thunk
4
- // returning any of those (possibly async). Memoized process-wide by source so
5
- // repeated builds don't refetch. DOM-free (uses global fetch, present in workers).
6
- const cache = new Map(); // source (string|object) Promise<ArrayBuffer>
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
- async function resolveOne(source) {
17
- if (cache.has(source)) return cache.get(source);
18
- const p = (async () => {
19
- let v = source;
20
- if (typeof v === "function") v = await v();
21
- if (v && typeof v === "object" && "default" in v && !toBuffer(v)) v = v.default; // dynamic-import module
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
- const out = new Map();
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
  }
@@ -826,14 +826,26 @@ function sourceBackedPositiveRegions(source, out, delta) {
826
826
 
827
827
  // Crossing clustering can snap both ends of a tiny curve run onto the same pool vertex.
828
828
  // The run then survives as a closed loop attached at one point (or as a whole one-segment
829
- // contour). It encloses only geometry inside the resolver's own 2*CLUSTER_TOL cluster-
830
- // diameter uncertainty, but a downstream cap triangulator may bridge that loop across the
831
- // face and expose the bridge as a bogus feature edge. Positive dilation may discard these
832
- // sub-resolution counter loops: they are collapsed negative boundaries, never new material.
833
- // Keep erosion unchanged; its tiny surviving islands have no equivalent source-domain proof.
829
+ // contour). It encloses only geometry inside the resolver's own splice uncertainty, but a
830
+ // downstream cap triangulator may bridge that loop across the face and expose the bridge
831
+ // as a bogus feature edge and a surviving zero-chord loop also breaks the NEXT offset or
832
+ // boolean over this output (its crossing chaining reads the loop as an unclosable
833
+ // arrangement: the "could not chain offset boundary" class of failure). Positive dilation
834
+ // may discard these sub-resolution counter loops: they are collapsed negative boundaries,
835
+ // never new material. Keep erosion unchanged; its tiny surviving islands have no
836
+ // equivalent source-domain proof.
837
+ //
838
+ // The radius is 10*CLUSTER_TOL, not the 2*CLUSTER_TOL cluster-diameter bound this pass
839
+ // shipped with: the CROSSINGS that got merged sit within one cluster, but the curve run
840
+ // BETWEEN them wanders further before returning — measured on offset(6, round) of
841
+ // "Scotty" size 24 (test/contour-cleanup.test.js), the surviving splice loops reached
842
+ // 0.028 mm of control extent, 5.6x the old radius, and every one of them slipped this
843
+ // pass. 10x (0.05 mm) covers them with margin while staying far below any printable
844
+ // feature; a genuine pinch-off lobe bigger than that survives as its own region ring and
845
+ // never presents as a single zero-chord segment in the first place.
834
846
  function dropSubresolutionPositiveLoops(out, delta) {
835
847
  if (delta <= 0) return out;
836
- const radius = 2 * CLUSTER_TOL;
848
+ const radius = 10 * CLUSTER_TOL;
837
849
  const clean = (contour) => {
838
850
  const segments = [];
839
851
  let from = contour.start;
@@ -656,7 +656,34 @@ function simplifyRun(scope, from, segs, tolerance) {
656
656
  return refitRunViaPaper(scope, from, segs, tolerance);
657
657
  }
658
658
 
659
- function simplifyContour(scope, contour, tolerance) {
659
+ // Sweep segments whose ENTIRE extent (endpoint and every control point) stays within the
660
+ // caller's tolerance of their own start — degenerate loop-backs and dust segments. These
661
+ // are exactly the artifacts an upstream offset/boolean can leave behind (the winding
662
+ // resolver's splice debris: zero-chord cubics with controls ~0.01-0.03 mm out), and
663
+ // without this pass they DEFEAT simplify: contourCorners reads the loop's joints as real
664
+ // corners, and the corner-preserving contract then pins the debris bit-exact, so
665
+ // `.simplify(0.03)` used to return a 0.02 mm loop untouched. Dropping a segment moves the
666
+ // path by at most its chord (≤ tolerance) — precisely the change the caller authorized.
667
+ // Everything larger than the tolerance is geometry, not debris, and passes through.
668
+ function sweepDegenerateSegments(contour, tolerance) {
669
+ const segments = [];
670
+ let from = contour.start;
671
+ for (const seg of contour.segments) {
672
+ const controls = [seg.via, seg.c1, seg.c2].filter(Boolean);
673
+ const extent = Math.max(
674
+ Math.hypot(seg.to[0] - from[0], seg.to[1] - from[1]),
675
+ ...controls.map((p) => Math.hypot(p[0] - from[0], p[1] - from[1])),
676
+ );
677
+ if (extent <= tolerance) continue; // debris: path continues from `from`
678
+ segments.push(seg);
679
+ from = seg.to;
680
+ }
681
+ if (segments.length < 2) return contour; // a ring of dust is not ours to delete
682
+ return closeContourGap({ start: [contour.start[0], contour.start[1]], segments });
683
+ }
684
+
685
+ function simplifyContour(scope, rawContour, tolerance) {
686
+ const contour = sweepDegenerateSegments(rawContour, tolerance);
660
687
  const corners = contourCorners(contour);
661
688
  if (corners.length === 0) {
662
689
  // Cornerless (smooth closed loop): simplify as ONE closed path — toPaperPath's default
@@ -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