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:
parent
c80622a860
commit
97d4f9657e
8 changed files with 152 additions and 6 deletions
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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)}`),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue