partforge 0.163.0 → 0.163.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.
@@ -877,6 +877,12 @@ between the Manifold preview and the OCCT STEP export.
877
877
  - **Cause:** Peak WASM memory during the preview build exceeded what the phone allows a page (roughly 1–1.5 GB on iOS; a desktop tolerates several GB). The usual shape is hundreds of repeated small features unioned into one body: before partforge 0.116 every sphere was 6,728 triangles regardless of radius (the flat 116-segment preview count, squared), so a body with ~250 rivet spheres carried 1.7 M triangles of rivets into its booleans — measured 10 s and a 2.7 GB peak on a desktop for a 400k-triangle sub-part, against 4 s and 500 MB with the rivets as 12-triangle boxes. A boolean chain whose intermediates dwarf the result (a full-body skin intersected per groove) is the other shape.
878
878
  - **Fix:** Update partforge (0.116 sizes spheres by chord tolerance: that body builds in ~550 MB and 120k triangles unchanged; 0.117 extends the rule to lathe profile arcs — `torus`, `roundedCylinder`, revolved rounded profiles — and `roundedBox` corners, the other two shapes that spend the count squared). Then keep the preview build's peak down the same ways the export needs: build repeated detail as one union of instances rather than a chain of per-feature booleans, cut grooves from a thin local skin rather than the whole envelope, and drop feature counts the print cannot show ([Preview vs print quality](AUTHORING-PARTS.md#conventions--gotchas)). `partforge measure` reports triangle counts per sub-part; a single preview sub-part past ~200k triangles, or a whole view past ~400k, is the range where phones start to fail.
879
879
 
880
+ ## kernel-internal-error
881
+
882
+ - **Symptom:** `the geometry kernel raised an internal error it could not describe` — from a build or a STEP import, naming the operation when known (`during STEP import of "frame" (17.1 MB)`) and, when the kernel's own heap could say, the C++ exception type (`C++ exception StdFail_NotDone`, `Standard_ConstructionError`, `std::bad_alloc`, …) and the heap size. Before partforge 0.163.1 the same failure was a bare number (`{"ok":false,"error":"154591824"}`), different on every attempt.
883
+ - **Cause:** The OCCT kernel is a WASM build compiled without C++ exception catching, so an exception nothing inside it catches — the STEP reader's transfer refusing an entity, a construction with a zero-length direction, an allocation the heap could not grow for — reaches JavaScript as the exception's ADDRESS. The number is not a code and means nothing across runs. partforge now names the operation and reads the exception's type off the heap, but it cannot read OCCT's own message for it.
884
+ - **Fix:** Act on the type. `std::bad_alloc` or `Standard_OutOfMemory` (the message then says "probably too large for the in-browser kernel") is memory: a STEP import needs roughly six times its file size of kernel heap (measured on OCCT-written files: 17 MB → 100 MiB, 42 MB → 248 MiB), so a file that exhausts the 2 GB wasm32 ceiling is hundreds of megabytes, or the browser refused to grow the heap — simplify the imported model or split it. Any other type on an import is the file itself: re-export it from the CAD tool as AP214/AP242 solids (no assembly-only or surface-only exports) and try the smaller file; the same type from a build is that operation refusing its input (a `Standard_ConstructionError` is usually a zero-length direction or a degenerate profile). Retrying the identical build fails the same way — it is not transient.
885
+
880
886
  # Hardware library
881
887
 
882
888
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.163.0",
3
+ "version": "0.163.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",
@@ -27,6 +27,7 @@ import { makeProfileWarner } from "./profile-warnings.js";
27
27
  import { createOcctRepair } from "./occt-repair.js";
28
28
  import { occtRoundAll } from "./occt-roundall.js";
29
29
  import { classifyFaceGroups } from "./feature-attribution.js";
30
+ import { emscriptenThrowEvidence, labelImportFailure } from "../kernel-error.js";
30
31
  import { resolveLoftRings, loftRingsKey } from "./loft-rings.js";
31
32
  import { resolveSweepStations } from "./sweep.js";
32
33
  import { normalizeProfile } from "./profile.js";
@@ -199,6 +200,7 @@ export function createOcctKernel(replicad) {
199
200
  // registers pre-build via `_registerImport` (kernel-lifetime, untracked by the
200
201
  // solid cache: imports are the framework's own memo, keyed by name+digest).
201
202
  const imports = new Map();
203
+ const throwEvidence = (value) => emscriptenThrowEvidence(replicad.getOC?.(), value);
202
204
  // name -> { digest, width, height, data } — depth-map grids the framework
203
205
  // registers pre-build via `_registerImage` (ensureImages). Kernel-lifetime like
204
206
  // `imports` above, and plain data rather than OCCT objects, so there is nothing
@@ -815,11 +817,22 @@ export function createOcctKernel(replicad) {
815
817
  const prev = imports.get(name);
816
818
  if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable
817
819
  if (error) { imports.set(name, { error, digest }); return; }
818
- imports.set(name, { shape: await importSTEP(new Blob([step])), digest });
820
+ // A STEP the reader refuses, or an OCCT exception out of its transfer,
821
+ // leaves here labelled with the import's name and size. The latter
822
+ // reaches JavaScript as a bare number from this exception-less build
823
+ // (kernel-error.js), and the heap is read for its C++ type BEFORE
824
+ // anything else runs on the kernel.
825
+ let shape;
826
+ try { shape = await importSTEP(new Blob([step])); }
827
+ catch (e) { throw labelImportFailure({ name, bytes: step?.byteLength, error: e, evidence: throwEvidence(e) }); }
828
+ imports.set(name, { shape, digest });
819
829
  },
820
830
  // Registration memo: undefined for an error entry, so a later registration with
821
831
  // the same digest can upgrade it rather than being treated as a no-op repeat.
822
832
  _importDigest: (name) => { const e = imports.get(name); return e?.error ? undefined : e?.digest; },
833
+ // What the kernel's heap can say about a value OCCT threw (kernel-error.js):
834
+ // the job loop asks this when a build throws something that is not an Error.
835
+ _throwEvidence: throwEvidence,
823
836
  // Depth-map grids, registered pre-build by the framework via `_registerImage`
824
837
  // (ensureImages). Unlike imports there is no per-format error entry: every
825
838
  // backend can consume a normalized grid, so registration never fails.
@@ -10,6 +10,7 @@ import { fontsFor, resolveFonts, forgetFontSource } from "./fonts.js";
10
10
  import { normalizeOpentype, parseFont } from "./geometry/opentype-interop.js";
11
11
  import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "./image-source.js";
12
12
  import { imagesFor, ensureImages } from "./images.js";
13
+ import { describeKernelThrow } from "./kernel-error.js";
13
14
  import { ensureImports, resolveImports } from "./imports.js";
14
15
  import { safeName } from "./safe-name.js";
15
16
  import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./vector-source.js";
@@ -651,7 +652,12 @@ export async function handle(kernel, part, msg, post, opts = {}) {
651
652
  } else if (opts.jobs && Object.hasOwn(opts.jobs, msg.type)) {
652
653
  await opts.jobs[msg.type](kernel, part, msg, post, { isStale: opts.isStale });
653
654
  }
654
- } catch (err) {
655
+ } catch (thrown) {
656
+ // A kernel compiled without exception catching throws a bare NUMBER for an
657
+ // uncaught C++ exception; describeKernelThrow turns that into a readable
658
+ // Error (with the C++ type when the kernel's heap can say) and returns a
659
+ // real Error untouched, `code` included.
660
+ const err = describeKernelThrow(thrown, { evidence: kernel._throwEvidence?.(thrown) });
655
661
  // `subparts` (generate jobs only) tells the reroute policy which sub-parts the
656
662
  // failed job covered, so only those latch to OCCT — not the whole part.
657
663
  if (err?.code === "NEEDS_IMPORT_MESH") post({ type: "needs-import-mesh", jobId: msg.jobId, subparts: msg.subparts });
@@ -0,0 +1,161 @@
1
+ // What to say when the geometry kernel throws something that is not an Error.
2
+ //
3
+ // replicad-opencascadejs's `replicad_single` build (the OCCT kernel worker.js
4
+ // loads) is compiled WITHOUT C++ exception catching. An exception OCCT throws
5
+ // that nothing inside the WASM catches — a Standard_Failure out of the STEP
6
+ // reader, a std::bad_alloc when the heap cannot grow — leaves the module through
7
+ // Emscripten's `___cxa_throw`, whose last line is `throw ptr`: JavaScript
8
+ // receives the exception's ADDRESS, a bare number such as 154591824, different
9
+ // on every run. Stringified as-is, that number was the whole error message a
10
+ // part author (and partforge-cloud's agent, which read it as the part's fault
11
+ // and retried blindly — cloud feedback #208) got for a failed build.
12
+ //
13
+ // This module is the ONE place such a value becomes a readable Error, used by
14
+ // both the job loop's error post (jobs.js, worker.js) and the STEP import path
15
+ // (occt-backend.js). It never invents a cause: the message says the kernel
16
+ // raised an internal error it could not describe, names the operation when the
17
+ // caller knows it, and adds only what the kernel's own heap can prove —
18
+ // `emscriptenThrowEvidence` reads the thrown exception's C++ type name off the
19
+ // heap through the Itanium ABI layout Emscripten's `ExceptionInfo` uses, and
20
+ // says "too large for the in-browser kernel" only when that type IS an
21
+ // out-of-memory type, or the heap has reached the wasm32 ceiling. Anything the
22
+ // read cannot validate is dropped rather than guessed.
23
+
24
+ // A thrown value the caller cannot read: a number (the Emscripten case above), or
25
+ // an object with no non-empty string message (a bare WebAssembly.Exception, a
26
+ // plain object). A real Error — anything with a message — is never opaque.
27
+ export function isOpaqueThrow(err) {
28
+ if (typeof err === "number") return true;
29
+ if (err == null) return true;
30
+ if (typeof err === "string") return err.trim() === "";
31
+ return !(typeof err.message === "string" && err.message.trim() !== "");
32
+ }
33
+
34
+ // Emscripten's ExceptionInfo (replicad-opencascadejs 0.23): the thrown pointer
35
+ // is the exception OBJECT; a 24-byte header sits before it, with the
36
+ // std::type_info pointer at header + 4. A std::type_info (libc++abi, wasm32)
37
+ // is { vtable*, const char* __type_name }, so the mangled class name is the
38
+ // C string at *(typeInfo + 4). test/kernel-error-occt.test.js holds this
39
+ // layout against the installed glue: a replicad-opencascadejs bump that moves
40
+ // it fails there, and until it is re-pinned the read simply yields no type
41
+ // name (a wrong read almost never parses as a mangled name — see below).
42
+ const EXCEPTION_HEADER_BYTES = 24;
43
+ const TYPE_OFFSET_IN_HEADER = 4;
44
+ const NAME_OFFSET_IN_TYPE_INFO = 4;
45
+ const MAX_MANGLED_NAME = 128;
46
+
47
+ // getHeapMax() in the replicad_single glue: the wasm32 build's memory ceiling.
48
+ export const WASM32_HEAP_CEILING_BYTES = 2 ** 31;
49
+ // At or past this share of the ceiling, a throw with no readable type is still
50
+ // called out of memory: nothing else grows a heap to the top of its range.
51
+ const HEAP_CEILING_SHARE = 0.9;
52
+
53
+ // C++ exception types that mean the kernel could not get memory.
54
+ const OUT_OF_MEMORY_TYPES = new Set(["std::bad_alloc", "std::bad_array_new_length", "Standard_OutOfMemory"]);
55
+
56
+ // Itanium-mangled class name → readable, for the shapes exception types take:
57
+ // `St9bad_alloc` (std::bad_alloc), `16Standard_Failure`, `N3foo3BarE`
58
+ // (foo::Bar). Every length prefix must match its identifier exactly and the
59
+ // string must be consumed whole, which is what makes a misread (a pointer that
60
+ // landed on arbitrary heap bytes) come back null instead of as a "type".
61
+ export function demangleClassName(mangled) {
62
+ if (typeof mangled !== "string" || !/^[A-Za-z0-9_]{1,128}$/.test(mangled)) return null;
63
+ let i = 0;
64
+ const nested = mangled[0] === "N";
65
+ if (nested) i++;
66
+ const parts = [];
67
+ if (mangled.startsWith("St", i)) { parts.push("std"); i += 2; }
68
+ while (i < mangled.length && /[0-9]/.test(mangled[i])) {
69
+ let j = i;
70
+ while (j < mangled.length && /[0-9]/.test(mangled[j])) j++;
71
+ const len = Number(mangled.slice(i, j));
72
+ if (len < 1 || j + len > mangled.length) return null;
73
+ const ident = mangled.slice(j, j + len);
74
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(ident)) return null;
75
+ parts.push(ident);
76
+ i = j + len;
77
+ }
78
+ if (nested ? mangled.slice(i) !== "E" : i !== mangled.length) return null;
79
+ if (parts.length === 0 || (parts.length === 1 && parts[0] === "std")) return null;
80
+ return parts.join("::");
81
+ }
82
+
83
+ function readCString(u8, ptr, cap) {
84
+ if (!Number.isInteger(ptr) || ptr <= 0 || ptr >= u8.length) return null;
85
+ let s = "";
86
+ for (let i = 0; i < cap && ptr + i < u8.length; i++) {
87
+ const c = u8[ptr + i];
88
+ if (c === 0) return s;
89
+ s += String.fromCharCode(c);
90
+ }
91
+ return null; // ran past the cap or the heap without a terminator
92
+ }
93
+
94
+ // What the kernel's heap can say about a thrown value: `{ heapBytes, typeName? }`.
95
+ // `module` is the Emscripten module object (what `opencascade()` resolved to,
96
+ // `replicad.getOC()`); the HEAP views are re-read from it on every call because
97
+ // the glue replaces them whenever the memory grows. Returns `{}` for a module
98
+ // without heap views, and never throws.
99
+ export function emscriptenThrowEvidence(module, value) {
100
+ const evidence = {};
101
+ const u8 = module?.HEAPU8;
102
+ const u32 = module?.HEAPU32;
103
+ if (!(u8 instanceof Uint8Array) || !(u32 instanceof Uint32Array)) return evidence;
104
+ evidence.heapBytes = u8.length;
105
+ if (!Number.isInteger(value) || value <= EXCEPTION_HEADER_BYTES || value >= u8.length) return evidence;
106
+ const typeInfo = u32[(value - EXCEPTION_HEADER_BYTES + TYPE_OFFSET_IN_HEADER) >> 2];
107
+ if (!(typeInfo > 0 && typeInfo + NAME_OFFSET_IN_TYPE_INFO + 4 <= u8.length)) return evidence;
108
+ const namePtr = u32[(typeInfo + NAME_OFFSET_IN_TYPE_INFO) >> 2];
109
+ const typeName = demangleClassName(readCString(u8, namePtr, MAX_MANGLED_NAME));
110
+ if (typeName) evidence.typeName = typeName;
111
+ return evidence;
112
+ }
113
+
114
+ export function formatBytes(n) {
115
+ if (!Number.isFinite(n) || n < 0) return "unknown size";
116
+ if (n < 1e6) return `${Math.max(1, Math.round(n / 1e3))} KB`;
117
+ return `${(n / 1e6).toFixed(1)} MB`;
118
+ }
119
+
120
+ const describeRaw = (err) => {
121
+ if (typeof err === "number") return `the kernel reported only an exception address, ${err}`;
122
+ const ctor = err?.constructor?.name;
123
+ return `the kernel reported only an opaque ${ctor && ctor !== "Object" ? ctor : "value"}`;
124
+ };
125
+
126
+ // Turn a thrown value into an Error. A real Error (anything with a message) is
127
+ // returned AS IS — same object, nothing rewritten — so a caller can branch on
128
+ // its `code` exactly as before. `operation` names what was running ("STEP
129
+ // import of "frame" (17.1 MB)"); `subject` is what the memory clause blames
130
+ // ("the file" for an import, "the part" otherwise); `evidence` is
131
+ // `emscriptenThrowEvidence`'s answer, or nothing.
132
+ export function describeKernelThrow(err, { operation, subject = "the part", evidence = {} } = {}) {
133
+ if (!isOpaqueThrow(err)) return err;
134
+ const { typeName, heapBytes } = evidence ?? {};
135
+ let message = "the geometry kernel raised an internal error it could not describe";
136
+ if (operation) message += ` during ${operation}`;
137
+ const detail = [];
138
+ if (typeName) detail.push(`C++ exception ${typeName}`);
139
+ if (Number.isFinite(heapBytes)) detail.push(`kernel heap ${formatBytes(heapBytes)}`);
140
+ message += detail.length ? ` (${detail.join(", ")})` : ` (${describeRaw(err)})`;
141
+ const outOfMemory = typeName
142
+ ? OUT_OF_MEMORY_TYPES.has(typeName)
143
+ : Number.isFinite(heapBytes) && heapBytes >= WASM32_HEAP_CEILING_BYTES * HEAP_CEILING_SHARE;
144
+ if (outOfMemory) {
145
+ message += ` — the kernel ran out of memory, so ${subject} is probably too large for the in-browser kernel; retrying without reducing it will fail the same way`;
146
+ }
147
+ const out = new Error(message, { cause: err });
148
+ if (typeName) out.kernelExceptionType = typeName;
149
+ return out;
150
+ }
151
+
152
+ // The STEP import path's wrapper: whatever `importSTEP` threw, labelled with the
153
+ // import's name and size. An opaque throw takes `describeKernelThrow` with the
154
+ // import as its operation; a real Error (replicad's "Failed to load STEP file",
155
+ // its "This shape has not type, it is null" for a file the reader parsed but
156
+ // could not transfer) keeps its text behind the same label, `cause` attached.
157
+ export function labelImportFailure({ name, bytes, error, evidence }) {
158
+ const operation = `STEP import of "${name}" (${formatBytes(bytes)})`;
159
+ if (isOpaqueThrow(error)) return describeKernelThrow(error, { operation, subject: "the file", evidence });
160
+ return new Error(`${operation} failed: ${error.message}`, { cause: error });
161
+ }
@@ -15,6 +15,7 @@ import { handle } from "./jobs.js";
15
15
  export { coreStatus, onCoreFallback, CORE_FALLBACK_REASONS } from "./core/core-status.js";
16
16
  import { lintPart } from "../lint.js";
17
17
  import { cachedVectorDocs } from "./vectors.js";
18
+ import { describeKernelThrow } from "./kernel-error.js";
18
19
 
19
20
  async function manifoldKernels() {
20
21
  const [{ default: Module }, { createManifoldKernel }] = await Promise.all([
@@ -109,10 +110,11 @@ export function runWorker(part, opts = {}) {
109
110
  // escaping rejection would kill the pump: this job AND everything queued behind
110
111
  // it would be dropped with no reply at all, leaving the host waiting on a message
111
112
  // that never comes until its own timeout fires.
113
+ let kernel = null;
112
114
  try {
113
115
  // A generate superseded while it sat in the queue never builds at all.
114
116
  if (job.epoch !== null && job.epoch !== epoch) continue;
115
- const kernel = await kernelFor(job.data);
117
+ kernel = await kernelFor(job.data);
116
118
  // handle() declares each message's transferables (the big binary buffers).
117
119
  const post = (m, transfer = []) => postMessage(m, transfer);
118
120
  if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes, jobs: opts.jobs }); continue; }
@@ -125,11 +127,14 @@ export function runWorker(part, opts = {}) {
125
127
  const gated = (m, transfer = []) =>
126
128
  (m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
127
129
  await handle(kernel, job.part, job.data, gated, { isStale, importMeshes, jobs: opts.jobs });
128
- } catch (err) {
130
+ } catch (thrown) {
129
131
  // Same shape jobs.js posts for a failed build, so hosts need no new branch.
130
132
  // Carry the job's jobId when it has one (capture/export are correlated by it):
131
133
  // a boot failure hitting kernelFor here must reach the right controller, or a
132
134
  // correlated caller (captureView, exportParts) would hang instead of settling.
135
+ // A bare number (an uncaught C++ exception out of the kernel) is given
136
+ // words here too, exactly as jobs.js does for a build.
137
+ const err = describeKernelThrow(thrown, { evidence: kernel?._throwEvidence?.(thrown) });
133
138
  const jobId = job.data?.jobId;
134
139
  postMessage({ type: "error", message: String(err?.message || err), ...(jobId != null ? { jobId } : {}) });
135
140
  }