feat(collab): cross-app selection — eeschema symbol ⇄ pcbnew footprint ghost highlight (collab-presence 0006)
Selecting a symbol in eeschema ghost-highlights the linked footprint(s) in
every pcbnew tab of the project, and vice versa — across users AND one
user's own two tabs. Native KIWAY cross-probe is inert in WASM (one frame
per page); this rides the presence layer instead.
- cross-app.ts: project-wide awareness-only room (presenceRoomId), publishes
full PresenceState at selection rate (cursor always null); peers() = other-
TOOL clients incl. own user's other tabs; window.__pcbjamCrossApp test handle
- presence-kicad.ts: parseSelectionEmit (bare array | {uuids,fpPaths}),
xselFromPeerState (pcbnew paths → symbol uuids; eeschema uuids verbatim),
cross peers appended to the kicadCollabSetRemote snapshot as
{id "<user>#x<client>", name "<user> · sch|pcb", xsel}
- C++ (zero fork changes): pcbnew emits {uuids, fpPaths} (FOOTPRINT::GetPath)
and ghost-renders xsel via path-tail suffix scan; eeschema resolves xsel via
ResolveItem gated to the CURRENT sheet (xsel arrives project-wide, unlike
room-scoped selections); ghostStyle = alphas × xselAlphaScale (0.55, tuner-
patchable); new exports kicadCollabGetSelectionFull / TestGetCrossMapped /
TestSelectComponent (skips power symbols — PWR_FLAG has no footprint) +
merged-image dispatch
- tests: presence suites extended (payload shape, ghost render pixel tests,
13/13) + new two-tab tests/web/cross-probe.spec.ts (passing vs real
partykit); eeschema pixel compares now target the #glcanvas-* GAL panel
(the whole-window #canvas compare flaked on the auto-dismissing version
infobar — also fixes the long-known presence-eeschema restore flake);
cross-app + presence-kicad vitest suites
Spec: docs/features/collab-presence/0006 (closed repo).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lg5jwWuhFH5dL8hEcBDuP2
This commit is contained in:
parent
157d60f6b8
commit
3186986a0a
14 changed files with 1276 additions and 48 deletions
|
|
@ -1106,6 +1106,10 @@ struct PEER
|
|||
bool hasCursor = false;
|
||||
VECTOR2D cursor; // world coords (IU/nm)
|
||||
std::vector<KIID> selection;
|
||||
// Cross-app selection (0006): SYMBOL uuids an eeschema peer has selected —
|
||||
// rendered as ghost outlines on every footprint whose GetPath() ends in one
|
||||
// (a reused sheet legitimately maps one symbol to N footprints).
|
||||
std::vector<KIID> xsel;
|
||||
};
|
||||
|
||||
// Comment pin dot (collab-presence 0005): the GAL half of the hybrid pin —
|
||||
|
|
@ -1171,8 +1175,36 @@ json selectionUuids( PCB_EDIT_FRAME* aFrame )
|
|||
return uuids;
|
||||
}
|
||||
|
||||
// The full selection emit payload (0006): the uuids plus, for every selected
|
||||
// FOOTPRINT, its schematic link — the GetPath() KIID_PATH string ending in the
|
||||
// symbol uuid. eeschema peers highlight the corresponding symbols from it.
|
||||
json selectionPayload( PCB_EDIT_FRAME* aFrame )
|
||||
{
|
||||
json payload;
|
||||
payload["uuids"] = selectionUuids( aFrame );
|
||||
payload["fpPaths"] = json::array();
|
||||
|
||||
PCB_SELECTION_TOOL* selTool = aFrame->GetToolManager()->GetTool<PCB_SELECTION_TOOL>();
|
||||
|
||||
if( !selTool )
|
||||
return payload;
|
||||
|
||||
for( EDA_ITEM* item : selTool->GetSelection() )
|
||||
{
|
||||
if( item->Type() != PCB_FOOTPRINT_T )
|
||||
continue;
|
||||
|
||||
const KIID_PATH& path = static_cast<FOOTPRINT*>( item )->GetPath();
|
||||
|
||||
if( !path.empty() )
|
||||
payload["fpPaths"].push_back( toUtf8( path.AsString() ) );
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Post-settle selection emit: read the selection AFTER the triggering event finished
|
||||
// (CallAfter), dedupe against the last emitted set, hand the uuid list to JS.
|
||||
// (CallAfter), dedupe against the last emitted set, hand the payload to JS.
|
||||
void checkSelection()
|
||||
{
|
||||
g_selCheckScheduled = false;
|
||||
|
|
@ -1182,7 +1214,7 @@ void checkSelection()
|
|||
if( !fr )
|
||||
return;
|
||||
|
||||
std::string s = selectionUuids( fr ).dump();
|
||||
std::string s = selectionPayload( fr ).dump();
|
||||
|
||||
if( s == g_lastSelectionJson )
|
||||
return;
|
||||
|
|
@ -1234,6 +1266,47 @@ void redrawOverlay()
|
|||
// All shapes/sizes/placements come from g_style (collab_presence_style.h);
|
||||
// the drawing itself is shared with eeschema's TU so the editors never
|
||||
// diverge visually.
|
||||
// Draw ONE item's selection box under the given style. Exact-geometry
|
||||
// outline (style shape 5): footprints hug their bounding hull, everything
|
||||
// else its transformed shape. Runs on the coroutine fiber (virtual
|
||||
// dispatch), falls back to the bbox on anything that can't produce a
|
||||
// polygon.
|
||||
auto drawItem = [&]( BOARD_ITEM* item, const std::string& name,
|
||||
const KIGFX::COLOR4D& color, const pcbjam_presence::STYLE& style )
|
||||
{
|
||||
SHAPE_POLY_SET outline;
|
||||
|
||||
if( style.selShape == 5 )
|
||||
{
|
||||
try
|
||||
{
|
||||
int pad = KiROUND( style.selPaddingPx * px );
|
||||
int err = KiROUND( px ) + 1;
|
||||
|
||||
if( item->Type() == PCB_FOOTPRINT_T )
|
||||
{
|
||||
outline = static_cast<FOOTPRINT*>( item )->GetBoundingHull();
|
||||
|
||||
if( pad > 0 )
|
||||
outline.Inflate( pad, CORNER_STRATEGY::ROUND_ALL_CORNERS, err );
|
||||
}
|
||||
else
|
||||
{
|
||||
item->TransformShapeToPolygon( outline, (PCB_LAYER_ID) itemLayer( item ),
|
||||
pad, err, ERROR_OUTSIDE );
|
||||
}
|
||||
}
|
||||
catch( ... )
|
||||
{
|
||||
outline.RemoveAllContours();
|
||||
}
|
||||
}
|
||||
|
||||
pcbjam_presence::drawSelectionBox( g_overlay.get(), g_textOverlay.get(),
|
||||
item->ViewBBox(), name, color, px,
|
||||
style, &outline );
|
||||
};
|
||||
|
||||
for( const PEER& peer : g_peers )
|
||||
{
|
||||
KIGFX::COLOR4D color = pcbjam_presence::peerColor( g_style, peer.name, peer.color );
|
||||
|
|
@ -1245,41 +1318,27 @@ void redrawOverlay()
|
|||
if( !item )
|
||||
continue; // not on this board (yet) — skip silently
|
||||
|
||||
// Exact-geometry outline (style shape 5): footprints hug their
|
||||
// bounding hull, everything else its transformed shape. Runs on
|
||||
// the coroutine fiber (virtual dispatch), falls back to the bbox
|
||||
// on anything that can't produce a polygon.
|
||||
SHAPE_POLY_SET outline;
|
||||
drawItem( item, peer.name, color, g_style );
|
||||
}
|
||||
|
||||
if( g_style.selShape == 5 )
|
||||
// Cross-app ghosts (0006): the peer's eeschema symbol uuids, matched
|
||||
// against every footprint's GetPath() tail. The per-redraw linear scan
|
||||
// reflects the live board with no index to invalidate; boards are small
|
||||
// relative to the redraw's own draw cost.
|
||||
if( !peer.xsel.empty() )
|
||||
{
|
||||
pcbjam_presence::STYLE ghost = pcbjam_presence::ghostStyle( g_style );
|
||||
|
||||
for( const KIID& symId : peer.xsel )
|
||||
{
|
||||
try
|
||||
for( FOOTPRINT* fp : board->Footprints() )
|
||||
{
|
||||
int pad = KiROUND( g_style.selPaddingPx * px );
|
||||
int err = KiROUND( px ) + 1;
|
||||
const KIID_PATH& path = fp->GetPath();
|
||||
|
||||
if( item->Type() == PCB_FOOTPRINT_T )
|
||||
{
|
||||
outline = static_cast<FOOTPRINT*>( item )->GetBoundingHull();
|
||||
|
||||
if( pad > 0 )
|
||||
outline.Inflate( pad, CORNER_STRATEGY::ROUND_ALL_CORNERS, err );
|
||||
}
|
||||
else
|
||||
{
|
||||
item->TransformShapeToPolygon( outline, (PCB_LAYER_ID) itemLayer( item ),
|
||||
pad, err, ERROR_OUTSIDE );
|
||||
}
|
||||
}
|
||||
catch( ... )
|
||||
{
|
||||
outline.RemoveAllContours();
|
||||
if( !path.empty() && path.back() == symId )
|
||||
drawItem( fp, peer.name, color, ghost );
|
||||
}
|
||||
}
|
||||
|
||||
pcbjam_presence::drawSelectionBox( g_overlay.get(), g_textOverlay.get(),
|
||||
item->ViewBBox(), peer.name, color, px,
|
||||
g_style, &outline );
|
||||
}
|
||||
|
||||
if( peer.hasCursor )
|
||||
|
|
@ -1700,6 +1759,14 @@ void pcbCollabSetRemote( std::string aJson )
|
|||
peer.selection.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
|
||||
}
|
||||
|
||||
// Cross-app selection (0006): symbol uuids from an eeschema peer,
|
||||
// ghost-rendered on the matching footprints.
|
||||
for( const json& u : p.value( "xsel", json::array() ) )
|
||||
{
|
||||
if( u.is_string() )
|
||||
peer.xsel.emplace_back( wxString::FromUTF8( u.get<std::string>().c_str() ) );
|
||||
}
|
||||
|
||||
peers.push_back( std::move( peer ) );
|
||||
}
|
||||
|
||||
|
|
@ -1886,6 +1953,49 @@ std::string pcbCollabGetSelection()
|
|||
return presence::selectionUuids( fr ).dump();
|
||||
}
|
||||
|
||||
// JS pull of the current selection WITH the footprints' schematic paths (0006):
|
||||
// `{uuids:[…], fpPaths:[…]}` — the same payload checkSelection emits. Seeds the
|
||||
// cross-app presence at attach; GetSelection above keeps its bare-array shape
|
||||
// for the pre-0006 callers and the e2e no-leak probe.
|
||||
std::string pcbCollabGetSelectionFull()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return "{\"uuids\":[],\"fpPaths\":[]}";
|
||||
|
||||
return presence::selectionPayload( fr ).dump();
|
||||
}
|
||||
|
||||
// Test probe (0006): the board-item uuids the current peers' cross-app
|
||||
// selections (xsel symbol uuids) resolve to on THIS board — a deterministic
|
||||
// assertion target next to the pixel diff.
|
||||
std::string pcbCollabTestGetCrossMapped()
|
||||
{
|
||||
json arr = json::array();
|
||||
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return arr.dump();
|
||||
|
||||
for( const presence::PEER& peer : presence::g_peers )
|
||||
{
|
||||
for( const KIID& symId : peer.xsel )
|
||||
{
|
||||
for( FOOTPRINT* fp : fr->GetBoard()->Footprints() )
|
||||
{
|
||||
const KIID_PATH& path = fp->GetPath();
|
||||
|
||||
if( !path.empty() && path.back() == symId )
|
||||
arr.push_back( toUtf8( fp->m_Uuid.AsString() ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return arr.dump();
|
||||
}
|
||||
|
||||
// Test helper: REALLY select the first top-level item through the selection tool (the
|
||||
// same call the cross-probe flash uses), then run the presence check — a programmatic
|
||||
// select has no closing canvas event. Returns the selected uuid.
|
||||
|
|
@ -1918,6 +2028,39 @@ std::string pcbCollabTestSelectFirst()
|
|||
return toUtf8( target->m_Uuid.AsString() );
|
||||
}
|
||||
|
||||
// Test helper (0006): REALLY select the first FOOTPRINT through the selection
|
||||
// tool — the deterministic cross-app subject (TestSelectFirst may pick a track,
|
||||
// which legitimately maps to nothing in eeschema). Returns the uuid, "" if the
|
||||
// board has no footprints.
|
||||
std::string pcbCollabTestSelectComponent()
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
|
||||
if( !fr )
|
||||
return "";
|
||||
|
||||
FOOTPRINT* target = nullptr;
|
||||
|
||||
for( FOOTPRINT* fp : fr->GetBoard()->Footprints() )
|
||||
{
|
||||
target = fp;
|
||||
break;
|
||||
}
|
||||
|
||||
if( !target )
|
||||
return "";
|
||||
|
||||
fr->CallAfter( [fr, target]() {
|
||||
if( PCB_SELECTION_TOOL* st = fr->GetToolManager()->GetTool<PCB_SELECTION_TOOL>() )
|
||||
{
|
||||
st->AddItemToSel( target );
|
||||
schedulePresenceSelCheck();
|
||||
}
|
||||
} );
|
||||
|
||||
return toUtf8( target->m_Uuid.AsString() );
|
||||
}
|
||||
|
||||
// Test helper: clear the selection through the tool + run the presence check.
|
||||
bool pcbCollabTestClearSelection()
|
||||
{
|
||||
|
|
@ -2192,7 +2335,11 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
|||
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);
|
||||
function("kicadCollabGetViewport", &pcbCollabGetViewport);
|
||||
function("kicadCollabGetSelection", &pcbCollabGetSelection);
|
||||
// Cross-app selection (0006).
|
||||
function("kicadCollabGetSelectionFull", &pcbCollabGetSelectionFull);
|
||||
function("kicadCollabTestGetCrossMapped", &pcbCollabTestGetCrossMapped);
|
||||
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);
|
||||
function("kicadCollabTestSelectComponent", &pcbCollabTestSelectComponent);
|
||||
function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection);
|
||||
#endif // !KICAD_MERGED_EMBIND
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue