diff --git a/tests/kicad/gerbview.spec.ts b/tests/kicad/gerbview.spec.ts index fde24bb..397db03 100644 --- a/tests/kicad/gerbview.spec.ts +++ b/tests/kicad/gerbview.spec.ts @@ -13,8 +13,30 @@ import { waitForEditorReady, stableShot } from '../e2e/utils/element-tracker'; * * Determinism: no waitForTimeout, no wizard click-through loop, screenshots via * stableShot (stabilizes before comparing). + * + * The embind file-open surface (wasm/bindings/gerbview_embind.cpp) IS in scope: + * the project page deep-links a gerber here, and the shell opens the whole + * fabrication set through `kicadOpenFiles`. */ +/** Minimal valid RS-274X gerber drawing one trace, so a layer really loads. */ +function gerber(xEndMm: number): string { + return [ + '%FSLAX46Y46*%', + '%MOMM*%', + '%ADD10C,0.200000*%', + 'D10*', + 'X10000000Y10000000D02*', + `X${xEndMm * 1_000_000}Y10000000D01*`, + 'M02*', + '', + ].join('\n'); +} + +/** Minimal Excellon drill file — GerbView routes .drl to its own loader. */ +const DRILL = ['M48', 'FMAT,2', 'METRIC', 'T1C0.800', '%', 'G90', 'G05', 'T1', + 'X20.0Y20.0', 'T0', 'M30', ''].join('\n'); + function hasAbort(testLogger: { consoleLogs: string[]; errors: string[] }): boolean { return [...testLogger.consoleLogs, ...testLogger.errors].some(line => line.includes('Aborted(')); } @@ -62,4 +84,64 @@ test.describe('gerbview WASM', () => { expect(metrics.glCanvasOk, 'GL canvas has nonzero dimensions').toBe(true); expect(hasAbort(testLogger)).toBe(false); }); + + /** + * kicadOpenFiles: the whole-set entry the project page's gerber links use. + * A fabrication set is a stack, so opening one layer alone is not the job — + * this asserts a multi-file open lands every layer (and the drill file) in + * one call, which is what GERBVIEW_FRAME::OpenProjectFiles gives us. + */ + test('kicadOpenFiles opens a whole fabrication set in one call', async ({ page, testLogger }) => { + await waitForEditorReady(page); + + const opened = await page.evaluate(({ gerbers, drill }) => { + const w = window as unknown as { + FS: { mkdirTree(p: string): void; writeFile(p: string, d: string): void }; + Module: { kicadOpenFiles?: (json: string) => boolean }; + }; + const dir = '/home/kicad/documents/fab'; + w.FS.mkdirTree(dir); + const paths: string[] = []; + for (const [name, content] of Object.entries(gerbers)) { + const p = `${dir}/${name}`; + w.FS.writeFile(p, content as string); + paths.push(p); + } + const drillPath = `${dir}/board-PTH.drl`; + w.FS.writeFile(drillPath, drill); + paths.push(drillPath); + const registryBefore = window.wxElementRegistry!.findAll({ visible: true }).length; + if (typeof w.Module.kicadOpenFiles !== 'function') return { hook: false, registryBefore }; + w.Module.kicadOpenFiles(JSON.stringify(paths)); + return { hook: true, registryBefore }; + }, { + gerbers: { + 'board-F_Cu.gbr': gerber(30), + 'board-B_Cu.gbr': gerber(40), + 'board-Edge_Cuts.gbr': gerber(50), + }, + drill: DRILL, + }); + + expect(opened.hook, 'gerbview exposes kicadOpenFiles (gerbview_embind.cpp)').toBe(true); + // NOT the return value: OpenProjectFiles parks under Asyncify, so the + // embind call unwinds and hands back a falsy placeholder long before the + // load finishes (same reason open-flow.ts ignores kicadOpenFile's bool). + // The truthful completion signal is the open-gate probe. + await expect.poll( + async () => page.evaluate(() => { + const w = window as unknown as { Module: { kicadOpenFileBusy?: () => boolean } }; + return w.Module.kicadOpenFileBusy?.() ?? true; + }), + { timeout: 30000, intervals: [250] }, + ).toBe(false); + + // Each file became its own draw layer, so the UI gained rows/entries. + expect( + await page.evaluate(() => window.wxElementRegistry!.findAll({ visible: true }).length), + 'the layers UI grew once the set loaded', + ).toBeGreaterThan(opened.registryBefore); + + expect(hasAbort(testLogger), 'no WASM abort during the multi-file open').toBe(false); + }); }); diff --git a/wasm/bindings/gerbview_embind.cpp b/wasm/bindings/gerbview_embind.cpp new file mode 100644 index 0000000..47b0d13 --- /dev/null +++ b/wasm/bindings/gerbview_embind.cpp @@ -0,0 +1,91 @@ +/* + * GerbView embind bindings. + * + * GerbView boots without a document (it is one of `FILELESS_TOOLS`), but a + * project route CAN name a gerber: clicking `Production/gerbers/board-F_Cu.gbr` + * on the project page deep-links here. Until this TU existed the bundle had no + * embind surface at all, so the shell staged the file into MEMFS and then had + * no way to say "open it" — GerbView came up empty and the user had to walk + * File→Open themselves. + * + * A gerber is rarely useful alone (a fabrication set is a stack of layers plus + * drill files), and `GERBVIEW_FRAME::OpenProjectFiles` already takes a LIST and + * auto-routes each entry by filename to the gerber / Excellon / job / archive + * loader, then zoom-fits. So the primary entry point here is the multi-file + * one: the shell hands over every gerber+drill sibling in the clicked file's + * folder, and the whole board renders. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "open_gate.h" + +using namespace emscripten; +using json = nlohmann::json; + +static GERBVIEW_FRAME* gerbFrame() +{ + return wxTheApp ? dynamic_cast( wxTheApp->GetTopWindow() ) : nullptr; +} + +static bool openFileSet( const std::vector& aFiles ) +{ + // Held across every Asyncify park of the load (open_gate.h): the layer load + // parks, and a wx timer dispatched into a half-built layer set traps. + pcbjam_open::BusyGuard busy; + + GERBVIEW_FRAME* frame = gerbFrame(); + + if( !frame || aFiles.empty() ) + return false; + + return frame->OpenProjectFiles( aFiles, 0 ); +} + +/** Open ONE gerber/drill file (the generic single-file entry every app has). */ +static bool kicadOpenFile( std::string path ) +{ + return openFileSet( { wxString::FromUTF8( path.c_str() ) } ); +} + +/** + * Open a whole fabrication set: a JSON array of MEMFS paths. Order is the + * caller's (the shell sorts, so layer order is stable across reloads). + * GERBVIEW_FRAME caps the set at GERBER_DRAWLAYERS_COUNT internally. + */ +static bool kicadOpenFiles( std::string pathsJson ) +{ + json paths = json::parse( pathsJson, nullptr, /*allow_exceptions*/ false ); + + if( !paths.is_array() ) + return false; + + std::vector files; + + for( const auto& entry : paths ) + { + if( entry.is_string() ) + files.push_back( wxString::FromUTF8( entry.get().c_str() ) ); + } + + return openFileSet( files ); +} + +/** JS-pollable open-in-flight probe — same contract as the editors. */ +static bool kicadOpenFileBusy() +{ + return pcbjam_open::busy(); +} + +EMSCRIPTEN_BINDINGS( gerbview ) +{ + function( "kicadOpenFile", &kicadOpenFile ); + function( "kicadOpenFiles", &kicadOpenFiles ); + function( "kicadOpenFileBusy", &kicadOpenFileBusy ); +} diff --git a/wasm/bindings/open_gate.h b/wasm/bindings/open_gate.h index 24f6444..14af4d2 100644 --- a/wasm/bindings/open_gate.h +++ b/wasm/bindings/open_gate.h @@ -18,6 +18,8 @@ */ #pragma once +#include + namespace pcbjam_open { @@ -27,10 +29,30 @@ inline int& busyCount() return s_count; } +/** + * Held for the whole open. Two counters, same Asyncify-RAII trick: + * + * - `busyCount` is OURS: it answers kicadOpenFileBusy() for the web shell and + * gates the collab entries (JS → embind reentry). + * - `wxWasmDispatchGuard` enrolls the open in the WX DISPATCH INTERLOCK. This + * matters because `kicadOpenFile` enters through embind, not through a wx + * dispatch entry point, so without it `wxWasmDispatchParked()` reads FALSE + * for the entire load: every park (progress pump, thread-pool futex wait, + * lib bridge) lets the pump dispatch a QUEUED WX TIMER into the half-built + * board — src/wasm/timer.cpp fires it because nothing looks parked — and + * the handler walks half-mutated widget/board state ("index out of bounds", + * the same signature as the symbol-chooser crash the interlock was built + * for). Holding the guard makes those timers defer (retry 17 ms later) + * until the load truly completes. Paints keep running; the progress + * dialog's own pump is the designed exception (it zeroes the count). + */ struct BusyGuard { BusyGuard() { ++busyCount(); } ~BusyGuard() { --busyCount(); } + +private: + wxWasmDispatchGuard m_dispatch; }; /** JS-pollable: is a kicadOpenFile chain still in flight (possibly parked)? */ diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 54ec629..e2360ef 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -1445,6 +1445,44 @@ export function WasmTool({ return { error }; } })(); + // Project presence room, same fan-out slot as the doc room: it needs + // identity and a socket, NOT the wasm, so the websocket handshake + // happens while the wasm still downloads instead of queueing behind a + // multi-second board load (which also starves the handshake — Firefox + // drops it as "interrupted while the page was loading"). Only the + // wasm-bound half (bindKicadPresence) waits for the open to settle. + // Never rejects: presence is best-effort, exactly as before. + // Read-only viewers skip the room entirely — the server rejects their + // connection anyway (presence requires write). + const collabOptOut = + new URLSearchParams(win.location.search).get("collab") === "0" || + new URLSearchParams(win.location.search).get("collab") === "false"; + const crossAppReady: Promise = + (tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly + ? (async () => { + try { + await identityReady; + return await startCrossAppPresence({ + scopeId, + projectId, + provider: yjsProviderConfig(), + user: presenceUser(), + tool, + }); + } catch (err) { + append(`[collab] cross-app presence connect failed: ${String(err)}`); + return undefined; + } + })() + : Promise.resolve(undefined); + // Take ownership the moment it lands — a boot that dies before the + // handoff below would otherwise leave this socket open, since unmount + // only tears down what reached crossAppRef. + void crossAppReady.then((h) => { + if (!h) return; + if (presyncAbort.signal.aborted) h.destroy(); // unmounted mid-connect + else crossAppRef.current = h; + }); await bootKicadTool({ tool, base, @@ -1570,25 +1608,14 @@ export function WasmTool({ // Cross-app selection (0006): join the project-wide presence room BEFORE // the per-file collab starts, so the first startPresence bind already // routes xsel. Honors the same ?collab=0 opt-out as the room collab. - const collabOptOut = - new URLSearchParams(win.location.search).get("collab") === "0" || - new URLSearchParams(win.location.search).get("collab") === "false"; - // Read-only viewers skip the project presence room entirely — the - // server rejects their connection anyway (presence requires write). - if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly) { - crossAppRef.current = - (await startCrossAppPresence({ - scopeId, - projectId, - provider: yjsProviderConfig(), - user: presenceUser(), - tool, - })) ?? null; - // Test/debug handle (mirrors __pcbjamComments): lets the e2e assert - // the project-room peer view without driving pixels. - (win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp = - crossAppRef.current; - } + // Cross-app presence: the ROOM was joined back in the boot fan-out + // (pure network + Y.Doc, no wasm) — only the handoff to the wasm-bound + // presence below has to wait for the open. Settle it here. + crossAppRef.current = (await crossAppReady) ?? null; + // Test/debug handle (mirrors __pcbjamComments): lets the e2e assert + // the project-room peer view without driving pixels. + (win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp = + crossAppRef.current; if (tool === "eeschema") { // Multi-room (subschema) collab: every .kicad_sch is its own warm room; the diff --git a/web/standalone/src/wasm/gerber-set.test.ts b/web/standalone/src/wasm/gerber-set.test.ts new file mode 100644 index 0000000..f04b874 --- /dev/null +++ b/web/standalone/src/wasm/gerber-set.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { gerberSiblings } from "./kicad-runner"; + +/** + * Clicking one gerber on the project page opens the whole fabrication SET in + * its folder (a single copper layer alone is a near-empty canvas). Drill files + * come along — GerbView routes them to the Excellon loader itself. + */ +const files = (...paths: string[]) => paths.map((path) => ({ path })); + +describe("gerberSiblings", () => { + const SET = [ + "Production/gerbers/board-B_Cu.gbr", + "Production/gerbers/board-Edge_Cuts.gbr", + "Production/gerbers/board-F_Cu.gbr", + "Production/gerbers/board-PTH.drl", + ]; + + it("collects every gerber and drill file in the clicked folder, sorted", () => { + expect( + gerberSiblings(files(...SET), "Production/gerbers/board-F_Cu.gbr"), + ).toEqual(SET); + }); + + it("ignores other folders, nested subfolders, and non-gerber files", () => { + const all = files( + ...SET, + "Production/gerbers/readme.md", + "Production/gerbers/old/board-F_Cu.gbr", + "Production/other/board-F_Cu.gbr", + "KiCad Projects/board.kicad_pcb", + ); + expect(gerberSiblings(all, "Production/gerbers/board-F_Cu.gbr")).toEqual(SET); + }); + + it("handles a gerber at the project root", () => { + const all = files("a-F_Cu.gbr", "a-B_Cu.gbr", "sub/deep.gbr"); + expect(gerberSiblings(all, "a-F_Cu.gbr")).toEqual(["a-B_Cu.gbr", "a-F_Cu.gbr"]); + }); + + it("always includes the clicked file, even with an unrecognised extension", () => { + const all = files("g/board-F_Cu.gbr", "g/weird.xyz"); + expect(gerberSiblings(all, "g/weird.xyz")).toEqual([ + "g/weird.xyz", + "g/board-F_Cu.gbr", + ]); + }); +}); diff --git a/web/standalone/src/wasm/kicad-runner.ts b/web/standalone/src/wasm/kicad-runner.ts index 31ccc22..18d3c55 100644 --- a/web/standalone/src/wasm/kicad-runner.ts +++ b/web/standalone/src/wasm/kicad-runner.ts @@ -1,5 +1,5 @@ import type { Tool } from "@pcbjam/shared"; -import { FILELESS_TOOLS } from "@pcbjam/shared"; +import { FILELESS_TOOLS, toolForFile } from "@pcbjam/shared"; import { defaultKicadPro } from "../lib/new-file"; import { memfsFilePath, memfsProjectDir } from "./constants"; import { prescanBoardModels } from "./libs/models-bridge"; @@ -137,6 +137,70 @@ function synthesizeProjectFile(win: ToolWindow, opts: DriveOptions): void { opts.log(`[memfs] synthesized ${proPath} (project has no project file)`); } +/** + * Every gerber/drill sibling of `targetPath`, in stable filename order. + * + * A fabrication set is a stack — copper layers, mask, silk, edge cuts, plus the + * Excellon drill files — and they are conventionally emitted into one folder. + * Opening only the clicked layer shows a near-empty canvas, so the clicked + * FOLDER is the real unit. `toolForFile` owns the extension list (it is what + * decided this route is gerbview in the first place); drill files come along + * because GerbView routes them to the Excellon loader itself. + */ +export function gerberSiblings(files: ToolFile[], targetPath: string): string[] { + const slash = targetPath.lastIndexOf("/"); + const dir = slash < 0 ? "" : targetPath.slice(0, slash + 1); + const inDir = files + .map((f) => f.path) + .filter((path) => { + if (!path.startsWith(dir)) return false; + if (path.slice(dir.length).includes("/")) return false; // nested deeper + return toolForFile(path) === "gerbview"; + }) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + // The clicked file always opens, even if its extension is unusual enough that + // toolForFile missed it (the route named it, so the user meant it). + return inDir.includes(targetPath) ? inDir : [targetPath, ...inDir]; +} + +/** + * Open a whole gerber set in GerbView. Prefers the multi-file embind entry + * (`kicadOpenFiles`); a bundle predating it falls back to the single-file open + * of just the clicked layer, which is the old behavior and still renders. + */ +async function openGerberSet( + win: ToolWindow, + opts: DriveOptions, +): Promise<"programmatic" | "ui" | "failed"> { + const paths = gerberSiblings(opts.files, opts.targetPath!); + const abs = paths.map((path) => memfsFilePath(opts.slug, path)); + return openFileInTool(win, abs[0]!, { + log: opts.log, + open: () => { + // Feature-detect HERE, not before the call: embind registers the Module + // functions during runtime init, which lands AFTER the Emscripten FS is + // ready — i.e. after we get here from driveProjectIntoTool. Probing any + // earlier always misses and silently degrades to the single-file open + // (openFileInTool's frame wait is what guarantees the exports exist). + const mod = win.Module as + | { + kicadOpenFiles?: (json: string) => boolean; + kicadOpenFile?: (path: string) => unknown; + } + | undefined; + if (typeof mod?.kicadOpenFiles === "function") { + opts.log(`[open] gerbview: opening ${abs.length} file(s) from ${opts.targetPath}`); + mod.kicadOpenFiles(JSON.stringify(abs)); + return; + } + opts.log( + "[open] gerbview bundle has no kicadOpenFiles — opening the clicked layer only", + ); + mod?.kicadOpenFile?.(abs[0]!); + }, + }); +} + /** * Drive a project into an already-booting tool runtime (booted into `win` by * bootKicadTool — the top-level window). Waits for the Emscripten FS, syncs the @@ -166,7 +230,14 @@ export async function driveProjectIntoTool( synthesizeProjectFile(win, opts); let result: "programmatic" | "ui" | "failed" | "none" = "none"; - if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) { + if (opts.targetPath && opts.tool === "gerbview") { + // GerbView boots fileless, but a project route can still name a gerber — + // and a single layer on its own is not a useful view, so open the whole + // fabrication set that lives beside it (see openGerberSet). + onStatus("Opening gerbers…"); + result = await openGerberSet(win, opts); + log(`[open] result: ${result}`); + } else if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) { onStatus("Opening file…"); const abs = memfsFilePath(opts.slug, opts.targetPath); result = await openFileInTool(win, abs, { log }); diff --git a/web/standalone/src/wasm/open-flow.ts b/web/standalone/src/wasm/open-flow.ts index 18bb43e..fa22eca 100644 --- a/web/standalone/src/wasm/open-flow.ts +++ b/web/standalone/src/wasm/open-flow.ts @@ -17,6 +17,13 @@ export interface OpenFlowOptions { timeoutMs?: number; /** Override the load-settle budget (kicadOpenFileBusy poll) — tests only. */ settleTimeoutMs?: number; + /** + * Replace the programmatic invocation (default: `Module.kicadOpenFile(path)`) + * while keeping the readiness handling around it — the frame wait, the + * settle gate, the no-UI-automation-while-parked rule. GerbView uses this to + * open a whole fabrication set through `kicadOpenFiles`. + */ + open?: () => void; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -220,8 +227,9 @@ export async function openFileInTool( // waitForOpenSettled). We must NOT fall back to UI automation while the hook // is in flight — synthesizing input would re-enter the suspended Asyncify // call and corrupt it. - if (hasProgrammaticHook(win)) { - invokeProgrammaticOpen(win, absPath, log); + if (opts.open || hasProgrammaticHook(win)) { + if (opts.open) opts.open(); + else invokeProgrammaticOpen(win, absPath, log); const settled = await waitForOpenSettled(win, log, timeoutMs, opts.settleTimeoutMs); return settled ? "programmatic" : "failed"; }