feat: libs 0009-S — footprint write-path spike DONE + verified (footprint editor as project-scoped WASM tool + PCB_IO_PCBJAM_FP plugin + bridge kind 4th-arg; New Footprint→FootprintSave fires through bridge on main thread, fork-native body v20251028, no wedge — Firefox e2e green); bump kicad + pcbjam-shared

This commit is contained in:
Gergő Törcsvári 2026-06-14 06:22:40 +02:00
commit 19fce866c1
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
12 changed files with 385 additions and 87 deletions

View file

@ -61,7 +61,7 @@ trap 'kw_fail 130; exit 130' INT TERM
cd "$(dirname "$0")/.."
VALID_APPS="pcbnew | eeschema | calculator | pl_editor | symbol_editor | gerbview | all"
VALID_APPS="pcbnew | eeschema | calculator | pl_editor | symbol_editor | footprint_editor | gerbview | all"
usage() {
echo "Usage: ./docker/build.sh <app>[,<app>...] [args...]" >&2
@ -87,12 +87,12 @@ shift
# pcbnew first in "all" — its 90-min host-side wasm-opt chain is the critical
# path, so it must start as early as possible (especially with KICAD_PIPELINE=1).
if [[ "$APP_NAME" == "all" ]]; then
APPS=(pcbnew eeschema calculator pl_editor symbol_editor gerbview)
APPS=(pcbnew eeschema calculator pl_editor symbol_editor footprint_editor gerbview)
else
IFS=',' read -r -a APPS <<< "$APP_NAME"
for app in "${APPS[@]}"; do
case "$app" in
pcbnew|eeschema|calculator|pl_editor|symbol_editor|gerbview) ;;
pcbnew|eeschema|calculator|pl_editor|symbol_editor|footprint_editor|gerbview) ;;
*)
echo "Error: unknown app '$app' (expected: ${VALID_APPS})" >&2
usage
@ -159,10 +159,11 @@ fi
# but lives under the pcb_calculator/ subtree.
kicad_subdir_for() {
case "$1" in
calculator) echo "pcb_calculator" ;;
pl_editor) echo "pagelayout_editor" ;;
symbol_editor) echo "eeschema" ;;
*) echo "$1" ;;
calculator) echo "pcb_calculator" ;;
pl_editor) echo "pagelayout_editor" ;;
symbol_editor) echo "eeschema" ;;
footprint_editor) echo "pcbnew" ;;
*) echo "$1" ;;
esac
}

2
kicad

@ -1 +1 @@
Subproject commit 67161915ebb89133b24e04d5d8d1e1e0cc791fbc
Subproject commit 3b20be780148c923d835c201eb6c3edba4bda3a3

View file

@ -0,0 +1,9 @@
#!/bin/bash
# Build KiCad Footprint Editor for WebAssembly.
# Thin wrapper around build-kicad-target.sh — see that script for options.
# (The footprint editor is the pcbnew kiface launched at FRAME_FOOTPRINT_EDITOR,
# like symbol_editor is the eeschema kiface at FRAME_SCH_SYMBOL_EDITOR.)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/build-kicad-target.sh" footprint_editor "$@"

View file

@ -60,8 +60,13 @@ case "$APP_NAME" in
KICAD_TARGET="symbol_editor"
KICAD_SUBDIR="eeschema"
;;
footprint_editor)
# served by the pcbnew kiface, so it builds in pcbnew/ (like symbol_editor in eeschema/)
KICAD_TARGET="footprint_editor"
KICAD_SUBDIR="pcbnew"
;;
*)
echo "Error: unknown app '$APP_NAME' (expected: pcbnew | eeschema | calculator | pl_editor | symbol_editor | gerbview)" >&2
echo "Error: unknown app '$APP_NAME' (expected: pcbnew | eeschema | calculator | pl_editor | symbol_editor | footprint_editor | gerbview)" >&2
exit 1
;;
esac
@ -72,8 +77,18 @@ esac
# et al.) the shared kiface objects reference. Without this the symbol_editor link
# fails with "undefined symbol: kicadCollabOnSave" (the placeholder defines nothing).
case "$APP_NAME" in
symbol_editor) EMBIND_APP="eeschema" ;;
*) EMBIND_APP="$APP_NAME" ;;
symbol_editor) EMBIND_APP="eeschema" ;;
footprint_editor) EMBIND_APP="pcbnew" ;;
*) EMBIND_APP="$APP_NAME" ;;
esac
# Which app's WASM stub libraries (scripting/frame placeholders) to link. Like
# EMBIND_APP, the footprint_editor reuses pcbnew's: it links the pcbnew kiface
# objects, which reference pcbnew's action-plugin scripting symbols
# (pcbnewGetScriptsSearchPaths et al., defined in pcbnew_scripting_stub.cpp).
case "$APP_NAME" in
footprint_editor) STUB_APP="pcbnew" ;;
*) STUB_APP="$APP_NAME" ;;
esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -262,20 +277,20 @@ WX_CXXFLAGS=$("${WX_BUILD}/wx-config" --cxxflags 2>/dev/null || echo "-I${WX_BUI
# - pcbnew: pcbnew_scripting_stub.cpp (action-plugin scripting placeholders)
# - eeschema: eeschema_frame_stub.cpp (placeholder; grows as linker dictates)
APP_STUB_LINK=""
APP_SCRIPTING_STUB_SRC="${STUBS_DIR}/${APP_NAME}_scripting_stub.cpp"
APP_SCRIPTING_STUB_SRC="${STUBS_DIR}/${STUB_APP}_scripting_stub.cpp"
if [ -f "${APP_SCRIPTING_STUB_SRC}" ]; then
log_info "Building app scripting stub: ${APP_NAME}_scripting_stub.cpp"
em++ -c ${WX_CXXFLAGS} "${APP_SCRIPTING_STUB_SRC}" -o "${STUBS_BUILD}/${APP_NAME}_scripting_stub.o"
emar rcs "${STUBS_BUILD}/lib${APP_NAME}_scripting_stub.a" "${STUBS_BUILD}/${APP_NAME}_scripting_stub.o"
APP_STUB_LINK="${APP_STUB_LINK} ${STUBS_BUILD}/lib${APP_NAME}_scripting_stub.a"
log_info "Building app scripting stub: ${STUB_APP}_scripting_stub.cpp"
em++ -c ${WX_CXXFLAGS} "${APP_SCRIPTING_STUB_SRC}" -o "${STUBS_BUILD}/${STUB_APP}_scripting_stub.o"
emar rcs "${STUBS_BUILD}/lib${STUB_APP}_scripting_stub.a" "${STUBS_BUILD}/${STUB_APP}_scripting_stub.o"
APP_STUB_LINK="${APP_STUB_LINK} ${STUBS_BUILD}/lib${STUB_APP}_scripting_stub.a"
fi
APP_FRAME_STUB_SRC="${STUBS_DIR}/${APP_NAME}_frame_stub.cpp"
APP_FRAME_STUB_SRC="${STUBS_DIR}/${STUB_APP}_frame_stub.cpp"
if [ -f "${APP_FRAME_STUB_SRC}" ] && [ -s "${APP_FRAME_STUB_SRC}" ]; then
log_info "Building app frame stub: ${APP_NAME}_frame_stub.cpp"
em++ -c ${WX_CXXFLAGS} "${APP_FRAME_STUB_SRC}" -o "${STUBS_BUILD}/${APP_NAME}_frame_stub.o"
emar rcs "${STUBS_BUILD}/lib${APP_NAME}_frame_stub.a" "${STUBS_BUILD}/${APP_NAME}_frame_stub.o"
APP_STUB_LINK="${APP_STUB_LINK} ${STUBS_BUILD}/lib${APP_NAME}_frame_stub.a"
log_info "Building app frame stub: ${STUB_APP}_frame_stub.cpp"
em++ -c ${WX_CXXFLAGS} "${APP_FRAME_STUB_SRC}" -o "${STUBS_BUILD}/${STUB_APP}_frame_stub.o"
emar rcs "${STUBS_BUILD}/lib${STUB_APP}_frame_stub.a" "${STUBS_BUILD}/${STUB_APP}_frame_stub.o"
APP_STUB_LINK="${APP_STUB_LINK} ${STUBS_BUILD}/lib${STUB_APP}_frame_stub.a"
fi
log_info "Stub libraries built"
@ -408,7 +423,9 @@ if [ -f "${EMBIND_SRC}" ]; then
# ${KICAD_BUILD}/common by make_lexer custom commands on the pcbcommon target).
# On a fresh build dir they don't exist until make runs — build pcbcommon first.
# No wasted work: the app target depends on pcbcommon anyway; incremental no-op.
if [ "${APP_NAME}" = "pcbnew" ]; then
# Guard on EMBIND_APP (not APP_NAME) so footprint_editor — whose embind IS
# pcbnew's — also pre-builds pcbcommon.
if [ "${EMBIND_APP}" = "pcbnew" ]; then
log_info "Pre-building pcbcommon so generated lexer headers exist for the embind compile..."
emmake make -j${JOBS} pcbcommon
fi

View file

@ -37,10 +37,11 @@ smart_cp() {
# source lives under pagelayout_editor/.
kicad_subdir_for() {
case "$1" in
calculator) echo "pcb_calculator" ;;
pl_editor) echo "pagelayout_editor" ;;
symbol_editor) echo "eeschema" ;;
*) echo "$1" ;;
calculator) echo "pcb_calculator" ;;
pl_editor) echo "pagelayout_editor" ;;
symbol_editor) echo "eeschema" ;;
footprint_editor) echo "pcbnew" ;;
*) echo "$1" ;;
esac
}
@ -80,15 +81,16 @@ copy_app() {
}
found_any=0
copy_app pcbnew && found_any=1
copy_app eeschema && found_any=1
copy_app calculator && found_any=1
copy_app pl_editor && found_any=1
copy_app symbol_editor && found_any=1
copy_app gerbview && found_any=1
copy_app pcbnew && found_any=1
copy_app eeschema && found_any=1
copy_app calculator && found_any=1
copy_app pl_editor && found_any=1
copy_app symbol_editor && found_any=1
copy_app footprint_editor && found_any=1
copy_app gerbview && found_any=1
if [ "$found_any" -eq 0 ]; then
echo "Error: no pcbnew/eeschema/calculator/pl_editor/symbol_editor/gerbview artifacts found in output/ or docker volume" >&2
echo "Error: no pcbnew/eeschema/calculator/pl_editor/symbol_editor/footprint_editor/gerbview artifacts found in output/ or docker volume" >&2
exit 1
fi

View file

@ -0,0 +1,171 @@
import { test, expect, type Page } from '@playwright/test';
import { waitForRegistry, clickByTooltip } from '../e2e/utils/element-tracker';
/**
* 0009-S footprint write-path spike: does the FOOTPRINT editor boot+render in
* WASM (with a working Prj()), and does its FootprintSave path route through the
* pcbjam write bridge, on the MAIN thread, without wedging?
*
* Mirrors the proven 0004-A symbol spike, one domain over. Boots the
* project-scoped footprint_editor with `?fpwrite=1` (an in-memory writable
* "My Footprints (spike)" lib via the new PCB_IO_PCBJAM_FP plugin + the 4th
* `kind` bridge arg), creates a new footprint in it, and saves. The plugin's
* FootprintSave serializes a fork-native (footprint ) s-expr and calls
* window.kicadLibs.request("save", , "footprint") on the main thread
* (EM_ASYNC_JS), captured onto window.__pcbjamSaved. Assert: the body is
* well-formed fork-native s-expr (version 20251028), and the app stays live
* (no abort / no OOM respawn) i.e. the editor-as-tool + main-thread Asyncify
* save both work. THIS IS THE GATE before the backend (0009-A) is built.
*/
const SHOT = (name: string) => `test-results/fpwrite-${name}.png`;
async function bootFootprintEditor(page: Page): Promise<void> {
await page.goto('/p/demo/footprint_editor/?fpwrite=1');
// GATE part 1: the footprint editor frame renders in WASM (canvas + GL).
await expect(page.locator('#canvas')).toBeVisible({ timeout: 180000 });
await waitForRegistry(page, 180000);
await page.waitForFunction(
() => !!window.wxElementRegistry && window.wxElementRegistry.findAll({}).length > 5,
null,
{ timeout: 180000 },
);
await page.waitForFunction(() => !!(window as any).kicadLibs, null, { timeout: 60000 });
await page.waitForTimeout(2000);
}
async function focusCanvas(page: Page): Promise<void> {
const box = await page.locator('#canvas').boundingBox();
if (box) await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
await page.waitForTimeout(300);
}
test('footprint editor save routes through the pcbjam_fp write bridge (main thread)', async ({ page }) => {
const logs: string[] = [];
page.on('console', (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on('pageerror', (e) => logs.push(`[pageerror] ${e.message}`));
await bootFootprintEditor(page);
await page.screenshot({ path: SHOT('01-boot'), scale: 'css' });
// Spike provider is active.
expect(await page.evaluate(() => !!(window as any).__pcbjamSaved), 'spike marker present').toBe(true);
// Select the writable lib in the tree so New Footprint targets it. Same LIB_TREE
// widget as the symbol editor: the dataviewitem's registry y sits on the column
// header, so anchor off the "Item" column header and click one row below it.
const hdr = await page.evaluate(() => {
const rd = window.wxElementRegistry.findAllRendered({});
const h = rd.find((e: any) => e.elementType === 'columnheader' && e.label === 'Item');
return h ? { cx: h.centerX, cy: h.centerY, hgt: h.height } : null;
});
if (hdr) {
await page.mouse.click(hdr.cx, hdr.cy + hdr.hgt + 8); // focus the tree
await page.waitForTimeout(200);
await page.keyboard.press('Home');
await page.waitForTimeout(150);
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(150);
await page.keyboard.press('ArrowUp');
await page.waitForTimeout(400);
} else {
logs.push('[spec] WARN: Item column header not found (tree may differ); proceeding');
}
await page.screenshot({ path: SHOT('02-lib-selected'), scale: 'css' });
// New Footprint via the toolbar button. The fork action's FriendlyName is
// "New Footprint" (PCB_ACTIONS::newFootprint); the "..." variant is a fallback.
let clicked = await clickByTooltip(page, 'New Footprint');
if (!clicked) clicked = await clickByTooltip(page, 'New Footprint...');
if (!clicked) {
// Diagnostics: dump available tooltips so we can fix the label if needed.
const tips = await page.evaluate(() =>
window.wxElementRegistry
.findAll({})
.map((e: any) => e.tooltip)
.filter((t: string) => !!t),
);
logs.push(`[spec] New Footprint tooltip not found; tooltips: ${JSON.stringify(tips)}`);
}
expect(clicked, 'New Footprint toolbar button clicked').toBe(true);
await page.waitForTimeout(1500);
await page.screenshot({ path: SHOT('03-newfp-dialog'), scale: 'css' });
// Diagnostics: what dialog/controls are present now.
const dlg = await page.evaluate(() => {
const all = window.wxElementRegistry.findAll({ visible: true });
const pick = (re: RegExp) => all.filter((e: any) => re.test(e.typeName))
.map((e: any) => ({ type: e.typeName, name: e.name, label: e.label, cx: Math.round(e.centerX), cy: Math.round(e.centerY), en: e.enabled }));
return { dialogs: pick(/Dialog/i), texts: pick(/TextCtrl/i), buttons: pick(/Button/i), combos: pick(/Choice|ComboBox/i) };
});
logs.push(`[spec] after New Footprint: ${JSON.stringify(dlg)}`);
// If a name dialog is present, type our name and confirm with Enter (default
// button). Some flows create directly with a default name — handle both.
const FP = 'SpikeFP';
if (dlg.texts.length > 0) {
const nameField = dlg.texts[0];
await page.mouse.click(nameField.cx, nameField.cy);
await page.waitForTimeout(150);
await page.keyboard.press('Control+a');
await page.keyboard.press('Delete');
await page.keyboard.type(FP, { delay: 40 });
await page.waitForTimeout(300);
await page.screenshot({ path: SHOT('04-name-typed'), scale: 'css' });
await page.keyboard.press('Enter');
await page.waitForTimeout(1500);
}
await page.screenshot({ path: SHOT('05-fp-created'), scale: 'css' });
logs.push(`[spec] title after create: ${await page.title()}`);
// Save: focus the canvas, Ctrl+S (the proven save trigger). The New Footprint
// flow may already auto-save into the writable lib; Ctrl+S is belt-and-braces.
await focusCanvas(page);
await page.keyboard.press('Control+s');
// Wait for the bridge to capture the saved body (name-agnostic — the editor may
// massage or default the footprint name).
await page.waitForFunction(
() => {
const saved = (window as any).__pcbjamSaved as Record<string, string> | undefined;
return !!saved && Object.values(saved).some((v) => typeof v === 'string' && v.length > 0);
},
null,
{ timeout: 30000 },
);
await page.screenshot({ path: SHOT('06-saved'), scale: 'css' });
const { savedName, body } = await page.evaluate(() => {
const saved = (window as any).__pcbjamSaved as Record<string, string>;
const k = Object.keys(saved).find((n) => saved[n]?.length)!;
return { savedName: k, body: saved[k] };
});
logs.push(`[spec] saved "${savedName}" (${body.length} bytes):\n${body}`);
// The captured body is a well-formed fork-native footprint at the fork's native
// board/footprint file version (20251028) — proves verbatim round-trip, no shim.
expect(body).toContain('(footprint');
expect(body).toContain('(version 20251028)');
expect(body).toContain('(generator "pcbnew")');
expect(body).toContain(savedName);
// No post-save error dialog (the MEMFS placeholder-file fix for the footprint
// editor's setFPWatcher -> GetModificationTime stat).
await page.waitForTimeout(500);
const errDialog = await page.evaluate(() =>
window.wxElementRegistry
.findAll({ visible: true })
.some((e: any) => /Dialog/i.test(e.typeName) && /Error/i.test(e.label || '')),
);
expect(errDialog, 'no post-save error dialog').toBe(false);
expect(
logs.some((l) => l.includes('Failed to retrieve file times')),
'no file-times error',
).toBe(false);
// App stayed live: no abort, no OOM respawn (the main-thread Asyncify save gate).
expect(logs.some((l) => l.includes('Aborted(')), 'no WASM abort').toBe(false);
expect(new URL(page.url()).searchParams.get('oomRetry'), 'no OOM respawn').toBeNull();
console.log('--- console + spec log ---\n' + logs.join('\n'));
});

@ -1 +1 @@
Subproject commit df297291b0bdfd0228c900e9c1a0c7279ffd1533
Subproject commit 3cb0a2ff3f746e52d48d389c19154ffa076cc288

View file

@ -11,7 +11,10 @@ export const WASM_ASSET_BASE_URL =
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
import { remoteLibsSource } from "@/wasm/libs/remote-source";
import type { LibsSource } from "@/wasm/libs/source";
import { withSpikeWritableLib } from "@/wasm/libs/spike-writable";
import {
withSpikeWritableFpLib,
withSpikeWritableLib,
} from "@/wasm/libs/spike-writable";
import { staticLibsSource } from "@/wasm/libs/static-source";
/**
@ -78,14 +81,18 @@ export function libsSourceConfig(projectId?: string): LibsSource | null {
? staticLibsSource()
: remoteLibsSource(API_BASE_URL, libsOwner(), projectId);
// 0004-A spike: `?libwrite=1` adds one in-memory writable user lib so the
// 0004-A spike: `?libwrite=1` adds one in-memory writable user SYMBOL lib so the
// editor save path works with no backend (a dev/test aid). The real remote
// write path (0004-C) needs no flag — boot ensures a user lib via createLib.
if (
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("libwrite") === "1"
) {
return withSpikeWritableLib(base, (m) => console.log(m));
// 0009-S spike: `?fpwrite=1` does the same for a writable FOOTPRINT lib.
if (typeof window !== "undefined") {
const params = new URLSearchParams(window.location.search);
if (params.get("fpwrite") === "1") {
return withSpikeWritableFpLib(base, (m) => console.log(m));
}
if (params.get("libwrite") === "1") {
return withSpikeWritableLib(base, (m) => console.log(m));
}
}
return base;

View file

@ -3,9 +3,15 @@ import {
KICAD_CONFIG_DIR,
RESOURCE_PATH,
TOOL_ARGV0,
TOOL_LIB_KIND,
TOOL_NEEDS_CONFIG_SEED,
} from "./constants";
import { buildSymLibTable, installLibsProvider, type LibsSource } from "./libs/source";
import {
buildFpLibTable,
buildSymLibTable,
installLibsProvider,
type LibsSource,
} from "./libs/source";
import { libUri, PCBJAM_LIB_MOUNT } from "./libs/uri";
/** The default user lib boot ensures exists, so there's a writable save target. */
@ -101,16 +107,20 @@ async function doBoot(opts: BootOptions): Promise<void> {
// the wasm boots (the table is seeded in preRun below). No source → empty
// table, libs disabled.
let symLibTable = "(sym_lib_table\n (version 7)\n)\n";
let fpLibTable = "(fp_lib_table\n (version 7)\n)\n";
// Every lib gets an empty placeholder FILE at its URI (not just the mount dir):
// the symbol-editor save path stat()s the lib file after a successful save
// (SetSymModificationTime -> wxFileName::GetModificationTime), which errors on
// a non-existent path. The bytes are virtual (served via window.kicadLibs);
// this file only satisfies incidental fs checks.
// the editor save path stat()s the lib file after a successful save
// (symbol: SetSymModificationTime; footprint: setFPWatcher -> GetModificationTime),
// 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[] = [];
if (libsSource) {
// Which lib table this tool consumes: symbol → sym-lib-table, footprint →
// fp-lib-table. The same lib source feeds whichever table the tool reads.
const libKind = TOOL_LIB_KIND[tool];
if (libsSource && libKind) {
installLibsProvider(libsSource, log);
try {
// Ensure the owner has at least one writable user lib to create symbols in.
// Ensure the owner has at least one writable user lib to save items into.
let libsList = await libsSource.listLibs();
if (libsSource.createLib && !libsList.some((l) => l.type === "user")) {
const created = await libsSource.createLib(DEFAULT_USER_LIB_NAME);
@ -119,9 +129,14 @@ async function doBoot(opts: BootOptions): Promise<void> {
log(`[libs] created default user lib "${created.name}"`);
}
}
symLibTable = buildSymLibTable(libsList);
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`);
}
libPlaceholderUris = libsList.map((l) => libUri(l.id));
log(`[libs] seeded ${libsList.length} lib(s) into sym-lib-table`);
} catch (e) {
log(`[libs] listLibs failed, seeding empty table: ${String(e)}`);
}
@ -222,13 +237,10 @@ async function doBoot(opts: BootOptions): Promise<void> {
2,
),
);
// libs: rows generated in doBoot from the lib source; the PCBJAM plugin
// resolves each via window.kicadLibs.
// libs: rows generated in doBoot from the lib source; the PCBJAM / PCBJAM_FP
// plugins resolve each via window.kicadLibs.
writeIfAbsent(`${KICAD_CONFIG_DIR}/sym-lib-table`, symLibTable);
writeIfAbsent(
`${KICAD_CONFIG_DIR}/fp-lib-table`,
"(fp_lib_table\n (version 7)\n)\n",
);
writeIfAbsent(`${KICAD_CONFIG_DIR}/fp-lib-table`, fpLibTable);
writeIfAbsent(
`${KICAD_CONFIG_DIR}/design-block-lib-table`,
"(design_block_lib_table\n (version 7)\n)\n",

View file

@ -26,6 +26,7 @@ export const TOOL_ARGV0: Record<Tool, string> = {
calculator: "/usr/bin/pcb_calculator",
pl_editor: "/usr/bin/pl_editor",
symbol_editor: "/usr/bin/symbol_editor",
footprint_editor: "/usr/bin/footprint_editor",
gerbview: "/usr/bin/gerbview",
};
@ -44,9 +45,26 @@ export const TOOL_NEEDS_CONFIG_SEED: Record<Tool, boolean> = {
calculator: true,
pl_editor: true,
symbol_editor: true,
footprint_editor: true,
gerbview: true,
};
/**
* 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.
*/
export const TOOL_LIB_KIND: Record<Tool, "symbol" | "footprint" | null> = {
pcbnew: "footprint",
eeschema: "symbol",
calculator: null,
pl_editor: null,
symbol_editor: "symbol",
footprint_editor: "footprint",
gerbview: null,
};
/** KiCad user settings dir for this build (PATHS::GetUserSettingsPath()). */
export const KICAD_CONFIG_DIR = `/home/kicad/.config/kicad/kicad/${KICAD_VERSION_DIR}`;

View file

@ -48,11 +48,18 @@ export interface LibsSource {
createLib?(name: string): Promise<LibInfo | null>;
}
/** The function the WASM `SCH_IO_PCBJAM_LIB` plugin calls via the JS bridge. */
/**
* The function the WASM lib plugins call via the JS bridge. Both the symbol
* plugin (`SCH_IO_PCBJAM_LIB`) and the footprint plugin (`PCB_IO_PCBJAM_FP`)
* call the same hook; `kind` (4th arg) discriminates the item kind. The symbol
* plugin omits it (passes 3 args) so it defaults to "symbol" keeping the
* existing eeschema binary correct with no rebuild.
*/
export type KicadLibsRequest = (
op: string,
lib: string,
arg: string,
kind?: string,
) => Promise<string | null>;
declare global {
@ -89,10 +96,32 @@ export function buildSymLibTable(libsList: LibInfo[]): string {
}
/**
* Install `window.kicadLibs` backed by a `LibsSource`. The plugin calls
* `request(op, "/mnt/pcbjam[-rw]/<id>", arg)`:
* "list" -> JSON {"symbols":[...]} (symbol names in the lib)
* "get" -> the item body s-expr (arg = symbol name; null if absent)
* Build fp-lib-table content (KiCad v7) with one PCBJAM_FP row per lib. The
* footprint editor selects the plugin from this row's `type` field (via
* PCB_IO_MGR::EnumFromStr), so it MUST be "PCBJAM_FP" to match the registered
* plugin name. Same /mnt/pcbjam/<id> URI as symbols (the same lib id can appear
* in both tables user libs are kind-agnostic containers).
*/
export function buildFpLibTable(libsList: LibInfo[]): string {
const rows = libsList.map((l) => {
const descr = l.description ? sexprEscape(l.description) : "";
return ` (lib (name "${sexprEscape(
l.name,
)}")(type "PCBJAM_FP")(uri "${libUri(
l.id,
)}")(options "")(descr "${descr}"))`;
});
return `(fp_lib_table\n (version 7)\n${rows.join("\n")}${
rows.length ? "\n" : ""
})\n`;
}
/**
* 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":..})
*/
export function installLibsProvider(
@ -102,9 +131,9 @@ export function installLibsProvider(
if (window.kicadLibs) return;
const delay = artificialDelayMs();
const request: KicadLibsRequest = async (op, lib, arg) => {
const request: KicadLibsRequest = async (op, lib, arg, kind = "symbol") => {
const id = libIdFromUri(lib);
log(`[libs] request op=${op} lib=${lib} (id=${id}) arg=${arg}`);
log(`[libs] request op=${op} kind=${kind} lib=${lib} (id=${id}) arg=${arg}`);
if (!id) return null;
if (delay) await sleep(delay);
@ -112,13 +141,15 @@ export function installLibsProvider(
switch (op) {
case "list": {
const items = await source.listItems(id);
const symbols = items
.filter((i) => i.kind === "symbol")
const names = items
.filter((i) => i.kind === kind)
.map((i) => i.name);
return JSON.stringify({ symbols });
// Each plugin parses its own key: footprints / symbols.
const key = kind === "footprint" ? "footprints" : "symbols";
return JSON.stringify({ [key]: names });
}
case "get":
return await source.getItemBody(id, "symbol", arg);
return await source.getItemBody(id, kind, arg);
case "save": {
let parsed: { name?: string; body?: string };
try {
@ -134,7 +165,7 @@ export function installLibsProvider(
}
const ok = await source.saveItemBody(
id,
"symbol",
kind,
parsed.name,
parsed.body,
);

View file

@ -1,30 +1,39 @@
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
/**
* 0004-A write-path spike (no backend). Wraps a `LibsSource` with ONE in-memory
* writable user lib so the editor's save-symbol flow has a target and we can
* exercise the full save enumerate load round-trip through the same WASM
* bridge without committing to the backend design first. Gated by
* `?libwrite=1`. Saved bodies are mirrored onto `window.__pcbjamSaved` so the
* Playwright probe can inspect them. Replaced in 0004-C by real remote writes.
* Write-path spike (no backend). Wraps a `LibsSource` with ONE in-memory writable
* user lib so the editor's save flow has a target and we can exercise the full
* save enumerate load round-trip through the same WASM bridge without
* committing to the backend design first.
*
* - Symbols (0004-A): `?libwrite=1` `withSpikeWritableLib`.
* - Footprints (0009-S): `?fpwrite=1` `withSpikeWritableFpLib`.
*
* Saved bodies are mirrored onto `window.__pcbjamSaved` so the Playwright probe
* can inspect them. Replaced by real remote writes once the backend lands.
*/
const SPIKE_RW_LIB_ID = "spike-user";
const SPIKE_RW_LIB_NAME = "My Symbols (spike)";
declare global {
interface Window {
__pcbjamSaved?: Record<string, string>;
}
}
export function withSpikeWritableLib(
interface SpikeLibSpec {
id: string;
name: string;
kind: "symbol" | "footprint";
}
function withSpikeKindLib(
inner: LibsSource | null,
log: (msg: string) => void,
spec: SpikeLibSpec,
): LibsSource {
const store = new Map<string, string>(); // symbol name -> body
window.__pcbjamSaved = Object.create(null) as Record<string, string>;
const store = new Map<string, string>(); // item name -> body
// Don't clobber an existing capture map (so symbol + footprint spikes coexist).
window.__pcbjamSaved ??= Object.create(null) as Record<string, string>;
const isSpike = (libId: string) => libId === SPIKE_RW_LIB_ID;
const isSpike = (libId: string) => libId === spec.id;
return {
async listLibs(): Promise<LibInfo[]> {
@ -37,15 +46,12 @@ export function withSpikeWritableLib(
} catch (e) {
log(`[libs] spike: inner listLibs failed, origins omitted: ${String(e)}`);
}
return [
...base,
{ id: SPIKE_RW_LIB_ID, name: SPIKE_RW_LIB_NAME, type: "user" },
];
return [...base, { id: spec.id, name: spec.name, type: "user" }];
},
async listItems(libId: string): Promise<LibItemInfo[]> {
if (isSpike(libId))
return [...store.keys()].map((name) => ({ kind: "symbol", name }));
return [...store.keys()].map((name) => ({ kind: spec.kind, name }));
return inner ? inner.listItems(libId) : [];
},
@ -71,8 +77,32 @@ export function withSpikeWritableLib(
}
store.set(name, body);
window.__pcbjamSaved![name] = body;
log(`[libs] spike saved symbol "${name}" (${body.length} bytes)`);
log(`[libs] spike saved ${spec.kind} "${name}" (${body.length} bytes)`);
return true;
},
};
}
/** 0004-A symbol write spike (`?libwrite=1`). */
export function withSpikeWritableLib(
inner: LibsSource | null,
log: (msg: string) => void,
): LibsSource {
return withSpikeKindLib(inner, log, {
id: "spike-user",
name: "My Symbols (spike)",
kind: "symbol",
});
}
/** 0009-S footprint write spike (`?fpwrite=1`). */
export function withSpikeWritableFpLib(
inner: LibsSource | null,
log: (msg: string) => void,
): LibsSource {
return withSpikeKindLib(inner, log, {
id: "spike-fp-user",
name: "My Footprints (spike)",
kind: "footprint",
});
}