feat(wasm): unify editor builds — footprint/symbol editors via runtime --frame

The footprint and symbol editors are no longer separate WASM bundles: the frontend loads the parent pcbnew/eeschema bundle and passes --frame=fpedit / --frame=symedit (TOOL_BUNDLE + TOOL_FRAME -> Module.arguments in boot). Drops the two duplicate build+deploy targets and their wrapper scripts + vestigial embind; adds low-level harnesses (footprint_editor.html, symbol_editor.html) and a runtime-frame spec. Bumps the kicad submodule to the runtime --frame launcher.

The frame-runtime spec is listed in PCBNEW_FAMILY_SPECS so CI routes it to the chromium-ci (V8) project — its footprint case boots the pcbnew module, which OOMs SpiderMonkey on x86 CI. Includes the editor-unification dossier (research docs 01-04 + the as-built implementation record 05).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Istvan Matejcsok 2026-07-01 17:39:42 +02:00
commit c6716e6b60
22 changed files with 1066 additions and 103 deletions

View file

@ -0,0 +1,142 @@
# 04 — Part 2: fuse the editors into one app
> **Verdict: feasible and well-bounded, but moderate work — and a product decision, not a dedup
> win.** Merging "the four editors" means linking the **two engines** (pcbnew kiface + eeschema
> kiface) into one WASM image and picking the view at runtime. The collision surface is small
> and concentrated; the real cost is bundle size. Prereqs:
> [`02-kiface-architecture.md`](02-kiface-architecture.md), [`03`](03-part1-library-editor-unification.md).
## What "the four" actually is
Board + Footprint + Schematic + Symbol = `FACE_PCB` (pcbnew kiface) + `FACE_SCH` (eeschema
kiface) = **two engines in one static image**. Extending to all 7 GUI tools adds `FACE_GERBVIEW`,
`FACE_PL_EDITOR`, `FACE_PCB_CALCULATOR` = 5 kifaces — same mechanism, more faces.
This is fundamentally different from Part 1. Part 1 is "one engine, many frames" — already
supported, you just expose the selector. Part 2 is "many engines, one image," which has **no
existing template**: upstream gets multi-kiface only by loading DLLs at runtime, the WASM build
has no `dlopen`, the `kicad` project manager isn't built for WASM (`kicad/CMakeLists.txt:113`),
and the WASM `KIWAY` explicitly assumes exactly one kiface (`kiway.cpp:223-258`). So Part 2 is a
real porting task, not a flag flip.
## The symbol-collision surface (when two kifaces link into one image)
From most to least trouble:
### 1. `Kiface()` — the load-bearing item (moderate)
Each kiface defines a global accessor `KIFACE_BASE& Kiface()` (declared
`include/kiface_base.h:134`; defined `pcbnew.cpp:600`, `eeschema.cpp:452`,
`gerbview.cpp:152`, `pl_editor.cpp:141`, `pcb_calculator.cpp:76`). Linking two kifaces gives two
definitions of `Kiface()` — a duplicate symbol.
Worse, it is not just a name clash; it is a **semantic** one. `Kiface()` is called ~97 times
(pcbnew 48 calls / 21 files; eeschema 49 / 20 files), **including 5 files in shared `common/`**
that are compiled **once** into libkicommon and linked into every app:
- `common/eda_base_frame.cpp` (e.g. `:1274 return Kiface().KifaceSettings();`, `:1280`, `:1286`)
- `common/gestfich.cpp`
- `common/design_block_tree_model_adapter.cpp:49`
- `common/dialogs/dialog_color_picker.cpp`
- `common/tool/common_control.cpp`
That shared code calls `Kiface()` expecting "the one active editor's kiface" — but with two
kifaces linked, it can't statically know whether the active frame is PCB or schematic. So
`Kiface()` must become a **runtime dispatch on the active editor** (a "current active kiface"
pointer keyed off the active frame's `FACE_T`). It's mostly process/editor-level config —
`KifaceSettings()`, `KifaceSearch()`, `GetHelpFileName()`, `IsSingle()` — so a single dispatching
`Kiface()` backed by an active-editor pointer is feasible, but it is a behavioral change touching
~100 call sites including shared code. **This is what lifts Part 2 from "small" to "moderate."**
> ⚠️ **`--allow-multiple-definition` is not a fix here.** The static kiface link already passes
> `LINKER:--allow-multiple-definition` (see `../symbol-editor/0001-symbol-editor-port.md`). That
> would let two `Kiface()` definitions *link* — by silently taking the first — which means shared
> `common/` code would always resolve to (say) pcbnew's kiface even while the schematic editor is
> active. That is the bug, not the fix. `Kiface()` needs real per-active-editor dispatch.
### 2. `KIFACE_GETTER` / `KIFACE_1` (trivial)
Every kiface exports the same `extern "C"` getter — `KIFACE_GETTER` is a macro expanding to the
unmangled symbol `KIFACE_1` (`include/kiway.h:110-115`), defined identically in each kiface
(`pcbnew.cpp:605`, `eeschema.cpp:457`, …), all returning `&kiface`. Two in one image = duplicate
`KIFACE_1`.
Fix is mechanical: the macro is unconditional at `kiway.h:111`, so guard it with `#ifndef
KIFACE_GETTER` and pass a per-target `-DKIFACE_GETTER=<distinct>` (the same `COMPILE_DEFINITIONS`
machinery that already sets `TOP_FRAME`). The `extern "C"` declaration at `kiway.h:541/543`
follows the macro automatically. A combined launcher then declares both distinct externs and
registers each face:
```cpp
extern "C" KIFACE* pcbnew_kiface_getter(int*,int,PGM_BASE*);
extern "C" KIFACE* eeschema_kiface_getter(int*,int,PGM_BASE*);
Kiway.set_kiface( KIWAY::FACE_PCB, pcbnew_kiface_getter(&v, KIFACE_VERSION, this) );
Kiway.set_kiface( KIWAY::FACE_SCH, eeschema_kiface_getter(&v, KIFACE_VERSION, this) );
```
### 3. Relax the WASM "exactly one kiface" convention (small)
`kiway.cpp:223-258` enforces single-kiface registration by *convention*, not storage —
`set_kiface` already writes into a per-`FACE_T` array (`include/kiway.h:476-481`) and `KiFACE()`
reads per slot. Registering several faces is just calling `set_kiface` N times. Update the comment
and the single-getter assumption to match the combined launcher above.
### Non-issues (verified)
- **`Pgm()` / `program` singleton** (`common/pgm_base.cpp:1051-1058`; `single_top.cpp:137,180`)
— there is exactly one shared instance, which is *correct* for a multi-kiface app. No collision.
- **The `static <X>::IFACE kiface(...)` instances** (`pcbnew.cpp:592`, `eeschema.cpp:445`, in named
namespaces `PCB`/`SCH`) — file-static, internal linkage, no external symbol. No collision.
- Beyond `KIFACE_1` and `Kiface()`, the kifaces emit no other global non-member symbols at file
scope; the lower libs (libkicommon, libgal, …) are linked once and shared. A one-shot trial
link is the definitive duplicate-symbol audit, but the *by-design* collisions are just those two.
## The real cost: bundle size
This is the strategic consideration, and it cuts against our current delivery model.
- **Today:** 7 separate bundles, each user lazy-loads only the tool they open. Total deployed
≈ 572 MB raw — but massively duplicated (wx + common + boost replicated in all 7).
- **Merged:** a combined image pays for shared code **once**. A 4-editor (pcbnew + eeschema)
binary ≈ pcbnew's 146 MB (already carries wx/common + the PCB-only OCC) + eeschema's *unique*
code (~30–40 MB; OCC is PCB-only, wx/common/boost are shared) ≈ **~180–190 MB**. All-7 ≈
~200–230 MB. *(Estimates — a trial link is the only way to know precisely.)*
- **The catch:** that ~190 MB is downloaded by **every** user — even one who only wants the
calculator. It works *against* the per-tool lazy loading we do now, and a merged binary can't
be lazily sub-loaded: `wasm-split` / `dlopen` are already assessed as a dead end for us (see
[`../perf/bundle-size.md`](../perf/bundle-size.md) and the bundle-composition research). So the
tradeoff is "one shared binary, zero duplication, cross-probing possible" vs. "larger first
load for single-tool users."
## When Part 2 is worth it
- **Worth it** if the product goal is a unified, project-based "KiCad in the browser" where a
user has a project open and flips between schematic and PCB — and you want desktop-style
**cross-probing** (select a component in the schematic, highlight it on the board). A combined
image holds the board editor *and* schematic editor live under one `KIWAY`, which is exactly how
desktop KiCad's single process works; the inter-editor `KIWAY` express messaging would light up
for free. This is essentially porting the upstream project-manager model to one static image.
- **Not worth it** if the goal is the fastest possible first load for a single tool. Then keep
the tools as separate lazy-loaded bundles and just take [Part 1's](03-part1-library-editor-unification.md)
free dedup.
## Suggested sequencing if pursued
1. Ship [Part 1](03-part1-library-editor-unification.md) first (free; builds the runtime-frame
launcher this reuses).
2. Spike a **2-kiface trial link** (pcbnew + eeschema) with the getter renamed (§2) and
`--allow-multiple-definition` *off*, to enumerate the true duplicate-symbol set and confirm
it's just `Kiface()`. Measure the combined size.
3. Convert `Kiface()` to active-editor dispatch (§1) — the bulk of the work; validate the shared
`common/` call sites resolve correctly with both editors live.
4. Combined launcher registers both faces (§2/§3); JS opens any `FRAME_T` against the one bundle
(and optionally two frames at once for cross-probing).
5. Only then consider folding in gerbview / pl_editor / calculator (3 more faces, same recipe).
## Bottom line
The engineering is bounded and the scary part (the getter) is trivial; the genuine work is one
well-understood refactor (`Kiface()` → runtime dispatch). But Part 2 trades per-tool download size
for a unified binary, so decide it on **product** grounds — do we want a single cross-probing
KiCad-in-the-browser? — not as a duplication cleanup. The duplication cleanup is
[Part 1](03-part1-library-editor-unification.md), and it's free.