| Filename | Latest commit message | Latest commit date |
|---|---|---|
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>
|
||
| .. | ||
| README.md | ||
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 carryfile:line(kicad @9d77139, root @9787efc).
TL;DR
OpenCASCADE (OCC) is ~30% of pcbnew.wasm (~19.5 MiB base code → ~35–40 MB after
Asyncify) and serves two features: parsing STEP/IGES component models for the 3D
viewer (working — and the primary model path, §1a), and STEP/3D export (broken —
its UI spawns a kicad-cli subprocess, impossible in a browser, §1b).
The plan: move OCC and both features into a standalone occ_service module —
own emscripten instance and memory, running in a Web Worker, fetched on demand —
leaving pcbnew.wasm OCC-free.
pcbnew.wasm: ~146 → ~109 MB raw (the 2D-editing download, ~25–30% less transfer).occ_service.wasm: ~25–45 MB raw estimate (our OCC compiles-O1; the worker also carries the board parser — measure), fetched only when a STEP model must be parsed or the user exports.- 3D viewer behavior is unchanged (same parser code, executed in the worker; §3); export starts working for the first time.
The pcbnew↔service API is two functions:
pcbnew.wasm (no OCC) occ_service.wasm (worker, lazy)
Export dialog OK ─► occExport(boardSexpr, paramsJson) ──► parse + EXPORTER_STEP ─► bytes ─► downloadBytes()
oce3d_Load shadow ─► occLoadModel(fileBytes, ext) ──────► OCC parse + tessellate ─► scenegraph-cache bytes ─► S3D::ReadCache
1. The two OCC consumers today
OCC is reached by exactly two subsystems (grep-proven across kicad/ for
TopoDS/BRep/Standard_/STEPControl/…). Everything else — 2D editor, GAL, DRC,
router, kimath, the 3D scene build and raytracer (board geometry is native earcut
tessellation), eeschema, gerbview — is OCC-clean.
1a. Component-model import — WORKING, and OCC is its primary parser
The 3D viewer's format loaders are static libraries in the wasm build (upstream loads them as dlopen plugins; wasm has no dlopen, so they're linked in with per-TU symbol renames and a registry):
plugins/3d/vrml(vrml3d_*) — VRML/WRL/X3D, no OCC.plugins/3d/oce(oce3d_*) — STEP/IGES via OCC (loadmodel.cpp: STEP/IGES readers →BRepMesh_IncrementalMesh→SCENEGRAPHtriangles).- Registry:
3d-viewer/3d_cache/pcbjam_static_3d_plugins.cpp(EMSCRIPTEN-only TU) feeds both into the plugin manager;3d-viewer/CMakeLists.txtlinkss3d_plugin_vrml s3d_plugin_ocefor 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
ArrayBuffers (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::ReadCacheconsumes them there. - Neither OCC job suspends internally (no
emscripten_sleep/modal/PROGRESS_REPORTERin either path), so the service builds-sASYNCIFY=0— no ~2× asyncify tax. External precedent: andymai/occt-wasm (OCCT in a Worker,-fwasm-exceptions, no asyncify → 20.8 MB / ~4 MB brotli); kovacsv/occt-import-js (STEP→mesh).
Latency reality: the 2D editor (the common session) never fetches the service. A 3D view of a board with library models effectively always will (the 10.x library is STEP-only) — one fetch (~5–9 MB brotli), then IDB-cached like the models themselves.
3. Design decisions
- Callee-shadowing, not caller-
#ifdefs. Both features reduce to one shadowable function each, and the wasm build swaps in replacement definitions (the establishedPCBNEW_WASM_STUBSpattern):EXPORTER_STEPctor/dtor/Export()— shadow TU in our repo (wasm/stubs/);pcbnew_jobs_handler.cppand every other caller compile untouched and keep working.oce3d_Load(+ theoce3d_*metadata getters the registry needs — mirroringoce.cpp's extension/filter answers) — shadow TU replaces linkings3d_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
#ifdefin the whole feature is indialog_export_step.cppat thewxExecutespawn site: read the same controls, fillEXPORTER_STEP_PARAMS, callEXPORTER_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_3Dregisters every field as a serializableJOB_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,wxProcessevent 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 viaadd_subdirectory( ${KICAD_WASM_LAYER}/occ-service )underoption( KICAD_OCC_SERVICE_WASM … OFF )(thewasm/editorpattern; kicad keeps only the option + hook + the exporter-sourceCACHE INTERNALexport). Linked likesym_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
-Ozwhole-module DCE (assym_convertdocuments,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 replicatingpcbnew_scripting_helpers.cpp:96-235(standaloneSETTINGS_MANAGER, default project,PCB_IO_MGR::Load(KICAD_SEXP),SetProject) — that file itself isKICAD_SCRIPTING-gated, so the ~30-line pattern is replicated, not linked; paramsJson →JOB_EXPORT_PCB_3D::FromJson→ the JOB→EXPORTER_STEP_PARAMSmapping ofpcbnew_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 realoce3d_Load,S3D::WriteCachethe returnedSCENEGRAPH→ return the cache bytes.wasm/occ-service/occ_service_pre.js: in-memory wxConfig store (copysym_convert_pre.js).
- Build wiring, mirroring
sym_convert:scripts/kicad/build-occ_service.sh; thecase/option/tool-gates inscripts/kicad/build-kicad-target.sh;docker/build.shVALID_APPS/validation/subdir + skip host post-processing and the asyncify pass for this target. - Verify: Node (
-sENVIRONMENT=worker,node) unit run —occExporton a real.kicad_pcb(validate the STEP round-trips, e.g.occt-import-js),occLoadModelon 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 toPCBNEW_WASM_STUBS):Export()serializes the liveBOARDto sexpr (in-memoryPCB_IO_KICAD_SEXPR), builds the job JSON, callsjs_occRequest(EM_ASYNC_JS, near-copy of the main-thread path ofpcb_io/pcbjam_fp/pcb_io_pcbjam_fp.cpp:58-95; thejs_*name is auto-covered byscripts/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 fulloce3d_*flat-C surface withoce.cpp's metadata answers;oce3d_Load(path)reads the file from MEMFS (already materialized byEnsureModelFile), ships bytes + ext via the samejs_occRequestchannel, writes the returned cache bytes to a temp MEMFS path,S3D::ReadCache→SCENEGRAPH*. Suspending here is proven legal —EnsureModelFilealready asyncify-suspends inside the sameS3D_CACHE::loadcall path. - Dialog seam: the one
#ifdef __EMSCRIPTEN__atdialog_export_step.cpp:568/:708(controls → params →EXPORTER_STEP(…).Export()). - JS provider/worker:
installOccService()setsglobalThis.occService = { request }(copyinstallLibsProvider,web/standalone/src/wasm/libs/source.ts:245-386). First request lazily fetches + boots the module in a dedicated Worker (manifest-resolved,MODULARIZEfactory; boot patternsite/public/gerber-demo/boot.js; reuse the cross-origin blob-importScriptsshimweb/standalone/src/wasm/boot.ts:113-137for the worker + its nested pthread workers); transferables both ways; export results →downloadBytes(), import results → back to the caller. Manifest entry inweb/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 ablob: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.wasmrequest 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-lineself.OCC_GLUE_URL = …prelude to the blob. In tests the provider is installed ambiently bytests/kicad/fixtures.tsas 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:
- Exporter sources (
exporters/step/*+exporters/u3d/*) →PCBNEW_OCC_EXPORTERS, appended toPCBNEW_EXPORTERSonlyif( NOT EMSCRIPTEN )(the service target compiles the variable). ${OCC_LIBRARIES}onpcbnew_kiface_objectsunderif( NOT EMSCRIPTEN ).find_package(OCC)+ top-level OCC include dirs stay (headers still compile).- 3d-viewer: for EMSCRIPTEN link
s3d_plugin_vrml+ the import shadow instead ofs3d_plugin_oce. - 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; assertsocc_serviceis fetched lazily (not on first load, once on export), the download's bytes validate (STEPISO-10303-21+ deep-parse via anocct-import-jsdevDependency; 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_servicefetch both happen and components render (screenshot). Fixtures come from the existing 3d-viewer-models board; extra.stepfixtures may be fetched from the models CDN and committed undertests/. - Benchmarks (main vs post-Stage-3; same machine, committed
BINARYEN_OPT_LEVEL; fill §6):- Raw sizes only (no gzip/brotli measuring):
pcbnew.wasm(+.jsglue),occ_service.wasm,footprint_editor.wasm(byte-dup of pcbnew). - Build time: wall-clock
./docker/build.sh pcbnew, plus theocc_servicebuild. - Build RAM: peak RSS of the post-link
wasm-opt/apply-asyncify.shsteps (GNU time) — asyncify peaks ~8.3 GB today; release-configwasm-optOOMs >64 GB; check whether the lean module fits release builds back under 64 GB.
- Raw sizes only (no gzip/brotli measuring):
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'svcpkg.jsonpulls opencascade'srapidjsonfeature): 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
-Ozleaves the service fat, use the$<TARGET_OBJECTS>→STATIC-archive fallback (§4 Stage 1). Measure first. - Import parity:
SCENEGRAPHmust surviveWriteCache→ReadCachebyte-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_Failureinside the worker;occLoadModelcatches 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'sPTHREAD_POOL_EXPR, the raytrace-deadlock fix7630c7e) AND theGetKiCadThreadPool()warm-up in itsmain(). Confirmed by aPTHREADS_DEBUGtrace: 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 insideEXPORTER_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.hand theoce3d_*surface across KiCad rebases; drift surfaces as link errors (the good failure mode). - Coordination: the import path relocates where
oce3d_Loadexecutes — same code, worker address space; owner of the 3D-models feature should know. - Deploy: the live R2 wasm manifest carries no
occ_serviceentry, so manifest-mode runs (dev-demo --wasm r2, the deployed demo) fail occ requests withno WASM version for "occ_service"until the next publish (publish-wasm.mjsalready lists the tool and its no-shared-files layout); the split editor build must ship together with itsocc_servicefolder. - IGES export is behind
#ifdef SUPPORTS_IGES(step_pcb_model.h:250), not in the dialog — out of scope. (IGES import works throughoce3d_Loadlike 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
.wrlproject files turn out rare,s3d_plugin_vrmlcould also move to the service; if common, it stays (it's small and OCC-free). AddPadShapecommand-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 EMSCRIPTENgate); protobuf looks dead but is load-bearing viaEDA_ITEM : SERIALIZABLE— trap, skip.
Sources
- External: andymai/occt-wasm · kovacsv/occt-import-js · 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.