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.