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:
parent
f04915ee1d
commit
19fce866c1
12 changed files with 385 additions and 87 deletions
|
|
@ -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
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 67161915ebb89133b24e04d5d8d1e1e0cc791fbc
|
||||
Subproject commit 3b20be780148c923d835c201eb6c3edba4bda3a3
|
||||
9
scripts/kicad/build-footprint_editor.sh
Executable file
9
scripts/kicad/build-footprint_editor.sh
Executable 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 "$@"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
171
tests/web/footprint-write-spike.spec.ts
Normal file
171
tests/web/footprint-write-spike.spec.ts
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue