diff --git a/tests/kicad/items-bridge.spec.ts b/tests/kicad/items-bridge.spec.ts new file mode 100644 index 0000000..dd75ce9 --- /dev/null +++ b/tests/kicad/items-bridge.spec.ts @@ -0,0 +1,324 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "./fixtures"; + +/** + * v2 "items" bridge (ysync 0008 Stage C): per-item s-expr payloads on + * kicadCollabSnapshotItems / kicadCollabApplyItems / window.kicadCollab.onItems. + * + * Per tool this drives the C++ exports directly (no reconciler/Y.Doc — that's the + * Stage B binding, unit-tested in the standalone): + * 1. snapshotItems: every fixture item's uuid appears in some wire blob; + * 2. applyItems: changed (full/bare blob) + added (bare blob → tool wraps in its + * envelope) + removed (uuid) land in the model — verified via the save export; + * 3. onItems: a genuine local edit emits a wire whose blob carries the item. + */ + +type Mod = { + kicadOpenFile(p: string): unknown; + kicadCollabSnapshotItems(): string; + kicadCollabApplyItems(j: string): unknown; +} & Record unknown>; +type FS = { + mkdirTree(p: string): void; + writeFile(p: string, d: string): void; + readFile(p: string, o: { encoding: "utf8" }): string; +}; + +interface ToolCfg { + tool: string; + html: string; + ext: string; + saveFn: string; + fixture: string; + /** uuids that must appear in the snapshot wire. */ + uuids: string[]; + /** applyItems payloads + the save-text markers proving they landed. Either a + * static sexpr, or derive it from the snapshot blob of `uuid` via replace — + * for pcbnew, whose non-footprint envelopes are the known-fragile parse. */ + changed: + | { sexpr: string; marker: string } + | { fromSnapshotUuid: string; replace: [string, string]; marker: string }; + added: { sexpr: string; uuid: string }; + removedUuid: string; + /** evaluate-able local edit that must trigger an onItems emit. Omitted for + * eeschema: the headless harness can't drive its emit (open=false → + * SCH_COMMIT no-ops — the documented reason eeschema-collab's two-tab test + * is skipped; verified working in the real web app). */ + localEdit?: string; +} + +const BOOT_TIMEOUT = 150000; + +function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean { + return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted(")); +} + +async function bootOpen(page: Page, cfg: ToolCfg): Promise { + await page.goto(`/kicad/${cfg.html}`); + await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT }); + await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: BOOT_TIMEOUT }); + await page.waitForFunction( + (saveFn) => { + const m = (window as unknown as { Module?: Mod }).Module; + return ( + typeof m?.kicadOpenFile === "function" && + typeof m?.kicadCollabSnapshotItems === "function" && + typeof m?.kicadCollabApplyItems === "function" && + typeof m?.[saveFn] === "function" + ); + }, + cfg.saveFn, + { timeout: BOOT_TIMEOUT }, + ); + await page.waitForFunction( + () => + !!window.wxElementRegistry && + window.wxElementRegistry + .findAll({ visible: true }) + .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")), + null, + { timeout: BOOT_TIMEOUT }, + ); + await page.evaluate( + ({ content, ext }) => { + const w = window as unknown as { FS: FS; Module: Mod }; + try { + w.FS.mkdirTree("/home/kicad/documents"); + } catch { + /* exists */ + } + const p = `/home/kicad/documents/rt.${ext}`; + w.FS.writeFile(p, content); + w.Module.kicadOpenFile(p); + }, + { content: cfg.fixture, ext: cfg.ext }, + ); +} + +async function saveRead(page: Page, cfg: ToolCfg, name: string): Promise { + return page.evaluate( + ({ saveFn, ext, name }) => { + const w = window as unknown as { FS: FS; Module: Mod }; + const out = `/home/kicad/documents/${name}.${ext}`; + (w.Module[saveFn] as (p: string) => unknown)(out); + return w.FS.readFile(out, { encoding: "utf8" }); + }, + { saveFn: cfg.saveFn, ext: cfg.ext, name }, + ); +} + +/** Poll the saved model until `marker` is present (apply is async for ee/pcb). */ +async function pollSaved(page: Page, cfg: ToolCfg, marker: string, present = true): Promise { + await expect + .poll(async () => (await saveRead(page, cfg, "probe")).includes(marker), { + timeout: 25000, + intervals: [400], + }) + .toBe(present); +} + +// ── Tool configurations ────────────────────────────────────────────────────── + +const PL: ToolCfg = { + tool: "pl_editor", + html: "pl_editor.html", + ext: "kicad_wks", + saveFn: "kicadSaveDrawingSheet", + fixture: `(kicad_wks (version 20220228) (generator "pl_editor") (generator_version "9.0") + (setup (textsize 1.5 1.5)(linewidth 0.15)(textlinewidth 0.15) + (left_margin 10)(right_margin 10)(top_margin 10)(bottom_margin 10)) + (rect (uuid "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") (name border) (start 0 0 ltcorner) (end 0 0 rbcorner)) + (tbtext "Title" (uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") (name title) (pos 100 20 ltcorner) (font (size 2 2))) +) +`, + uuids: ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"], + changed: { + // Bare item (Y.Doc-rendered shape) — the tool must wrap it in its envelope. + sexpr: `(tbtext "Title" (uuid "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") (name title) (pos 55 65 ltcorner) (font (size 2 2)))`, + marker: "(pos 55 65", + }, + added: { + sexpr: `(tbtext "BareAdd" (uuid "dddddddd-dddd-dddd-dddd-dddddddddddd") (name bare) (pos 10 10 ltcorner) (font (size 2 2)))`, + uuid: "dddddddd-dddd-dddd-dddd-dddddddddddd", + }, + removedUuid: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + localEdit: `window.Module.kicadCollabTestAddText("EmitMe", 40, 40)`, +}; + +const SCH: ToolCfg = { + tool: "eeschema", + html: "eeschema.html", + ext: "kicad_sch", + saveFn: "kicadSaveSchematic", + fixture: `(kicad_sch + (version 20250114) + (generator "eeschema") + (generator_version "9.0") + (uuid "11111111-1111-1111-1111-111111111111") + (paper "A4") + (lib_symbols) + (wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000001")) + (wire (pts (xy 50.8 76.2) (xy 101.6 76.2)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000002")) + (sheet_instances (path "/" (page "1"))) +) +`, + uuids: ["22222222-0000-0000-0000-000000000001", "22222222-0000-0000-0000-000000000002"], + changed: { + sexpr: `(wire (pts (xy 25.4 25.4) (xy 76.2 25.4)) (stroke (width 0) (type default)) (uuid "22222222-0000-0000-0000-000000000001"))`, + marker: "(xy 25.4 25.4)", + }, + added: { + // Connectivity-neutral: a lone junction would be deleted by SCH_COMMIT::Push's + // connection cleanup (it sits on no wire crossing) — that's correct editor + // behavior, not a bridge gap. Text survives. + sexpr: `(text "BareAdd" (exclude_from_sim no) (at 60.96 60.96 0) (effects (font (size 1.27 1.27))) (uuid "33333333-0000-0000-0000-000000000003"))`, + uuid: "33333333-0000-0000-0000-000000000003", + }, + removedUuid: "22222222-0000-0000-0000-000000000002", + // no localEdit: headless harness cannot drive eeschema emit (see ToolCfg note) +}; + +const PCB: ToolCfg = { + tool: "pcbnew", + html: "pcbnew-collab.html", + ext: "kicad_pcb", + saveFn: "kicadSaveBoard", + fixture: `(kicad_pcb + (version 20241229) + (generator "pcbnew") + (generator_version "9.0") + (general (thickness 1.6)) + (paper "A4") + (layers + (0 "F.Cu" signal) + (2 "B.Cu" signal) + (37 "F.SilkS" user) + (25 "Edge.Cuts" user) + ) + (setup) + (net 0 "") + (footprint "TestLib:R" + (layer "F.Cu") + (uuid "66666666-0000-0000-0000-000000000001") + (at 100 100) + (attr smd) + (property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15)))) + (property "Value" "R" (at 0 4.6 0) (layer "F.Fab") (uuid "66666666-0000-0000-0000-0000000000bb") (effects (font (size 1 1) (thickness 0.15)))) + (fp_text user "HELLO" (at 0 0 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000cc") (effects (font (size 1 1) (thickness 0.15)))) + ) + (via (at 80 80) (size 1.4) (drill 0.6) (layers "F.Cu" "B.Cu") (net 0) (uuid "77777777-0000-0000-0000-000000000001")) + (segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "88888888-0000-0000-0000-000000000001")) + (segment (start 50.8 76.2) (end 101.6 76.2) (width 0.2) (layer "F.Cu") (net 0) (uuid "88888888-0000-0000-0000-000000000002")) +) +`, + uuids: [ + "66666666-0000-0000-0000-000000000001", + "77777777-0000-0000-0000-000000000001", + "88888888-0000-0000-0000-000000000001", + ], + // pcbnew v2-apply scope (ysync 0008 Stage C): FOOTPRINT blobs are the proven + // path (bare-footprint parse + replace-by-uuid with children — the 0004 + // containment win). Track/via/zone/text APPLY via the (kicad_pcb …) envelope + // is the codebase's documented asyncify-fragile parse — those types remain on + // the legacy scalar apply until that's solved (tracked in 0008 status). + changed: { + // Replace the footprint wholesale from its own snapshot blob, moved. + fromSnapshotUuid: "66666666-0000-0000-0000-000000000001", + replace: ["(at 100 100)", "(at 90 110)"], + marker: "(at 90 110", + }, + added: { + // A bare footprint parses top-level (no envelope) — the proven add path. + sexpr: `(footprint "TestLib:C" (layer "F.Cu") (uuid "99999999-0000-0000-0000-000000000009") (at 50 50) (attr smd) (property "Reference" "C1" (at 0 -2 0) (layer "F.SilkS") (uuid "99999999-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15)))))`, + uuid: "99999999-0000-0000-0000-000000000009", + }, + removedUuid: "88888888-0000-0000-0000-000000000002", + // no localEdit: pcbnew emit is likewise unverifiable headless (pcbnew-collab two-tab is test.skip) +}; + +// ── The per-tool suite ─────────────────────────────────────────────────────── + +for (const cfg of [PL, SCH, PCB]) { + test.describe(`${cfg.tool} items bridge (v2, per-item s-expr)`, () => { + test.describe.configure({ timeout: 420000 }); + + test(`${cfg.tool}: snapshot, apply (changed/added/removed), emit`, async ({ + page, + testLogger, + }) => { + await bootOpen(page, cfg); + + // 1. snapshotItems: every fixture uuid appears in some wire blob. + const snap = JSON.parse( + await page.evaluate(() => window.Module.kicadCollabSnapshotItems()), + ) as { added: Array<{ sexpr: string; parent: string | null }> }; + const allBlobs = snap.added.map((w) => w.sexpr).join("\n"); + for (const uuid of cfg.uuids) { + expect(allBlobs, `snapshot blob for ${uuid}`).toContain(uuid); + } + + // 2. Register the v2 emit hook. + await page.evaluate(() => { + (window as unknown as { __items: string[] }).__items = []; + (window as unknown as { kicadCollab: object }).kicadCollab = { + onItems: (j: string) => (window as unknown as { __items: string[] }).__items.push(j), + }; + }); + + // 3. A genuine local edit emits an items wire carrying the touched item. + // Run this BEFORE the applies: TestMoveFirst moves the FIRST screen item, + // which must be a fixture wire/track (the proven off-fiber move path) — an + // apply-added text would no-op the virtual Move (known asyncify quirk). + // Skipped when the harness can't drive the tool's emit (see ToolCfg). + if (cfg.localEdit) { + const editedUuid = (await page.evaluate(cfg.localEdit)) as string; + await expect + .poll( + async () => + await page.evaluate( + () => (window as unknown as { __items: string[] }).__items.join("\n"), + ), + { timeout: 20000, intervals: [400] }, + ) + .toContain(editedUuid); + } + const emitsAfterLocalEdit = await page.evaluate( + () => (window as unknown as { __items: string[] }).__items.length, + ); + + // 4. applyItems: changed + added (bare blobs) + removed. + let changedSexpr: string; + if ("fromSnapshotUuid" in cfg.changed) { + const blob = snap.added + .map((w) => w.sexpr) + .find((s) => s.includes((cfg.changed as { fromSnapshotUuid: string }).fromSnapshotUuid)); + expect(blob, "snapshot blob for the changed item").toBeTruthy(); + const [from, to] = cfg.changed.replace; + expect(blob!, `blob contains "${from}"`).toContain(from); + changedSexpr = blob!.replace(from, to); + } else { + changedSexpr = cfg.changed.sexpr; + } + await page.evaluate( + ({ changed, added, removed }) => { + window.Module.kicadCollabApplyItems( + JSON.stringify({ added: [{ sexpr: added }], changed: [{ sexpr: changed }], removed: [removed] }), + ); + }, + { changed: changedSexpr, added: cfg.added.sexpr, removed: cfg.removedUuid }, + ); + + await pollSaved(page, cfg, cfg.changed.marker); + await pollSaved(page, cfg, cfg.added.uuid); + await pollSaved(page, cfg, cfg.removedUuid, false); + + // Remote applies must not have echoed an onItems emit. + const echoed = await page.evaluate( + () => (window as unknown as { __items: string[] }).__items.length, + ); + expect(echoed, "apply must not emit onItems").toBe(emitsAfterLocalEdit); + + expect(hasAbort(testLogger), "no WASM abort").toBe(false); + }); + }); +} diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index 2131e0f..9e1d194 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -25,6 +25,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -287,6 +290,35 @@ void emit( const json& aDelta ) }, s.c_str() ); } +// v2 "items" wire emit (ysync 0008): per-item s-expr blobs instead of decomposed +// scalars. A JS runtime registers window.kicadCollab.onItems to opt in; both wires +// are emitted side by side until the scalar path is retired. +void emitItems( const json& aWire ) +{ + std::string s = aWire.dump(); + EM_ASM( { + if( window.kicadCollab && window.kicadCollab.onItems ) + window.kicadCollab.onItems( UTF8ToString( $0 ) ); + }, s.c_str() ); +} + +// Serialize one live schematic item to its native s-expr via the clipboard +// formatter (the exact path Ctrl-C uses: a one-item SCH_SELECTION through +// SCH_IO_KICAD_SEXPR::Format). For a symbol the output also carries its +// (lib_symbols …) definition, just like a copy does. +std::string itemBlob( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem ) +{ + SCH_SELECTION sel; + sel.SetScreen( aFrame->GetScreen() ); + sel.Add( aItem ); + + STRING_FORMATTER fmt; + SCH_IO_KICAD_SEXPR plugin; + plugin.Format( &sel, &aFrame->GetCurrentSheet(), aFrame->Schematic(), &fmt, + /*aForClipboard*/ true ); + return fmt.GetString(); +} + // ── Emit via post-settle snapshot diff ─────────────────────────────────────────────────── // // A local edit is a single SCH_COMMIT::Push that fires OnItemsAdded/Removed/Changed @@ -352,14 +384,32 @@ void flushDiff() json added = json::array(), changed = json::array(), removed = json::array(); + // v2 items wire (per-item s-expr blobs), built from the same diff. Screen items + // are already root-level (fields live inside their symbols), so no lifting. + json wAdded = json::array(), wChanged = json::array(); + + auto blobFor = [&]( const std::string& id, json& aArr ) + { + KIID kid( wxString::FromUTF8( id.c_str() ) ); + + if( SCH_ITEM* item = fr->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ) ) + aArr.push_back( json{ { "sexpr", itemBlob( fr, item ) }, { "parent", nullptr } } ); + }; + for( const auto& [id, j] : cur ) { auto it = g_baseline.find( id ); if( it == g_baseline.end() ) + { added.push_back( j ); + blobFor( id, wAdded ); + } else if( it->second != j ) + { changed.push_back( j ); + blobFor( id, wChanged ); + } } for( const auto& [id, j] : g_baseline ) @@ -371,11 +421,17 @@ void flushDiff() g_baseline = std::move( cur ); if( !added.empty() || !changed.empty() || !removed.empty() ) + { emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } ); + emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "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. +// turn) into a single post-settle diff. flushDiff runs inside a COROUTINE: its v2 items +// emit serializes items via SCH_IO_KICAD_SEXPR::Format (itemBlob), whose virtual dispatch +// is only reliable on the libcontext fiber stack — on the bare CallAfter stack it traps +// and silently kills the whole flush, legacy emit included (same lesson as doApply, 0007). void scheduleFlush() { if( g_flushScheduled ) @@ -384,9 +440,20 @@ void scheduleFlush() g_flushScheduled = true; if( SCH_EDIT_FRAME* fr = schFrame() ) - fr->CallAfter( []() { flushDiff(); } ); + { + fr->CallAfter( []() { + COROUTINE cor( []( int ) -> int + { + flushDiff(); + return 0; + } ); + cor.Call( 0 ); + } ); + } else + { flushDiff(); + } } // ChangeSource: the native SCHEMATIC_LISTENER is just a trigger — the actual change set comes @@ -565,6 +632,125 @@ void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta ) s_applyingRemote = false; } +// v2 items apply: removed by uuid; added/changed are an idempotent per-item upsert. +// Each blob is parsed through the clipboard-paste path — LoadContent into a throwaway +// sheet (sch_editor_control.cpp Paste pattern) — then the loaded items are detached, +// matched by uuid against the live model (replace), lib-relinked for symbols, and +// committed. Runs inside the apply COROUTINE (see kicadCollabApplyItems). +void doApplyItems( SCH_EDIT_FRAME* aFrame, const json& aWire ) +{ + SCHEMATIC& sch = aFrame->Schematic(); + + s_applyingRemote = true; + + SCH_COMMIT commit( aFrame ); + bool staged = false; + + for( const json& rid : aWire.value( "removed", json::array() ) ) + { + SCH_SHEET_PATH path; + KIID id( wxString::FromUTF8( rid.get().c_str() ) ); + + if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) ) + { + commit.Remove( item, path.LastScreen() ); + staged = true; + } + } + + auto upsert = [&]( const json& w ) + { + std::string sexpr = w.value( "sexpr", "" ); + + if( sexpr.find_first_not_of( " \t\r\n" ) == std::string::npos ) + return; + + // Parse into a throwaway sheet exactly like clipboard paste does. The screen + // is heap-allocated and owned by the sheet (freed with it). + SCH_SHEET tempSheet; + SCH_SCREEN* tempScreen = new SCH_SCREEN( &sch ); + tempSheet.SetScreen( tempScreen ); + + STRING_LINE_READER reader( sexpr, wxT( "collab-items" ) ); + SCH_IO_KICAD_SEXPR plugin; + + try + { + plugin.LoadContent( reader, &tempSheet ); + } + catch( ... ) + { + EM_ASM( { console.log( "[collab] eeschema applyItems: blob parse failed" ); } ); + return; + } + + // Resolve a symbol's LIB_SYMBOL: prefer the blob's own (lib_symbols …) cache + // (a clipboard-style blob carries it), else the live screen's — peers share + // the same document so the definition is normally already present. + auto findLib = [&]( SCH_SYMBOL* aSym ) -> LIB_SYMBOL* + { + wxString lookup = aSym->GetLibId().Format().wx_str(); + + if( !aSym->UseLibIdLookup() ) + lookup = aSym->GetSchSymbolLibraryName(); + + auto& tlibs = tempScreen->GetLibSymbols(); + auto ti = tlibs.find( lookup ); + + if( ti != tlibs.end() ) + return new LIB_SYMBOL( *ti->second ); + + auto& libs = aFrame->GetScreen()->GetLibSymbols(); + auto li = libs.find( lookup ); + + if( li != libs.end() ) + return new LIB_SYMBOL( *li->second ); + + return nullptr; + }; + + std::vector loaded; + + for( SCH_ITEM* item : tempScreen->Items() ) + loaded.push_back( item ); + + for( SCH_ITEM* item : loaded ) + { + tempScreen->Remove( item ); // detach: tempSheet's dtor must not free it + + SCH_SHEET_PATH path; + + if( SCH_ITEM* existing = sch.ResolveItem( item->m_Uuid, &path, /*allowNull*/ true ) ) + commit.Remove( existing, path.LastScreen() ); + + if( item->Type() == SCH_SYMBOL_T ) + { + auto* sym = static_cast( item ); + + if( LIB_SYMBOL* lib = findLib( sym ) ) + sym->SetLibSymbol( lib ); + } + + item->SetParent( &sch ); + commit.Add( item, aFrame->GetScreen() ); + staged = true; + } + }; + + for( const json& w : aWire.value( "added", json::array() ) ) + upsert( w ); + for( const json& w : aWire.value( "changed", json::array() ) ) + upsert( w ); + + if( staged ) + commit.Push( wxT( "Collaborative edit (items)" ) ); + + // Fold the applied state into the baseline so the post-apply listener flush + // doesn't re-broadcast it as a local diff (echo). + rebaseline(); + s_applyingRemote = false; +} + // Test/PoC move (the SCH_COMMIT body for kicadCollabTestMoveFirst, deferred via CallAfter). void collabTestMove( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem, SCH_SCREEN* aScreen, int aDx, int aDy ) @@ -634,6 +820,68 @@ std::string kicadCollabSnapshot() } +// JS → C++, v2 items wire. Same CallAfter + COROUTINE context as kicadCollabApply +// (LoadContent + SCH_COMMIT must run where native edits run). +void kicadCollabApplyItems( std::string aJson ) +{ + json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); + + if( wire.is_discarded() ) + return; + + SCH_EDIT_FRAME* fr = schFrame(); + + if( !fr ) + return; + + fr->CallAfter( [fr, wire]() { + COROUTINE cor( [fr, wire]( int ) -> int + { + doApplyItems( fr, wire ); + return 0; + } ); + cor.Call( 0 ); + } ); +} + + +// JS pull of the full current model as an all-"added" v2 items wire: one clipboard- +// style blob per screen item across the hierarchy (deduped by uuid). Registers the +// listener + rebaselines exactly like kicadCollabSnapshot. +std::string kicadCollabSnapshotItems() +{ + SCH_EDIT_FRAME* fr = schFrame(); + + json added = json::array(); + + if( fr ) + { + ensureBridge(); + + std::set seen; + + for( const SCH_SHEET_PATH& path : fr->Schematic().Hierarchy() ) + { + SCH_SCREEN* screen = const_cast( path ).LastScreen(); + + if( !screen ) + continue; + + for( SCH_ITEM* item : screen->Items() ) + { + if( seen.insert( toUtf8( item->m_Uuid.AsString() ) ).second ) + added.push_back( json{ { "sexpr", itemBlob( fr, item ) }, { "parent", nullptr } } ); + } + } + + rebaseline(); + } + + return json{ { "added", added }, { "changed", json::array() }, + { "removed", json::array() } }.dump(); +} + + // Test/PoC helper: move the first schematic item by (dx,dy) IU via a real SCH_COMMIT, // firing the listener — a deterministic local edit for the two-tab demo / e2e. // Returns the moved item's uuid. @@ -730,6 +978,9 @@ EMSCRIPTEN_BINDINGS(eeschema) { // Yjs collaborative bridge entry points (same contract as pl_editor). function("kicadCollabApply", &kicadCollabApply); function("kicadCollabSnapshot", &kicadCollabSnapshot); + // v2 items bridge: per-item s-expr payloads (ysync 0008). + function("kicadCollabApplyItems", &kicadCollabApplyItems); + function("kicadCollabSnapshotItems", &kicadCollabSnapshotItems); function("kicadCollabTestMoveFirst", &kicadCollabTestMoveFirst); function("kicadCollabGetPos", &kicadCollabGetPos); } diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index b355835..18d41a2 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -314,6 +314,29 @@ BOARD_ITEM* makeFromBlob( BOARD& aBoard, const std::string& aBlob ) return found; } +// Wrap a BARE item s-expr (e.g. one rendered from the Y.Doc Slot body) in the fake +// `(kicad_pcb …)` envelope CLIPBOARD_IO's parser requires for non-footprint items. The +// envelope carries the LIVE board's layer table so the item's layer names resolve — peers +// in a collab session share the same board, so names map 1:1. (Peer-emitted blobs already +// arrive enveloped by blobForItem; this is only for bare payloads.) +std::string wrapInBoardEnvelope( BOARD& aBoard, const std::string& aItemSexpr ) +{ + std::string s = "(kicad_pcb (version " + std::to_string( SEXPR_BOARD_FILE_VERSION ) + + ") (generator \"pcbnew\") (layers"; + + for( PCB_LAYER_ID id : aBoard.GetEnabledLayers().Seq() ) + { + const char* type = IsCopperLayer( id ) ? LAYER::ShowType( aBoard.GetLayerType( id ) ) + : "user"; + + s += " (" + std::to_string( (int) id ) + " \"" + + std::string( aBoard.GetLayerName( id ).utf8_str() ) + "\" " + type + ")"; + } + + s += ") " + aItemSexpr + ")"; + return s; +} + // 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). PCB_TRACK segments reconstruct natively // from their fields (cheap, trap-free); every other type goes through the s-expr clipboard blob @@ -444,6 +467,18 @@ void emit( const json& aDelta ) }, s.c_str() ); } +// v2 "items" wire emit (ysync 0008): per-item s-expr blobs instead of decomposed +// scalars. A JS runtime registers window.kicadCollab.onItems to opt in; both wires +// are emitted side by side until the scalar path is retired. +void emitItems( const json& aWire ) +{ + std::string s = aWire.dump(); + EM_ASM( { + if( window.kicadCollab && window.kicadCollab.onItems ) + window.kicadCollab.onItems( 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 @@ -495,12 +530,47 @@ void flushDiff() json added = json::array(), changed = json::array(), removed = json::array(); + // v2 items wire (per-item s-expr blobs): each touched id LIFTS to its root live + // item (footprint children → the footprint), deduped, and the root is blobbed + // whole — so containment travels and a child edit re-sends its parent subtree. + json wAdded = json::array(), wChanged = json::array(); + std::set wDone; + + auto liftBlob = [&]( const std::string& id, json& aArr ) + { + BOARD_ITEM* live = + board->ResolveItem( KIID( wxString::FromUTF8( id.c_str() ) ), /*allowNull*/ true ); + + if( !live ) + return; + + bool lifted = false; + + if( FOOTPRINT* fp = live->GetParentFootprint() ) + { + live = fp; + lifted = true; + } + + std::string rootId = toUtf8( live->m_Uuid.AsString() ); + + if( !wDone.insert( rootId ).second ) + return; + + json w = json{ { "sexpr", blobForItem( board, live ) }, { "parent", nullptr } }; + + // A lifted child means its (pre-existing) parent's CONTENT changed. + ( lifted ? wChanged : aArr ).push_back( w ); + }; + for( const auto& [id, j] : cur ) { auto it = g_baseline.find( id ); if( it == g_baseline.end() ) { + liftBlob( id, wAdded ); + // Skip a newly-added footprint's text CHILDREN: the footprint's own add carries them, // and emitting a lone child would (for a field) wrap it in a spurious footprint. (A // child-only add onto an existing footprint is therefore not synced yet — rare.) @@ -524,6 +594,7 @@ void flushDiff() } else if( it->second != j ) { + liftBlob( id, wChanged ); changed.push_back( j ); } } @@ -537,11 +608,18 @@ void flushDiff() g_baseline = std::move( cur ); if( !added.empty() || !changed.empty() || !removed.empty() ) + { emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } ); + emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "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. +// flushDiff runs inside a COROUTINE: the v2 items emit serializes ROOT items via +// CLIPBOARD_IO Format (blobForItem), whose virtual dispatch is only reliable on the +// libcontext fiber stack — on the bare CallAfter stack it can trap and silently kill +// the whole flush, legacy emit included (same lesson as doApply / eeschema 0007). void scheduleFlush() { if( g_flushScheduled ) @@ -550,9 +628,20 @@ void scheduleFlush() g_flushScheduled = true; if( PCB_EDIT_FRAME* fr = pcbFrame() ) - fr->CallAfter( []() { flushDiff(); } ); + { + fr->CallAfter( []() { + COROUTINE cor( []( int ) -> int + { + flushDiff(); + return 0; + } ); + cor.Call( 0 ); + } ); + } else + { flushDiff(); + } } // ChangeSource: the native BOARD_LISTENER is just a trigger — the actual change set comes from @@ -672,6 +761,84 @@ void doApply( PCB_EDIT_FRAME* aFrame, const json& aDelta ) s_applyingRemote = false; } +// v2 items apply: removed by uuid; added/changed are an idempotent per-item upsert — +// parse the blob (wrapping bare non-footprint payloads in a live-board envelope), +// then replace any existing item sharing the parsed uuid. Runs inside the apply +// COROUTINE (see kicadCollabApplyItems), via BOARD_COMMIT like every remote op. +void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire ) +{ + BOARD* board = aFrame->GetBoard(); + + s_applyingRemote = true; + + BOARD_COMMIT commit( aFrame ); + bool staged = false; + + for( const json& rid : aWire.value( "removed", json::array() ) ) + { + KIID id( wxString::FromUTF8( rid.get().c_str() ) ); + + if( BOARD_ITEM* item = board->ResolveItem( id, /*allowNullptr*/ true ) ) + { + // A child uuid in `removed` is covered by its parent's replace/remove. + if( item->GetParentFootprint() ) + continue; + + commit.Remove( item ); + staged = true; + } + } + + auto upsert = [&]( const json& w ) + { + std::string sexpr = w.value( "sexpr", "" ); + size_t p = sexpr.find_first_not_of( " \t\r\n" ); + + if( p == std::string::npos ) + return; + + std::string trimmed = sexpr.substr( p ); + + // Peer-emitted blobs are already enveloped (or a bare footprint, which the + // parser accepts top-level); bare Y.Doc-rendered items need the envelope. + if( trimmed.rfind( "(kicad_pcb", 0 ) != 0 && trimmed.rfind( "(footprint", 0 ) != 0 ) + trimmed = wrapInBoardEnvelope( *board, trimmed ); + + BOARD_ITEM* parsed = makeFromBlob( *board, trimmed ); + + if( !parsed ) + { + EM_ASM( { console.log( "[collab] pcbnew applyItems: blob parse failed" ); } ); + return; + } + + if( BOARD_ITEM* existing = board->ResolveItem( parsed->m_Uuid, /*allowNullptr*/ true ) ) + { + // Replacing by uuid; a (shouldn't-happen) child match replaces its parent. + if( FOOTPRINT* fp = existing->GetParentFootprint() ) + existing = fp; + + commit.Remove( existing ); + } + + commit.Add( parsed ); + staged = true; + }; + + for( const json& w : aWire.value( "added", json::array() ) ) + upsert( w ); + for( const json& w : aWire.value( "changed", json::array() ) ) + upsert( w ); + + if( staged ) + commit.Push( wxT( "Collaborative edit (items)" ) ); + + // Fold the applied state into the baseline so the post-apply listener flush + // doesn't re-broadcast it as a local diff (echo). + rebaseline(); + s_applyingRemote = false; +} + // Test/PoC move (the BOARD_COMMIT body for kicadCollabTestMoveFirst). Run inside a COROUTINE by // the caller: `BOARD_ITEM::Move` is virtual, and dispatched off the app main stack (a bare // CallAfter) it hits the asyncify call_indirect mis-dispatch and silently NO-OPS — the commit @@ -722,6 +889,31 @@ void kicadCollabApply( std::string aJson ) } +// JS → C++, v2 items wire. Same CallAfter + COROUTINE context as kicadCollabApply +// (the blob parse + commit must run where native edits run — see above). +void kicadCollabApplyItems( std::string aJson ) +{ + json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); + + if( wire.is_discarded() ) + return; + + PCB_EDIT_FRAME* fr = pcbFrame(); + + if( !fr ) + return; + + fr->CallAfter( [fr, wire]() { + COROUTINE cor( [fr, wire]( int ) -> int + { + doApplyItems( fr, wire ); + 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() @@ -744,6 +936,35 @@ std::string kicadCollabSnapshot() } +// JS pull of the full current model as an all-"added" v2 items wire: one blob per ROOT +// item (a footprint's blob embeds its children — the TS side flattens). Registers the +// listener + rebaselines exactly like kicadCollabSnapshot. +std::string kicadCollabSnapshotItems() +{ + BOARD* board = ensureBridge(); + + json added = json::array(); + + if( board ) + { + auto push = [&]( BOARD_ITEM* item ) + { + added.push_back( json{ { "sexpr", blobForItem( board, item ) }, { "parent", nullptr } } ); + }; + + for( FOOTPRINT* fp : board->Footprints() ) push( fp ); + for( PCB_TRACK* t : board->Tracks() ) push( t ); + for( ZONE* z : board->Zones() ) push( z ); + for( BOARD_ITEM* d : board->Drawings() ) push( d ); + } + + rebaseline(); + + return json{ { "added", added }, { "changed", json::array() }, + { "removed", json::array() } }.dump(); +} + + // Programmatically save the in-memory board to a .kicad_pcb file, without driving // the Save As dialog — pcbnew's analogue of pl_editor's kicadSaveDrawingSheet. // Serializes exactly what the editor has loaded via the same writer eeschema/pcbnew @@ -922,6 +1143,9 @@ EMSCRIPTEN_BINDINGS(pcbnew) { // Yjs collaborative bridge entry points (same contract as pl_editor / eeschema). function("kicadCollabApply", &kicadCollabApply); function("kicadCollabSnapshot", &kicadCollabSnapshot); + // v2 items bridge: per-item s-expr payloads (ysync 0008). + function("kicadCollabApplyItems", &kicadCollabApplyItems); + function("kicadCollabSnapshotItems", &kicadCollabSnapshotItems); function("kicadCollabTestMoveFirst", &kicadCollabTestMoveFirst); function("kicadCollabGetPos", &kicadCollabGetPos); function("kicadCollabTestItemBlob", &kicadCollabTestItemBlob); diff --git a/wasm/bindings/pl_editor_embind.cpp b/wasm/bindings/pl_editor_embind.cpp index 676d3c0..9e41ed8 100644 --- a/wasm/bindings/pl_editor_embind.cpp +++ b/wasm/bindings/pl_editor_embind.cpp @@ -22,6 +22,7 @@ #include #include #include +#include using namespace emscripten; using json = nlohmann::json; @@ -182,6 +183,26 @@ void emit( const json& aDelta ) }, s.c_str() ); } +// v2 "items" wire emit (ysync 0008): per-item s-expr blobs instead of decomposed +// scalars. A JS runtime registers window.kicadCollab.onItems to opt in; both wires +// are emitted side by side until the scalar path is retired. +void emitItems( const json& aWire ) +{ + std::string s = aWire.dump(); + EM_ASM( { + if( window.kicadCollab && window.kicadCollab.onItems ) + window.kicadCollab.onItems( UTF8ToString( $0 ) ); + }, s.c_str() ); +} + +// One items-wire entry for a live item: its native blob (SaveInString — a +// self-contained `(kicad_wks …)` envelope; the TS side unwraps). pl_editor's +// model is flat, so parent is always null. +json wireItemFor( DS_DATA_ITEM* aItem ) +{ + return json{ { "sexpr", itemBlob( aItem ) }, { "parent", nullptr } }; +} + // Apply scalar fields from json onto an existing scalar item (text/segment/rect). void applyFields( DS_DATA_ITEM* aItem, const json& j ) { @@ -331,14 +352,30 @@ extern "C" void kicadCollabOnModify() json changed = json::array(); json removed = json::array(); + // v2 items wire (per-item s-expr blobs), built from the same diff. + json wAdded = json::array(); + json wChanged = json::array(); + + DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance(); + for( const auto& [id, j] : cur ) { auto prev = s_snapshot.find( id ); if( prev == s_snapshot.end() ) + { added.push_back( j ); + + if( DS_DATA_ITEM* item = findByUuid( model, id ) ) + wAdded.push_back( wireItemFor( item ) ); + } else if( prev->second != j ) + { changed.push_back( j ); + + if( DS_DATA_ITEM* item = findByUuid( model, id ) ) + wChanged.push_back( wireItemFor( item ) ); + } } for( const auto& [id, j] : s_snapshot ) @@ -353,6 +390,7 @@ extern "C" void kicadCollabOnModify() return; emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } ); + emitItems( json{ { "added", wAdded }, { "changed", wChanged }, { "removed", removed } } ); } @@ -374,6 +412,107 @@ std::string kicadCollabSnapshot() } +// ── v2 "items" bridge: per-item s-expr (ysync 0008 Stage C) ───────────────────────── +// +// Same contract as the scalar bridge but the payload is each item's full native +// s-expr blob: { added: [{sexpr, parent}], changed: [...], removed: [uuid] }. +// Blobs are SaveInString envelopes; apply accepts both enveloped and bare items. + +// JS pull of the full current model as an all-"added" items wire. Rebaselines the +// differ exactly like kicadCollabSnapshot, so a v2 consumer gets no echo either. +std::string kicadCollabSnapshotItems() +{ + DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance(); + + json added = json::array(); + + for( DS_DATA_ITEM* item : model.GetItems() ) + added.push_back( wireItemFor( item ) ); + + s_snapshot = snapshotMap(); + + return json{ { "added", added }, { "changed", json::array() }, + { "removed", json::array() } }.dump(); +} + + +// JS → C++. Apply a remote items wire: removed by uuid; added/changed are an +// idempotent per-item upsert — append the blob through the normal parser, then +// drop any pre-existing item that shares an appended uuid (replace-by-uuid). +void kicadCollabApplyItems( std::string aJson ) +{ + json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); + + if( wire.is_discarded() ) + return; + + s_applyingRemote = true; + + DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance(); + + for( const json& rid : wire.value( "removed", json::array() ) ) + { + if( DS_DATA_ITEM* item = findByUuid( model, rid.get() ) ) + { + model.Remove( item ); + delete item; + } + } + + auto upsert = [&]( const json& w ) + { + std::string sexpr = w.value( "sexpr", "" ); + + if( sexpr.empty() ) + return; + + // Bare items (e.g. rendered from the Y.Doc Slot body) get the envelope the + // drawing-sheet parser requires; peer-emitted blobs already carry it. + if( sexpr.rfind( "(kicad_wks", 0 ) != 0 ) + { + sexpr = "(kicad_wks (version " + std::to_string( SEXPR_WORKSHEET_FILE_VERSION ) + + ") (generator \"pl_editor\") " + sexpr + ")"; + } + + // Snapshot the pre-append item pointers, then append through the parser. + std::vector before = model.GetItems(); + model.SetPageLayout( sexpr.c_str(), /*aAppend*/ true, wxT( "collab-items" ) ); + + // Replace-by-uuid: drop any pre-existing item sharing a newly appended uuid. + // Work on pointer snapshots — model.Remove() mutates the live vector. + std::vector appended( model.GetItems().begin() + before.size(), + model.GetItems().end() ); + + for( DS_DATA_ITEM* neu : appended ) + { + for( DS_DATA_ITEM* old : before ) + { + if( old->m_Uuid == neu->m_Uuid ) + { + model.Remove( old ); + delete old; + break; + } + } + } + }; + + for( const json& w : wire.value( "added", json::array() ) ) + upsert( w ); + for( const json& w : wire.value( "changed", json::array() ) ) + upsert( w ); + + // Rebase the differ on the post-apply state so our own mutations aren't echoed, + // then rebuild the GAL view from the model. + s_snapshot = snapshotMap(); + + if( EDA_DRAW_FRAME* fr = topFrame() ) + fr->HardRedraw(); + + s_applyingRemote = false; +} + + // Test/PoC helper: perform a genuine local text insert (the same model mutation a // PL_DRAWING_TOOLS::PlaceItem(DS_TEXT) UI click produces — 0002 §text-insert path) and // fire OnModify, so the differ emits an `added` delta. Lets a two-tab demo / e2e create @@ -405,6 +544,9 @@ EMSCRIPTEN_BINDINGS(pl_editor) { // Yjs collaborative bridge entry points. function("kicadCollabApply", &kicadCollabApply); function("kicadCollabSnapshot", &kicadCollabSnapshot); + // v2 items bridge: per-item s-expr payloads (ysync 0008). + function("kicadCollabApplyItems", &kicadCollabApplyItems); + function("kicadCollabSnapshotItems", &kicadCollabSnapshotItems); function("kicadCollabTestAddText", &kicadCollabTestAddText); } #endif diff --git a/web/pcbjam-shared b/web/pcbjam-shared index cc9c73d..9cfea41 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit cc9c73dba95188ad4a9d9d406c4868ac4c9fae14 +Subproject commit 9cfea4184b007d2358978cbfd472d4a93a656c82