feat(collab): follow-user (collab-presence 0008) + chip depth-layer fix

Follow-user: click a peer's roster avatar to mirror their viewport until
local input breaks it.
- collab_presence_core.h: CORE::fitViewport(cx, cy, halfW, halfH) — fit the
  leader's world rect with CONTAIN semantics (zoom derived from the
  follower's own canvas via the ToScreen ratio; GetScale is the zoom, not
  px/IU). Exported as kicadCollabFitViewport from both editor TUs + the
  merged dispatcher.
- presence-kicad.ts: publish the visible world rect (viewportRect) into
  awareness, 100 ms trailing throttle; guarded for pre-0008 handles.
- follow-user.ts: createFollow — follows an awareness CLIENT (a tab, not a
  user); applies leader rect changes via FitViewport, dedupes unchanged
  republishes; break-on-interact compares local onViewport echoes against
  the last applied rect (2% rel tolerance, echo-grace before the first fit
  lands); unfollows on leader-left; pauses on eeschema sheet mismatch.
- PresenceRoster: avatars are follow toggles (ring on the followed peer);
  WasmTool renders the "Following <name> — move to stop" banner.
- tests: 7 controller units (85/85 collab), fitViewport round-trip e2e in
  both kicad presence specs (20/20), two-tab tests/web/follow.spec.ts
  (converge → track → wheel-zoom breaks → subsequent moves ignored).

Chip depth-layer fix (user-reported): name chips washed out inside
low-alpha selection fills — chip rects shared the shapes overlay's single
depth, and same-depth fragments drawn LATER lose the depth test, so an
earlier-painted fill rejected the chip's pixels. Now three layers via the
fork's VIEW_OVERLAY::SetDepthOffset: text (0) < chips + pin dots (1) <
selection shapes (2). drawLabel/drawCursor/drawSelectionBox take the chip
overlay explicitly; comment-pin dots move to the chip layer too (the 0005
"drawn last so pins sit above" comment had the rule backwards). Verified
with a chip-inside-30%-fill pixel repro + the full presence suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u9h8fkQktH7KRECaHJmUG
This commit is contained in:
Gergő Törcsvári 2026-07-09 09:30:40 +02:00
commit 1f85e13acf
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
16 changed files with 821 additions and 52 deletions

View file

@ -128,8 +128,11 @@ struct CORE
STYLE style;
std::shared_ptr<KIGFX::VIEW_OVERLAY> overlay;
// Labels render from their own overlay at the nearest depth (chip rects
// would otherwise erase same-depth text) — see PRESENCE_TEXT_OVERLAY.
// Name chips + pin dots render one depth unit ABOVE the selection shapes
// (else an earlier-painted low-alpha fill rejects the chip's fragments and
// the tag washes out to the fill's alpha), and the text one unit above the
// chips — see the depth-layering note in collab_presence_style.h.
std::shared_ptr<KIGFX::VIEW_OVERLAY> chipOverlay;
std::shared_ptr<KIGFX::VIEW_OVERLAY> textOverlay;
bool started = false;
@ -235,6 +238,38 @@ struct CORE
{ "w", sz.x }, { "h", sz.y } }.dump();
}
/** kicadCollabFitViewport (0008 follow-user): fit the given world RECT
* (center + half-extents, IU) into this canvas — contain, never crop:
* the follower's zoom is derived from ITS OWN canvas size, so leaders
* and followers on different monitors see the same world region.
* Fiber like every other view mutation from JS. */
void fitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
EDA_DRAW_FRAME* fr = frame();
if( !fr || aHalfW <= 0 || aHalfH <= 0 )
return;
pcbjam_collab::runOnFiber( fr, [this, fr, aCx, aCy, aHalfW, aHalfH]() {
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
const VECTOR2I& sz = view->GetScreenPixelSize();
// Pixels-per-IU that CONTAINS the rect in both axes. GetScale() is
// the zoom, not px/IU (0002 lesson) — convert via the GAL matrix:
// zoom scales linearly with px/IU, so target zoom = current zoom ×
// (target px/IU ÷ current px/IU).
double pxPerIuNow = view->ToScreen( 1.0 );
double pxPerIuFit = std::min( sz.x / ( 2.0 * aHalfW ), sz.y / ( 2.0 * aHalfH ) );
if( pxPerIuNow > 0 && pxPerIuFit > 0 )
view->SetScale( view->GetScale() * ( pxPerIuFit / pxPerIuNow ) );
view->SetCenter( VECTOR2D( aCx, aCy ) );
fr->GetCanvas()->ForceRefresh();
emitViewportIfChanged();
} );
}
/** kicadCollabSetViewport (0005): pan to a world position (comment panel
* "jump to pin"). Fiber like every other view mutation from JS. */
void panTo( double aCx, double aCy )
@ -300,16 +335,23 @@ struct CORE
if( !overlay )
{
// Depth layering near→deep: text (0) < chips (1) < shapes (2) —
// fork SetDepthOffset; see collab_presence_style.h.
overlay = view->MakeOverlay();
// Shapes sit one depth unit BELOW the text overlay (fork
// SetDepthOffset) so labels always render on top of chips/boxes.
overlay->SetDepthOffset( PRESENCE_SHAPES_DEPTH_OFFSET );
}
if( !chipOverlay )
{
chipOverlay = view->MakeOverlay();
chipOverlay->SetDepthOffset( PRESENCE_CHIPS_DEPTH_OFFSET );
}
if( !textOverlay )
textOverlay = makePresenceTextOverlay( view );
overlay->Clear();
chipOverlay->Clear();
textOverlay->Clear();
// Screen-constant sizing: px → world units, so cursors/outline widths
@ -326,18 +368,20 @@ struct CORE
drawPeerShapes( *this, fr, peer, color, px );
if( peer.hasCursor )
drawCursor( overlay.get(), textOverlay.get(), peer.cursor, peer.name, color,
px, style );
drawCursor( overlay.get(), chipOverlay.get(), textOverlay.get(), peer.cursor,
peer.name, color, px, style );
}
// Comment pin dots (0005), drawn last so they sit above selection outlines.
// Comment pin dots (0005) — on the CHIPS layer so selection fills
// can't reject their fragments (see drawPin).
for( const PIN& pin : pins )
{
KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color );
drawPin( overlay.get(), pin.pos, color, pin.resolved, px, style );
drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, px, style );
}
view->Update( overlay.get() );
view->Update( chipOverlay.get() );
view->Update( textOverlay.get() );
// The canvas repaints on its own only with focus/input — force it,
// exactly as the cross-probe flash does.

View file

@ -245,8 +245,18 @@ public:
}
};
/** Depth offset for the SHAPES overlay — one unit deeper than the text. */
constexpr double PRESENCE_SHAPES_DEPTH_OFFSET = 1.0;
/**
* Depth layering (0007 lesson, extended): each overlay draws its WHOLE
* command list at one depth, and same-depth fragments drawn later LOSE the
* depth test — so anything that must render on top of something else needs
* its own overlay one unit nearer. Three layers, near → deep:
* text (0) < chips + pin dots (1) < selection shapes/fills (2)
* Chips above fills fixes the washed-out name tags inside low-alpha
* selection areas (the chip fragments were rejected against the
* earlier-drawn fill, leaving only the fill's alpha behind the glyphs).
*/
constexpr double PRESENCE_CHIPS_DEPTH_OFFSET = 1.0;
constexpr double PRESENCE_SHAPES_DEPTH_OFFSET = 2.0;
/** VIEW::MakeOverlay's body, for the text overlay (make + Add to the view). */
inline std::shared_ptr<PRESENCE_TEXT_OVERLAY> makePresenceTextOverlay( KIGFX::VIEW* aView )
@ -257,12 +267,13 @@ inline std::shared_ptr<PRESENCE_TEXT_OVERLAY> makePresenceTextOverlay( KIGFX::VI
}
/** Name tag next to (or inside) a box, per the label placement knobs. `px` is
* world-units-per-screen-pixel. The chip rect goes to the SHAPES overlay,
* the text to the TEXT overlay (nearest depth + pinned TOP-LEFT justify —
* see PRESENCE_TEXT_OVERLAY), so the text always renders on top. */
inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, const BOX2I& aBox,
const std::string& aText, const KIGFX::COLOR4D& aColor, double aPx,
const STYLE& aS )
* world-units-per-screen-pixel. The chip rect goes to the CHIPS overlay
* (above selection fills, below text), the text to the TEXT overlay (nearest
* depth + pinned TOP-LEFT justify — see PRESENCE_TEXT_OVERLAY), so the tag
* renders solid regardless of what selection geometry it overlaps. */
inline void drawLabel( KIGFX::VIEW_OVERLAY* aChipOv, KIGFX::VIEW_OVERLAY* aTextOv,
const BOX2I& aBox, const std::string& aText,
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
{
if( !aS.labelShow || aText.empty() )
return;
@ -288,13 +299,13 @@ inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, c
if( aS.labelChip )
{
double padX = 3 * aPx, padY = 2 * aPx;
aOv->SetIsStroke( false );
aOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
aOv->Rectangle( VECTOR2D( x - padX, y - padY ),
VECTOR2D( x + w + padX, y + h + padY ) );
aOv->SetIsStroke( true );
aOv->SetIsFill( false );
aChipOv->SetIsStroke( false );
aChipOv->SetIsFill( true );
aChipOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
aChipOv->Rectangle( VECTOR2D( x - padX, y - padY ),
VECTOR2D( x + w + padX, y + h + padY ) );
aChipOv->SetIsStroke( true );
aChipOv->SetIsFill( false );
}
aTextOv->SetIsStroke( true );
@ -307,8 +318,11 @@ inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, c
/** Selection highlight for one item, in the chosen shape. `aOutline` is the
* item's exact geometry for selShape 5 (pcbnew supplies it; eeschema passes
* nullptr and shape 5 falls back to the bbox rectangle). */
inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv, BOX2I aBox,
* nullptr and shape 5 falls back to the bbox rectangle). Shapes/fills paint
* on `aOv` (deepest layer); the name chip goes to `aChipOv` one unit nearer
* so it stays solid over any fill (see the depth-layering note above). */
inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aChipOv,
KIGFX::VIEW_OVERLAY* aTextOv, BOX2I aBox,
const std::string& aName, const KIGFX::COLOR4D& aColor, double aPx,
const STYLE& aS, const SHAPE_POLY_SET* aOutline = nullptr )
{
@ -323,7 +337,7 @@ inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTe
BOX2I labelBox = aOutline->BBox();
labelBox.Inflate( KiROUND( aS.selPaddingPx * aPx ) );
drawLabel( aOv, aTextOv, labelBox, aName, aColor, aPx, aS );
drawLabel( aChipOv, aTextOv, labelBox, aName, aColor, aPx, aS );
return;
}
@ -403,11 +417,13 @@ inline void drawSelectionBox( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTe
}
}
drawLabel( aOv, aTextOv, aBox, aName, aColor, aPx, aS );
drawLabel( aChipOv, aTextOv, aBox, aName, aColor, aPx, aS );
}
/** Remote cursor (+ name label) in the chosen shape. */
inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv,
/** Remote cursor (+ name label) in the chosen shape. The cursor glyph paints
* on `aOv`; its label chip on `aChipOv` (same layering as drawSelectionBox). */
inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aChipOv,
KIGFX::VIEW_OVERLAY* aTextOv,
const VECTOR2D& aPos, const std::string& aName,
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
{
@ -455,18 +471,18 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv,
VECTOR2D at = aPos + VECTOR2D( ( aS.cursorSizePx + 4 ) * aPx,
( aS.cursorSizePx + 4 ) * aPx );
// Chip rect on the shapes overlay, text on the TEXT overlay (nearest
// depth) — text always renders on top of its chip.
// Chip rect on the CHIPS overlay, text on the TEXT overlay (nearest
// depth) — text on top of its chip, chip on top of selection fills.
if( aS.cursorLabelChip )
{
double padX = 3 * aPx, padY = 2 * aPx;
aOv->SetIsStroke( false );
aOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
aOv->Rectangle( at + VECTOR2D( -padX, -padY ),
at + VECTOR2D( w + padX, h + padY ) );
aOv->SetIsStroke( true );
aOv->SetIsFill( false );
aChipOv->SetIsStroke( false );
aChipOv->SetIsFill( true );
aChipOv->SetFillColor( aColor.WithAlpha( aS.chipBgAlpha ) );
aChipOv->Rectangle( at + VECTOR2D( -padX, -padY ),
at + VECTOR2D( w + padX, h + padY ) );
aChipOv->SetIsStroke( true );
aChipOv->SetIsFill( false );
}
aTextOv->SetIsStroke( true );
@ -478,7 +494,9 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, KIGFX::VIEW_OVERLAY* aTextOv,
}
}
/** Comment pin dot. */
/** Comment pin dot. Draw onto the CHIPS overlay: at the shapes depth an
* earlier-painted selection fill would reject the dot's fragments (the 0005
* "drawn last sits above" comment had it backwards — later fragments LOSE). */
inline void drawPin( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const KIGFX::COLOR4D& aColor,
bool aResolved, double aPx, const STYLE& aS )
{

View file

@ -733,7 +733,8 @@ pcbjam_presence::CORE& presenceCore()
if( !item )
continue; // not in this schematic (yet) — skip silently
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.textOverlay.get(),
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(),
aCore.textOverlay.get(),
item->ViewBBox(), peer.name, color, px,
aCore.style );
}
@ -745,7 +746,8 @@ pcbjam_presence::CORE& presenceCore()
for( SCH_ITEM* item : resolveXsel( fr, peer ) )
{
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.textOverlay.get(),
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(),
aCore.textOverlay.get(),
item->ViewBBox(), peer.name, color, px,
ghost );
}
@ -1467,6 +1469,12 @@ 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()
@ -1691,6 +1699,8 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadCollabSetRemote", &schCollabSetRemote);
function("kicadCollabSetPins", &schCollabSetPins);
function("kicadCollabSetViewport", &schCollabSetViewport);
// Follow-user (collab-presence 0008).
function("kicadCollabFitViewport", &schCollabFitViewport);
function("kicadCollabSetStyle", &schCollabSetStyle);
function("kicadCollabTestListItems", &schCollabTestListItems);
function("kicadCollabTestDemoSet", &schCollabTestDemoSet);

View file

@ -58,6 +58,8 @@ void pcbCollabPresenceStart();
void pcbCollabSetRemote( std::string aJson );
void pcbCollabSetPins( std::string aJson );
void pcbCollabSetViewport( double aCx, double aCy );
// Follow-user (collab-presence 0008).
void pcbCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH );
void pcbCollabSetStyle( std::string aJson );
std::string pcbCollabTestListItems( int aCount );
std::string pcbCollabTestDemoSet();
@ -90,6 +92,8 @@ void schCollabPresenceStart();
void schCollabSetRemote( std::string aJson );
void schCollabSetPins( std::string aJson );
void schCollabSetViewport( double aCx, double aCy );
// Follow-user (collab-presence 0008).
void schCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH );
void schCollabSetStyle( std::string aJson );
std::string schCollabTestListItems( int aCount );
std::string schCollabTestDemoSet();
@ -337,6 +341,13 @@ static void collabSetViewport( double aCx, double aCy )
pcbEditorActive() ? pcbCollabSetViewport( aCx, aCy ) : schCollabSetViewport( aCx, aCy );
}
// Follow-user (collab-presence 0008).
static void collabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
pcbEditorActive() ? pcbCollabFitViewport( aCx, aCy, aHalfW, aHalfH )
: schCollabFitViewport( aCx, aCy, aHalfW, aHalfH );
}
static void collabSetStyle( std::string aJson )
{
pcbEditorActive() ? pcbCollabSetStyle( aJson ) : schCollabSetStyle( aJson );
@ -426,6 +437,8 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabSetRemote", &collabSetRemote);
function("kicadCollabSetPins", &collabSetPins);
function("kicadCollabSetViewport", &collabSetViewport);
// Follow-user (collab-presence 0008).
function("kicadCollabFitViewport", &collabFitViewport);
function("kicadCollabSetStyle", &collabSetStyle);
function("kicadCollabTestListItems", &collabTestListItems);
function("kicadCollabTestDemoSet", &collabTestDemoSet);

View file

@ -1203,7 +1203,8 @@ pcbjam_presence::CORE& presenceCore()
}
}
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.textOverlay.get(),
pcbjam_presence::drawSelectionBox( aCore.overlay.get(), aCore.chipOverlay.get(),
aCore.textOverlay.get(),
item->ViewBBox(), name, itemColor, px,
style, &outline );
};
@ -1594,6 +1595,12 @@ void pcbCollabSetViewport( double aCx, double aCy )
presenceCore().panTo( aCx, aCy );
}
// JS → C++ (0008 follow-user): fit a leader's world rect into this canvas.
void pcbCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
presenceCore().fitViewport( aCx, aCy, aHalfW, aHalfH );
}
// JS pull of the current viewport transform (world↔screen mapping for the DOM layer):
// `{cx,cy,scale,w,h}` — world center, pixels-per-IU scale, canvas size in px.
std::string pcbCollabGetViewport()
@ -1981,6 +1988,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadCollabSetRemote", &pcbCollabSetRemote);
function("kicadCollabSetPins", &pcbCollabSetPins);
function("kicadCollabSetViewport", &pcbCollabSetViewport);
// Follow-user (collab-presence 0008).
function("kicadCollabFitViewport", &pcbCollabFitViewport);
function("kicadCollabSetStyle", &pcbCollabSetStyle);
function("kicadCollabTestListItems", &pcbCollabTestListItems);
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);