/* * Embind bindings for KiCad eeschema WASM. * * Picked up automatically by scripts/kicad/build-kicad-target.sh when building * the eeschema app (it compiles wasm/bindings/_embind.cpp if present). */ #ifdef __EMSCRIPTEN__ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "collab_common.h" #include "open_gate.h" #include "collab_presence_core.h" #include "collab_presence_style.h" #include "pcbjam_theme.h" #include "pcbjam_libs_reload.h" #include "pcbjam_async_policy.h" #include using namespace emscripten; using json = nlohmann::json; // Programmatically open a project file (schematic) in the running editor frame, // without UI automation. Mirrors single_top.cpp's MacOpenFile path: the editor // frame is the app's top window and is a KIWAY_PLAYER. Returns the result of // OpenProjectFiles, or false if no frame is available — letting the JS caller // fall back to driving File→Open. // // KICAD_MERGED_EMBIND (kicad_editor, editor-unification Part 2): pcbnew_embind.cpp // defines the identical function and registers the same JS names — in the merged image // the frame-agnostic duplicates (this + kicadCollabOnSave) and the shared-name // registrations live once in kicad_editor_embind.cpp, which dispatches the per-editor // entries (renamed schCollab*/pcbCollab* below; JS-facing names are unchanged). #ifndef KICAD_MERGED_EMBIND bool kicadOpenFile( std::string path ) { // Held across every suspension of the load; see open_gate.h. pcbjam_open::BusyGuard busy; if( pcbjam_open::testParkMs() > 0 ) emscripten_sleep( pcbjam_open::testParkMs() ); KIWAY_PLAYER* frame = wxTheApp ? static_cast( wxTheApp->GetTopWindow() ) : nullptr; if( !frame ) return false; if( wxWindow* blocking = frame->Kiway().GetBlockingDialog() ) blocking->Close( true ); bool ok = frame->OpenProjectFiles( std::vector( 1, wxString::FromUTF8( path.c_str() ) ) ); // Test-only post-load park (open_gate.h): model fully loaded, gate still // closed — the deterministic window the collab-load-fuzz spec hammers. if( pcbjam_open::testParkMs() > 0 ) emscripten_sleep( pcbjam_open::testParkMs() ); return ok; } // JS-pollable open-in-flight probe (open_gate.h): the web shell defers the // collab/presence attach until the open chain has truly completed. bool kicadOpenFileBusy() { return pcbjam_open::busy(); } // Test-only (collab-load-fuzz): arm the deterministic open parks. void kicadTestSetOpenPark( int aMs ) { pcbjam_open::testParkMs() = aMs; } // Read-only viewer lock (read-only-viewer): flips the process-global // PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist) // and the selection tools (nothing selectable), and mirrors it onto the // project so the setup dialogs grey out. Returns false until the editor frame // exists so JS polls; the shell fails CLOSED if it never applies. bool kicadSetReadOnly( bool aReadOnly ) { KIWAY_PLAYER* frame = wxTheApp ? dynamic_cast( wxTheApp->GetTopWindow() ) : nullptr; if( !frame ) return false; PCBJAM_READ_ONLY::Set( aReadOnly ); frame->Prj().SetReadOnly( aReadOnly ); return true; } #endif // !KICAD_MERGED_EMBIND // ───────────────────────────── Yjs collaborative bridge ───────────────────────────── // // eeschema's half of the unified bridge contract (features/yjs-bridge/0001, 0003). // Unlike pl_editor it needs NO kicad-fork change: SCH_ITEM already carries a stable // KIID, and eeschema has native change machinery, so the adapter is a thin re-use: // ChangeSource (emit) = a SCHEMATIC_LISTENER subclass (SCH_COMMIT::Push fires it) // apply = SCH_COMMIT Modify/Remove + Push (drives connectivity recompute) // The generic JS reconciler / transport / WasmTool wiring are reused unchanged. // // Scope of this first commit (0003 §"first PoC"): position-level sync of existing // items — changed (move/edit) and removed. Decomposed field coverage via reflection, // constructing arbitrary new item types on `added`, and symbol-instance / multi-sheet // scoping are deferred (see TODOs). Items are resolved by globally-unique uuid, so // changed/removed already work across the whole hierarchy without sheet scoping. namespace { // Guard so SCH_COMMIT::Push's listener callbacks during apply() aren't re-emitted. bool s_applyingRemote = false; using pcbjam_collab::toUtf8; SCH_EDIT_FRAME* schFrame() { return wxTheApp ? dynamic_cast( wxTheApp->GetTopWindow() ) : nullptr; } // The screen of the sheet the editor is currently showing. Per-sheet collab keys a room // to each .kicad_sch, so the snapshot/diff that feeds a room must cover ONLY this screen, // never the whole Hierarchy(). GetCurrentSheet().LastScreen() is the active sheet's screen // (GetScreen() tracks the same screen and is the fallback before a sheet path exists). SCH_SCREEN* currentScreen( SCH_EDIT_FRAME* aFrame ) { if( !aFrame ) return nullptr; if( SCH_SCREEN* screen = aFrame->GetCurrentSheet().LastScreen() ) return screen; return aFrame->GetScreen(); } json itemToJson( SCH_ITEM* aItem ) { VECTOR2I p = aItem->GetPosition(); json j = { { "id", toUtf8( aItem->m_Uuid.AsString() ) }, { "type", toUtf8( aItem->GetClass() ) }, { "x", p.x }, // internal units; integral, so no quantization needed { "y", p.y }, }; // Per-type fields needed to reconstruct the item on `added` (0003 converters). // Hand-mapped for the wire-editing types; other types sync position-only (move) and // are skipped on `added` (logged) until their converters are added. if( aItem->Type() == SCH_LINE_T ) { auto* line = static_cast( aItem ); j["sx"] = line->GetStartPoint().x; j["sy"] = line->GetStartPoint().y; j["ex"] = line->GetEndPoint().x; j["ey"] = line->GetEndPoint().y; j["layer"] = (int) line->GetLayer(); } if( EDA_TEXT* txt = dynamic_cast( aItem ) ) j["text"] = toUtf8( txt->GetText() ); if( SCH_LABEL_BASE* lbl = dynamic_cast( aItem ) ) j["shape"] = (int) lbl->GetShape(); // Graphic shapes (circle / rectangle / arc / line / bezier): geometry is start/end plus, // for an arc, the center, and for a bezier the two control points. Stroke + fill complete it. if( aItem->Type() == SCH_SHAPE_T ) { auto* shp = static_cast( aItem ); j["stype"] = (int) shp->GetShape(); // SHAPE_T j["sx"] = shp->GetStart().x; j["sy"] = shp->GetStart().y; j["ex"] = shp->GetEnd().x; j["ey"] = shp->GetEnd().y; j["layer"] = (int) shp->GetLayer(); j["width"] = shp->GetStroke().GetWidth(); j["fill"] = (int) shp->GetFillMode(); if( shp->GetShape() == SHAPE_T::ARC ) { VECTOR2I c = shp->GetCenter(); j["cx"] = c.x; j["cy"] = c.y; } else if( shp->GetShape() == SHAPE_T::BEZIER ) { j["c1x"] = shp->GetBezierC1().x; j["c1y"] = shp->GetBezierC1().y; j["c2x"] = shp->GetBezierC2().x; j["c2y"] = shp->GetBezierC2().y; } } return j; } // Construct a new SCH_ITEM from a delta item (for `added`), with the delta's uuid // (m_Uuid is const → const_cast, exactly as the s-expr parser does). Returns nullptr for // types without a converter yet. SCH_ITEM* makeItem( const json& j ) { std::string type = j.value( "type", "" ); SCH_ITEM* item = nullptr; if( type == "SCH_LINE" ) { int layer = j.value( "layer", (int) LAYER_NOTES ); auto* line = new SCH_LINE( VECTOR2I( j.value( "sx", 0 ), j.value( "sy", 0 ) ), layer ); line->SetEndPoint( VECTOR2I( j.value( "ex", 0 ), j.value( "ey", 0 ) ) ); item = line; } else if( type == "SCH_JUNCTION" ) { item = new SCH_JUNCTION( VECTOR2I( j.value( "x", 0 ), j.value( "y", 0 ) ) ); } else if( type == "SCH_NO_CONNECT" ) { item = new SCH_NO_CONNECT( VECTOR2I( j.value( "x", 0 ), j.value( "y", 0 ) ) ); } else if( type == "SCH_TEXT" ) { auto* txt = new SCH_TEXT( VECTOR2I( j.value( "x", 0 ), j.value( "y", 0 ) ), wxString::FromUTF8( j.value( "text", "" ).c_str() ) ); // Mirror the interactive text tool (sch_drawing_tools.cpp createNewText): parent the // item to the schematic and apply the project's default text size, so a remotely-added // text resolves variables / renders identically to a locally-placed one. if( SCH_EDIT_FRAME* fr = schFrame() ) { txt->SetParent( &fr->Schematic() ); int sz = fr->Schematic().Settings().m_DefaultTextSize; txt->SetTextSize( VECTOR2I( sz, sz ) ); } item = txt; } else if( type == "SCH_LABEL" || type == "SCH_GLOBALLABEL" || type == "SCH_HIERLABEL" ) { VECTOR2I pos( j.value( "x", 0 ), j.value( "y", 0 ) ); wxString text = wxString::FromUTF8( j.value( "text", "" ).c_str() ); SCH_LABEL_BASE* lbl = ( type == "SCH_LABEL" ) ? (SCH_LABEL_BASE*) new SCH_LABEL( pos, text ) : ( type == "SCH_GLOBALLABEL" ) ? (SCH_LABEL_BASE*) new SCH_GLOBALLABEL( pos, text ) : (SCH_LABEL_BASE*) new SCH_HIERLABEL( pos, text ); if( j.contains( "shape" ) ) lbl->SetShape( (LABEL_FLAG_SHAPE) j["shape"].get() ); item = lbl; } else if( type == "SCH_SHAPE" ) { // Reconstruct from the geometry itemToJson emits. Committing a *new* SCH_SHAPE used to // trap in SCH_COMMIT::Push's CHT_ADD path under the retired asyncify runtime because // doApply ran off the coroutine context; doApply runs inside a COROUTINE // (kicadCollabApply), serialized with local edits, exactly as a native draw does. // (0006/0007.) NB FILL_T::NO_FILL == 1, not 0. SHAPE_T st = (SHAPE_T) j.value( "stype", (int) SHAPE_T::RECTANGLE ); int layer = j.value( "layer", (int) LAYER_NOTES ); int width = j.value( "width", 0 ); FILL_T fill = (FILL_T) j.value( "fill", (int) FILL_T::NO_FILL ); auto* shp = new SCH_SHAPE( st, (SCH_LAYER_ID) layer, width, fill ); // Rectangle: two corners. Circle: start = center, end = a point on the radius. Both are // fully defined by start+end (what itemToJson emits via GetStart()/GetEnd()). shp->SetStart( VECTOR2I( j.value( "sx", 0 ), j.value( "sy", 0 ) ) ); shp->SetEnd( VECTOR2I( j.value( "ex", 0 ), j.value( "ey", 0 ) ) ); if( st == SHAPE_T::ARC && j.contains( "cx" ) ) { shp->SetCenterX( j["cx"].get() ); shp->SetCenterY( j["cy"].get() ); } else if( st == SHAPE_T::BEZIER ) { if( j.contains( "c1x" ) ) shp->SetBezierC1( VECTOR2I( j["c1x"].get(), j["c1y"].get() ) ); if( j.contains( "c2x" ) ) shp->SetBezierC2( VECTOR2I( j["c2x"].get(), j["c2y"].get() ) ); } if( SCH_EDIT_FRAME* fr = schFrame() ) shp->SetParent( &fr->Schematic() ); item = shp; } // SCH_SYMBOL `added` is still deferred (needs the s-expr clipboard-blob emit + LoadContent // reconstruction; symbol PLACEMENT is also natively blocked until symbol libraries are // bundled — see features/yjs-bridge tasks). Symbol move/position already syncs via `changed`. if( item ) const_cast( item->m_Uuid ) = KIID( wxString::FromUTF8( j.value( "id", "" ).c_str() ) ); return item; } // Current model of the ACTIVE screen as an array of item json. One collab room == one // .kicad_sch screen, so we never fold in the rest of the hierarchy (uuids are unique // within a screen, so no cross-sheet dedup is needed). json snapshotItems( SCH_EDIT_FRAME* aFrame ) { json arr = json::array(); if( SCH_SCREEN* screen = currentScreen( aFrame ) ) { for( SCH_ITEM* item : screen->Items() ) arr.push_back( itemToJson( item ) ); } return arr; } // Wire emitters (legacy scalar delta + v2 items): shared, collab_common.h. using pcbjam_collab::emitDelta; using pcbjam_collab::emitItemsWire; // Notify the standalone that the editor switched to a different sheet — a different // .kicad_sch == a different collab room (ysync subschemas). The path is the active // screen's load path: the same absolute MEMFS form kicadCollabOnSave emits, so the JS // side strips the project prefix the same way (relativeProjectPath). No-op without a // JS listener. void emitSheetChanged() { SCH_SCREEN* screen = currentScreen( schFrame() ); if( !screen ) return; std::string s = toUtf8( screen->GetFileName() ); EM_ASM( { if( window.kicadCollab && window.kicadCollab.onSheetChanged ) window.kicadCollab.onSheetChanged( UTF8ToString( $0 ) ); }, s.c_str() ); } // Serialize one live schematic item to its native s-expr via a one-item // SCH_SELECTION through SCH_IO_KICAD_SEXPR::Format. For a symbol the output also // carries its (lib_symbols …) definition (that prelude is emitted for any symbol // in the selection, independent of aForClipboard). // // aForClipboard MUST be false. Clipboard mode is a LOSSY, paste-oriented dialect: // it rewrites `(instances (project … (path …)))` relative to aRelativePath — so a // symbol on the current sheet collapses to `(path "")` — takes the REFERENCE field // from the per-sheet instance instead of the ordinal one, and keeps orphaned // instance data (sch_io_kicad_sexpr.cpp saveSymbol: ~758-766, ~791-806, ~903). // The Y.Doc is the source of truth for the FILE, so a wire blob must be byte-equal // to that item's subtree in a full file save; clipboard form would silently strip // every symbol's sheet path and unit/reference on materialize. // // aRelativePath is still required (Format wxCHECKs it non-null) but is unread on // the aForClipboard=false path. 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*/ false ); return fmt.GetString(); } // ── Emit via post-settle snapshot diff ─────────────────────────────────────────────────── // // A local edit is a single SCH_COMMIT::Push that fires OnItemsAdded/Removed/Changed // synchronously and THEN runs RecalculateConnections (sch_commit.cpp ~402-430). So the native // listener only ever sees the *pre-cleanup* (raw) geometry, while the connectivity cleanup // that follows — merging collinear wires, dropping redundant junctions, splitting at new // crossings — is never reported. Broadcasting those raw per-category lists made the peer // reconstruct the edit from the raw state and run ITS OWN cleanup, over a different "dirty" // scope, so on a big connected drag the two peers cleaned up differently and the peer lost // segments/junctions. // // Instead, treat the listener purely as a "something changed" trigger and broadcast a DIFF of // the full model taken AFTER the edit settles — a CallAfter, which runs once Push (cleanup // included) has fully returned. That captures tab A's FINAL, already-clean geometry; the peer // applies it and re-cleaning already-clean geometry is idempotent, so the two converge. (This // mirrors pl_editor's snapshot-differ.) g_baseline is the last-broadcast state. // Diff baseline of the ACTIVE screen only (per-sheet collab room scope), keyed by uuid. std::map snapshotByUuid( SCH_EDIT_FRAME* aFrame ) { std::map m; if( SCH_SCREEN* screen = currentScreen( aFrame ) ) { for( SCH_ITEM* item : screen->Items() ) m[toUtf8( item->m_Uuid.AsString() )] = itemToJson( item ); } return m; } std::map g_baseline; bool g_flushScheduled = false; // Roots the listener saw change since the last flush (uuids, captured at callback // time — removed items may be freed before the flush runs). The scalar snapshot // diff is a LOSSY projection: rotations/mirrors (anchor unchanged), field text // edits (the commit stages the SCH_FIELD, which is not a screen item), stroke // properties etc. never move it (bug 04). Dirty roots emit their v2 blob // unconditionally; the apply is an idempotent upsert and the TS layer drops // no-op bodies, so a false positive costs one local serialization. std::set g_dirty; // Lift a commit-staged item to the SCREEN item the differ tracks (a field/pin/cell // lifts to its symbol/sheet/label/table — same promotion sch_commit's undo uses). void noteDirty( SCH_ITEM* aItem ) { if( !aItem ) return; while( EDA_ITEM* p = aItem->GetParent() ) { if( !p->IsType( { SCH_SYMBOL_T, SCH_TABLE_T, SCH_SHEET_T, SCH_LABEL_LOCATE_ANY_T } ) ) break; aItem = static_cast( p ); } g_dirty.insert( toUtf8( aItem->m_Uuid.AsString() ) ); } // Re-seed the diff baseline to the current model — after handing out a seed snapshot, or after // applying a remote delta (so those items aren't re-broadcast as a spurious local diff/echo). // Declares "current model == broadcast state", so pending dirty marks are stale too — on a // sheet switch they'd otherwise emit the OLD sheet's items into the new sheet's room. void rebaseline() { if( SCH_EDIT_FRAME* fr = schFrame() ) g_baseline = snapshotByUuid( fr ); g_dirty.clear(); } // TARGETED rebaseline (bug 05): refresh baseline entries ONLY for the uuids a remote // apply touched. A global rebaseline() would fold a concurrently-committed local edit // (its flush is queued BEHIND the apply on the same pending-event list) into the // baseline and silently swallow it; targeted, the edit still diffs and emits. The // connectivity cleanup the apply's Push produced likewise stays diffable — the // post-apply flush broadcasts it (idempotent on the original sender). void rebaselineTouched( SCH_EDIT_FRAME* aFrame, const std::vector& aIds ) { for( const std::string& id : aIds ) { g_baseline.erase( id ); KIID kid( wxString::FromUTF8( id.c_str() ) ); if( SCH_ITEM* live = aFrame->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ) ) g_baseline[id] = itemToJson( live ); } } // Diff the current (settled, post-cleanup) model against the baseline and broadcast the change. void flushDiff() { g_flushScheduled = false; SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return; std::map cur = snapshotByUuid( fr ); 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(); std::set wDone; auto blobFor = [&]( const std::string& id, json& aArr ) { if( !wDone.insert( id ).second ) return; 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 ) { if( !cur.count( id ) ) removed.push_back( id ); } // Dirty roots (bug 04): whatever the listener saw commit emits its blob on the // v2 wire even when the scalar projection didn't move (rotation, field text, // stroke edits). wDone dedups against the scalar-diff emits; deleted ids // resolve null inside blobFor and skip (the removal loop covered them). for( const std::string& id : g_dirty ) blobFor( id, wChanged ); g_dirty.clear(); g_baseline = std::move( cur ); if( !added.empty() || !changed.empty() || !removed.empty() ) emitDelta( json{ { "added", added }, { "changed", changed }, { "removed", removed } } ); if( !wAdded.empty() || !wChanged.empty() || !removed.empty() ) emitItemsWire( 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 via the apply // queue: its v2 items emit serializes items via SCH_IO_KICAD_SEXPR::Format (itemBlob), and // queueing it with the applies keeps emits from interleaving with a suspended apply body // (under the retired asyncify runtime the bare CallAfter stack additionally trapped here — // same lesson as doApply, 0007). void scheduleFlush() { if( g_flushScheduled ) return; g_flushScheduled = true; if( SCH_EDIT_FRAME* fr = schFrame() ) pcbjam_collab::runOnCoroutine( fr, []() { flushDiff(); } ); else flushDiff(); } // A hierarchical sheet was just created locally ("Add Sheet"): write its new child screen // to the child .kicad_sch file and tell the standalone (window.kicadCollab.onSheetCreated), // so the child is persisted/registered the moment it's created — without waiting for the // user to enter it or save the project. Otherwise the parent's `(sheet … child)` reference // dangles for peers / on reload. Deferred onto the apply coroutine (CallAfter + COROUTINE), // same as flushDiff/doApply, so the save serializes with any in-flight apply. // The sheet is re-resolved by uuid in the deferred body so a // since-deleted sheet (e.g. an immediate undo) is a no-op rather than a dangling pointer. void scheduleSheetSave( SCH_SHEET* aSheet ) { SCH_EDIT_FRAME* fr = schFrame(); SCH_SCREEN* parent = currentScreen( fr ); if( !fr || !parent || !aSheet->GetScreen() ) return; wxFileName childFn( aSheet->GetFileName() ); // relative "Sheetfile" childFn.MakeAbsolute( wxFileName( parent->GetFileName() ).GetPath() ); std::string childAbs = toUtf8( childFn.GetFullPath() ); std::string uuid = toUtf8( aSheet->m_Uuid.AsString() ); pcbjam_collab::runOnCoroutine( fr, [fr, childAbs, uuid]() { KIID kid( wxString::FromUTF8( uuid.c_str() ) ); SCH_ITEM* item = fr->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true ); if( !item || item->Type() != SCH_SHEET_T ) return; try { SCH_IO_KICAD_SEXPR io; io.SaveSchematicFile( wxString::FromUTF8( childAbs.c_str() ), static_cast( item ), &fr->Schematic() ); } catch( ... ) { return; // a write failure must not abort the runtime } EM_ASM( { if( window.kicadCollab && window.kicadCollab.onSheetCreated ) window.kicadCollab.onSheetCreated( UTF8ToString( $0 ) ); }, childAbs.c_str() ); } ); } // ChangeSource: the native SCHEMATIC_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. // // Presence (collab-presence 0003): schematic changes often change the selection // too (delete, paste) with no closing canvas event — the trigger below also // piggybacks a selection re-check. Defined in the presence section further down. void schedulePresenceSelCheck(); class COLLAB_LISTENER : public SCHEMATIC_LISTENER { public: void OnSchItemsAdded( SCHEMATIC&, std::vector& aItems ) override { // A newly-added hierarchical sheet → persist + register its child file (above). if( !s_applyingRemote ) { for( SCH_ITEM* item : aItems ) if( item->Type() == SCH_SHEET_T ) scheduleSheetSave( static_cast( item ) ); } trigger( aItems ); } void OnSchItemsChanged( SCHEMATIC&, std::vector& v ) override { trigger( v ); } void OnSchItemsRemoved( SCHEMATIC&, std::vector& v ) override { trigger( v ); } // The editor switched to a different sheet (a different .kicad_sch == a different // collab room). Re-baseline so the first edit on the new sheet diffs against ITS // screen, not the previous one, then tell the standalone to rebind its room to the // now-active sheet file. Fires from SCH_EDIT_FRAME::DisplayCurrentSheet, by which // point GetCurrentSheet()/GetScreen() already point at the new sheet. void OnSchSheetChanged( SCHEMATIC& ) override { rebaseline(); emitSheetChanged(); } private: // Capture the touched roots at callback time (fields/pins lift to their // symbol — noteDirty), then coalesce into one post-settle flush. void trigger( const std::vector& aItems ) { if( s_applyingRemote ) return; for( SCH_ITEM* item : aItems ) noteDirty( item ); scheduleFlush(); schedulePresenceSelCheck(); } }; COLLAB_LISTENER* g_listener = nullptr; // Get the live SCHEMATIC and ensure our listener is registered on it (idempotent). SCHEMATIC* ensureBridge() { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return nullptr; SCHEMATIC& sch = fr->Schematic(); if( !g_listener ) { g_listener = new COLLAB_LISTENER(); sch.AddListener( g_listener ); } return &sch; } // ───────────────────────── collab presence (collab-presence 0003) ───────────────────────── // // eeschema port of pcbnew's presence layer (0002 — see pcbnew_embind.cpp for the full // design rationale). The state + event/scheduling machinery live in the shared // pcbjam_presence::CORE (collab_presence_core.h); this TU supplies only the // eeschema-specific hooks: frame/tool lookup, KIID resolution via // SCHEMATIC::ResolveItem, the bare-uuid selection payload, and the per-peer draw // (cross-app ghosts gated to the CURRENT sheet). Zero kicad-fork changes: wx-layer // Bind() triggers on the GAL canvas + the COLLAB_LISTENER piggyback, selection read // post-settle from SCH_SELECTION_TOOL. Rooms are per-sheet (warm pool), so peers // publishing cursor/selection in the bound room are BY CONSTRUCTION on this same // sheet file — no sheet filtering is needed for same-room selections; the JS side // rebinds the whole presence layer on sheet navigation (onSheetChanged) and clears // the overlay in between. // Cross-app selection (0006): the schematic items a peer's xsel (symbol uuids // from a pcbnew peer) resolves to on the CURRENT sheet. Unlike same-room // selections (per-sheet rooms scope those by construction) xsel arrives // project-wide, so only items that resolve onto the sheet THIS canvas is // showing count — a bbox from another sheet's screen would land at meaningless // coordinates. ONE resolver shared by the ghost render and the test probe // (kicadCollabTestGetCrossMapped), so the assertion can't drift from the pixels. std::vector resolveXsel( SCH_EDIT_FRAME* aFrame, const pcbjam_presence::PEER& aPeer ) { std::vector items; for( const KIID& id : aPeer.xsel ) { SCH_SHEET_PATH path; SCH_ITEM* item = aFrame->Schematic().ResolveItem( id, &path, /*allowNull*/ true ); if( item && path.LastScreen() == aFrame->GetScreen() ) items.push_back( item ); } return items; } pcbjam_presence::CORE& presenceCore() { static pcbjam_presence::CORE core = []() { pcbjam_presence::CORE c; // eeschema ships its own defaults (hairline outline, subtler fill/cursor). c.style = pcbjam_presence::eeschemaDefaultStyle(); c.frame = []() -> EDA_DRAW_FRAME* { return schFrame(); }; c.selectionTool = []( EDA_DRAW_FRAME* fr ) -> SELECTION_TOOL* { return fr->GetToolManager()->GetTool(); }; // Selection emit = the bare uuid array (0006: eeschema uuids ARE the // symbol uuids; pcbnew's counterpart adds fpPaths). c.selectionEmitPayload = []( EDA_DRAW_FRAME* fr ) -> json { return presenceCore().selectionUuids( fr ); }; c.resolveItem = []( EDA_DRAW_FRAME* fr, const KIID& id ) -> EDA_ITEM* { return static_cast( fr )->Schematic() .ResolveItem( id, nullptr, /*allowNull*/ true ); }; c.drawPeerShapes = []( pcbjam_presence::CORE& aCore, EDA_DRAW_FRAME* aFrame, const pcbjam_presence::PEER& peer, const KIGFX::COLOR4D& color, double px ) { SCH_EDIT_FRAME* fr = static_cast( aFrame ); for( const KIID& id : peer.selection ) { SCH_SHEET_PATH path; SCH_ITEM* item = fr->Schematic().ResolveItem( id, &path, /*allowNull*/ true ); if( !item ) continue; // not in this schematic (yet) — skip silently pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(), aCore.textOverlay.get(), item->ViewBBox(), peer.name, color, px, aCore.style ); } // Cross-app ghosts (0006) — see resolveXsel for the sheet gating. if( !peer.xsel.empty() ) { pcbjam_presence::STYLE ghost = pcbjam_presence::ghostStyle( aCore.style ); for( SCH_ITEM* item : resolveXsel( fr, peer ) ) { pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(), aCore.textOverlay.get(), item->ViewBBox(), peer.name, color, px, ghost ); } } }; return c; }(); return core; } void schedulePresenceSelCheck() { presenceCore().scheduleSelCheck(); } } // namespace namespace { // SCH_SYMBOL::Move() / SCH_LABEL_BASE::Move() move their child fields (reference, value, …) via // an inner `field.Move()`. Under the retired asyncify runtime that inner call silently no-oped // in the apply context — the field text was left behind at its old position while the body // moved — so the fields are re-moved here with a devirtualized call. Kept as-is through the // JSPI migration; the collab move/drift suites pin the peer's field positions. void moveFields( std::vector& aFields, const VECTOR2I& aDelta ) { for( SCH_FIELD& field : aFields ) field.SCH_FIELD::Move( aDelta ); } // Move an item to an absolute position for the `changed` path. Under the retired asyncify // runtime the virtual SCH_ITEM::Move(), dispatched from the apply/CallAfter context, silently // NO-OPED (symbols/junctions/labels never moved on the peer — only SCH_LINE worked, via its // direct SetStart/EndPoint path; see 0003 / eeschema_collab_asyncify_apply). Move() is // therefore devirtualized with explicit class-qualified calls — statically bound plain wasm // calls, correct on any runtime, so they stay. Composite items additionally need their child // fields moved (see moveFields). void moveItemTo( SCH_ITEM* aItem, const VECTOR2I& aNewPos ) { VECTOR2I delta = aNewPos - aItem->GetPosition(); if( delta == VECTOR2I( 0, 0 ) ) return; switch( aItem->Type() ) { case SCH_SYMBOL_T: { auto* sym = static_cast( aItem ); sym->SCH_SYMBOL::Move( delta ); moveFields( sym->GetFields(), delta ); break; } case SCH_JUNCTION_T: static_cast( aItem )->SCH_JUNCTION::Move( delta ); break; case SCH_NO_CONNECT_T: static_cast( aItem )->SCH_NO_CONNECT::Move( delta ); break; case SCH_TEXT_T: static_cast( aItem )->SCH_TEXT::Move( delta ); break; case SCH_LABEL_T: case SCH_GLOBAL_LABEL_T: case SCH_HIER_LABEL_T: { auto* lbl = static_cast( aItem ); lbl->SCH_LABEL_BASE::Move( delta ); moveFields( lbl->GetFields(), delta ); break; } default: aItem->Move( delta ); // virtual fallback (may no-op in the apply context) break; } } // The actual model mutation, via SCH_COMMIT so connectivity/ERC recompute as for a UI // edit. (0003 decoded an asyncify-era dynCall trap on this path; the shim that healed // it retired with that runtime.) void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta ) { SCHEMATIC& sch = aFrame->Schematic(); s_applyingRemote = true; SCH_COMMIT commit( aFrame ); bool staged = false; // With SKIP_UNDO no undo picker takes ownership of removed items; the commit // detaches them from the screen and we free them after Push. Fields are hidden // by CHT_REMOVE, not detached (still owned by their parent), so never freed. std::vector removedItems; for( const json& rid : aDelta.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() ); if( item->Type() != SCH_FIELD_T ) removedItems.push_back( item ); staged = true; } } for( const json& j : aDelta.value( "changed", json::array() ) ) { SCH_SHEET_PATH path; KIID id( wxString::FromUTF8( j.value( "id", "" ).c_str() ) ); if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) ) { commit.Modify( item, path.LastScreen() ); if( item->Type() == SCH_LINE_T && j.contains( "sx" ) ) { // Wires reshape (endpoints move independently), so set both points. auto* line = static_cast( item ); line->SetStartPoint( VECTOR2I( j["sx"].get(), j["sy"].get() ) ); line->SetEndPoint( VECTOR2I( j["ex"].get(), j["ey"].get() ) ); } else if( j.contains( "x" ) && j.contains( "y" ) ) { moveItemTo( item, VECTOR2I( j["x"].get(), j["y"].get() ) ); } staged = true; } } for( const json& j : aDelta.value( "added", json::array() ) ) { KIID id( wxString::FromUTF8( j.value( "id", "" ).c_str() ) ); if( sch.ResolveItem( id, nullptr, /*allowNull*/ true ) ) continue; // already present (our own echo) if( SCH_ITEM* item = makeItem( j ) ) { commit.Add( item, aFrame->GetScreen() ); staged = true; } else { EM_ASM( { console.log( "[collab] eeschema apply: no converter for added type " + UTF8ToString( $0 ) ); }, j.value( "type", "?" ).c_str() ); } } // SKIP_UNDO: a peer's edit must never land on this editor's undo stack — Ctrl+Z // would revert (and re-broadcast) the peer's work. Undo is local-ops-only; stale // local undo entries are dropped/re-resolved by UUID at undo time (miss 09). if( staged ) commit.Push( wxT( "Collaborative edit" ), SKIP_UNDO ); for( SCH_ITEM* item : removedItems ) delete item; // The applied remote changes (and any connectivity cleanup they triggered) are now the // shared state — fold them into the baseline so the post-apply listener flush doesn't // re-broadcast them as a local diff (echo). rebaseline(); s_applyingRemote = false; } // 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; std::vector touched; // uuids this apply acts on (targeted rebaseline) // Owned by nobody once the SKIP_UNDO commit detaches them — freed after Push // (fields are hidden, not detached, so excluded). See doApply. std::vector removedItems; for( const json& rid : aWire.value( "removed", json::array() ) ) { SCH_SHEET_PATH path; KIID id( wxString::FromUTF8( rid.get().c_str() ) ); touched.push_back( rid.get() ); if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) ) { commit.Remove( item, path.LastScreen() ); if( item->Type() != SCH_FIELD_T ) removedItems.push_back( item ); 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( existing->Type() != SCH_FIELD_T ) removedItems.push_back( existing ); } if( item->Type() == SCH_SYMBOL_T ) { auto* sym = static_cast( item ); if( LIB_SYMBOL* lib = findLib( sym ) ) sym->SetLibSymbol( lib ); } item->SetParent( &sch ); touched.push_back( toUtf8( item->m_Uuid.AsString() ) ); 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 ); // SKIP_UNDO: remote applies never land on the local undo stack (see doApply). if( staged ) commit.Push( wxT( "Collaborative edit (items)" ), SKIP_UNDO ); for( SCH_ITEM* item : removedItems ) delete item; // Fold ONLY the applied uuids into the baseline (echo suppression), then flush: // anything else that now differs — a concurrent local edit, the connectivity // cleanup this apply's Push produced — broadcasts as a normal local diff // instead of being swallowed (bug 05). rebaselineTouched( aFrame, touched ); s_applyingRemote = false; scheduleFlush(); } // 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 ) { SCH_COMMIT commit( aFrame ); commit.Modify( aItem, aScreen ); aItem->Move( VECTOR2I( aDx, aDy ) ); commit.Push( wxT( "Collab test move" ) ); } } // namespace // JS → C++. Apply a remote per-item delta by uuid, through SCH_COMMIT so connectivity/ // ERC/hierarchy recompute the same way a UI edit would (0003 §apply). // // SCH_COMMIT must run on the editor's main loop, not on this embind ccall or an // emscripten_async_call/setTimeout callback: wxEvtHandler::CallAfter queues onto the // app's pending-event list, which the wasm main loop drains every frame via // ProcessPendingEvents() (src/wasm/evtloop.cpp) — i.e. the exact context real UI edits // run in. So defer the whole mutation there. void schCollabApply( std::string aJson ) { // Open-in-flight guard (open_gate.h): never touch the model while a // kicadOpenFile chain is suspended mid-load — commits/virtuals would walk // a half-built schematic mid-mutation. // Callers gate on kicadOpenFileBusy; fuzzed by tests/kicad/collab-load-fuzz.spec.ts. if( pcbjam_open::busy() ) return; json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); if( delta.is_discarded() ) return; SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return; // Defer to the editor's main loop + apply coroutine (runOnCoroutine) so SCH_COMMIT runs // like a normal edit: a commit body that suspends (connectivity/GAL work) returns early // from COROUTINE::Call, so applies must serialize with each other and with local edits // or they interleave on shared commit/listener state. (0007, drift-trio #10.) pcbjam_collab::runOnCoroutine( fr, [fr, delta]() { doApply( fr, delta ); } ); } // JS pull of the full current model as an all-"added" delta (seed/baseline). Also // registers the change listener on first call. std::string schCollabSnapshot() { if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see schCollabApply return json{ { "added", json::array() }, { "changed", json::array() }, { "removed", json::array() } }.dump(); ensureBridge(); json added = snapshotItems( schFrame() ); // Seed the diff baseline to exactly the model we're handing out, so the first local edit // diffs against this snapshot (and we don't re-broadcast the whole model). rebaseline(); return json{ { "added", added }, { "changed", json::array() }, { "removed", json::array() } }.dump(); } // JS → C++, v2 items wire. Same CallAfter + COROUTINE context as kicadCollabApply // (LoadContent + SCH_COMMIT must run where native edits run). void schCollabApplyItems( std::string aJson ) { if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see schCollabApply return; json wire = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); if( wire.is_discarded() ) return; SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return; pcbjam_collab::runOnCoroutine( fr, [fr, wire]() { doApplyItems( fr, wire ); } ); } // JS pull of the ACTIVE screen's model as an all-"added" v2 items wire: one clipboard- // style blob per item on the current sheet (one collab room == one .kicad_sch screen). // Registers the listener + rebaselines exactly like kicadCollabSnapshot. std::string schCollabSnapshotItems() { if( pcbjam_open::busy() ) // open in flight (open_gate.h) — see schCollabApply return json{ { "added", json::array() }, { "changed", json::array() }, { "removed", json::array() } }.dump(); SCH_EDIT_FRAME* fr = schFrame(); json added = json::array(); if( fr ) { ensureBridge(); if( SCH_SCREEN* screen = currentScreen( fr ) ) { for( SCH_ITEM* item : screen->Items() ) 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. std::string schCollabTestMoveFirst( int aDx, int aDy ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return ""; SCHEMATIC& sch = fr->Schematic(); for( const SCH_SHEET_PATH& path : sch.Hierarchy() ) { SCH_SCREEN* screen = const_cast( path ).LastScreen(); if( !screen ) continue; for( SCH_ITEM* item : screen->Items() ) { fr->CallAfter( [fr, item, screen, aDx, aDy]() { collabTestMove( fr, item, screen, aDx, aDy ); } ); return toUtf8( item->m_Uuid.AsString() ); } } return ""; } // How many placed instances of a library symbol the open schematic holds — // the JS lib-sync bridge asks after a remote lib update so the editor chrome // can warn "a symbol you are using changed" (placed SCH_SYMBOLs keep their // embedded copy across a lib reload, so the user must update explicitly). // Counts across all unique screens of the hierarchy; 0 without a schematic // frame (symbol editor / viewer sessions). int schLibsSymbolUsage( std::string aLibNickname, std::string aSymbolName ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return 0; const LIB_ID target( wxString::FromUTF8( aLibNickname.c_str() ), wxString::FromUTF8( aSymbolName.c_str() ) ); int count = 0; SCH_SCREENS screens( fr->Schematic().Root() ); for( SCH_SCREEN* screen = screens.GetFirst(); screen; screen = screens.GetNext() ) { for( SCH_ITEM* item : screen->Items().OfType( SCH_SYMBOL_T ) ) { if( static_cast( item )->GetLibId() == target ) count++; } } return count; } // Test helper: read an item's position by uuid as "x,y" (internal units). std::string schCollabGetPos( std::string aId ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return ""; KIID id( wxString::FromUTF8( aId.c_str() ) ); if( SCH_ITEM* item = fr->Schematic().ResolveItem( id, nullptr, /*allowNull*/ true ) ) { VECTOR2I p = item->GetPosition(); return std::to_string( p.x ) + "," + std::to_string( p.y ); } return ""; } // ── ysync-review repro hooks ───────────────────────────────────────────────── // Local-edit test hooks for the ysync-review repro e2e (docs/features/ // ysync-review on the ysync-review branch): each drives a REAL SCH_COMMIT via // CallAfter + COROUTINE (the doApply wrapping), so the SCHEMATIC_LISTENER → // flushDiff emit path runs exactly as for a UI edit. Each returns false when // the uuid doesn't resolve, letting the spec distinguish "hook missed the // item" from "differ missed the edit" (bug 04). // Delete an item by uuid via a real SCH_COMMIT. bool schCollabTestRemoveItem( std::string aId ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return false; SCH_SHEET_PATH path; SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &path, /*allowNull*/ true ); if( !item ) return false; SCH_SCREEN* screen = path.LastScreen(); pcbjam_collab::runOnCoroutine( fr, [fr, item, screen]() { SCH_COMMIT commit( fr ); commit.Remove( item, screen ); commit.Push( wxT( "Collab test remove" ) ); } ); return true; } // Rotate an item in place (aDeg snapped to 90° CCW steps) — bug 04: a symbol's // GetPosition() is unchanged by an in-place rotation and its json carries no // orientation, so the rotation is invisible to the scalar differ. bool schCollabTestRotateItem( std::string aId, double aDeg ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return false; SCH_SHEET_PATH path; SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &path, /*allowNull*/ true ); if( !item ) return false; SCH_SCREEN* screen = path.LastScreen(); int steps = ( (int) ( aDeg / 90.0 + ( aDeg >= 0 ? 0.5 : -0.5 ) ) % 4 + 4 ) % 4; pcbjam_collab::runOnCoroutine( fr, [fr, item, screen, steps]() { SCH_COMMIT commit( fr ); commit.Modify( item, screen ); for( int i = 0; i < steps; ++i ) item->Rotate( item->GetPosition(), /*aRotateCCW*/ true ); commit.Push( wxT( "Collab test rotate" ) ); } ); return true; } // Run Edit>Undo / read the undo depth — miss 09; frame-generic, collab_common.h. bool schCollabTestUndo() { return pcbjam_collab::testUndo( schFrame() ); } int schCollabTestUndoDepth() { return pcbjam_collab::testUndoDepth( schFrame() ); } // Set a symbol's Value field text — bug 04: fields live inside the symbol (not // in screen->Items()) and the symbol json carries no field text, so the most // common schematic edit after moving things never syncs. bool schCollabTestSetFieldText( std::string aId, std::string aText ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return false; SCH_SHEET_PATH path; SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &path, /*allowNull*/ true ); if( !item || item->Type() != SCH_SYMBOL_T ) return false; SCH_SYMBOL* sym = static_cast( item ); SCH_SCREEN* screen = path.LastScreen(); wxString text = wxString::FromUTF8( aText.c_str() ); pcbjam_collab::runOnCoroutine( fr, [fr, sym, screen, text]() { SCH_COMMIT commit( fr ); commit.Modify( sym, screen ); sym->SetValueFieldText( text ); commit.Push( wxT( "Collab test field text" ) ); } ); return true; } // ── drift-trio phase B action hooks (standalone-hardening 0008 §5) ─────────── // Creation/mutation primitives for the trio harness's action catalog. Each // drives a REAL SCH_COMMIT on the apply coroutine, so the SCHEMATIC_LISTENER → // flushDiff emit path runs exactly as for a UI edit. Names are tool-unique // (registered outside the KICAD_MERGED_EMBIND guard — same convention as // kicadCollabTestSetFieldText), so the merged image needs no dispatcher. // Commit a freshly-built item onto the current screen; returns its uuid. static std::string schCollabTestCommitAdd( SCH_ITEM* aItem, const wxChar* aMsg ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) { delete aItem; return ""; } SCH_SCREEN* screen = fr->GetScreen(); // SCH_COMMIT::Push finds the SCHEMATIC via the ITEM's parent chain and // silently skips every listener notification (OnItemsAdded → our emit) // when it comes up null — a bare `new SCH_ITEM` has no parent yet, so // parent it like the drawing tools do before staging. aItem->SetParent( screen ); std::string id = toUtf8( aItem->m_Uuid.AsString() ); wxString msg( aMsg ); pcbjam_collab::runOnCoroutine( fr, [fr, aItem, screen, msg]() { SCH_COMMIT commit( fr ); commit.Add( aItem, screen ); commit.Push( msg ); } ); return id; } std::string schCollabTestAddWire( int aX1, int aY1, int aX2, int aY2 ) { SCH_LINE* line = new SCH_LINE( VECTOR2I( aX1, aY1 ), LAYER_WIRE ); line->SetEndPoint( VECTOR2I( aX2, aY2 ) ); return schCollabTestCommitAdd( line, wxT( "Collab test add wire" ) ); } std::string schCollabTestAddJunction( int aX, int aY ) { return schCollabTestCommitAdd( new SCH_JUNCTION( VECTOR2I( aX, aY ) ), wxT( "Collab test add junction" ) ); } std::string schCollabTestAddNoConnect( int aX, int aY ) { return schCollabTestCommitAdd( new SCH_NO_CONNECT( VECTOR2I( aX, aY ) ), wxT( "Collab test add no-connect" ) ); } // aKind: "label" | "global" | "hier". std::string schCollabTestAddLabel( std::string aKind, std::string aText, int aX, int aY ) { wxString txt = wxString::FromUTF8( aText.c_str() ); VECTOR2I pos( aX, aY ); SCH_LABEL_BASE* label; if( aKind == "global" ) label = new SCH_GLOBALLABEL( pos, txt ); else if( aKind == "hier" ) label = new SCH_HIERLABEL( pos, txt ); else label = new SCH_LABEL( pos, txt ); return schCollabTestCommitAdd( label, wxT( "Collab test add label" ) ); } // Place another instance of a symbol ALREADY in the schematic's lib_symbols // cache (the harness fixtures embed their defs — no external lib needed). std::string schCollabTestAddSymbol( std::string aLibId, int aX, int aY, std::string aRef ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return ""; SCH_SCREEN* screen = fr->GetScreen(); wxString libIdStr = wxString::FromUTF8( aLibId.c_str() ); LIB_SYMBOL* libSym = nullptr; for( const auto& [name, sym] : screen->GetLibSymbols() ) { if( name == libIdStr ) { libSym = sym; break; } } if( !libSym ) return ""; LIB_ID libId; if( libId.Parse( libIdStr ) >= 0 ) return ""; SCH_SHEET_PATH& sheet = fr->GetCurrentSheet(); SCH_SYMBOL* sym = new SCH_SYMBOL( *libSym, libId, &sheet, 1, 1, VECTOR2I( aX, aY ) ); sym->SetParent( screen ); // Push's SCHEMATIC lookup — see schCollabTestCommitAdd sym->SetRef( &sheet, wxString::FromUTF8( aRef.c_str() ) ); std::string id = toUtf8( sym->m_Uuid.AsString() ); pcbjam_collab::runOnCoroutine( fr, [fr, sym, screen]() { SCH_COMMIT commit( fr ); commit.Add( sym, screen ); commit.Push( wxT( "Collab test add symbol" ) ); } ); return id; } // By-uuid variant of MoveFirst (same CallAfter + devirtualized move path). bool schCollabTestMoveSchItem( std::string aId, int aDx, int aDy ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return false; SCH_SHEET_PATH probe; if( !fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &probe, /*allowNull*/ true ) ) return false; // Re-resolve when the deferred body runs: a remote remove can apply in // between and the captured pointer would be dangling — the commit would // resurrect a deleted item (drift-trio S4 move-vs-delete). Vanished => // the move loses, silently. fr->CallAfter( [fr, aId, aDx, aDy]() { SCH_SHEET_PATH path; SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &path, /*allowNull*/ true ); if( item ) collabTestMove( fr, item, path.LastScreen(), aDx, aDy ); } ); return true; } bool schCollabTestMirrorSchItem( std::string aId, bool aHorizontal ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return false; SCH_SHEET_PATH path; SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &path, /*allowNull*/ true ); if( !item ) return false; pcbjam_collab::runOnCoroutine( fr, [fr, aId, aHorizontal]() { // re-resolve on the coroutine (S4) SCH_SHEET_PATH path; SCH_ITEM* live = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &path, /*allowNull*/ true ); if( !live ) return; SCH_COMMIT commit( fr ); commit.Modify( live, path.LastScreen() ); if( aHorizontal ) live->MirrorHorizontally( live->GetPosition().x ); else live->MirrorVertically( live->GetPosition().y ); commit.Push( wxT( "Collab test mirror" ) ); } ); return true; } // Duplicate (fresh uuids — exercises the Clone()/uuid-churn drift class). std::string schCollabTestDuplicateSchItem( std::string aId, int aDx, int aDy ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return ""; SCH_SHEET_PATH path; SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &path, /*allowNull*/ true ); if( !item ) return ""; SCH_SCREEN* screen = path.LastScreen(); SCH_ITEM* dup = item->Duplicate( /*addToParentGroup*/ false ); dup->Move( VECTOR2I( aDx, aDy ) ); std::string id = toUtf8( dup->m_Uuid.AsString() ); pcbjam_collab::runOnCoroutine( fr, [fr, dup, screen]() { SCH_COMMIT commit( fr ); commit.Add( dup, screen ); commit.Push( wxT( "Collab test duplicate" ) ); } ); return id; } // Programmatically save the in-memory schematic to a .kicad_sch file, without // driving the Save As dialog — eeschema's analogue of pl_editor's // kicadSaveDrawingSheet. Serializes the root sheet via the same SCH_IO_KICAD_SEXPR // writer eeschema uses, so a test can read the file back from MEMFS and assert the // file ⇄ Y.Doc round trip (README §A; feature 0004). Single-sheet scope: the round- // trip fixtures are flat schematics; saving the root sheet writes the whole model. // C++ → JS save notification (standalone-hardening save routing). Called from the // kicad fork's save chokepoint (SCH_EDIT_FRAME::saveSchematicFile) after a // successful write to MEMFS, so the web app can route the saved bytes onward // (API upload, local-disk write-back, download). No-op without a JS listener. // KICAD_MERGED_EMBIND: identical definition in pcbnew_embind.cpp; the merged image // gets the one in kicad_editor_embind.cpp (both fork save chokepoints call it). #ifndef KICAD_MERGED_EMBIND extern "C" void kicadCollabOnSave( const char* aPath ) { EM_ASM( { if( window.kicadCollab && window.kicadCollab.onSave ) window.kicadCollab.onSave( UTF8ToString( $0 ) ); }, aPath ); } #endif // !KICAD_MERGED_EMBIND // ── presence entry points (collab-presence 0003 — eeschema port of the 0002 set) ──────────── // Install the presence input hooks on the GAL canvas + the fork's soft-lock // query (idempotent) — shared core. The canvas is the same window across // sheet navigation, so one install serves the whole session. void schCollabPresenceStart() { presenceCore().start(); } // JS → C++: full remote-peers snapshot (same wire as pcbnew's kicadCollabSetRemote). // Rooms are per-sheet, so the JS rebind pushes a fresh (or empty) snapshot on every // sheet switch — peers here are always this sheet's. void schCollabSetRemote( std::string aJson ) { presenceCore().setRemote( aJson ); } // JS → C++ (collab-presence 0005): comment pin dots (same wire as pcbnew). void schCollabSetPins( std::string aJson ) { presenceCore().setPins( aJson ); } // JS → C++ (presence tuner): live-patch the overlay STYLE and repaint — // see collab_presence_style.h + pcbnew's counterpart. void schCollabSetStyle( std::string aJson ) { presenceCore().setStyle( aJson ); } // JS → C++ (comments-ux 0002 F4): live color-theme switch (see pcbjam_theme.h). void schSetColorTheme( std::string aTheme ) { pcbjam_theme::setColorTheme( schFrame(), aTheme ); } // Pre-main chrome appearance seed (called at onRuntimeInitialized). void schSetDarkChrome( bool aDark ) { pcbjam_theme::setDarkChromeFlag( aDark ); } // Tuner helper: a VARIED demo-selection set for the current sheet — smallest + // largest symbol and two bundles of wires (net-ish), mirroring pcbnew's // pcbCollabTestDemoSet so the style preview shows the range of shapes. std::string schCollabTestDemoSet() { SCH_EDIT_FRAME* fr = schFrame(); json groups = json::array(); if( fr ) { if( SCH_SCREEN* screen = currentScreen( fr ) ) { SCH_SYMBOL* smallest = nullptr; SCH_SYMBOL* largest = nullptr; double minA = 0, maxA = 0; std::vector wires; for( SCH_ITEM* item : screen->Items() ) { if( item->Type() == SCH_SYMBOL_T ) { BOX2I bb = item->GetBoundingBox(); double a = (double) bb.GetWidth() * bb.GetHeight(); auto* sym = static_cast( item ); if( !smallest || a < minA ) { smallest = sym; minA = a; } if( !largest || a > maxA ) { largest = sym; maxA = a; } } else if( item->Type() == SCH_LINE_T && wires.size() < 8 ) { wires.push_back( toUtf8( item->m_Uuid.AsString() ) ); } } if( smallest ) groups.push_back( { { "label", "symbol small" }, { "ids", { toUtf8( smallest->m_Uuid.AsString() ) } } } ); if( largest && largest != smallest ) groups.push_back( { { "label", "symbol large" }, { "ids", { toUtf8( largest->m_Uuid.AsString() ) } } } ); if( wires.size() >= 2 ) { size_t half = wires.size() / 2; groups.push_back( { { "label", "wires A" }, { "ids", std::vector( wires.begin(), wires.begin() + half ) } } ); groups.push_back( { { "label", "wires B" }, { "ids", std::vector( wires.begin() + half, wires.end() ) } } ); } } } return json{ { "groups", groups } }.dump(); } // Test/tuner helper: the first N item uuids of the CURRENT sheet — real, // resolvable KIIDs for synthetic remote-selection previews. std::string schCollabTestListItems( int aCount ) { SCH_EDIT_FRAME* fr = schFrame(); json out = json::array(); if( fr ) { if( SCH_SCREEN* screen = currentScreen( fr ) ) { for( SCH_ITEM* item : screen->Items() ) { if( (int) out.size() >= aCount ) break; out.push_back( toUtf8( item->m_Uuid.AsString() ) ); } } } return out.dump(); } // JS → C++ (0005): pan the view to a world position (comment panel "jump to pin"). void schCollabSetViewport( double aCx, double aCy ) { presenceCore().panTo( aCx, aCy ); } // JS → C++ (0008 follow-user): fit a leader's world rect into this canvas. void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH ) { presenceCore().fitViewport( aCx, aCy, aHalfW, aHalfH ); } // JS pull of the current viewport transform: `{cx,cy,scale,w,h}` with scale = px per // IU via the GAL matrix (GetScale() is the zoom, not px/IU — pcbnew 0002 lesson). std::string schCollabGetViewport() { return presenceCore().viewportJson(); } // JS pull of the CURRENT selection's uuids (presence seed + e2e no-leak probe). std::string schCollabGetSelection() { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return "[]"; return presenceCore().selectionUuids( fr ).dump(); } // JS pull of the current selection in the 0006 payload shape. eeschema has no // footprint paths — the uuids ARE the symbol uuids — but the export keeps the // merged image's kicadCollabGetSelectionFull contract uniform across editors. std::string schCollabGetSelectionFull() { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return "{\"uuids\":[],\"fpPaths\":[]}"; json payload; payload["uuids"] = presenceCore().selectionUuids( fr ); payload["fpPaths"] = json::array(); return payload.dump(); } // Test probe (0006): the schematic-item uuids the current peers' cross-app // selections resolve to ON THE CURRENT SHEET — same resolveXsel the render uses. std::string schCollabTestGetCrossMapped() { json arr = json::array(); SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return arr.dump(); for( const pcbjam_presence::PEER& peer : presenceCore().peers ) { for( SCH_ITEM* item : resolveXsel( fr, peer ) ) arr.push_back( toUtf8( item->m_Uuid.AsString() ) ); } return arr.dump(); } // Test helper: REALLY select the current sheet's first item through the selection // tool, then run the presence check (programmatic selects close no canvas event). std::string schCollabTestSelectFirst() { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return ""; SCH_SCREEN* screen = currentScreen( fr ); if( !screen ) return ""; SCH_ITEM* target = nullptr; for( SCH_ITEM* item : screen->Items() ) { target = item; break; } if( !target ) return ""; presenceCore().selectItem( target ); return toUtf8( target->m_Uuid.AsString() ); } // Test helper (0006): REALLY select the first SYMBOL on the current sheet — // the deterministic cross-app subject (TestSelectFirst may pick a wire, which // legitimately maps to nothing in pcbnew). Returns the uuid, "" without one. std::string schCollabTestSelectComponent() { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return ""; SCH_SCREEN* screen = currentScreen( fr ); if( !screen ) return ""; SCH_ITEM* target = nullptr; for( SCH_ITEM* item : screen->Items().OfType( SCH_SYMBOL_T ) ) { // Power symbols (PWR_FLAG, GND, …) legitimately have no footprint — // they'd make the cross-app subject map to nothing by construction. if( static_cast( item )->IsPower() ) continue; target = item; break; } if( !target ) return ""; presenceCore().selectItem( target ); return toUtf8( target->m_Uuid.AsString() ); } // Test helper (0007): select a SPECIFIC item by uuid — see pcbnew_embind.cpp // for why the tiebreak specs must not rely on cross-tab iteration order. bool schCollabTestSelectByUuid( std::string aUuid ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return false; SCH_SCREEN* screen = currentScreen( fr ); if( !screen ) return false; const wxString want = wxString::FromUTF8( aUuid.c_str() ); for( SCH_ITEM* item : screen->Items() ) { if( item->m_Uuid.AsString() == want ) { presenceCore().selectItem( item ); return true; } } return false; } // JS → C++ (0007): tiebreak release — see pcbnew_embind.cpp for the design // (cancel-interactive-if-a-tool-holds-them → selective unselect → infobar → // forced re-emit). void schCollabReleaseSelection( std::string aUuidsJson, std::string aHolder ) { presenceCore().releaseSelection( aUuidsJson, aHolder ); } // Test probe (0007): the current remote soft-lock set as `[{uuid, name}]`. std::string schCollabTestGetLocked() { return presenceCore().locksJson(); } // Test helper: clear the selection through the tool + run the presence check. bool schCollabTestClearSelection() { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return false; fr->CallAfter( [fr]() { // ClearSelection is not on the shared SELECTION_TOOL base — the one // presence hook that stays editor-typed. if( SCH_SELECTION_TOOL* st = fr->GetToolManager()->GetTool() ) { st->ClearSelection(); presenceCore().scheduleSelCheck(); } } ); return true; } // Merged-image dispatch probe (kicad_editor_embind.cpp): is the active top window the // schematic editor? Counterpart of pcbnew_embind.cpp's pcbEditorActive(). bool schEditorActive() { return schFrame() != nullptr; } void kicadSaveSchematic( std::string path ) { SCH_EDIT_FRAME* fr = schFrame(); if( !fr ) return; SCHEMATIC& sch = fr->Schematic(); // Save the CURRENT sheet, not Schematic().Root(): in the wasm open flow the // opened document is displayed as the current sheet but can sit under an // auto-created project root, so Root()'s own screen holds only a child-sheet // symbol (not the loaded items). GetCurrentSheet() is the screen the editor is // actually showing — the one whose items the snapshot/round-trip care about. SCH_SHEET* sheet = fr->GetCurrentSheet().Last(); if( !sheet ) sheet = &sch.Root(); try { SCH_IO_KICAD_SEXPR io; io.SaveSchematicFile( wxString::FromUTF8( path.c_str() ), sheet, &sch ); } catch( ... ) { // Don't abort the wasm runtime on a save failure; the JS caller detects it // by the file being absent / empty. } } static bool kicadCollabBusyProbe() { return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty(); } EMSCRIPTEN_BINDINGS(eeschema) { // Programmatic save of the in-memory schematic (round-trip tests, README §A). function("kicadSaveSchematic", &kicadSaveSchematic); // eeschema-only ysync-review repro hook (name not shared with pcbnew). function("kicadCollabTestSetFieldText", &schCollabTestSetFieldText); // drift-trio phase B action hooks (tool-unique names, merged-image safe). function("kicadCollabTestAddWire", &schCollabTestAddWire); function("kicadCollabTestAddJunction", &schCollabTestAddJunction); function("kicadCollabTestAddNoConnect", &schCollabTestAddNoConnect); function("kicadCollabTestAddLabel", &schCollabTestAddLabel); function("kicadCollabTestAddSymbol", &schCollabTestAddSymbol); function("kicadCollabTestMoveSchItem", &schCollabTestMoveSchItem); function("kicadCollabTestMirrorSchItem", &schCollabTestMirrorSchItem); function("kicadCollabTestDuplicateSchItem", &schCollabTestDuplicateSchItem); #ifndef KICAD_MERGED_EMBIND // JS names ALSO registered by pcbnew_embind.cpp — in the merged image these are // registered once by kicad_editor_embind.cpp, dispatching on the active frame. // Programmatic file open (preferred over UI automation from the web app). function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY); function("kicadOpenFileBusy", &kicadOpenFileBusy); function("kicadTestSetOpenPark", &kicadTestSetOpenPark); function("kicadCollabBusy", &kicadCollabBusyProbe); // Read-only viewer lock (read-only-viewer). function("kicadSetReadOnly", &kicadSetReadOnly); // Yjs collaborative bridge entry points (same contract as pl_editor). function("kicadCollabApply", &schCollabApply); function("kicadCollabSnapshot", &schCollabSnapshot); // v2 items bridge: per-item s-expr payloads (ysync 0008). function("kicadCollabApplyItems", &schCollabApplyItems); function("kicadCollabSnapshotItems", &schCollabSnapshotItems); function("kicadCollabTestMoveFirst", &schCollabTestMoveFirst); function("kicadCollabGetPos", &schCollabGetPos); // ysync-review repro hooks shared with pcbnew (dispatched when merged). function("kicadCollabTestRemoveItem", &schCollabTestRemoveItem); function("kicadCollabTestRotateItem", &schCollabTestRotateItem); function("kicadCollabTestUndo", &schCollabTestUndo); function("kicadCollabTestUndoDepth", &schCollabTestUndoDepth); // Presence (collab-presence 0003) — shared names with pcbnew's 0002 set. function("kicadCollabPresenceStart", &schCollabPresenceStart); function("kicadCollabSetRemote", &schCollabSetRemote); function("kicadCollabSetPins", &schCollabSetPins); function("kicadCollabSetViewport", &schCollabSetViewport); // Follow-user (collab-presence 0008). function("kicadCollabFitViewport", &schCollabFitViewport); function("kicadCollabSetStyle", &schCollabSetStyle); // Live color-theme switch (comments-ux 0002 F4). function("kicadSetColorTheme", &schSetColorTheme); function("kicadSetDarkChrome", &schSetDarkChrome); function("kicadCollabTestListItems", &schCollabTestListItems); function("kicadCollabTestDemoSet", &schCollabTestDemoSet); function("kicadCollabGetViewport", &schCollabGetViewport); function("kicadCollabGetSelection", &schCollabGetSelection); // Cross-app selection (0006). function("kicadCollabGetSelectionFull", &schCollabGetSelectionFull); function("kicadCollabTestGetCrossMapped", &schCollabTestGetCrossMapped); // Selection soft-locks (0007). function("kicadCollabReleaseSelection", &schCollabReleaseSelection); function("kicadCollabTestGetLocked", &schCollabTestGetLocked); function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst); function("kicadCollabTestSelectComponent", &schCollabTestSelectComponent); function("kicadCollabTestSelectByUuid", &schCollabTestSelectByUuid); function("kicadCollabTestClearSelection", &schCollabTestClearSelection); // Library reload after a remote (synced) lib edit — r2-idb-sync realtime. function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY); // Placed-instance count for a library symbol (drives the "a symbol you are // using was updated" toast after a remote lib edit). function("kicadLibsSymbolUsage", &schLibsSymbolUsage); #endif // !KICAD_MERGED_EMBIND } #endif