From db9d6ee04b3c712badd25ee0d45e6fc5e51ea47f Mon Sep 17 00:00:00 2001 From: Viktor Vaczi Date: Fri, 3 Jul 2026 12:39:58 +0200 Subject: [PATCH] =?UTF-8?q?feat(wasm):=20occ-split=20=E2=80=94=20lazy=20oc?= =?UTF-8?q?c=5Fservice=20worker;=20kicad=5Feditor=20drops=20OCC=20(?= =?UTF-8?q?=E2=88=9231%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 +- docker/build.sh | 13 +- docs/features/occ-split/README.md | 468 +++++++++++++++++++++++++ kicad | 2 +- scripts/common/versions.sh | 8 + scripts/deploy/dev-demo.mjs | 5 +- scripts/deploy/publish-wasm.mjs | 9 +- scripts/deps/build-opencascade.sh | 19 +- scripts/kicad/build-kicad-target.sh | 47 ++- scripts/kicad/build-occ_service.sh | 8 + tests/fixtures/demo/demo.kicad_prl | 105 ++++++ tests/kicad/3d-viewer-models.spec.ts | 12 + tests/kicad/fixtures.ts | 11 + tests/kicad/occ-export.spec.ts | 147 ++++++++ tests/kicad/occ-probe.spec.ts | 110 ++++++ tests/kicad/utils/occ-service.ts | 102 ++++++ tests/playwright-kicad.config.ts | 5 + tests/scripts/setup-kicad-wasm.sh | 2 + wasm/occ-service/CMakeLists.txt | 71 ++++ wasm/occ-service/occ_service_main.cpp | 391 +++++++++++++++++++++ wasm/occ-service/occ_service_pre.js | 117 +++++++ wasm/stubs/exporter_step_stub.cpp | 183 ++++++++++ wasm/stubs/oce_plugin_stub.cpp | 225 ++++++++++++ web/standalone/src/wasm/boot.ts | 10 + web/standalone/src/wasm/constants.ts | 6 +- web/standalone/src/wasm/occ-service.ts | 162 +++++++++ web/standalone/src/wasm/occ-worker.js | 68 ++++ web/standalone/src/wasm/wasm-assets.ts | 10 +- 28 files changed, 2296 insertions(+), 23 deletions(-) create mode 100644 docs/features/occ-split/README.md create mode 100755 scripts/kicad/build-occ_service.sh create mode 100644 tests/fixtures/demo/demo.kicad_prl create mode 100644 tests/kicad/occ-export.spec.ts create mode 100644 tests/kicad/occ-probe.spec.ts create mode 100644 tests/kicad/utils/occ-service.ts create mode 100644 wasm/occ-service/CMakeLists.txt create mode 100644 wasm/occ-service/occ_service_main.cpp create mode 100644 wasm/occ-service/occ_service_pre.js create mode 100644 wasm/stubs/exporter_step_stub.cpp create mode 100644 wasm/stubs/oce_plugin_stub.cpp create mode 100644 web/standalone/src/wasm/occ-service.ts create mode 100644 web/standalone/src/wasm/occ-worker.js diff --git a/.gitignore b/.gitignore index 4ab43d1..8a527dc 100644 --- a/.gitignore +++ b/.gitignore @@ -93,4 +93,5 @@ output/ /memory/ # Wrangler (Cloudflare CLI) local state/cache — created when running R2 deploys. -.wrangler/ \ No newline at end of file +.wrangler/ +tests/.scratch/ diff --git a/docker/build.sh b/docker/build.sh index 4a61f35..c881d6b 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -67,7 +67,7 @@ trap 'kw_fail 130; exit 130' INT TERM cd "$(dirname "$0")/.." -VALID_APPS="kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | sym_convert | all" +VALID_APPS="kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | sym_convert | occ_service | all" usage() { echo "Usage: ./docker/build.sh [,...] [args...]" >&2 @@ -95,12 +95,12 @@ shift # possible (especially with KICAD_PIPELINE=1). pcbnew/eeschema stay buildable as # standalone debug aids but are not part of "all" (not deployed). if [[ "$APP_NAME" == "all" ]]; then - APPS=(kicad_editor calculator pl_editor gerbview) + APPS=(kicad_editor occ_service calculator pl_editor gerbview) else IFS=',' read -r -a APPS <<< "$APP_NAME" for app in "${APPS[@]}"; do case "$app" in - kicad_editor|pcbnew|eeschema|calculator|pl_editor|gerbview|sym_convert) ;; + kicad_editor|pcbnew|eeschema|calculator|pl_editor|gerbview|sym_convert|occ_service) ;; *) echo "Error: unknown app '$app' (expected: ${VALID_APPS})" >&2 usage @@ -256,9 +256,10 @@ postprocess_app() { local app="$1" local out_dir="output" - # The converter is finalized in-container (real tools, small -g0 wasm) and is - # a synchronous node CLI, so it needs no host post-processing. - if [ "$app" = "sym_convert" ]; then + # The converter and the OCC service are finalized in-container (real tools, + # small -g0 wasm) and build with ASYNCIFY=0, so they need no host + # post-processing (no dyncall shims, no finalize, no asyncify). + if [ "$app" = "sym_convert" ] || [ "$app" = "occ_service" ]; then echo "Skipping host post-processing for ${app} (finalized in-container)" return 0 fi diff --git a/docs/features/occ-split/README.md b/docs/features/occ-split/README.md new file mode 100644 index 0000000..4632fdf --- /dev/null +++ b/docs/features/occ-split/README.md @@ -0,0 +1,468 @@ +# OCC split — move OpenCASCADE out of `pcbnew.wasm` into a lazy worker service + +> Feature doc for branch **`occ-lazy-load`** (worktree `../kicad-wasm-occ-lazy-load`), 2026-07-02. +> Hard constraints: **native wasm-EH, pthreads, Asyncify, NO JSPI, NO dynamic linking** +> (dlopen/SIDE_MODULE/wasm-split are RED — §2). Codebase claims carry `file:line` +> (kicad @ `9d77139`, root @ `9787efc`). + +--- + +## TL;DR + +OpenCASCADE (OCC) is **~30% of `pcbnew.wasm`** (~19.5 MiB base code → ~35–40 MB after +Asyncify) and serves **two features**: parsing STEP/IGES **component models** for the 3D +viewer (working — and the primary model path, §1a), and STEP/3D **export** (broken — +its UI spawns a `kicad-cli` subprocess, impossible in a browser, §1b). + +**The plan:** move OCC and both features into a standalone **`occ_service`** module — +own emscripten instance and memory, running in a **Web Worker**, fetched **on demand** — +leaving `pcbnew.wasm` OCC-free. + +- `pcbnew.wasm`: **~146 → ~109 MB raw** (the 2D-editing download, ~25–30% less transfer). +- `occ_service.wasm`: **~25–45 MB raw** estimate (our OCC compiles `-O1`; the worker also + carries the board parser — measure), fetched only when a STEP model must be parsed or + the user exports. +- **3D viewer behavior is unchanged** (same parser code, executed in the worker; §3); + **export starts working for the first time**. + +The pcbnew↔service API is **two functions**: + +``` +pcbnew.wasm (no OCC) occ_service.wasm (worker, lazy) +Export dialog OK ─► occExport(boardSexpr, paramsJson) ──► parse + EXPORTER_STEP ─► bytes ─► downloadBytes() +oce3d_Load shadow ─► occLoadModel(fileBytes, ext) ──────► OCC parse + tessellate ─► scenegraph-cache bytes ─► S3D::ReadCache +``` + +--- + +## 1. The two OCC consumers today + +OCC is reached by exactly two subsystems (grep-proven across `kicad/` for +`TopoDS`/`BRep`/`Standard_`/`STEPControl`/…). Everything else — 2D editor, GAL, DRC, +router, kimath, the 3D scene build and raytracer (board geometry is native earcut +tessellation), eeschema, gerbview — is OCC-clean. + +### 1a. Component-model import — WORKING, and OCC is its primary parser + +The 3D viewer's format loaders are static libraries in the wasm build (upstream loads +them as dlopen plugins; wasm has no dlopen, so they're linked in with per-TU symbol +renames and a registry): + +- `plugins/3d/vrml` (`vrml3d_*`) — VRML/WRL/X3D, no OCC. +- `plugins/3d/oce` (`oce3d_*`) — **STEP/IGES via OCC** (`loadmodel.cpp`: STEP/IGES + readers → `BRepMesh_IncrementalMesh` → `SCENEGRAPH` triangles). +- Registry: `3d-viewer/3d_cache/pcbjam_static_3d_plugins.cpp` (EMSCRIPTEN-only TU) feeds + both into the plugin manager; `3d-viewer/CMakeLists.txt` links + `s3d_plugin_vrml s3d_plugin_oce` for EMSCRIPTEN. + +Model **files** arrive lazily: when the filename resolver can't find a model, +`S3D_CACHE::load` (`3d_cache.cpp:156-161`) calls `PCBJAM_3D::EnsureModelFile` +(`3d-viewer/3d_cache/pcbjam_model_fetch.cpp`) — an **`EM_ASYNC_JS` asyncify suspend** +that asks the JS provider (`kicadLibs.request`, kind `model3d`; +`web/standalone/src/wasm/libs/models-{source,bridge}.ts`), which fetches +**R2 CDN → IndexedDB cache → MEMFS** and returns the absolute path. Once per unique +model, memoized, e2e-covered (`tests/kicad/3d-viewer-models.spec.ts`). + +**Key consequence for this feature:** the published model library (kicad-packages3D +10.x) is **STEP-only** — `.wrl` asks are served `.step` bodies by the bridge's fallback +map (`models-bridge.ts:106-117`), and the served extension picks the plugin. So for +library models, **`oce`/OCC is the parser that runs**; VRML handles only project-local +legacy files. Any OCC removal must therefore carry this path along, gated by the +3d-viewer-models spec + screenshots staying green. + +### 1b. STEP/3D export — BROKEN (its UI spawns a subprocess) + +`DIALOG_EXPORT_STEP` (File → Export → STEP/GLB…) does not run the exporter in-process, +even on desktop. On Export (`pcbnew/dialogs/dialog_export_step.cpp:548-708`) it locates +the **`kicad-cli`** binary, builds a command string from the dialog controls +(`… pcb export step --no-dnp --subst-models --output …`), and spawns it via `wxExecute`; +the child process maps the flags to `JOB_EXPORT_PCB_3D` and runs +`PCBNEW_JOBS_HANDLER::JobExportStep` (`pcbnew_jobs_handler.cpp:532`, `EXPORTER_STEP` +ctor at `:648`) — OCC executes in the child. In the browser this fails twice: we ship +no kicad-cli, and wasm has no processes (`wxExecute` needs fork/exec). The exporter +cluster (`exporters/step/*` + `exporters/u3d/*`, whose only external caller is that +jobs handler) is linked but unreachable from any UI path. + +**Scale note:** in time, both consumers are batch — once per unique model at scene +build, once per export click. Inside, they make thousands of fine-grained OCC calls +(`step_pcb_model.cpp`, `loadmodel.cpp`) — which is why the split boundary is files/bytes +handed to two entry points, never OCC's own API. + +--- + +## 2. Architecture: two RPC functions, separate instance, Web Worker + +Every form of Emscripten **dynamic linking is RED** here (each constraint breaks it +independently): dlopen can't rewind through asyncify (emscripten #13049), pthread +workers re-compile side modules (#17078), EH-across-boundary is fragile (#22285), +`wasm-split` needs `PROXY_TO_PTHREAD` (GUI is on our main thread). Full analysis: +`docs/features/perf/bundle-size.md §4` (bundle-size branch). + +A **separate emscripten instance in a dedicated Worker** sidesteps all of it — own +`Module`, own memory, own runtime; data crosses by copying. For export this mirrors +what desktop already does (kicad-cli child process ↔ worker; flags ↔ params JSON; file +on disk ↔ bytes back). For import it relocates where the parse executes; the delivery +pipeline (CDN→IDB→MEMFS) and everything downstream of `SCENEGRAPH` are untouched. + +- **Transport = postMessage + transferable `ArrayBuffer`s** (move semantics, zero-copy). + The only real copies are into/out of each wasm heap — fundamental (a module addresses + only its own linear memory), ms-scale on MB payloads vs seconds-scale OCC work. + SharedArrayBuffer views were considered and rejected: both heaps are already SABs + (`-pthread`), but direct sharing grants the worker write access to the editor heap and + couples us to cross-heap pointer lifetime + shared-memory-growth semantics across 3 + engines, for no measurable win. Cancel = `worker.terminate()` + re-boot. +- **Export result bytes never enter pcbnew's heap**: worker → provider → Blob → + `downloadBytes()` (`web/standalone/src/lib/download.ts:6`); C++ receives a small + status/report JSON. Import results (scenegraph-cache bytes) do enter — `S3D::ReadCache` + consumes them there. +- Neither OCC job suspends internally (no `emscripten_sleep`/modal/`PROGRESS_REPORTER` + in either path), so the service builds **`-sASYNCIFY=0`** — no ~2× asyncify tax. + External precedent: [andymai/occt-wasm](https://github.com/andymai/occt-wasm) (OCCT in + a Worker, `-fwasm-exceptions`, no asyncify → 20.8 MB / ~4 MB brotli); + [kovacsv/occt-import-js](https://github.com/kovacsv/occt-import-js) (STEP→mesh). + +**Latency reality:** the 2D editor (the common session) never fetches the service. A 3D +view of a board with library models effectively always will (the 10.x library is +STEP-only) — one fetch (~5–9 MB brotli), then IDB-cached like the models themselves. + +--- + +## 3. Design decisions + +- **Callee-shadowing, not caller-`#ifdef`s.** Both features reduce to one shadowable + function each, and the wasm build swaps in replacement definitions (the established + `PCBNEW_WASM_STUBS` pattern): + - `EXPORTER_STEP` ctor/dtor/`Export()` — shadow TU in our repo (`wasm/stubs/`); + `pcbnew_jobs_handler.cpp` and every other caller compile untouched and keep working. + - `oce3d_Load` (+ the `oce3d_*` metadata getters the registry needs — mirroring + `oce.cpp`'s extension/filter answers) — shadow TU replaces linking `s3d_plugin_oce`; + the registry, plugin manager, `S3D_CACHE`, and scene build compile untouched. +- **UI stays exactly as desktop.** Dialog, menu, actions untouched. The **single KiCad + source `#ifdef`** in the whole feature is in `dialog_export_step.cpp` at the + `wxExecute` spawn site: read the same controls, fill `EXPORTER_STEP_PARAMS`, call + `EXPORTER_STEP(…).Export()` directly — the shadow does the rest. (There is no function + to shadow there; the divergence *is* the process spawn.) +- **3D viewer parity is a hard gate.** Same parser code (`loadmodel.cpp`) compiled into + the service; results return via KiCad's own scenegraph serialization + (`S3D::WriteCache`/`ReadCache`, `plugins/3dapi/ifsg_api.h` — the on-disk model-cache + format). `3d-viewer-models.spec.ts` + the screenshot gate must stay green. +- **Params JSON = the official job JSON.** `JOB_EXPORT_PCB_3D` registers every field as + a serializable `JOB_PARAM` (`common/jobs/job_export_pcb_3d.cpp:94-149`); the bridge + uses KiCad's own (de)serialization, no invented schema. Every dialog option is honored + (formats STEP/GLB/XAO/BREP/PLY/STL + all flags). +- **Export component models = desktop parity in v1.** Models embedded in the board file + travel inside the sexpr and work; disk-path references the worker can't see hit + `EXPORTER_STEP`'s missing-file warn+skip, like desktop with a broken path. The + CDN model pipeline (§1a) is the natural upgrade to full component embedding — + follow-on (§7). +- **Alternative rejected — wasm kicad-cli in a worker:** same architecture, but stock + kicad-cli links both kifaces + all job handlers (~100+ MB lazy module); trimming it is + more fork surgery than the one dialog `#ifdef`; emulating the spawn (argv string + parsing, `wxProcess` event plumbing) outweighs it; and the real glue (board in, bytes + out) is needed either way. + +--- + +## 4. Implementation + +### Stage 1 — `occ_service` target (build + entry, no pcbnew changes) + +Template = `sym_convert`, the proven gated headless `-sASYNCIFY=0` KiCad module +(`kicad/eeschema/CMakeLists.txt:796-830`), adapted from run-once Node CLI to persistent +worker embind: + +- Target lives in the superproject: `wasm/occ-service/CMakeLists.txt`, hooked from the + kicad fork's top-level CMakeLists via + `add_subdirectory( ${KICAD_WASM_LAYER}/occ-service )` under + `option( KICAD_OCC_SERVICE_WASM … OFF )` (the `wasm/editor` pattern; kicad keeps only + the option + hook + the exporter-source `CACHE INTERNAL` export). Linked like + `sym_convert`: full pcbnew kiface libraries + `s3d_plugin_oce` + `kicad_3dsg` + + `${OCC_LIBRARIES}` + `LINKER:--allow-multiple-definition`, in its own configured tree. +- **Size mechanism = link-time `-Oz` whole-module DCE** (as `sym_convert` documents, + `eeschema/CMakeLists.txt:825-827`): the only roots are the embind entry + runtime, so + editor/GAL/tool code pulled via the kiface objects is stripped. Fallback if fat: + `add_library( … STATIC $ )` for + object-granularity pruning. Measure first. +- **LINK_FLAGS** = `-Oz -g0 -sASYNCIFY=0 --pre-js …/occ_service_pre.js -sMODULARIZE=1 + -sEXPORT_NAME=OccService --bind -sENVIRONMENT=worker,node -sEXIT_RUNTIME=0`, keep + `-pthread` + the EH triple (`env.sh:46`) + the inherited **2N+8 pre-warmed pthread + pool** — load-bearing; see the threading note in §7 for the confirmed Chromium + deadlock it prevents. NOT `-sINVOKE_RUN`/`-sEXIT_RUNTIME=1`/`-sNODERAWFS` + (sym_convert's CLI model). +- **Entry `wasm/occ-service/occ_service_main.cpp`**, embind (compiled inside the CMake target so + its defines match the linked objects — the embind vtable-skew class): + - `occExport(boardSexpr, paramsJson) → bytes`: headless board load replicating + `pcbnew_scripting_helpers.cpp:96-235` (standalone `SETTINGS_MANAGER`, default + project, `PCB_IO_MGR::Load(KICAD_SEXP)`, `SetProject`) — that file itself is + `KICAD_SCRIPTING`-gated, so the ~30-line pattern is replicated, not linked; + paramsJson → `JOB_EXPORT_PCB_3D::FromJson` → the JOB→`EXPORTER_STEP_PARAMS` mapping + of `pcbnew_jobs_handler.cpp:630-648` → `EXPORTER_STEP(…).Export()` to MEMFS → bytes. + - `occLoadModel(fileBytes, ext) → bytes`: write to MEMFS with the right extension + (extension picks STEP vs IGES inside), call the real `oce3d_Load`, + `S3D::WriteCache` the returned `SCENEGRAPH` → return the cache bytes. + - `wasm/occ-service/occ_service_pre.js`: in-memory wxConfig store (copy `sym_convert_pre.js`). +- **Build wiring**, mirroring `sym_convert`: `scripts/kicad/build-occ_service.sh`; the + `case`/option/tool-gates in `scripts/kicad/build-kicad-target.sh`; `docker/build.sh` + `VALID_APPS`/validation/subdir + **skip host post-processing and the asyncify pass** + for this target. +- **Verify**: Node (`-sENVIRONMENT=worker,node`) unit run — `occExport` on a real + `.kicad_pcb` (validate the STEP round-trips, e.g. `occt-import-js`), `occLoadModel` on + a real `.step` (cache bytes non-empty, `ReadCache`-able). + +### Stage 2 — bridges + JS provider (pcbnew gains the worker paths; OCC still linked) + +- **Export shadow** `wasm/stubs/exporter_step_stub.cpp` (appended to + `PCBNEW_WASM_STUBS`): `Export()` serializes the live `BOARD` to sexpr (in-memory + `PCB_IO_KICAD_SEXPR`), builds the job JSON, calls `js_occRequest` (`EM_ASYNC_JS`, + near-copy of the main-thread path of `pcb_io/pcbjam_fp/pcb_io_pcbjam_fp.cpp:58-95`; + the `js_*` name is auto-covered by `scripts/common/asyncify-imports.txt`), returns the + status/report JSON to the caller; bytes go provider → Blob → `downloadBytes()`. +- **Import shadow** `wasm/stubs/oce_plugin_stub.cpp`: the full `oce3d_*` flat-C surface + with `oce.cpp`'s metadata answers; `oce3d_Load(path)` reads the file from MEMFS + (already materialized by `EnsureModelFile`), ships bytes + ext via the same + `js_occRequest` channel, writes the returned cache bytes to a temp MEMFS path, + `S3D::ReadCache` → `SCENEGRAPH*`. Suspending here is proven legal — `EnsureModelFile` + already asyncify-suspends inside the same `S3D_CACHE::load` call path. +- **Dialog seam**: the one `#ifdef __EMSCRIPTEN__` at `dialog_export_step.cpp:568/:708` + (controls → params → `EXPORTER_STEP(…).Export()`). +- **JS provider/worker**: `installOccService()` sets `globalThis.occService = { request }` + (copy `installLibsProvider`, `web/standalone/src/wasm/libs/source.ts:245-386`). First + request lazily fetches + boots the module in a dedicated Worker (manifest-resolved, + `MODULARIZE` factory; boot pattern `site/public/gerber-demo/boot.js`; reuse the + cross-origin blob-`importScripts` shim `web/standalone/src/wasm/boot.ts:113-137` for + the worker + its nested pthread workers); transferables both ways; export results → + `downloadBytes()`, import results → back to the caller. Manifest entry in + `web/standalone/src/wasm/wasm-assets.ts`. + Inside the blob wrapper every asset path must be absolutized against the glue's + URL — `locateFile: (f) => new URL(f, GLUE).href` — because a `blob:` worker has + no http base: a string-concat base works for absolute CDN URLs but a + root-relative base like `/wasm/` fails URL parsing in the worker, aborting the + module before the `.wasm` request even hits the network. Export download names + guard against the dialog's empty-stem default (`.step` → `export.step`). + The worker-side wrapper is ONE shared plain-JS file, + `web/standalone/src/wasm/occ-worker.js` (app imports it via vite `?raw`; the + e2e harness stub reads it off disk) — the host prepends a one-line + `self.OCC_GLUE_URL = …` prelude to the blob. In tests the provider is + installed ambiently by `tests/kicad/fixtures.ts` as an init script. +- In this stage the shadows can ship dark (service used only under a flag or test) — + pcbnew still links OCC, so behavior is unchanged until Stage 3 flips. + +### Stage 3 — unlink OCC from `pcbnew` (the payoff, gated by green e2e) + +All in `kicad/pcbnew/CMakeLists.txt` + `kicad/3d-viewer/CMakeLists.txt`, house +importer-gate style: + +1. Exporter sources (`exporters/step/*` + `exporters/u3d/*`) → `PCBNEW_OCC_EXPORTERS`, + appended to `PCBNEW_EXPORTERS` only `if( NOT EMSCRIPTEN )` (the service target + compiles the variable). +2. `${OCC_LIBRARIES}` on `pcbnew_kiface_objects` under `if( NOT EMSCRIPTEN )`. + `find_package(OCC)` + top-level OCC include dirs stay (headers still compile). +3. 3d-viewer: for EMSCRIPTEN link `s3d_plugin_vrml` + the import shadow instead of + `s3d_plugin_oce`. +4. Export + import shadows go live (they are the only definitions now). + +Gate: full kicad e2e in all 3 engines + `3d-viewer-models.spec.ts` + screenshot diff — +STEP component models must render identically through the worker. Then measure. + +--- + +## 5. Verification & benchmarks + +- **Stage-by-stage**: Node unit (Stage 1); e2e with the service behind a flag (Stage 2); + the full gate on Stage 3 (above). +- **New e2e specs** (all 3 engines, wired like `3d-viewer-models.spec.ts`): + `tests/kicad/occ-export.spec.ts` — dialog-driven export; asserts `occ_service` is + fetched lazily (not on first load, once on export), the download's bytes validate + (STEP `ISO-10303-21` + deep-parse via an `occt-import-js` devDependency; magic-byte + smoke for GLB/BREP/XAO/PLY/STL), and the UI stays responsive mid-export. + `tests/kicad/occ-import.spec.ts` — cold-cache 3D view of a board with library + models; asserts the model fetch + `occ_service` fetch both happen and components + render (screenshot). Fixtures come from the existing 3d-viewer-models board; extra + `.step` fixtures may be fetched from the models CDN and committed under `tests/`. +- **Benchmarks** (main vs post-Stage-3; same machine, committed `BINARYEN_OPT_LEVEL`; + fill §6): + - **Raw sizes only** (no gzip/brotli measuring): `pcbnew.wasm` (+ `.js` glue), + `occ_service.wasm`, `footprint_editor.wasm` (byte-dup of pcbnew). + - **Build time**: wall-clock `./docker/build.sh pcbnew`, plus the `occ_service` build. + - **Build RAM**: peak RSS of the post-link `wasm-opt`/`apply-asyncify.sh` steps (GNU + time) — asyncify peaks ~8.3 GB today; release-config `wasm-opt` OOMs >64 GB; check + whether the lean module fits release builds back under 64 GB. + +## 6. Results + +| Metric | main | after split | +|---|---|---| +| `pcbnew.wasm` raw | 146.7 MB (153,787,939 B) | **103.4 MB (103,378,614 B) — −29.6%** | +| `footprint_editor.wasm` raw | ~146.7 MB (byte-dup) | **103.4 MB (103,378,688 B)** | +| `kicad_editor.wasm` raw (merged, post-unification) | ~190 MB (CI, OCC-linked) | **130.0 MB (136,282,145 B)** | +| `occ_service.wasm` raw (lazy) | n/a | **57.0 MB (57,037,715 B)** (+ 0.28 MB glue) | + +The `kicad_editor` row is the post-rebase state (editor-unification Part 2 merged +pcbnew+eeschema into ONE bundle): the split carries over structurally — the OCC +gates live on `pcbnew_kiface_objects` / `3d-viewer`, which is exactly what the +merged target links via `PCBNEW_KIFACE_LIBRARIES` — so the merged image is +OCC-free with no extra wiring (zero OCC type-registration strings in the shipped +wasm; positive control `occ_service.wasm` has 35). + +**Clean-KiCad build benchmark** (2026-07-02, same machine, sequential/idle, +`--clean-kicad`, deps + ccache warm on both sides; container mem sampled at 5 s): + +| Stage | old `pcbnew` (main) | new `pcbnew` | `occ_service` | +|---|---|---|---| +| kicad-configure | 131 s | 28 s | 32 s | +| kicad-compile + link | 160 s | 37 s | 110 s (incl. in-container `-Oz` DCE + finalize) | +| finalize (host) | 5 s | 3 s | — | +| post-link stage (hoist + `--asyncify` + `wasm-opt -O1`, host) | **106 s** | **55 s (−48%)** | — (`ASYNCIFY=0`) | +| **total wall** | **449.7 s** | **185.7 s** | **159.2 s** | +| host peak RSS (post-processing) | **10.28 GiB** | **6.74 GiB (−35%)** | 0.05 GiB | +| container mem peak (compile/link) | 8.87 GiB | 6.01 GiB | 6.81 GiB | + +**The race: old `pcbnew` 449.7 s vs new `pcbnew` + `occ_service` 344.9 s (−23%)** — +and the two new-side builds are independent (parallelizable in CI). Caveats: the +worktree container's ccache was hotter (it had compiled these sources several times +that day), which flatters the new side's configure/compile numbers; the post-link +stage and RSS numbers are input-size-driven and ccache-independent — those are the +structural wins. Per-pass wasm-opt splits only appear on Linux (GNU `time -v`); on +macOS the post-link stage is timed as one unit. + +OCC symbol check: zero `libTK*`/`StepAP214`/`BRepBuilderAPI` strings in the +shipped `pcbnew.wasm`; the asyncify removelist's OCC patterns now warn +"non-matching" (nothing left to match). + +Node unit (2026-07-02): `occExport` → valid `ISO-10303-21` STEP (60,628 B) in +274 ms incl. per-model desktop-parity warn+skip reporting; `occLoadModel` +round-trips that STEP into a 199,971 B scenegraph cache in 124 ms; boot 146 ms. + +E2e (2026-07-02, final binaries, ALL apps built): **full suite 138 passed / 6 +skipped / 0 failed / 0 flaky** — every kicad spec × Firefox+Chromium, including +`occ-export` (lazy-fetch boundary + dialog-driven STEP download), `occ-probe`, +`3d-viewer-models` (hard `oce Load ok` worker-parse assertion + render), and +`3d-viewer-deadlock`. Chromium probe exports the demo board in 1.3 s. + +Standalone + demo app (2026-07-02, live dev servers, headless Chromium): the +real web provider passes end to end in both modes. Plain dev — full dialog +click-through (File → Export → STEP dialog → Export) fetches +`occ_service.{js,wasm}` only on the Export click and lands a browser download +`export.step` (60,628 B, `ISO-10303-21`), byte-identical to the Node unit; +`loadModel` parses a 700,618 B STEP into a 569,097 B scenegraph cache in 2.1 s +including worker boot. Demo mode (`dev-demo.mjs`, CDN libs + models): the demo +board's `.wrl` models resolve through the still-static VRML plugin and +`occ_service` is correctly never fetched — the lazy boundary holds for +VRML-only boards. (The cold-cache "Failed to retrieve file times" modal that +demo mode pops while models download is the 3D-models pipeline's +stat-before-ensure behavior — reproduced identically on a main checkout with +OCC-linked pcbnew, i.e. independent of this split.) + +**Validated against desktop kicad-cli 10.0.4 (2026-07-03, user's install; neutral +referee = occt-import-js tessellating both files):** geometric **exact equality** +— identical mesh/triangle counts, bbox Δ = 0.00 µm, volume Δ = 0.0000% — across +3 boards (demo / pic_programmer / openair body-only) and option sweeps (copper +stack, silk+mask, components-on skip-parity), for STEP, GLB, STL, BREP and +STPZ; PLY/XAO structurally identical (sizes within dozens of bytes); U3D +same-size/same-structure with only quantizer float-LSB byte differences +(deterministic on both sides; its input tessellation is proven identical by the +STL result); 3D-PDF structurally equal (0.02% size delta = embedded +timestamps). Desktop runs OCC 7.9 vs our wasm OCC 7.8 — equality across +*different* OCC versions. Our worker was also faster per export than the CLI. +The comparison surfaced exactly one gap — the GLB writer flag, §7 — now fixed +and guarded by occ-probe's 9-format matrix (step/stpz/brep/xao/ply/stl/glb/u3d/ +pdf, magic + size asserted through the real worker). + +Post-unification rerun (2026-07-02, after rebasing onto editor-unification +Part 2 — the four editors now ship as the ONE merged `kicad_editor` bundle): +full kicad suite **74 passed / 3 skipped / 0 failed / 0 flaky on Firefox +(2.3 m) AND Chromium (12.2 m)** — including `occ-export`/`occ-probe`, +`3d-viewer-models` (STEP parse through the worker) and `xface-probe` (the +cross-face path that motivated installing the occ provider whenever the +`kicad_editor` bundle boots, not just for the PCB tools). Standalone reruns on +the live dev server: dialog click-through → lazy `occ_service` fetch → browser +download `export.step` (60,628 B, `ISO-10303-21`, byte-identical to the Node +unit); `loadModel` 700,618 B STEP → 569,097 B cache in 1.6 s incl. worker boot. + +--- + +## 7. Risks / notes + +- **OCC writer toolkits (RESOLVED 2026-07-03)**: the toolkits were all built, but + OCC's glTF/GLB writer compiles itself out without RapidJSON + (`-DUSE_RAPIDJSON=OFF` → runtime "glTF writer is unavailable + [HAVE_RAPIDJSON undefined]") — found by the kicad-cli comparison, invisible to + the then-STEP-only e2e. Fixed by building OCC against the same RapidJSON + official KiCad uses (kicad's `vcpkg.json` pulls opencascade's `rapidjson` + feature): the vcpkg-pinned **master snapshot 2025-02-26** + (`24b5e7a8b27f42fa16b96fc70aade9106cf7102f`). RapidJSON's latest release tag + (v1.1.0, 2016) is ill-formed under modern clang (`GenericStringRef::operator=` + assigns const members — upstream issue #2347) and is not what any current + KiCad build consumes. Regression net: occ-probe's 9-format matrix. +- **DCE efficacy**: if link-time `-Oz` leaves the service fat, use the + `$`→STATIC-archive fallback (§4 Stage 1). Measure first. +- **Import parity**: `SCENEGRAPH` must survive `WriteCache`→`ReadCache` byte-exactly + enough for identical renders — it's KiCad's own on-disk cache format, used for exactly + this purpose; screenshot gate confirms. +- **Malformed STEP**: OCCT throws `Standard_Failure` inside the worker; `occLoadModel` + catches and returns empty → the existing per-model skip behavior (the registry's + exception barrier stays as a second net). +- **Threading (RESOLVED — two load-bearing pieces)**: the service needs the **2N+8 + pre-warmed pthread pool** (inherited from `build-kicad-target.sh`'s + `PTHREAD_POOL_EXPR`, the raytrace-deadlock fix `7630c7e`) AND the + `GetKiCadThreadPool()` warm-up in its `main()`. Confirmed by a `PTHREADS_DEBUG` trace: + during export the KiCad pool consumes N workers, then OCC spawns its own wave (a + launcher + N/2 workers) from a blocked context — a browser cannot create Workers on + demand for a blocked thread, so with only N pre-warmed workers Chromium hangs forever + inside `EXPORTER_STEP` (Firefox and Node happened to tolerate it; Chromium e2e caught + it). Don't shrink the pool. +- **Headless bootstrap** in the worker (SETTINGS_MANAGER/locale/ADVANCED_CFG) mirrors + `sym_convert` + the scripting-helpers pattern; budget for pcbnew-specific singletons. +- **Asyncify-EH caveat**: don't rely on RAII/try-catch cleanup on the pcbnew side + *across* the suspend (`asyncify-eh-unwind-landing-pads-unreliable`); the happy path is + proven by clipboard/fonts/`pcbjam_fp`/`EnsureModelFile`. +- **Shadow ↔ header fidelity**: the shadows must track `exporter_step.h` and the + `oce3d_*` surface across KiCad rebases; drift surfaces as link errors (the good + failure mode). +- **Coordination**: the import path relocates where `oce3d_Load` executes — same code, + worker address space; owner of the 3D-models feature should know. +- **Deploy**: the live R2 wasm manifest carries no `occ_service` entry, so + manifest-mode runs (`dev-demo --wasm r2`, the deployed demo) fail occ requests + with `no WASM version for "occ_service"` until the next publish + (`publish-wasm.mjs` already lists the tool and its no-shared-files layout); + the split editor build must ship together with its `occ_service` folder. +- IGES **export** is behind `#ifdef SUPPORTS_IGES` (`step_pcb_model.h:250`), not in the + dialog — out of scope. (IGES **import** works through `oce3d_Load` like STEP.) + +--- + +## 8. Follow-ons (out of scope for v1) + +- **Full component embedding in exports**: stage referenced model files into the worker + (they're fetchable now via the models CDN pipeline) instead of warn+skip. +- **VRML in numbers**: if legacy `.wrl` project files turn out rare, `s3d_plugin_vrml` + could also move to the service; if common, it stays (it's small and OCC-free). +- **`AddPadShape` command-stream refactor** (`step_pcb_model.h:105`): would shrink the + service by dropping the board-model/parser duplication; touches KiCad core → deferred. +- **Other pull-out candidates** (bundle-size research, ranked): GAL bitmap font atlas + ~3.0 MiB + newstroke font ~2.2 MiB (externalize as fetched assets — best + value-per-effort after OCC); resident foreign importers (extend the existing + `NOT EMSCRIPTEN` gate); protobuf looks dead but is load-bearing via + `EDA_ITEM : SERIALIZABLE` — trap, skip. + +--- + +## Sources + +- External: [andymai/occt-wasm](https://github.com/andymai/occt-wasm) · + [kovacsv/occt-import-js](https://github.com/kovacsv/occt-import-js) · + [donalffons/opencascade.js](https://github.com/donalffons/opencascade.js). +- Dynamic-linking RED analysis + bundle composition: `docs/features/perf/bundle-size.md` + (bundle-size branch; emscripten #13049, #17078, #19034, #19848, #22285). +- Import path: `3d-viewer/3d_cache/{pcbjam_static_3d_plugins,pcbjam_model_fetch,3d_cache}.cpp`, + `plugins/3d/{oce,vrml}/CMakeLists.txt`, `plugins/3d/oce/loadmodel.cpp`, + `web/standalone/src/wasm/libs/models-{source,bridge}.ts`, + `tests/kicad/3d-viewer-models.spec.ts`, `plugins/3dapi/ifsg_api.h` (Write/ReadCache). +- Export path: `pcbnew/dialogs/dialog_export_step.cpp:548-708`, + `pcbnew_jobs_handler.cpp:532-656`, `common/jobs/job_export_pcb_3d.cpp:94-149`, + `pcbnew/exporters/step/*`. +- Build template: `eeschema/CMakeLists.txt:796-830`, `wasm/cli/sym_convert_main.cpp`, + `scripts/kicad/build-kicad-target.sh`, `docker/build.sh`. Headless load: + `pcbnew/python/scripting/pcbnew_scripting_helpers.cpp:96-235`. +- Web plumbing: `web/standalone/src/wasm/{boot.ts,wasm-assets.ts,libs/source.ts}`, + `web/standalone/src/lib/download.ts:6`, `site/public/gerber-demo/boot.js`. diff --git a/kicad b/kicad index f5df597..11a52e4 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit f5df5977e0977063da6f6b66cf69799f5c90159a +Subproject commit 11a52e4c12be7811b1df585749c4cfa874cddbc7 diff --git a/scripts/common/versions.sh b/scripts/common/versions.sh index 85d5a40..4444997 100755 --- a/scripts/common/versions.sh +++ b/scripts/common/versions.sh @@ -29,6 +29,13 @@ export SWIG_MIN="4.0" # Recommended versions for WASM build export OCC_VERSION="7.8.0" +# Header-only; OCC's glTF (GLB) writer requires it (HAVE_RAPIDJSON). RapidJSON +# has tagged no release since 1.1.0 (2016) — whose headers are ill-formed under +# modern clang — so, like official KiCad (whose vcpkg.json pulls opencascade's +# rapidjson feature), we pin the dated master snapshot vcpkg ships. The +# "version" is vcpkg's port date for that commit. +export RAPIDJSON_VERSION="2025-02-26" +export RAPIDJSON_COMMIT="24b5e7a8b27f42fa16b96fc70aade9106cf7102f" export ZSTD_VERSION="1.5.5" export FREETYPE_VERSION="2.13.2" export HARFBUZZ_VERSION="8.3.0" @@ -43,6 +50,7 @@ export HARFBUZZ_URL="https://github.com/harfbuzz/harfbuzz/releases/download/${HA export CAIRO_URL="https://cairographics.org/releases/cairo-${CAIRO_VERSION}.tar.xz" export PIXMAN_URL="https://cairographics.org/releases/pixman-${PIXMAN_VERSION}.tar.gz" export OCC_URL="https://github.com/Open-Cascade-SAS/OCCT/archive/refs/tags/V${OCC_VERSION//./_}.tar.gz" +export RAPIDJSON_URL="https://github.com/Tencent/rapidjson/archive/${RAPIDJSON_COMMIT}.tar.gz" export NGSPICE_URL="https://sourceforge.net/projects/ngspice/files/ng-spice-rework/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz/download" # SHA256 checksums (to be filled in after first successful download) diff --git a/scripts/deploy/dev-demo.mjs b/scripts/deploy/dev-demo.mjs index 26f334b..e823d2f 100644 --- a/scripts/deploy/dev-demo.mjs +++ b/scripts/deploy/dev-demo.mjs @@ -210,7 +210,10 @@ function main() { env.VITE_REPO_URL = a.repo; const viteArgs = ["--dir", "web", "--filter", "@pcbjam/standalone", "dev"]; - if (a.port) viteArgs.push("--", "--port", String(a.port)); + // No "--" separator: pnpm forwards script args verbatim, so a literal "--" + // reaches vite and makes it IGNORE the flags after it ("vite -- --port N" + // starts on the default port). Appending directly yields "vite --port N". + if (a.port) viteArgs.push("--port", String(a.port)); console.log("dev-demo: standalone in demo mode (R2-only backend, no partykit)"); console.log(` VITE_LIBS_SOURCE=${env.VITE_LIBS_SOURCE}${env.VITE_LIBS_MANIFEST_URL ? ` (${env.VITE_LIBS_MANIFEST_URL})` : ""}`); diff --git a/scripts/deploy/publish-wasm.mjs b/scripts/deploy/publish-wasm.mjs index 202024f..c57e10a 100644 --- a/scripts/deploy/publish-wasm.mjs +++ b/scripts/deploy/publish-wasm.mjs @@ -34,16 +34,23 @@ import { // bundle, booted with a runtime --frame flag (editor-unification Part 2) — none of // them publishes anything of its own. The frontend maps tools onto bundles via // TOOL_BUNDLE. sym_convert is a node CLI, not served. +// occ_service is the lazy OCC worker module (STEP export + STEP/IGES model +// parsing for the PCB frames, docs/features/occ-split/) — served, but headless: +// no wx glue, no image archive. const TOOLS = [ "kicad_editor", "pl_editor", "gerbview", "calculator", + "occ_service", ]; // Files that make up a self-contained tool bundle. `` is substituted. const SHARED_FILES = ["wx.js", "wx-dom.js", "images.tar.gz"]; -const toolFiles = (tool) => [`${tool}.wasm`, `${tool}.js`, ...SHARED_FILES]; +const toolFiles = (tool) => + tool === "occ_service" + ? [`${tool}.wasm`, `${tool}.js`] + : [`${tool}.wasm`, `${tool}.js`, ...SHARED_FILES]; // Per-file HTTP rules (see the 0001 header matrix). `compress` is whether the // publisher compresses + sets Content-Encoding; images.tar.gz must stay RAW diff --git a/scripts/deps/build-opencascade.sh b/scripts/deps/build-opencascade.sh index 223845b..89de5db 100755 --- a/scripts/deps/build-opencascade.sh +++ b/scripts/deps/build-opencascade.sh @@ -50,6 +50,22 @@ if [ ! -d "${OCC_DIR}" ]; then rm "opencascade-${OCC_VERSION}.tar.gz" fi +# RapidJSON (header-only): required by OCC's glTF/GLB writer — without it the +# writer compiles out (HAVE_RAPIDJSON undefined) and GLB export fails at +# runtime with "glTF writer is unavailable". Pinned to the vcpkg master +# snapshot (see versions.sh) — a commit archive extracts as rapidjson-, +# so rename to the dated version dir. +RAPIDJSON_DIR="${DEPS_ROOT}/rapidjson-${RAPIDJSON_VERSION}" +if [ ! -d "${RAPIDJSON_DIR}" ]; then + log_info "Downloading RapidJSON ${RAPIDJSON_VERSION} (master snapshot ${RAPIDJSON_COMMIT:0:12})..." + mkdir -p "${DEPS_ROOT}" + cd "${DEPS_ROOT}" + download_file "${RAPIDJSON_URL}" "rapidjson-${RAPIDJSON_VERSION}.tar.gz" + tar -xzf "rapidjson-${RAPIDJSON_VERSION}.tar.gz" + mv "rapidjson-${RAPIDJSON_COMMIT}" "rapidjson-${RAPIDJSON_VERSION}" + rm "rapidjson-${RAPIDJSON_VERSION}.tar.gz" +fi + log_info "Building OpenCASCADE ${OCC_VERSION} for WASM..." log_warn "This is a large library and may take a while..." @@ -100,7 +116,8 @@ emcmake cmake "${OCC_DIR}" \ -DUSE_GLES2=OFF \ -DUSE_OPENGL=OFF \ -DUSE_D3D=OFF \ - -DUSE_RAPIDJSON=OFF \ + -DUSE_RAPIDJSON=ON \ + -D3RDPARTY_RAPIDJSON_DIR="${RAPIDJSON_DIR}" \ -DUSE_DRACO=OFF \ -DBUILD_DOC_Overview=OFF \ -DINSTALL_SAMPLES=OFF \ diff --git a/scripts/kicad/build-kicad-target.sh b/scripts/kicad/build-kicad-target.sh index e434521..82960ea 100755 --- a/scripts/kicad/build-kicad-target.sh +++ b/scripts/kicad/build-kicad-target.sh @@ -73,8 +73,16 @@ case "$APP_NAME" in KICAD_TARGET="sym_convert" KICAD_SUBDIR="eeschema" ;; + occ_service) + # Standalone OpenCASCADE 3D service (worker embind module). Target lives + # in wasm/occ-service/ (added by the fork's top-level CMakeLists under + # -DKICAD_OCC_SERVICE_WASM=ON); like kicad_editor, its binary dir + # doubles as the artifact subdir of its own kicad-occ_service tree. + KICAD_TARGET="occ_service" + KICAD_SUBDIR="occ_service" + ;; *) - echo "Error: unknown app '$APP_NAME' (expected: kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | sym_convert)" >&2 + echo "Error: unknown app '$APP_NAME' (expected: kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | sym_convert | occ_service)" >&2 exit 1 ;; esac @@ -84,6 +92,10 @@ esac # embind symbols (kicadCollabOnSave et al.) — reuse eeschema's embind object. case "$APP_NAME" in sym_convert) EMBIND_APP="eeschema" ;; + # occ_service links the pcbnew kiface objects → pcbnew's embind object, same + # reason (its own embind entry points live in occ_service_main.cpp, compiled + # inside the CMake target). + occ_service) EMBIND_APP="pcbnew" ;; *) EMBIND_APP="$APP_NAME" ;; esac @@ -95,6 +107,9 @@ esac case "$APP_NAME" in sym_convert) STUB_APP="eeschema" ;; kicad_editor) STUB_APP="pcbnew" ;; + # occ_service links the pcbnew kiface objects → pcbnew's stubs (frame + + # action-plugin scripting placeholders), like the editors. + occ_service) STUB_APP="pcbnew" ;; *) STUB_APP="$APP_NAME" ;; esac @@ -337,11 +352,12 @@ fi EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt" EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize" -if [ "${APP_NAME}" = "sym_convert" ]; then - # Use the real tools so the converter is fully finalized inside the container. +if [ "${APP_NAME}" = "sym_convert" ] || [ "${APP_NAME}" = "occ_service" ]; then + # Use the real tools so the small -g0 module is fully finalized inside the + # container (no host post-processing / asyncify for these targets). [ -f "${EMSDK_WASM_OPT}.real" ] && cp "${EMSDK_WASM_OPT}.real" "${EMSDK_WASM_OPT}" [ -f "${EMSDK_FINALIZE}.real" ] && cp "${EMSDK_FINALIZE}.real" "${EMSDK_FINALIZE}" - log_info "Using real wasm-opt/finalize for sym_convert (finalize in-container)" + log_info "Using real wasm-opt/finalize for ${APP_NAME} (finalize in-container)" else if [ -f "${EMSDK_WASM_OPT}" ] && [ ! -f "${EMSDK_WASM_OPT}.real" ]; then log_info "Backing up real wasm-opt..." @@ -421,6 +437,13 @@ if [ "${APP_NAME}" = "kicad_editor" ]; then MERGED_EDITOR_CMAKE_FLAG="-DKICAD_WASM_MERGED_EDITOR=ON" fi +# The standalone OCC 3D service (worker embind module) — gates the +# wasm/occ-service/ subdir in the fork's top-level CMakeLists. +OCC_SERVICE_CMAKE_FLAG="" +if [ "${APP_NAME}" = "occ_service" ]; then + OCC_SERVICE_CMAKE_FLAG="-DKICAD_OCC_SERVICE_WASM=ON" +fi + # 3D viewer: built by DEFAULT (BUILD_3D_VIEWER=ON). Opt out with BUILD_3D_VIEWER=OFF, which links the # 3D stubs instead. The 3D viewer renders with the GL-free CPU raytracer (RENDER_3D_RAYTRACE_RAM) # blitted to the canvas through a plain WebGL2 textured quad — no -sLEGACY_GL_EMULATION. KiCad's @@ -472,6 +495,7 @@ emcmake cmake "${KICAD_DIR}" \ ${CCACHE_OPTS} \ ${SYM_CONVERTER_CMAKE_FLAG} \ ${MERGED_EDITOR_CMAKE_FLAG} \ + ${OCC_SERVICE_CMAKE_FLAG} \ -DCMAKE_BUILD_TYPE=${BUILD_TYPE} \ -DCMAKE_INSTALL_PREFIX="${SYSROOT}" \ -DCMAKE_MODULE_PATH="${WASM_LAYER}/cmake" \ @@ -582,7 +606,16 @@ elif [ -f "${EMBIND_SRC}" ]; then emmake make -j${JOBS} pcbcommon fi log_info "Compiling Embind bindings (${APP_NAME})..." - compile_embind_tu "${EMBIND_SRC}" "${EMBIND_OBJ}" "${KICAD_SUBDIR}" + # The include home is the app the BINDINGS belong to, not the artifact + # subdir — for occ_service the two differ (bindings = pcbnew's; artifacts + # land in the wasm/occ-service target's own occ_service/ binary dir, which + # has no KiCad sources). + case "${EMBIND_APP}" in + pcbnew) EMBIND_INC_SUBDIR="pcbnew" ;; + eeschema) EMBIND_INC_SUBDIR="eeschema" ;; + *) EMBIND_INC_SUBDIR="${KICAD_SUBDIR}" ;; + esac + compile_embind_tu "${EMBIND_SRC}" "${EMBIND_OBJ}" "${EMBIND_INC_SUBDIR}" else log_info "No embind source for ${APP_NAME} (expected at ${EMBIND_SRC}); using empty placeholder" EMPTY_C="${STUBS_BUILD}/${APP_NAME}_embind_empty.c" @@ -613,8 +646,8 @@ emmake make -j${JOBS} "${KICAD_TARGET}" # Step 8.1: Build bitmap resources (images.tar.gz) # This creates the icon archive that KiCad loads at runtime. The headless -# converter has no GUI/icons, so skip it. -if [ "${APP_NAME}" != "sym_convert" ]; then +# converter/service targets have no GUI/icons, so skip it. +if [ "${APP_NAME}" != "sym_convert" ] && [ "${APP_NAME}" != "occ_service" ]; then kw_stage kicad-bitmaps log_info "Building bitmap resources..." emmake make bitmap_archive_build diff --git a/scripts/kicad/build-occ_service.sh b/scripts/kicad/build-occ_service.sh new file mode 100755 index 0000000..52d9d04 --- /dev/null +++ b/scripts/kicad/build-occ_service.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Build the standalone OpenCASCADE 3D service (occ_service) for WebAssembly — +# a persistent Web-Worker embind module (STEP/3D export + STEP/IGES model +# tessellation). Thin wrapper around build-kicad-target.sh — see that script +# for options, and docs/features/occ-split/README.md for the design. +set -e +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "${SCRIPT_DIR}/build-kicad-target.sh" occ_service "$@" diff --git a/tests/fixtures/demo/demo.kicad_prl b/tests/fixtures/demo/demo.kicad_prl new file mode 100644 index 0000000..0b57bb8 --- /dev/null +++ b/tests/fixtures/demo/demo.kicad_prl @@ -0,0 +1,105 @@ +{ + "board": { + "active_layer": 0, + "active_layer_preset": "", + "auto_track_width": true, + "hidden_netclasses": [], + "hidden_nets": [], + "high_contrast_mode": 0, + "net_color_mode": 1, + "opacity": { + "images": 0.6, + "pads": 1.0, + "shapes": 1.0, + "tracks": 1.0, + "vias": 1.0, + "zones": 0.6 + }, + "prototype_zone_fills": false, + "selection_filter": { + "dimensions": true, + "footprints": true, + "graphics": true, + "keepouts": true, + "lockedItems": false, + "otherItems": true, + "pads": true, + "text": true, + "tracks": true, + "vias": true, + "zones": true + }, + "visible_items": [ + "vias", + "footprint_text", + "footprint_anchors", + "ratsnest", + "grid", + "footprints_front", + "footprints_back", + "footprint_values", + "footprint_references", + "tracks", + "drc_errors", + "drawing_sheet", + "bitmaps", + "pads", + "zones", + "drc_warnings", + "drc_exclusions", + "locked_item_shadows", + "conflict_shadows", + "shapes", + "board_outline_area", + "ly_points" + ], + "visible_layers": "ffffffff_ffffffff_ffffffff_ffffffff", + "zone_display_mode": 0 + }, + "git": { + "integration_disabled": false, + "repo_type": "", + "repo_username": "", + "ssh_key": "" + }, + "meta": { + "filename": "demo.kicad_prl", + "version": 5 + }, + "net_inspector_panel": { + "col_hidden": [], + "col_order": [], + "col_widths": [], + "custom_group_rules": [], + "expanded_rows": [], + "filter_by_net_name": true, + "filter_by_netclass": true, + "filter_text": "", + "group_by_constraint": false, + "group_by_netclass": false, + "show_time_domain_details": false, + "show_unconnected_nets": false, + "show_zero_pad_nets": false, + "sort_ascending": true, + "sorting_column": -1 + }, + "open_jobsets": [], + "project": { + "files": [] + }, + "schematic": { + "hierarchy_collapsed": [], + "selection_filter": { + "graphics": true, + "images": true, + "labels": true, + "lockedItems": false, + "otherItems": true, + "pins": true, + "ruleAreas": true, + "symbols": true, + "text": true, + "wires": true + } + } +} diff --git a/tests/kicad/3d-viewer-models.spec.ts b/tests/kicad/3d-viewer-models.spec.ts index 3838cab..f025c2f 100644 --- a/tests/kicad/3d-viewer-models.spec.ts +++ b/tests/kicad/3d-viewer-models.spec.ts @@ -183,6 +183,8 @@ test.describe('3D viewer component models', () => { fs.readFileSync(fixtureAbs).toString('base64'), ); await installModelProviderStub(page); + // (.step models parse in the occ_service worker — the oce3d_Load shadow + // suspends on globalThis.occService, installed ambiently by fixtures.) await loadBoard(page, testLogger); @@ -222,6 +224,16 @@ test.describe('3D viewer component models', () => { ); expect(servedSize, 'served STEP written into the model root').toBeGreaterThan(1000); + // OCC split: the .step parse runs in the occ_service worker (the oce3d + // shadow bridges to it) and must SUCCEED — a boot/bridge failure logs + // 'oce Load FAILED' and silently skips the model, which the render + // assertions below can miss (hollow green). + const oceLoadLines = testLogger.consoleLogs.filter((l) => l.includes('oce Load')); + expect(oceLoadLines.some((l) => l.includes('oce Load ok')), + 'the served STEP must parse in the occ_service worker').toBe(true); + expect(oceLoadLines.some((l) => l.includes('oce Load FAILED')), + 'no oce model parse may fail').toBe(false); + // --- render assertion -------------------------------------------------- const render = await page.evaluate(() => { const list = document.querySelectorAll('canvas[id^="glcanvas-"]'); diff --git a/tests/kicad/fixtures.ts b/tests/kicad/fixtures.ts index f0a06ab..b883070 100644 --- a/tests/kicad/fixtures.ts +++ b/tests/kicad/fixtures.ts @@ -1,11 +1,22 @@ import { test as base } from '@playwright/test'; import * as path from 'path'; import { setupTestLogger, writeTestLogs, TestLogger, MAIN_CANVAS, waitForApp, tryLoadApp, getCanvasBox, KICAD_LOGS_DIR, getTestFileName } from '../e2e/utils/test-utils'; +import { installOccServiceStub } from './utils/occ-service'; // Extend base test with automatic logging export const test = base.extend<{ testLogger: TestLogger; }>({ + // Every harness page gets the occ_service provider ambiently (standalone + // parity: boot.ts installs it whenever the editor bundle boots). Init-script + // based, so it exists from document start on every navigation; the worker + // itself is only fetched on the first occ request, so specs can still assert + // the lazy-load boundary. + page: async ({ page }, use) => { + await installOccServiceStub(page); + await use(page); + }, + testLogger: async ({ page }, use, testInfo) => { // Build test name from describe block + test title const testName = testInfo.titlePath.join(' - '); diff --git a/tests/kicad/occ-export.spec.ts b/tests/kicad/occ-export.spec.ts new file mode 100644 index 0000000..04af2d7 --- /dev/null +++ b/tests/kicad/occ-export.spec.ts @@ -0,0 +1,147 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from './fixtures'; +import { clickMenuBarItem, clickMenuItem } from '../e2e/utils/element-tracker'; +import { injectFromSubmodule } from './utils/fs-inject'; +import { waitForBoardLoaded } from './utils/board-ready'; +import { waitForPcbnew } from './utils/pcbnew-ready'; + +/** + * STEP export through the occ_service worker (docs/features/occ-split/): + * pcbnew.wasm carries no OCC — DIALOG_EXPORT_STEP's browser branch runs + * EXPORTER_STEP, whose WASM shadow suspends via EM_ASYNC_JS and hands the + * job to `globalThis.occService` (worker with its own OCC-linked module). + * + * Asserted end to end: + * 1. occ_service.{js,wasm} is NOT fetched at boot or board load — only the + * export click triggers it (the lazy-load boundary). + * 2. The (unchanged) export dialog drives the whole chain: menu → dialog → + * Export button → worker → STEP bytes. + * 3. The result is a real STEP file (ISO-10303-21 magic, non-trivial size), + * captured by the provider stub where the app would download it. + */ + +const KICAD_VERSION_DIR = '10.0'; +const PROJECT_DIR_MEMFS = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`; + +const DEMO = { name: 'pic_programmer', dir: 'pic_programmer', stem: 'pic_programmer' } as const; + +async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors: string[] }): Promise { + const pcbFilename = `${DEMO.stem}.kicad_pcb`; + const proFilename = `${DEMO.stem}.kicad_pro`; + + await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${pcbFilename}`, + `${PROJECT_DIR_MEMFS}/${pcbFilename}`); + await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${proFilename}`, + `${PROJECT_DIR_MEMFS}/${proFilename}`); + + expect(await clickMenuBarItem(page, 'File'), 'File menu should be findable').toBe(true); + await page.waitForTimeout(400); + expect(await clickMenuItem(page, 'Open...'), 'Open… menu item should be findable').toBe(true); + + await page.waitForFunction(() => { + const registry = window.wxElementRegistry; + return !!registry && registry.findAll({ visible: true }) + .some((el) => el.typeName === 'wxFileDialog'); + }, null, { timeout: 15000 }); + await page.waitForTimeout(1000); + + const filenameInput = await page.evaluate(() => { + const registry = window.wxElementRegistry; + if (!registry) return null; + const text = registry.findAll({ visible: true }) + .find((el) => el.typeName === 'wxTextCtrl' && el.name === 'text'); + return text ? { x: text.centerX, y: text.centerY } : null; + }); + expect(filenameInput, 'filename text input should be visible').not.toBeNull(); + if (!filenameInput) throw new Error('filename text input not found'); + + await page.mouse.click(filenameInput.x, filenameInput.y); + await page.waitForTimeout(200); + await page.keyboard.type(pcbFilename); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1000); + + const result = await waitForBoardLoaded(page, testLogger, 60000); + console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`); +} + +/** Click a visible wx button by label; returns whether it was found. */ +async function clickWxButton(page: Page, label: string): Promise { + const pos = await page.evaluate((wanted: string) => { + const registry = window.wxElementRegistry; + if (!registry) return null; + const el = registry.findAll({ visible: true }) + .find((e) => (e.label === wanted || e.label === `&${wanted}`) + && (e.typeName ?? '').includes('Button')); + return el ? { x: el.centerX, y: el.centerY } : null; + }, label); + if (!pos) return false; + await page.mouse.click(pos.x, pos.y); + return true; +} + +test.describe('OCC export via occ_service worker', () => { + test.describe.configure({ mode: 'serial' }); + test.setTimeout(240000); + + test('export dialog produces a valid STEP; occ_service fetches lazily', async ({ page, testLogger }) => { + // Track occ_service fetches from the very start — the lazy boundary is + // the core assertion. + const occFetches: string[] = []; + page.on('request', (r) => { + if (r.url().includes('occ_service')) occFetches.push(r.url()); + }); + + await page.goto('/kicad/pcbnew.html'); + await waitForPcbnew(page); + await loadBoard(page, testLogger); + + expect(occFetches, 'occ_service must NOT be fetched before the export').toHaveLength(0); + + // File → Export → STEP/GLB/… + expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true); + await page.waitForTimeout(400); + expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true); + await page.waitForTimeout(400); + expect(await clickMenuItem(page, 'STEP/GLB/BREP/XAO/PLY/STL...'), + 'STEP export menu item').toBe(true); + + // The (unchanged) DIALOG_EXPORT_STEP: wait for its Export button. + await page.waitForFunction(() => { + const registry = window.wxElementRegistry; + return !!registry && registry.findAll({ visible: true }) + .some((el) => (el.label === 'Export' || el.label === '&Export') + && (el.typeName ?? '').includes('Button')); + }, null, { timeout: 20000 }); + await page.waitForTimeout(800); + await page.screenshot({ path: 'test-results/occ-export-dialog.png', scale: 'css' }); + + expect(await clickWxButton(page, 'Export'), 'Export button click').toBe(true); + + // The provider stub captures the bytes where the app would download. + await page.waitForFunction( + () => ((window as any).__occExports?.length ?? 0) > 0, + null, { timeout: 180000 }); + + const exports = await page.evaluate(() => (window as any).__occExports as Array<{ + name: string; size: number; magic: string; + }>); + console.log(`[TEST] captured exports: ${JSON.stringify(exports)}`); + + expect(exports).toHaveLength(1); + expect(exports[0].name, 'download name comes from the dialog') + .toMatch(/\.step$/i); + expect(exports[0].magic.startsWith('ISO-10303-21'), 'STEP magic').toBe(true); + expect(exports[0].size, 'non-trivial STEP body').toBeGreaterThan(10_000); + + expect(occFetches.length, 'occ_service was fetched lazily by the export') + .toBeGreaterThan(0); + + // Dismiss the "Export complete" report dialog if present. + await page.waitForTimeout(1000); + await clickWxButton(page, 'OK'); + + await page.screenshot({ path: 'test-results/occ-export-done.png', scale: 'css' }); + }); +}); diff --git a/tests/kicad/occ-probe.spec.ts b/tests/kicad/occ-probe.spec.ts new file mode 100644 index 0000000..c34fc66 --- /dev/null +++ b/tests/kicad/occ-probe.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from './fixtures'; + +/** + * Minimal occ_service probe (no pcbnew boot): drive occExport directly from + * the page with a small fixture board, in whatever engine the project runs. + * Isolates worker/module behavior from the editor entirely — when export + * breaks, it answers "service module or editor-side bridge?" in seconds. + * The provider itself arrives via the fixtures' ambient init script. + */ +test.describe('occ_service probe', () => { + test.setTimeout(180000); + + test('occExport of the demo board completes', async ({ page }) => { + // Any harness page gives the COI (COOP/COEP) context; don't wait for wasm. + await page.goto('/kicad/pcbnew.html', { waitUntil: 'domcontentloaded' }); + + const fs = require('fs') as typeof import('fs'); + const path = require('path') as typeof import('path'); + const board = fs.readFileSync( + path.resolve(__dirname, '..', 'fixtures', 'demo', 'demo.kicad_pcb'), 'utf8'); + + const res = await page.evaluate(async (boardText: string) => { + const svc = (globalThis as any).occService; + const bytes = new TextEncoder().encode(boardText); + const t0 = performance.now(); + const r = await svc.request({ + kind: 'export', board: bytes, + jobJson: JSON.stringify({ format: 'step' }), + fileName: 'probe.step', + }); + return { ok: r.ok, report: r.report, ms: Math.round(performance.now() - t0) }; + }, board); + + console.log(`[PROBE] ok=${res.ok} in ${res.ms}ms report=${(res.report || '').slice(0, 300)}`); + const captured = await page.evaluate(() => (window as any).__occExports); + console.log(`[PROBE] captured: ${JSON.stringify(captured)}`); + await page.screenshot({ path: 'test-results/occ-probe-done.png', scale: 'css' }); + expect(res.ok, 'demo board exports').toBe(true); + }); + + // Every format the export dialog offers must round-trip through the worker. + // Validated against kicad-cli 10.0.4 (2026-07-03): STEP/STPZ/BREP/XAO/PLY/ + // STL/GLB geometrically exact-equal; U3D same structure (quantizer float + // LSBs differ cross-platform); PDF structurally equal. GLB regressed once + // already (OCC built without RapidJSON compiles the glTF writer out) — + // this matrix is the net for that class of build-flag loss. + test('format matrix: every dialog format exports through the worker', async ({ page }) => { + await page.goto('/kicad/pcbnew.html', { waitUntil: 'domcontentloaded' }); + + const fs = require('fs') as typeof import('fs'); + const path = require('path') as typeof import('path'); + const board = fs.readFileSync( + path.resolve(__dirname, '..', 'fixtures', 'demo', 'demo.kicad_pcb'), 'utf8'); + + // format -> expected magic prefix of the produced bytes (utf8-decoded). + const TEXT_MAGIC: Record = { + step: 'ISO-10303-21', + // The file leads with "\nCASCADE Topology V1…" and the stub captures + // only 16 bytes — match the leading-newline-trimmed prefix. + brep: 'CASCADE Topolog', + xao: ' { + const svc = (globalThis as any).occService; + return await svc.request({ + kind: 'export', + board: new TextEncoder().encode(boardText), + jobJson: JSON.stringify({ format: fmt, export_components: false }), + fileName: `probe.${fmt}`, + }); + }, { boardText: board, fmt }); + expect(r.ok, `${fmt} export reports ok`).toBe(true); + } + + // stpz is gzip — binary magic, checked by charcode below. + const rz = await page.evaluate(async (boardText: string) => { + const svc = (globalThis as any).occService; + return await svc.request({ + kind: 'export', + board: new TextEncoder().encode(boardText), + jobJson: JSON.stringify({ format: 'stpz', export_components: false }), + fileName: 'probe.stpz', + }); + }, board); + expect(rz.ok, 'stpz export reports ok').toBe(true); + + const captured: Array<{ name: string; size: number; magic: string }> = + await page.evaluate(() => (window as any).__occExports); + console.log(`[PROBE] formats captured: ${JSON.stringify(captured.map( + (c) => ({ name: c.name, size: c.size })))}`); + + for (const [fmt, magic] of Object.entries(TEXT_MAGIC)) { + const hit = captured.find((c) => c.name === `probe.${fmt}`); + expect(hit, `${fmt} bytes captured`).toBeTruthy(); + expect(hit!.magic.trimStart().startsWith(magic), `${fmt} magic "${magic}"`).toBe(true); + expect(hit!.size, `${fmt} non-trivial size`).toBeGreaterThan(1000); + } + const z = captured.find((c) => c.name === 'probe.stpz'); + expect(z, 'stpz bytes captured').toBeTruthy(); + expect(z!.magic.charCodeAt(0), 'stpz gzip magic byte 0').toBe(0x1f); + expect(z!.size, 'stpz non-trivial size').toBeGreaterThan(1000); + }); +}); diff --git a/tests/kicad/utils/occ-service.ts b/tests/kicad/utils/occ-service.ts new file mode 100644 index 0000000..744cedd --- /dev/null +++ b/tests/kicad/utils/occ-service.ts @@ -0,0 +1,102 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { Page } from '@playwright/test'; + +/** + * Install a REAL `globalThis.occService` provider into a harness page — the + * same worker-backed occ_service boot the standalone app does, minus the CDN + * manifest resolution: the harness serves occ_service.{js,wasm} same-origin + * next to the tool page (tests/scripts/setup-kicad-wasm.sh copies them from + * output/). + * + * The worker-side wrapper is the SHARED source of truth + * (web/standalone/src/wasm/occ-worker.js — the standalone imports it via vite + * `?raw`; the harness reads it off disk and injects it verbatim), so the + * trap-prone boot logic (blob worker + locateFile absolutization + pthread + * mainScriptUrlOrBlob) cannot drift between app and tests. + * + * Differences from the app provider, for assertability: + * - export results are captured into window.__occExports (name, size, magic + * prefix) instead of triggering a browser download; + * - installed as an init script (kicad fixtures do this for every page), so + * it exists from document start on every navigation — standalone parity, + * where boot.ts installs the provider whenever the editor bundle boots. + * + * The worker fetches occ_service.js lazily on the FIRST request — specs can + * assert the lazy-load boundary by watching network requests. + */ + +const OCC_WORKER_SRC = fs.readFileSync( + path.resolve(__dirname, '..', '..', '..', + 'web', 'standalone', 'src', 'wasm', 'occ-worker.js'), + 'utf8'); + +export async function installOccServiceStub(page: Page): Promise { + await page.addInitScript((workerSrc: string) => { + if ((globalThis as any).occService) return; + + (window as any).__occExports = []; + + let workerP: Promise | null = null; + const pending = new Map void>(); + let nextId = 1; + + const ensureWorker = (): Promise => { + if (!workerP) { + workerP = (async () => { + const glue = new URL('occ_service.js', window.location.href).href; + console.log(`[TEST-OCC] booting occ_service from ${glue}`); + const worker = new Worker(URL.createObjectURL(new Blob( + [`self.OCC_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc], + { 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); } + }; + await new Promise((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); + }); + console.log('[TEST-OCC] occ_service ready'); + return worker; + })().catch((e) => { workerP = null; throw e; }); + } + return workerP; + }; + + const request = async (req: any) => { + let worker: Worker; + try { + worker = await ensureWorker(); + } catch (e) { + return { ok: false, report: `occ_service unavailable: ${e}` }; + } + const id = nextId++; + const transfer = req.kind === 'export' ? [req.board.buffer] : [req.bytes.buffer]; + const res: any = await new Promise((resolve) => { + pending.set(id, resolve); + worker.postMessage({ id, req }, transfer); + }); + if (req.kind === 'export') { + if (res.ok && res.bytes?.length) { + const magic = new TextDecoder().decode(res.bytes.slice(0, 16)); + (window as any).__occExports.push({ + name: req.fileName || res.fileName, + size: res.bytes.length, + magic, + }); + console.log(`[TEST-OCC] export captured: ${req.fileName} ${res.bytes.length}B "${magic}"`); + } + return { ok: res.ok, report: res.report, fileName: res.fileName }; + } + return res; + }; + + (globalThis as any).occService = { request }; + }, OCC_WORKER_SRC); +} diff --git a/tests/playwright-kicad.config.ts b/tests/playwright-kicad.config.ts index a1b0147..12435d7 100644 --- a/tests/playwright-kicad.config.ts +++ b/tests/playwright-kicad.config.ts @@ -111,6 +111,11 @@ const BIG_MODULE_SPECS = [ "**/symbol_editor.spec.ts", // Cross-face probe: schematic session lazily starts the PCB kiface (Preferences). "**/xface-probe.spec.ts", + // OCC split: occ-export boots pcbnew.html (the merged module) and drives the + // export dialog through the occ_service worker — same V8 routing. occ-probe + // only boots the (small) occ_service module but shares the harness page. + "**/occ-export.spec.ts", + "**/occ-probe.spec.ts", ]; // Runtime-perf specs run ONLY on the Chromium 'perf' project below: they need diff --git a/tests/scripts/setup-kicad-wasm.sh b/tests/scripts/setup-kicad-wasm.sh index efe91db..fd3b86c 100755 --- a/tests/scripts/setup-kicad-wasm.sh +++ b/tests/scripts/setup-kicad-wasm.sh @@ -87,6 +87,8 @@ copy_app kicad_editor && found_any=1 copy_app calculator && found_any=1 copy_app pl_editor && found_any=1 copy_app gerbview && found_any=1 +# OCC 3D service (lazy worker module; pcbnew's STEP export + model parsing) +copy_app occ_service || true if [ "$found_any" -eq 0 ]; then echo "Error: no kicad_editor/calculator/pl_editor/gerbview artifacts found in output/ or docker volume" >&2 diff --git a/wasm/occ-service/CMakeLists.txt b/wasm/occ-service/CMakeLists.txt new file mode 100644 index 0000000..138f384 --- /dev/null +++ b/wasm/occ-service/CMakeLists.txt @@ -0,0 +1,71 @@ +# occ_service — standalone OpenCASCADE 3D service for the browser build +# (docs/features/occ-split/): STEP/3D export and STEP/IGES model tessellation +# as a separate emscripten module run in a Web Worker, so the editor drops its +# OCC link entirely. +# +# Added from the kicad fork's top-level CMakeLists.txt via +# add_subdirectory( ${KICAD_WASM_LAYER}/occ-service ) when +# EMSCRIPTEN AND KICAD_OCC_SERVICE_WASM — after pcbnew/, so the kiface library +# list and the OCC exporter source list (both exported CACHE INTERNAL there) +# exist here. Same pattern as the merged editor (wasm/editor/CMakeLists.txt). +# Only the occ_service build tree configures with the option ON; its binary +# dir doubles as the artifact subdir (build-wasm/kicad-occ_service/occ_service/). + +# Mirror the pcbnew directory scope these sources were written for: the OCC +# exporters and the service main are pcbnew-kiface code (board classes, 3d +# resolver, exporter headers). OCC headers arrive via the top-level +# include_directories( SYSTEM ${OCC_INCLUDE_DIR} ); nlohmann_json/gzip-hpp via +# their INTERFACE targets. +include_directories( BEFORE ${INC_BEFORE} ) +include_directories( + ${CMAKE_SOURCE_DIR}/pcbnew + ${CMAKE_SOURCE_DIR}/pcbnew/dialogs + ${CMAKE_SOURCE_DIR}/pcbnew/exporters + ${CMAKE_SOURCE_DIR}/pcbnew/specctra_import_export + ${CMAKE_SOURCE_DIR}/3d-viewer + ${CMAKE_SOURCE_DIR}/common + ${CMAKE_SOURCE_DIR}/common/dialogs + ${CMAKE_BINARY_DIR}/pcbnew + ${INC_AFTER} + ) + +# The exporter sources expect the pcbnew compile environment. +add_compile_definitions( PCBNEW ) + +# The OCC exporter sources are gated out of the kiface objects for EMSCRIPTEN +# (pcbnew/CMakeLists.txt) — the service compiles the real ones itself. +add_executable( occ_service + occ_service_main.cpp + ${PCBNEW_OCC_EXPORTER_SRCS} ) + +target_link_libraries( occ_service + PRIVATE + ${PCBNEW_KIFACE_LIBRARIES} + nlohmann_json + gzip-hpp # step_pcb_model.cpp (.stpZ/.gz) — kiface-PRIVATE, so restate + ) +# Match the editors' WASM link shape: allow benign duplicate symbols, and +# --whole-archive pcbcommon so RTTI-only-referenced vtables/typeinfo are +# pulled in. +target_link_options( occ_service PRIVATE + "LINKER:--allow-multiple-definition" + "LINKER:--whole-archive" + "$" + "LINKER:--no-whole-archive" + ) + +# Persistent worker embind module: no asyncify (neither OCC job suspends). +# -Oz at link runs wasm-opt -Oz = whole-module dead-code elimination that +# strips the unreachable editor code (same mechanism sym_convert documents +# in eeschema/CMakeLists.txt); -g0 drops debug info so in-container +# finalize doesn't OOM. MODULARIZE factory booted by the JS provider inside +# a dedicated Worker; node kept in ENVIRONMENT so unit tests can drive it. +# These override the browser-oriented values inherited from +# CMAKE_EXE_LINKER_FLAGS (notably -sASYNCIFY=1). The inherited FULL pthread +# pool ('navigator.hardwareConcurrency') is kept deliberately: the exporter +# uses GetKiCadThreadPool (step_pcb_model.cpp), and a browser cannot spawn +# workers on demand while the calling thread is blocked inside occExport — +# an undersized pool deadlocks in Chromium. +# --pre-js supplies the wxConfig JS hooks backed by an in-memory store. +set_target_properties( occ_service PROPERTIES + LINK_FLAGS "-Oz -g0 -sASYNCIFY=0 -sMODULARIZE=1 -sEXPORT_NAME=OccService -sENVIRONMENT=worker,node -sEXIT_RUNTIME=0 --pre-js ${CMAKE_CURRENT_SOURCE_DIR}/occ_service_pre.js" ) diff --git a/wasm/occ-service/occ_service_main.cpp b/wasm/occ-service/occ_service_main.cpp new file mode 100644 index 0000000..5e95abb --- /dev/null +++ b/wasm/occ-service/occ_service_main.cpp @@ -0,0 +1,391 @@ +/* + * occ_service — the OpenCASCADE 3D service for the browser build. + * + * A standalone WebAssembly module (own emscripten instance + memory) meant to + * run in a dedicated Web Worker, so the editor can drop its OCC link entirely + * (docs/features/occ-split/README.md). pcbnew reaches it through a postMessage + * RPC; this module never suspends, so it builds -sASYNCIFY=0. + * + * Two embind entry points, both batch/synchronous: + * occExport(boardSexpr, paramsJson) -> { ok, report, fileName, bytes } + * Parse the board text (KICAD_SEXP), map the official JOB_EXPORT_PCB_3D + * JSON onto EXPORTER_STEP_PARAMS (the pcbnew_jobs_handler mapping) and + * run EXPORTER_STEP — STEP/STEPZ/BREP/XAO/GLB/PLY/STL out. + * occLoadModel(bytes, ext) -> { ok, report, bytes } + * Feed a STEP/IGES model to the (statically linked) oce plugin loader + * and return the resulting SCENEGRAPH serialized with S3D::WriteCache — + * pcbnew's import shadow rebuilds it with S3D::ReadCache. + * + * main() runs once at module boot (default INVOKE_RUN; -sEXIT_RUNTIME=0 keeps + * the runtime alive afterwards) and brings up wxBase + a minimal PGM the + * kicad-cli way (SetPgm + InitPgm(headless)) — EXPORTER_STEP's + * FILENAME_RESOLVER calls Pgm() unconditionally (exporter_step.cpp:154). + * Settings land in the in-memory wxConfig store from occ_service_pre.js. + */ + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +class SCENEGRAPH; + +// The statically linked oce plugin's loader (plugins/3d/oce/oce.cpp, renamed +// per-TU to oce3d_* by its EMSCRIPTEN CMake block; the registry in +// 3d-viewer/3d_cache/pcbjam_static_3d_plugins.cpp declares the same surface). +extern "C" SCENEGRAPH* oce3d_Load( char const* aFileName ); + +// The export dialog's browser seam (dialog_export_step.cpp, compiled into the +// kiface objects this module links) feeds the full job JSON to the EDITOR-side +// EXPORTER_STEP shadow through this hook. No dialog ever runs in the worker — +// the service's EXPORTER_STEP is the real one — so it's a no-op here; the +// symbol just has to resolve. +extern "C" void Pcbjam_SetExportJobJson( const char* ) {} + +namespace +{ + +// Minimal headless PGM: MacOpenFile is PGM_BASE's only pure virtual. +class PGM_OCC_SERVICE : public PGM_BASE +{ +public: + void MacOpenFile( const wxString& ) override {} +}; + +PGM_OCC_SERVICE s_program; + +const char* const TMP_BOARD = "/tmp/occ_service_board.kicad_pcb"; +const char* const TMP_CACHE = "/tmp/occ_service_model.3dc"; + + +bool writeFile( const wxString& aPath, const void* aData, size_t aLen ) +{ + wxFFile f( aPath, wxT( "wb" ) ); + return f.IsOpened() && f.Write( aData, aLen ) == aLen; +} + + +bool readFile( const wxString& aPath, std::vector* aOut ) +{ + wxFFile f( aPath, wxT( "rb" ) ); + + if( !f.IsOpened() ) + return false; + + wxFileOffset len = f.Length(); + aOut->resize( (size_t) len ); + return len >= 0 && f.Read( aOut->data(), (size_t) len ) == (size_t) len; +} + + +// Copy a std::vector's bytes into a fresh JS-owned Uint8Array (the +// typed_memory_view itself only aliases this module's heap; the Uint8Array +// constructor makes the copy that survives postMessage/transfer). +emscripten::val toUint8Array( const std::vector& aBytes ) +{ + emscripten::val view = emscripten::val( emscripten::typed_memory_view( + aBytes.size(), reinterpret_cast( aBytes.data() ) ) ); + return emscripten::val::global( "Uint8Array" ).new_( view ); +} + + +// Headless board load from sexpr text — the pcbnew_scripting_helpers::LoadBoard +// pattern (that file is KICAD_SCRIPTING-gated and the wasm stub returns null, +// so the few needed lines are replicated here): default project from the PGM's +// settings manager, PCB_IO_MGR parse, project attach. +BOARD* loadBoardFromSexpr( const std::string& aSexpr, wxString* aErr ) +{ + if( !writeFile( wxString::FromUTF8( TMP_BOARD ), aSexpr.data(), aSexpr.size() ) ) + { + *aErr = wxT( "occ_service: cannot stage board file in MEMFS" ); + return nullptr; + } + + SETTINGS_MANAGER& mgr = Pgm().GetSettingsManager(); + PROJECT* project = mgr.GetProject( wxEmptyString ); + + if( !project ) + { + mgr.LoadProject( wxEmptyString ); + project = mgr.GetProject( wxEmptyString ); + } + + BOARD* brd = nullptr; + + try + { + brd = PCB_IO_MGR::Load( PCB_IO_MGR::KICAD_SEXP, wxString::FromUTF8( TMP_BOARD ) ); + } + catch( const std::exception& e ) + { + *aErr = wxString::Format( wxT( "board parse failed: %s" ), e.what() ); + } + catch( ... ) + { + *aErr = wxT( "board parse failed" ); + } + + if( brd && project ) + brd->SetProject( project ); + + return brd; +} + + +emscripten::val occExport( std::string aBoardSexpr, std::string aParamsJson ) +{ + emscripten::val ret = emscripten::val::object(); + ret.set( "ok", false ); + + // The official job JSON: JOB_EXPORT_PCB_3D registers every dialog/CLI field + // as a JOB_PARAM (common/jobs/job_export_pcb_3d.cpp), so FromJson fills + // m_3dparams + m_format directly. + JOB_EXPORT_PCB_3D job; + + try + { + job.FromJson( nlohmann::json::parse( aParamsJson ) ); + } + catch( const std::exception& e ) + { + ret.set( "report", std::string( "occ_service: bad paramsJson: " ) + e.what() ); + return ret; + } + + EXPORTER_STEP_PARAMS params = job.m_3dparams; + + // Same format mapping as PCBNEW_JOBS_HANDLER::JobExportStep. + switch( job.m_format ) + { + case JOB_EXPORT_PCB_3D::FORMAT::STEP: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::STEP; break; + case JOB_EXPORT_PCB_3D::FORMAT::STEPZ: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::STEPZ; break; + case JOB_EXPORT_PCB_3D::FORMAT::BREP: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::BREP; break; + case JOB_EXPORT_PCB_3D::FORMAT::XAO: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::XAO; break; + case JOB_EXPORT_PCB_3D::FORMAT::GLB: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::GLB; break; + case JOB_EXPORT_PCB_3D::FORMAT::PLY: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::PLY; break; + case JOB_EXPORT_PCB_3D::FORMAT::STL: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::STL; break; + case JOB_EXPORT_PCB_3D::FORMAT::U3D: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::U3D; break; + case JOB_EXPORT_PCB_3D::FORMAT::PDF: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::PDF; break; + default: + // VRML exports run in-editor (EXPORTER_VRML, no OCC); anything else is a caller bug. + ret.set( "report", std::string( "occ_service: unsupported format" ) ); + return ret; + } + + // Phase breadcrumbs (stderr → worker console): the only field-visible + // signal of where a hang/trap sits inside this synchronous call. + std::fprintf( stderr, "[occ_service] export: parsing board (%zu bytes)\n", + aBoardSexpr.size() ); + + wxString err; + BOARD* brd = loadBoardFromSexpr( aBoardSexpr, &err ); + + if( !brd ) + { + ret.set( "report", std::string( err.utf8_string() ) ); + return ret; + } + + std::fprintf( stderr, "[occ_service] export: board parsed, running EXPORTER_STEP (%s)\n", + params.GetFormatName().utf8_string().c_str() ); + + WX_STRING_REPORTER reporter; + EXPORTER_STEP exporter( brd, params, &reporter ); + + wxFileName outFn( wxT( "/tmp" ), wxT( "occ_service_out" ), + params.GetDefaultExportExtension() ); + exporter.m_outputFile = outFn.GetFullPath(); + + bool ok = false; + std::vector outBytes; + + try + { + ok = exporter.Export(); + } + catch( const std::exception& e ) + { + reporter.Report( wxString::Format( wxT( "export exception: %s" ), e.what() ), + RPT_SEVERITY_ERROR ); + } + catch( ... ) + { + // OCCT throws Standard_Failure on malformed geometry internals. + reporter.Report( wxT( "export exception (OCCT)" ), RPT_SEVERITY_ERROR ); + } + + if( ok ) + ok = readFile( exporter.m_outputFile, &outBytes ); + + ret.set( "ok", ok ); + ret.set( "report", std::string( reporter.GetMessages().utf8_string() ) ); + ret.set( "fileName", std::string( outFn.GetFullName().utf8_string() ) ); + + if( ok ) + ret.set( "bytes", toUint8Array( outBytes ) ); + + wxRemoveFile( exporter.m_outputFile ); + wxRemoveFile( wxString::FromUTF8( TMP_BOARD ) ); + delete brd; + + std::fprintf( stderr, "[occ_service] export %s (%zu bytes)\n", + ok ? "ok" : "FAILED", outBytes.size() ); + return ret; +} + + +emscripten::val occLoadModel( emscripten::val aBytes, std::string aExt ) +{ + emscripten::val ret = emscripten::val::object(); + ret.set( "ok", false ); + + // Sanitize the extension — it picks the parser (STEP vs IGES) inside the + // oce loader via the temp file name. + std::string ext; + + for( char c : aExt ) + { + if( isalnum( (unsigned char) c ) ) + ext += (char) tolower( (unsigned char) c ); + } + + if( ext.empty() ) + { + ret.set( "report", std::string( "occ_service: empty model extension" ) ); + return ret; + } + + const size_t len = aBytes["byteLength"].as(); + std::vector buf( len ); + + // memcpy from the JS Uint8Array into this module's heap via a view .set(). + emscripten::val view = + emscripten::val( emscripten::typed_memory_view( len, buf.data() ) ); + view.call( "set", aBytes ); + + const wxString modelPath = + wxString::FromUTF8( ( std::string( "/tmp/occ_service_model." ) + ext ).c_str() ); + + if( !writeFile( modelPath, buf.data(), buf.size() ) ) + { + ret.set( "report", std::string( "occ_service: cannot stage model file" ) ); + return ret; + } + + SCENEGRAPH* sg = nullptr; + + try + { + sg = oce3d_Load( modelPath.utf8_string().c_str() ); + } + catch( ... ) + { + // Standard_Failure on malformed STEP internals — treat as parse failure, + // mirroring the per-model skip behavior in pcbjam_static_3d_plugins.cpp. + sg = nullptr; + } + + wxRemoveFile( modelPath ); + + if( !sg ) + { + ret.set( "report", std::string( "occ_service: model parse failed (." ) + ext + ")" ); + return ret; + } + + // SCENEGRAPH's first and only base is SGNODE (sg/scenegraph.h); the full + // definition lives in 3d-viewer-internal headers, so cast at ABI level. + SGNODE* node = reinterpret_cast( sg ); + + bool ok = S3D::WriteCache( TMP_CACHE, true, node, "pcbjam-occ_service:1" ); + std::vector cacheBytes; + + if( ok ) + ok = readFile( wxString::FromUTF8( TMP_CACHE ), &cacheBytes ); + + S3D::DestroyNode( node ); + wxRemoveFile( wxString::FromUTF8( TMP_CACHE ) ); + + ret.set( "ok", ok ); + + if( ok ) + ret.set( "bytes", toUint8Array( cacheBytes ) ); + else + ret.set( "report", std::string( "occ_service: scenegraph cache write failed" ) ); + + std::fprintf( stderr, "[occ_service] loadModel .%s %s (%zu -> %zu bytes)\n", ext.c_str(), + ok ? "ok" : "FAILED", len, cacheBytes.size() ); + return ret; +} + +} // namespace + + +EMSCRIPTEN_BINDINGS( occ_service ) +{ + emscripten::function( "occExport", &occExport ); + emscripten::function( "occLoadModel", &occLoadModel ); +} + + +int main( int argc, char** argv ) +{ + // Bring up wxBase (no GUI): wxString/wxFileName/wxFFile need the library + // initialized. Function-static so it lives for the module's lifetime + // (-sEXIT_RUNTIME=0 keeps the runtime alive after main returns). + static wxInitializer initializer( argc, argv ); + + if( !initializer.IsOk() ) + { + std::fprintf( stderr, "[occ_service] wxWidgets initialisation failed\n" ); + return 1; + } + + KIPLATFORM::ENV::Init(); + SetPgm( &s_program ); + + // Headless, no python — the kicad-cli bootstrap (kicad_cli.cpp): creates the + // settings manager + locale so Pgm()-dependent code (FILENAME_RESOLVER, + // ADVANCED_CFG) works. Settings I/O lands in the pre-js in-memory store. + if( !s_program.InitPgm( true, true ) ) + { + std::fprintf( stderr, "[occ_service] InitPgm failed\n" ); + return 2; + } + + // Boards may embed bitmap images in various formats. + wxInitAllImageHandlers(); + + // Warm the KiCad thread pool NOW, while this thread's event loop is still + // live. The exporter's first use (step_pcb_model.cpp CreatePCB) happens + // inside the synchronous occExport call — and in a browser a blocked + // thread cannot finish spawning Workers, which deadlocks the pool ctor in + // Chromium (the same class as the raytracer pre-warm fix, root commit + // 7630c7e). Constructing it here lets every std::thread start cleanly. + GetKiCadThreadPool(); + + std::fprintf( stderr, "[occ_service] ready\n" ); + return 0; +} diff --git a/wasm/occ-service/occ_service_pre.js b/wasm/occ-service/occ_service_pre.js new file mode 100644 index 0000000..3ef7d10 --- /dev/null +++ b/wasm/occ-service/occ_service_pre.js @@ -0,0 +1,117 @@ +// Host shims for the occ_service worker module (emscripten --pre-js). +// +// KiCad's wxWidgets wasm port reads/writes settings through wxConfig, which +// bridges to JS hooks (getConfigEntryLength, …). The web editor provides those +// via wx.js, backed by the browser's localStorage. The service runs in a +// dedicated Worker (or Node for unit runs) with no localStorage and needs no +// persisted settings, so we back the same hooks with an in-memory store: every +// read returns "absent" → KiCad falls back to defaults; writes live only for +// the module lifetime. Semantics mirror wxwidgets/build/wasm/wx.js exactly +// (sans persistence). Same shim as wasm/cli/sym_convert_pre.js. +(function( g ) { + if( !g.localStorage ) + { + var store = new Map(); + g.localStorage = { + get length() { return store.size; }, + key: function( i ) { var k = Array.from( store.keys() )[i]; return k === undefined ? null : k; }, + getItem: function( k ) { return store.has( k ) ? store.get( k ) : null; }, + setItem: function( k, v ) { store.set( String( k ), String( v ) ); }, + removeItem: function( k ) { store.delete( k ); }, + clear: function() { store.clear(); }, + }; + } + + var ls = g.localStorage; + + // Only reached once a stored value exists; with the empty in-memory store these + // never run, but keep them correct in case settings are written then read back. + var s2u = function( str, buf, len ) { + var f = g.stringToUTF8 || ( g.Module && g.Module.stringToUTF8 ); + f( str, buf, len ); + }; + + g.hasConfigEntry = function( key ) { return ls.getItem( key ) !== null; }; + + g.hasConfigGroup = function( key ) { + for( var i = 0; i < ls.length; i++ ) if( ls.key( i ).startsWith( key ) ) return true; + return false; + }; + + g.getConfigEntryCount = function( prefix, recurse ) { + var n = 0; + for( var i = 0; i < ls.length; i++ ) { + var key = ls.key( i ); + if( key.startsWith( prefix ) ) { + var end = key.indexOf( '/', prefix.length ); + if( end == -1 || recurse ) ++n; + } + } + return n; + }; + + g.getConfigEntryIndex = function( prefix, index ) { + var n = 0; + for( var i = 0; i < ls.length; i++ ) { + var key = ls.key( i ); + if( key.startsWith( prefix ) ) { + var end = key.indexOf( '/', prefix.length ); + if( end == -1 ) { if( n >= index ) return i; else ++n; } + } + } + return -1; + }; + + g.getConfigGroupCount = function( prefix, recurse ) { + var c = new Set(); + for( var i = 0; i < ls.length; i++ ) { + var key = ls.key( i ); + if( key.startsWith( prefix ) ) { + var end = key.indexOf( '/', prefix.length ); + if( end != -1 ) { if( recurse ) end = key.lastIndexOf( '/' ); c.add( key.substring( prefix.length, end ) ); } + } + } + return c.size; + }; + + g.getConfigGroupIndex = function( prefix, index ) { + var c = new Set(); + for( var i = 0; i < ls.length; i++ ) { + var key = ls.key( i ); + if( key.startsWith( prefix ) ) { + var end = key.indexOf( '/', prefix.length ); + if( end != -1 ) { + var child = key.substring( prefix.length, end ); + if( !c.has( child ) ) { if( c.size >= index ) return i; else c.add( child ); } + } + } + } + return -1; + }; + + g.getConfigKeyLength = function( index ) { var k = ls.key( index ); return k ? k.length : 0; }; + g.getConfigKey = function( index, buf, len ) { s2u( ls.key( index ), buf, len ); }; + + g.getConfigEntryLength = function( key ) { var v = ls.getItem( key ); return v === null ? -1 : v.length; }; + g.getConfigEntry = function( key, buf, len ) { + var v = ls.getItem( key ); + if( v !== null ) { s2u( v, buf, len ); return true; } + return false; + }; + + g.setConfigEntry = function( key, value ) { ls.setItem( key, value ); }; + + g.renameConfigGroup = function( oldG, newG ) { + var keys = []; + for( var i = 0; i < ls.length; i++ ) { + var key = ls.key( i ); + if( key.startsWith( oldG ) ) keys.push( key ); + else if( key.startsWith( newG ) ) return false; + } + for( var j = 0; j < keys.length; j++ ) { + var nk = newG + keys[j].substring( oldG.length ); + ls.setItem( nk, ls.getItem( keys[j] ) ); ls.removeItem( keys[j] ); + } + return keys.length > 0; + }; +})( typeof globalThis !== 'undefined' ? globalThis : this ); diff --git a/wasm/stubs/exporter_step_stub.cpp b/wasm/stubs/exporter_step_stub.cpp new file mode 100644 index 0000000..0863296 --- /dev/null +++ b/wasm/stubs/exporter_step_stub.cpp @@ -0,0 +1,183 @@ +/* + * WASM shadow of EXPORTER_STEP — the editor build compiles this INSTEAD of + * exporters/step/exporter_step.cpp (no OCC in pcbnew.wasm; see + * docs/features/occ-split/README.md). The class layout comes from the real + * header; only the three symbols other TUs reference are defined here: + * constructor, destructor, Export(). + * + * Export() bridges to the occ_service worker: stage the live BOARD as sexpr + * text in MEMFS, ship it + the official JOB_EXPORT_PCB_3D JSON through an + * EM_ASYNC_JS suspend (globalThis.occService.request, installed by the web + * app), and report the outcome. The exported file's bytes never enter this + * module — the JS provider hands them straight to the browser download path. + * + * Callers stay untouched: PCBNEW_JOBS_HANDLER::JobExportStep and the browser + * branch of DIALOG_EXPORT_STEP construct EXPORTER_STEP exactly as on desktop. + */ + +#include +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include + +// Complete types for the unique_ptr members destroyed in ~EXPORTER_STEP (their +// headers still exist in the tree/sysroot; only the OCC *link* is gone). +#include +#include + +namespace +{ +const char* const TMP_BOARD = "/tmp/pcbjam_occ_export_board.kicad_pcb"; + +// Optional side-channel: the dialog seam serializes the FULL job (including +// fields EXPORTER_STEP_PARAMS doesn't carry, e.g. the assembly variant) right +// before calling Export(). Consumed once. When unset (jobs-handler path), the +// job JSON is reconstructed from m_params. +std::string s_nextJobJson; +} // namespace + +// One-shot job-JSON override for the next EXPORTER_STEP::Export() call. +extern "C" void Pcbjam_SetExportJobJson( const char* aJson ) +{ + s_nextJobJson = aJson ? aJson : ""; +} + +// Suspends pcbnew (Asyncify; the __asyncjs__* import is auto-covered by +// scripts/common/asyncify-imports.txt) while the worker exports. JS returns a +// malloc'd JSON string: { ok, report } — the download already happened there. +EM_ASYNC_JS( char*, js_occExportRequest, + ( const char* aBoardPath, const char* aJobJson, const char* aFileName ), +{ + const boardPath = UTF8ToString( aBoardPath ); + const jobJson = UTF8ToString( aJobJson ); + const fileName = UTF8ToString( aFileName ); + let res; + + try + { + const hook = globalThis.occService; + + if( !hook || typeof hook.request !== 'function' ) + { + res = { ok: false, report: 'occ_service provider not installed' }; + } + else + { + const board = FS.readFile( boardPath ); // Uint8Array copy — transferable + res = await hook.request( { kind: 'export', board, jobJson, fileName } ); + } + } + catch( e ) + { + console.error( '[pcbjam-occ] export request failed:', e ); + res = { ok: false, report: 'occ_service request failed: ' + e }; + } + + const s = JSON.stringify( res || { ok: false, report: 'occ_service: no response' } ); + const n = lengthBytesUTF8( s ) + 1; + const p = _malloc( n ); + stringToUTF8( s, p, n ); + return p; +} ) + + +EXPORTER_STEP::EXPORTER_STEP( BOARD* aBoard, const EXPORTER_STEP_PARAMS& aParams, + REPORTER* aReporter ) : + m_params( aParams ), + m_reporter( aReporter ), + m_board( aBoard ), + m_platingThickness( 0 ) +{ +} + + +EXPORTER_STEP::~EXPORTER_STEP() +{ +} + + +bool EXPORTER_STEP::Export() +{ + if( !m_board ) + return false; + + // Stage the LIVE board (unsaved edits included) as sexpr text in MEMFS. + try + { + PCB_IO_KICAD_SEXPR io; + io.SaveBoard( wxString::FromUTF8( TMP_BOARD ), m_board ); + } + catch( const std::exception& e ) + { + if( m_reporter ) + { + m_reporter->Report( wxString::Format( wxT( "Failed to stage board for export: %s" ), + e.what() ), + RPT_SEVERITY_ERROR ); + } + + return false; + } + + // The official job JSON. Prefer the seam-provided full job; otherwise + // rebuild one from the params we were constructed with. + std::string jobJson; + + if( !s_nextJobJson.empty() ) + { + jobJson = std::move( s_nextJobJson ); + s_nextJobJson.clear(); + } + else + { + JOB_EXPORT_PCB_3D job; + job.m_3dparams = m_params; + job.SetStepFormat( m_params.m_Format ); + + nlohmann::json j; + job.ToJson( j ); + jobJson = j.dump(); + } + + const wxString downloadName = wxFileName( m_outputFile ).GetFullName(); + + char* response = js_occExportRequest( TMP_BOARD, jobJson.c_str(), + downloadName.utf8_string().c_str() ); + + bool ok = false; + + try + { + nlohmann::json res = nlohmann::json::parse( response ? response : "{}" ); + ok = res.value( "ok", false ); + + const std::string report = res.value( "report", std::string() ); + + if( m_reporter && !report.empty() ) + { + m_reporter->Report( wxString::FromUTF8( report.c_str() ), + ok ? RPT_SEVERITY_INFO : RPT_SEVERITY_ERROR ); + } + } + catch( ... ) + { + if( m_reporter ) + m_reporter->Report( wxT( "occ_service: malformed response" ), RPT_SEVERITY_ERROR ); + } + + std::free( response ); + wxRemoveFile( wxString::FromUTF8( TMP_BOARD ) ); + + return ok; +} diff --git a/wasm/stubs/oce_plugin_stub.cpp b/wasm/stubs/oce_plugin_stub.cpp new file mode 100644 index 0000000..d2e6977 --- /dev/null +++ b/wasm/stubs/oce_plugin_stub.cpp @@ -0,0 +1,225 @@ +/* + * WASM shadow of the oce 3D plugin's flat-C surface (oce3d_*) — the editor + * build links this INSTEAD of s3d_plugin_oce (no OCC in pcbnew.wasm; see + * docs/features/occ-split/README.md). The static plugin registry + * (kicad/3d-viewer/3d_cache/pcbjam_static_3d_plugins.cpp) consumes exactly + * this surface, so the plugin manager, S3D_CACHE, and the scene build compile + * untouched — .step/.iges footprint models keep loading, their parse just runs + * in the occ_service worker. + * + * oce3d_Load: the model file is already in MEMFS (materialized by + * PCBJAM_3D::EnsureModelFile before the plugin dispatch). Ship its bytes to + * the worker (EM_ASYNC_JS suspend — legal here: EnsureModelFile already + * suspends on this same S3D_CACHE::load path), get back the SCENEGRAPH + * serialized in KiCad's own binary cache format, and rebuild it with + * S3D::ReadCache. + * + * Metadata (extensions/filters/versions) mirrors plugins/3d/oce/oce.cpp and + * include/plugins/3d/3d_plugin.h so the manager's handshake and extension map + * are identical to the real plugin's. + */ + +#include +#include +#include +#include + +#include + +#include +#include + +#include + +class SCENEGRAPH; + +namespace +{ + +// Non-Windows extension/filter lists from plugins/3d/oce/oce.cpp (FILE_DATA). +const std::vector k_extensions = { + "stp", "STP", "stpZ", "stpz", "STPZ", "step", "STEP", "stp.gz", "STP.GZ", "step.gz", + "STEP.GZ", "igs", "IGS", "iges", "IGES" +}; + +const std::vector k_filters = { + "STEP (*.stp;*.STP;*.stpZ;*.stpz;*.STPZ;*.step;*.STEP;*.stp.gz;*.STP.GZ;*.step.gz;" + "*.STEP.GZ)|*.stp;*.STP;*.stpZ;*.stpz;*.STPZ;*.step;*.STEP;*.stp.gz;*.STP.GZ;" + "*.step.gz;*.STEP.GZ", + "IGES (*.igs;*.IGS;*.iges;*.IGES)|*.igs;*.IGS;*.iges;*.IGES" +}; + +bool acceptAnyCacheTag( const char*, void* ) +{ + // The cache blob just crossed the worker boundary from our own writer — + // there is no plugin-version drift to guard against. + return true; +} + +} // namespace + +// Suspends pcbnew while the worker parses + tessellates the model. JS returns +// a malloc'd path string: the scenegraph-cache file it wrote into this +// module's MEMFS ("" on failure). +EM_ASYNC_JS( char*, js_occLoadModelRequest, ( const char* aModelPath ), +{ + const modelPath = UTF8ToString( aModelPath ); + let cachePath = ''; + + try + { + const hook = globalThis.occService; + + if( !hook || typeof hook.request !== 'function' ) + { + console.error( '[pcbjam-occ] loadModel: occ_service provider not installed' ); + } + else + { + const bytes = FS.readFile( modelPath ); // Uint8Array copy — transferable + const dot = modelPath.lastIndexOf( '.' ); + const ext = dot >= 0 ? modelPath.slice( dot + 1 ) : 'step'; + const res = await hook.request( { kind: 'loadModel', bytes, ext } ); + + if( res && res.ok && res.bytes && res.bytes.length ) + { + cachePath = '/tmp/pcbjam_occ_model_cache.3dc'; + FS.writeFile( cachePath, res.bytes ); + } + else if( res && res.report ) + { + console.error( '[pcbjam-occ] loadModel failed:', res.report ); + } + } + } + catch( e ) + { + console.error( '[pcbjam-occ] loadModel request failed:', e ); + cachePath = ''; + } + + const n = lengthBytesUTF8( cachePath ) + 1; + const p = _malloc( n ); + stringToUTF8( cachePath, p, n ); + return p; +} ) + + +extern "C" +{ + +// --- class handshake (include/plugins/3d/3d_plugin.h semantics) ------------- + +char const* oce3d_GetKicadPluginClass( void ) +{ + return "PLUGIN_3D"; +} + + +void oce3d_GetClassVersion( unsigned char* Major, unsigned char* Minor, unsigned char* Patch, + unsigned char* Revision ) +{ + if( Major ) + *Major = 1; + + if( Minor ) + *Minor = 0; + + if( Patch ) + *Patch = 0; + + if( Revision ) + *Revision = 0; +} + + +bool oce3d_CheckClassVersion( unsigned char Major, unsigned char, unsigned char, unsigned char ) +{ + return Major == 1; +} + + +// --- plugin identity (plugins/3d/oce/oce.cpp values) ------------------------- + +const char* oce3d_GetKicadPluginName( void ) +{ + return "PLUGIN_3D_OCE"; +} + + +void oce3d_GetPluginVersion( unsigned char* Major, unsigned char* Minor, unsigned char* Patch, + unsigned char* Revision ) +{ + if( Major ) + *Major = 1; + + if( Minor ) + *Minor = 4; + + if( Patch ) + *Patch = 2; + + if( Revision ) + *Revision = 0; +} + + +int oce3d_GetNExtensions( void ) +{ + return (int) k_extensions.size(); +} + + +char const* oce3d_GetModelExtension( int aIndex ) +{ + if( aIndex < 0 || aIndex >= (int) k_extensions.size() ) + return nullptr; + + return k_extensions[aIndex].c_str(); +} + + +int oce3d_GetNFilters( void ) +{ + return (int) k_filters.size(); +} + + +char const* oce3d_GetFileFilter( int aIndex ) +{ + if( aIndex < 0 || aIndex >= (int) k_filters.size() ) + return nullptr; + + return k_filters[aIndex].c_str(); +} + + +bool oce3d_CanRender( void ) +{ + return true; +} + + +SCENEGRAPH* oce3d_Load( char const* aFileName ) +{ + if( !aFileName ) + return nullptr; + + char* cachePath = js_occLoadModelRequest( aFileName ); + + if( !cachePath || !*cachePath ) + { + std::free( cachePath ); + return nullptr; + } + + // ReadCache returns the top-level SCENEGRAPH as its SGNODE base. + SGNODE* node = S3D::ReadCache( cachePath, nullptr, &acceptAnyCacheTag ); + + wxRemoveFile( wxString::FromUTF8( cachePath ) ); + std::free( cachePath ); + + return reinterpret_cast( node ); +} + +} // extern "C" diff --git a/web/standalone/src/wasm/boot.ts b/web/standalone/src/wasm/boot.ts index 53f4b78..c87d7fd 100644 --- a/web/standalone/src/wasm/boot.ts +++ b/web/standalone/src/wasm/boot.ts @@ -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 { // 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 diff --git a/web/standalone/src/wasm/constants.ts b/web/standalone/src/wasm/constants.ts index a953e52..de969b6 100644 --- a/web/standalone/src/wasm/constants.ts +++ b/web/standalone/src/wasm/constants.ts @@ -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 diff --git a/web/standalone/src/wasm/occ-service.ts b/web/standalone/src/wasm/occ-service.ts new file mode 100644 index 0000000..2d627e9 --- /dev/null +++ b/web/standalone/src/wasm/occ-service.ts @@ -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 } | 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 void>(); + let workerP: Promise | null = null; + + const ensureWorker = (): Promise => { + 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((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 => { + const id = nextId++; + const transfer: Transferable[] = + req.kind === "export" ? [req.board.buffer] : [req.bytes.buffer]; + return new Promise((resolve) => { + pending.set(id, resolve); + worker.postMessage({ id, req }, transfer); + }); + }; + + const request = async (req: OccRequest): Promise => { + 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)"); +} diff --git a/web/standalone/src/wasm/occ-worker.js b/web/standalone/src/wasm/occ-worker.js new file mode 100644 index 0000000..2e541b4 --- /dev/null +++ b/web/standalone/src/wasm/occ-worker.js @@ -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 = ""; + * + * 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] : []); +}; diff --git a/web/standalone/src/wasm/wasm-assets.ts b/web/standalone/src/wasm/wasm-assets.ts index bbc1cf6..c7681be 100644 --- a/web/standalone/src/wasm/wasm-assets.ts +++ b/web/standalone/src/wasm/wasm-assets.ts @@ -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 { * - manifest ⇒ `WASM_ROOT//` from `manifest-.json`. */ export async function resolveWasmBase( - tool: Tool, + tool: Tool | Bundle, override?: string, ): Promise { 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>)[tool] ?? (tool as Bundle); const manifest = await loadManifest(); const ver = manifest.tools?.[bundle]; if (!ver) {