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 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 VIA1 = "77777777-0000-0000-0000-000000000001"; // a through via (PCB_VIA)
const ZONE1 = "77777777-0000-0000-0000-000000000002"; // a copper zone (ZONE) 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 const SAMPLE_PCB = `(kicad_pcb
\t(version 20241229) \t(version 20241229)
\t(generator "pcbnew") \t(generator "pcbnew")
@ -79,6 +80,12 @@ const SAMPLE_PCB = `(kicad_pcb
\t\t(min_thickness 0.25) \t\t(min_thickness 0.25)
\t\t(polygon (pts (xy 60 110) (xy 75 110) (xy 75 125) (xy 60 125))) \t\t(polygon (pts (xy 60 110) (xy 75 110) (xy 75 125) (xy 60 125)))
\t) \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 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}")) \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, (byId.get(ZONE1) as { poly?: number[][] }).poly?.length,
"zone outline emitted", "zone outline emitted",
).toBeGreaterThanOrEqual(3); ).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); expect(hasAbort(testLogger), "no WASM abort").toBe(false);
}); });
@ -191,8 +203,9 @@ test.describe("pcbnew collab bridge — single page", () => {
["footprint", FP1, "FOOTPRINT"], ["footprint", FP1, "FOOTPRINT"],
["via", VIA1, "PCB_VIA"], ["via", VIA1, "PCB_VIA"],
["zone", ZONE1, "ZONE"], ["zone", ZONE1, "ZONE"],
["board text", TEXT1, "PCB_TEXT"],
] as const) { ] 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}`); await bootAndOpen(page, `add-${label}`);
// Full emit-equivalent payload: snapshot item (native geometry fields) + the clipboard blob. // 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], intervals: [200],
}) })
.toBe(posBefore); .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); expect(hasAbort(testLogger), "no WASM abort").toBe(false);
}); });
} }

View file

@ -28,6 +28,7 @@
#include <tools/pcb_selection.h> #include <tools/pcb_selection.h>
#include <geometry/shape_poly_set.h> #include <geometry/shape_poly_set.h>
#include <geometry/shape_line_chain.h> #include <geometry/shape_line_chain.h>
#include <geometry/eda_angle.h>
#include <kiway_player.h> #include <kiway_player.h>
#include <kiway.h> #include <kiway.h>
#include <kiid.h> #include <kiid.h>
@ -196,9 +197,28 @@ json itemToJson( BOARD_ITEM* aItem )
j["poly"] = pts; 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 // 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 ) ) if( EDA_TEXT* txt = dynamic_cast<EDA_TEXT*>( aItem ) )
j["text"] = toUtf8( txt->GetText() ); j["text"] = toUtf8( txt->GetText() );
@ -327,6 +347,40 @@ BOARD_ITEM* makeItem( BOARD& aBoard, const json& j )
item = zone; 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" ) ) else if( j.contains( "sexpr" ) )
{ {
item = makeFromBlob( aBoard, j.value( "sexpr", "" ) ); item = makeFromBlob( aBoard, j.value( "sexpr", "" ) );
@ -439,10 +493,11 @@ void flushDiff()
json withBlob = j; json withBlob = j;
// Attach an s-expr clipboard blob ONLY for types makeItem reconstructs from it // 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 // fields itemToJson already emitted, so they need no blob — and skipping it avoids a
// wasted SaveSelection plus the asyncify-fragile envelope parse for those. // 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 ); withBlob["sexpr"] = blobForItem( board, live );
added.push_back( withBlob ); added.push_back( withBlob );