feat(pl_editor): Yjs collaborative bridge — differ/apply + generic reconciler (yjs-bridge commit 2)
Bidirectional bridge between pl_editor's DS_DATA_MODEL and a Yjs doc, two same-origin
tabs syncing over BroadcastChannel. Full architecture in features/yjs-bridge/0001-0002.
C++ (wasm layer, wasm/bindings/pl_editor_embind.cpp — public DS_DATA_MODEL API only,
zero added fork divergence beyond the OnModify hook):
- snapshot-differ ChangeSource: diff model vs last-emitted snapshot on OnModify,
emit per-item delta JSON via EM_ASM window.kicadCollab.onDelta
- kicadCollabApply(json): apply remote delta by uuid — scalars (text/segment/rect)
by field, polygon/bitmap via SetPageLayout-append blob; reseed snapshot + HardRedraw
- kicadCollabSnapshot() (seed/baseline), s_applyingRemote echo guard, and a
kicadCollabTestAddText() PoC local-edit hook
- wire format: {added:[item],changed:[item],removed:[uuid]}, item = {id,type,...fields}
JS (web/apps/frontend/src/wasm/collab/, generic + schema-agnostic):
- reconciler: uuid-keyed Y.Map of per-item Y.Map; down = onDelta→Y, up = observe→apply,
origin-tagged echo suppression; seed-once join adopts the doc authoritatively
- broadcast-transport: minimal BroadcastChannel Yjs provider (query/state catch-up)
- WasmTool wiring behind ?collab=1 (pl_editor only); gated debug logging
Tests: tests/kicad/pl_editor-collab.spec.ts — single-page C++ contract (snapshot/apply
changed+removed+added/echo-suppression) + two-tab BroadcastChannel A<->B propagation.
Reconciler+yjs bundled via esbuild (tests/collab/build.mjs, npm run build:collab).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f94114efb8
commit
83b3418778
15 changed files with 1662 additions and 3 deletions
|
|
@ -6,16 +6,25 @@
|
|||
*/
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/bind.h>
|
||||
#include <kiway_player.h>
|
||||
#include <kiway.h>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <wx/app.h>
|
||||
#include <wx/string.h>
|
||||
#include <wx/window.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <eda_draw_frame.h>
|
||||
#include <kiid.h>
|
||||
#include <font/text_attributes.h>
|
||||
#include <drawing_sheet/ds_data_model.h>
|
||||
#include <drawing_sheet/ds_data_item.h>
|
||||
|
||||
using namespace emscripten;
|
||||
using json = nlohmann::json;
|
||||
|
||||
// Programmatically open a drawing-sheet file (.kicad_wks) in the running editor
|
||||
// frame, without UI automation. Mirrors single_top.cpp's MacOpenFile path: the
|
||||
|
|
@ -49,9 +58,353 @@ void kicadSaveDrawingSheet( std::string path )
|
|||
DS_DATA_MODEL::GetTheInstance().Save( wxString::FromUTF8( path.c_str() ) );
|
||||
}
|
||||
|
||||
// ───────────────────────────── Yjs collaborative bridge ─────────────────────────────
|
||||
//
|
||||
// pl_editor's half of the unified bridge contract (features/yjs-bridge/0001-0002).
|
||||
// This lives in the wasm layer (not the kicad fork) so fork divergence stays at the
|
||||
// single OnModify() hook; everything below uses only public DS_DATA_MODEL / DS_DATA_ITEM
|
||||
// API. The contract is a per-item delta { added:[item], changed:[item], removed:[uuid] }
|
||||
// where each item is { id, type, …fields }. text/segment/rect use decomposed scalars
|
||||
// (field-level merge); polygon/bitmap reuse the native per-item s-expr serializer as an
|
||||
// opaque `sexpr` blob (item-level merge) — see 0002 §field-mapping.
|
||||
//
|
||||
// C++ → JS (emit): OnModify → kicadCollabOnModify → snapshot-diff → window.kicadCollab.onDelta(json)
|
||||
// JS → C++ (apply): peer delta → Module.kicadCollabApply(json) → mutate model by uuid → HardRedraw
|
||||
//
|
||||
namespace {
|
||||
|
||||
// Guard: set around apply() so the differ ignores model mutations we caused ourselves
|
||||
// (apply → HardRedraw/OnModify → differ would otherwise echo them back). 0001 §5.
|
||||
bool s_applyingRemote = false;
|
||||
|
||||
// Last-emitted item-set, keyed by uuid → its field json. The differ's baseline.
|
||||
std::map<std::string, json> s_snapshot;
|
||||
|
||||
std::string toUtf8( const wxString& s ) { return std::string( s.utf8_str() ); }
|
||||
|
||||
const char* typeStr( DS_DATA_ITEM::DS_ITEM_TYPE t )
|
||||
{
|
||||
switch( t )
|
||||
{
|
||||
case DS_DATA_ITEM::DS_TEXT: return "text";
|
||||
case DS_DATA_ITEM::DS_SEGMENT: return "segment";
|
||||
case DS_DATA_ITEM::DS_RECT: return "rect";
|
||||
case DS_DATA_ITEM::DS_POLYPOLYGON:return "polygon";
|
||||
case DS_DATA_ITEM::DS_BITMAP: return "bitmap";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
EDA_DRAW_FRAME* topFrame()
|
||||
{
|
||||
return wxTheApp ? dynamic_cast<EDA_DRAW_FRAME*>( wxTheApp->GetTopWindow() ) : nullptr;
|
||||
}
|
||||
|
||||
DS_DATA_ITEM* findByUuid( DS_DATA_MODEL& aModel, const std::string& aId )
|
||||
{
|
||||
for( DS_DATA_ITEM* it : aModel.GetItems() )
|
||||
{
|
||||
if( toUtf8( it->m_Uuid.AsString() ) == aId )
|
||||
return it;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Serialize one item to its opaque s-expr blob via the native per-item formatter. The
|
||||
// blob already carries (uuid …), so a string compare detects any field change and the
|
||||
// blob re-parses to a fully-formed item on apply. (0002 mechanism 2.)
|
||||
std::string itemBlob( DS_DATA_ITEM* aItem )
|
||||
{
|
||||
std::vector<DS_DATA_ITEM*> one{ aItem };
|
||||
wxString str;
|
||||
DS_DATA_MODEL::GetTheInstance().SaveInString( one, &str );
|
||||
return toUtf8( str );
|
||||
}
|
||||
|
||||
json itemToJson( DS_DATA_ITEM* aItem )
|
||||
{
|
||||
json j;
|
||||
j["id"] = toUtf8( aItem->m_Uuid.AsString() );
|
||||
j["type"] = typeStr( aItem->GetType() );
|
||||
j["name"] = toUtf8( aItem->m_Name );
|
||||
j["x"] = aItem->m_Pos.m_Pos.x; // mm (model's native units for data items)
|
||||
j["y"] = aItem->m_Pos.m_Pos.y;
|
||||
j["anchor"] = aItem->m_Pos.m_Anchor;
|
||||
|
||||
switch( aItem->GetType() )
|
||||
{
|
||||
case DS_DATA_ITEM::DS_TEXT:
|
||||
{
|
||||
auto* t = static_cast<DS_DATA_ITEM_TEXT*>( aItem );
|
||||
j["text"] = toUtf8( t->m_TextBase );
|
||||
j["orient"] = t->m_Orient;
|
||||
j["hjustify"] = (int) t->m_Hjustify;
|
||||
j["vjustify"] = (int) t->m_Vjustify;
|
||||
j["italic"] = t->m_Italic;
|
||||
j["bold"] = t->m_Bold;
|
||||
j["sizeX"] = t->m_TextSize.x;
|
||||
j["sizeY"] = t->m_TextSize.y;
|
||||
break;
|
||||
}
|
||||
case DS_DATA_ITEM::DS_SEGMENT:
|
||||
case DS_DATA_ITEM::DS_RECT:
|
||||
j["ex"] = aItem->m_End.m_Pos.x;
|
||||
j["ey"] = aItem->m_End.m_Pos.y;
|
||||
j["eanchor"] = aItem->m_End.m_Anchor;
|
||||
j["linewidth"] = aItem->m_LineWidth;
|
||||
break;
|
||||
|
||||
case DS_DATA_ITEM::DS_POLYPOLYGON:
|
||||
case DS_DATA_ITEM::DS_BITMAP:
|
||||
j["sexpr"] = itemBlob( aItem ); // opaque; item-level merge
|
||||
break;
|
||||
}
|
||||
|
||||
return j;
|
||||
}
|
||||
|
||||
std::map<std::string, json> snapshotMap()
|
||||
{
|
||||
std::map<std::string, json> out;
|
||||
|
||||
for( DS_DATA_ITEM* it : DS_DATA_MODEL::GetTheInstance().GetItems() )
|
||||
out[ toUtf8( it->m_Uuid.AsString() ) ] = itemToJson( it );
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
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() );
|
||||
}
|
||||
|
||||
// Apply scalar fields from json onto an existing scalar item (text/segment/rect).
|
||||
void applyFields( DS_DATA_ITEM* aItem, const json& j )
|
||||
{
|
||||
if( j.contains( "name" ) ) aItem->m_Name = wxString::FromUTF8( std::string( j["name"] ).c_str() );
|
||||
if( j.contains( "x" ) ) aItem->m_Pos.m_Pos.x = j["x"].get<double>();
|
||||
if( j.contains( "y" ) ) aItem->m_Pos.m_Pos.y = j["y"].get<double>();
|
||||
if( j.contains( "anchor" ) ) aItem->m_Pos.m_Anchor = j["anchor"].get<int>();
|
||||
|
||||
switch( aItem->GetType() )
|
||||
{
|
||||
case DS_DATA_ITEM::DS_TEXT:
|
||||
{
|
||||
auto* t = static_cast<DS_DATA_ITEM_TEXT*>( aItem );
|
||||
if( j.contains( "text" ) ) t->m_TextBase = wxString::FromUTF8( std::string( j["text"] ).c_str() );
|
||||
if( j.contains( "orient" ) ) t->m_Orient = j["orient"].get<double>();
|
||||
if( j.contains( "hjustify" ) ) t->m_Hjustify = (GR_TEXT_H_ALIGN_T) j["hjustify"].get<int>();
|
||||
if( j.contains( "vjustify" ) ) t->m_Vjustify = (GR_TEXT_V_ALIGN_T) j["vjustify"].get<int>();
|
||||
if( j.contains( "italic" ) ) t->m_Italic = j["italic"].get<bool>();
|
||||
if( j.contains( "bold" ) ) t->m_Bold = j["bold"].get<bool>();
|
||||
if( j.contains( "sizeX" ) ) t->m_TextSize.x = j["sizeX"].get<double>();
|
||||
if( j.contains( "sizeY" ) ) t->m_TextSize.y = j["sizeY"].get<double>();
|
||||
break;
|
||||
}
|
||||
case DS_DATA_ITEM::DS_SEGMENT:
|
||||
case DS_DATA_ITEM::DS_RECT:
|
||||
if( j.contains( "ex" ) ) aItem->m_End.m_Pos.x = j["ex"].get<double>();
|
||||
if( j.contains( "ey" ) ) aItem->m_End.m_Pos.y = j["ey"].get<double>();
|
||||
if( j.contains( "eanchor" ) ) aItem->m_End.m_Anchor = j["eanchor"].get<int>();
|
||||
if( j.contains( "linewidth" ) ) aItem->m_LineWidth = j["linewidth"].get<double>();
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
DS_DATA_ITEM* createScalarItem( const std::string& aType )
|
||||
{
|
||||
if( aType == "text" ) return new DS_DATA_ITEM_TEXT( wxEmptyString );
|
||||
if( aType == "segment" ) return new DS_DATA_ITEM( DS_DATA_ITEM::DS_SEGMENT );
|
||||
if( aType == "rect" ) return new DS_DATA_ITEM( DS_DATA_ITEM::DS_RECT );
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Reconstruct a polygon/bitmap from its opaque blob by appending it through the normal
|
||||
// parser (the blob is a self-contained mini (kicad_wks …) model). Its (uuid …) round-trips.
|
||||
void addBlob( DS_DATA_MODEL& aModel, const json& j )
|
||||
{
|
||||
if( !j.contains( "sexpr" ) )
|
||||
return;
|
||||
|
||||
std::string sexpr = j["sexpr"];
|
||||
aModel.SetPageLayout( sexpr.c_str(), /*aAppend*/ true, wxT( "collab-blob" ) );
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
// JS → C++. Apply a remote per-item delta to the model, by uuid. Guarded so the
|
||||
// resulting model mutations are not re-emitted as local changes.
|
||||
void kicadCollabApply( std::string aJson )
|
||||
{
|
||||
json delta = json::parse( aJson, nullptr, /*allow_exceptions*/ false );
|
||||
|
||||
if( delta.is_discarded() )
|
||||
return;
|
||||
|
||||
s_applyingRemote = true;
|
||||
|
||||
DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance();
|
||||
|
||||
for( const json& rid : delta.value( "removed", json::array() ) )
|
||||
{
|
||||
if( DS_DATA_ITEM* item = findByUuid( model, rid.get<std::string>() ) )
|
||||
{
|
||||
model.Remove( item );
|
||||
delete item;
|
||||
}
|
||||
}
|
||||
|
||||
for( const json& j : delta.value( "changed", json::array() ) )
|
||||
{
|
||||
std::string id = j.value( "id", "" );
|
||||
std::string type = j.value( "type", "" );
|
||||
DS_DATA_ITEM* item = findByUuid( model, id );
|
||||
|
||||
if( type == "polygon" || type == "bitmap" )
|
||||
{
|
||||
// Blob items merge at item granularity: replace wholesale.
|
||||
if( item )
|
||||
{
|
||||
model.Remove( item );
|
||||
delete item;
|
||||
}
|
||||
|
||||
addBlob( model, j );
|
||||
}
|
||||
else if( item )
|
||||
{
|
||||
applyFields( item, j );
|
||||
}
|
||||
}
|
||||
|
||||
for( const json& j : delta.value( "added", json::array() ) )
|
||||
{
|
||||
std::string id = j.value( "id", "" );
|
||||
std::string type = j.value( "type", "" );
|
||||
|
||||
if( !id.empty() && findByUuid( model, id ) )
|
||||
continue; // already present (our own echo)
|
||||
|
||||
if( type == "polygon" || type == "bitmap" )
|
||||
{
|
||||
addBlob( model, j );
|
||||
}
|
||||
else if( DS_DATA_ITEM* item = createScalarItem( type ) )
|
||||
{
|
||||
item->m_Uuid = KIID( wxString::FromUTF8( id.c_str() ) );
|
||||
applyFields( item, j );
|
||||
model.Append( item );
|
||||
}
|
||||
}
|
||||
|
||||
// Rebase the differ on the post-apply state so our own mutations aren't echoed,
|
||||
// then rebuild the GAL view from the model. (Selection re-acquire by uuid is a
|
||||
// deferred refinement — 0002.)
|
||||
s_snapshot = snapshotMap();
|
||||
|
||||
if( EDA_DRAW_FRAME* fr = topFrame() )
|
||||
fr->HardRedraw();
|
||||
|
||||
s_applyingRemote = false;
|
||||
}
|
||||
|
||||
|
||||
// C++ → JS. The snapshot-differ ChangeSource: derive per-item add/remove/change events
|
||||
// by diffing the current model against the last-emitted snapshot, then emit the delta.
|
||||
// Called from PL_EDITOR_FRAME::OnModify() (pl_editor's single change chokepoint).
|
||||
extern "C" void kicadCollabOnModify()
|
||||
{
|
||||
if( s_applyingRemote )
|
||||
return;
|
||||
|
||||
std::map<std::string, json> cur = snapshotMap();
|
||||
|
||||
json added = json::array();
|
||||
json changed = json::array();
|
||||
json removed = json::array();
|
||||
|
||||
for( const auto& [id, j] : cur )
|
||||
{
|
||||
auto prev = s_snapshot.find( id );
|
||||
|
||||
if( prev == s_snapshot.end() )
|
||||
added.push_back( j );
|
||||
else if( prev->second != j )
|
||||
changed.push_back( j );
|
||||
}
|
||||
|
||||
for( const auto& [id, j] : s_snapshot )
|
||||
{
|
||||
if( !cur.count( id ) )
|
||||
removed.push_back( id );
|
||||
}
|
||||
|
||||
s_snapshot = std::move( cur );
|
||||
|
||||
if( added.empty() && changed.empty() && removed.empty() )
|
||||
return;
|
||||
|
||||
emit( json{ { "added", added }, { "changed", changed }, { "removed", removed } } );
|
||||
}
|
||||
|
||||
|
||||
// JS pull of the full current model as an all-"added" delta, used to seed the Y.Doc on
|
||||
// join and to (re)baseline the differ. Idempotent.
|
||||
std::string kicadCollabSnapshot()
|
||||
{
|
||||
std::map<std::string, json> cur = snapshotMap();
|
||||
|
||||
json added = json::array();
|
||||
|
||||
for( const auto& [id, j] : cur )
|
||||
added.push_back( j );
|
||||
|
||||
s_snapshot = cur;
|
||||
|
||||
return json{ { "added", added }, { "changed", json::array() },
|
||||
{ "removed", json::array() } }.dump();
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
// a deterministic local edit in one tab and observe it propagate, without canvas UI
|
||||
// automation. Returns the new item's uuid.
|
||||
std::string kicadCollabTestAddText( std::string aText, double aX, double aY )
|
||||
{
|
||||
DS_DATA_MODEL& model = DS_DATA_MODEL::GetTheInstance();
|
||||
|
||||
auto* item = new DS_DATA_ITEM_TEXT( wxString::FromUTF8( aText.c_str() ) );
|
||||
item->m_Pos.m_Pos.x = aX;
|
||||
item->m_Pos.m_Pos.y = aY;
|
||||
item->m_Pos.m_Anchor = LT_CORNER;
|
||||
model.Append( item );
|
||||
|
||||
if( EDA_DRAW_FRAME* fr = topFrame() )
|
||||
{
|
||||
fr->OnModify(); // -> kicadCollabOnModify -> emit added
|
||||
fr->HardRedraw(); // show it locally
|
||||
}
|
||||
|
||||
return toUtf8( item->m_Uuid.AsString() );
|
||||
}
|
||||
|
||||
EMSCRIPTEN_BINDINGS(pl_editor) {
|
||||
// Programmatic file open (preferred over UI automation from the web app).
|
||||
function("kicadOpenFile", &kicadOpenFile);
|
||||
function("kicadSaveDrawingSheet", &kicadSaveDrawingSheet);
|
||||
// Yjs collaborative bridge entry points.
|
||||
function("kicadCollabApply", &kicadCollabApply);
|
||||
function("kicadCollabSnapshot", &kicadCollabSnapshot);
|
||||
function("kicadCollabTestAddText", &kicadCollabTestAddText);
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
Loading…
Reference in a new issue