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.
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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.
|
package/src/framework/jobs.js
CHANGED
|
@@ -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 (
|
|
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
|
+
}
|
package/src/framework/worker.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
}
|