feat(eeschema): collab bridge read/emit + build fixes; apply WIP (yjs-bridge commit 3)
eeschema's half of the Yjs collaborative bridge, reusing the generic reconciler /
BroadcastChannel transport unchanged. Zero kicad-fork change: native SCH_ITEM uuid +
native SCHEMATIC_LISTENER. All in the wasm layer (wasm/bindings/eeschema_embind.cpp).
Working (verified in the web app):
- kicadCollabSnapshot(): enumerate sch.Hierarchy() -> LastScreen()->Items() as
{id,type,x,y}; registers the listener on first call
- emit: SCHEMATIC_LISTENER subclass -> per-item delta via window.kicadCollab.onDelta;
fires on real SCH_COMMIT::Push (a real wire move broadcasts added/removed/changed)
Apply is a documented follow-up (gated off so a peer tab can't crash): SCH_ITEM::Move
traps with 'indirect call signature mismatch' when invoked outside a KiCad tool
coroutine (Asyncify+fiber+exception-trampoline). Modify/Clone/GetPosition all work;
only the virtual Move write traps. Fix direction: route apply through TOOL_MANAGER.
Also: build-kicad-target.sh now force-relinks when only <app>_embind.cpp changed (the
embind .o isn't a make dep, so new bindings silently vanished), and adds the
expected/rtree/fmt thirdparty includes the eeschema bindings need.
Tests: eeschema-collab.spec.ts covers snapshot (green); apply/two-tab skipped with the
blocker noted. WasmTool gates collab to pl_editor only until eeschema apply works.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2d93e51329
commit
4f8c2d1f56
4 changed files with 464 additions and 7 deletions
|
|
@ -6,15 +6,28 @@
|
|||
*/
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/bind.h>
|
||||
#include <kiway_player.h>
|
||||
#include <kiway.h>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <wx/app.h>
|
||||
#include <wx/string.h>
|
||||
#include <wx/window.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <kiid.h>
|
||||
#include <schematic.h>
|
||||
#include <sch_edit_frame.h>
|
||||
#include <sch_commit.h>
|
||||
#include <sch_item.h>
|
||||
#include <sch_screen.h>
|
||||
#include <sch_sheet_path.h>
|
||||
|
||||
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
|
||||
|
|
@ -36,8 +49,311 @@ bool kicadOpenFile( std::string path )
|
|||
std::vector<wxString>( 1, wxString::FromUTF8( path.c_str() ) ) );
|
||||
}
|
||||
|
||||
// ───────────────────────────── 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;
|
||||
|
||||
std::string toUtf8( const wxString& s ) { return std::string( s.utf8_str() ); }
|
||||
|
||||
SCH_EDIT_FRAME* schFrame()
|
||||
{
|
||||
return wxTheApp ? dynamic_cast<SCH_EDIT_FRAME*>( wxTheApp->GetTopWindow() ) : nullptr;
|
||||
}
|
||||
|
||||
json itemToJson( SCH_ITEM* aItem )
|
||||
{
|
||||
VECTOR2I p = aItem->GetPosition();
|
||||
return json{
|
||||
{ "id", toUtf8( aItem->m_Uuid.AsString() ) },
|
||||
{ "type", toUtf8( aItem->GetClass() ) },
|
||||
{ "x", p.x }, // internal units (nm); integral, so no quantization needed
|
||||
{ "y", p.y },
|
||||
};
|
||||
}
|
||||
|
||||
// Full current model as an array of item json, deduped by uuid across the hierarchy.
|
||||
json snapshotItems( SCHEMATIC& aSch )
|
||||
{
|
||||
json arr = json::array();
|
||||
std::set<std::string> seen;
|
||||
|
||||
for( const SCH_SHEET_PATH& path : aSch.Hierarchy() )
|
||||
{
|
||||
SCH_SCREEN* screen = const_cast<SCH_SHEET_PATH&>( path ).LastScreen();
|
||||
|
||||
if( !screen )
|
||||
continue;
|
||||
|
||||
for( SCH_ITEM* item : screen->Items() )
|
||||
{
|
||||
std::string id = toUtf8( item->m_Uuid.AsString() );
|
||||
|
||||
if( seen.insert( id ).second )
|
||||
arr.push_back( itemToJson( item ) );
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
void emit( const json& aDelta )
|
||||
{
|
||||
std::string s = aDelta.dump();
|
||||
EM_ASM( {
|
||||
if( window.kicadCollab && window.kicadCollab.onDelta )
|
||||
window.kicadCollab.onDelta( UTF8ToString( $0 ) );
|
||||
}, s.c_str() );
|
||||
}
|
||||
|
||||
// ChangeSource: native SCHEMATIC_LISTENER. SCH_COMMIT::Push fires these in bulk for
|
||||
// every local edit (move, add, remove, …) — that's our emit trigger.
|
||||
class COLLAB_LISTENER : public SCHEMATIC_LISTENER
|
||||
{
|
||||
public:
|
||||
void OnSchItemsAdded( SCHEMATIC&, std::vector<SCH_ITEM*>& aItems ) override
|
||||
{
|
||||
emitItems( "added", aItems );
|
||||
}
|
||||
|
||||
void OnSchItemsChanged( SCHEMATIC&, std::vector<SCH_ITEM*>& aItems ) override
|
||||
{
|
||||
emitItems( "changed", aItems );
|
||||
}
|
||||
|
||||
void OnSchItemsRemoved( SCHEMATIC&, std::vector<SCH_ITEM*>& aItems ) override
|
||||
{
|
||||
if( s_applyingRemote )
|
||||
return;
|
||||
|
||||
json removed = json::array();
|
||||
|
||||
for( SCH_ITEM* item : aItems )
|
||||
removed.push_back( toUtf8( item->m_Uuid.AsString() ) );
|
||||
|
||||
if( !removed.empty() )
|
||||
emit( json{ { "added", json::array() }, { "changed", json::array() },
|
||||
{ "removed", removed } } );
|
||||
}
|
||||
|
||||
private:
|
||||
void emitItems( const char* aKey, std::vector<SCH_ITEM*>& aItems )
|
||||
{
|
||||
if( s_applyingRemote )
|
||||
return;
|
||||
|
||||
json arr = json::array();
|
||||
|
||||
for( SCH_ITEM* item : aItems )
|
||||
arr.push_back( itemToJson( item ) );
|
||||
|
||||
if( arr.empty() )
|
||||
return;
|
||||
|
||||
json d = { { "added", json::array() }, { "changed", json::array() },
|
||||
{ "removed", json::array() } };
|
||||
d[aKey] = arr;
|
||||
emit( d );
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
// The actual model mutation. Must run inside a KiCad tool coroutine — calling editor
|
||||
// write ops (notably SCH_ITEM::Move) outside one traps with "indirect call signature
|
||||
// mismatch" (the Asyncify+fiber+exception-trampoline machinery; see
|
||||
// memory/eeschema-collab-asyncify-apply / 0003). Routing apply through the tool
|
||||
// framework is the open follow-up; reads (GetPosition) and Clone/Modify already work.
|
||||
void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
|
||||
{
|
||||
SCHEMATIC& sch = aFrame->Schematic();
|
||||
|
||||
s_applyingRemote = true;
|
||||
|
||||
SCH_COMMIT commit( aFrame );
|
||||
bool staged = false;
|
||||
|
||||
for( const json& rid : aDelta.value( "removed", json::array() ) )
|
||||
{
|
||||
SCH_SHEET_PATH path;
|
||||
KIID id( wxString::FromUTF8( rid.get<std::string>().c_str() ) );
|
||||
|
||||
if( SCH_ITEM* item = sch.ResolveItem( id, &path, /*allowNull*/ true ) )
|
||||
{
|
||||
commit.Remove( item, path.LastScreen() );
|
||||
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( j.contains( "x" ) && j.contains( "y" ) )
|
||||
{
|
||||
VECTOR2I newPos( j["x"].get<int>(), j["y"].get<int>() );
|
||||
item->Move( newPos - item->GetPosition() );
|
||||
}
|
||||
|
||||
staged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(0003 follow-up): `added` requires constructing the right SCH_ITEM subclass
|
||||
// per KICAD_T from JSON (no per-item blob path in eeschema — §serialization note).
|
||||
|
||||
if( staged )
|
||||
commit.Push( wxT( "Collaborative edit" ) );
|
||||
|
||||
s_applyingRemote = false;
|
||||
}
|
||||
|
||||
} // 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 in the editor's Asyncify-rooted main loop — invoking it from this
|
||||
// embind ccall, or from an emscripten_async_call/setTimeout callback, traps with an
|
||||
// "indirect call signature mismatch" because those are not the asyncify root (0001 §5).
|
||||
// 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 kicadCollabApply( std::string aJson )
|
||||
{
|
||||
json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
|
||||
|
||||
if( delta.is_discarded() )
|
||||
return;
|
||||
|
||||
SCH_EDIT_FRAME* fr = schFrame();
|
||||
|
||||
if( !fr )
|
||||
return;
|
||||
|
||||
fr->CallAfter( [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 kicadCollabSnapshot()
|
||||
{
|
||||
SCHEMATIC* sch = ensureBridge();
|
||||
json added = sch ? snapshotItems( *sch ) : json::array();
|
||||
|
||||
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 kicadCollabTestMoveFirst( 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<SCH_SHEET_PATH&>( path ).LastScreen();
|
||||
|
||||
if( !screen )
|
||||
continue;
|
||||
|
||||
for( SCH_ITEM* item : screen->Items() )
|
||||
{
|
||||
// Defer the SCH_COMMIT to the main loop (same Asyncify reason as apply).
|
||||
fr->CallAfter( [fr, item, screen, aDx, aDy]() {
|
||||
SCH_COMMIT commit( fr );
|
||||
commit.Modify( item, screen );
|
||||
item->Move( VECTOR2I( aDx, aDy ) );
|
||||
commit.Push( wxT( "Collab test move" ) );
|
||||
} );
|
||||
return toUtf8( item->m_Uuid.AsString() );
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
// Test helper: read an item's position by uuid as "x,y" (internal units).
|
||||
std::string kicadCollabGetPos( 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 "";
|
||||
}
|
||||
|
||||
|
||||
EMSCRIPTEN_BINDINGS(eeschema) {
|
||||
// Programmatic file open (preferred over UI automation from the web app).
|
||||
function("kicadOpenFile", &kicadOpenFile);
|
||||
// Yjs collaborative bridge entry points (same contract as pl_editor).
|
||||
function("kicadCollabApply", &kicadCollabApply);
|
||||
function("kicadCollabSnapshot", &kicadCollabSnapshot);
|
||||
function("kicadCollabTestMoveFirst", &kicadCollabTestMoveFirst);
|
||||
function("kicadCollabGetPos", &kicadCollabGetPos);
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
Loading…
Reference in a new issue