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

View file

@ -17,6 +17,7 @@ import {
buildFpLibTable,
buildSymLibTable,
installLibsProvider,
type LibInfo,
type LibsSource,
} from "./libs/source";
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 %. */
onProgress?: (loaded: number, total: number) => void;
/** 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;
/** 3D model source (lazy, per-board). Null/omitted ⇒ the viewer renders the
* 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
// window.kicadLibs); this file only satisfies incidental fs checks.
let libPlaceholderUris: string[] = [];
// Which lib table this tool consumes: symbol → sym-lib-table, footprint →
// fp-lib-table. The same lib source feeds whichever table the tool reads.
// Which lib tables to seed. The merged kicad_editor bundle serves all four
// 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 libKinds: ReadonlyArray<"symbol" | "footprint"> =
bundle === "kicad_editor" ? ["symbol", "footprint"] : libKind ? [libKind] : [];
// 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.
@ -250,7 +258,7 @@ async function doBoot(opts: BootOptions): Promise<void> {
installOccService(log);
}
if (libsSource && libKind) {
if (libsSource && libKinds.length) {
installLibsProvider(libsSource, log);
// 3D models ride the same provider (kind "model3d"): the C++ ensure fallback
// 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");
}
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.
// Pass the tool's kind so origins are filtered to the right domain.
let libsList = await libsSource.listLibs(libKind);
if (libsSource.createLib && !libsList.some((l) => l.type === "user")) {
// A user lib holds either kind, so the created lib joins every table.
const hasUserLib = [...listsByKind.values()].some((libs) =>
libs.some((l) => l.type === "user"),
);
if (libsSource.createLib && !hasUserLib) {
const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME);
if (created) {
libsList = [...libsList, created];
for (const libs of listsByKind.values()) libs.push(created);
log(`[libs] created default user lib "${created.name}"`);
}
}
if (libKind === "footprint") {
fpLibTable = buildFpLibTable(libsList);
log(`[libs] seeded ${libsList.length} lib(s) into fp-lib-table`);
} else {
symLibTable = buildSymLibTable(libsList);
log(`[libs] seeded ${libsList.length} lib(s) into sym-lib-table`);
const symList = listsByKind.get("symbol");
const fpList = listsByKind.get("footprint");
if (symList) {
symLibTable = buildSymLibTable(symList);
log(`[libs] seeded ${symList.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) {
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
* from the lib source (symbol → sym-lib-table; footprint → fp-lib-table). A
* user lib is a kind-agnostic container, so the same lib id can land in both
* tables depending on the tool. `null` = the tool uses no libraries.
* The library kind a tool PRIMARILY consumes — drives the IDB presync warm-up
* (WasmTool) and which lib-table boot populates for single-engine bundles. The
* merged kicad_editor bundle seeds BOTH tables regardless of this (cross-face
* 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> = {
pcbnew: "footprint",

View file

@ -136,4 +136,28 @@ describe("cdn libs source", () => {
const src = await fakeCdn();
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>/`):
* 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>/bundle per-lib bundle: manifest + all bodies (cold init)
* Item paths inside a lib follow the `"<kind>/<name>"` scheme.
@ -40,6 +41,7 @@ export function cdnLibsSource(
const fetchImpl = opts?.fetchImpl ?? fetch;
let manifestP: Promise<CdnLibsManifest> | null = null;
let fpIndexP: Promise<string | null> | null = null;
const fetchManifest = async (): Promise<CdnLibsManifest> => {
// Retry with backoff. Firefox can fail a cross-origin fetch issued in the
// 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}`);
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).
};
}

View file

@ -130,4 +130,25 @@ describe("installLibsProvider — fat list (arg=bodies)", () => {
const res = await request("list", libUri("Device"), "", "symbol");
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.
*/
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>;
}
/**
@ -239,9 +249,11 @@ export function buildFpLibTable(libsList: LibInfo[]): string {
* Install `window.kicadLibs` backed by a `LibsSource`. Both lib plugins call
* `request(op, "/mnt/pcbjam/<id>", arg, kind)` (kind defaults to "symbol" so the
* symbol plugin's 3-arg calls still work):
* "list" -> JSON {"symbols":[...]} | {"footprints":[...]} (names of that kind)
* "get" -> the item body s-expr (arg = item name; null if absent)
* "save" -> "ok" / null (arg = JSON {"name":..,"body":..})
* "list" -> JSON {"symbols":[...]} | {"footprints":[...]} (names of that kind)
* "get" -> the item body s-expr (arg = item name; null if absent)
* "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(
source: LibsSource,
@ -266,6 +278,14 @@ export function installLibsProvider(
log(`[libs] request op=${op} kind=model3d arg=${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);
log(`[libs] request op=${op} kind=${kind} lib=${lib} (id=${id}) arg=${arg}`);
if (!id) return null;