feat(pcbnew): collab adds footprints (s-expr blob) + vias/zones (native)

Footprint add reconstructs from the bare `(footprint …)` s-expr clipboard blob via
CLIPBOARD_IO (SetWriter/SetReader redirect it to a string for headless/wasm). The
non-footprint `(kicad_pcb …)` envelope parse (parseBOARD) is asyncify-fragile in wasm
— commit.Add of an envelope-parsed item silently stages nothing and subsequent virtual
dispatch traps ("index out of bounds"), the same wall that deferred the eeschema symbol
blob — so vias and zones reconstruct NATIVELY instead: itemToJson emits drill/layer-pair
for a PCB_VIA and the outline polygon for a ZONE, and makeItem builds a fresh PCB_VIA /
ZONE. flushDiff attaches the blob only to types that need it (footprints/board graphics);
tracks/vias/zones skip it. Includes the dangling-parent fix for the envelope path
(SetParent before delete) which footprints don't hit.

Build: kicad_clipboard.h pulls in the generated pcb_lexer.h (emitted into the common
build subdir by make_lexer) — added -I${KICAD_BUILD}/common to the embind include path.

Tests: sample board gains a via + zone; snapshot asserts their native fields are emitted,
and three round-trip add tests (footprint via blob, via/zone native) delete then re-add
each by uuid and confirm it returns at the same position. 6 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 11:39:29 +02:00
commit 9dbfddc527
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
3 changed files with 272 additions and 9 deletions

View file

@ -26,6 +26,8 @@ const SEG2 = "44444444-0000-0000-0000-000000000002";
const FP1 = "66666666-0000-0000-0000-000000000001";
const FP1_REF = "66666666-0000-0000-0000-0000000000aa"; // Reference field (PCB_FIELD, 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 ZONE1 = "77777777-0000-0000-0000-000000000002"; // a copper zone (ZONE)
const SAMPLE_PCB = `(kicad_pcb
\t(version 20241229)
\t(generator "pcbnew")
@ -66,6 +68,17 @@ const SAMPLE_PCB = `(kicad_pcb
\t\t\t(effects (font (size 1 1) (thickness 0.15)))
\t\t)
\t)
\t(via (at 80 80) (size 1.4) (drill 0.6) (layers "F.Cu" "B.Cu") (net 0) (uuid "${VIA1}"))
\t(zone
\t\t(net 0)
\t\t(net_name "")
\t\t(layer "F.Cu")
\t\t(uuid "${ZONE1}")
\t\t(hatch edge 0.5)
\t\t(connect_pads (clearance 0))
\t\t(min_thickness 0.25)
\t\t(polygon (pts (xy 60 110) (xy 75 110) (xy 75 125) (xy 60 125)))
\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}"))
)
@ -78,6 +91,7 @@ type Mod = {
kicadCollabApply(j: string): unknown;
kicadCollabTestMoveFirst(dx: number, dy: number): string;
kicadCollabGetPos(id: string): string;
kicadCollabTestItemBlob(id: string): string;
};
function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
@ -155,9 +169,69 @@ test.describe("pcbnew collab bridge — single page", () => {
expect(byId.get(FP1_REF)!.type).toBe("PCB_FIELD");
expect(byId.has(FP1_TXT), "footprint user fp_text present").toBe(true);
expect(byId.get(FP1_TXT)!.type).toBe("PCB_TEXT");
// Via/zone carry the native geometry their `added` reconstruction needs (no blob path).
expect(byId.has(VIA1), "via present").toBe(true);
expect(byId.get(VIA1)!.type).toBe("PCB_VIA");
expect((byId.get(VIA1) as { drill?: number }).drill, "via drill emitted").toBeGreaterThan(0);
expect(byId.has(ZONE1), "zone present").toBe(true);
expect(byId.get(ZONE1)!.type).toBe("ZONE");
expect(
(byId.get(ZONE1) as { poly?: number[][] }).poly?.length,
"zone outline emitted",
).toBeGreaterThanOrEqual(3);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// `added` reconstruction of a footprint, via and zone. The emit side attaches BOTH the full
// itemToJson fields AND an s-expr clipboard blob; makeItem then reconstructs a footprint from
// the bare `(footprint …)` blob, and a via/zone NATIVELY from the geometry fields (the
// `(kicad_pcb …)` envelope parse is asyncify-fragile in wasm for those). Round-trip each: read
// its full snapshot item + blob, delete it, re-add, confirm it returns at the same position.
for (const [label, id, type] of [
["footprint", FP1, "FOOTPRINT"],
["via", VIA1, "PCB_VIA"],
["zone", ZONE1, "ZONE"],
] as const) {
test(`apply adds a ${label} (footprint via blob, via/zone native)`, async ({ page, testLogger }) => {
await bootAndOpen(page, `add-${label}`);
// Full emit-equivalent payload: snapshot item (native geometry fields) + the clipboard blob.
const payload = await page.evaluate((i) => {
const snap = JSON.parse(window.Module.kicadCollabSnapshot());
const item = snap.added.find((it: { id: string }) => it.id === i);
return { ...item, sexpr: window.Module.kicadCollabTestItemBlob(i) };
}, id);
expect(payload.id, `${label} in snapshot`).toBe(id);
const posBefore = await page.evaluate((i) => window.Module.kicadCollabGetPos(i), id);
expect(posBefore, `${label} resolvable before`).not.toBe("");
// delete it
await page.evaluate(
(i) => window.Module.kicadCollabApply(JSON.stringify({ added: [], changed: [], removed: [i] })),
id,
);
await expect
.poll(() => page.evaluate((i) => window.Module.kicadCollabGetPos(i), id), {
timeout: 10000,
intervals: [200],
})
.toBe("");
// re-add it
await page.evaluate(
(p) => window.Module.kicadCollabApply(JSON.stringify({ added: [p], changed: [], removed: [] })),
payload,
);
await expect
.poll(() => page.evaluate((i) => window.Module.kicadCollabGetPos(i), id), {
timeout: 10000,
intervals: [200],
})
.toBe(posBefore);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
}
// Apply mutates the model headless: kicadOpenFile returns false (the incomplete-project load
// skips some late steps) but the board IS built, so BOARD_COMMIT::Push takes effect. Rendering
// still needs the real app. (Same headless reality as the eeschema apply test.)