feat(pcbnew): collab adds board text natively (PCB_TEXT) incl. justification

A board-level graphic text (Place→Text) lands in Drawings() and would otherwise hit the
same asyncify-fragile (kicad_pcb …) envelope-blob wall as via/zone on add. So PCB_TEXT
reconstructs NATIVELY: itemToJson emits size/thickness/angle plus horizontal & vertical
justification, mirror, and bold/italic (the text string was already emitted for any
EDA_TEXT); makeItem builds a fresh PCB_TEXT and restores all of them. flushDiff skips the
blob for PCB_TEXT_T. Footprint child text is unaffected — it syncs by move, and its add
is carried by the footprint blob.

Justification matters: it anchors the glyphs relative to the text POSITION, so without it
a left-justified text reconstructed centered on the peer and rendered visibly offset even
though GetPosition() matched (which is why the anchor-only headless check missed it).

Verified two-tab in the real app: a left/bottom-justified bold text added in tab A
reconstructs in tab B at the exact position with matching justification + string. Headless:
a board-text round-trip add test (sample text is left/bottom-justified) asserts the
position AND that hjust/vjust/text round-trip. 7 passed, 1 skipped, 0 aborts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-08 16:02:57 +02:00
commit 2ee87889fa
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
2 changed files with 85 additions and 4 deletions

View file

@ -28,6 +28,7 @@ const FP1_REF = "66666666-0000-0000-0000-0000000000aa"; // Reference field (PCB_
const FP1_TXT = "66666666-0000-0000-0000-0000000000cc"; // user fp_text (PCB_TEXT, F.SilkS)
const VIA1 = "77777777-0000-0000-0000-000000000001"; // a through via (PCB_VIA)
const ZONE1 = "77777777-0000-0000-0000-000000000002"; // a copper zone (ZONE)
const TEXT1 = "77777777-0000-0000-0000-000000000003"; // a board graphic text (PCB_TEXT)
const SAMPLE_PCB = `(kicad_pcb
\t(version 20241229)
\t(generator "pcbnew")
@ -79,6 +80,12 @@ const SAMPLE_PCB = `(kicad_pcb
\t\t(min_thickness 0.25)
\t\t(polygon (pts (xy 60 110) (xy 75 110) (xy 75 125) (xy 60 125)))
\t)
\t(gr_text "BOARDTEXT"
\t\t(at 90 95 0)
\t\t(layer "F.SilkS")
\t\t(uuid "${TEXT1}")
\t\t(effects (font (size 1 1) (thickness 0.15)) (justify left bottom))
\t)
\t(segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG1}"))
\t(segment (start 50.8 76.2) (end 101.6 76.2) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG2}"))
)
@ -179,6 +186,11 @@ test.describe("pcbnew collab bridge — single page", () => {
(byId.get(ZONE1) as { poly?: number[][] }).poly?.length,
"zone outline emitted",
).toBeGreaterThanOrEqual(3);
expect(byId.has(TEXT1), "board text present").toBe(true);
expect(byId.get(TEXT1)!.type).toBe("PCB_TEXT");
expect((byId.get(TEXT1) as { text?: string }).text, "board text string emitted").toBe(
"BOARDTEXT",
);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
@ -191,8 +203,9 @@ test.describe("pcbnew collab bridge — single page", () => {
["footprint", FP1, "FOOTPRINT"],
["via", VIA1, "PCB_VIA"],
["zone", ZONE1, "ZONE"],
["board text", TEXT1, "PCB_TEXT"],
] as const) {
test(`apply adds a ${label} (footprint via blob, via/zone native)`, async ({ page, testLogger }) => {
test(`apply adds a ${label} (footprint via blob, via/zone/text native)`, async ({ page, testLogger }) => {
await bootAndOpen(page, `add-${label}`);
// Full emit-equivalent payload: snapshot item (native geometry fields) + the clipboard blob.
@ -228,6 +241,19 @@ test.describe("pcbnew collab bridge — single page", () => {
intervals: [200],
})
.toBe(posBefore);
// Fidelity beyond the anchor: a text's justification anchors its glyphs, so a peer that
// dropped it would render the text visibly offset even though GetPosition matches. Confirm
// the reconstructed item round-trips its type-defining fields.
const after = await page.evaluate((i) => {
const snap = JSON.parse(window.Module.kicadCollabSnapshot());
return snap.added.find((it: { id: string }) => it.id === i);
}, id);
if (type === "PCB_TEXT") {
expect(after.text, "text string preserved").toBe(payload.text);
expect(after.hjust, "text horizontal justification preserved").toBe(payload.hjust);
expect(after.vjust, "text vertical justification preserved").toBe(payload.vjust);
}
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
}

View file

@ -28,6 +28,7 @@
#include <tools/pcb_selection.h>
#include <geometry/shape_poly_set.h>
#include <geometry/shape_line_chain.h>
#include <geometry/eda_angle.h>
#include <kiway_player.h>
#include <kiway.h>
#include <kiid.h>
@ -196,9 +197,28 @@ json itemToJson( BOARD_ITEM* aItem )
j["poly"] = pts;
}
// A board-level graphic text (Place→Text) also reconstructs NATIVELY (same asyncify reason as
// via/zone): emit its size / stroke / angle so makeItem can rebuild it. Footprint child text
// is synced by move, not `added`, so this is only the board PCB_TEXT case.
else if( aItem->Type() == PCB_TEXT_T )
{
auto* txt = static_cast<PCB_TEXT*>( aItem );
j["tw"] = txt->GetTextSize().x;
j["th"] = txt->GetTextSize().y;
j["thick"] = txt->GetTextThickness();
j["angle"] = txt->GetTextAngle().AsTenthsOfADegree();
// Justification + mirror anchor the glyphs relative to the text POSITION; without them a
// left-justified text reconstructs centered and renders visibly offset from the same
// GetPosition() (peers diverge visually though GetPosition matches). Bold/italic for looks.
j["hjust"] = (int) txt->GetHorizJustify();
j["vjust"] = (int) txt->GetVertJustify();
j["mirror"] = txt->IsMirrored();
j["bold"] = txt->IsBold();
j["italic"] = txt->IsItalic();
}
// Text items (incl. footprint fields / graphic text): carry the string so a move diff is
// legible and a future text `added` can reconstruct. Position-only sync uses x/y above.
// legible and a text `added` can reconstruct. Position-only sync uses x/y above.
if( EDA_TEXT* txt = dynamic_cast<EDA_TEXT*>( aItem ) )
j["text"] = toUtf8( txt->GetText() );
@ -327,6 +347,40 @@ BOARD_ITEM* makeItem( BOARD& aBoard, const json& j )
item = zone;
}
else if( type == "PCB_TEXT" && j.contains( "text" ) )
{
auto* txt = new PCB_TEXT( &aBoard );
txt->SetText( wxString::FromUTF8( j.value( "text", "" ).c_str() ) );
txt->SetPosition( VECTOR2I( j.value( "x", 0 ), j.value( "y", 0 ) ) );
txt->SetLayer( (PCB_LAYER_ID) j.value( "layer", (int) F_SilkS ) );
if( j.contains( "tw" ) )
txt->SetTextSize( VECTOR2I( j.value( "tw", 0 ), j.value( "th", 0 ) ) );
if( j.contains( "thick" ) )
txt->SetTextThickness( j.value( "thick", 0 ) );
if( j.contains( "angle" ) )
txt->SetTextAngle( EDA_ANGLE( j.value( "angle", 0 ), TENTHS_OF_A_DEGREE_T ) );
// Restore justification/mirror so the glyphs sit at the same place relative to position.
if( j.contains( "hjust" ) )
txt->SetHorizJustify( (GR_TEXT_H_ALIGN_T) j.value( "hjust", (int) GR_TEXT_H_ALIGN_LEFT ) );
if( j.contains( "vjust" ) )
txt->SetVertJustify( (GR_TEXT_V_ALIGN_T) j.value( "vjust", (int) GR_TEXT_V_ALIGN_CENTER ) );
if( j.contains( "mirror" ) )
txt->SetMirrored( j.value( "mirror", false ) );
if( j.contains( "bold" ) )
txt->SetBold( j.value( "bold", false ) );
if( j.contains( "italic" ) )
txt->SetItalic( j.value( "italic", false ) );
item = txt;
}
else if( j.contains( "sexpr" ) )
{
item = makeFromBlob( aBoard, j.value( "sexpr", "" ) );
@ -439,10 +493,11 @@ void flushDiff()
json withBlob = j;
// Attach an s-expr clipboard blob ONLY for types makeItem reconstructs from it
// (footprints, board shapes/text, …). Tracks/vias/zones rebuild NATIVELY from the
// (footprints, board graphics, …). Tracks/vias/zones/text rebuild NATIVELY from the
// fields itemToJson already emitted, so they need no blob — and skipping it avoids a
// wasted SaveSelection plus the asyncify-fragile envelope parse for those.
if( live && !isTrackType( live->Type() ) && live->Type() != PCB_ZONE_T )
if( live && !isTrackType( live->Type() ) && live->Type() != PCB_ZONE_T
&& live->Type() != PCB_TEXT_T )
withBlob["sexpr"] = blobForItem( board, live );
added.push_back( withBlob );