From 19fce866c1ccbc9df2117338b71af88594550dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Sun, 14 Jun 2026 06:22:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20libs=200009-S=20=E2=80=94=20footprint?= =?UTF-8?q?=20write-path=20spike=20DONE=20+=20verified=20(footprint=20edit?= =?UTF-8?q?or=20as=20project-scoped=20WASM=20tool=20+=20PCB=5FIO=5FPCBJAM?= =?UTF-8?q?=5FFP=20plugin=20+=20bridge=20kind=204th-arg;=20New=20Footprint?= =?UTF-8?q?=E2=86=92FootprintSave=20fires=20through=20bridge=20on=20main?= =?UTF-8?q?=20thread,=20fork-native=20body=20v20251028,=20no=20wedge=20?= =?UTF-8?q?=E2=80=94=20Firefox=20e2e=20green);=20bump=20kicad=20+=20pcbjam?= =?UTF-8?q?-shared?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/build.sh | 15 +- kicad | 2 +- scripts/kicad/build-footprint_editor.sh | 9 + scripts/kicad/build-kicad-target.sh | 45 +++-- tests/scripts/setup-kicad-wasm.sh | 24 +-- tests/web/footprint-write-spike.spec.ts | 171 ++++++++++++++++++ web/pcbjam-shared | 2 +- web/standalone/src/lib/config.ts | 21 ++- web/standalone/src/wasm/boot.ts | 42 +++-- web/standalone/src/wasm/constants.ts | 18 ++ web/standalone/src/wasm/libs/source.ts | 55 ++++-- .../src/wasm/libs/spike-writable.ts | 68 +++++-- 12 files changed, 385 insertions(+), 87 deletions(-) create mode 100755 scripts/kicad/build-footprint_editor.sh create mode 100644 tests/web/footprint-write-spike.spec.ts diff --git a/docker/build.sh b/docker/build.sh index 92c8f7a..4fd4f7b 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -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 [,...] [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 } diff --git a/kicad b/kicad index 6716191..3b20be7 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit 67161915ebb89133b24e04d5d8d1e1e0cc791fbc +Subproject commit 3b20be780148c923d835c201eb6c3edba4bda3a3 diff --git a/scripts/kicad/build-footprint_editor.sh b/scripts/kicad/build-footprint_editor.sh new file mode 100755 index 0000000..1765868 --- /dev/null +++ b/scripts/kicad/build-footprint_editor.sh @@ -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 "$@" diff --git a/scripts/kicad/build-kicad-target.sh b/scripts/kicad/build-kicad-target.sh index d0ee439..590e459 100755 --- a/scripts/kicad/build-kicad-target.sh +++ b/scripts/kicad/build-kicad-target.sh @@ -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 diff --git a/tests/scripts/setup-kicad-wasm.sh b/tests/scripts/setup-kicad-wasm.sh index 690be37..523c4e9 100755 --- a/tests/scripts/setup-kicad-wasm.sh +++ b/tests/scripts/setup-kicad-wasm.sh @@ -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 diff --git a/tests/web/footprint-write-spike.spec.ts b/tests/web/footprint-write-spike.spec.ts new file mode 100644 index 0000000..aa888cc --- /dev/null +++ b/tests/web/footprint-write-spike.spec.ts @@ -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 { + 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 { + 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 | 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; + 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')); +}); diff --git a/web/pcbjam-shared b/web/pcbjam-shared index df29729..3cb0a2f 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit df297291b0bdfd0228c900e9c1a0c7279ffd1533 +Subproject commit 3cb0a2ff3f746e52d48d389c19154ffa076cc288 diff --git a/web/standalone/src/lib/config.ts b/web/standalone/src/lib/config.ts index 9a2acb6..7883390 100644 --- a/web/standalone/src/lib/config.ts +++ b/web/standalone/src/lib/config.ts @@ -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; diff --git a/web/standalone/src/wasm/boot.ts b/web/standalone/src/wasm/boot.ts index 2f66832..d622c89 100644 --- a/web/standalone/src/wasm/boot.ts +++ b/web/standalone/src/wasm/boot.ts @@ -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 { // 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 { 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 { 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", diff --git a/web/standalone/src/wasm/constants.ts b/web/standalone/src/wasm/constants.ts index a750c03..273e8c0 100644 --- a/web/standalone/src/wasm/constants.ts +++ b/web/standalone/src/wasm/constants.ts @@ -26,6 +26,7 @@ export const TOOL_ARGV0: Record = { 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 = { 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 = { + 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}`; diff --git a/web/standalone/src/wasm/libs/source.ts b/web/standalone/src/wasm/libs/source.ts index 35e33cb..4a8b6aa 100644 --- a/web/standalone/src/wasm/libs/source.ts +++ b/web/standalone/src/wasm/libs/source.ts @@ -48,11 +48,18 @@ export interface LibsSource { createLib?(name: string): Promise; } -/** 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; 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]/", 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/ 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/", 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, ); diff --git a/web/standalone/src/wasm/libs/spike-writable.ts b/web/standalone/src/wasm/libs/spike-writable.ts index a188fd6..7e80387 100644 --- a/web/standalone/src/wasm/libs/spike-writable.ts +++ b/web/standalone/src/wasm/libs/spike-writable.ts @@ -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; } } -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(); // symbol name -> body - window.__pcbjamSaved = Object.create(null) as Record; + const store = new Map(); // item name -> body + // Don't clobber an existing capture map (so symbol + footprint spikes coexist). + window.__pcbjamSaved ??= Object.create(null) as Record; - const isSpike = (libId: string) => libId === SPIKE_RW_LIB_ID; + const isSpike = (libId: string) => libId === spec.id; return { async listLibs(): Promise { @@ -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 { 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", + }); +}