feat(wasm): occ-split — lazy occ_service worker; kicad_editor drops OCC (−31%)
Move OpenCASCADE out of the merged editor image into occ_service: a separate
emscripten module (-sASYNCIFY=0, MODULARIZE, in-container -Oz finalize, 2N+8
pre-warmed pthread pool) booted lazily in a dedicated Web Worker on the first
STEP export or STEP/IGES model parse. kicad_editor.wasm ~190 MB -> 130 MB;
sessions that never touch OCC never fetch its 57 MB. STEP export works in the
browser for the first time: the unchanged desktop dialog runs EXPORTER_STEP,
whose wasm shadow suspends into globalThis.occService and the export bytes go
straight to a browser download (never entering the editor heap). STEP/IGES 3D
models parse in the worker via the oce shadow (S3D WriteCache/ReadCache wire).
- wasm/occ-service/: service CMake target (hooked from the kicad fork's
top-level CMakeLists, wasm/editor pattern), embind entry
(occExport/occLoadModel), wxConfig pre-js.
- wasm/stubs/{exporter_step,oce_plugin}_stub.cpp: EM_ASYNC_JS worker bridges
(callee-shadowing; no caller #ifdefs).
- web/standalone: provider installed whenever the kicad_editor bundle boots
(cross-face safe); ONE shared worker-boot source occ-worker.js (vite ?raw;
the e2e stub reads the same file) — blob worker with locateFile absolutized
against the glue URL; export download-name guard.
- deps: OCC builds with RapidJSON so its glTF/GLB writer exists — pinned to
the vcpkg master snapshot 2025-02-26 (24b5e7a8b27f), the same code official
KiCad consumes via vcpkg.json's opencascade[rapidjson]; rapidjson's latest
tag (v1.1.0, 2016) is ill-formed under modern clang.
- tests: occ-export dialog e2e (lazy-fetch boundary + STEP download bytes),
occ-probe incl. a 9-format matrix (step/stpz/brep/xao/ply/stl/glb/u3d/pdf),
3d-viewer-models hard-asserts the worker parse; occ provider stub installed
ambiently by the kicad fixtures.
Validated against desktop kicad-cli 10.0.4: geometric exact equality (bbox
delta 0 um, volume delta 0.0000%) for STEP/GLB/STL/BREP/STPZ across three
boards and option sweeps — with desktop OCC 7.9 vs wasm OCC 7.8; PLY/XAO/PDF
structurally equal; U3D same-size (quantizer float LSBs differ). Full kicad
e2e green on Firefox and Chromium; standalone verified end to end (lazy fetch
only on the Export click; export.step 60,628 B ISO-10303-21; loadModel 700 KB
STEP -> 569 KB scenegraph cache).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
96abe5d4a5
commit
db9d6ee04b
28 changed files with 2296 additions and 23 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -93,4 +93,5 @@ output/
|
||||||
/memory/
|
/memory/
|
||||||
|
|
||||||
# Wrangler (Cloudflare CLI) local state/cache — created when running R2 deploys.
|
# Wrangler (Cloudflare CLI) local state/cache — created when running R2 deploys.
|
||||||
.wrangler/
|
.wrangler/
|
||||||
|
tests/.scratch/
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ trap 'kw_fail 130; exit 130' INT TERM
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
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() {
|
usage() {
|
||||||
echo "Usage: ./docker/build.sh <app>[,<app>...] [args...]" >&2
|
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
|
# possible (especially with KICAD_PIPELINE=1). pcbnew/eeschema stay buildable as
|
||||||
# standalone debug aids but are not part of "all" (not deployed).
|
# standalone debug aids but are not part of "all" (not deployed).
|
||||||
if [[ "$APP_NAME" == "all" ]]; then
|
if [[ "$APP_NAME" == "all" ]]; then
|
||||||
APPS=(kicad_editor calculator pl_editor gerbview)
|
APPS=(kicad_editor occ_service calculator pl_editor gerbview)
|
||||||
else
|
else
|
||||||
IFS=',' read -r -a APPS <<< "$APP_NAME"
|
IFS=',' read -r -a APPS <<< "$APP_NAME"
|
||||||
for app in "${APPS[@]}"; do
|
for app in "${APPS[@]}"; do
|
||||||
case "$app" in
|
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
|
echo "Error: unknown app '$app' (expected: ${VALID_APPS})" >&2
|
||||||
usage
|
usage
|
||||||
|
|
@ -256,9 +256,10 @@ postprocess_app() {
|
||||||
local app="$1"
|
local app="$1"
|
||||||
local out_dir="output"
|
local out_dir="output"
|
||||||
|
|
||||||
# The converter is finalized in-container (real tools, small -g0 wasm) and is
|
# The converter and the OCC service are finalized in-container (real tools,
|
||||||
# a synchronous node CLI, so it needs no host post-processing.
|
# small -g0 wasm) and build with ASYNCIFY=0, so they need no host
|
||||||
if [ "$app" = "sym_convert" ]; then
|
# 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)"
|
echo "Skipping host post-processing for ${app} (finalized in-container)"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
468
docs/features/occ-split/README.md
Normal file
468
docs/features/occ-split/README.md
Normal 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 → ~35–40 MB after
|
||||||
|
Asyncify) and serves **two features**: parsing STEP/IGES **component models** for the 3D
|
||||||
|
viewer (working — and the primary model path, §1a), and STEP/3D **export** (broken —
|
||||||
|
its UI spawns a `kicad-cli` subprocess, impossible in a browser, §1b).
|
||||||
|
|
||||||
|
**The plan:** move OCC and both features into a standalone **`occ_service`** module —
|
||||||
|
own emscripten instance and memory, running in a **Web Worker**, fetched **on demand** —
|
||||||
|
leaving `pcbnew.wasm` OCC-free.
|
||||||
|
|
||||||
|
- `pcbnew.wasm`: **~146 → ~109 MB raw** (the 2D-editing download, ~25–30% less transfer).
|
||||||
|
- `occ_service.wasm`: **~25–45 MB raw** estimate (our OCC compiles `-O1`; the worker also
|
||||||
|
carries the board parser — measure), fetched only when a STEP model must be parsed or
|
||||||
|
the user exports.
|
||||||
|
- **3D viewer behavior is unchanged** (same parser code, executed in the worker; §3);
|
||||||
|
**export starts working for the first time**.
|
||||||
|
|
||||||
|
The pcbnew↔service API is **two functions**:
|
||||||
|
|
||||||
|
```
|
||||||
|
pcbnew.wasm (no OCC) occ_service.wasm (worker, lazy)
|
||||||
|
Export dialog OK ─► occExport(boardSexpr, paramsJson) ──► parse + EXPORTER_STEP ─► bytes ─► downloadBytes()
|
||||||
|
oce3d_Load shadow ─► occLoadModel(fileBytes, ext) ──────► OCC parse + tessellate ─► scenegraph-cache bytes ─► S3D::ReadCache
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The two OCC consumers today
|
||||||
|
|
||||||
|
OCC is reached by exactly two subsystems (grep-proven across `kicad/` for
|
||||||
|
`TopoDS`/`BRep`/`Standard_`/`STEPControl`/…). Everything else — 2D editor, GAL, DRC,
|
||||||
|
router, kimath, the 3D scene build and raytracer (board geometry is native earcut
|
||||||
|
tessellation), eeschema, gerbview — is OCC-clean.
|
||||||
|
|
||||||
|
### 1a. Component-model import — WORKING, and OCC is its primary parser
|
||||||
|
|
||||||
|
The 3D viewer's format loaders are static libraries in the wasm build (upstream loads
|
||||||
|
them as dlopen plugins; wasm has no dlopen, so they're linked in with per-TU symbol
|
||||||
|
renames and a registry):
|
||||||
|
|
||||||
|
- `plugins/3d/vrml` (`vrml3d_*`) — VRML/WRL/X3D, no OCC.
|
||||||
|
- `plugins/3d/oce` (`oce3d_*`) — **STEP/IGES via OCC** (`loadmodel.cpp`: STEP/IGES
|
||||||
|
readers → `BRepMesh_IncrementalMesh` → `SCENEGRAPH` triangles).
|
||||||
|
- Registry: `3d-viewer/3d_cache/pcbjam_static_3d_plugins.cpp` (EMSCRIPTEN-only TU) feeds
|
||||||
|
both into the plugin manager; `3d-viewer/CMakeLists.txt` links
|
||||||
|
`s3d_plugin_vrml s3d_plugin_oce` for EMSCRIPTEN.
|
||||||
|
|
||||||
|
Model **files** arrive lazily: when the filename resolver can't find a model,
|
||||||
|
`S3D_CACHE::load` (`3d_cache.cpp:156-161`) calls `PCBJAM_3D::EnsureModelFile`
|
||||||
|
(`3d-viewer/3d_cache/pcbjam_model_fetch.cpp`) — an **`EM_ASYNC_JS` asyncify suspend**
|
||||||
|
that asks the JS provider (`kicadLibs.request`, kind `model3d`;
|
||||||
|
`web/standalone/src/wasm/libs/models-{source,bridge}.ts`), which fetches
|
||||||
|
**R2 CDN → IndexedDB cache → MEMFS** and returns the absolute path. Once per unique
|
||||||
|
model, memoized, e2e-covered (`tests/kicad/3d-viewer-models.spec.ts`).
|
||||||
|
|
||||||
|
**Key consequence for this feature:** the published model library (kicad-packages3D
|
||||||
|
10.x) is **STEP-only** — `.wrl` asks are served `.step` bodies by the bridge's fallback
|
||||||
|
map (`models-bridge.ts:106-117`), and the served extension picks the plugin. So for
|
||||||
|
library models, **`oce`/OCC is the parser that runs**; VRML handles only project-local
|
||||||
|
legacy files. Any OCC removal must therefore carry this path along, gated by the
|
||||||
|
3d-viewer-models spec + screenshots staying green.
|
||||||
|
|
||||||
|
### 1b. STEP/3D export — BROKEN (its UI spawns a subprocess)
|
||||||
|
|
||||||
|
`DIALOG_EXPORT_STEP` (File → Export → STEP/GLB…) does not run the exporter in-process,
|
||||||
|
even on desktop. On Export (`pcbnew/dialogs/dialog_export_step.cpp:548-708`) it locates
|
||||||
|
the **`kicad-cli`** binary, builds a command string from the dialog controls
|
||||||
|
(`… pcb export step --no-dnp --subst-models --output …`), and spawns it via `wxExecute`;
|
||||||
|
the child process maps the flags to `JOB_EXPORT_PCB_3D` and runs
|
||||||
|
`PCBNEW_JOBS_HANDLER::JobExportStep` (`pcbnew_jobs_handler.cpp:532`, `EXPORTER_STEP`
|
||||||
|
ctor at `:648`) — OCC executes in the child. In the browser this fails twice: we ship
|
||||||
|
no kicad-cli, and wasm has no processes (`wxExecute` needs fork/exec). The exporter
|
||||||
|
cluster (`exporters/step/*` + `exporters/u3d/*`, whose only external caller is that
|
||||||
|
jobs handler) is linked but unreachable from any UI path.
|
||||||
|
|
||||||
|
**Scale note:** in time, both consumers are batch — once per unique model at scene
|
||||||
|
build, once per export click. Inside, they make thousands of fine-grained OCC calls
|
||||||
|
(`step_pcb_model.cpp`, `loadmodel.cpp`) — which is why the split boundary is files/bytes
|
||||||
|
handed to two entry points, never OCC's own API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture: two RPC functions, separate instance, Web Worker
|
||||||
|
|
||||||
|
Every form of Emscripten **dynamic linking is RED** here (each constraint breaks it
|
||||||
|
independently): dlopen can't rewind through asyncify (emscripten #13049), pthread
|
||||||
|
workers re-compile side modules (#17078), EH-across-boundary is fragile (#22285),
|
||||||
|
`wasm-split` needs `PROXY_TO_PTHREAD` (GUI is on our main thread). Full analysis:
|
||||||
|
`docs/features/perf/bundle-size.md §4` (bundle-size branch).
|
||||||
|
|
||||||
|
A **separate emscripten instance in a dedicated Worker** sidesteps all of it — own
|
||||||
|
`Module`, own memory, own runtime; data crosses by copying. For export this mirrors
|
||||||
|
what desktop already does (kicad-cli child process ↔ worker; flags ↔ params JSON; file
|
||||||
|
on disk ↔ bytes back). For import it relocates where the parse executes; the delivery
|
||||||
|
pipeline (CDN→IDB→MEMFS) and everything downstream of `SCENEGRAPH` are untouched.
|
||||||
|
|
||||||
|
- **Transport = postMessage + transferable `ArrayBuffer`s** (move semantics, zero-copy).
|
||||||
|
The only real copies are into/out of each wasm heap — fundamental (a module addresses
|
||||||
|
only its own linear memory), ms-scale on MB payloads vs seconds-scale OCC work.
|
||||||
|
SharedArrayBuffer views were considered and rejected: both heaps are already SABs
|
||||||
|
(`-pthread`), but direct sharing grants the worker write access to the editor heap and
|
||||||
|
couples us to cross-heap pointer lifetime + shared-memory-growth semantics across 3
|
||||||
|
engines, for no measurable win. Cancel = `worker.terminate()` + re-boot.
|
||||||
|
- **Export result bytes never enter pcbnew's heap**: worker → provider → Blob →
|
||||||
|
`downloadBytes()` (`web/standalone/src/lib/download.ts:6`); C++ receives a small
|
||||||
|
status/report JSON. Import results (scenegraph-cache bytes) do enter — `S3D::ReadCache`
|
||||||
|
consumes them there.
|
||||||
|
- Neither OCC job suspends internally (no `emscripten_sleep`/modal/`PROGRESS_REPORTER`
|
||||||
|
in either path), so the service builds **`-sASYNCIFY=0`** — no ~2× asyncify tax.
|
||||||
|
External precedent: [andymai/occt-wasm](https://github.com/andymai/occt-wasm) (OCCT in
|
||||||
|
a Worker, `-fwasm-exceptions`, no asyncify → 20.8 MB / ~4 MB brotli);
|
||||||
|
[kovacsv/occt-import-js](https://github.com/kovacsv/occt-import-js) (STEP→mesh).
|
||||||
|
|
||||||
|
**Latency reality:** the 2D editor (the common session) never fetches the service. A 3D
|
||||||
|
view of a board with library models effectively always will (the 10.x library is
|
||||||
|
STEP-only) — one fetch (~5–9 MB brotli), then IDB-cached like the models themselves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Design decisions
|
||||||
|
|
||||||
|
- **Callee-shadowing, not caller-`#ifdef`s.** Both features reduce to one shadowable
|
||||||
|
function each, and the wasm build swaps in replacement definitions (the established
|
||||||
|
`PCBNEW_WASM_STUBS` pattern):
|
||||||
|
- `EXPORTER_STEP` ctor/dtor/`Export()` — shadow TU in our repo (`wasm/stubs/`);
|
||||||
|
`pcbnew_jobs_handler.cpp` and every other caller compile untouched and keep working.
|
||||||
|
- `oce3d_Load` (+ the `oce3d_*` metadata getters the registry needs — mirroring
|
||||||
|
`oce.cpp`'s extension/filter answers) — shadow TU replaces linking `s3d_plugin_oce`;
|
||||||
|
the registry, plugin manager, `S3D_CACHE`, and scene build compile untouched.
|
||||||
|
- **UI stays exactly as desktop.** Dialog, menu, actions untouched. The **single KiCad
|
||||||
|
source `#ifdef`** in the whole feature is in `dialog_export_step.cpp` at the
|
||||||
|
`wxExecute` spawn site: read the same controls, fill `EXPORTER_STEP_PARAMS`, call
|
||||||
|
`EXPORTER_STEP(…).Export()` directly — the shadow does the rest. (There is no function
|
||||||
|
to shadow there; the divergence *is* the process spawn.)
|
||||||
|
- **3D viewer parity is a hard gate.** Same parser code (`loadmodel.cpp`) compiled into
|
||||||
|
the service; results return via KiCad's own scenegraph serialization
|
||||||
|
(`S3D::WriteCache`/`ReadCache`, `plugins/3dapi/ifsg_api.h` — the on-disk model-cache
|
||||||
|
format). `3d-viewer-models.spec.ts` + the screenshot gate must stay green.
|
||||||
|
- **Params JSON = the official job JSON.** `JOB_EXPORT_PCB_3D` registers every field as
|
||||||
|
a serializable `JOB_PARAM` (`common/jobs/job_export_pcb_3d.cpp:94-149`); the bridge
|
||||||
|
uses KiCad's own (de)serialization, no invented schema. Every dialog option is honored
|
||||||
|
(formats STEP/GLB/XAO/BREP/PLY/STL + all flags).
|
||||||
|
- **Export component models = desktop parity in v1.** Models embedded in the board file
|
||||||
|
travel inside the sexpr and work; disk-path references the worker can't see hit
|
||||||
|
`EXPORTER_STEP`'s missing-file warn+skip, like desktop with a broken path. The
|
||||||
|
CDN model pipeline (§1a) is the natural upgrade to full component embedding —
|
||||||
|
follow-on (§7).
|
||||||
|
- **Alternative rejected — wasm kicad-cli in a worker:** same architecture, but stock
|
||||||
|
kicad-cli links both kifaces + all job handlers (~100+ MB lazy module); trimming it is
|
||||||
|
more fork surgery than the one dialog `#ifdef`; emulating the spawn (argv string
|
||||||
|
parsing, `wxProcess` event plumbing) outweighs it; and the real glue (board in, bytes
|
||||||
|
out) is needed either way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation
|
||||||
|
|
||||||
|
### Stage 1 — `occ_service` target (build + entry, no pcbnew changes)
|
||||||
|
|
||||||
|
Template = `sym_convert`, the proven gated headless `-sASYNCIFY=0` KiCad module
|
||||||
|
(`kicad/eeschema/CMakeLists.txt:796-830`), adapted from run-once Node CLI to persistent
|
||||||
|
worker embind:
|
||||||
|
|
||||||
|
- Target lives in the superproject: `wasm/occ-service/CMakeLists.txt`, hooked from the
|
||||||
|
kicad fork's top-level CMakeLists via
|
||||||
|
`add_subdirectory( ${KICAD_WASM_LAYER}/occ-service )` under
|
||||||
|
`option( KICAD_OCC_SERVICE_WASM … OFF )` (the `wasm/editor` pattern; kicad keeps only
|
||||||
|
the option + hook + the exporter-source `CACHE INTERNAL` export). Linked like
|
||||||
|
`sym_convert`: full pcbnew kiface libraries + `s3d_plugin_oce` + `kicad_3dsg` +
|
||||||
|
`${OCC_LIBRARIES}` + `LINKER:--allow-multiple-definition`, in its own configured tree.
|
||||||
|
- **Size mechanism = link-time `-Oz` whole-module DCE** (as `sym_convert` documents,
|
||||||
|
`eeschema/CMakeLists.txt:825-827`): the only roots are the embind entry + runtime, so
|
||||||
|
editor/GAL/tool code pulled via the kiface objects is stripped. Fallback if fat:
|
||||||
|
`add_library( … STATIC $<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
2
kicad
|
|
@ -1 +1 @@
|
||||||
Subproject commit f5df5977e0977063da6f6b66cf69799f5c90159a
|
Subproject commit 11a52e4c12be7811b1df585749c4cfa874cddbc7
|
||||||
|
|
@ -29,6 +29,13 @@ export SWIG_MIN="4.0"
|
||||||
|
|
||||||
# Recommended versions for WASM build
|
# Recommended versions for WASM build
|
||||||
export OCC_VERSION="7.8.0"
|
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 ZSTD_VERSION="1.5.5"
|
||||||
export FREETYPE_VERSION="2.13.2"
|
export FREETYPE_VERSION="2.13.2"
|
||||||
export HARFBUZZ_VERSION="8.3.0"
|
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 CAIRO_URL="https://cairographics.org/releases/cairo-${CAIRO_VERSION}.tar.xz"
|
||||||
export PIXMAN_URL="https://cairographics.org/releases/pixman-${PIXMAN_VERSION}.tar.gz"
|
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 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"
|
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)
|
# SHA256 checksums (to be filled in after first successful download)
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,10 @@ function main() {
|
||||||
env.VITE_REPO_URL = a.repo;
|
env.VITE_REPO_URL = a.repo;
|
||||||
|
|
||||||
const viteArgs = ["--dir", "web", "--filter", "@pcbjam/standalone", "dev"];
|
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("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})` : ""}`);
|
console.log(` VITE_LIBS_SOURCE=${env.VITE_LIBS_SOURCE}${env.VITE_LIBS_MANIFEST_URL ? ` (${env.VITE_LIBS_MANIFEST_URL})` : ""}`);
|
||||||
|
|
|
||||||
|
|
@ -34,16 +34,23 @@ import {
|
||||||
// bundle, booted with a runtime --frame flag (editor-unification Part 2) — none of
|
// 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
|
// them publishes anything of its own. The frontend maps tools onto bundles via
|
||||||
// TOOL_BUNDLE. sym_convert is a node CLI, not served.
|
// 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 = [
|
const TOOLS = [
|
||||||
"kicad_editor",
|
"kicad_editor",
|
||||||
"pl_editor",
|
"pl_editor",
|
||||||
"gerbview",
|
"gerbview",
|
||||||
"calculator",
|
"calculator",
|
||||||
|
"occ_service",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Files that make up a self-contained tool bundle. `<tool>` is substituted.
|
// Files that make up a self-contained tool bundle. `<tool>` is substituted.
|
||||||
const SHARED_FILES = ["wx.js", "wx-dom.js", "images.tar.gz"];
|
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
|
// Per-file HTTP rules (see the 0001 header matrix). `compress` is whether the
|
||||||
// publisher compresses + sets Content-Encoding; images.tar.gz must stay RAW
|
// publisher compresses + sets Content-Encoding; images.tar.gz must stay RAW
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,22 @@ if [ ! -d "${OCC_DIR}" ]; then
|
||||||
rm "opencascade-${OCC_VERSION}.tar.gz"
|
rm "opencascade-${OCC_VERSION}.tar.gz"
|
||||||
fi
|
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_info "Building OpenCASCADE ${OCC_VERSION} for WASM..."
|
||||||
log_warn "This is a large library and may take a while..."
|
log_warn "This is a large library and may take a while..."
|
||||||
|
|
||||||
|
|
@ -100,7 +116,8 @@ emcmake cmake "${OCC_DIR}" \
|
||||||
-DUSE_GLES2=OFF \
|
-DUSE_GLES2=OFF \
|
||||||
-DUSE_OPENGL=OFF \
|
-DUSE_OPENGL=OFF \
|
||||||
-DUSE_D3D=OFF \
|
-DUSE_D3D=OFF \
|
||||||
-DUSE_RAPIDJSON=OFF \
|
-DUSE_RAPIDJSON=ON \
|
||||||
|
-D3RDPARTY_RAPIDJSON_DIR="${RAPIDJSON_DIR}" \
|
||||||
-DUSE_DRACO=OFF \
|
-DUSE_DRACO=OFF \
|
||||||
-DBUILD_DOC_Overview=OFF \
|
-DBUILD_DOC_Overview=OFF \
|
||||||
-DINSTALL_SAMPLES=OFF \
|
-DINSTALL_SAMPLES=OFF \
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,16 @@ case "$APP_NAME" in
|
||||||
KICAD_TARGET="sym_convert"
|
KICAD_TARGET="sym_convert"
|
||||||
KICAD_SUBDIR="eeschema"
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
@ -84,6 +92,10 @@ esac
|
||||||
# embind symbols (kicadCollabOnSave et al.) — reuse eeschema's embind object.
|
# embind symbols (kicadCollabOnSave et al.) — reuse eeschema's embind object.
|
||||||
case "$APP_NAME" in
|
case "$APP_NAME" in
|
||||||
sym_convert) EMBIND_APP="eeschema" ;;
|
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" ;;
|
*) EMBIND_APP="$APP_NAME" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|
@ -95,6 +107,9 @@ esac
|
||||||
case "$APP_NAME" in
|
case "$APP_NAME" in
|
||||||
sym_convert) STUB_APP="eeschema" ;;
|
sym_convert) STUB_APP="eeschema" ;;
|
||||||
kicad_editor) STUB_APP="pcbnew" ;;
|
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" ;;
|
*) STUB_APP="$APP_NAME" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|
@ -337,11 +352,12 @@ fi
|
||||||
EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt"
|
EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt"
|
||||||
EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize"
|
EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize"
|
||||||
|
|
||||||
if [ "${APP_NAME}" = "sym_convert" ]; then
|
if [ "${APP_NAME}" = "sym_convert" ] || [ "${APP_NAME}" = "occ_service" ]; then
|
||||||
# Use the real tools so the converter is fully finalized inside the container.
|
# 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_WASM_OPT}.real" ] && cp "${EMSDK_WASM_OPT}.real" "${EMSDK_WASM_OPT}"
|
||||||
[ -f "${EMSDK_FINALIZE}.real" ] && cp "${EMSDK_FINALIZE}.real" "${EMSDK_FINALIZE}"
|
[ -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
|
else
|
||||||
if [ -f "${EMSDK_WASM_OPT}" ] && [ ! -f "${EMSDK_WASM_OPT}.real" ]; then
|
if [ -f "${EMSDK_WASM_OPT}" ] && [ ! -f "${EMSDK_WASM_OPT}.real" ]; then
|
||||||
log_info "Backing up real wasm-opt..."
|
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"
|
MERGED_EDITOR_CMAKE_FLAG="-DKICAD_WASM_MERGED_EDITOR=ON"
|
||||||
fi
|
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 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)
|
# 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
|
# 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} \
|
${CCACHE_OPTS} \
|
||||||
${SYM_CONVERTER_CMAKE_FLAG} \
|
${SYM_CONVERTER_CMAKE_FLAG} \
|
||||||
${MERGED_EDITOR_CMAKE_FLAG} \
|
${MERGED_EDITOR_CMAKE_FLAG} \
|
||||||
|
${OCC_SERVICE_CMAKE_FLAG} \
|
||||||
-DCMAKE_BUILD_TYPE=${BUILD_TYPE} \
|
-DCMAKE_BUILD_TYPE=${BUILD_TYPE} \
|
||||||
-DCMAKE_INSTALL_PREFIX="${SYSROOT}" \
|
-DCMAKE_INSTALL_PREFIX="${SYSROOT}" \
|
||||||
-DCMAKE_MODULE_PATH="${WASM_LAYER}/cmake" \
|
-DCMAKE_MODULE_PATH="${WASM_LAYER}/cmake" \
|
||||||
|
|
@ -582,7 +606,16 @@ elif [ -f "${EMBIND_SRC}" ]; then
|
||||||
emmake make -j${JOBS} pcbcommon
|
emmake make -j${JOBS} pcbcommon
|
||||||
fi
|
fi
|
||||||
log_info "Compiling Embind bindings (${APP_NAME})..."
|
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
|
else
|
||||||
log_info "No embind source for ${APP_NAME} (expected at ${EMBIND_SRC}); using empty placeholder"
|
log_info "No embind source for ${APP_NAME} (expected at ${EMBIND_SRC}); using empty placeholder"
|
||||||
EMPTY_C="${STUBS_BUILD}/${APP_NAME}_embind_empty.c"
|
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)
|
# Step 8.1: Build bitmap resources (images.tar.gz)
|
||||||
# This creates the icon archive that KiCad loads at runtime. The headless
|
# This creates the icon archive that KiCad loads at runtime. The headless
|
||||||
# converter has no GUI/icons, so skip it.
|
# converter/service targets have no GUI/icons, so skip it.
|
||||||
if [ "${APP_NAME}" != "sym_convert" ]; then
|
if [ "${APP_NAME}" != "sym_convert" ] && [ "${APP_NAME}" != "occ_service" ]; then
|
||||||
kw_stage kicad-bitmaps
|
kw_stage kicad-bitmaps
|
||||||
log_info "Building bitmap resources..."
|
log_info "Building bitmap resources..."
|
||||||
emmake make bitmap_archive_build
|
emmake make bitmap_archive_build
|
||||||
|
|
|
||||||
8
scripts/kicad/build-occ_service.sh
Executable file
8
scripts/kicad/build-occ_service.sh
Executable 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
105
tests/fixtures/demo/demo.kicad_prl
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -183,6 +183,8 @@ test.describe('3D viewer component models', () => {
|
||||||
fs.readFileSync(fixtureAbs).toString('base64'),
|
fs.readFileSync(fixtureAbs).toString('base64'),
|
||||||
);
|
);
|
||||||
await installModelProviderStub(page);
|
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);
|
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);
|
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 --------------------------------------------------
|
// --- render assertion --------------------------------------------------
|
||||||
const render = await page.evaluate(() => {
|
const render = await page.evaluate(() => {
|
||||||
const list = document.querySelectorAll('canvas[id^="glcanvas-"]');
|
const list = document.querySelectorAll('canvas[id^="glcanvas-"]');
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,22 @@
|
||||||
import { test as base } from '@playwright/test';
|
import { test as base } from '@playwright/test';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { setupTestLogger, writeTestLogs, TestLogger, MAIN_CANVAS, waitForApp, tryLoadApp, getCanvasBox, KICAD_LOGS_DIR, getTestFileName } from '../e2e/utils/test-utils';
|
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
|
// Extend base test with automatic logging
|
||||||
export const test = base.extend<{
|
export const test = base.extend<{
|
||||||
testLogger: TestLogger;
|
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) => {
|
testLogger: async ({ page }, use, testInfo) => {
|
||||||
// Build test name from describe block + test title
|
// Build test name from describe block + test title
|
||||||
const testName = testInfo.titlePath.join(' - ');
|
const testName = testInfo.titlePath.join(' - ');
|
||||||
|
|
|
||||||
147
tests/kicad/occ-export.spec.ts
Normal file
147
tests/kicad/occ-export.spec.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
110
tests/kicad/occ-probe.spec.ts
Normal file
110
tests/kicad/occ-probe.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
tests/kicad/utils/occ-service.ts
Normal file
102
tests/kicad/utils/occ-service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -111,6 +111,11 @@ const BIG_MODULE_SPECS = [
|
||||||
"**/symbol_editor.spec.ts",
|
"**/symbol_editor.spec.ts",
|
||||||
// Cross-face probe: schematic session lazily starts the PCB kiface (Preferences).
|
// Cross-face probe: schematic session lazily starts the PCB kiface (Preferences).
|
||||||
"**/xface-probe.spec.ts",
|
"**/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
|
// Runtime-perf specs run ONLY on the Chromium 'perf' project below: they need
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ copy_app kicad_editor && found_any=1
|
||||||
copy_app calculator && found_any=1
|
copy_app calculator && found_any=1
|
||||||
copy_app pl_editor && found_any=1
|
copy_app pl_editor && found_any=1
|
||||||
copy_app gerbview && 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
|
if [ "$found_any" -eq 0 ]; then
|
||||||
echo "Error: no kicad_editor/calculator/pl_editor/gerbview artifacts found in output/ or docker volume" >&2
|
echo "Error: no kicad_editor/calculator/pl_editor/gerbview artifacts found in output/ or docker volume" >&2
|
||||||
|
|
|
||||||
71
wasm/occ-service/CMakeLists.txt
Normal file
71
wasm/occ-service/CMakeLists.txt
Normal 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" )
|
||||||
391
wasm/occ-service/occ_service_main.cpp
Normal file
391
wasm/occ-service/occ_service_main.cpp
Normal 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;
|
||||||
|
}
|
||||||
117
wasm/occ-service/occ_service_pre.js
Normal file
117
wasm/occ-service/occ_service_pre.js
Normal 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 );
|
||||||
183
wasm/stubs/exporter_step_stub.cpp
Normal file
183
wasm/stubs/exporter_step_stub.cpp
Normal 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;
|
||||||
|
}
|
||||||
225
wasm/stubs/oce_plugin_stub.cpp
Normal file
225
wasm/stubs/oce_plugin_stub.cpp
Normal 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"
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { installModel3dHandler } from "./libs/models-bridge";
|
import { installModel3dHandler } from "./libs/models-bridge";
|
||||||
import type { Model3dSource } from "./libs/models-source";
|
import type { Model3dSource } from "./libs/models-source";
|
||||||
|
import { installOccService } from "./occ-service";
|
||||||
import {
|
import {
|
||||||
buildFpLibTable,
|
buildFpLibTable,
|
||||||
buildSymLibTable,
|
buildSymLibTable,
|
||||||
|
|
@ -240,6 +241,15 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
||||||
// Which lib table this tool consumes: symbol → sym-lib-table, footprint →
|
// Which lib table this tool consumes: symbol → sym-lib-table, footprint →
|
||||||
// fp-lib-table. The same lib source feeds whichever table the tool reads.
|
// fp-lib-table. The same lib source feeds whichever table the tool reads.
|
||||||
const libKind = TOOL_LIB_KIND[tool];
|
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) {
|
if (libsSource && libKind) {
|
||||||
installLibsProvider(libsSource, log);
|
installLibsProvider(libsSource, log);
|
||||||
// 3D models ride the same provider (kind "model3d"): the C++ ensure fallback
|
// 3D models ride the same provider (kind "model3d"): the C++ ensure fallback
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,11 @@ export type Bundle =
|
||||||
| "kicad_editor"
|
| "kicad_editor"
|
||||||
| "calculator"
|
| "calculator"
|
||||||
| "pl_editor"
|
| "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
|
* Which deployed WASM bundle actually backs each tool. The four editors share the
|
||||||
|
|
|
||||||
162
web/standalone/src/wasm/occ-service.ts
Normal file
162
web/standalone/src/wasm/occ-service.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { downloadBytes } from "@/lib/download";
|
||||||
|
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
|
||||||
|
// also injected by the e2e harness stub (tests/kicad/utils/occ-service.ts).
|
||||||
|
import occWorkerSource from "./occ-worker.js?raw";
|
||||||
|
import { resolveWasmBase } from "./wasm-assets";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `globalThis.occService` — the lazy OpenCASCADE 3D service provider.
|
||||||
|
*
|
||||||
|
* pcbnew.wasm carries no OCC (docs/features/occ-split/): its two OCC-backed
|
||||||
|
* paths suspend via EM_ASYNC_JS bridges (wasm/stubs/{exporter_step,oce_plugin}_stub.cpp)
|
||||||
|
* and land here:
|
||||||
|
* { kind: "export", board, jobJson, fileName } → STEP/GLB/… export; the
|
||||||
|
* resulting bytes are delivered straight to the browser download path and
|
||||||
|
* only { ok, report } goes back to the editor.
|
||||||
|
* { kind: "loadModel", bytes, ext } → STEP/IGES parse + tessellation; returns
|
||||||
|
* the SCENEGRAPH serialized in KiCad's binary cache format, which the
|
||||||
|
* C++ stub rebuilds with S3D::ReadCache.
|
||||||
|
*
|
||||||
|
* The occ_service module (own emscripten instance, `-sASYNCIFY=0`) boots in a
|
||||||
|
* dedicated Worker on the FIRST request — a pcbnew session that never exports
|
||||||
|
* and never views STEP models never fetches it. Same cross-origin worker rules
|
||||||
|
* as the pthread workers (boot.ts): a same-origin blob wrapper importScripts
|
||||||
|
* the (possibly CDN) glue; the module's own pthread children reuse the trick
|
||||||
|
* via mainScriptUrlOrBlob.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface OccExportRequest {
|
||||||
|
kind: "export";
|
||||||
|
board: Uint8Array;
|
||||||
|
jobJson: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OccLoadModelRequest {
|
||||||
|
kind: "loadModel";
|
||||||
|
bytes: Uint8Array;
|
||||||
|
ext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OccRequest = OccExportRequest | OccLoadModelRequest;
|
||||||
|
|
||||||
|
export interface OccResponse {
|
||||||
|
ok: boolean;
|
||||||
|
report?: string;
|
||||||
|
fileName?: string;
|
||||||
|
bytes?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var occService: { request(req: OccRequest): Promise<OccResponse> } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the worker blob: a one-line prelude carrying the glue URL, then the
|
||||||
|
* shared wrapper source (occ-worker.js), which reads `self.OCC_GLUE_URL`.
|
||||||
|
*/
|
||||||
|
export function occWorkerBlobParts(glueHref: string): string[] {
|
||||||
|
return [
|
||||||
|
`self.OCC_GLUE_URL = ${JSON.stringify(glueHref)};\n`,
|
||||||
|
occWorkerSource,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installOccService(log: (msg: string) => void): void {
|
||||||
|
if (globalThis.occService) return;
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
const pending = new Map<number, (res: OccResponse) => void>();
|
||||||
|
let workerP: Promise<Worker> | null = null;
|
||||||
|
|
||||||
|
const ensureWorker = (): Promise<Worker> => {
|
||||||
|
if (!workerP) {
|
||||||
|
workerP = (async () => {
|
||||||
|
// occ_service is a Bundle (a published delivery artifact), not a Tool —
|
||||||
|
// resolveWasmBase accepts either and looks the bundle up directly.
|
||||||
|
const base = await resolveWasmBase("occ_service");
|
||||||
|
const glue = new URL(`${base}/occ_service.js`, window.location.href).href;
|
||||||
|
log(`[occ] booting occ_service from ${base}`);
|
||||||
|
|
||||||
|
const worker = new Worker(
|
||||||
|
URL.createObjectURL(
|
||||||
|
new Blob(occWorkerBlobParts(glue), { type: "text/javascript" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.onmessage = (e) => {
|
||||||
|
const { id, res } = e.data ?? {};
|
||||||
|
if (typeof id !== "number") return;
|
||||||
|
const resolve = pending.get(id);
|
||||||
|
if (resolve) {
|
||||||
|
pending.delete(id);
|
||||||
|
resolve(res as OccResponse);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onFirst = (e: MessageEvent) => {
|
||||||
|
if (e.data?.ready) {
|
||||||
|
worker.removeEventListener("message", onFirst);
|
||||||
|
resolve();
|
||||||
|
} else if (e.data?.bootError) {
|
||||||
|
reject(new Error(e.data.bootError));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
worker.addEventListener("message", onFirst);
|
||||||
|
worker.onerror = (e) => reject(new Error(`occ_service worker: ${e.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
log("[occ] occ_service ready");
|
||||||
|
return worker;
|
||||||
|
})().catch((e) => {
|
||||||
|
workerP = null; // a failed boot must stay retryable
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return workerP;
|
||||||
|
};
|
||||||
|
|
||||||
|
const post = (worker: Worker, req: OccRequest): Promise<OccResponse> => {
|
||||||
|
const id = nextId++;
|
||||||
|
const transfer: Transferable[] =
|
||||||
|
req.kind === "export" ? [req.board.buffer] : [req.bytes.buffer];
|
||||||
|
return new Promise<OccResponse>((resolve) => {
|
||||||
|
pending.set(id, resolve);
|
||||||
|
worker.postMessage({ id, req }, transfer);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = async (req: OccRequest): Promise<OccResponse> => {
|
||||||
|
let worker: Worker;
|
||||||
|
try {
|
||||||
|
worker = await ensureWorker();
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, report: `occ_service unavailable: ${e}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await post(worker, req);
|
||||||
|
|
||||||
|
if (req.kind === "export") {
|
||||||
|
// Deliver the export straight to the user; the editor gets status only
|
||||||
|
// (the bytes never enter pcbnew's heap).
|
||||||
|
if (res.ok && res.bytes?.length) {
|
||||||
|
// The dialog can hand over an extension-only name (".step" — its
|
||||||
|
// default filename field is empty in the browser); Chromium mangles a
|
||||||
|
// bare dotfile download to "step.txt", so give it a real stem while
|
||||||
|
// keeping the format extension the user picked.
|
||||||
|
const raw = req.fileName || res.fileName || "";
|
||||||
|
const name = !raw || raw.startsWith(".") ? `export${raw || ".step"}` : raw;
|
||||||
|
downloadBytes(name, res.bytes);
|
||||||
|
log(`[occ] export downloaded: ${name} (${res.bytes.length} bytes)`);
|
||||||
|
}
|
||||||
|
return { ok: res.ok, report: res.report, fileName: res.fileName };
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.occService = { request };
|
||||||
|
log("[occ] occ_service provider installed (lazy)");
|
||||||
|
}
|
||||||
68
web/standalone/src/wasm/occ-worker.js
Normal file
68
web/standalone/src/wasm/occ-worker.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/*
|
||||||
|
* Worker-side wrapper for the occ_service MODULARIZE module — the SINGLE
|
||||||
|
* source of truth for the worker boot, shared verbatim by:
|
||||||
|
* - the standalone app provider (occ-service.ts, vite `?raw` import), and
|
||||||
|
* - the e2e harness stub (tests/kicad/utils/occ-service.ts, read off disk).
|
||||||
|
*
|
||||||
|
* The host prepends one prelude line to the blob before this file's content:
|
||||||
|
* self.OCC_GLUE_URL = "<absolute URL of occ_service.js>";
|
||||||
|
*
|
||||||
|
* Protocol: the host posts { id, req } (req = { kind: "export" | "loadModel",
|
||||||
|
* … }); the worker answers { id, res } with the result bytes transferred. A
|
||||||
|
* one-shot { ready: true } / { bootError } message reports module boot.
|
||||||
|
*/
|
||||||
|
const GLUE = self.OCC_GLUE_URL;
|
||||||
|
|
||||||
|
self.addEventListener("error", (e) =>
|
||||||
|
console.error("[occ_service] worker error:", e.message, e.filename, e.lineno));
|
||||||
|
self.addEventListener("unhandledrejection", (e) =>
|
||||||
|
console.error("[occ_service] unhandled rejection:", e.reason));
|
||||||
|
|
||||||
|
importScripts(GLUE);
|
||||||
|
|
||||||
|
// wx boot logs a "Debug:" line per image handler etc. — pure noise in the page
|
||||||
|
// console (and in the captured test logs); real problems don't carry the marker.
|
||||||
|
const noise = (s) => /(^|: )Debug: /.test(String(s));
|
||||||
|
|
||||||
|
const modP = OccService({
|
||||||
|
onAbort: (what) => console.error("[occ_service] ABORT:", what),
|
||||||
|
// The module's own pthread children must boot from a same-origin script even
|
||||||
|
// when the glue lives on a CDN — same blob-importScripts trick as boot.ts.
|
||||||
|
mainScriptUrlOrBlob: new Blob(
|
||||||
|
["importScripts(" + JSON.stringify(GLUE) + ");"],
|
||||||
|
{ type: "text/javascript" }),
|
||||||
|
// A blob: worker has no http base URL — every asset path must be absolutized
|
||||||
|
// against the glue's own URL or the .wasm fetch dies with "Failed to parse
|
||||||
|
// URL" (root-relative bases like "/wasm" don't resolve).
|
||||||
|
locateFile: (f) => new URL(f, GLUE).href,
|
||||||
|
print: (s) => { if (!noise(s)) console.log("[occ_service]", s); },
|
||||||
|
printErr: (s) => { if (!noise(s)) console.warn("[occ_service]", s); },
|
||||||
|
});
|
||||||
|
|
||||||
|
modP.then(() => postMessage({ ready: true }),
|
||||||
|
(e) => postMessage({ bootError: String(e) }));
|
||||||
|
|
||||||
|
onmessage = async (e) => {
|
||||||
|
const { id, req } = e.data;
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
const mod = await modP;
|
||||||
|
if (req.kind === "export") {
|
||||||
|
const board = new TextDecoder().decode(req.board);
|
||||||
|
res = mod.occExport(board, req.jobJson);
|
||||||
|
} else if (req.kind === "loadModel") {
|
||||||
|
res = mod.occLoadModel(req.bytes, req.ext);
|
||||||
|
} else {
|
||||||
|
res = { ok: false, report: "occ_service: unknown request kind " + req.kind };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res = { ok: false, report: "occ_service worker: " + err };
|
||||||
|
}
|
||||||
|
const out = {
|
||||||
|
ok: !!(res && res.ok),
|
||||||
|
report: res && res.report,
|
||||||
|
fileName: res && res.fileName,
|
||||||
|
bytes: res && res.bytes,
|
||||||
|
};
|
||||||
|
postMessage({ id, res: out }, out.bytes ? [out.bytes.buffer] : []);
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Tool } from "@pcbjam/shared";
|
import type { Tool } from "@pcbjam/shared";
|
||||||
import { WASM_MANIFEST_FILE, WASM_ROOT } from "@/lib/config";
|
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.
|
* 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`.
|
* - manifest ⇒ `WASM_ROOT/<tool>/<ver>` from `manifest-<appTag>.json`.
|
||||||
*/
|
*/
|
||||||
export async function resolveWasmBase(
|
export async function resolveWasmBase(
|
||||||
tool: Tool,
|
tool: Tool | Bundle,
|
||||||
override?: string,
|
override?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (override) return override.replace(/\/+$/, "");
|
if (override) return override.replace(/\/+$/, "");
|
||||||
if (!WASM_MANIFEST_FILE) return WASM_ROOT; // flat (dev / same-origin)
|
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);
|
// 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
|
// resolve the folder/version of the bundle, not the logical tool (bundles are
|
||||||
// the only thing published/listed in the manifest).
|
// the only thing published/listed in the manifest). A caller may also name a
|
||||||
const bundle = TOOL_BUNDLE[tool];
|
// 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 manifest = await loadManifest();
|
||||||
const ver = manifest.tools?.[bundle];
|
const ver = manifest.tools?.[bundle];
|
||||||
if (!ver) {
|
if (!ver) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue