feat(wasm): occ-split — lazy occ_service worker; kicad_editor drops OCC (−31%)

Move OpenCASCADE out of the merged editor image into occ_service: a separate
emscripten module (-sASYNCIFY=0, MODULARIZE, in-container -Oz finalize, 2N+8
pre-warmed pthread pool) booted lazily in a dedicated Web Worker on the first
STEP export or STEP/IGES model parse. kicad_editor.wasm ~190 MB -> 130 MB;
sessions that never touch OCC never fetch its 57 MB. STEP export works in the
browser for the first time: the unchanged desktop dialog runs EXPORTER_STEP,
whose wasm shadow suspends into globalThis.occService and the export bytes go
straight to a browser download (never entering the editor heap). STEP/IGES 3D
models parse in the worker via the oce shadow (S3D WriteCache/ReadCache wire).

- wasm/occ-service/: service CMake target (hooked from the kicad fork's
  top-level CMakeLists, wasm/editor pattern), embind entry
  (occExport/occLoadModel), wxConfig pre-js.
- wasm/stubs/{exporter_step,oce_plugin}_stub.cpp: EM_ASYNC_JS worker bridges
  (callee-shadowing; no caller #ifdefs).
- web/standalone: provider installed whenever the kicad_editor bundle boots
  (cross-face safe); ONE shared worker-boot source occ-worker.js (vite ?raw;
  the e2e stub reads the same file) — blob worker with locateFile absolutized
  against the glue URL; export download-name guard.
- deps: OCC builds with RapidJSON so its glTF/GLB writer exists — pinned to
  the vcpkg master snapshot 2025-02-26 (24b5e7a8b27f), the same code official
  KiCad consumes via vcpkg.json's opencascade[rapidjson]; rapidjson's latest
  tag (v1.1.0, 2016) is ill-formed under modern clang.
- tests: occ-export dialog e2e (lazy-fetch boundary + STEP download bytes),
  occ-probe incl. a 9-format matrix (step/stpz/brep/xao/ply/stl/glb/u3d/pdf),
  3d-viewer-models hard-asserts the worker parse; occ provider stub installed
  ambiently by the kicad fixtures.

Validated against desktop kicad-cli 10.0.4: geometric exact equality (bbox
delta 0 um, volume delta 0.0000%) for STEP/GLB/STL/BREP/STPZ across three
boards and option sweeps — with desktop OCC 7.9 vs wasm OCC 7.8; PLY/XAO/PDF
structurally equal; U3D same-size (quantizer float LSBs differ). Full kicad
e2e green on Firefox and Chromium; standalone verified end to end (lazy fetch
only on the Export click; export.step 60,628 B ISO-10303-21; loadModel 700 KB
STEP -> 569 KB scenegraph cache).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-07-03 12:39:58 +02:00
commit db9d6ee04b
28 changed files with 2296 additions and 23 deletions

1
.gitignore vendored
View file

@ -94,3 +94,4 @@ output/
# Wrangler (Cloudflare CLI) local state/cache — created when running R2 deploys.
.wrangler/
tests/.scratch/

View file

@ -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 <app>[,<app>...] [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

View file

@ -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 → ~3540 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, ~2530% less transfer).
- `occ_service.wasm`: **~2545 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 (~59 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 $<TARGET_OBJECTS:pcbnew_kiface_objects> )` 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
`$<TARGET_OBJECTS>`→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`.

2
kicad

@ -1 +1 @@
Subproject commit f5df5977e0977063da6f6b66cf69799f5c90159a
Subproject commit 11a52e4c12be7811b1df585749c4cfa874cddbc7

View file

@ -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)

View file

@ -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})` : ""}`);

View file

@ -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. `<tool>` 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

View file

@ -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-<sha>,
# 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 \

View file

@ -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

View file

@ -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 "$@"

105
tests/fixtures/demo/demo.kicad_prl vendored Normal file
View file

@ -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
}
}
}

View file

@ -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-"]');

View file

@ -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(' - ');

View file

@ -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<void> {
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<boolean> {
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' });
});
});

View file

@ -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<string, string> = {
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: '<?xml',
ply: 'ply',
stl: 'solid',
glb: 'glTF',
u3d: 'U3D',
pdf: '%PDF',
};
for (const [fmt, magic] of Object.entries(TEXT_MAGIC)) {
const r = await page.evaluate(async ({ boardText, fmt }) => {
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);
});
});

View file

@ -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<void> {
await page.addInitScript((workerSrc: string) => {
if ((globalThis as any).occService) return;
(window as any).__occExports = [];
let workerP: Promise<Worker> | null = null;
const pending = new Map<number, (res: any) => void>();
let nextId = 1;
const ensureWorker = (): Promise<Worker> => {
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<void>((resolve, reject) => {
const onFirst = (e: MessageEvent) => {
if (e.data?.ready) { worker.removeEventListener('message', onFirst); resolve(); }
else if (e.data?.bootError) reject(new Error(e.data.bootError));
};
worker.addEventListener('message', onFirst);
});
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);
}

View file

@ -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

View file

@ -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

View file

@ -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"
"$<TARGET_FILE:pcbcommon>"
"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" )

View file

@ -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 <cstdio>
#include <string>
#include <vector>
#include <emscripten/bind.h>
#include <emscripten/val.h>
#include <wx/init.h>
#include <wx/string.h>
#include <wx/ffile.h>
#include <wx/filename.h>
#include <wx/image.h>
#include <pgm_base.h>
#include <settings/settings_manager.h>
#include <kiplatform/environment.h>
#include <reporter.h>
#include <thread_pool.h>
#include <nlohmann/json.hpp>
#include <jobs/job_export_pcb_3d.h>
#include <board.h>
#include <pcb_io/pcb_io_mgr.h>
#include <exporters/step/exporter_step.h>
#include <plugins/3dapi/ifsg_api.h>
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<char>* 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<char>& aBytes )
{
emscripten::val view = emscripten::val( emscripten::typed_memory_view(
aBytes.size(), reinterpret_cast<const uint8_t*>( 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<char> 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<size_t>();
std::vector<uint8_t> 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<void>( "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<SGNODE*>( sg );
bool ok = S3D::WriteCache( TMP_CACHE, true, node, "pcbjam-occ_service:1" );
std::vector<char> 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;
}

View file

@ -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 );

View file

@ -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 <cstdlib>
#include <string>
#include <emscripten.h>
#include <wx/filename.h>
#include <wx/string.h>
#include <nlohmann/json.hpp>
#include <board.h>
#include <reporter.h>
#include <jobs/job_export_pcb_3d.h>
#include <pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h>
#include <exporters/step/exporter_step.h>
// 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 <exporters/step/step_pcb_model.h>
#include <filename_resolver.h>
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;
}

View file

@ -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 <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <emscripten.h>
#include <wx/string.h>
#include <wx/filefn.h>
#include <plugins/3dapi/ifsg_api.h>
class SCENEGRAPH;
namespace
{
// Non-Windows extension/filter lists from plugins/3d/oce/oce.cpp (FILE_DATA).
const std::vector<std::string> k_extensions = {
"stp", "STP", "stpZ", "stpz", "STPZ", "step", "STEP", "stp.gz", "STP.GZ", "step.gz",
"STEP.GZ", "igs", "IGS", "iges", "IGES"
};
const std::vector<std::string> 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<SCENEGRAPH*>( node );
}
} // extern "C"

View file

@ -12,6 +12,7 @@ import {
} from "./constants";
import { installModel3dHandler } from "./libs/models-bridge";
import type { Model3dSource } from "./libs/models-source";
import { installOccService } from "./occ-service";
import {
buildFpLibTable,
buildSymLibTable,
@ -240,6 +241,15 @@ async function doBoot(opts: BootOptions): Promise<void> {
// Which lib table this tool consumes: symbol → sym-lib-table, footprint →
// fp-lib-table. The same lib source feeds whichever table the tool reads.
const libKind = TOOL_LIB_KIND[tool];
// OCC service (STEP export + STEP/IGES model parsing): install whenever the
// merged editor bundle boots — a PCB frame can open from ANY session (e.g.
// eeschema → cross-face into pcbnew), and the install is a synchronous global
// set; the worker itself is only fetched lazily on first use.
if (bundle === "kicad_editor") {
installOccService(log);
}
if (libsSource && libKind) {
installLibsProvider(libsSource, log);
// 3D models ride the same provider (kind "model3d"): the C++ ensure fallback

View file

@ -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

View file

@ -0,0 +1,162 @@
import { downloadBytes } from "@/lib/download";
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
// also injected by the e2e harness stub (tests/kicad/utils/occ-service.ts).
import occWorkerSource from "./occ-worker.js?raw";
import { resolveWasmBase } from "./wasm-assets";
/**
* `globalThis.occService` the lazy OpenCASCADE 3D service provider.
*
* pcbnew.wasm carries no OCC (docs/features/occ-split/): its two OCC-backed
* paths suspend via EM_ASYNC_JS bridges (wasm/stubs/{exporter_step,oce_plugin}_stub.cpp)
* and land here:
* { kind: "export", board, jobJson, fileName } STEP/GLB/ export; the
* resulting bytes are delivered straight to the browser download path and
* only { ok, report } goes back to the editor.
* { kind: "loadModel", bytes, ext } STEP/IGES parse + tessellation; returns
* the SCENEGRAPH serialized in KiCad's binary cache format, which the
* C++ stub rebuilds with S3D::ReadCache.
*
* The occ_service module (own emscripten instance, `-sASYNCIFY=0`) boots in a
* dedicated Worker on the FIRST request a pcbnew session that never exports
* and never views STEP models never fetches it. Same cross-origin worker rules
* as the pthread workers (boot.ts): a same-origin blob wrapper importScripts
* the (possibly CDN) glue; the module's own pthread children reuse the trick
* via mainScriptUrlOrBlob.
*/
interface OccExportRequest {
kind: "export";
board: Uint8Array;
jobJson: string;
fileName: string;
}
interface OccLoadModelRequest {
kind: "loadModel";
bytes: Uint8Array;
ext: string;
}
export type OccRequest = OccExportRequest | OccLoadModelRequest;
export interface OccResponse {
ok: boolean;
report?: string;
fileName?: string;
bytes?: Uint8Array;
}
declare global {
// eslint-disable-next-line no-var
var occService: { request(req: OccRequest): Promise<OccResponse> } | undefined;
}
/**
* Assemble the worker blob: a one-line prelude carrying the glue URL, then the
* shared wrapper source (occ-worker.js), which reads `self.OCC_GLUE_URL`.
*/
export function occWorkerBlobParts(glueHref: string): string[] {
return [
`self.OCC_GLUE_URL = ${JSON.stringify(glueHref)};\n`,
occWorkerSource,
];
}
export function installOccService(log: (msg: string) => void): void {
if (globalThis.occService) return;
let nextId = 1;
const pending = new Map<number, (res: OccResponse) => void>();
let workerP: Promise<Worker> | null = null;
const ensureWorker = (): Promise<Worker> => {
if (!workerP) {
workerP = (async () => {
// occ_service is a Bundle (a published delivery artifact), not a Tool —
// resolveWasmBase accepts either and looks the bundle up directly.
const base = await resolveWasmBase("occ_service");
const glue = new URL(`${base}/occ_service.js`, window.location.href).href;
log(`[occ] booting occ_service from ${base}`);
const worker = new Worker(
URL.createObjectURL(
new Blob(occWorkerBlobParts(glue), { type: "text/javascript" }),
),
);
worker.onmessage = (e) => {
const { id, res } = e.data ?? {};
if (typeof id !== "number") return;
const resolve = pending.get(id);
if (resolve) {
pending.delete(id);
resolve(res as OccResponse);
}
};
await new Promise<void>((resolve, reject) => {
const onFirst = (e: MessageEvent) => {
if (e.data?.ready) {
worker.removeEventListener("message", onFirst);
resolve();
} else if (e.data?.bootError) {
reject(new Error(e.data.bootError));
}
};
worker.addEventListener("message", onFirst);
worker.onerror = (e) => reject(new Error(`occ_service worker: ${e.message}`));
});
log("[occ] occ_service ready");
return worker;
})().catch((e) => {
workerP = null; // a failed boot must stay retryable
throw e;
});
}
return workerP;
};
const post = (worker: Worker, req: OccRequest): Promise<OccResponse> => {
const id = nextId++;
const transfer: Transferable[] =
req.kind === "export" ? [req.board.buffer] : [req.bytes.buffer];
return new Promise<OccResponse>((resolve) => {
pending.set(id, resolve);
worker.postMessage({ id, req }, transfer);
});
};
const request = async (req: OccRequest): Promise<OccResponse> => {
let worker: Worker;
try {
worker = await ensureWorker();
} catch (e) {
return { ok: false, report: `occ_service unavailable: ${e}` };
}
const res = await post(worker, req);
if (req.kind === "export") {
// Deliver the export straight to the user; the editor gets status only
// (the bytes never enter pcbnew's heap).
if (res.ok && res.bytes?.length) {
// The dialog can hand over an extension-only name (".step" — its
// default filename field is empty in the browser); Chromium mangles a
// bare dotfile download to "step.txt", so give it a real stem while
// keeping the format extension the user picked.
const raw = req.fileName || res.fileName || "";
const name = !raw || raw.startsWith(".") ? `export${raw || ".step"}` : raw;
downloadBytes(name, res.bytes);
log(`[occ] export downloaded: ${name} (${res.bytes.length} bytes)`);
}
return { ok: res.ok, report: res.report, fileName: res.fileName };
}
return res;
};
globalThis.occService = { request };
log("[occ] occ_service provider installed (lazy)");
}

View file

@ -0,0 +1,68 @@
/*
* Worker-side wrapper for the occ_service MODULARIZE module the SINGLE
* source of truth for the worker boot, shared verbatim by:
* - the standalone app provider (occ-service.ts, vite `?raw` import), and
* - the e2e harness stub (tests/kicad/utils/occ-service.ts, read off disk).
*
* The host prepends one prelude line to the blob before this file's content:
* self.OCC_GLUE_URL = "<absolute URL of occ_service.js>";
*
* Protocol: the host posts { id, req } (req = { kind: "export" | "loadModel",
* }); the worker answers { id, res } with the result bytes transferred. A
* one-shot { ready: true } / { bootError } message reports module boot.
*/
const GLUE = self.OCC_GLUE_URL;
self.addEventListener("error", (e) =>
console.error("[occ_service] worker error:", e.message, e.filename, e.lineno));
self.addEventListener("unhandledrejection", (e) =>
console.error("[occ_service] unhandled rejection:", e.reason));
importScripts(GLUE);
// wx boot logs a "Debug:" line per image handler etc. — pure noise in the page
// console (and in the captured test logs); real problems don't carry the marker.
const noise = (s) => /(^|: )Debug: /.test(String(s));
const modP = OccService({
onAbort: (what) => console.error("[occ_service] ABORT:", what),
// The module's own pthread children must boot from a same-origin script even
// when the glue lives on a CDN — same blob-importScripts trick as boot.ts.
mainScriptUrlOrBlob: new Blob(
["importScripts(" + JSON.stringify(GLUE) + ");"],
{ type: "text/javascript" }),
// A blob: worker has no http base URL — every asset path must be absolutized
// against the glue's own URL or the .wasm fetch dies with "Failed to parse
// URL" (root-relative bases like "/wasm" don't resolve).
locateFile: (f) => new URL(f, GLUE).href,
print: (s) => { if (!noise(s)) console.log("[occ_service]", s); },
printErr: (s) => { if (!noise(s)) console.warn("[occ_service]", s); },
});
modP.then(() => postMessage({ ready: true }),
(e) => postMessage({ bootError: String(e) }));
onmessage = async (e) => {
const { id, req } = e.data;
let res;
try {
const mod = await modP;
if (req.kind === "export") {
const board = new TextDecoder().decode(req.board);
res = mod.occExport(board, req.jobJson);
} else if (req.kind === "loadModel") {
res = mod.occLoadModel(req.bytes, req.ext);
} else {
res = { ok: false, report: "occ_service: unknown request kind " + req.kind };
}
} catch (err) {
res = { ok: false, report: "occ_service worker: " + err };
}
const out = {
ok: !!(res && res.ok),
report: res && res.report,
fileName: res && res.fileName,
bytes: res && res.bytes,
};
postMessage({ id, res: out }, out.bytes ? [out.bytes.buffer] : []);
};

View file

@ -1,6 +1,6 @@
import type { Tool } from "@pcbjam/shared";
import { WASM_MANIFEST_FILE, WASM_ROOT } from "@/lib/config";
import { TOOL_BUNDLE } from "./constants";
import { TOOL_BUNDLE, type Bundle } from "./constants";
/**
* Resolve the per-tool WASM asset base at runtime from the CDN release manifest.
@ -38,15 +38,17 @@ function loadManifest(): Promise<WasmManifest> {
* - manifest `WASM_ROOT/<tool>/<ver>` from `manifest-<appTag>.json`.
*/
export async function resolveWasmBase(
tool: Tool,
tool: Tool | Bundle,
override?: string,
): Promise<string> {
if (override) return override.replace(/\/+$/, "");
if (!WASM_MANIFEST_FILE) return WASM_ROOT; // flat (dev / same-origin)
// A tool may be served by a shared bundle (all four editors → kicad_editor);
// resolve the folder/version of the bundle, not the logical tool (bundles are
// the only thing published/listed in the manifest).
const bundle = TOOL_BUNDLE[tool];
// the only thing published/listed in the manifest). A caller may also name a
// bundle directly (occ_service — a delivery artifact backing no tool).
const bundle: Bundle =
(TOOL_BUNDLE as Partial<Record<string, Bundle>>)[tool] ?? (tool as Bundle);
const manifest = await loadManifest();
const ver = manifest.tools?.[bundle];
if (!ver) {