read-only-viewer 0003: no lib catalog for viewers; 3D viewer from the session menu

- kicadShow3DViewer embind (pcbnew-only name, registered in the unguarded
  section so the merged kicad_editor image carries it): runOnCoroutine →
  ACTIONS::show3DViewer.
- Session menu: "3D viewer" row (pcbnew) — the only 3D entry once the wx
  chrome is hidden. Runs the deferred model prescan first.
- Read-only sessions: skip the boot-time enableRealtime scope-room socket
  (would 401 for non-members) and defer the board's 3D-model prescan until
  the viewer is opened (deferBoardModelPrescan / runDeferredModelPrescan).
- kicad submodule → read-only allowlist for the 3D viewer actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXCntRWNn89M72EkrqMkvc
This commit is contained in:
Gergő Törcsvári 2026-08-27 15:26:30 +02:00
commit 97d4f9657e
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 152 additions and 6 deletions

View file

@ -118,9 +118,11 @@ import {
waitForWxUi,
} from "@/components/wasm-tool/collab-start";
import { installQuitHook } from "@/components/wasm-tool/quit-hook";
import { runDeferredModelPrescan } from "@/wasm/libs/models-bridge";
import { installToolNavigationHook } from "@/components/wasm-tool/tool-navigation";
import {
chromeSetter,
show3DOpener,
COLLAB_TOOLS,
INSPECTOR_OPEN_KEY,
LAYERS_OPEN_KEY,
@ -1065,6 +1067,8 @@ export function WasmTool({
slug,
files,
targetPath,
// Viewers fetch 3D bodies only if they open the viewer (session menu).
deferModelPrescan: readOnly,
// ydoc source with a populated room: the target file's bytes come
// from the doc; everything else (sibling files) still fetches.
fetchBytes:
@ -1386,7 +1390,10 @@ export function WasmTool({
// DOCUMENT references — a peer editing a PLACED symbol must still
// reach this session live (lib-update toast); everything else syncs
// on the next load. Fire-and-forget: boot never waits on sockets.
if (targetPath && source?.enableRealtime) {
// Read-only sessions carry no editable libs (the boot payload is
// model3d-only for them) and may not open the team's scope room —
// skip the socket rather than collect a 401.
if (targetPath && source?.enableRealtime && !readOnly) {
const staged = readStagedFile(win, slug, targetPath);
const nicks = staged
? usedLibNicknames(new TextDecoder().decode(staged))
@ -1472,6 +1479,22 @@ export function WasmTool({
[ready],
);
// kicadShow3DViewer — the session-menu 3D entry (read-only-viewer / hide-UI
// have no wx View menu). Runs the deferred model prescan first so a viewer's
// first open resolves its refs from MEMFS instead of one C++ ensure each.
const show3DFn = React.useMemo(() => {
if (!ready || tool !== "pcbnew") return null;
const open = show3DOpener(window);
if (!open) return null;
return () => {
void runDeferredModelPrescan()
.catch((e) => append(`[3d] deferred prescan failed: ${String(e)}`))
.then(() => {
if (!open()) append("[3d] kicadShow3DViewer: no board frame");
});
};
}, [ready, tool, append]);
// Layer bridge (viewer-panels), pcbnew sessions only — the merged bundle
// exports the names for every frame, but they no-op on a non-PCB frame.
const layersMod = React.useMemo<LayersModule | null>(() => {
@ -1604,6 +1627,7 @@ export function WasmTool({
canToggleChrome={setChromeFn !== null}
chromeHidden={chromeHidden}
onToggleChrome={() => toggleChromeHidden()}
onShow3D={show3DFn}
/>
)}

View file

@ -2,6 +2,7 @@
import * as React from "react";
import {
AlertTriangle,
Box,
Crosshair,
EyeOff,
Layers,
@ -155,6 +156,7 @@ export function SessionMenu({
canToggleChrome,
chromeHidden,
onToggleChrome,
onShow3D,
}: {
tool: Tool;
readOnly: boolean;
@ -184,6 +186,9 @@ export function SessionMenu({
canToggleChrome: boolean;
chromeHidden: boolean;
onToggleChrome: () => void;
/** Open the board's 3D viewer (pcbnew only); null when the bundle lacks
* the bridge. The only 3D entry point once the wx menus are hidden. */
onShow3D: (() => void) | null;
}) {
return (
<OverlayMenu
@ -279,6 +284,17 @@ export function SessionMenu({
<span>{inspectorOpen ? "Hide inspector" : "Inspector"}</span>
</button>
)}
{tool === "pcbnew" && onShow3D && (
<button
data-testid="show-3d-viewer"
className={overlayRowClass}
title="Open the 3D viewer"
onClick={onShow3D}
>
<Box size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
<span>3D viewer</span>
</button>
)}
{canToggleChrome && !readOnly && (
<button
data-testid="chrome-toggle"

View file

@ -20,6 +20,15 @@ export function chromeSetter(win: Window): ((show: boolean) => boolean) | null {
return typeof fn === "function" ? (fn as (show: boolean) => boolean) : null;
}
/** kicadShow3DViewer (pcbnew bundle): opens the 3D viewer from the session
* menu the one way to reach it when the wx chrome is hidden (read-only
* viewer / hide-UI). Null on bundles without it. */
export function show3DOpener(win: Window): (() => boolean) | null {
const fn = (win as { Module?: { kicadShow3DViewer?: unknown } }).Module
?.kicadShow3DViewer;
return typeof fn === "function" ? (fn as () => boolean) : null;
}
// Viewer panels (viewer-panels): floating layer selector + selection
// inspector open-state persistence, mirroring the comments panel's keys.
export const LAYERS_OPEN_KEY = "pcbjam:layers-panel-open";

View file

@ -4,7 +4,7 @@ import { SyncStack } from "@pcbjam/sync-client";
import { defaultKicadPro } from "../lib/new-file";
import { memfsFilePath, memfsProjectDir } from "./constants";
import { mark } from "./load-trace";
import { prescanBoardModels } from "./libs/models-bridge";
import { deferBoardModelPrescan, prescanBoardModels } from "./libs/models-bridge";
import { openFileInTool } from "./open-flow";
/**
@ -65,6 +65,9 @@ export interface DriveOptions {
* the boot overlay's "Project files — n/m" line. Reported once up front
* with done=0 so the line appears as soon as staging starts. */
onFileProgress?: (done: number, total: number) => void;
/** Read-only sessions: park the board's 3D prescan until the viewer opens
* (runDeferredModelPrescan) instead of prefetching every model at open. */
deferModelPrescan?: boolean;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@ -169,9 +172,11 @@ async function syncProjectToMemfs(win: ToolWindow, opts: DriveOptions): Promise<
// ensure. No-op unless a model source is installed (bootKicadTool).
if (path.endsWith(".kicad_pcb")) {
const text = new TextDecoder().decode(bytes);
void prescanBoardModels(text).catch((e) =>
opts.log(`[3d] prescan failed: ${String(e)}`),
);
if (opts.deferModelPrescan) deferBoardModelPrescan(text);
else
void prescanBoardModels(text).catch((e) =>
opts.log(`[3d] prescan failed: ${String(e)}`),
);
}
};

View file

@ -3,6 +3,8 @@ import {
collectBoardModelFiles,
ensureModelInMemfs,
installModel3dHandler,
deferBoardModelPrescan,
runDeferredModelPrescan,
normalizeModelRef,
scanModelRefs,
} from "./models-bridge";
@ -168,3 +170,48 @@ describe("scanModelRefs", () => {
expect(scanModelRefs("(kicad_pcb (version 20240101))")).toEqual([]);
});
});
describe("deferred board prescan (read-only sessions)", () => {
function installCounting() {
const files = new Map<string, Uint8Array>();
const fs = {
mkdirTree: () => {},
writeFile: (p: string, b: Uint8Array) => void files.set(p, b),
analyzePath: (p: string) => ({ exists: files.has(p) }),
};
(globalThis as unknown as { window: unknown }).window ??= globalThis;
// The prescan emits its progress event on window (node: bare globalThis).
(globalThis as unknown as { dispatchEvent?: unknown }).dispatchEvent ??= () => true;
(globalThis as unknown as { FS: unknown }).FS = fs;
let fetches = 0;
const source: Model3dSource = {
getModelBody: async (ref) => {
fetches++;
return new TextEncoder().encode(`body:${ref}`);
},
hasModel: async () => true,
};
installModel3dHandler(source, () => {});
return { files, fetches: () => fetches };
}
it("fetches nothing at defer time and everything on the first run", async () => {
const t = installCounting();
deferBoardModelPrescan(
'(model "${KICAD10_3DMODEL_DIR}/DeferLib.3dshapes/A.step") (model "${KICAD10_3DMODEL_DIR}/DeferLib.3dshapes/B.step")',
);
expect(t.fetches()).toBe(0);
await runDeferredModelPrescan();
expect(t.fetches()).toBe(2);
expect(t.files.has("/pcbjam/3dmodels/DeferLib.3dshapes/A.step")).toBe(true);
// Consumed: a second open does not re-scan.
await runDeferredModelPrescan();
expect(t.fetches()).toBe(2);
});
it("is a no-op when nothing was deferred", async () => {
const t = installCounting();
await runDeferredModelPrescan();
expect(t.fetches()).toBe(0);
});
});

View file

@ -240,6 +240,30 @@ export async function collectBoardModelFiles(
return out;
}
/** Board text whose prescan was deferred (read-only sessions): run on the
* first 3D open via {@link runDeferredModelPrescan}. */
let deferredBoardText: string | null = null;
/**
* Read-only viewers download nothing 3D until they actually open the viewer:
* park the board text instead of prescanning it at project open. The last
* board staged wins (one board per session).
*/
export function deferBoardModelPrescan(boardText: string): void {
deferredBoardText = boardText;
}
/** Run the parked prescan (no-op when nothing was deferred or it already ran).
* Resolves when the models are in MEMFS, so a caller can open the 3D viewer
* right after and have it resolve refs locally (the per-model C++ ensure
* still covers any miss). */
export async function runDeferredModelPrescan(): Promise<void> {
const text = deferredBoardText;
if (text === null) return;
deferredBoardText = null;
await prescanBoardModels(text);
}
/**
* Prefetch every model a board references (fire-and-forget from the project
* sync). Bodies land in IDB + MEMFS before the user opens the 3D viewer in the
@ -250,6 +274,7 @@ export async function prescanBoardModels(
concurrency = 6,
): Promise<void> {
if (!installedSource) return;
deferredBoardText = null;
const refs = scanModelRefs(boardText);
if (!refs.length) return;