feat(wasm): occ-split — lazy occ_service worker; kicad_editor drops OCC (−31%)
Move OpenCASCADE out of the merged editor image into occ_service: a separate
emscripten module (-sASYNCIFY=0, MODULARIZE, in-container -Oz finalize, 2N+8
pre-warmed pthread pool) booted lazily in a dedicated Web Worker on the first
STEP export or STEP/IGES model parse. kicad_editor.wasm ~190 MB -> 130 MB;
sessions that never touch OCC never fetch its 57 MB. STEP export works in the
browser for the first time: the unchanged desktop dialog runs EXPORTER_STEP,
whose wasm shadow suspends into globalThis.occService and the export bytes go
straight to a browser download (never entering the editor heap). STEP/IGES 3D
models parse in the worker via the oce shadow (S3D WriteCache/ReadCache wire).
- wasm/occ-service/: service CMake target (hooked from the kicad fork's
top-level CMakeLists, wasm/editor pattern), embind entry
(occExport/occLoadModel), wxConfig pre-js.
- wasm/stubs/{exporter_step,oce_plugin}_stub.cpp: EM_ASYNC_JS worker bridges
(callee-shadowing; no caller #ifdefs).
- web/standalone: provider installed whenever the kicad_editor bundle boots
(cross-face safe); ONE shared worker-boot source occ-worker.js (vite ?raw;
the e2e stub reads the same file) — blob worker with locateFile absolutized
against the glue URL; export download-name guard.
- deps: OCC builds with RapidJSON so its glTF/GLB writer exists — pinned to
the vcpkg master snapshot 2025-02-26 (24b5e7a8b27f), the same code official
KiCad consumes via vcpkg.json's opencascade[rapidjson]; rapidjson's latest
tag (v1.1.0, 2016) is ill-formed under modern clang.
- tests: occ-export dialog e2e (lazy-fetch boundary + STEP download bytes),
occ-probe incl. a 9-format matrix (step/stpz/brep/xao/ply/stl/glb/u3d/pdf),
3d-viewer-models hard-asserts the worker parse; occ provider stub installed
ambiently by the kicad fixtures.
Validated against desktop kicad-cli 10.0.4: geometric exact equality (bbox
delta 0 um, volume delta 0.0000%) for STEP/GLB/STL/BREP/STPZ across three
boards and option sweeps — with desktop OCC 7.9 vs wasm OCC 7.8; PLY/XAO/PDF
structurally equal; U3D same-size (quantizer float LSBs differ). Full kicad
e2e green on Firefox and Chromium; standalone verified end to end (lazy fetch
only on the Export click; export.step 60,628 B ISO-10303-21; loadModel 700 KB
STEP -> 569 KB scenegraph cache).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
96abe5d4a5
commit
db9d6ee04b
28 changed files with 2296 additions and 23 deletions
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from "./constants";
|
||||
import { installModel3dHandler } from "./libs/models-bridge";
|
||||
import type { Model3dSource } from "./libs/models-source";
|
||||
import { installOccService } from "./occ-service";
|
||||
import {
|
||||
buildFpLibTable,
|
||||
buildSymLibTable,
|
||||
|
|
@ -240,6 +241,15 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
// Which lib table this tool consumes: symbol → sym-lib-table, footprint →
|
||||
// fp-lib-table. The same lib source feeds whichever table the tool reads.
|
||||
const libKind = TOOL_LIB_KIND[tool];
|
||||
|
||||
// OCC service (STEP export + STEP/IGES model parsing): install whenever the
|
||||
// merged editor bundle boots — a PCB frame can open from ANY session (e.g.
|
||||
// eeschema → cross-face into pcbnew), and the install is a synchronous global
|
||||
// set; the worker itself is only fetched lazily on first use.
|
||||
if (bundle === "kicad_editor") {
|
||||
installOccService(log);
|
||||
}
|
||||
|
||||
if (libsSource && libKind) {
|
||||
installLibsProvider(libsSource, log);
|
||||
// 3D models ride the same provider (kind "model3d"): the C++ ensure fallback
|
||||
|
|
|
|||
|
|
@ -46,7 +46,11 @@ export type Bundle =
|
|||
| "kicad_editor"
|
||||
| "calculator"
|
||||
| "pl_editor"
|
||||
| "gerbview";
|
||||
| "gerbview"
|
||||
// Headless lazy OCC worker module (docs/features/occ-split/) — fetched by the
|
||||
// occService provider on first STEP export / STEP-IGES model parse; backs no
|
||||
// tool/route of its own.
|
||||
| "occ_service";
|
||||
|
||||
/**
|
||||
* Which deployed WASM bundle actually backs each tool. The four editors share the
|
||||
|
|
|
|||
162
web/standalone/src/wasm/occ-service.ts
Normal file
162
web/standalone/src/wasm/occ-service.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { downloadBytes } from "@/lib/download";
|
||||
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
|
||||
// also injected by the e2e harness stub (tests/kicad/utils/occ-service.ts).
|
||||
import occWorkerSource from "./occ-worker.js?raw";
|
||||
import { resolveWasmBase } from "./wasm-assets";
|
||||
|
||||
/**
|
||||
* `globalThis.occService` — the lazy OpenCASCADE 3D service provider.
|
||||
*
|
||||
* pcbnew.wasm carries no OCC (docs/features/occ-split/): its two OCC-backed
|
||||
* paths suspend via EM_ASYNC_JS bridges (wasm/stubs/{exporter_step,oce_plugin}_stub.cpp)
|
||||
* and land here:
|
||||
* { kind: "export", board, jobJson, fileName } → STEP/GLB/… export; the
|
||||
* resulting bytes are delivered straight to the browser download path and
|
||||
* only { ok, report } goes back to the editor.
|
||||
* { kind: "loadModel", bytes, ext } → STEP/IGES parse + tessellation; returns
|
||||
* the SCENEGRAPH serialized in KiCad's binary cache format, which the
|
||||
* C++ stub rebuilds with S3D::ReadCache.
|
||||
*
|
||||
* The occ_service module (own emscripten instance, `-sASYNCIFY=0`) boots in a
|
||||
* dedicated Worker on the FIRST request — a pcbnew session that never exports
|
||||
* and never views STEP models never fetches it. Same cross-origin worker rules
|
||||
* as the pthread workers (boot.ts): a same-origin blob wrapper importScripts
|
||||
* the (possibly CDN) glue; the module's own pthread children reuse the trick
|
||||
* via mainScriptUrlOrBlob.
|
||||
*/
|
||||
|
||||
interface OccExportRequest {
|
||||
kind: "export";
|
||||
board: Uint8Array;
|
||||
jobJson: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface OccLoadModelRequest {
|
||||
kind: "loadModel";
|
||||
bytes: Uint8Array;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
export type OccRequest = OccExportRequest | OccLoadModelRequest;
|
||||
|
||||
export interface OccResponse {
|
||||
ok: boolean;
|
||||
report?: string;
|
||||
fileName?: string;
|
||||
bytes?: Uint8Array;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var occService: { request(req: OccRequest): Promise<OccResponse> } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the worker blob: a one-line prelude carrying the glue URL, then the
|
||||
* shared wrapper source (occ-worker.js), which reads `self.OCC_GLUE_URL`.
|
||||
*/
|
||||
export function occWorkerBlobParts(glueHref: string): string[] {
|
||||
return [
|
||||
`self.OCC_GLUE_URL = ${JSON.stringify(glueHref)};\n`,
|
||||
occWorkerSource,
|
||||
];
|
||||
}
|
||||
|
||||
export function installOccService(log: (msg: string) => void): void {
|
||||
if (globalThis.occService) return;
|
||||
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, (res: OccResponse) => void>();
|
||||
let workerP: Promise<Worker> | null = null;
|
||||
|
||||
const ensureWorker = (): Promise<Worker> => {
|
||||
if (!workerP) {
|
||||
workerP = (async () => {
|
||||
// occ_service is a Bundle (a published delivery artifact), not a Tool —
|
||||
// resolveWasmBase accepts either and looks the bundle up directly.
|
||||
const base = await resolveWasmBase("occ_service");
|
||||
const glue = new URL(`${base}/occ_service.js`, window.location.href).href;
|
||||
log(`[occ] booting occ_service from ${base}`);
|
||||
|
||||
const worker = new Worker(
|
||||
URL.createObjectURL(
|
||||
new Blob(occWorkerBlobParts(glue), { type: "text/javascript" }),
|
||||
),
|
||||
);
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
const { id, res } = e.data ?? {};
|
||||
if (typeof id !== "number") return;
|
||||
const resolve = pending.get(id);
|
||||
if (resolve) {
|
||||
pending.delete(id);
|
||||
resolve(res as OccResponse);
|
||||
}
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onFirst = (e: MessageEvent) => {
|
||||
if (e.data?.ready) {
|
||||
worker.removeEventListener("message", onFirst);
|
||||
resolve();
|
||||
} else if (e.data?.bootError) {
|
||||
reject(new Error(e.data.bootError));
|
||||
}
|
||||
};
|
||||
worker.addEventListener("message", onFirst);
|
||||
worker.onerror = (e) => reject(new Error(`occ_service worker: ${e.message}`));
|
||||
});
|
||||
|
||||
log("[occ] occ_service ready");
|
||||
return worker;
|
||||
})().catch((e) => {
|
||||
workerP = null; // a failed boot must stay retryable
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
return workerP;
|
||||
};
|
||||
|
||||
const post = (worker: Worker, req: OccRequest): Promise<OccResponse> => {
|
||||
const id = nextId++;
|
||||
const transfer: Transferable[] =
|
||||
req.kind === "export" ? [req.board.buffer] : [req.bytes.buffer];
|
||||
return new Promise<OccResponse>((resolve) => {
|
||||
pending.set(id, resolve);
|
||||
worker.postMessage({ id, req }, transfer);
|
||||
});
|
||||
};
|
||||
|
||||
const request = async (req: OccRequest): Promise<OccResponse> => {
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = await ensureWorker();
|
||||
} catch (e) {
|
||||
return { ok: false, report: `occ_service unavailable: ${e}` };
|
||||
}
|
||||
|
||||
const res = await post(worker, req);
|
||||
|
||||
if (req.kind === "export") {
|
||||
// Deliver the export straight to the user; the editor gets status only
|
||||
// (the bytes never enter pcbnew's heap).
|
||||
if (res.ok && res.bytes?.length) {
|
||||
// The dialog can hand over an extension-only name (".step" — its
|
||||
// default filename field is empty in the browser); Chromium mangles a
|
||||
// bare dotfile download to "step.txt", so give it a real stem while
|
||||
// keeping the format extension the user picked.
|
||||
const raw = req.fileName || res.fileName || "";
|
||||
const name = !raw || raw.startsWith(".") ? `export${raw || ".step"}` : raw;
|
||||
downloadBytes(name, res.bytes);
|
||||
log(`[occ] export downloaded: ${name} (${res.bytes.length} bytes)`);
|
||||
}
|
||||
return { ok: res.ok, report: res.report, fileName: res.fileName };
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
globalThis.occService = { request };
|
||||
log("[occ] occ_service provider installed (lazy)");
|
||||
}
|
||||
68
web/standalone/src/wasm/occ-worker.js
Normal file
68
web/standalone/src/wasm/occ-worker.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Worker-side wrapper for the occ_service MODULARIZE module — the SINGLE
|
||||
* source of truth for the worker boot, shared verbatim by:
|
||||
* - the standalone app provider (occ-service.ts, vite `?raw` import), and
|
||||
* - the e2e harness stub (tests/kicad/utils/occ-service.ts, read off disk).
|
||||
*
|
||||
* The host prepends one prelude line to the blob before this file's content:
|
||||
* self.OCC_GLUE_URL = "<absolute URL of occ_service.js>";
|
||||
*
|
||||
* Protocol: the host posts { id, req } (req = { kind: "export" | "loadModel",
|
||||
* … }); the worker answers { id, res } with the result bytes transferred. A
|
||||
* one-shot { ready: true } / { bootError } message reports module boot.
|
||||
*/
|
||||
const GLUE = self.OCC_GLUE_URL;
|
||||
|
||||
self.addEventListener("error", (e) =>
|
||||
console.error("[occ_service] worker error:", e.message, e.filename, e.lineno));
|
||||
self.addEventListener("unhandledrejection", (e) =>
|
||||
console.error("[occ_service] unhandled rejection:", e.reason));
|
||||
|
||||
importScripts(GLUE);
|
||||
|
||||
// wx boot logs a "Debug:" line per image handler etc. — pure noise in the page
|
||||
// console (and in the captured test logs); real problems don't carry the marker.
|
||||
const noise = (s) => /(^|: )Debug: /.test(String(s));
|
||||
|
||||
const modP = OccService({
|
||||
onAbort: (what) => console.error("[occ_service] ABORT:", what),
|
||||
// The module's own pthread children must boot from a same-origin script even
|
||||
// when the glue lives on a CDN — same blob-importScripts trick as boot.ts.
|
||||
mainScriptUrlOrBlob: new Blob(
|
||||
["importScripts(" + JSON.stringify(GLUE) + ");"],
|
||||
{ type: "text/javascript" }),
|
||||
// A blob: worker has no http base URL — every asset path must be absolutized
|
||||
// against the glue's own URL or the .wasm fetch dies with "Failed to parse
|
||||
// URL" (root-relative bases like "/wasm" don't resolve).
|
||||
locateFile: (f) => new URL(f, GLUE).href,
|
||||
print: (s) => { if (!noise(s)) console.log("[occ_service]", s); },
|
||||
printErr: (s) => { if (!noise(s)) console.warn("[occ_service]", s); },
|
||||
});
|
||||
|
||||
modP.then(() => postMessage({ ready: true }),
|
||||
(e) => postMessage({ bootError: String(e) }));
|
||||
|
||||
onmessage = async (e) => {
|
||||
const { id, req } = e.data;
|
||||
let res;
|
||||
try {
|
||||
const mod = await modP;
|
||||
if (req.kind === "export") {
|
||||
const board = new TextDecoder().decode(req.board);
|
||||
res = mod.occExport(board, req.jobJson);
|
||||
} else if (req.kind === "loadModel") {
|
||||
res = mod.occLoadModel(req.bytes, req.ext);
|
||||
} else {
|
||||
res = { ok: false, report: "occ_service: unknown request kind " + req.kind };
|
||||
}
|
||||
} catch (err) {
|
||||
res = { ok: false, report: "occ_service worker: " + err };
|
||||
}
|
||||
const out = {
|
||||
ok: !!(res && res.ok),
|
||||
report: res && res.report,
|
||||
fileName: res && res.fileName,
|
||||
bytes: res && res.bytes,
|
||||
};
|
||||
postMessage({ id, res: out }, out.bytes ? [out.bytes.buffer] : []);
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Tool } from "@pcbjam/shared";
|
||||
import { WASM_MANIFEST_FILE, WASM_ROOT } from "@/lib/config";
|
||||
import { TOOL_BUNDLE } from "./constants";
|
||||
import { TOOL_BUNDLE, type Bundle } from "./constants";
|
||||
|
||||
/**
|
||||
* Resolve the per-tool WASM asset base at runtime from the CDN release manifest.
|
||||
|
|
@ -38,15 +38,17 @@ function loadManifest(): Promise<WasmManifest> {
|
|||
* - manifest ⇒ `WASM_ROOT/<tool>/<ver>` from `manifest-<appTag>.json`.
|
||||
*/
|
||||
export async function resolveWasmBase(
|
||||
tool: Tool,
|
||||
tool: Tool | Bundle,
|
||||
override?: string,
|
||||
): Promise<string> {
|
||||
if (override) return override.replace(/\/+$/, "");
|
||||
if (!WASM_MANIFEST_FILE) return WASM_ROOT; // flat (dev / same-origin)
|
||||
// A tool may be served by a shared bundle (all four editors → kicad_editor);
|
||||
// resolve the folder/version of the bundle, not the logical tool (bundles are
|
||||
// the only thing published/listed in the manifest).
|
||||
const bundle = TOOL_BUNDLE[tool];
|
||||
// the only thing published/listed in the manifest). A caller may also name a
|
||||
// bundle directly (occ_service — a delivery artifact backing no tool).
|
||||
const bundle: Bundle =
|
||||
(TOOL_BUNDLE as Partial<Record<string, Bundle>>)[tool] ?? (tool as Bundle);
|
||||
const manifest = await loadManifest();
|
||||
const ver = manifest.tools?.[bundle];
|
||||
if (!ver) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue