diff --git a/kicad b/kicad index f503b00..4a7250d 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit f503b009a16694f05267907471fb0b2351b2e27f +Subproject commit 4a7250d11a91042ba7cb58e3962881373a1c904d diff --git a/scripts/deploy/dev-demo.mjs b/scripts/deploy/dev-demo.mjs index e823d2f..bceb6cc 100644 --- a/scripts/deploy/dev-demo.mjs +++ b/scripts/deploy/dev-demo.mjs @@ -74,6 +74,7 @@ function parseArgs(argv) { noGallery: false, // disable the example gallery (local-folder + IDB only) 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) + libsLocal: null, // local publish-libs --driver local output dir (serve same-origin) port: null, repo: "https://github.com/emergence-engineering/pcbjam", }; @@ -91,6 +92,7 @@ function parseArgs(argv) { case "--repo": a.repo = next(); break; case "--models-tag": a.modelsTag = next(); break; case "--models-local": a.modelsLocal = next(); break; + case "--libs-local": a.libsLocal = next(); break; case "-h": case "--help": a.help = true; break; 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 enable lazy 3D models from the CDN snapshot at this tag --models-local serve a local publish-models layout (--driver local --compress none) same-origin instead of the CDN (requires --models-tag) + --libs-local serve a local publish-libs layout (--driver local) same-origin + instead of the CDN (uses --lib-tag as the snapshot tag) --port dev server port By default the read-only example gallery (deploy/demo/gallery.json) is built @@ -141,7 +145,20 @@ function main() { const env = { ...process.env }; // --- Libraries: live R2 CDN (the lazy/fat lib-load path), or offline examples. - if (a.libTag) { + // --libs-local 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_MANIFEST_URL = `${a.cdn}/libs/kicad/${a.libTag}/manifest.json`; } else { diff --git a/scripts/deploy/publish-libs.ts b/scripts/deploy/publish-libs.ts index c9d49e8..73bf37c 100644 --- a/scripts/deploy/publish-libs.ts +++ b/scripts/deploy/publish-libs.ts @@ -17,12 +17,19 @@ // manifest SyncManifest { version, entries: { "/": {hash,size,mtime} } } // bundle encodeBundle(manifest, bodies) — cold-init payload (all bodies) // + top `//manifest.json` { schema, tag, libs:[{id,name,kind,itemCount}] } +// + `//fp-index.json` { schema, tag, libs: { : [[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). +// 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 { existsSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; 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 { IMMUTABLE, makeStore, putJSON, sha256hex } from "./lib/cdn-store.mjs"; @@ -99,14 +106,21 @@ async function main(): Promise { const store = makeStore(a.driver, a); const enc = new TextEncoder(); 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. - 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)`); 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 / // --footprints-src still win if also given (local checkouts). @@ -120,13 +134,20 @@ async function main(): Promise { } 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, }); const topLibs: Array<{ id: string; name: string; kind: string; itemCount: number }> = []; + const fpIndexLibs: Record> = {}; let totalItems = 0; 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( (it): [string, Uint8Array] => [`${it.kind}/${it.name}`, enc.encode(it.body)], ); @@ -146,11 +167,18 @@ async function main(): Promise { totalItems += items.length; } - topLibs.sort((x, y) => x.id.localeCompare(y.id)); - putJSON(store, topKey, { schema: 1, tag: a.libTag, libs: topLibs }, IMMUTABLE); + if (!indexOnly) { + topLibs.sort((x, y) => x.id.localeCompare(y.id)); + 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( - `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}`); } diff --git a/tests/web/eeschema-fp-selector.spec.ts b/tests/web/eeschema-fp-selector.spec.ts new file mode 100644 index 0000000..f70dd24 --- /dev/null +++ b/tests/web/eeschema-fp-selector.spec.ts @@ -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); +}); diff --git a/web/.gitignore b/web/.gitignore index 2039a46..e4ff50f 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -16,3 +16,7 @@ dist/ # 3D models: public/models-cdn symlinks a local publish-models layout, created # by scripts/deploy/dev-demo.mjs --models-local — never committed. **/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/ diff --git a/web/backend/src/extract/kicad-pretty.ts b/web/backend/src/extract/kicad-pretty.ts index 420c19a..1507be9 100644 --- a/web/backend/src/extract/kicad-pretty.ts +++ b/web/backend/src/extract/kicad-pretty.ts @@ -63,6 +63,48 @@ function findTopFootprint(src: string): [number, number] { * 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. */ +/** + * 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(); + + 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 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 { const [s, e] = findTopFootprint(src); const block = src.slice(s, e); diff --git a/web/standalone/src/wasm/boot.ts b/web/standalone/src/wasm/boot.ts index c87d7fd..2718d01 100644 --- a/web/standalone/src/wasm/boot.ts +++ b/web/standalone/src/wasm/boot.ts @@ -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 { // 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 { 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 { 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)}`); } diff --git a/web/standalone/src/wasm/constants.ts b/web/standalone/src/wasm/constants.ts index de969b6..821c58f 100644 --- a/web/standalone/src/wasm/constants.ts +++ b/web/standalone/src/wasm/constants.ts @@ -108,10 +108,13 @@ export const TOOL_NEEDS_CONFIG_SEED: Record = { }; /** - * 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 = { pcbnew: "footprint", diff --git a/web/standalone/src/wasm/libs/cdn-source.test.ts b/web/standalone/src/wasm/libs/cdn-source.test.ts index 752e5f7..99fb888 100644 --- a/web/standalone/src/wasm/libs/cdn-source.test.ts +++ b/web/standalone/src/wasm/libs/cdn-source.test.ts @@ -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(); + }); }); diff --git a/web/standalone/src/wasm/libs/cdn-source.ts b/web/standalone/src/wasm/libs/cdn-source.ts index 0f0527e..7bd45f2 100644 --- a/web/standalone/src/wasm/libs/cdn-source.ts +++ b/web/standalone/src/wasm/libs/cdn-source.ts @@ -12,6 +12,7 @@ import type { LibInfo, LibItemInfo, LibsSource } from "./source"; * * Layout under the manifest's directory (`/libs/kicad//`): * manifest.json top index: every lib (id, name, kind, itemCount) + * fp-index.json footprint index: per-lib [name, uniquePadCount] * /manifest per-lib SyncManifest (GET .../manifest) * /bundle per-lib bundle: manifest + all bodies (cold init) * Item paths inside a lib follow the `"/"` scheme. @@ -40,6 +41,7 @@ export function cdnLibsSource( const fetchImpl = opts?.fetchImpl ?? fetch; let manifestP: Promise | null = null; + let fpIndexP: Promise | null = null; const fetchManifest = async (): Promise => { // 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 { + // 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). }; } diff --git a/web/standalone/src/wasm/libs/source.test.ts b/web/standalone/src/wasm/libs/source.test.ts index 0e91eb6..4e9c580 100644 --- a/web/standalone/src/wasm/libs/source.test.ts +++ b/web/standalone/src/wasm/libs/source.test.ts @@ -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(); + }); }); diff --git a/web/standalone/src/wasm/libs/source.ts b/web/standalone/src/wasm/libs/source.ts index e793dbf..e5d7b1d 100644 --- a/web/standalone/src/wasm/libs/source.ts +++ b/web/standalone/src/wasm/libs/source.ts @@ -91,6 +91,16 @@ export interface LibsSource { * conflict). Used by boot to ensure the owner has a writable target. */ createLib?(name: string): Promise; + /** + * The publish-time footprint index as raw JSON text: + * { schema, tag, libs: { "": [["", ], …] } } + * 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; } /** @@ -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/", 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; diff --git a/wxwidgets b/wxwidgets index 4cad1e4..d9c3fee 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit 4cad1e4876b9749fd8b9e550a3b504c430bbe1a3 +Subproject commit d9c3feecddad2ac33fc27a217f1a32885d0c0823