feat(pcbnew): Yjs collab bridge — snapshot/diff emit + BOARD_COMMIT apply (move/delete/track-add), verified two-tab in real app

pcbnew's half of the unified Yjs collab bridge (4th tool; yjs-bridge commit 4),
a near-verbatim port of the eeschema design. Root-repo only — kicad/wxwidgets
submodules untouched.

- wasm/bindings/pcbnew_embind.cpp: BOARD_LISTENER trigger + post-settle snapshot
  diff emit; BOARD_COMMIT apply inside a CallAfter + COROUTINE fiber (so a new
  item's GAL view->Add dispatches correctly). Move/delete sync for any top-level
  item by uuid; native PCB_TRACK add. Footprint/via/zone add deferred.
- WasmTool.tsx: add pcbnew to COLLAB_TOOLS.
- tests/apps/kicad/pcbnew-collab.html: seeded (wizard-free) harness, leaving
  pcbnew.html untouched for its wizard test.
- tests/kicad/pcbnew-collab.spec.ts: snapshot + apply(move/remove/add) — 2 pass,
  two-tab skipped headless.

Verified two-tab in the real web app: footprint move applies + syncs A->B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-05 15:56:50 +02:00
commit 2dc55c6138
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
4 changed files with 965 additions and 1 deletions

View file

@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="en-us">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>KiCad PCBnew WASM (collab)</title>
<style>
.emscripten { padding-right: 0; margin-left: auto; margin-right: auto; display: block; }
div.emscripten { text-align: center; }
/* the canvas *must not* have any border or padding, or mouse coords will be wrong */
canvas.emscripten { border: 0px none; }
.window {
position: absolute;
pointer-events: none;
z-index: 10;
background-color: black;
overflow: hidden;
width: 0;
height: 0;
}
.window-canvas {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
#status {
position: fixed;
bottom: 10px;
left: 10px;
color: #fff;
font-family: monospace;
z-index: 1000;
background: rgba(0,0,0,0.7);
padding: 10px;
border-radius: 5px;
}
#progress {
width: 300px;
height: 20px;
background: #333;
margin-top: 5px;
}
#progress-bar {
height: 100%;
background: #4CAF50;
width: 0%;
transition: width 0.3s;
}
</style>
</head>
<body style="margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a2e;">
<div id="main-window" style="width: 100vw; height: 100vh; position: absolute; top: 0; left: 0;"></div>
<div id="status">
<div id="status-text">Initializing...</div>
<div id="progress"><div id="progress-bar"></div></div>
</div>
<div id="window-container"></div>
<script>
var mainWindow = document.getElementById('main-window');
var statusText = document.getElementById('status-text');
var progressBar = document.getElementById('progress-bar');
var showError = function(msg) {
console.error('[KICAD_ERROR] ' + msg);
statusText.textContent = 'Error: ' + msg;
statusText.style.color = 'red';
};
var createCanvas = function() {
var canvas = document.createElement('canvas');
canvas.id = 'canvas';
canvas.style.display = 'none';
// wx.js owns the backing-store size via setWindowRect(); keep the HTML
// shell responsible only for the CSS size.
var width = window.innerWidth;
var height = window.innerHeight;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
canvas.oncontextmenu = function() { event.preventDefault(); };
canvas.addEventListener("webglcontextlost", function(e) {
showError('WebGL context lost. You will need to reload the page.');
e.preventDefault();
}, false);
mainWindow.appendChild(canvas);
Module.canvas = canvas;
console.log('[KICAD] preRun complete, canvas created: ' + width + 'x' + height);
};
var onRuntimeInitialized = function() {
console.log('[KICAD] Runtime initialized');
var canvas = Module.canvas;
canvas.style.display = 'block';
document.getElementById('status').style.display = 'none';
};
// Pre-fetched resource data (fetched before pcbnew.js loads)
var resourceData = null;
// Start fetching images.tar.gz immediately (runs in parallel with WASM loading)
fetch('images.tar.gz')
.then(function(response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.arrayBuffer();
})
.then(function(buffer) {
resourceData = new Uint8Array(buffer);
console.log('[KICAD] Prefetched images.tar.gz (' + resourceData.length + ' bytes)');
})
.catch(function(err) {
console.warn('[KICAD] Could not prefetch images.tar.gz:', err.message);
});
// Write pre-fetched resources to FS (called in preRun after FS is available)
var writeResources = function() {
// Create directory structure matching KiCad's compiled-in KICAD_DATA path
// This path is baked in during CMake configuration
var resourcePath = '/workspace/build-wasm/sysroot/share/kicad/resources';
FS.mkdirTree(resourcePath);
// Write pre-fetched data if available
if (resourceData) {
FS.writeFile(resourcePath + '/images.tar.gz', resourceData);
console.log('[KICAD] Wrote images.tar.gz to ' + resourcePath);
} else {
console.warn('[KICAD] images.tar.gz not ready yet (WASM loaded faster than fetch)');
}
};
// KiCad's standalone entry (single_top.cpp) runs STARTWIZARD on launch: a
// modal first-run "Setup" wizard shown whenever the settings dir lacks a
// kicad_common.json or valid global library tables. In this ephemeral MEMFS
// that is EVERY load, and the wizard's modal event loop crashes Asyncify
// (func is not a function). Seed a minimal default config before main() so
// all three providers report NeedsUserInput()==false — equivalent to the
// wizard's "use defaults" path — and it never opens. Settings dir matches
// PATHS::GetUserSettingsPath() for this build.
//
// NB pcbnew.html (the plain harness) intentionally OMITS this seed because
// pcbnew.spec.ts explicitly exercises the wizard; the collab tests instead
// need a clean, wizard-free boot (like eeschema.html), hence this variant.
var seedKicadConfig = function() {
var cfgDir = '/home/kicad/.config/kicad/kicad/9.99';
FS.mkdirTree(cfgDir);
var writeIfAbsent = function(path, contents) {
try { FS.stat(path); return; } catch (e) { /* absent — seed it */ }
FS.writeFile(path, contents);
console.log('[KICAD] Seeded ' + path);
};
// SETTINGS provider: settings dir is "valid" once kicad_common.json exists.
// PRIVACY provider: both prompts must be flagged do-not-show-again.
writeIfAbsent(cfgDir + '/kicad_common.json', JSON.stringify({
do_not_show_again: { update_check_prompt: true, data_collection_prompt: true }
}, null, 2));
// LIBRARIES provider: needs valid global symbol/footprint/design-block
// tables. Empty (zero-row) tables parse fine and satisfy GlobalTablesValid().
writeIfAbsent(cfgDir + '/sym-lib-table', '(sym_lib_table\n (version 7)\n)\n');
writeIfAbsent(cfgDir + '/fp-lib-table', '(fp_lib_table\n (version 7)\n)\n');
writeIfAbsent(cfgDir + '/design-block-lib-table', '(design_block_lib_table\n (version 7)\n)\n');
};
var Module = {
thisProgram: '/usr/bin/pcbnew', // Fake absolute path for argv[0] (KiCad DEBUG check)
preRun: [createCanvas, writeResources, seedKicadConfig],
postRun: [],
print: function(text) {
if (arguments.length > 1)
text = Array.prototype.slice.call(arguments).join(' ');
console.log('[KICAD_OUT] ' + text);
},
printErr: function(text) {
if (arguments.length > 1)
text = Array.prototype.slice.call(arguments).join(' ');
console.error('[KICAD_ERR] ' + text);
},
setStatus: function(text) {
console.log('[KICAD_STATUS] ' + text);
statusText.textContent = text;
// Parse progress from status text
var match = text.match(/(\d+)\/(\d+)/);
if (match) {
var pct = (parseInt(match[1]) / parseInt(match[2])) * 100;
progressBar.style.width = pct + '%';
}
},
totalDependencies: 0,
monitorRunDependencies: function(left) {
this.totalDependencies = Math.max(this.totalDependencies, left);
Module.setStatus(left ? 'Preparing... (' + (this.totalDependencies-left) + '/' + this.totalDependencies + ')' : 'All downloads complete.');
},
onRuntimeInitialized: onRuntimeInitialized,
// Required for locating .wasm and .worker.js files
locateFile: function(path) {
return path;
}
};
Module.setStatus('Downloading...');
window.onerror = function(msg, url, line) {
showError(msg + ' at ' + url + ':' + line);
Module.setStatus = function(text) {
if (text) Module.printErr('[post-exception status] ' + text);
};
return false;
};
</script>
<!-- wxWidgets WASM glue code (defines getConfigEntryLength, etc.) -->
<script src="wx.js"></script>
<script async src="pcbnew.js"></script>
</body>
</html>

View file

@ -0,0 +1,259 @@
import { execSync } from "node:child_process";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
/**
* pcbnew Yjs collaborative bridge (features/yjs-bridge commit 4).
*
* pcbnew reuses the same wire contract + generic JS reconciler as pl_editor/eeschema; the new
* code is the C++ adapter a native BOARD_LISTENER trigger + post-settle snapshot-diff emit,
* and a BOARD_COMMIT apply run inside a COROUTINE (so a freshly-built item's GAL view->Add has
* the Asyncify/fiber context it needs, exactly as eeschema). Coverage:
* - snapshot (read): kicadCollabSnapshot reflects items by uuid/type/position.
* - apply (single page): kicadCollabApply moves/removes/adds tracks by uuid (deferred via
* CallAfter + coroutine, so poll for the result).
* - two-tab: a real local move propagates AB over BroadcastChannel (skipped headless the
* harness can't PAINT; verified in the real web app).
*
* pcbnew internal units are nanometres (1 mm = 1e6 IU), unlike eeschema (1e4 IU/mm).
*/
const SEG1 = "44444444-0000-0000-0000-000000000001";
const SEG2 = "44444444-0000-0000-0000-000000000002";
const SAMPLE_PCB = `(kicad_pcb
\t(version 20241229)
\t(generator "pcbnew")
\t(generator_version "9.0")
\t(general
\t\t(thickness 1.6)
\t)
\t(paper "A4")
\t(layers
\t\t(0 "F.Cu" signal)
\t\t(2 "B.Cu" signal)
\t\t(25 "Edge.Cuts" user)
\t)
\t(setup)
\t(net 0 "")
\t(segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG1}"))
\t(segment (start 50.8 76.2) (end 101.6 76.2) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG2}"))
)
`;
type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
type Mod = {
kicadOpenFile(p: string): unknown;
kicadCollabSnapshot(): string;
kicadCollabApply(j: string): unknown;
kicadCollabTestMoveFirst(dx: number, dy: number): string;
kicadCollabGetPos(id: string): string;
};
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
}
async function bootAndOpen(page: Page, name: string): Promise<void> {
// Use the seeded collab harness (pcbnew-collab.html), which skips the first-run setup
// wizard via a kicad_common.json seed — exactly like eeschema.html. The plain pcbnew.html
// deliberately keeps the wizard (pcbnew.spec.ts tests it), which would block boot here.
await page.goto("/kicad/pcbnew-collab.html");
await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForFunction(
() => {
const m = (window as unknown as { Module?: Mod }).Module;
return (
typeof m?.kicadOpenFile === "function" &&
typeof m?.kicadCollabSnapshot === "function" &&
typeof m?.kicadCollabApply === "function" &&
typeof m?.kicadCollabTestMoveFirst === "function"
);
},
null,
{ timeout: 90000 },
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
null,
{ timeout: 90000 },
);
await page.evaluate(
({ content, name }) => {
const w = window as unknown as { FS: FS; Module: Mod };
const dir = "/home/kicad/documents";
try {
w.FS.mkdirTree(dir);
} catch {
/* exists */
}
const p = `${dir}/${name}.kicad_pcb`;
w.FS.writeFile(p, content);
w.Module.kicadOpenFile(p);
},
{ content: SAMPLE_PCB, name },
);
await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(new RegExp(name, "i"));
}
test.beforeAll(() => {
execSync("node collab/build.mjs", { cwd: path.resolve(__dirname, ".."), stdio: "inherit" });
});
test.describe("pcbnew collab bridge — single page", () => {
test("snapshot reflects board by uuid/type/position", async ({ page, testLogger }) => {
await bootAndOpen(page, "snap");
const snap = await page.evaluate(() => JSON.parse(window.Module.kicadCollabSnapshot()));
const byId = new Map<string, { type: string; x: number; y: number }>(
snap.added.map((i: { id: string; type: string; x: number; y: number }) => [i.id, i]),
);
expect(byId.has(SEG1)).toBe(true);
expect(byId.get(SEG1)!.type).toBe("PCB_TRACK");
expect(byId.get(SEG1)!.x).toBe(50_800_000); // 50.8mm × 1e6 IU (nm)
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// Apply mutates the model headless: kicadOpenFile returns false (the incomplete-project load
// skips some late steps) but the board IS built, so BOARD_COMMIT::Push takes effect. Rendering
// still needs the real app. (Same headless reality as the eeschema apply test.)
const TRACK_ID = "55555555-0000-0000-0000-000000000001";
test("apply moves/removes/adds tracks by uuid, no echo", async ({ page, testLogger }) => {
await bootAndOpen(page, "apply");
const before = await page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG1);
const [bx, by] = before.split(",").map(Number);
const nx = bx + 5_000_000; // +5mm
await page.evaluate(() => {
(window as unknown as { __echo: string[] }).__echo = [];
(window as unknown as { kicadCollab: { onDelta: (j: string) => void } }).kicadCollab = {
onDelta: (j: string) => (window as unknown as { __echo: string[] }).__echo.push(j),
};
});
// changed: reshape SEG1's endpoints (a track moves via its two endpoints, like an eeschema
// wire — the sx/sy/ex/ey form the emit side always produces). Deferred via CallAfter → poll.
await page.evaluate(
({ id, nx, by }) =>
window.Module.kicadCollabApply(
JSON.stringify({
changed: [{ id, type: "PCB_TRACK", sx: nx, sy: by, ex: nx + 50_800_000, ey: by, width: 200000 }],
added: [],
removed: [],
}),
),
{ id: SEG1, nx, by },
);
await expect
.poll(() => page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG1), {
timeout: 10000,
intervals: [200],
})
.toBe(`${nx},${by}`);
// removed: delete SEG2.
await page.evaluate(
(seg) =>
window.Module.kicadCollabApply(JSON.stringify({ changed: [], added: [], removed: [seg] })),
SEG2,
);
await expect
.poll(() => page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG2), {
timeout: 10000,
intervals: [200],
})
.toBe("");
// added: a new track reconstructs by uuid (native PCB_TRACK build — no clipboard Parse).
await page.evaluate(
(trackId) =>
window.Module.kicadCollabApply(
JSON.stringify({
changed: [],
removed: [],
added: [
{
id: trackId,
type: "PCB_TRACK",
sx: 60_000_000,
sy: 60_000_000,
ex: 90_000_000,
ey: 60_000_000,
width: 200000,
layer: 0, // F_Cu
},
],
}),
),
TRACK_ID,
);
await expect
.poll(
async () =>
(await page.evaluate(() => window.Module.kicadCollabSnapshot())).includes(TRACK_ID),
{ timeout: 10000, intervals: [250] },
)
.toBe(true);
const echoes = await page.evaluate(() => (window as unknown as { __echo: string[] }).__echo);
expect(echoes, "apply() must not echo a local onDelta").toHaveLength(0);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
});
test.describe("pcbnew collab bridge — two tabs (BroadcastChannel)", () => {
// SKIP headless for the same reason as the single-page apply test (harness can't PAINT).
// Verified working in the real web app.
test.skip("a local move propagates A→B", async ({ context, testLogger }) => {
const channel = `pcb-collab-e2e-${test.info().workerIndex}`;
const bundle = path.resolve(__dirname, "../apps/kicad/collab-bundle.js");
const tabA = await context.newPage();
const tabB = await context.newPage();
await bootAndOpen(tabA, "tabA");
await bootAndOpen(tabB, "tabB");
for (const p of [tabA, tabB]) await p.addScriptTag({ path: bundle });
const startCollab = (p: Page) =>
p.evaluate(async (ch) => {
const w = window as unknown as {
KicadCollab: { start: (m: unknown, win: unknown, o: unknown) => Promise<unknown> };
Module: unknown;
};
await w.KicadCollab.start(w.Module, window, { channel: ch, settleMs: 500 });
}, channel);
await startCollab(tabA);
await startCollab(tabB);
const uuid = await tabA.evaluate(() => window.Module.kicadCollabTestMoveFirst(2_000_000, 0));
expect(uuid).toMatch(/[0-9a-f-]{36}/);
const orig = await tabA.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid);
await expect
.poll(() => tabA.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid), {
timeout: 15000,
intervals: [300],
})
.not.toBe(orig);
const posA = await tabA.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid);
await expect
.poll(() => tabB.evaluate((id) => window.Module.kicadCollabGetPos(id), uuid), {
timeout: 15000,
intervals: [300],
})
.toBe(posA);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await tabA.close();
await tabB.close();
});
});

View file

@ -10,18 +10,33 @@
*/
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#include <emscripten/bind.h>
#include <board.h>
#include <board_commit.h>
#include <board_item.h>
#include <footprint.h>
#include <pad.h>
#include <pcb_track.h>
#include <pcb_group.h>
#include <zone.h>
#include <pcb_edit_frame.h>
#include <kiway_player.h>
#include <kiway.h>
#include <kiid.h>
#include <layer_ids.h>
#include <tool/coroutine.h>
#include <nlohmann/json.hpp>
#include <map>
#include <set>
#include <string>
#include <vector>
#include <wx/app.h>
#include <wx/string.h>
#include <wx/window.h>
using namespace emscripten;
using json = nlohmann::json;
// Programmatically open a project file (board/schematic) in the running editor
// frame, without UI automation. Mirrors single_top.cpp's MacOpenFile path:
@ -43,6 +58,456 @@ bool kicadOpenFile( std::string path )
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
}
// ───────────────────────────── Yjs collaborative bridge ─────────────────────────────
//
// pcbnew's half of the unified bridge contract (features/yjs-bridge 0001, 0004). Like
// eeschema it needs NO kicad-fork change: BOARD_ITEM already carries a stable KIID, and
// pcbnew has native change machinery, so the adapter is a thin re-use of public API:
// ChangeSource (emit) = a BOARD_LISTENER subclass (BOARD_COMMIT::Push fires it)
// apply = BOARD_COMMIT Add/Modify/Remove + Push (drives connectivity +
// ratsnest recompute — mandatory on pcbnew, 0004 §apply)
// The generic JS reconciler / transport / WasmTool wiring are reused unchanged, and the
// emit/apply structure mirrors the (battle-tested) eeschema bridge:
// - emit = a POST-SETTLE snapshot DIFF (the listener is just a "something changed"
// trigger; the real change set is a diff of the full model taken after the
// edit's BOARD_COMMIT::Push — connectivity cleanup included — has returned,
// so peers converge by re-applying already-clean geometry). See eeschema 0007.
// - apply = BOARD_COMMIT run inside a CallAfter + COROUTINE fiber stack, the exact
// context native tool edits run in, so GAL view->Add of a freshly-constructed
// item dispatches its asyncify-instrumented virtuals correctly (eeschema 0007).
//
// Scope of this first commit (0004 §"first PoC", matching eeschema commit-3's first cut):
// position/geometry sync of existing items — changed (move/reshape) and removed work for
// ANY top-level item by uuid; `added` reconstructs PCB_TRACK segments natively. Footprint/
// via/zone `added` (which need a library or the s-expr clipboard blob, same class as the
// deferred SCH_SYMBOL add) are logged + skipped until a later commit. Net assignment +
// ratsnest are recomputed by BOARD_COMMIT::Push regardless.
namespace {
// Guard so BOARD_COMMIT::Push's listener callbacks during apply() aren't re-emitted.
bool s_applyingRemote = false;
std::string toUtf8( const wxString& s ) { return std::string( s.utf8_str() ); }
PCB_EDIT_FRAME* pcbFrame()
{
return wxTheApp ? dynamic_cast<PCB_EDIT_FRAME*>( wxTheApp->GetTopWindow() ) : nullptr;
}
bool isTrackType( KICAD_T t )
{
return t == PCB_TRACE_T || t == PCB_ARC_T || t == PCB_VIA_T;
}
// Iterate every TOP-LEVEL board item (tracks, footprints, drawings, zones, groups). Footprint
// child items (pads, fp text) are intentionally NOT visited individually — they move with their
// parent footprint, so the bridge syncs the footprint as a unit (its uuid in m_itemByIdCache).
template <typename Fn>
void forEachTopItem( BOARD& aBoard, Fn&& aFn )
{
for( PCB_TRACK* t : aBoard.Tracks() ) aFn( static_cast<BOARD_ITEM*>( t ) );
for( FOOTPRINT* f : aBoard.Footprints() ) aFn( static_cast<BOARD_ITEM*>( f ) );
for( BOARD_ITEM* d : aBoard.Drawings() ) aFn( d );
for( ZONE* z : aBoard.Zones() ) aFn( static_cast<BOARD_ITEM*>( z ) );
for( PCB_GROUP* g : aBoard.Groups() ) aFn( static_cast<BOARD_ITEM*>( g ) );
}
// The diff/wire unit for one board item: the fields apply() can act on. Tracks carry their two
// endpoints + width (they reshape, like an eeschema SCH_LINE); everything else syncs position.
// Deliberately NO opaque s-expr blob here — keeping the diff unit to the applicable fields
// avoids broadcasting `changed` entries the peer can only partially apply (which would diverge
// then loop). Added-item reconstruction is handled type-by-type in makeItem instead.
json itemToJson( BOARD_ITEM* aItem )
{
VECTOR2I p = aItem->GetPosition();
json j = {
{ "id", toUtf8( aItem->m_Uuid.AsString() ) },
{ "type", toUtf8( aItem->GetClass() ) },
{ "x", p.x }, // internal units (nm); integral, no quantization needed
{ "y", p.y },
{ "layer", (int) aItem->GetLayer() },
};
if( isTrackType( aItem->Type() ) )
{
auto* tr = static_cast<PCB_TRACK*>( aItem );
j["sx"] = tr->GetStart().x;
j["sy"] = tr->GetStart().y;
j["ex"] = tr->GetEnd().x;
j["ey"] = tr->GetEnd().y;
j["width"] = tr->GetWidth();
}
return j;
}
// Construct a new BOARD_ITEM from a delta item (for `added`), with the delta's uuid (m_Uuid is
// const → const_cast, exactly as the s-expr parser does). Returns nullptr for types without a
// converter yet (footprints/vias/zones — deferred, see header). PCB_TRACK segments reconstruct
// natively (no clipboard Parse), so the add path is trap-free for the common collab case.
BOARD_ITEM* makeItem( BOARD& aBoard, const json& j )
{
std::string type = j.value( "type", "" );
BOARD_ITEM* item = nullptr;
if( type == "PCB_TRACK" )
{
auto* tr = new PCB_TRACK( &aBoard );
tr->SetStart( VECTOR2I( j.value( "sx", 0 ), j.value( "sy", 0 ) ) );
tr->SetEnd( VECTOR2I( j.value( "ex", 0 ), j.value( "ey", 0 ) ) );
tr->SetWidth( j.value( "width", 0 ) );
tr->SetLayer( (PCB_LAYER_ID) j.value( "layer", (int) F_Cu ) );
item = tr;
}
// PCB_VIA / PCB_ARC / FOOTPRINT / ZONE `added` deferred (need layer-pair/drill, arc center,
// or a library / s-expr clipboard blob — same deferred class as SCH_SYMBOL). Their move/
// delete already sync via the generic changed/removed paths.
if( item )
const_cast<KIID&>( item->m_Uuid ) = KIID( wxString::FromUTF8( j.value( "id", "" ).c_str() ) );
return item;
}
// Set an existing item's geometry from a `changed` delta. Tracks reshape via their endpoints
// (independent — like an eeschema wire); everything else moves to an absolute position.
// SetStart/SetEnd/SetPosition run inside the apply COROUTINE (see kicadCollabApply), the same
// fiber context native edits use, so the virtual dispatch resolves correctly.
void applyChanged( BOARD_ITEM* aItem, const json& j )
{
if( isTrackType( aItem->Type() ) && j.contains( "sx" ) )
{
auto* tr = static_cast<PCB_TRACK*>( aItem );
tr->SetStart( VECTOR2I( j["sx"].get<int>(), j["sy"].get<int>() ) );
tr->SetEnd( VECTOR2I( j["ex"].get<int>(), j["ey"].get<int>() ) );
if( j.contains( "width" ) )
tr->SetWidth( j["width"].get<int>() );
}
else if( j.contains( "x" ) && j.contains( "y" ) )
{
aItem->SetPosition( VECTOR2I( j["x"].get<int>(), j["y"].get<int>() ) );
}
}
void emit( const json& aDelta )
{
std::string s = aDelta.dump();
EM_ASM( {
if( window.kicadCollab && window.kicadCollab.onDelta )
window.kicadCollab.onDelta( UTF8ToString( $0 ) );
}, s.c_str() );
}
// ── Emit via post-settle snapshot diff (mirrors eeschema 0007) ───────────────────────────────
//
// A local edit is one BOARD_COMMIT::Push that fires the listener callbacks synchronously and
// THEN recomputes connectivity/ratsnest. The native listener therefore only ever sees the
// pre-cleanup geometry. So treat the listener purely as a "something changed" trigger and
// broadcast a DIFF of the full model taken AFTER the edit settles (a CallAfter, which runs once
// Push has fully returned) — capturing this tab's FINAL geometry. The peer applies that and
// re-applying already-settled geometry is idempotent, so the two converge. g_baseline holds the
// last-broadcast state.
std::map<std::string, json> snapshotByUuid( BOARD& aBoard )
{
std::map<std::string, json> m;
forEachTopItem( aBoard, [&]( BOARD_ITEM* item )
{
std::string id = toUtf8( item->m_Uuid.AsString() );
if( !m.count( id ) )
m[id] = itemToJson( item );
} );
return m;
}
std::map<std::string, json> g_baseline;
bool g_flushScheduled = false;
// Re-seed the diff baseline to the current model — after handing out a seed snapshot, or after
// applying a remote delta (so those items aren't re-broadcast as a spurious local diff/echo).
void rebaseline()
{
if( PCB_EDIT_FRAME* fr = pcbFrame() )
g_baseline = snapshotByUuid( *fr->GetBoard() );
}
// Diff the current (settled, post-cleanup) model against the baseline and broadcast the change.
void flushDiff()
{
g_flushScheduled = false;
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return;
std::map<std::string, json> cur = snapshotByUuid( *fr->GetBoard() );
json added = json::array(), changed = json::array(), removed = json::array();
for( const auto& [id, j] : cur )
{
auto it = g_baseline.find( id );
if( it == g_baseline.end() )
added.push_back( j );
else if( it->second != j )
changed.push_back( j );
}
for( const auto& [id, j] : g_baseline )
{
if( !cur.count( id ) )
removed.push_back( id );
}
g_baseline = std::move( cur );
if( !added.empty() || !changed.empty() || !removed.empty() )
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
}
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop
// turn) into a single post-settle diff.
void scheduleFlush()
{
if( g_flushScheduled )
return;
g_flushScheduled = true;
if( PCB_EDIT_FRAME* fr = pcbFrame() )
fr->CallAfter( []() { flushDiff(); } );
else
flushDiff();
}
// ChangeSource: the native BOARD_LISTENER is just a trigger — the actual change set comes from
// the post-settle snapshot diff above. Skipped while applying a remote delta (no echo); doApply
// rebaselines instead. OnBoardCompositeUpdate (the single combined add/remove/change event,
// 0004) plus the bulk + singular callbacks all funnel into one trigger.
class COLLAB_LISTENER : public BOARD_LISTENER
{
public:
void OnBoardItemAdded( BOARD&, BOARD_ITEM* ) override { trigger(); }
void OnBoardItemsAdded( BOARD&, std::vector<BOARD_ITEM*>& ) override { trigger(); }
void OnBoardItemRemoved( BOARD&, BOARD_ITEM* ) override { trigger(); }
void OnBoardItemsRemoved( BOARD&, std::vector<BOARD_ITEM*>& ) override { trigger(); }
void OnBoardItemChanged( BOARD&, BOARD_ITEM* ) override { trigger(); }
void OnBoardItemsChanged( BOARD&, std::vector<BOARD_ITEM*>& ) override { trigger(); }
void OnBoardCompositeUpdate( BOARD&, std::vector<BOARD_ITEM*>&,
std::vector<BOARD_ITEM*>&,
std::vector<BOARD_ITEM*>& ) override { trigger(); }
private:
void trigger()
{
if( !s_applyingRemote )
scheduleFlush();
}
};
COLLAB_LISTENER* g_listener = nullptr;
// Get the live BOARD and ensure our listener is registered on it (idempotent).
BOARD* ensureBridge()
{
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return nullptr;
BOARD* board = fr->GetBoard();
if( !g_listener )
{
g_listener = new COLLAB_LISTENER();
board->AddListener( g_listener );
}
return board;
}
// The actual model mutation, via BOARD_COMMIT so connectivity + ratsnest recompute exactly as
// for a UI edit (0004 §apply: never bypass the commit for remote ops). Runs inside the apply
// COROUTINE (see kicadCollabApply).
void doApply( PCB_EDIT_FRAME* aFrame, const json& aDelta )
{
BOARD* board = aFrame->GetBoard();
s_applyingRemote = true;
BOARD_COMMIT commit( aFrame );
bool staged = false;
for( const json& rid : aDelta.value( "removed", json::array() ) )
{
KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) );
if( BOARD_ITEM* item = board->ResolveItem( id, /*allowNullptr*/ true ) )
{
commit.Remove( item );
staged = true;
}
}
for( const json& j : aDelta.value( "changed", json::array() ) )
{
KIID id( wxString::FromUTF8( j.value( "id", "" ).c_str() ) );
if( BOARD_ITEM* item = board->ResolveItem( id, /*allowNullptr*/ true ) )
{
commit.Modify( item );
applyChanged( item, j );
staged = true;
}
}
for( const json& j : aDelta.value( "added", json::array() ) )
{
KIID id( wxString::FromUTF8( j.value( "id", "" ).c_str() ) );
if( board->ResolveItem( id, /*allowNullptr*/ true ) )
continue; // already present (our own echo)
if( BOARD_ITEM* item = makeItem( *board, j ) )
{
commit.Add( item );
staged = true;
}
else
{
EM_ASM( { console.log( "[collab] pcbnew apply: no converter for added type " + UTF8ToString( $0 ) ); },
j.value( "type", "?" ).c_str() );
}
}
if( staged )
commit.Push( wxT( "Collaborative edit" ) );
// The applied remote changes (and any connectivity cleanup they triggered) are now the
// shared state — fold them into the baseline so the post-apply listener flush doesn't
// re-broadcast them as a local diff (echo).
rebaseline();
s_applyingRemote = false;
}
// Test/PoC move (the BOARD_COMMIT body for kicadCollabTestMoveFirst, deferred via CallAfter).
void collabTestMove( PCB_EDIT_FRAME* aFrame, BOARD_ITEM* aItem, int aDx, int aDy )
{
BOARD_COMMIT commit( aFrame );
commit.Modify( aItem );
aItem->Move( VECTOR2I( aDx, aDy ) );
commit.Push( wxT( "Collab test move" ) );
}
} // namespace
// JS → C++. Apply a remote per-item delta by uuid, through BOARD_COMMIT so connectivity/ratsnest
// recompute the same way a UI edit would (0004 §apply).
//
// BOARD_COMMIT must run in the editor's Asyncify-rooted main loop — invoking it from this embind
// ccall, or from a setTimeout callback, traps with an "indirect call signature mismatch" (those
// aren't the asyncify root). wxEvtHandler::CallAfter queues onto the app's pending-event list,
// drained every frame by the wasm main loop (src/wasm/evtloop.cpp) — the exact context real UI
// edits run in. Additionally run the mutation inside a COROUTINE so it executes on a libcontext
// fiber stack: BOARD_COMMIT::Push's CHT_ADD of a freshly-built item dispatches GAL virtuals
// (view->Add → ViewGetLayers) through asyncify-instrumented invoke_*; off the fiber stack those
// mis-dispatch and trap inside KiCad core, on it they dispatch correctly (eeschema 0007).
void kicadCollabApply( std::string aJson )
{
json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
if( delta.is_discarded() )
return;
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return;
fr->CallAfter( [fr, delta]() {
COROUTINE<int, int> cor( [fr, delta]( int ) -> int
{
doApply( fr, delta );
return 0;
} );
cor.Call( 0 );
} );
}
// JS pull of the full current model as an all-"added" delta (seed/baseline). Also registers the
// change listener on first call.
std::string kicadCollabSnapshot()
{
BOARD* board = ensureBridge();
json added = json::array();
if( board )
{
forEachTopItem( *board, [&]( BOARD_ITEM* item ) { added.push_back( itemToJson( item ) ); } );
}
// Seed the diff baseline to exactly the model we're handing out, so the first local edit
// diffs against this snapshot (and we don't re-broadcast the whole model).
rebaseline();
return json{ { "added", added }, { "changed", json::array() },
{ "removed", json::array() } }.dump();
}
// Test/PoC helper: move the first top-level board item by (dx,dy) IU via a real BOARD_COMMIT,
// firing the listener — a deterministic local edit for the two-tab demo / e2e. Returns the
// moved item's uuid.
std::string kicadCollabTestMoveFirst( int aDx, int aDy )
{
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return "";
std::string movedId;
forEachTopItem( *fr->GetBoard(), [&]( BOARD_ITEM* item )
{
if( !movedId.empty() )
return;
movedId = toUtf8( item->m_Uuid.AsString() );
fr->CallAfter( [fr, item, aDx, aDy]() { collabTestMove( fr, item, aDx, aDy ); } );
} );
return movedId;
}
// Test helper: read an item's position by uuid as "x,y" (internal units).
std::string kicadCollabGetPos( std::string aId )
{
PCB_EDIT_FRAME* fr = pcbFrame();
if( !fr )
return "";
KIID id( wxString::FromUTF8( aId.c_str() ) );
if( BOARD_ITEM* item = fr->GetBoard()->ResolveItem( id, /*allowNullptr*/ true ) )
{
VECTOR2I p = item->GetPosition();
return std::to_string( p.x ) + "," + std::to_string( p.y );
}
return "";
}
// Wrapper to return footprints as vector for JS iteration
std::vector<FOOTPRINT*> Board_GetFootprints(BOARD* board) {
if (!board) return {};
@ -110,5 +575,11 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
// Programmatic file open (preferred over UI automation from the web app).
function("kicadOpenFile", &kicadOpenFile);
// Yjs collaborative bridge entry points (same contract as pl_editor / eeschema).
function("kicadCollabApply", &kicadCollabApply);
function("kicadCollabSnapshot", &kicadCollabSnapshot);
function("kicadCollabTestMoveFirst", &kicadCollabTestMoveFirst);
function("kicadCollabGetPos", &kicadCollabGetPos);
}
#endif

View file

@ -9,7 +9,7 @@ import type { CollabWindow } from "@/wasm/collab";
import { clog, cwarn } from "@/wasm/collab/debug";
// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports).
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema"]);
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
/**
* Opt-in collaborative editing (features/yjs-bridge). Enabled when the URL carries