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:
parent
bb6dbfc76e
commit
2dc55c6138
4 changed files with 965 additions and 1 deletions
234
tests/apps/kicad/pcbnew-collab.html
Normal file
234
tests/apps/kicad/pcbnew-collab.html
Normal 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>
|
||||
259
tests/kicad/pcbnew-collab.spec.ts
Normal file
259
tests/kicad/pcbnew-collab.spec.ts
Normal 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 A→B 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue