feat: libs 0003 — remote provider + example backend serving; bump kicad, shared
standalone: LibsSource abstraction (remote over the contract + static fallback), generic window.kicadLibs provider, sym-lib-table generated from the backend's lib list at boot (replaces the spike). backend: serve pre-built self-contained symbol bodies from LIBS_DIR (listLibs/listLibItems + raw item-body route). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
29a7dcc233
commit
d2d2c19a8c
12 changed files with 447 additions and 145 deletions
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 51743e6c7981d4349adaabc9416eb35469a8c0d6
|
||||
Subproject commit 7932b3ace1e5e21fb6ab38ece2dc1faa41a25a12
|
||||
107
web/backend/src/libs.ts
Normal file
107
web/backend/src/libs.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
// Minimal reference libraries serving for the @pcbjam/shared libs contract.
|
||||
//
|
||||
// Serves PRE-BUILT self-contained symbol bodies from a directory (LIBS_DIR),
|
||||
// laid out as:
|
||||
//
|
||||
// <LIBS_DIR>/<LibName>/index.json { items: [{kind,name,description,keywords}], ... }
|
||||
// <LIBS_DIR>/<LibName>/<Symbol>.kicad_sym a complete kicad_symbol_lib s-expr
|
||||
// <LIBS_DIR>/<LibName>/LICENSE.md attribution (CC-BY-SA travels with data)
|
||||
//
|
||||
// This backend does NO parsing — bodies are produced upstream (the closed
|
||||
// ingestion/extractor) so the open reference server stays trivial. If LIBS_DIR
|
||||
// is unset or empty, the lib endpoints simply report no libraries.
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { Lib, LibItem } from "@pcbjam/shared";
|
||||
|
||||
export interface LibsConfig {
|
||||
dir: string | null;
|
||||
}
|
||||
|
||||
export function libsConfig(): LibsConfig {
|
||||
const dir = process.env.LIBS_DIR;
|
||||
return { dir: dir ? path.resolve(process.cwd(), dir) : null };
|
||||
}
|
||||
|
||||
/** A lib id is its directory name; reject anything that isn't a plain segment. */
|
||||
function safeLibDir(root: string, lib: string): string | null {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(lib)) return null;
|
||||
return path.join(root, lib);
|
||||
}
|
||||
|
||||
interface IndexFile {
|
||||
items?: { kind: string; name: string; description?: string | null; keywords?: string | null }[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
async function readIndex(dir: string): Promise<IndexFile | null> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(path.join(dir, "index.json"), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listLibs(cfg: LibsConfig): Promise<Lib[]> {
|
||||
if (!cfg.dir) return [];
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(cfg.dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const libs: Lib[] = [];
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
||||
const idx = await readIndex(path.join(cfg.dir, e.name));
|
||||
libs.push({
|
||||
id: e.name,
|
||||
name: e.name,
|
||||
type: "origin",
|
||||
description: idx?.description ?? null,
|
||||
itemCount: idx?.items?.length ?? undefined,
|
||||
});
|
||||
}
|
||||
libs.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return libs;
|
||||
}
|
||||
|
||||
export async function listLibItems(
|
||||
cfg: LibsConfig,
|
||||
lib: string,
|
||||
): Promise<LibItem[] | null> {
|
||||
if (!cfg.dir) return null;
|
||||
const dir = safeLibDir(cfg.dir, lib);
|
||||
if (!dir) return null;
|
||||
const idx = await readIndex(dir);
|
||||
if (!idx) return null;
|
||||
return (idx.items ?? []).map((i) => ({
|
||||
kind: i.kind,
|
||||
name: i.name,
|
||||
description: i.description ?? null,
|
||||
keywords: i.keywords ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Resolve the on-disk body file for one item, or null (guarded). */
|
||||
export function itemBodyPath(
|
||||
cfg: LibsConfig,
|
||||
lib: string,
|
||||
kind: string,
|
||||
name: string,
|
||||
): string | null {
|
||||
if (!cfg.dir || kind !== "symbol") return null;
|
||||
const dir = safeLibDir(cfg.dir, lib);
|
||||
if (!dir) return null;
|
||||
// Symbol names allow a wide charset but never path separators.
|
||||
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
|
||||
return null;
|
||||
}
|
||||
return path.join(dir, `${name}.kicad_sym`);
|
||||
}
|
||||
|
||||
export function streamBody(absPath: string) {
|
||||
return createReadStream(absPath);
|
||||
}
|
||||
|
|
@ -19,6 +19,13 @@ import {
|
|||
type Project,
|
||||
type ProjectFile,
|
||||
} from "@pcbjam/shared";
|
||||
import {
|
||||
itemBodyPath,
|
||||
type LibsConfig,
|
||||
libsConfig,
|
||||
listLibItems,
|
||||
listLibs,
|
||||
} from "./libs.js";
|
||||
|
||||
const PROJECT_DIR = path.resolve(
|
||||
process.cwd(),
|
||||
|
|
@ -121,6 +128,8 @@ async function main(): Promise<void> {
|
|||
});
|
||||
app.get("/health", async () => ({ ok: true }));
|
||||
|
||||
const libs: LibsConfig = libsConfig();
|
||||
|
||||
const s = initServer();
|
||||
const router = s.router(contract, {
|
||||
listProjects: async () => ({ status: 200, body: [await project()] }),
|
||||
|
|
@ -139,9 +148,33 @@ async function main(): Promise<void> {
|
|||
}
|
||||
return { status: 200 as const, body: await walk(PROJECT_DIR) };
|
||||
},
|
||||
listLibs: async () => ({ status: 200 as const, body: await listLibs(libs) }),
|
||||
listLibItems: async ({ params }) => {
|
||||
const items = await listLibItems(libs, params.lib);
|
||||
if (items === null) {
|
||||
return { status: 404 as const, body: { message: "library not found" } };
|
||||
}
|
||||
return { status: 200 as const, body: items };
|
||||
},
|
||||
});
|
||||
await app.register(s.plugin(router));
|
||||
|
||||
// Streamed item-body fetch (text; intentionally not a ts-rest endpoint).
|
||||
app.get<{ Params: { lib: string; kind: string; name: string } }>(
|
||||
"/api/libs/:lib/items/:kind/:name",
|
||||
async (req, reply) => {
|
||||
const abs = itemBodyPath(libs, req.params.lib, req.params.kind, req.params.name);
|
||||
if (!abs) return reply.code(400).send({ message: "invalid item" });
|
||||
const st = await fs.stat(abs).catch(() => null);
|
||||
if (!st?.isFile()) {
|
||||
return reply.code(404).send({ message: "item not found" });
|
||||
}
|
||||
reply.header("Content-Type", "text/plain; charset=utf-8");
|
||||
reply.header("Content-Length", st.size);
|
||||
return reply.send(createReadStream(abs));
|
||||
},
|
||||
);
|
||||
|
||||
// Streamed file-byte download (binary; intentionally not a ts-rest endpoint).
|
||||
app.get<{ Params: { project: string; "*": string } }>(
|
||||
"/api/projects/:project/files/*",
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 92b2634c6d17fb387574b43a38890fd22b30891d
|
||||
Subproject commit eb806fc14ba1f91d8f99f03186add68d2a19a033
|
||||
|
|
@ -12,7 +12,12 @@ import {
|
|||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { WASM_ASSET_BASE_URL, yjsProviderConfig, type DocSource } from "@/lib/config";
|
||||
import {
|
||||
libsSourceConfig,
|
||||
WASM_ASSET_BASE_URL,
|
||||
yjsProviderConfig,
|
||||
type DocSource,
|
||||
} from "@/lib/config";
|
||||
import { bootKicadTool } from "@/wasm/boot";
|
||||
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
|
||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||
|
|
@ -437,6 +442,7 @@ export function WasmTool({
|
|||
log: append,
|
||||
onStatus: setStatus,
|
||||
onAbort: oom.onAbort,
|
||||
libsSource: libsSourceConfig(),
|
||||
});
|
||||
// Register the save sink before the file opens: from here on, every
|
||||
// editor File→Save (MEMFS write) is routed onward through saveBytes.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ export const WASM_ASSET_BASE_URL =
|
|||
import.meta.env.VITE_WASM_ASSET_BASE_URL ?? "/wasm";
|
||||
|
||||
import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
||||
import { remoteLibsSource } from "@/wasm/libs/remote-source";
|
||||
import type { LibsSource } from "@/wasm/libs/source";
|
||||
import { staticLibsSource } from "@/wasm/libs/static-source";
|
||||
|
||||
/**
|
||||
* Which Yjs collab provider this deployment uses (one active per env), and its
|
||||
|
|
@ -43,3 +46,18 @@ export type DocSource = "api" | "ydoc";
|
|||
export function docSourceConfig(): DocSource {
|
||||
return import.meta.env.VITE_DOC_SOURCE === "ydoc" ? "ydoc" : "api";
|
||||
}
|
||||
|
||||
/**
|
||||
* Which library source backs the editor's symbol chooser (env `VITE_LIBS_SOURCE`):
|
||||
* "remote" (default) — fetch from the backend at `API_BASE_URL` over the
|
||||
* shared contract (origins served by the registry, or the
|
||||
* GPL example backend).
|
||||
* "static" — built-in offline example symbols (no backend).
|
||||
* "off" — disable libs (empty sym-lib-table).
|
||||
*/
|
||||
export function libsSourceConfig(): LibsSource | null {
|
||||
const kind = import.meta.env.VITE_LIBS_SOURCE ?? "remote";
|
||||
if (kind === "off") return null;
|
||||
if (kind === "static") return staticLibsSource();
|
||||
return remoteLibsSource(API_BASE_URL);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,8 @@ import {
|
|||
TOOL_ARGV0,
|
||||
TOOL_NEEDS_CONFIG_SEED,
|
||||
} from "./constants";
|
||||
import {
|
||||
installSpikeLibsProvider,
|
||||
PCBJAM_LIB_MOUNT,
|
||||
SPIKE_LIB_TABLE_ROW,
|
||||
} from "./libs/spike-provider";
|
||||
import { buildSymLibTable, installLibsProvider, type LibsSource } from "./libs/source";
|
||||
import { PCBJAM_LIB_MOUNT } from "./libs/uri";
|
||||
|
||||
/**
|
||||
* Boot a KiCad tool directly in the main React document — no iframe.
|
||||
|
|
@ -45,6 +42,9 @@ export interface BootOptions {
|
|||
/** OOM recovery hook (feature 0002): emscripten `abort()` routes here so a
|
||||
* soft OOM can respawn a fresh tab. Optional — boot works without it. */
|
||||
onAbort?: (what: string) => void;
|
||||
/** Library source backing `window.kicadLibs`. Null/omitted disables libs
|
||||
* (an empty sym-lib-table is seeded). Its libs become sym-lib-table rows. */
|
||||
libsSource?: LibsSource | null;
|
||||
}
|
||||
|
||||
let booted: { tool: Tool; promise: Promise<void> } | null = null;
|
||||
|
|
@ -83,7 +83,7 @@ function loadScript(src: string): Promise<void> {
|
|||
}
|
||||
|
||||
async function doBoot(opts: BootOptions): Promise<void> {
|
||||
const { tool, base, container, log, onStatus, onAbort } = opts;
|
||||
const { tool, base, container, log, onStatus, onAbort, libsSource } = opts;
|
||||
const w = window as ToolWindow;
|
||||
|
||||
// The wasm reads the top-level frame geometry from a GLOBAL `mainWindow`
|
||||
|
|
@ -93,8 +93,21 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
// mismatches the viewport, breaking the whole AUI layout (toolbars/panels).
|
||||
(w as unknown as { mainWindow: HTMLElement }).mainWindow = container;
|
||||
|
||||
// libs 0002 spike: must exist before any plugin call can suspend on it.
|
||||
installSpikeLibsProvider(log);
|
||||
// libs: install the provider (must exist before any plugin call can suspend
|
||||
// on it) and generate the sym-lib-table from the source's libs — both before
|
||||
// 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";
|
||||
if (libsSource) {
|
||||
installLibsProvider(libsSource, log);
|
||||
try {
|
||||
const libsList = await libsSource.listLibs();
|
||||
symLibTable = buildSymLibTable(libsList);
|
||||
log(`[libs] seeded ${libsList.length} lib(s) into sym-lib-table`);
|
||||
} catch (e) {
|
||||
log(`[libs] listLibs failed, seeding empty table: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
onStatus("Downloading…");
|
||||
|
||||
|
|
@ -186,12 +199,9 @@ async function doBoot(opts: BootOptions): Promise<void> {
|
|||
2,
|
||||
),
|
||||
);
|
||||
// libs 0002 spike: one remote lib row; the PCBJAM plugin resolves it
|
||||
// through window.kicadLibs (installed in doBoot).
|
||||
writeIfAbsent(
|
||||
`${KICAD_CONFIG_DIR}/sym-lib-table`,
|
||||
`(sym_lib_table\n (version 7)\n${SPIKE_LIB_TABLE_ROW}\n)\n`,
|
||||
);
|
||||
// libs: rows generated in doBoot from the lib source; the PCBJAM plugin
|
||||
// resolves 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",
|
||||
|
|
|
|||
44
web/standalone/src/wasm/libs/remote-source.ts
Normal file
44
web/standalone/src/wasm/libs/remote-source.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { contract } from "@pcbjam/shared";
|
||||
import { initClient } from "@ts-rest/core";
|
||||
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
|
||||
|
||||
/**
|
||||
* A `LibsSource` backed by a contract-conforming backend (the closed registry
|
||||
* server, or the GPL example backend). List ops go through the ts-rest client;
|
||||
* item bodies stream from the raw text route `GET /api/libs/:lib/items/:kind/:name`
|
||||
* (binary/text does not round-trip ts-rest — same as file-byte download).
|
||||
*/
|
||||
export function remoteLibsSource(apiBase: string): LibsSource {
|
||||
const client = initClient(contract, { baseUrl: apiBase, baseHeaders: {} });
|
||||
|
||||
return {
|
||||
async listLibs(): Promise<LibInfo[]> {
|
||||
const res = await client.listLibs();
|
||||
if (res.status !== 200) return [];
|
||||
return res.body.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
description: l.description ?? null,
|
||||
}));
|
||||
},
|
||||
|
||||
async listItems(libId: string): Promise<LibItemInfo[]> {
|
||||
const res = await client.listLibItems({ params: { lib: libId } });
|
||||
if (res.status !== 200) return [];
|
||||
return res.body.map((i) => ({ kind: i.kind, name: i.name }));
|
||||
},
|
||||
|
||||
async getItemBody(
|
||||
libId: string,
|
||||
kind: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const url =
|
||||
`${apiBase}/api/libs/${encodeURIComponent(libId)}/items/` +
|
||||
`${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
},
|
||||
};
|
||||
}
|
||||
114
web/standalone/src/wasm/libs/source.ts
Normal file
114
web/standalone/src/wasm/libs/source.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { libIdFromUri, libUri } from "./uri";
|
||||
|
||||
/**
|
||||
* The data a `LibsSource` provides. A source abstracts WHERE library data comes
|
||||
* from (a remote backend over the shared contract, a static public snapshot, a
|
||||
* local folder); the WASM-facing provider below is the same regardless.
|
||||
*/
|
||||
export interface LibInfo {
|
||||
/** Opaque id used in the lib-table URI (/mnt/pcbjam/<id>). */
|
||||
id: string;
|
||||
/** Display nickname for the sym-lib-table row. */
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface LibItemInfo {
|
||||
kind: string; // 'symbol' | 'footprint' | 'model3d'
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface LibsSource {
|
||||
/** Libraries to expose to the editor (one sym-lib-table row each). */
|
||||
listLibs(): Promise<LibInfo[]>;
|
||||
/** Items in a library (by lib id). */
|
||||
listItems(libId: string): Promise<LibItemInfo[]>;
|
||||
/**
|
||||
* One self-contained item body (a complete `kicad_symbol_lib` s-expr), or
|
||||
* null if absent. `kind` is 'symbol' for now.
|
||||
*/
|
||||
getItemBody(libId: string, kind: string, name: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
/** The function the WASM `SCH_IO_PCBJAM_LIB` plugin calls via the JS bridge. */
|
||||
export type KicadLibsRequest = (
|
||||
op: string,
|
||||
lib: string,
|
||||
arg: string,
|
||||
) => Promise<string | null>;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
kicadLibs?: { request: KicadLibsRequest };
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional artificial latency (`?libdelay=1500`) to exercise the bridge. */
|
||||
function artificialDelayMs(): number {
|
||||
const raw = new URLSearchParams(window.location.search).get("libdelay");
|
||||
const n = raw ? Number(raw) : 0;
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Escape a string for a KiCad s-expr quoted token. */
|
||||
function sexprEscape(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/** Build sym-lib-table content (KiCad v7) with one PCBJAM row per lib. */
|
||||
export function buildSymLibTable(libsList: LibInfo[]): string {
|
||||
const rows = libsList.map((l) => {
|
||||
const descr = l.description ? sexprEscape(l.description) : "";
|
||||
return ` (lib (name "${sexprEscape(l.name)}")(type "PCBJAM")(uri "${libUri(
|
||||
l.id,
|
||||
)}")(options "")(descr "${descr}"))`;
|
||||
});
|
||||
return `(sym_lib_table\n (version 7)\n${rows.join("\n")}${
|
||||
rows.length ? "\n" : ""
|
||||
})\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install `window.kicadLibs` backed by a `LibsSource`. The plugin calls
|
||||
* `request(op, "/mnt/pcbjam/<id>", arg)`:
|
||||
* "list" -> JSON {"symbols":[...]} (symbol names in the lib)
|
||||
* "get" -> the item body s-expr (arg = symbol name)
|
||||
*/
|
||||
export function installLibsProvider(
|
||||
source: LibsSource,
|
||||
log: (msg: string) => void,
|
||||
): void {
|
||||
if (window.kicadLibs) return;
|
||||
const delay = artificialDelayMs();
|
||||
|
||||
const request: KicadLibsRequest = async (op, lib, arg) => {
|
||||
const id = libIdFromUri(lib);
|
||||
log(`[libs] request op=${op} lib=${lib} (id=${id}) arg=${arg}`);
|
||||
if (!id) return null;
|
||||
if (delay) await sleep(delay);
|
||||
|
||||
try {
|
||||
switch (op) {
|
||||
case "list": {
|
||||
const items = await source.listItems(id);
|
||||
const symbols = items
|
||||
.filter((i) => i.kind === "symbol")
|
||||
.map((i) => i.name);
|
||||
return JSON.stringify({ symbols });
|
||||
}
|
||||
case "get":
|
||||
return await source.getItemBody(id, "symbol", arg);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
log(`[libs] request failed: ${String(e)}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
window.kicadLibs = { request };
|
||||
log("[libs] provider installed");
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
/**
|
||||
* libs 0002 spike: a static JS-side library provider.
|
||||
*
|
||||
* The kicad fork's SCH_IO_PCBJAM_LIB plugin (lib-table type "PCBJAM") calls
|
||||
* `globalThis.kicadLibs.request(op, lib, arg)` through an Asyncify suspension
|
||||
* (EM_ASYNC_JS) and blocks the C++ stack until the returned promise resolves:
|
||||
*
|
||||
* request("list", "pcbjam://spike", "") -> JSON {"symbols": [...]}
|
||||
* request("get", "pcbjam://spike", "R") -> full kicad_symbol_lib s-expr
|
||||
* document holding that symbol
|
||||
*
|
||||
* This stub serves two handcrafted symbols from memory — no server. An
|
||||
* artificial delay (`?libdelay=1500` in the URL) exercises the reentrancy
|
||||
* question: the tab must stay alive while C++ is suspended mid-fetch.
|
||||
*/
|
||||
|
||||
export const SPIKE_LIB_NICKNAME = "pcbjam-spike";
|
||||
|
||||
/**
|
||||
* pcbjam lib URIs are absolute POSIX paths under this prefix. Absolute so that
|
||||
* KiCad's lib-table URI expansion (ExpandURI -> wxFileName::MakeAbsolute) is a
|
||||
* no-op and the path reaches the plugin/provider unmangled — a "scheme://" URI
|
||||
* gets rewritten to "/scheme:/..." against the cwd, which differs per project.
|
||||
*/
|
||||
export const PCBJAM_LIB_MOUNT = "/mnt/pcbjam";
|
||||
export const PCBJAM_LIB_PREFIX = `${PCBJAM_LIB_MOUNT}/`;
|
||||
export const SPIKE_LIB_URI = `${PCBJAM_LIB_PREFIX}spike`;
|
||||
|
||||
/** sym-lib-table row seeded at boot (see boot.ts seedKicadConfig). */
|
||||
export const SPIKE_LIB_TABLE_ROW = ` (lib (name "${SPIKE_LIB_NICKNAME}")(type "PCBJAM")(uri "${SPIKE_LIB_URI}")(options "")(descr "pcbjam libs 0002 spike"))`;
|
||||
|
||||
const wrapLib = (symbolBody: string) =>
|
||||
`(kicad_symbol_lib (version 20241209) (generator "pcbjam") (generator_version "0.1")
|
||||
${symbolBody}
|
||||
)
|
||||
`;
|
||||
|
||||
const SYMBOLS: Record<string, string> = {
|
||||
R: ` (symbol "R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
|
||||
(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
|
||||
(property "Footprint" "" (at -1.778 0 90) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "Description" "Resistor (pcbjam spike)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "ki_keywords" "R res resistor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(symbol "R_0_1"
|
||||
(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
|
||||
)
|
||||
(symbol "R_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
|
||||
(pin passive line (at 0 -3.81 90) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
|
||||
)
|
||||
)`,
|
||||
C: ` (symbol "C" (pin_numbers hide) (pin_names (offset 0.254)) (exclude_from_sim no) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "C" (at 0.635 2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
|
||||
(property "Value" "C" (at 0.635 -2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
|
||||
(property "Footprint" "" (at 0.9652 -3.81 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "Description" "Capacitor (pcbjam spike)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "ki_keywords" "cap capacitor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(symbol "C_0_1"
|
||||
(polyline (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
|
||||
(polyline (pts (xy -2.032 0.762) (xy 2.032 0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
|
||||
)
|
||||
(symbol "C_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
|
||||
(pin passive line (at 0 -3.81 90) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
|
||||
)
|
||||
)`,
|
||||
};
|
||||
|
||||
export type KicadLibsRequest = (
|
||||
op: string,
|
||||
lib: string,
|
||||
arg: string,
|
||||
) => Promise<string | null>;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
kicadLibs?: { request: KicadLibsRequest };
|
||||
}
|
||||
}
|
||||
|
||||
function artificialDelayMs(): number {
|
||||
const raw = new URLSearchParams(window.location.search).get("libdelay");
|
||||
const n = raw ? Number(raw) : 0;
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Recover the lib id from a "/mnt/pcbjam/<id>" URI (arrives unmangled). */
|
||||
function libIdFromUri(lib: string): string | null {
|
||||
return lib.startsWith(PCBJAM_LIB_PREFIX)
|
||||
? lib.slice(PCBJAM_LIB_PREFIX.length)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function installSpikeLibsProvider(log: (msg: string) => void): void {
|
||||
if (window.kicadLibs) return;
|
||||
|
||||
const delay = artificialDelayMs();
|
||||
|
||||
const request: KicadLibsRequest = async (op, lib, arg) => {
|
||||
log(`[libs] request op=${op} lib=${lib} arg=${arg} (delay=${delay}ms)`);
|
||||
|
||||
if (libIdFromUri(lib) !== "spike") return null;
|
||||
if (delay) await sleep(delay);
|
||||
|
||||
switch (op) {
|
||||
case "list":
|
||||
return JSON.stringify({ symbols: Object.keys(SYMBOLS) });
|
||||
case "get": {
|
||||
const body = SYMBOLS[arg];
|
||||
return body ? wrapLib(body) : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
window.kicadLibs = { request };
|
||||
log(`[libs] spike provider installed (${SPIKE_LIB_URI})`);
|
||||
}
|
||||
78
web/standalone/src/wasm/libs/static-source.ts
Normal file
78
web/standalone/src/wasm/libs/static-source.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import type { LibInfo, LibItemInfo, LibsSource } from "./source";
|
||||
|
||||
/**
|
||||
* A static, in-memory `LibsSource` — two handcrafted symbols, no backend. The
|
||||
* offline fallback when no API base is configured (and the origin of the libs
|
||||
* 0002 spike). Keeps the editor's lib chooser populated in a bare checkout.
|
||||
*/
|
||||
|
||||
const STATIC_LIB: LibInfo = {
|
||||
id: "examples",
|
||||
name: "pcbjam-examples",
|
||||
description: "Built-in example symbols (offline)",
|
||||
};
|
||||
|
||||
const wrapLib = (symbolBody: string) =>
|
||||
`(kicad_symbol_lib (version 20241209) (generator "pcbjam") (generator_version "0.1")
|
||||
${symbolBody}
|
||||
)
|
||||
`;
|
||||
|
||||
const SYMBOLS: Record<string, string> = {
|
||||
R: ` (symbol "R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
|
||||
(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
|
||||
(property "Footprint" "" (at -1.778 0 90) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "Description" "Resistor (pcbjam example)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "ki_keywords" "R res resistor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(symbol "R_0_1"
|
||||
(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none)))
|
||||
)
|
||||
(symbol "R_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
|
||||
(pin passive line (at 0 -3.81 90) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
|
||||
)
|
||||
)`,
|
||||
C: ` (symbol "C" (pin_numbers hide) (pin_names (offset 0.254)) (exclude_from_sim no) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "C" (at 0.635 2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
|
||||
(property "Value" "C" (at 0.635 -2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
|
||||
(property "Footprint" "" (at 0.9652 -3.81 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "Datasheet" "~" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "Description" "Capacitor (pcbjam example)" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(property "ki_keywords" "cap capacitor" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))
|
||||
(symbol "C_0_1"
|
||||
(polyline (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
|
||||
(polyline (pts (xy -2.032 0.762) (xy 2.032 0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
|
||||
)
|
||||
(symbol "C_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
|
||||
(pin passive line (at 0 -3.81 90) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))
|
||||
)
|
||||
)`,
|
||||
};
|
||||
|
||||
export function staticLibsSource(): LibsSource {
|
||||
return {
|
||||
async listLibs(): Promise<LibInfo[]> {
|
||||
return [STATIC_LIB];
|
||||
},
|
||||
async listItems(libId: string): Promise<LibItemInfo[]> {
|
||||
if (libId !== STATIC_LIB.id) return [];
|
||||
return Object.keys(SYMBOLS).map((name) => ({ kind: "symbol", name }));
|
||||
},
|
||||
async getItemBody(
|
||||
libId: string,
|
||||
_kind: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
if (libId !== STATIC_LIB.id) return null;
|
||||
const body = SYMBOLS[name];
|
||||
return body ? wrapLib(body) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
20
web/standalone/src/wasm/libs/uri.ts
Normal file
20
web/standalone/src/wasm/libs/uri.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* pcbjam lib URIs are absolute POSIX paths under this mount. Absolute so that
|
||||
* KiCad's lib-table URI expansion (ExpandURI -> wxFileName::MakeAbsolute) is a
|
||||
* no-op and the path reaches the plugin/provider unmangled — a "scheme://" URI
|
||||
* gets rewritten to "/scheme:/..." against the cwd, which differs per project.
|
||||
*/
|
||||
export const PCBJAM_LIB_MOUNT = "/mnt/pcbjam";
|
||||
export const PCBJAM_LIB_PREFIX = `${PCBJAM_LIB_MOUNT}/`;
|
||||
|
||||
/** The lib-table URI for a lib id. */
|
||||
export function libUri(id: string): string {
|
||||
return `${PCBJAM_LIB_PREFIX}${id}`;
|
||||
}
|
||||
|
||||
/** Recover the lib id from a "/mnt/pcbjam/<id>" URI (arrives unmangled). */
|
||||
export function libIdFromUri(uri: string): string | null {
|
||||
return uri.startsWith(PCBJAM_LIB_PREFIX)
|
||||
? uri.slice(PCBJAM_LIB_PREFIX.length)
|
||||
: null;
|
||||
}
|
||||
Loading…
Reference in a new issue