partforge 0.145.0 → 0.145.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.145.0",
3
+ "version": "0.145.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",
@@ -47,8 +47,8 @@ export function buildCoreBVH(c, verts, makeJsIndex) {
47
47
  if (c.poisoned) return fallback();
48
48
  try {
49
49
  return fn();
50
- } catch {
51
- poisonCore(c);
50
+ } catch (err) {
51
+ poisonCore(c, err);
52
52
  return fallback();
53
53
  }
54
54
  };
@@ -0,0 +1,56 @@
1
+ // Where this realm's native core stands, and who wants to hear about it — kept
2
+ // apart from core.js so a host can import it (partforge/worker re-exports it)
3
+ // without pulling in the WebAssembly bytes. core.js records into it.
4
+ //
5
+ // Falling back to the JS is silent to the user (the answers are identical) but
6
+ // must not be silent to the host: onCoreFallback(listener) is told — once — when
7
+ // the core becomes unavailable or faults, with a fixed reason and nothing else,
8
+ // so it can go straight into a host's telemetry.
9
+
10
+ // Every reason a fallback can carry. A fixed vocabulary on purpose: hosts record
11
+ // it, and an error message could carry anything.
12
+ export const CORE_FALLBACK_REASONS = Object.freeze(["no_webassembly", "boot_failed", "out_of_memory", "trap", "error"]);
13
+
14
+ let fallback = null; // { state: "unavailable" | "faulted", reason } once it happened
15
+ let refused = 0; // meshes the core declined (malformed MeshGL) — the JS took them
16
+ let probe = () => "idle"; // core.js installs the live answer when it loads
17
+ const listeners = new Set();
18
+
19
+ export function setStateProbe(fn) { probe = fn; }
20
+
21
+ export function recordFallback(state, reason) {
22
+ if (fallback) return; // the first cause is the one worth knowing
23
+ fallback = { state, reason };
24
+ for (const fn of listeners) {
25
+ try { fn({ ...fallback }); } catch { /* a host listener must never break a build */ }
26
+ }
27
+ }
28
+
29
+ // A pass sent a mesh to the JS because the core declined it (not a fault).
30
+ export function countRefusedMesh() { refused++; }
31
+
32
+ // Where this realm's core stands:
33
+ // state: "idle" (not needed yet) | "on" | "off" (switched off) |
34
+ // "unavailable" (could not start) | "faulted" (stopped after a fault)
35
+ // reason: one of CORE_FALLBACK_REASONS for unavailable/faulted, else null
36
+ // refusedMeshes: how many meshes went to the JS because the core declined them
37
+ export function coreStatus() {
38
+ return { state: fallback?.state ?? probe(), reason: fallback?.reason ?? null, refusedMeshes: refused };
39
+ }
40
+
41
+ // Call `listener({ state, reason })` once when the core becomes unavailable or
42
+ // faults — immediately if it already has. Returns an unsubscribe function.
43
+ export function onCoreFallback(listener) {
44
+ if (fallback) {
45
+ try { listener({ ...fallback }); } catch { /* see recordFallback */ }
46
+ }
47
+ listeners.add(listener);
48
+ return () => listeners.delete(listener);
49
+ }
50
+
51
+ // Tests only (via core.js's setCoreForTesting): forget everything recorded.
52
+ export function resetCoreStatus() {
53
+ fallback = null;
54
+ refused = 0;
55
+ listeners.clear();
56
+ }
@@ -25,7 +25,16 @@
25
25
  // WebAssembly memory only ever grows, an instance whose memory passed
26
26
  // RECYCLE_BYTES is dropped once nothing holds it (releaseIfIdle); the next pass
27
27
  // boots a fresh one in a few milliseconds and the old memory goes to the GC.
28
+ //
29
+ // Falling back is silent to the user (the answers are identical) but must not be
30
+ // silent to the host: coreStatus() says where this realm stands, and
31
+ // onCoreFallback(listener) is told — once — when the core becomes unavailable or
32
+ // faults, with a fixed reason (CORE_FALLBACK_REASONS) and nothing else, so a host
33
+ // can count fallbacks in its own telemetry.
28
34
  import { CORE_WASM_BASE64 } from "./core-wasm.js";
35
+ import { recordFallback, resetCoreStatus, setStateProbe } from "./core-status.js";
36
+
37
+ export { CORE_FALLBACK_REASONS, coreStatus, countRefusedMesh, onCoreFallback } from "./core-status.js";
29
38
 
30
39
  // What the core may keep holding between passes. Ordinary forges never grow it
31
40
  // past its 16 MB start; a preview mesh needs ~145 bytes/triangle at the peak
@@ -35,6 +44,16 @@ export const RECYCLE_BYTES = 64 * 1024 * 1024;
35
44
 
36
45
  let enabled = true;
37
46
  let booted; // undefined: not tried yet (or recycled); null: unavailable or poisoned
47
+ const OUT_OF_MEMORY = "partforge core: out of WebAssembly memory";
48
+
49
+ function reasonOf(err) {
50
+ if (err?.message === OUT_OF_MEMORY) return "out_of_memory";
51
+ if (typeof WebAssembly === "object" && err instanceof WebAssembly.RuntimeError) return "trap";
52
+ return "error";
53
+ }
54
+
55
+ // The live answer for coreStatus() (core-status.js) while nothing has failed.
56
+ setStateProbe(() => (!enabled || flagOff() ? "off" : booted ? "on" : "idle"));
38
57
 
39
58
  export function setCoreEnabled(on) { enabled = !!on; }
40
59
 
@@ -49,9 +68,11 @@ export function core() {
49
68
 
50
69
  // The instance `c` faulted (a trap, or an allocation that failed): never use it
51
70
  // again in this realm. Objects still holding it check `c.poisoned` and fall back.
52
- export function poisonCore(c) {
71
+ // `err` is what was thrown; only its category is kept (reasonOf).
72
+ export function poisonCore(c, err) {
53
73
  if (c) c.poisoned = true;
54
74
  if (booted === c || c == null) booted = null;
75
+ recordFallback("faulted", reasonOf(err));
55
76
  }
56
77
 
57
78
  // Drop `c` if its memory grew past RECYCLE_BYTES and nothing holds it (no live
@@ -60,8 +81,12 @@ export function releaseIfIdle(c) {
60
81
  if (booted === c && c.live === 0 && c.x.memory.buffer.byteLength > RECYCLE_BYTES) booted = undefined;
61
82
  }
62
83
 
63
- // Tests only: install a stand-in instance (or undefined to re-boot).
64
- export function setCoreForTesting(c) { booted = c; }
84
+ // Tests only: install a stand-in instance (or undefined to re-boot), and forget
85
+ // any recorded fallback, refusal count and listeners.
86
+ export function setCoreForTesting(c) {
87
+ booted = c;
88
+ resetCoreStatus();
89
+ }
65
90
 
66
91
  const B64 = (() => {
67
92
  const t = new Uint8Array(128);
@@ -86,7 +111,10 @@ function decodeBase64(s) {
86
111
  }
87
112
 
88
113
  function boot() {
89
- if (typeof WebAssembly !== "object") return null;
114
+ if (typeof WebAssembly !== "object") {
115
+ recordFallback("unavailable", "no_webassembly");
116
+ return null;
117
+ }
90
118
  try {
91
119
  const stub = () => 0;
92
120
  // Synchronous on purpose: a pass asks mid-build and must get an answer now.
@@ -108,7 +136,7 @@ function boot() {
108
136
  // nothing is ever written over the module's low memory.
109
137
  alloc(bytes) {
110
138
  const ptr = x.malloc(bytes || 1);
111
- if (!ptr) throw new Error("partforge core: out of WebAssembly memory");
139
+ if (!ptr) throw new Error(OUT_OF_MEMORY);
112
140
  return ptr;
113
141
  },
114
142
  // Views are rebuilt per use: any call that grows memory detaches the old buffer.
@@ -122,6 +150,7 @@ function boot() {
122
150
  copyOut: (Ctor, ptr, len) => (ptr && len ? new Ctor(x.memory.buffer, ptr, len).slice() : new Ctor(0)),
123
151
  };
124
152
  } catch {
153
+ recordFallback("unavailable", "boot_failed");
125
154
  return null;
126
155
  }
127
156
  }
@@ -19,7 +19,7 @@
19
19
  import { SMOOTH, COPLANAR_ANGLE, MIN_EDGE, MIN_FACE, cosDeg } from "./shading-policy.js";
20
20
  import { affineAt, IDENTITY, runEvaluator } from "./blend-surfaces.js";
21
21
  import { hypot3 } from "./js-math.js";
22
- import { core, poisonCore } from "../core/core.js";
22
+ import { core, countRefusedMesh, poisonCore } from "../core/core.js";
23
23
  import { coreAccepts, creasedNormalsCore } from "../core/creased-normals-core.js";
24
24
 
25
25
  const COPLANAR_COS = cosDeg(COPLANAR_ANGLE);
@@ -43,12 +43,16 @@ const SHADE_SLIVER = 1e-3;
43
43
  // poisons that instance and this mesh, and every one after, runs the JS.
44
44
  export function creasedNormals(g, opts) {
45
45
  const c = core();
46
- if (c && coreAccepts(g)) {
47
- try {
48
- const out = creasedNormalsCore(c, g, opts);
49
- if (out) return out;
50
- } catch {
51
- poisonCore(c);
46
+ if (c) {
47
+ if (!coreAccepts(g)) countRefusedMesh();
48
+ else {
49
+ try {
50
+ const out = creasedNormalsCore(c, g, opts);
51
+ if (out) return out;
52
+ countRefusedMesh();
53
+ } catch (err) {
54
+ poisonCore(c, err);
55
+ }
52
56
  }
53
57
  }
54
58
  return creasedNormalsJS(g, opts);
@@ -316,8 +316,8 @@ function indexFor(mesh, allowCore) {
316
316
  if (c) {
317
317
  try {
318
318
  return buildCoreBVH(c, triangleVertices(mesh), () => buildBVH(mesh));
319
- } catch {
320
- poisonCore(c); // a fault while building: this realm's core is done; JS from here
319
+ } catch (err) {
320
+ poisonCore(c, err); // a fault while building: this realm's core is done; JS from here
321
321
  }
322
322
  }
323
323
  return buildBVH(mesh);
@@ -9,6 +9,10 @@
9
9
  // guarantees about epochs, cache sweeps, and the re-posted ready) is normative
10
10
  // in docs/KERNEL-CONTRACT.md.
11
11
  import { handle } from "./jobs.js";
12
+ // The native core's status (core/core-status.js): a host's worker entry
13
+ // subscribes here to count fallbacks. That module carries no WebAssembly, so
14
+ // re-exporting it costs a worker nothing at boot.
15
+ export { coreStatus, onCoreFallback, CORE_FALLBACK_REASONS } from "./core/core-status.js";
12
16
  import { lintPart } from "../lint.js";
13
17
  import { cachedVectorDocs } from "./vectors.js";
14
18
 
package/src/oracle.js CHANGED
@@ -27,7 +27,7 @@ export { buildBVH, meshTriangles } from "./framework/oracle/bvh.js";
27
27
  export { minWall } from "./framework/oracle/min-wall.js";
28
28
  // The native core's switch (framework/core/core.js): bit-identical either way, so
29
29
  // this is a performance and kill switch, never a behaviour choice.
30
- export { setCoreEnabled } from "./framework/core/core.js";
30
+ export { setCoreEnabled, coreStatus, onCoreFallback, CORE_FALLBACK_REASONS } from "./framework/core/core.js";
31
31
  export { overhang } from "./framework/oracle/overhang.js";
32
32
  // Mesh file parsers — the import pipeline's own readers, browser-safe pure
33
33
  // functions; the oracle package's corpus tests read real files through them.
package/types/oracle.d.ts CHANGED
@@ -17,7 +17,8 @@ export {
17
17
  // mesh facts, gaps, BVH, min wall
18
18
  meshVolume, bboxSize, bounds, meshArea, assemblyGaps, meshGaps, buildBVH, meshTriangles, minWall, overhang,
19
19
  // the native core's switch
20
- setCoreEnabled,
20
+ setCoreEnabled, coreStatus, onCoreFallback, CORE_FALLBACK_REASONS,
21
+ type CoreStatus, type CoreFallback, type CoreFallbackReason,
21
22
  type Gap, type BVH,
22
23
  // mesh file parsers (the import pipeline's own readers)
23
24
  parseStl, parse3MF,
@@ -274,6 +274,28 @@ export function minWall(
274
274
  */
275
275
  export function setCoreEnabled(on: boolean): void;
276
276
 
277
+ /** Why the core stopped answering in this realm — a fixed vocabulary, safe to record. */
278
+ export type CoreFallbackReason = "no_webassembly" | "boot_failed" | "out_of_memory" | "trap" | "error";
279
+ export const CORE_FALLBACK_REASONS: readonly CoreFallbackReason[];
280
+ export interface CoreFallback {
281
+ state: "unavailable" | "faulted";
282
+ reason: CoreFallbackReason;
283
+ }
284
+ export interface CoreStatus {
285
+ /** idle: not needed yet · on · off: switched off · unavailable: could not start · faulted: stopped after a fault */
286
+ state: "idle" | "on" | "off" | "unavailable" | "faulted";
287
+ reason: CoreFallbackReason | null;
288
+ /** Meshes sent to the JS pass because the core declined them (malformed MeshGL) — not a fault. */
289
+ refusedMeshes: number;
290
+ }
291
+ /** Where this realm's native core stands. Output is identical either way; this is for telemetry. */
292
+ export function coreStatus(): CoreStatus;
293
+ /**
294
+ * Called once when the core becomes unavailable or faults (immediately if it
295
+ * already has), so a host can count fallbacks. Returns an unsubscribe function.
296
+ */
297
+ export function onCoreFallback(listener: (fallback: CoreFallback) => void): () => void;
298
+
277
299
  /**
278
300
  * Unsupported downward-facing surface (oracle/overhang.js): the mm² of faces
279
301
  * steeper than `maxAngle` from vertical, excluding the footprint on the bed
package/types/worker.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  // its own backend.
6
6
 
7
7
  import type { PartDefinition } from "./part.js";
8
+ export type { CoreStatus, CoreFallback, CoreFallbackReason } from "./testing.js";
9
+ export { coreStatus, onCoreFallback, CORE_FALLBACK_REASONS } from "./testing.js";
8
10
 
9
11
  /** The rebind handle `runWorker` returns. */
10
12
  export interface WorkerHandle {