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
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
||||||
Subproject commit 27051b46e2b3662731f913355a2bd752ca5c6561
|
Subproject commit 39da876c2df6326392b240453a485f28f91231f8
|
||||||
|
|
@ -1898,6 +1898,23 @@ void pcbCollabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH
|
||||||
presenceCore().fitViewport( aCx, aCy, aHalfW, aHalfH );
|
presenceCore().fitViewport( aCx, aCy, aHalfW, aHalfH );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JS → C++: open the board's 3D viewer (read-only-viewer / hide-UI sessions have
|
||||||
|
// no wx View menu). Dispatched like a native edit — CallAfter + COROUTINE via
|
||||||
|
// runOnCoroutine — so the tool runs on its own stack, where the 3D frame's
|
||||||
|
// RunMainStack bounce (the 8/12 sleep-park fix) is valid; never from inside a
|
||||||
|
// parked bridge crossing. Returns false when no board frame is up.
|
||||||
|
bool pcbShow3DViewer()
|
||||||
|
{
|
||||||
|
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||||
|
|
||||||
|
if( !fr )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
pcbjam_collab::runOnCoroutine(
|
||||||
|
fr, [fr]() { fr->GetToolManager()->RunAction( ACTIONS::show3DViewer ); } );
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// JS pull of the current viewport transform (world↔screen mapping for the DOM layer):
|
// JS pull of the current viewport transform (world↔screen mapping for the DOM layer):
|
||||||
// `{cx,cy,scale,w,h}` — world center, pixels-per-IU scale, canvas size in px.
|
// `{cx,cy,scale,w,h}` — world center, pixels-per-IU scale, canvas size in px.
|
||||||
std::string pcbCollabGetViewport()
|
std::string pcbCollabGetViewport()
|
||||||
|
|
@ -2795,6 +2812,9 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
||||||
function("kicadLayersGetState", &pcbLayersGetState);
|
function("kicadLayersGetState", &pcbLayersGetState);
|
||||||
function("kicadLayersSetVisible", &pcbLayersSetVisible);
|
function("kicadLayersSetVisible", &pcbLayersSetVisible);
|
||||||
function("kicadLayersSetActive", &pcbLayersSetActive);
|
function("kicadLayersSetActive", &pcbLayersSetActive);
|
||||||
|
// Session-menu 3D entry (read-only-viewer 0003) — pcbnew-only name,
|
||||||
|
// merged-image safe (false when no board frame is up).
|
||||||
|
function("kicadShow3DViewer", &pcbShow3DViewer);
|
||||||
// pcbnew-only test helper (no eeschema counterpart — name is not shared).
|
// pcbnew-only test helper (no eeschema counterpart — name is not shared).
|
||||||
function("kicadCollabTestItemBlob", &kicadCollabTestItemBlob);
|
function("kicadCollabTestItemBlob", &kicadCollabTestItemBlob);
|
||||||
// pcbnew-only ysync-review repro hooks (names not shared with eeschema).
|
// pcbnew-only ysync-review repro hooks (names not shared with eeschema).
|
||||||
|
|
|
||||||
|
|
@ -118,9 +118,11 @@ import {
|
||||||
waitForWxUi,
|
waitForWxUi,
|
||||||
} from "@/components/wasm-tool/collab-start";
|
} from "@/components/wasm-tool/collab-start";
|
||||||
import { installQuitHook } from "@/components/wasm-tool/quit-hook";
|
import { installQuitHook } from "@/components/wasm-tool/quit-hook";
|
||||||
|
import { runDeferredModelPrescan } from "@/wasm/libs/models-bridge";
|
||||||
import { installToolNavigationHook } from "@/components/wasm-tool/tool-navigation";
|
import { installToolNavigationHook } from "@/components/wasm-tool/tool-navigation";
|
||||||
import {
|
import {
|
||||||
chromeSetter,
|
chromeSetter,
|
||||||
|
show3DOpener,
|
||||||
COLLAB_TOOLS,
|
COLLAB_TOOLS,
|
||||||
INSPECTOR_OPEN_KEY,
|
INSPECTOR_OPEN_KEY,
|
||||||
LAYERS_OPEN_KEY,
|
LAYERS_OPEN_KEY,
|
||||||
|
|
@ -1065,6 +1067,8 @@ export function WasmTool({
|
||||||
slug,
|
slug,
|
||||||
files,
|
files,
|
||||||
targetPath,
|
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
|
// ydoc source with a populated room: the target file's bytes come
|
||||||
// from the doc; everything else (sibling files) still fetches.
|
// from the doc; everything else (sibling files) still fetches.
|
||||||
fetchBytes:
|
fetchBytes:
|
||||||
|
|
@ -1386,7 +1390,10 @@ export function WasmTool({
|
||||||
// DOCUMENT references — a peer editing a PLACED symbol must still
|
// DOCUMENT references — a peer editing a PLACED symbol must still
|
||||||
// reach this session live (lib-update toast); everything else syncs
|
// reach this session live (lib-update toast); everything else syncs
|
||||||
// on the next load. Fire-and-forget: boot never waits on sockets.
|
// 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 staged = readStagedFile(win, slug, targetPath);
|
||||||
const nicks = staged
|
const nicks = staged
|
||||||
? usedLibNicknames(new TextDecoder().decode(staged))
|
? usedLibNicknames(new TextDecoder().decode(staged))
|
||||||
|
|
@ -1472,6 +1479,22 @@ export function WasmTool({
|
||||||
[ready],
|
[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
|
// 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.
|
// exports the names for every frame, but they no-op on a non-PCB frame.
|
||||||
const layersMod = React.useMemo<LayersModule | null>(() => {
|
const layersMod = React.useMemo<LayersModule | null>(() => {
|
||||||
|
|
@ -1604,6 +1627,7 @@ export function WasmTool({
|
||||||
canToggleChrome={setChromeFn !== null}
|
canToggleChrome={setChromeFn !== null}
|
||||||
chromeHidden={chromeHidden}
|
chromeHidden={chromeHidden}
|
||||||
onToggleChrome={() => toggleChromeHidden()}
|
onToggleChrome={() => toggleChromeHidden()}
|
||||||
|
onShow3D={show3DFn}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Box,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Layers,
|
Layers,
|
||||||
|
|
@ -155,6 +156,7 @@ export function SessionMenu({
|
||||||
canToggleChrome,
|
canToggleChrome,
|
||||||
chromeHidden,
|
chromeHidden,
|
||||||
onToggleChrome,
|
onToggleChrome,
|
||||||
|
onShow3D,
|
||||||
}: {
|
}: {
|
||||||
tool: Tool;
|
tool: Tool;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
|
@ -184,6 +186,9 @@ export function SessionMenu({
|
||||||
canToggleChrome: boolean;
|
canToggleChrome: boolean;
|
||||||
chromeHidden: boolean;
|
chromeHidden: boolean;
|
||||||
onToggleChrome: () => void;
|
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 (
|
return (
|
||||||
<OverlayMenu
|
<OverlayMenu
|
||||||
|
|
@ -279,6 +284,17 @@ export function SessionMenu({
|
||||||
<span>{inspectorOpen ? "Hide inspector" : "Inspector"}</span>
|
<span>{inspectorOpen ? "Hide inspector" : "Inspector"}</span>
|
||||||
</button>
|
</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 && (
|
{canToggleChrome && !readOnly && (
|
||||||
<button
|
<button
|
||||||
data-testid="chrome-toggle"
|
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;
|
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
|
// Viewer panels (viewer-panels): floating layer selector + selection
|
||||||
// inspector open-state persistence, mirroring the comments panel's keys.
|
// inspector open-state persistence, mirroring the comments panel's keys.
|
||||||
export const LAYERS_OPEN_KEY = "pcbjam:layers-panel-open";
|
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 { defaultKicadPro } from "../lib/new-file";
|
||||||
import { memfsFilePath, memfsProjectDir } from "./constants";
|
import { memfsFilePath, memfsProjectDir } from "./constants";
|
||||||
import { mark } from "./load-trace";
|
import { mark } from "./load-trace";
|
||||||
import { prescanBoardModels } from "./libs/models-bridge";
|
import { deferBoardModelPrescan, prescanBoardModels } from "./libs/models-bridge";
|
||||||
import { openFileInTool } from "./open-flow";
|
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
|
* 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. */
|
* with done=0 so the line appears as soon as staging starts. */
|
||||||
onFileProgress?: (done: number, total: number) => void;
|
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));
|
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).
|
// ensure. No-op unless a model source is installed (bootKicadTool).
|
||||||
if (path.endsWith(".kicad_pcb")) {
|
if (path.endsWith(".kicad_pcb")) {
|
||||||
const text = new TextDecoder().decode(bytes);
|
const text = new TextDecoder().decode(bytes);
|
||||||
void prescanBoardModels(text).catch((e) =>
|
if (opts.deferModelPrescan) deferBoardModelPrescan(text);
|
||||||
opts.log(`[3d] prescan failed: ${String(e)}`),
|
else
|
||||||
);
|
void prescanBoardModels(text).catch((e) =>
|
||||||
|
opts.log(`[3d] prescan failed: ${String(e)}`),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import {
|
||||||
collectBoardModelFiles,
|
collectBoardModelFiles,
|
||||||
ensureModelInMemfs,
|
ensureModelInMemfs,
|
||||||
installModel3dHandler,
|
installModel3dHandler,
|
||||||
|
deferBoardModelPrescan,
|
||||||
|
runDeferredModelPrescan,
|
||||||
normalizeModelRef,
|
normalizeModelRef,
|
||||||
scanModelRefs,
|
scanModelRefs,
|
||||||
} from "./models-bridge";
|
} from "./models-bridge";
|
||||||
|
|
@ -168,3 +170,48 @@ describe("scanModelRefs", () => {
|
||||||
expect(scanModelRefs("(kicad_pcb (version 20240101))")).toEqual([]);
|
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;
|
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
|
* 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
|
* 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,
|
concurrency = 6,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!installedSource) return;
|
if (!installedSource) return;
|
||||||
|
deferredBoardText = null;
|
||||||
const refs = scanModelRefs(boardText);
|
const refs = scanModelRefs(boardText);
|
||||||
if (!refs.length) return;
|
if (!refs.length) return;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue