feat(collab): presence P2 — pcbnew selection/cursor emit + remote VIEW_OVERLAY render (collab-presence 0002)
Zero kicad-fork changes — all in the embind layer: - pcbnew_embind.cpp: presence section — canvas wx Bind() triggers (motion/ leave/up/key/wheel) + COLLAB_LISTENER piggyback → post-settle selection check (dedupe) → onSelection; throttled cursor emit via VIEW::ToWorld; viewport push/pull (px-per-IU via the GAL matrix — GetScale() is the zoom and sized the first cut's overlay nm-small); kicadCollabSetRemote renders peers' cursors (cross + name) and selection bbox outlines into one per-user-colored VIEW_OVERLAY (CallAfter+COROUTINE), never touching local selection; PresenceStart/GetSelection/TestSelectFirst/TestClearSelection. - kicad_editor_embind.cpp: merged-image dispatch (pcb-only until 0003). - presence-kicad.ts: bindKicadPresence — routes emits into awareness (0001) and pushes trailing-throttled peer snapshots into the wasm; wired from WasmTool.startPresence (pcbnew-gated). +5 unit tests. - tests/kicad/presence-pcbnew.spec.ts: 5 e2e — programmatic + real box-select emit, throttled cursor, remote render with no-leak + pixel restore, viewport unit band. Existing pcbnew-collab/items-bridge suites stay green. Verified live: two tabs over partykit — peer cursor cross + label + selection outline visible on the other tab's canvas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
parent
22528f450b
commit
8ad1cc673a
6 changed files with 1162 additions and 0 deletions
379
tests/kicad/presence-pcbnew.spec.ts
Normal file
379
tests/kicad/presence-pcbnew.spec.ts
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
/**
|
||||
* pcbnew collab presence — C++ bridge e2e (collab-presence 0002).
|
||||
*
|
||||
* Covers the wasm side of presence in isolation (no yjs/awareness — that layer is
|
||||
* unit-tested in the standalone and exercised end-to-end by tests/web):
|
||||
* - selection emit: kicadCollabTestSelectFirst → window.kicadCollab.onSelection
|
||||
* fires with the item's uuid; clear → fires with []. Real canvas CLICKS also
|
||||
* drive the emit (the wx-layer trigger path).
|
||||
* - cursor emit: synthetic mouse motion over the canvas → onCursor with world
|
||||
* coords inside the board area; throttled; pointer-leave handled by the DOM
|
||||
* leave event (not synthesizable reliably headless — covered in tests/web).
|
||||
* - remote render, no state leak: kicadCollabSetRemote paints the VIEW_OVERLAY
|
||||
* (canvas pixels change) while kicadCollabGetSelection stays [].
|
||||
* - viewport export: kicadCollabGetViewport returns a sane transform.
|
||||
*
|
||||
* Board fixture mirrors pcbnew-collab.spec.ts (seeded harness, wizard-free).
|
||||
*/
|
||||
|
||||
const SEG1 = "44444444-0000-0000-0000-000000000001";
|
||||
const FP1 = "66666666-0000-0000-0000-000000000001";
|
||||
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(37 "F.SilkS" user)
|
||||
\t\t(25 "Edge.Cuts" user)
|
||||
\t)
|
||||
\t(setup)
|
||||
\t(net 0 "")
|
||||
\t(footprint "TestLib:R"
|
||||
\t\t(layer "F.Cu")
|
||||
\t\t(uuid "${FP1}")
|
||||
\t\t(at 100 100)
|
||||
\t\t(attr smd)
|
||||
\t\t(property "Reference" "R1"
|
||||
\t\t\t(at 0 -4.2 0)
|
||||
\t\t\t(layer "F.SilkS")
|
||||
\t\t\t(uuid "66666666-0000-0000-0000-0000000000aa")
|
||||
\t\t\t(effects (font (size 1 1) (thickness 0.15)))
|
||||
\t\t)
|
||||
\t\t(property "Value" "R"
|
||||
\t\t\t(at 0 4.6 0)
|
||||
\t\t\t(layer "F.Fab")
|
||||
\t\t\t(uuid "66666666-0000-0000-0000-0000000000bb")
|
||||
\t\t\t(effects (font (size 1 1) (thickness 0.15)))
|
||||
\t\t)
|
||||
\t\t(pad "1" smd rect
|
||||
\t\t\t(at -1 0)
|
||||
\t\t\t(size 1 1)
|
||||
\t\t\t(layers "F.Cu")
|
||||
\t\t\t(uuid "66666666-0000-0000-0000-0000000000dd")
|
||||
\t\t)
|
||||
\t)
|
||||
\t(segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG1}"))
|
||||
)
|
||||
`;
|
||||
|
||||
type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void };
|
||||
type Mod = {
|
||||
kicadOpenFile(p: string): unknown;
|
||||
kicadCollabPresenceStart(): void;
|
||||
kicadCollabSetRemote(j: string): void;
|
||||
kicadCollabGetViewport(): string;
|
||||
kicadCollabGetSelection(): string;
|
||||
kicadCollabTestSelectFirst(): string;
|
||||
kicadCollabTestClearSelection(): boolean;
|
||||
};
|
||||
type PresenceWindow = {
|
||||
FS: FS;
|
||||
Module: Mod;
|
||||
kicadCollab?: Record<string, unknown>;
|
||||
__selEmits?: string[][];
|
||||
__cursorEmits?: Array<{ x: number; y: number; active: number }>;
|
||||
};
|
||||
|
||||
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
|
||||
return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted("));
|
||||
}
|
||||
|
||||
async function bootAndOpen(page: Page): Promise<void> {
|
||||
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?: Partial<Mod> }).Module;
|
||||
return (
|
||||
typeof m?.kicadOpenFile === "function" &&
|
||||
typeof m?.kicadCollabSetRemote === "function" &&
|
||||
typeof m?.kicadCollabTestSelectFirst === "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 }) => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
const dir = "/home/kicad/documents";
|
||||
try {
|
||||
w.FS.mkdirTree(dir);
|
||||
} catch {
|
||||
/* exists */
|
||||
}
|
||||
const p = `${dir}/presence.kicad_pcb`;
|
||||
w.FS.writeFile(p, content);
|
||||
w.Module.kicadOpenFile(p);
|
||||
},
|
||||
{ content: SAMPLE_PCB },
|
||||
);
|
||||
|
||||
// Board open settles asynchronously; wait for the title to carry the file stem.
|
||||
await expect
|
||||
.poll(() => page.title(), { timeout: 60000, intervals: [500] })
|
||||
.toMatch(/presence/i);
|
||||
|
||||
// Install the capture hooks + start the presence input bindings.
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
w.__selEmits = [];
|
||||
w.__cursorEmits = [];
|
||||
w.kicadCollab = {
|
||||
...w.kicadCollab,
|
||||
onSelection: (json: string) => w.__selEmits!.push(JSON.parse(json)),
|
||||
onCursor: (x: number, y: number, active: number) =>
|
||||
w.__cursorEmits!.push({ x, y, active }),
|
||||
};
|
||||
w.Module.kicadCollabPresenceStart();
|
||||
});
|
||||
}
|
||||
|
||||
test("selection emit: programmatic select/clear reaches onSelection with uuids", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const selectedId = await page.evaluate(() =>
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabTestSelectFirst(),
|
||||
);
|
||||
expect(selectedId).toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
return w.__selEmits!.at(-1) ?? null;
|
||||
}),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual([selectedId]);
|
||||
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabTestClearSelection(),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
return w.__selEmits!.at(-1) ?? null;
|
||||
}),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual([]);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("selection emit: a real canvas click drives the wx-layer trigger", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
// Rubber-band box-select across the sheet: `#canvas` is the WHOLE wx app
|
||||
// window (menus/toolbars included) — the GAL draw panel is its own DOM element
|
||||
// (`#glcanvas-*`, see zoom-cursor.spec.ts). A drag over most of the panel
|
||||
// catches the board items without depending on exact world→screen math; the
|
||||
// closing LEFT_UP is the wx-layer trigger under test.
|
||||
const glId = await page.evaluate(() => {
|
||||
const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
|
||||
.map((c) => c as HTMLCanvasElement)
|
||||
.find((c) => {
|
||||
const rect = c.getBoundingClientRect();
|
||||
return window.getComputedStyle(c).display !== "none" && rect.width > 0;
|
||||
});
|
||||
return visible?.id ?? null;
|
||||
});
|
||||
expect(glId).toBeTruthy();
|
||||
const box = await page.locator(`#${glId}`).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
|
||||
const x0 = box!.x + box!.width * 0.1;
|
||||
const y0 = box!.y + box!.height * 0.1;
|
||||
const x1 = box!.x + box!.width * 0.9;
|
||||
const y1 = box!.y + box!.height * 0.9;
|
||||
|
||||
await page.mouse.move(x0, y0);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(x1, y1, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
|
||||
// First check the selection actually happened (separates "the drag didn't
|
||||
// select" from "the emit trigger is broken").
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse(
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabGetSelection(),
|
||||
).length,
|
||||
),
|
||||
{ timeout: 10000, message: "box-select never selected anything" },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
return w.__selEmits!.some((s) => s.length > 0);
|
||||
}),
|
||||
{ timeout: 10000, message: "selection happened but onSelection never emitted" },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("cursor emit: mouse motion over the canvas produces throttled world coords", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const canvas = page.locator("#canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
|
||||
// Sweep the pointer across the canvas center with many small steps.
|
||||
const cx = box!.x + box!.width / 2;
|
||||
const cy = box!.y + box!.height / 2;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await page.mouse.move(cx - 150 + i * 10, cy, { steps: 1 });
|
||||
}
|
||||
|
||||
const emits = await page.evaluate(
|
||||
() => (window as unknown as PresenceWindow).__cursorEmits!,
|
||||
);
|
||||
expect(emits.length).toBeGreaterThan(0);
|
||||
// Throttle: 30 moves in well under a second must not emit 30 times.
|
||||
expect(emits.length).toBeLessThan(25);
|
||||
|
||||
const active = emits.filter((e) => e.active === 1);
|
||||
expect(active.length).toBeGreaterThan(0);
|
||||
// World coords: an A4 board view centers around 10^8 nm — sanity-band check.
|
||||
for (const e of active) {
|
||||
expect(Math.abs(e.x)).toBeLessThan(1e10);
|
||||
expect(Math.abs(e.y)).toBeLessThan(1e10);
|
||||
}
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("remote render paints the overlay without touching local selection", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const canvas = page.locator("#canvas");
|
||||
const before = await canvas.screenshot();
|
||||
|
||||
await page.evaluate(
|
||||
({ fp }) => {
|
||||
const w = window as unknown as PresenceWindow;
|
||||
w.Module.kicadCollabSetRemote(
|
||||
JSON.stringify({
|
||||
peers: [
|
||||
{
|
||||
id: "bob",
|
||||
name: "bob",
|
||||
color: "#ef4444",
|
||||
cursor: { x: 90e6, y: 90e6 },
|
||||
selection: [fp],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ fp: FP1 },
|
||||
);
|
||||
|
||||
// The overlay redraw runs via CallAfter + coroutine; poll the pixels.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const after = await canvas.screenshot();
|
||||
return !after.equals(before);
|
||||
},
|
||||
{ timeout: 15000, intervals: [500] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// No state leak: the remote render never enters the local selection.
|
||||
const localSel = await page.evaluate(() =>
|
||||
JSON.parse(
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabGetSelection(),
|
||||
),
|
||||
);
|
||||
expect(localSel).toEqual([]);
|
||||
|
||||
// Clearing the peers restores the original pixels.
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as PresenceWindow).Module.kicadCollabSetRemote(
|
||||
JSON.stringify({ peers: [] }),
|
||||
),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const after = await canvas.screenshot();
|
||||
return after.equals(before);
|
||||
},
|
||||
{ timeout: 15000, intervals: [500] },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
||||
test("viewport export returns a sane world↔screen transform", async ({ page, testLogger }) => {
|
||||
await bootAndOpen(page);
|
||||
|
||||
const vp = await page.evaluate(() =>
|
||||
JSON.parse((window as unknown as PresenceWindow).Module.kicadCollabGetViewport()),
|
||||
);
|
||||
expect(vp.w).toBeGreaterThan(0);
|
||||
expect(vp.h).toBeGreaterThan(0);
|
||||
// scale must be px per IU (nm) THROUGH THE GAL MATRIX — VIEW::GetScale() is
|
||||
// the zoom (O(1)), which once shipped here and made every screen-constant
|
||||
// overlay size sub-nanometre. A framed A4 sheet is a few thousand px per
|
||||
// 3e8 nm, so the true value is O(1e-5).
|
||||
expect(vp.scale).toBeGreaterThan(0);
|
||||
expect(vp.scale).toBeLessThan(0.01);
|
||||
const worldWidthMm = vp.w / vp.scale / 1e6;
|
||||
expect(worldWidthMm).toBeGreaterThan(50);
|
||||
expect(worldWidthMm).toBeLessThan(3000);
|
||||
// The opened A4 board frames around (148.5,105) mm — the center must be on-board.
|
||||
expect(vp.cx).toBeGreaterThan(0);
|
||||
expect(vp.cx).toBeLessThan(400e6);
|
||||
expect(vp.cy).toBeGreaterThan(0);
|
||||
expect(vp.cy).toBeLessThan(400e6);
|
||||
|
||||
expect(hasAbort(testLogger)).toBe(false);
|
||||
});
|
||||
|
|
@ -45,6 +45,13 @@ std::string pcbCollabTestMoveFirst( int aDx, int aDy );
|
|||
std::string pcbCollabGetPos( std::string aId );
|
||||
bool pcbCollabTestRemoveItem( std::string aId );
|
||||
bool pcbCollabTestRotateItem( std::string aId, double aDeg );
|
||||
// Presence (collab-presence 0002) — pcbnew only until 0003 adds the sch side.
|
||||
void pcbCollabPresenceStart();
|
||||
void pcbCollabSetRemote( std::string aJson );
|
||||
std::string pcbCollabGetViewport();
|
||||
std::string pcbCollabGetSelection();
|
||||
std::string pcbCollabTestSelectFirst();
|
||||
bool pcbCollabTestClearSelection();
|
||||
|
||||
bool schEditorActive();
|
||||
void schCollabApply( std::string aJson );
|
||||
|
|
@ -133,6 +140,41 @@ static bool collabTestRotateItem( std::string aId, double aDeg )
|
|||
: schCollabTestRotateItem( aId, aDeg );
|
||||
}
|
||||
|
||||
// Presence shims (collab-presence 0002): pcb-only for now — the sch frame no-ops /
|
||||
// returns empty until 0003 lands schCollab* counterparts, matching how the JS side
|
||||
// gates presence on the active tool.
|
||||
static void collabPresenceStart()
|
||||
{
|
||||
if( pcbEditorActive() )
|
||||
pcbCollabPresenceStart();
|
||||
}
|
||||
|
||||
static void collabSetRemote( std::string aJson )
|
||||
{
|
||||
if( pcbEditorActive() )
|
||||
pcbCollabSetRemote( aJson );
|
||||
}
|
||||
|
||||
static std::string collabGetViewport()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabGetViewport() : std::string();
|
||||
}
|
||||
|
||||
static std::string collabGetSelection()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabGetSelection() : std::string( "[]" );
|
||||
}
|
||||
|
||||
static std::string collabTestSelectFirst()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabTestSelectFirst() : std::string();
|
||||
}
|
||||
|
||||
static bool collabTestClearSelection()
|
||||
{
|
||||
return pcbEditorActive() ? pcbCollabTestClearSelection() : false;
|
||||
}
|
||||
|
||||
|
||||
EMSCRIPTEN_BINDINGS(kicad_editor) {
|
||||
// Programmatic file open (preferred over UI automation from the web app).
|
||||
|
|
@ -150,6 +192,13 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
|
|||
// endpoint, field text — flow from the per-editor blocks unchanged).
|
||||
function("kicadCollabTestRemoveItem", &collabTestRemoveItem);
|
||||
function("kicadCollabTestRotateItem", &collabTestRotateItem);
|
||||
// Presence (collab-presence 0002).
|
||||
function("kicadCollabPresenceStart", &collabPresenceStart);
|
||||
function("kicadCollabSetRemote", &collabSetRemote);
|
||||
function("kicadCollabGetViewport", &collabGetViewport);
|
||||
function("kicadCollabGetSelection", &collabGetSelection);
|
||||
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);
|
||||
function("kicadCollabTestClearSelection", &collabTestClearSelection);
|
||||
}
|
||||
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
#include <io/kicad/kicad_io_utils.h>
|
||||
#include <richio.h>
|
||||
#include <tools/pcb_selection.h>
|
||||
#include <tools/pcb_selection_tool.h>
|
||||
#include <geometry/shape_poly_set.h>
|
||||
#include <geometry/shape_line_chain.h>
|
||||
#include <geometry/eda_angle.h>
|
||||
|
|
@ -38,13 +39,21 @@
|
|||
#include <kiid.h>
|
||||
#include <layer_ids.h>
|
||||
#include <lset.h>
|
||||
#include <math/util.h>
|
||||
#include <tool/coroutine.h>
|
||||
#include <tool/tool_manager.h>
|
||||
#include <view/view.h>
|
||||
#include <view/view_overlay.h>
|
||||
#include <pcb_draw_panel_gal.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <chrono>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <wx/app.h>
|
||||
#include <wx/event.h>
|
||||
#include <wx/string.h>
|
||||
#include <wx/window.h>
|
||||
|
||||
|
|
@ -825,6 +834,11 @@ void scheduleFlush()
|
|||
}
|
||||
}
|
||||
|
||||
// Presence (collab-presence 0002): board changes often change the selection too
|
||||
// (delete, paste) with no closing canvas event — piggyback a selection re-check
|
||||
// on the collab listener trigger. Defined in the presence section below.
|
||||
void schedulePresenceSelCheck();
|
||||
|
||||
// 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,
|
||||
|
|
@ -859,6 +873,7 @@ private:
|
|||
noteDirty( item );
|
||||
|
||||
scheduleFlush();
|
||||
schedulePresenceSelCheck();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1064,6 +1079,280 @@ void collabTestMove( PCB_EDIT_FRAME* aFrame, BOARD_ITEM* aItem, int aDx, int aDy
|
|||
commit.Push( wxT( "Collab test move" ) );
|
||||
}
|
||||
|
||||
// ───────────────────────── collab presence (collab-presence 0002) ─────────────────────────
|
||||
//
|
||||
// Ephemeral presence: emit THIS tab's selection + cursor to JS (they ride Yjs awareness),
|
||||
// and render REMOTE peers' cursors + selection outlines into a per-user-colored
|
||||
// KIGFX::VIEW_OVERLAY. Never via the SELECTED/BRIGHTENED item flags — those have one
|
||||
// global color and mutate real selection state (events, serialization, races with the
|
||||
// local user); the overlay's command list takes a COLOR4D per command, never enters
|
||||
// m_selection, and never serializes.
|
||||
//
|
||||
// Zero kicad-fork changes: the input triggers are wx-layer Bind() handlers on the GAL
|
||||
// canvas (bound after WX_VIEW_CONTROLS' own, so they run FIRST and Skip() onward), the
|
||||
// selection is read from PCB_SELECTION_TOOL after the event settles (CallAfter), and the
|
||||
// repaint is the MakeOverlay()/VIEW::Update()/ForceRefresh() combo the cross-probe flash
|
||||
// already uses. Selection changes with no closing canvas event (Edit→Select All from the
|
||||
// menu) are missed until the next input or board change — acceptable for an ephemeral
|
||||
// layer that fully resyncs on every emit.
|
||||
namespace presence {
|
||||
|
||||
struct PEER
|
||||
{
|
||||
std::string name;
|
||||
KIGFX::COLOR4D color;
|
||||
bool hasCursor = false;
|
||||
VECTOR2D cursor; // world coords (IU/nm)
|
||||
std::vector<KIID> selection;
|
||||
};
|
||||
|
||||
std::vector<PEER> g_peers;
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> g_overlay;
|
||||
bool g_started = false;
|
||||
bool g_redrawScheduled = false;
|
||||
bool g_selCheckScheduled = false;
|
||||
std::string g_lastSelectionJson; // dedupe: emit only when the uuid set changed
|
||||
long long g_lastCursorEmitMs = 0;
|
||||
double g_lastVpScale = 0.0;
|
||||
VECTOR2D g_lastVpCenter;
|
||||
|
||||
long long nowMs()
|
||||
{
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now().time_since_epoch() )
|
||||
.count();
|
||||
}
|
||||
|
||||
KIGFX::COLOR4D parseColor( const std::string& aHex )
|
||||
{
|
||||
if( aHex.size() == 7 && aHex[0] == '#' )
|
||||
{
|
||||
long v = strtol( aHex.c_str() + 1, nullptr, 16 );
|
||||
return KIGFX::COLOR4D( ( ( v >> 16 ) & 0xff ) / 255.0, ( ( v >> 8 ) & 0xff ) / 255.0,
|
||||
( v & 0xff ) / 255.0, 0.9 );
|
||||
}
|
||||
|
||||
return KIGFX::COLOR4D( 0.23, 0.51, 0.96, 0.9 ); // palette blue fallback
|
||||
}
|
||||
|
||||
json selectionUuids( PCB_EDIT_FRAME* aFrame )
|
||||
{
|
||||
json uuids = json::array();
|
||||
|
||||
PCB_SELECTION_TOOL* selTool = aFrame->GetToolManager()->GetTool<PCB_SELECTION_TOOL>();
|
||||
|
||||
if( !selTool )
|
||||
return uuids;
|
||||
|
||||
for( EDA_ITEM* item : selTool->GetSelection() )
|
||||
uuids.push_back( toUtf8( item->m_Uuid.AsString() ) );
|
||||
|
||||
return uuids;
|
||||
}
|
||||
|
||||
// Post-settle selection emit: read the selection AFTER the triggering event finished
|
||||
// (CallAfter), dedupe against the last emitted set, hand the uuid list to JS.
|
||||
void checkSelection()
|
||||
{
|
||||
g_selCheckScheduled = false;
|
||||
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
std::string s = selectionUuids( fr ).dump();
|
||||
|
||||
if( s == g_lastSelectionJson )
|
||||
return;
|
||||
|
||||
g_lastSelectionJson = s;
|
||||
|
||||
EM_ASM( {
|
||||
if( window.kicadCollab && window.kicadCollab.onSelection )
|
||||
window.kicadCollab.onSelection( UTF8ToString( $0 ) );
|
||||
}, s.c_str() );
|
||||
}
|
||||
|
||||
// Repaint the remote-peers overlay. Runs in CallAfter + COROUTINE: the first
|
||||
// MakeOverlay() view->Add and each item's virtual ViewBBox() need the fiber stack
|
||||
// (asyncify virtual dispatch — same constraint as doApply above).
|
||||
void redrawOverlay()
|
||||
{
|
||||
g_redrawScheduled = false;
|
||||
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
|
||||
|
||||
if( !g_overlay )
|
||||
g_overlay = view->MakeOverlay();
|
||||
|
||||
g_overlay->Clear();
|
||||
|
||||
BOARD* board = fr->GetBoard();
|
||||
// Screen-constant sizing: px → world units, so cursors/outline widths don't
|
||||
// scale with zoom. MUST go through the GAL matrix (ToWorld(double)) — the
|
||||
// naive 1/GetScale() is the ZOOM factor, not px-per-IU (the GAL world-unit
|
||||
// scale is folded into the matrix only), and it under-sizes the drawing by
|
||||
// ~7 orders of magnitude (invisible cursors).
|
||||
double px = view->ToWorld( 1.0 );
|
||||
|
||||
for( const PEER& peer : g_peers )
|
||||
{
|
||||
g_overlay->SetIsFill( false );
|
||||
g_overlay->SetIsStroke( true );
|
||||
g_overlay->SetStrokeColor( peer.color );
|
||||
g_overlay->SetLineWidth( 1.5 * px );
|
||||
|
||||
for( const KIID& id : peer.selection )
|
||||
{
|
||||
BOARD_ITEM* item = board->ResolveItem( id, /*aAllowNullptrReturn*/ true );
|
||||
|
||||
if( !item )
|
||||
continue; // not on this board (yet) — skip silently
|
||||
|
||||
BOX2I bb = item->ViewBBox();
|
||||
bb.Inflate( KiROUND( 4 * px ) );
|
||||
g_overlay->Rectangle( bb.GetOrigin(), bb.GetEnd() );
|
||||
}
|
||||
|
||||
if( peer.hasCursor )
|
||||
{
|
||||
g_overlay->SetLineWidth( 2.0 * px );
|
||||
g_overlay->Cross( peer.cursor, KiROUND( 7 * px ) );
|
||||
|
||||
if( !peer.name.empty() )
|
||||
{
|
||||
g_overlay->SetGlyphSize( VECTOR2I( KiROUND( 10 * px ), KiROUND( 10 * px ) ) );
|
||||
g_overlay->BitmapText( wxString::FromUTF8( peer.name.c_str() ),
|
||||
VECTOR2I( KiROUND( peer.cursor.x + 10 * px ),
|
||||
KiROUND( peer.cursor.y + 16 * px ) ),
|
||||
ANGLE_0 );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view->Update( g_overlay.get() );
|
||||
// The canvas repaints on its own only with focus/input — force it, exactly as
|
||||
// the cross-probe flash does.
|
||||
fr->GetCanvas()->ForceRefresh();
|
||||
}
|
||||
|
||||
void scheduleRedraw()
|
||||
{
|
||||
if( g_redrawScheduled )
|
||||
return;
|
||||
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
g_redrawScheduled = true;
|
||||
|
||||
fr->CallAfter( []() {
|
||||
COROUTINE<int, int> cor( []( int ) -> int
|
||||
{
|
||||
redrawOverlay();
|
||||
return 0;
|
||||
} );
|
||||
cor.Call( 0 );
|
||||
} );
|
||||
}
|
||||
|
||||
// Viewport emit (world→screen mapping for the React comment/DOM layer, 0005): pushed
|
||||
// when the view center/scale changed (checked from the input handlers below), pulled
|
||||
// any time via kicadCollabGetViewport. Zoom also invalidates the overlay's
|
||||
// screen-constant sizes, so a change schedules a redraw too.
|
||||
void emitViewportIfChanged()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
|
||||
double scale = view->GetScale(); // zoom — cheap change detector only
|
||||
VECTOR2D c = view->GetCenter();
|
||||
|
||||
if( scale == g_lastVpScale && c == g_lastVpCenter )
|
||||
return;
|
||||
|
||||
g_lastVpScale = scale;
|
||||
g_lastVpCenter = c;
|
||||
|
||||
const VECTOR2I& sz = view->GetScreenPixelSize();
|
||||
// px per IU via the GAL matrix — GetScale() is the zoom, not px/IU.
|
||||
double pxPerIu = view->ToScreen( 1.0 );
|
||||
|
||||
EM_ASM( {
|
||||
if( window.kicadCollab && window.kicadCollab.onViewport )
|
||||
window.kicadCollab.onViewport( $0, $1, $2, $3, $4 );
|
||||
}, c.x, c.y, pxPerIu, sz.x, sz.y );
|
||||
|
||||
if( !g_peers.empty() )
|
||||
scheduleRedraw();
|
||||
}
|
||||
|
||||
void emitCursor( double aX, double aY, bool aActive )
|
||||
{
|
||||
EM_ASM( {
|
||||
if( window.kicadCollab && window.kicadCollab.onCursor )
|
||||
window.kicadCollab.onCursor( $0, $1, $2 );
|
||||
}, aX, aY, aActive ? 1 : 0 );
|
||||
}
|
||||
|
||||
void onMotion( wxMouseEvent& aEvt )
|
||||
{
|
||||
aEvt.Skip();
|
||||
|
||||
long long now = nowMs();
|
||||
|
||||
if( now - g_lastCursorEmitMs < 50 ) // ≤20 emits/s, event-driven (no timers)
|
||||
return;
|
||||
|
||||
g_lastCursorEmitMs = now;
|
||||
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
// Screen→world via the non-virtual VIEW::ToWorld (the virtual
|
||||
// VIEW_CONTROLS::GetMousePosition is an asyncify dispatch risk here).
|
||||
wxPoint p = aEvt.GetPosition();
|
||||
VECTOR2D world = fr->GetCanvas()->GetView()->ToWorld( VECTOR2D( p.x, p.y ), true );
|
||||
|
||||
emitCursor( world.x, world.y, true );
|
||||
emitViewportIfChanged(); // catches drag-pan while moving
|
||||
}
|
||||
|
||||
void onLeave( wxMouseEvent& aEvt )
|
||||
{
|
||||
aEvt.Skip();
|
||||
emitCursor( 0, 0, false );
|
||||
}
|
||||
|
||||
} // namespace presence
|
||||
|
||||
void schedulePresenceSelCheck()
|
||||
{
|
||||
if( presence::g_selCheckScheduled )
|
||||
return;
|
||||
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
presence::g_selCheckScheduled = true;
|
||||
fr->CallAfter( []() { presence::checkSelection(); } );
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
|
|
@ -1287,6 +1576,160 @@ std::string pcbCollabGetPos( std::string aId )
|
|||
}
|
||||
|
||||
|
||||
// ── presence entry points (collab-presence 0002) ────────────────────────────────────────────
|
||||
|
||||
// Install the presence input hooks on the GAL canvas (idempotent). Called by the JS
|
||||
// presence binding at collab attach; also implied by the first kicadCollabSetRemote.
|
||||
// Handlers Skip() so WX_VIEW_CONTROLS' own processing is untouched; selection checks
|
||||
// run POST-event via CallAfter (the selection tool acts on the same event after us).
|
||||
void pcbCollabPresenceStart()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr || presence::g_started )
|
||||
return;
|
||||
|
||||
presence::g_started = true;
|
||||
|
||||
wxWindow* canvas = fr->GetCanvas();
|
||||
|
||||
canvas->Bind( wxEVT_MOTION, []( wxMouseEvent& e ) { presence::onMotion( e ); } );
|
||||
canvas->Bind( wxEVT_LEAVE_WINDOW, []( wxMouseEvent& e ) { presence::onLeave( e ); } );
|
||||
|
||||
auto selAndViewport = []( wxEvent& e )
|
||||
{
|
||||
e.Skip();
|
||||
schedulePresenceSelCheck();
|
||||
|
||||
if( PCB_EDIT_FRAME* f = pcbFrame() )
|
||||
f->CallAfter( []() { presence::emitViewportIfChanged(); } );
|
||||
};
|
||||
|
||||
canvas->Bind( wxEVT_LEFT_UP, [selAndViewport]( wxMouseEvent& e ) { selAndViewport( e ); } );
|
||||
canvas->Bind( wxEVT_RIGHT_UP, [selAndViewport]( wxMouseEvent& e ) { selAndViewport( e ); } );
|
||||
canvas->Bind( wxEVT_KEY_UP, [selAndViewport]( wxKeyEvent& e ) { selAndViewport( e ); } );
|
||||
canvas->Bind( wxEVT_MOUSEWHEEL, [selAndViewport]( wxMouseEvent& e ) { selAndViewport( e ); } );
|
||||
}
|
||||
|
||||
// JS → C++: full remote-peers snapshot — `{peers:[{id,name,color,cursor:{x,y}|null,
|
||||
// selection:[uuid]}]}`, trivially derived from awareness.getStates() and idempotent
|
||||
// (the overlay is cleared + fully redrawn). An empty peers list clears the overlay.
|
||||
void pcbCollabSetRemote( std::string aJson )
|
||||
{
|
||||
json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
|
||||
|
||||
if( j.is_discarded() )
|
||||
return;
|
||||
|
||||
std::vector<presence::PEER> peers;
|
||||
|
||||
for( const json& p : j.value( "peers", json::array() ) )
|
||||
{
|
||||
presence::PEER peer;
|
||||
peer.name = p.value( "name", "" );
|
||||
peer.color = presence::parseColor( p.value( "color", "" ) );
|
||||
|
||||
if( p.contains( "cursor" ) && p["cursor"].is_object() )
|
||||
{
|
||||
peer.hasCursor = true;
|
||||
peer.cursor = VECTOR2D( p["cursor"].value( "x", 0.0 ), p["cursor"].value( "y", 0.0 ) );
|
||||
}
|
||||
|
||||
for( const json& u : p.value( "selection", json::array() ) )
|
||||
{
|
||||
if( u.is_string() )
|
||||
peer.selection.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
|
||||
}
|
||||
|
||||
peers.push_back( std::move( peer ) );
|
||||
}
|
||||
|
||||
presence::g_peers = std::move( peers );
|
||||
pcbCollabPresenceStart();
|
||||
presence::scheduleRedraw();
|
||||
}
|
||||
|
||||
// JS pull of the current viewport transform (world↔screen mapping for the DOM layer):
|
||||
// `{cx,cy,scale,w,h}` — world center, pixels-per-IU scale, canvas size in px.
|
||||
std::string pcbCollabGetViewport()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return "";
|
||||
|
||||
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
|
||||
VECTOR2D c = view->GetCenter();
|
||||
const VECTOR2I& sz = view->GetScreenPixelSize();
|
||||
|
||||
// scale = px per IU via the GAL matrix (GetScale() is the zoom, not px/IU).
|
||||
return json{ { "cx", c.x }, { "cy", c.y }, { "scale", view->ToScreen( 1.0 ) },
|
||||
{ "w", sz.x }, { "h", sz.y } }.dump();
|
||||
}
|
||||
|
||||
// JS pull of the CURRENT selection's uuids (presence seed at attach + the e2e's
|
||||
// no-state-leak probe: a remote render must leave this empty).
|
||||
std::string pcbCollabGetSelection()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return "[]";
|
||||
|
||||
return presence::selectionUuids( fr ).dump();
|
||||
}
|
||||
|
||||
// Test helper: REALLY select the first top-level item through the selection tool (the
|
||||
// same call the cross-probe flash uses), then run the presence check — a programmatic
|
||||
// select has no closing canvas event. Returns the selected uuid.
|
||||
std::string pcbCollabTestSelectFirst()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return "";
|
||||
|
||||
BOARD_ITEM* target = nullptr;
|
||||
|
||||
forEachTopItem( *fr->GetBoard(), [&]( BOARD_ITEM* item )
|
||||
{
|
||||
if( !target )
|
||||
target = item;
|
||||
} );
|
||||
|
||||
if( !target )
|
||||
return "";
|
||||
|
||||
fr->CallAfter( [fr, target]() {
|
||||
if( PCB_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<PCB_SELECTION_TOOL>() )
|
||||
{
|
||||
st->AddItemToSel( target );
|
||||
schedulePresenceSelCheck();
|
||||
}
|
||||
} );
|
||||
|
||||
return toUtf8( target->m_Uuid.AsString() );
|
||||
}
|
||||
|
||||
// Test helper: clear the selection through the tool + run the presence check.
|
||||
bool pcbCollabTestClearSelection()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return false;
|
||||
|
||||
fr->CallAfter( [fr]() {
|
||||
if( PCB_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<PCB_SELECTION_TOOL>() )
|
||||
{
|
||||
st->ClearSelection();
|
||||
schedulePresenceSelCheck();
|
||||
}
|
||||
} );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Test helper: the s-expr clipboard blob for an item by uuid (what the emit side attaches to an
|
||||
// `added` payload). Lets the e2e round-trip the blob add path without a real draw.
|
||||
std::string kicadCollabTestItemBlob( std::string aId )
|
||||
|
|
@ -1531,6 +1974,14 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
|||
// ysync-review repro hooks shared with eeschema (dispatched when merged).
|
||||
function("kicadCollabTestRemoveItem", &pcbCollabTestRemoveItem);
|
||||
function("kicadCollabTestRotateItem", &pcbCollabTestRotateItem);
|
||||
// Presence (collab-presence 0002) — shared names; eeschema's counterparts land
|
||||
// with 0003 (the merged image dispatches pcb-only until then).
|
||||
function("kicadCollabPresenceStart", &pcbCollabPresenceStart);
|
||||
function("kicadCollabSetRemote", &pcbCollabSetRemote);
|
||||
function("kicadCollabGetViewport", &pcbCollabGetViewport);
|
||||
function("kicadCollabGetSelection", &pcbCollabGetSelection);
|
||||
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);
|
||||
function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection);
|
||||
#endif // !KICAD_MERGED_EMBIND
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ import {
|
|||
type PresenceHandle,
|
||||
type PresencePeer,
|
||||
} from "@/wasm/collab/presence";
|
||||
import {
|
||||
bindKicadPresence,
|
||||
hasPresenceBridge,
|
||||
type PresenceKicadWindow,
|
||||
} from "@/wasm/collab/presence-kicad";
|
||||
import { PresenceRoster } from "@/components/PresenceRoster";
|
||||
import {
|
||||
createSheetCollabManager,
|
||||
|
|
@ -580,6 +585,7 @@ export function WasmTool({
|
|||
const startedRef = React.useRef(false);
|
||||
const driftRef = React.useRef<{ stop(): void } | null>(null);
|
||||
const presenceRef = React.useRef<PresenceHandle | null>(null);
|
||||
const presenceBridgeRef = React.useRef<{ destroy(): void } | null>(null);
|
||||
const sheetManagerRef = React.useRef<SheetCollabManager | null>(null);
|
||||
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
|
||||
// (miss 08B); eeschema routes per sheet through the manager instead.
|
||||
|
|
@ -748,6 +754,8 @@ export function WasmTool({
|
|||
// pcbnew/pl_editor bind once; eeschema rebinds per active sheet, so the
|
||||
// roster shows who is on the SAME sheet (room = sheet).
|
||||
const startPresence = (provider: YjsProvider | undefined, sheetPath?: string) => {
|
||||
presenceBridgeRef.current?.destroy();
|
||||
presenceBridgeRef.current = null;
|
||||
presenceRef.current?.destroy();
|
||||
presenceRef.current = null;
|
||||
const awareness = provider?.awareness;
|
||||
|
|
@ -764,6 +772,16 @@ export function WasmTool({
|
|||
presenceRef.current = presence;
|
||||
presence.subscribe(setPeers);
|
||||
setPeers(presence.peers());
|
||||
// Canvas presence (0002): cursor + selection emit and the remote
|
||||
// VIEW_OVERLAY render. pcbnew only until the eeschema port (0003); the
|
||||
// bridge gate also skips wasm builds predating the presence exports.
|
||||
if (tool === "pcbnew" && hasPresenceBridge(win.Module)) {
|
||||
presenceBridgeRef.current = bindKicadPresence({
|
||||
mod: win.Module,
|
||||
win: win as unknown as PresenceKicadWindow,
|
||||
presence,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Cmd/Ctrl+S belongs to the editor: preventDefault suppresses ONLY the
|
||||
|
|
@ -949,6 +967,8 @@ export function WasmTool({
|
|||
|
||||
return () => {
|
||||
win.removeEventListener("keydown", swallowBrowserSave, true);
|
||||
presenceBridgeRef.current?.destroy();
|
||||
presenceBridgeRef.current = null;
|
||||
presenceRef.current?.destroy();
|
||||
presenceRef.current = null;
|
||||
driftRef.current?.stop();
|
||||
|
|
|
|||
124
web/standalone/src/wasm/collab/presence-kicad.test.ts
Normal file
124
web/standalone/src/wasm/collab/presence-kicad.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { colorForUser } from "@pcbjam/shared";
|
||||
import { bindKicadPresence, hasPresenceBridge, type PresenceKicadWindow } from "./presence-kicad";
|
||||
import type { PresenceHandle, PresencePeer } from "./presence";
|
||||
|
||||
/**
|
||||
* presence-kicad bridge unit tests (collab-presence 0002): a fake Module +
|
||||
* window stand in for the wasm side; a stub presence handle stands in for
|
||||
* awareness. Verifies both directions and teardown.
|
||||
*/
|
||||
|
||||
function fakeModule() {
|
||||
return {
|
||||
kicadCollabPresenceStart: vi.fn(),
|
||||
kicadCollabSetRemote: vi.fn(),
|
||||
kicadCollabGetViewport: vi.fn(() => '{"cx":0,"cy":0,"scale":1,"w":800,"h":600}'),
|
||||
kicadCollabGetSelection: vi.fn(() => "[]"),
|
||||
};
|
||||
}
|
||||
|
||||
function stubPresence(peers: PresencePeer[] = []) {
|
||||
const subscribers = new Set<(p: PresencePeer[]) => void>();
|
||||
const handle: PresenceHandle & { firePeers(p: PresencePeer[]): void } = {
|
||||
peers: () => peers,
|
||||
subscribe(cb) {
|
||||
subscribers.add(cb);
|
||||
return () => subscribers.delete(cb);
|
||||
},
|
||||
setCursor: vi.fn(),
|
||||
setSelection: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
firePeers(p) {
|
||||
peers = p;
|
||||
for (const cb of subscribers) cb(p);
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
|
||||
function peer(id: string, over: Partial<PresencePeer> = {}): PresencePeer {
|
||||
return {
|
||||
clientId: 1,
|
||||
user: { id, name: id, color: colorForUser(id) },
|
||||
tool: "pcbnew",
|
||||
cursor: null,
|
||||
selection: [],
|
||||
updatedAt: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("bindKicadPresence", () => {
|
||||
it("routes C++ emits into the presence setters", () => {
|
||||
const mod = fakeModule();
|
||||
const win: PresenceKicadWindow = {};
|
||||
const presence = stubPresence();
|
||||
bindKicadPresence({ mod, win, presence });
|
||||
|
||||
expect(mod.kicadCollabPresenceStart).toHaveBeenCalled();
|
||||
|
||||
win.kicadCollab!.onSelection!('["uuid-1","uuid-2"]');
|
||||
expect(presence.setSelection).toHaveBeenCalledWith(["uuid-1", "uuid-2"]);
|
||||
|
||||
win.kicadCollab!.onCursor!(5, 7, 1);
|
||||
expect(presence.setCursor).toHaveBeenCalledWith({ x: 5, y: 7 });
|
||||
win.kicadCollab!.onCursor!(0, 0, 0);
|
||||
expect(presence.setCursor).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("seeds an existing selection from the wasm at attach", () => {
|
||||
const mod = fakeModule();
|
||||
mod.kicadCollabGetSelection.mockReturnValue('["pre-selected"]');
|
||||
const presence = stubPresence();
|
||||
bindKicadPresence({ mod, win: {}, presence });
|
||||
expect(presence.setSelection).toHaveBeenCalledWith(["pre-selected"]);
|
||||
});
|
||||
|
||||
it("pushes a throttled remote snapshot on peers change", async () => {
|
||||
const mod = fakeModule();
|
||||
const presence = stubPresence();
|
||||
bindKicadPresence({ mod, win: {}, presence });
|
||||
expect(mod.kicadCollabSetRemote).toHaveBeenCalledTimes(1); // initial push
|
||||
|
||||
presence.firePeers([
|
||||
peer("bob", { cursor: { x: 10, y: 20 }, selection: ["u1"] }),
|
||||
]);
|
||||
presence.firePeers([
|
||||
peer("bob", { cursor: { x: 11, y: 21 }, selection: ["u1"] }),
|
||||
]);
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
|
||||
// Two rapid changes coalesce into ONE trailing push carrying the latest state.
|
||||
expect(mod.kicadCollabSetRemote).toHaveBeenCalledTimes(2);
|
||||
const snapshot = JSON.parse(mod.kicadCollabSetRemote.mock.calls.at(-1)![0]);
|
||||
expect(snapshot.peers).toEqual([
|
||||
{
|
||||
id: "bob",
|
||||
name: "bob",
|
||||
color: colorForUser("bob"),
|
||||
cursor: { x: 11, y: 21 },
|
||||
selection: ["u1"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("destroy unhooks the window and clears the remote overlay", () => {
|
||||
const mod = fakeModule();
|
||||
const win: PresenceKicadWindow = {};
|
||||
const presence = stubPresence();
|
||||
const binding = bindKicadPresence({ mod, win, presence });
|
||||
|
||||
binding.destroy();
|
||||
expect(win.kicadCollab?.onSelection).toBeUndefined();
|
||||
expect(win.kicadCollab?.onCursor).toBeUndefined();
|
||||
const last = JSON.parse(mod.kicadCollabSetRemote.mock.calls.at(-1)![0]);
|
||||
expect(last.peers).toEqual([]);
|
||||
});
|
||||
|
||||
it("hasPresenceBridge gates on the 0002 exports", () => {
|
||||
expect(hasPresenceBridge(fakeModule())).toBe(true);
|
||||
expect(hasPresenceBridge({})).toBe(false);
|
||||
expect(hasPresenceBridge(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
139
web/standalone/src/wasm/collab/presence-kicad.ts
Normal file
139
web/standalone/src/wasm/collab/presence-kicad.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { clog } from "./debug";
|
||||
import type { PresenceHandle, PresencePeer } from "./presence";
|
||||
|
||||
/**
|
||||
* Wire the C++ presence bridge (collab-presence 0002) to the awareness layer:
|
||||
*
|
||||
* C++ → awareness: `window.kicadCollab.onSelection/onCursor` (this tab's
|
||||
* selection uuids + world-coord cursor, emitted by the wasm input hooks)
|
||||
* publish into the room via the presence handle's setters.
|
||||
*
|
||||
* awareness → C++: on every peers change, push a full remote snapshot to
|
||||
* `Module.kicadCollabSetRemote` — the wasm side clears + redraws its
|
||||
* VIEW_OVERLAY from it (idempotent), so no delta bookkeeping is needed.
|
||||
* Pushes are trailing-throttled: N peers × 20 cursor updates/s must not
|
||||
* cross the JS↔wasm boundary per event.
|
||||
*
|
||||
* `onViewport` (world↔screen transform) feeds an optional callback for the
|
||||
* DOM layers (comment pins, 0005); the roster doesn't need it.
|
||||
*/
|
||||
|
||||
export interface PresenceKicadModule {
|
||||
kicadCollabPresenceStart(): void;
|
||||
kicadCollabSetRemote(json: string): void;
|
||||
kicadCollabGetViewport(): string;
|
||||
kicadCollabGetSelection(): string;
|
||||
}
|
||||
|
||||
export interface PresenceKicadWindow {
|
||||
kicadCollab?: {
|
||||
onSelection?: (uuidsJson: string) => void;
|
||||
onCursor?: (x: number, y: number, active: number) => void;
|
||||
onViewport?: (cx: number, cy: number, scale: number, w: number, h: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/** The GAL viewport transform: world center (IU), pixels-per-IU scale, px size. */
|
||||
export interface ViewportState {
|
||||
cx: number;
|
||||
cy: number;
|
||||
scale: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** True when the loaded wasm exposes the presence bridge (0002 exports). */
|
||||
export function hasPresenceBridge(mod: unknown): mod is PresenceKicadModule {
|
||||
const m = mod as Partial<PresenceKicadModule> | undefined;
|
||||
return (
|
||||
typeof m?.kicadCollabPresenceStart === "function" &&
|
||||
typeof m?.kicadCollabSetRemote === "function"
|
||||
);
|
||||
}
|
||||
|
||||
const PUSH_THROTTLE_MS = 30;
|
||||
|
||||
export function bindKicadPresence(opts: {
|
||||
mod: PresenceKicadModule;
|
||||
win: PresenceKicadWindow;
|
||||
presence: PresenceHandle;
|
||||
onViewport?: (vp: ViewportState) => void;
|
||||
}): { destroy(): void } {
|
||||
const { mod, win, presence } = opts;
|
||||
|
||||
// C++ → awareness ------------------------------------------------------------
|
||||
win.kicadCollab = {
|
||||
...win.kicadCollab,
|
||||
onSelection: (uuidsJson) => {
|
||||
try {
|
||||
const uuids = JSON.parse(uuidsJson) as string[];
|
||||
presence.setSelection(Array.isArray(uuids) ? uuids : []);
|
||||
} catch {
|
||||
/* malformed emit — keep the last published selection */
|
||||
}
|
||||
},
|
||||
onCursor: (x, y, active) => {
|
||||
presence.setCursor(active ? { x, y } : null);
|
||||
},
|
||||
onViewport: (cx, cy, scale, w, h) => {
|
||||
opts.onViewport?.({ cx, cy, scale, w, h });
|
||||
},
|
||||
};
|
||||
|
||||
// Seed: the tab may attach with a selection already made (e.g. rebind).
|
||||
try {
|
||||
const seed = JSON.parse(mod.kicadCollabGetSelection() || "[]") as string[];
|
||||
if (Array.isArray(seed) && seed.length) presence.setSelection(seed);
|
||||
} catch {
|
||||
/* bridge present but frame not up yet — the first emit will seed */
|
||||
}
|
||||
|
||||
// awareness → C++ ------------------------------------------------------------
|
||||
const pushRemote = () => {
|
||||
const peers = presence.peers();
|
||||
const snapshot = {
|
||||
peers: peers.map((p: PresencePeer) => ({
|
||||
id: p.user.id,
|
||||
name: p.user.name,
|
||||
color: p.user.color,
|
||||
cursor: p.cursor,
|
||||
selection: p.selection,
|
||||
})),
|
||||
};
|
||||
mod.kicadCollabSetRemote(JSON.stringify(snapshot));
|
||||
};
|
||||
|
||||
let pushTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const schedulePush = () => {
|
||||
if (pushTimer) return;
|
||||
pushTimer = setTimeout(() => {
|
||||
pushTimer = undefined;
|
||||
pushRemote();
|
||||
}, PUSH_THROTTLE_MS);
|
||||
};
|
||||
|
||||
const unsubscribe = presence.subscribe(schedulePush);
|
||||
|
||||
mod.kicadCollabPresenceStart();
|
||||
pushRemote();
|
||||
clog("presence-kicad: bridge bound (cursor/selection emit + remote overlay)");
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
unsubscribe();
|
||||
if (pushTimer) clearTimeout(pushTimer);
|
||||
pushTimer = undefined;
|
||||
if (win.kicadCollab) {
|
||||
delete win.kicadCollab.onSelection;
|
||||
delete win.kicadCollab.onCursor;
|
||||
delete win.kicadCollab.onViewport;
|
||||
}
|
||||
// Clear the remote overlay so a dead session leaves no ghost cursors.
|
||||
try {
|
||||
mod.kicadCollabSetRemote(JSON.stringify({ peers: [] }));
|
||||
} catch {
|
||||
/* wasm may already be gone on page teardown */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue