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:
Gergő Törcsvári 2026-07-06 15:59:51 +02:00
commit 8ad1cc673a
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
6 changed files with 1162 additions and 0 deletions

View 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);
});

View file

@ -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__

View file

@ -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

View file

@ -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();

View 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);
});
});

View 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 */
}
},
};
}