feat(libs): eeschema symbol-chooser footprint selector + preview via publish-time fp-index

The merged kicad_editor bundle made the chooser's footprint side reachable
from a schematic session; this wires up the data:

- boot.ts/constants.ts: every kicad_editor frame seeds BOTH sym-lib-table and
  fp-lib-table (+ placeholder files; a created user lib joins both lists) —
  the eeschema frame used to write fp-lib-table empty, leaving the selector
  dead. TOOL_LIB_KIND remains only the presync/primary-kind lever.
- publish-libs.ts + kicad-pretty.ts: publish fp-index.json per tag —
  [name, uniquePadCount] per footprint (countUniquePads mirrors KiCad's
  GetUniquePadCount(DO_NOT_INCLUDE_NPTH)); index-only top-up mode for
  already-published immutable tags.
- source.ts/cdn-source.ts: new bridge op "index" (source-global, dispatched
  before the lib-id parse) + LibsSource.getFpIndex; the CDN source fetches
  <tag>/fp-index.json once (404 ⇒ null ⇒ C++ default-only fallback).
- dev-demo.mjs: --libs-local serves a local publish-libs layout same-origin
  at /libs-cdn (mirrors --models-local).
- tests/web/eeschema-fp-selector.spec.ts: e2e — chooser opens in --frame=sch,
  selector fills from ONE index crossing, clicking a row per-item-gets the
  body and the cross-face GAL preview renders; adaptive for index-less
  sources (asserts crash-free default-only selector).

Submodule bumps: kicad (index-backed filterFootprints + preview AsyncLoad
fix + modal-pump crash guard), wxwidgets (modal pump logs e.stack).

Doc: pcbjam-private docs/features/libs/0014-eeschema-footprint-selector.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SCWVaCRM9T847PdPwYYajX
This commit is contained in:
Gergő Törcsvári 2026-07-06 13:39:21 +02:00
commit 6fc6a64cd0
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
13 changed files with 492 additions and 31 deletions

2
kicad

@ -1 +1 @@
Subproject commit f503b009a16694f05267907471fb0b2351b2e27f Subproject commit 4a7250d11a91042ba7cb58e3962881373a1c904d

View file

@ -74,6 +74,7 @@ function parseArgs(argv) {
noGallery: false, // disable the example gallery (local-folder + IDB only) noGallery: false, // disable the example gallery (local-folder + IDB only)
modelsTag: null, // 3D models snapshot tag (live CDN, or the local dir's tag) modelsTag: null, // 3D models snapshot tag (live CDN, or the local dir's tag)
modelsLocal: null, // local publish-models --driver local output dir (serve same-origin) modelsLocal: null, // local publish-models --driver local output dir (serve same-origin)
libsLocal: null, // local publish-libs --driver local output dir (serve same-origin)
port: null, port: null,
repo: "https://github.com/emergence-engineering/pcbjam", repo: "https://github.com/emergence-engineering/pcbjam",
}; };
@ -91,6 +92,7 @@ function parseArgs(argv) {
case "--repo": a.repo = next(); break; case "--repo": a.repo = next(); break;
case "--models-tag": a.modelsTag = next(); break; case "--models-tag": a.modelsTag = next(); break;
case "--models-local": a.modelsLocal = next(); break; case "--models-local": a.modelsLocal = next(); break;
case "--libs-local": a.libsLocal = next(); break;
case "-h": case "--help": a.help = true; break; case "-h": case "--help": a.help = true; break;
default: throw new Error(`unknown arg: ${argv[i]}`); default: throw new Error(`unknown arg: ${argv[i]}`);
} }
@ -115,6 +117,8 @@ const HELP = `dev-demo.mjs — run the standalone locally in demo mode (R2-only
--models-tag <tag> enable lazy 3D models from the CDN snapshot at this tag --models-tag <tag> enable lazy 3D models from the CDN snapshot at this tag
--models-local <dir> serve a local publish-models layout (--driver local --compress none) --models-local <dir> serve a local publish-models layout (--driver local --compress none)
same-origin instead of the CDN (requires --models-tag) same-origin instead of the CDN (requires --models-tag)
--libs-local <dir> serve a local publish-libs layout (--driver local) same-origin
instead of the CDN (uses --lib-tag as the snapshot tag)
--port <n> dev server port --port <n> dev server port
By default the read-only example gallery (deploy/demo/gallery.json) is built By default the read-only example gallery (deploy/demo/gallery.json) is built
@ -141,7 +145,20 @@ function main() {
const env = { ...process.env }; const env = { ...process.env };
// --- Libraries: live R2 CDN (the lazy/fat lib-load path), or offline examples. // --- Libraries: live R2 CDN (the lazy/fat lib-load path), or offline examples.
if (a.libTag) { // --libs-local <publish-libs --out dir> serves that layout same-origin at
// /libs-cdn via a public/ symlink (mirrors --models-local) — for testing
// an unpublished snapshot, e.g. one with a fresh fp-index.json.
if (a.libTag && a.libsLocal) {
const link = join(repoRoot, "web/standalone/public/libs-cdn");
try {
if (lstatSync(link)) rmSync(link, { recursive: true, force: true });
} catch {
/* no existing link */
}
symlinkSync(resolve(a.libsLocal, "libs/kicad"), link);
env.VITE_LIBS_SOURCE = "cdn";
env.VITE_LIBS_MANIFEST_URL = `/libs-cdn/${a.libTag}/manifest.json`;
} else if (a.libTag) {
env.VITE_LIBS_SOURCE = "cdn"; env.VITE_LIBS_SOURCE = "cdn";
env.VITE_LIBS_MANIFEST_URL = `${a.cdn}/libs/kicad/${a.libTag}/manifest.json`; env.VITE_LIBS_MANIFEST_URL = `${a.cdn}/libs/kicad/${a.libTag}/manifest.json`;
} else { } else {

View file

@ -17,12 +17,19 @@
// manifest SyncManifest { version, entries: { "<kind>/<name>": {hash,size,mtime} } } // manifest SyncManifest { version, entries: { "<kind>/<name>": {hash,size,mtime} } }
// bundle encodeBundle(manifest, bodies) — cold-init payload (all bodies) // bundle encodeBundle(manifest, bodies) — cold-init payload (all bodies)
// + top `<prefix>/<libTag>/manifest.json` { schema, tag, libs:[{id,name,kind,itemCount}] } // + top `<prefix>/<libTag>/manifest.json` { schema, tag, libs:[{id,name,kind,itemCount}] }
// + `<prefix>/<libTag>/fp-index.json` { schema, tag, libs: { <libId>: [[name, pads], …] } }
// — the publish-time footprint index: unique electrical pad count per footprint,
// so the editor's symbol-chooser footprint selector can filter EVERY footprint
// lib without fat-loading a single body (kicad pcbnew.cpp `filterFootprints`).
// All immutable (content is pinned by the tag). // All immutable (content is pinned by the tag).
// A tag published before fp-index.json existed gets an INDEX-ONLY top-up run:
// bundles/manifests are skipped (immutable + present), only the index is put.
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs"; import { existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { extractAllLibs } from "../../web/backend/src/extract/extract-libs.js"; import { extractAllLibs } from "../../web/backend/src/extract/extract-libs.js";
import { countUniquePads } from "../../web/backend/src/extract/kicad-pretty.js";
import { encodeBundle, type SyncManifest } from "../../web/pcbjam-shared/src/sync-wire.js"; import { encodeBundle, type SyncManifest } from "../../web/pcbjam-shared/src/sync-wire.js";
import { IMMUTABLE, makeStore, putJSON, sha256hex } from "./lib/cdn-store.mjs"; import { IMMUTABLE, makeStore, putJSON, sha256hex } from "./lib/cdn-store.mjs";
@ -99,14 +106,21 @@ async function main(): Promise<void> {
const store = makeStore(a.driver, a); const store = makeStore(a.driver, a);
const enc = new TextEncoder(); const enc = new TextEncoder();
const topKey = `${a.prefix}/${a.libTag}/manifest.json`; const topKey = `${a.prefix}/${a.libTag}/manifest.json`;
const indexKey = `${a.prefix}/${a.libTag}/fp-index.json`;
// Skip-if-exists: the snapshot is immutable + content-pinned by the tag. // Skip-if-exists: the snapshot is immutable + content-pinned by the tag.
if (!a.force && store.getJSON(topKey)) { // A tag published before fp-index.json existed drops into INDEX-ONLY mode:
// recompute + put just the footprint index (pure local work; no bundle puts).
const indexOnly = !a.force && !!store.getJSON(topKey);
if (indexOnly && store.getJSON(indexKey)) {
console.log(`publish-libs: ${topKey} already published — skipping (use --force)`); console.log(`publish-libs: ${topKey} already published — skipping (use --force)`);
return; return;
} }
console.log(`publish-libs: tag=${a.libTag} driver=${store.kind}${a.prefix}/${a.libTag}/`); console.log(
`publish-libs: tag=${a.libTag} driver=${store.kind}${a.prefix}/${a.libTag}/` +
(indexOnly ? " (index-only top-up)" : ""),
);
// Source the full set from upstream when asked (CI path); --symbols-src / // Source the full set from upstream when asked (CI path); --symbols-src /
// --footprints-src still win if also given (local checkouts). // --footprints-src still win if also given (local checkouts).
@ -120,13 +134,20 @@ async function main(): Promise<void> {
} }
const libs = await extractAllLibs({ const libs = await extractAllLibs({
symbolsSrc: a.symbolsSrc ?? undefined, // Index-only: the index covers footprints only, so skip symbol extraction.
symbolsSrc: indexOnly ? undefined : (a.symbolsSrc ?? undefined),
footprintsSrc: a.footprintsSrc ?? undefined, footprintsSrc: a.footprintsSrc ?? undefined,
}); });
const topLibs: Array<{ id: string; name: string; kind: string; itemCount: number }> = []; const topLibs: Array<{ id: string; name: string; kind: string; itemCount: number }> = [];
const fpIndexLibs: Record<string, Array<[string, number]>> = {};
let totalItems = 0; let totalItems = 0;
for (const { lib, kind, items } of libs) { for (const { lib, kind, items } of libs) {
if (kind === "footprint") {
fpIndexLibs[lib] = items.map((it) => [it.name, countUniquePads(it.body)]);
}
if (indexOnly) continue; // bundles/manifests already live under this tag
const bodies = items.map( const bodies = items.map(
(it): [string, Uint8Array] => [`${it.kind}/${it.name}`, enc.encode(it.body)], (it): [string, Uint8Array] => [`${it.kind}/${it.name}`, enc.encode(it.body)],
); );
@ -146,11 +167,18 @@ async function main(): Promise<void> {
totalItems += items.length; totalItems += items.length;
} }
if (!indexOnly) {
topLibs.sort((x, y) => x.id.localeCompare(y.id)); topLibs.sort((x, y) => x.id.localeCompare(y.id));
putJSON(store, topKey, { schema: 1, tag: a.libTag, libs: topLibs }, IMMUTABLE); putJSON(store, topKey, { schema: 1, tag: a.libTag, libs: topLibs }, IMMUTABLE);
}
putJSON(store, indexKey, { schema: 1, tag: a.libTag, libs: fpIndexLibs }, IMMUTABLE);
const fpIndexCount = Object.values(fpIndexLibs).reduce((n, v) => n + v.length, 0);
console.log( console.log(
`publish-libs: done — ${topLibs.length} libs, ${totalItems} items → ${topKey}`, indexOnly
? `publish-libs: done — fp-index only (${fpIndexCount} footprints) → ${indexKey}`
: `publish-libs: done — ${topLibs.length} libs, ${totalItems} items ` +
`(+fp-index: ${fpIndexCount} footprints) → ${topKey}`,
); );
if (store.kind === "local") console.log(`local layout under: ${a.out}`); if (store.kind === "local") console.log(`local layout under: ${a.out}`);
} }

View file

@ -0,0 +1,260 @@
import { test, expect, type Page } from '@playwright/test';
import { waitForRegistry } from '../e2e/utils/element-tracker';
/**
* The symbol chooser's footprint selector + preview in the SCHEMATIC editor
* the cross-face feature the merged kicad_editor bundle exists for (eeschema
* reaches KiFACE(FACE_PCB) in-process; docs/features/editor-unification).
*
* Flow: open the demo schematic, press "A" (Add Symbol) to open the chooser,
* pick Device:R (footprint filter "R_*", 2 pins), and assert:
* - the footprint selector combobox populates with real footprint entries
* (filterFootprints over fp-lib-table rows seeded at boot the eeschema
* frame used to seed fp-lib-table EMPTY, leaving the selector dead);
* - selecting an entry drives the footprint preview (per-item "get" over
* window.kicadLibs) without aborting the runtime.
*
* The footprint list is served either from the publish-time fp-index (op
* "index" CDN source) or by lazy per-lib fat-loads (fallback remote/example
* backend). The spec logs which path ran; it asserts on behavior, not path.
*/
const SHOT = (n: string) => `test-results/eefpsel-${n}.png`;
async function canvasCenter(page: Page): Promise<{ x: number; y: number }> {
const box = await page.locator('#canvas').boundingBox();
expect(box, 'canvas has a bounding box').toBeTruthy();
return { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 };
}
test('symbol chooser footprint selector populates and preview renders (eeschema)', async ({ page }) => {
test.setTimeout(420000);
const logs: string[] = [];
page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`));
const pageErrors: string[] = [];
page.on('pageerror', (e) => pageErrors.push(e.message));
// Record every window.kicadLibs.request the WASM issues (the provider logs
// only into the in-page React buffer, invisible to page.on('console')): wrap
// the provider as boot installs it, into window.__libsCalls = [op,lib,arg,kind][].
await page.addInitScript(() => {
const calls: unknown[][] = ((window as any).__libsCalls = []);
let inner: any;
Object.defineProperty(window, 'kicadLibs', {
configurable: true,
get: () => inner,
set: (v: any) => {
if (v && typeof v.request === 'function') {
const orig = v.request.bind(v);
v = {
...v,
request: (...a: unknown[]) => {
const entry = a.slice(0, 4);
calls.push(entry);
const p = orig(...a);
// 5th slot: 'ok' | 'null' | 'err' once the provider settles.
Promise.resolve(p).then(
(r: unknown) => entry.push(r === null ? 'null' : 'ok'),
() => entry.push('err'),
);
return p;
},
};
}
inner = v;
},
});
});
// ?trace= mirrors the WASM's print/printErr to the real browser console (see
// boot.ts) — any C++ error/abort text becomes visible to this spec.
await page.goto('/default/projects/demo/-/eeschema?trace=KI_TRACE_FP_CHOOSER');
await expect(page.locator('#canvas')).toBeVisible({ timeout: 150000 });
await waitForRegistry(page, 150000);
await expect
.poll(() => page.title(), { timeout: 150000, intervals: [1000] })
.toMatch(/Schematic Editor/i);
await page.waitForFunction(() => !!(window as any).kicadLibs, null, { timeout: 60000 });
await page.waitForTimeout(3000);
await page.screenshot({ path: SHOT('01-boot'), scale: 'css' });
// Add Symbol (hotkey A over the canvas) arms the placer; the chooser dialog
// opens on the tool's first interaction. wx WASM defers wxPostEvent'd
// follow-ups until the next input event, so wiggle the mouse to pump the
// loop and click the canvas once if the dialog hasn't shown. The chooser's
// construction fat-loads the symbol libs before the window registers.
const c = await canvasCenter(page);
await page.mouse.move(c.x, c.y);
await page.mouse.click(c.x, c.y);
await page.waitForTimeout(300);
await page.keyboard.press('a');
await page.waitForTimeout(1500);
// The registry reports little while a modal pumps: the reliable open signal
// is the dialog's Cancel button becoming visible (the main frame has none).
const chooserUp = () =>
page.evaluate(() => {
const reg = (window as any).wxElementRegistry;
if (!reg) return false;
return reg
.findAll({ visible: true })
.some((e: any) => /^&?Cancel$/i.test(e.label ?? ''));
});
for (let i = 0; i < 40 && !(await chooserUp()); i++) {
await page.mouse.move(c.x + (i % 5) * 4, c.y + (i % 3) * 4);
if (i === 2) await page.mouse.click(c.x, c.y); // armed placer → open chooser
await page.waitForTimeout(2000);
}
if (!(await chooserUp())) {
// Diagnostics: dump what IS registered + the page console before failing.
const dump = await page.evaluate(() =>
(window as any).wxElementRegistry
.findAll({})
.map(
(e: any) =>
`${e.typeName ?? '?'}|${e.elementType ?? '?'}|${e.label ?? ''}|vis=${e.visible}`,
)
.slice(0, 120),
);
console.log(`[eefpsel] no chooser; visible elements:\n${dump.join('\n')}`);
console.log(
`[eefpsel] console (libs/boot/errors):\n${logs
.filter((l) => /\[libs\]|\[boot\]|\[out\]|\[err\]|error/i.test(l))
.slice(0, 120)
.join('\n')}`,
);
console.log(`[eefpsel] console tail:\n${logs.slice(-40).join('\n')}`);
}
expect(await chooserUp(), 'symbol chooser dialog opened').toBe(true);
await page.waitForTimeout(1500);
await page.screenshot({ path: SHOT('02-chooser'), scale: 'css' });
// Search for Device:R and select the top hit. The search box has focus on open.
await page.keyboard.type('R', { delay: 60 });
await page.waitForTimeout(1000);
await page.keyboard.press('ArrowDown');
// Selecting a symbol fires showFootprintFor + populateFootprintSelector —
// the footprint side loads now (index: one small fetch; fallback: per-lib
// fat-loads). Poll the combobox until it holds more than the default row.
const comboPopulated = await page
.waitForFunction(
() => {
const reg = (window as any).wxElementRegistry;
if (!reg) return false;
const combos = reg
.findAll({})
.filter(
(e: any) =>
/combo|footprint_choice|choice/i.test(e.typeName ?? '') ||
/combo/i.test(e.elementType ?? ''),
);
// FOOTPRINT_CHOICE reports its item count via label/value on some
// builds; fall back to "a combobox exists" + console-side asserts.
return combos.length > 0 ? combos.map((c: any) => ({
type: c.typeName ?? '', label: c.label ?? '', value: c.value ?? '',
count: c.itemCount ?? -1,
})) : false;
},
null,
{ timeout: 240000 },
)
.then((h) => h.jsonValue());
console.log(`[eefpsel] combo state: ${JSON.stringify(comboPopulated)}`);
await page.waitForTimeout(2500);
await page.screenshot({ path: SHOT('03-selected'), scale: 'css' });
// Which footprint-list path ran? (info only — index for CDN sources, per-lib
// fat-loads for sources without a published index)
const libsCalls = (await page.evaluate(() => (window as any).__libsCalls)) as string[][];
const fpCalls = libsCalls.filter((c) => c[3] === 'footprint');
console.log(
`[eefpsel] fp bridge calls: ${JSON.stringify(fpCalls.map((c) => [c[0], c[2]]))}`,
);
// The selector must have been fed footprints through the shared bridge once
// a symbol is selected (index op and/or per-lib list).
expect(
fpCalls.length > 0,
`footprint bridge traffic after symbol select; all calls:\n${JSON.stringify(libsCalls)}`,
).toBe(true);
// Sources without a published footprint index (the remote/example backend)
// answer the index op null; the WASM side then intentionally leaves the
// selector default-only instead of lazily fat-loading every lib inside the
// modal pump (which crashes Asyncify — see filterFootprints in pcbnew.cpp).
// In that mode the meaningful assertions are: the chooser survived with the
// dual-seeded fp-lib-table, and no modal-pump/runtime error fired.
const indexAnswered = fpCalls.some((c) => c[0] === 'index' && c[4] === 'ok');
if (!indexAnswered) {
console.log('[eefpsel] no fp index from this source — asserting crash-free default-only selector');
expect(pageErrors, `no page errors, got:\n${pageErrors.join('\n')}`).toEqual([]);
expect(
logs.some((l) => /pump error|abort|RuntimeError/i.test(l)),
`no modal-pump crash; console tail:\n${logs.slice(-15).join('\n')}`,
).toBe(false);
return;
}
// Drive the selector: open the popup and click a real footprint row →
// EVT_COMBOBOX → showFootprint → FOOTPRINT_PREVIEW_PANEL::DisplayFootprint
// (per-item get).
const combo = await page.evaluate(() => {
const reg = (window as any).wxElementRegistry;
const combos = reg
.findAllRendered({})
.filter(
(e: any) =>
/combo|footprint_choice|choice/i.test(e.typeName ?? '') ||
/combo/i.test(e.elementType ?? ''),
);
const c = combos[combos.length - 1];
return c ? { x: c.centerX, y: c.centerY } : null;
});
expect(combo, 'footprint selector combobox rendered').toBeTruthy();
await page.mouse.click(combo!.x, combo!.y); // opens the popup list
await page.waitForTimeout(1000);
await page.mouse.move(combo!.x, combo!.y - 40); // pump wx deferred paints
await page.waitForTimeout(500);
await page.screenshot({ path: SHOT('04-popup'), scale: 'css' });
// Click a real footprint row in the popup by its registered position; the
// rows render as popup list entries (label "Lib:Name"). Fall back to
// keyboard if the rows aren't tracked.
const row = await page.evaluate(() => {
const reg = (window as any).wxElementRegistry;
const rows = reg
.findAllRendered({})
.filter((e: any) => /:R_/.test(e.label ?? ''));
const r = rows[2] ?? rows[0];
return r ? { x: r.centerX, y: r.centerY, label: r.label } : null;
});
console.log(`[eefpsel] popup row: ${JSON.stringify(row)}`);
if (row) {
await page.mouse.click(row.x, row.y);
} else {
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(300);
await page.keyboard.press('Enter');
}
await page.waitForTimeout(1000);
await page.mouse.move(c.x, c.y); // pump so the selection's follow-ups run
await page.waitForTimeout(3000);
await page.screenshot({ path: SHOT('05-fp-selected'), scale: 'css' });
// The preview load is a per-item footprint "get" over the bridge (or served
// from the plugin cache when a fat-load already pulled the body).
const callsAfter = (await page.evaluate(() => (window as any).__libsCalls)) as string[][];
const fpGets = callsAfter.filter((c) => c[3] === 'footprint' && c[0] === 'get');
const fpBodies = callsAfter.filter((c) => c[3] === 'footprint' && c[2] === 'bodies');
console.log(`[eefpsel] fp gets: ${JSON.stringify(fpGets)} fatloads: ${fpBodies.length}`);
expect(
fpGets.length + fpBodies.length > 0,
`preview loaded a footprint body; fp calls:\n${JSON.stringify(
callsAfter.filter((c) => c[3] === 'footprint'),
)}`,
).toBe(true);
// Runtime survived the whole cross-face flow.
expect(pageErrors, `no page errors, got:\n${pageErrors.join('\n')}`).toEqual([]);
expect(logs.some((l) => /abort|RuntimeError/i.test(l)), 'no wasm abort').toBe(false);
});

4
web/.gitignore vendored
View file

@ -16,3 +16,7 @@ dist/
# 3D models: public/models-cdn symlinks a local publish-models layout, created # 3D models: public/models-cdn symlinks a local publish-models layout, created
# by scripts/deploy/dev-demo.mjs --models-local — never committed. # by scripts/deploy/dev-demo.mjs --models-local — never committed.
**/public/models-cdn **/public/models-cdn
# Libraries: public/libs-cdn symlinks a local publish-libs layout (dev-demo.mjs
# --libs-local) and .libs-cdn holds its generated bytes — never committed.
**/public/libs-cdn
**/standalone/.libs-cdn/

View file

@ -63,6 +63,48 @@ function findTopFootprint(src: string): [number, number] {
* the footprint's direct children (depth 1) so a `descr`/`tags`/`model` word * the footprint's direct children (depth 1) so a `descr`/`tags`/`model` word
* inside a quoted string can't be mistaken for a real token. * inside a quoted string can't be mistaken for a real token.
*/ */
/**
* Unique electrical pad count, mirroring the WASM fork's
* `FOOTPRINT::GetUniquePadCount( DO_NOT_INCLUDE_NPTH )` (pcbnew/footprint.cpp,
* `GetUniquePadNumbers`): count DISTINCT pad numbers, skipping pads that are
* not on any copper layer, pads with an empty number ("mechanical" pads), and
* NPTH pads. The symbol chooser's footprint selector filters on exactly this
* value (`filterFootprints`, pcbnew.cpp), so the published index must agree
* with what the editor would compute from the parsed footprint.
*/
export function countUniquePads(src: string): number {
const [s, e] = findTopFootprint(src);
const block = src.slice(s, e);
const numbers = new Set<string>();
let i = block.indexOf("(", 1); // first child form
while (i >= 0 && i < block.length) {
const [cs, ce] = matchParen(block, i);
const form = block.slice(cs, ce);
// (pad "<number>" <type> <shape> …) — number quoted (modern) or bare (old).
const m = form.match(
/^\(\s*pad\s+(?:"((?:[^"\\]|\\.)*)"|([^\s()"]+))\s+([a-z_]+)/,
);
if (m) {
const number = m[1] !== undefined ? unescape(m[1]) : m[2]!;
const type = m[3]!;
// The pad's own (layers …) precedes any nested primitives, so the first
// match is the right one. Copper = any token ending in ".Cu" ("F.Cu",
// "B.Cu", "*.Cu", "F&B.Cu"). No layers form ⇒ count it (be permissive).
const layers = form.match(/\(\s*layers\s+([^)]*)\)/);
const onCopper = !layers || /\.Cu\b/.test(layers[1]!);
if (number !== "" && type !== "np_thru_hole" && onCopper) {
numbers.add(number);
}
}
i = block.indexOf("(", ce); // next sibling
}
return numbers.size;
}
export function parseFootprintFile(src: string, name: string): ParsedFootprint { export function parseFootprintFile(src: string, name: string): ParsedFootprint {
const [s, e] = findTopFootprint(src); const [s, e] = findTopFootprint(src);
const block = src.slice(s, e); const block = src.slice(s, e);

View file

@ -17,6 +17,7 @@ import {
buildFpLibTable, buildFpLibTable,
buildSymLibTable, buildSymLibTable,
installLibsProvider, installLibsProvider,
type LibInfo,
type LibsSource, type LibsSource,
} from "./libs/source"; } from "./libs/source";
import { libUri, PCBJAM_LIB_MOUNT } from "./libs/uri"; import { libUri, PCBJAM_LIB_MOUNT } from "./libs/uri";
@ -65,7 +66,8 @@ export interface BootOptions {
* disagrees with the decoded stream under gzip/br) then show bytes, not a %. */ * disagrees with the decoded stream under gzip/br) then show bytes, not a %. */
onProgress?: (loaded: number, total: number) => void; onProgress?: (loaded: number, total: number) => void;
/** Library source backing `window.kicadLibs`. Null/omitted disables libs /** Library source backing `window.kicadLibs`. Null/omitted disables libs
* (an empty sym-lib-table is seeded). Its libs become sym-lib-table rows. */ * (empty lib-tables are seeded). Its libs become sym-lib-table and/or
* fp-lib-table rows depending on the tool (see `libKinds` in doBoot). */
libsSource?: LibsSource | null; libsSource?: LibsSource | null;
/** 3D model source (lazy, per-board). Null/omitted the viewer renders the /** 3D model source (lazy, per-board). Null/omitted the viewer renders the
* bare board only, exactly as before models existed. */ * bare board only, exactly as before models existed. */
@ -238,9 +240,15 @@ async function doBoot(opts: BootOptions): Promise<void> {
// which errors on a non-existent path. The bytes are virtual (served via // which errors on a non-existent path. The bytes are virtual (served via
// window.kicadLibs); this file only satisfies incidental fs checks. // window.kicadLibs); this file only satisfies incidental fs checks.
let libPlaceholderUris: string[] = []; let libPlaceholderUris: string[] = [];
// Which lib table this tool consumes: symbol → sym-lib-table, footprint → // Which lib tables to seed. The merged kicad_editor bundle serves all four
// fp-lib-table. The same lib source feeds whichever table the tool reads. // editors AND cross-face features — the symbol chooser's footprint selector/
// preview reach FACE_PCB from a schematic session — so it gets BOTH tables
// regardless of frame. Library loading is lazy (enumerate is a no-op; bodies
// load on demand), so the extra table costs nothing until a feature reads it.
// Single-engine tools keep their one kind (TOOL_LIB_KIND).
const libKind = TOOL_LIB_KIND[tool]; const libKind = TOOL_LIB_KIND[tool];
const libKinds: ReadonlyArray<"symbol" | "footprint"> =
bundle === "kicad_editor" ? ["symbol", "footprint"] : libKind ? [libKind] : [];
// OCC service (STEP export + STEP/IGES model parsing): install whenever the // OCC service (STEP export + STEP/IGES model parsing): install whenever the
// merged editor bundle boots — a PCB frame can open from ANY session (e.g. // merged editor bundle boots — a PCB frame can open from ANY session (e.g.
@ -250,7 +258,7 @@ async function doBoot(opts: BootOptions): Promise<void> {
installOccService(log); installOccService(log);
} }
if (libsSource && libKind) { if (libsSource && libKinds.length) {
installLibsProvider(libsSource, log); installLibsProvider(libsSource, log);
// 3D models ride the same provider (kind "model3d"): the C++ ensure fallback // 3D models ride the same provider (kind "model3d"): the C++ ensure fallback
// and the board prescan both resolve through this source. // and the board prescan both resolve through this source.
@ -259,24 +267,37 @@ async function doBoot(opts: BootOptions): Promise<void> {
log("[3d] model source installed"); log("[3d] model source installed");
} }
try { try {
// One list per kind (origins are filtered to that kind; user libs are
// kind-agnostic containers and appear in every list).
const listsByKind = new Map<"symbol" | "footprint", LibInfo[]>();
for (const k of libKinds) listsByKind.set(k, await libsSource.listLibs(k));
// Ensure the owner has at least one writable user lib to save items into. // Ensure the owner has at least one writable user lib to save items into.
// Pass the tool's kind so origins are filtered to the right domain. // A user lib holds either kind, so the created lib joins every table.
let libsList = await libsSource.listLibs(libKind); const hasUserLib = [...listsByKind.values()].some((libs) =>
if (libsSource.createLib && !libsList.some((l) => l.type === "user")) { libs.some((l) => l.type === "user"),
);
if (libsSource.createLib && !hasUserLib) {
const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME); const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME);
if (created) { if (created) {
libsList = [...libsList, created]; for (const libs of listsByKind.values()) libs.push(created);
log(`[libs] created default user lib "${created.name}"`); log(`[libs] created default user lib "${created.name}"`);
} }
} }
if (libKind === "footprint") { const symList = listsByKind.get("symbol");
fpLibTable = buildFpLibTable(libsList); const fpList = listsByKind.get("footprint");
log(`[libs] seeded ${libsList.length} lib(s) into fp-lib-table`); if (symList) {
} else { symLibTable = buildSymLibTable(symList);
symLibTable = buildSymLibTable(libsList); log(`[libs] seeded ${symList.length} lib(s) into sym-lib-table`);
log(`[libs] seeded ${libsList.length} lib(s) into sym-lib-table`);
} }
libPlaceholderUris = libsList.map((l) => libUri(l.id)); if (fpList) {
fpLibTable = buildFpLibTable(fpList);
log(`[libs] seeded ${fpList.length} lib(s) into fp-lib-table`);
}
libPlaceholderUris = [
...new Set(
[...listsByKind.values()].flat().map((l) => libUri(l.id)),
),
];
} catch (e) { } catch (e) {
log(`[libs] listLibs failed, seeding empty table: ${String(e)}`); log(`[libs] listLibs failed, seeding empty table: ${String(e)}`);
} }

View file

@ -108,10 +108,13 @@ export const TOOL_NEEDS_CONFIG_SEED: Record<Tool, boolean> = {
}; };
/** /**
* Which library kind a tool consumes drives which lib-table boot populates * The library kind a tool PRIMARILY consumes drives the IDB presync warm-up
* from the lib source (symbol sym-lib-table; footprint fp-lib-table). A * (WasmTool) and which lib-table boot populates for single-engine bundles. The
* user lib is a kind-agnostic container, so the same lib id can land in both * merged kicad_editor bundle seeds BOTH tables regardless of this (cross-face
* tables depending on the tool. `null` = the tool uses no libraries. * features like the symbol chooser's footprint selector read the other kind;
* see `libKinds` in boot.ts) only its presync stays per-frame. A user lib is
* a kind-agnostic container, so the same lib id can land in both tables.
* `null` = the tool uses no libraries.
*/ */
export const TOOL_LIB_KIND: Record<Tool, "symbol" | "footprint" | null> = { export const TOOL_LIB_KIND: Record<Tool, "symbol" | "footprint" | null> = {
pcbnew: "footprint", pcbnew: "footprint",

View file

@ -136,4 +136,28 @@ describe("cdn libs source", () => {
const src = await fakeCdn(); const src = await fakeCdn();
expect(src.saveItemBody).toBeUndefined(); expect(src.saveItemBody).toBeUndefined();
}); });
it("getFpIndex fetches fp-index.json as raw text, null on 404", async () => {
const INDEX = { schema: 1, tag: "9.0.0", libs: { Resistor_SMD: [["R_0402_1005Metric", 2]] } };
let indexFetches = 0;
const fetchImpl = (async (url: string) => {
if (url === `${BASE}/fp-index.json`) {
indexFetches++;
return { ok: true, status: 200, text: async () => JSON.stringify(INDEX) };
}
return { ok: false, status: 404 };
}) as unknown as typeof fetch;
const src = cdnLibsSource(MANIFEST_URL, { fetchImpl, storeFactory: () => memStore() });
expect(JSON.parse((await src.getFpIndex!())!)).toEqual(INDEX);
await src.getFpIndex!(); // cached — no second fetch
expect(indexFetches).toBe(1);
// A tag published without an index resolves null (fallback path).
const noIndex = cdnLibsSource(MANIFEST_URL, {
fetchImpl: (async () => ({ ok: false, status: 404 })) as unknown as typeof fetch,
storeFactory: () => memStore(),
});
expect(await noIndex.getFpIndex!()).toBeNull();
});
}); });

View file

@ -12,6 +12,7 @@ import type { LibInfo, LibItemInfo, LibsSource } from "./source";
* *
* Layout under the manifest's directory (`<cdn>/libs/kicad/<tag>/`): * Layout under the manifest's directory (`<cdn>/libs/kicad/<tag>/`):
* manifest.json top index: every lib (id, name, kind, itemCount) * manifest.json top index: every lib (id, name, kind, itemCount)
* fp-index.json footprint index: per-lib [name, uniquePadCount]
* <libId>/manifest per-lib SyncManifest (GET .../manifest) * <libId>/manifest per-lib SyncManifest (GET .../manifest)
* <libId>/bundle per-lib bundle: manifest + all bodies (cold init) * <libId>/bundle per-lib bundle: manifest + all bodies (cold init)
* Item paths inside a lib follow the `"<kind>/<name>"` scheme. * Item paths inside a lib follow the `"<kind>/<name>"` scheme.
@ -40,6 +41,7 @@ export function cdnLibsSource(
const fetchImpl = opts?.fetchImpl ?? fetch; const fetchImpl = opts?.fetchImpl ?? fetch;
let manifestP: Promise<CdnLibsManifest> | null = null; let manifestP: Promise<CdnLibsManifest> | null = null;
let fpIndexP: Promise<string | null> | null = null;
const fetchManifest = async (): Promise<CdnLibsManifest> => { const fetchManifest = async (): Promise<CdnLibsManifest> => {
// Retry with backoff. Firefox can fail a cross-origin fetch issued in the // Retry with backoff. Firefox can fail a cross-origin fetch issued in the
// first moments after navigation under COEP (the lazy path runs seconds // first moments after navigation under COEP (the lazy path runs seconds
@ -177,6 +179,25 @@ export function cdnLibsSource(
const bytes = await stack.read(`${kind}/${name}`); const bytes = await stack.read(`${kind}/${name}`);
return bytes ? new TextDecoder().decode(bytes) : null; return bytes ? new TextDecoder().decode(bytes) : null;
}, },
async getFpIndex(): Promise<string | null> {
// Published next to the top manifest (immutable, ~100 KB compressed) —
// passed through as raw text; the WASM side parses it once and caches.
// A missing index (tag predates fp-index publishing, or a fetch error)
// resolves null and the editor falls back to per-lib lazy loads; null is
// NOT cached so a transient failure retries on the next chooser use.
if (!fpIndexP) {
fpIndexP = (async () => {
const r = await fetchImpl(`${baseDir}/fp-index.json`);
if (r.status === 404) return null;
if (!r.ok) throw new Error(`cdn fp-index ${r.status}`);
return await r.text();
})().catch(() => {
fpIndexP = null;
return null;
});
}
return fpIndexP;
},
// Read-only: no saveItemBody / createLib (the demo's default libs are fixed). // Read-only: no saveItemBody / createLib (the demo's default libs are fixed).
}; };
} }

View file

@ -130,4 +130,25 @@ describe("installLibsProvider — fat list (arg=bodies)", () => {
const res = await request("list", libUri("Device"), "", "symbol"); const res = await request("list", libUri("Device"), "", "symbol");
expect(JSON.parse(res as string)).toEqual({ symbols: ["R", "C"] }); expect(JSON.parse(res as string)).toEqual({ symbols: ["R", "C"] });
}); });
it('"index" passes the source-global footprint index through (bare mount URI)', async () => {
const INDEX = JSON.stringify({
schema: 1,
tag: "10.0.3",
libs: { Resistor_SMD: [["R_0402_1005Metric", 2]] },
});
const request = installAndGetRequest(
baseSource({ getFpIndex: async () => INDEX }),
);
// The C++ side passes the bare mount root (no lib id) — must not be
// rejected by the lib-id parse.
expect(await request("index", "/mnt/pcbjam/", "", "footprint")).toBe(INDEX);
// Only the footprint kind has an index.
expect(await request("index", "/mnt/pcbjam/", "", "symbol")).toBeNull();
});
it('"index" resolves null when the source has no index', async () => {
const request = installAndGetRequest(baseSource()); // no getFpIndex
expect(await request("index", "/mnt/pcbjam/", "", "footprint")).toBeNull();
});
}); });

View file

@ -91,6 +91,16 @@ export interface LibsSource {
* conflict). Used by boot to ensure the owner has a writable target. * conflict). Used by boot to ensure the owner has a writable target.
*/ */
createLib?(name: string): Promise<LibInfo | null>; createLib?(name: string): Promise<LibInfo | null>;
/**
* The publish-time footprint index as raw JSON text:
* { schema, tag, libs: { "<libId>": [["<name>", <uniquePadCount>], ] } }
* One small artifact covering EVERY footprint lib, so the editor's symbol-
* chooser footprint selector can pin-count/wildcard-filter the full set
* without fat-loading a single body (kicad `filterFootprints`, pcbnew.cpp).
* Optional: sources without a published index omit it (or resolve null) and
* the C++ side falls back to the per-lib lazy load.
*/
getFpIndex?(): Promise<string | null>;
} }
/** /**
@ -242,6 +252,8 @@ export function buildFpLibTable(libsList: LibInfo[]): string {
* "list" -> JSON {"symbols":[...]} | {"footprints":[...]} (names of that kind) * "list" -> JSON {"symbols":[...]} | {"footprints":[...]} (names of that kind)
* "get" -> the item body s-expr (arg = item name; null if absent) * "get" -> the item body s-expr (arg = item name; null if absent)
* "save" -> "ok" / null (arg = JSON {"name":..,"body":..}) * "save" -> "ok" / null (arg = JSON {"name":..,"body":..})
* "index" -> the publish-time footprint index JSON (source-global; lib arg
* ignored) / null when the source has none see getFpIndex.
*/ */
export function installLibsProvider( export function installLibsProvider(
source: LibsSource, source: LibsSource,
@ -266,6 +278,14 @@ export function installLibsProvider(
log(`[libs] request op=${op} kind=model3d arg=${arg}`); log(`[libs] request op=${op} kind=model3d arg=${arg}`);
return handleModel3dRequest(op, arg); return handleModel3dRequest(op, arg);
} }
// The footprint index is source-global (not per-lib) — dispatch before the
// lib-id parse, which would reject the bare mount URI the C++ side passes.
if (op === "index") {
log(`[libs] request op=index kind=${kind}`);
return kind === "footprint" && source.getFpIndex
? await source.getFpIndex()
: null;
}
const id = libIdFromUri(lib); const id = libIdFromUri(lib);
log(`[libs] request op=${op} kind=${kind} lib=${lib} (id=${id}) arg=${arg}`); log(`[libs] request op=${op} kind=${kind} lib=${lib} (id=${id}) arg=${arg}`);
if (!id) return null; if (!id) return null;

@ -1 +1 @@
Subproject commit 4cad1e4876b9749fd8b9e550a3b504c430bbe1a3 Subproject commit d9c3feecddad2ac33fc27a217f1a32885d0c0823