feat(collab): tuner round 2 — center-anchored labels, exact outlines, varied demo set, clearer color modes

- collab_presence_style.h: GAL BitmapText CENTERS on its position (confirmed
  in GAL::ResetTextAttributes — the mispositioned nameplates); labels/chips
  now hand GAL the block center. New selection shape 5 'exact outline':
  pcbnew hugs real geometry (footprint bounding hull, TransformShapeToPolygon
  for the rest, padding inflates the polygon); eeschema falls back to rect.
- kicadCollabTestDemoSet (both TUs + merged): labeled demo groups — smallest
  + largest footprint and the two busiest nets' segments (symbols + wire
  bundles on sch) — so the style preview covers the real range of shapes.
- PresenceTuner: Colors section rebuilt as explicit modes (per-user / fixed /
  palette) with preset palettes (default, pastel, vivid, okabe-ito), buffered
  hex editing + Apply (the old always-filtering textarea ate keystrokes), an
  'overlay only' hint; demo injection consumes the varied demo set; 'exact
  outline (pcb)' in the shape list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CvqUd4QsJSGHN28aunJRTq
This commit is contained in:
Gergő Törcsvári 2026-07-07 10:41:15 +02:00
commit 0f19811602
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 374 additions and 62 deletions

View file

@ -15,6 +15,7 @@
#include <gal/color4d.h>
#include <geometry/eda_angle.h>
#include <geometry/shape_poly_set.h>
#include <math/util.h>
#include <math/box2.h>
#include <view/view_overlay.h>
@ -32,6 +33,7 @@ struct STYLE
{
// ── selection box ──────────────────────────────────────────────────────
// 0 rect · 1 corner brackets · 2 underline · 3 rounded rect · 4 filled only
// 5 exact item outline (pcbnew; eeschema falls back to rect)
int selShape = 0;
double selStrokeWidth = 2.5; // px
double selStrokeAlpha = 0.9;
@ -164,8 +166,9 @@ inline double textWidth( const std::string& aText, double aGlyphH )
}
/** Name tag next to (or inside) a box, per the label placement knobs. `px` is
* world-units-per-screen-pixel. BitmapText anchors near its position's left
* edge, vertically centered-ish — offsets are tuned around that. */
* world-units-per-screen-pixel. GAL BitmapText CENTERS on its position (the
* default justify is CENTER/CENTER — GAL::ResetTextAttributes), so the block
* math below computes a top-left and hands GAL the block's center. */
inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, const BOX2I& aBox, const std::string& aText,
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
{
@ -183,21 +186,21 @@ inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, const BOX2I& aBox, const std::s
else if( aS.labelHPos == 2 )
x = ( aBox.GetOrigin().x + aBox.GetEnd().x ) / 2.0 - w / 2.0; // center
double y;
double y; // top of the text block
if( aS.labelVPos == 0 )
y = aS.labelInside ? aBox.GetOrigin().y + off : aBox.GetOrigin().y - off;
y = aS.labelInside ? aBox.GetOrigin().y + off : aBox.GetOrigin().y - off - h;
else
y = aS.labelInside ? aBox.GetEnd().y - off : aBox.GetEnd().y + off;
y = aS.labelInside ? aBox.GetEnd().y - off - h : aBox.GetEnd().y + off;
if( aS.labelChip )
{
double padX = 3 * aPx, padY = 2.5 * aPx;
double padX = 3 * aPx, padY = 2 * aPx;
aOv->SetIsStroke( false );
aOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( 0.92 ) );
aOv->Rectangle( VECTOR2D( x - padX, y - h / 2 - padY ),
VECTOR2D( x + w + padX, y + h / 2 + padY ) );
aOv->Rectangle( VECTOR2D( x - padX, y - padY ),
VECTOR2D( x + w + padX, y + h + padY ) );
aOv->SetIsStroke( true );
aOv->SetIsFill( false );
aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, 1 ) );
@ -210,14 +213,32 @@ inline void drawLabel( KIGFX::VIEW_OVERLAY* aOv, const BOX2I& aBox, const std::s
}
aOv->SetGlyphSize( VECTOR2I( KiROUND( h ), KiROUND( h ) ) );
aOv->BitmapText( wxString::FromUTF8( aText.c_str() ), VECTOR2I( KiROUND( x ), KiROUND( y ) ),
ANGLE_0 );
aOv->BitmapText( wxString::FromUTF8( aText.c_str() ),
VECTOR2I( KiROUND( x + w / 2 ), KiROUND( y + h / 2 ) ), ANGLE_0 );
}
/** Selection highlight for one item bbox, in the chosen shape. */
/** 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, BOX2I aBox, const std::string& aName,
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS )
const KIGFX::COLOR4D& aColor, double aPx, const STYLE& aS,
const SHAPE_POLY_SET* aOutline = nullptr )
{
if( aS.selShape == 5 && aOutline && aOutline->OutlineCount() > 0 )
{
aOv->SetIsStroke( true );
aOv->SetIsFill( aS.selFillAlpha > 0.001 );
aOv->SetStrokeColor( aColor.WithAlpha( aS.selStrokeAlpha ) );
aOv->SetFillColor( aColor.WithAlpha( aS.selFillAlpha ) );
aOv->SetLineWidth( aS.selStrokeWidth * aPx );
aOv->Polygon( *aOutline );
BOX2I labelBox = aOutline->BBox();
labelBox.Inflate( KiROUND( aS.selPaddingPx * aPx ) );
drawLabel( aOv, labelBox, aName, aColor, aPx, aS );
return;
}
aBox.Inflate( KiROUND( aS.selPaddingPx * aPx ) );
const VECTOR2D tl = aBox.GetOrigin();
@ -339,17 +360,20 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const st
if( aS.cursorLabel && !aName.empty() )
{
double h = aS.cursorLabelSizePx * aPx;
double w = textWidth( aName, h );
// Top-left of the text block, below-right of the cursor glyph; GAL
// centers text on its position, so it gets the block center.
VECTOR2D at = aPos + VECTOR2D( ( aS.cursorSizePx + 4 ) * aPx,
( aS.cursorSizePx + 9 ) * aPx );
( aS.cursorSizePx + 4 ) * aPx );
if( aS.cursorLabelChip )
{
double w = textWidth( aName, h ), padX = 3 * aPx, padY = 2.5 * aPx;
double padX = 3 * aPx, padY = 2 * aPx;
aOv->SetIsStroke( false );
aOv->SetIsFill( true );
aOv->SetFillColor( aColor.WithAlpha( 0.92 ) );
aOv->Rectangle( at + VECTOR2D( -padX, -h / 2 - padY ),
at + VECTOR2D( w + padX, h / 2 + padY ) );
aOv->Rectangle( at + VECTOR2D( -padX, -padY ),
at + VECTOR2D( w + padX, h + padY ) );
aOv->SetIsStroke( true );
aOv->SetIsFill( false );
aOv->SetStrokeColor( KIGFX::COLOR4D( 1, 1, 1, 1 ) );
@ -361,7 +385,8 @@ inline void drawCursor( KIGFX::VIEW_OVERLAY* aOv, const VECTOR2D& aPos, const st
aOv->SetGlyphSize( VECTOR2I( KiROUND( h ), KiROUND( h ) ) );
aOv->BitmapText( wxString::FromUTF8( aName.c_str() ),
VECTOR2I( KiROUND( at.x ), KiROUND( at.y ) ), ANGLE_0 );
VECTOR2I( KiROUND( at.x + w / 2 ), KiROUND( at.y + h / 2 ) ),
ANGLE_0 );
}
}

View file

@ -55,6 +55,7 @@
#include <schematic_settings.h>
#include <tool/coroutine.h>
#include "collab_presence_style.h"
#include <algorithm>
using namespace emscripten;
using json = nlohmann::json;
@ -1627,6 +1628,66 @@ void schCollabSetStyle( std::string aJson )
presence::scheduleRedraw();
}
// Tuner helper: a VARIED demo-selection set for the current sheet — smallest +
// largest symbol and two bundles of wires (net-ish), mirroring pcbnew's
// pcbCollabTestDemoSet so the style preview shows the range of shapes.
std::string schCollabTestDemoSet()
{
SCH_EDIT_FRAME* fr = schFrame();
json groups = json::array();
if( fr )
{
if( SCH_SCREEN* screen = currentScreen( fr ) )
{
SCH_SYMBOL* smallest = nullptr;
SCH_SYMBOL* largest = nullptr;
double minA = 0, maxA = 0;
std::vector<std::string> wires;
for( SCH_ITEM* item : screen->Items() )
{
if( item->Type() == SCH_SYMBOL_T )
{
BOX2I bb = item->GetBoundingBox();
double a = (double) bb.GetWidth() * bb.GetHeight();
auto* sym = static_cast<SCH_SYMBOL*>( item );
if( !smallest || a < minA ) { smallest = sym; minA = a; }
if( !largest || a > maxA ) { largest = sym; maxA = a; }
}
else if( item->Type() == SCH_LINE_T && wires.size() < 8 )
{
wires.push_back( toUtf8( item->m_Uuid.AsString() ) );
}
}
if( smallest )
groups.push_back( { { "label", "symbol small" },
{ "ids", { toUtf8( smallest->m_Uuid.AsString() ) } } } );
if( largest && largest != smallest )
groups.push_back( { { "label", "symbol large" },
{ "ids", { toUtf8( largest->m_Uuid.AsString() ) } } } );
if( wires.size() >= 2 )
{
size_t half = wires.size() / 2;
groups.push_back( { { "label", "wires A" },
{ "ids", std::vector<std::string>( wires.begin(),
wires.begin() + half ) } } );
groups.push_back( { { "label", "wires B" },
{ "ids", std::vector<std::string>( wires.begin() + half,
wires.end() ) } } );
}
}
}
return json{ { "groups", groups } }.dump();
}
// Test/tuner helper: the first N item uuids of the CURRENT sheet — real,
// resolvable KIIDs for synthetic remote-selection previews.
std::string schCollabTestListItems( int aCount )
@ -1824,6 +1885,7 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadCollabSetViewport", &schCollabSetViewport);
function("kicadCollabSetStyle", &schCollabSetStyle);
function("kicadCollabTestListItems", &schCollabTestListItems);
function("kicadCollabTestDemoSet", &schCollabTestDemoSet);
function("kicadCollabGetViewport", &schCollabGetViewport);
function("kicadCollabGetSelection", &schCollabGetSelection);
function("kicadCollabTestSelectFirst", &schCollabTestSelectFirst);

View file

@ -52,6 +52,7 @@ void pcbCollabSetPins( std::string aJson );
void pcbCollabSetViewport( double aCx, double aCy );
void pcbCollabSetStyle( std::string aJson );
std::string pcbCollabTestListItems( int aCount );
std::string pcbCollabTestDemoSet();
std::string pcbCollabGetViewport();
std::string pcbCollabGetSelection();
std::string pcbCollabTestSelectFirst();
@ -73,6 +74,7 @@ void schCollabSetPins( std::string aJson );
void schCollabSetViewport( double aCx, double aCy );
void schCollabSetStyle( std::string aJson );
std::string schCollabTestListItems( int aCount );
std::string schCollabTestDemoSet();
std::string schCollabGetViewport();
std::string schCollabGetSelection();
std::string schCollabTestSelectFirst();
@ -187,6 +189,11 @@ static std::string collabTestListItems( int aCount )
return pcbEditorActive() ? pcbCollabTestListItems( aCount ) : schCollabTestListItems( aCount );
}
static std::string collabTestDemoSet()
{
return pcbEditorActive() ? pcbCollabTestDemoSet() : schCollabTestDemoSet();
}
static std::string collabGetViewport()
{
return pcbEditorActive() ? pcbCollabGetViewport() : schCollabGetViewport();
@ -231,6 +238,7 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadCollabSetViewport", &collabSetViewport);
function("kicadCollabSetStyle", &collabSetStyle);
function("kicadCollabTestListItems", &collabTestListItems);
function("kicadCollabTestDemoSet", &collabTestDemoSet);
function("kicadCollabGetViewport", &collabGetViewport);
function("kicadCollabGetSelection", &collabGetSelection);
function("kicadCollabTestSelectFirst", &collabTestSelectFirst);

View file

@ -47,6 +47,7 @@
#include <pcb_draw_panel_gal.h>
#include <nlohmann/json.hpp>
#include "collab_presence_style.h"
#include <algorithm>
#include <chrono>
#include <map>
#include <memory>
@ -1232,8 +1233,40 @@ 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;
if( g_style.selShape == 5 )
{
try
{
int pad = KiROUND( g_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(), item->ViewBBox(), peer.name,
color, px, g_style );
color, px, g_style, &outline );
}
if( peer.hasCursor )
@ -1703,6 +1736,71 @@ void pcbCollabSetStyle( std::string aJson )
presence::scheduleRedraw();
}
// Tuner helper: a VARIED demo-selection set — labeled uuid groups (smallest +
// largest footprint, the two busiest nets' track segments) so the style
// preview shows the real range of shapes instead of two overlapping items.
std::string pcbCollabTestDemoSet()
{
PCB_EDIT_FRAME* fr = pcbFrame();
json groups = json::array();
if( fr )
{
BOARD* board = fr->GetBoard();
// Footprints by bbox area → smallest and largest.
FOOTPRINT* smallest = nullptr;
FOOTPRINT* largest = nullptr;
double minA = 0, maxA = 0;
for( FOOTPRINT* f : board->Footprints() )
{
BOX2I bb = f->GetBoundingBox();
double a = (double) bb.GetWidth() * bb.GetHeight();
if( !smallest || a < minA ) { smallest = f; minA = a; }
if( !largest || a > maxA ) { largest = f; maxA = a; }
}
if( smallest )
groups.push_back( { { "label", "fp small (" + toUtf8( smallest->GetReference() ) + ")" },
{ "ids", { toUtf8( smallest->m_Uuid.AsString() ) } } } );
if( largest && largest != smallest )
groups.push_back( { { "label", "fp large (" + toUtf8( largest->GetReference() ) + ")" },
{ "ids", { toUtf8( largest->m_Uuid.AsString() ) } } } );
// Tracks grouped by net → the two busiest nets (capped segment lists).
std::map<std::string, std::vector<std::string>> nets;
for( PCB_TRACK* t : board->Tracks() )
{
std::string net = toUtf8( t->GetNetname() );
if( !net.empty() )
nets[net].push_back( toUtf8( t->m_Uuid.AsString() ) );
}
std::vector<std::pair<std::string, std::vector<std::string>>> byCount( nets.begin(),
nets.end() );
std::sort( byCount.begin(), byCount.end(),
[]( const auto& a, const auto& b ) { return a.second.size() > b.second.size(); } );
for( size_t i = 0; i < byCount.size() && i < 2; ++i )
{
auto ids = byCount[i].second;
if( ids.size() > 12 )
ids.resize( 12 );
groups.push_back( { { "label", "net " + byCount[i].first }, { "ids", ids } } );
}
}
return json{ { "groups", groups } }.dump();
}
// Test/tuner helper: the first N top-level item uuids — real, resolvable KIIDs
// for synthetic remote-selection previews (a solo tab has no peer to borrow from).
std::string pcbCollabTestListItems( int aCount )
@ -2077,6 +2175,7 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadCollabSetViewport", &pcbCollabSetViewport);
function("kicadCollabSetStyle", &pcbCollabSetStyle);
function("kicadCollabTestListItems", &pcbCollabTestListItems);
function("kicadCollabTestDemoSet", &pcbCollabTestDemoSet);
function("kicadCollabGetViewport", &pcbCollabGetViewport);
function("kicadCollabGetSelection", &pcbCollabGetSelection);
function("kicadCollabTestSelectFirst", &pcbCollabTestSelectFirst);