feat(occ): ship board 3D model bodies with STEP/GLB exports (3d-models 0007)

The occ_service export worker has its own MEMFS — the editor's lazily-fetched
lib models were invisible there, so every export was a bare board (54
"Could not add 3D model" warnings on pic_programmer, 2 STEP products).

- models-bridge: collectBoardModelFiles(boardText) — scan refs, ensure via
  the 0004 sparse source (IDB/R2, wrl->step fallback), read staged bytes
  back, dedupe by real staged path.
- occ-service.ts: attach the collected models to every export request
  (best-effort — prefetch failure still exports, misses reported by the
  exporter); transfer the body buffers.
- occ-worker.js (shared app/harness): pass req.models through to occExport.
- occ_service_main.cpp: occExport(board, params, models) stages each entry
  under PCBJAM_3D::MODELS_MEMFS_ROOT (path-sanitized) for the exporter's
  staged-model probe (kicad 83645275ac), removed again after the export.
- tests: harness occ stub mirrors the prefetch against the page kicadLibs
  provider + captures report/productCount; new occ-export-models.spec.ts
  guards the delivery (green companion pins preconditions; guard asserts 0
  missing lib models + component PRODUCTs). pic_programmer: 17/17 staged,
  87 products @ 13.3 MB (was 2 @ 402 KB). models-bridge unit tests 13/13.

Known remainder (0007 step 4): project-local ${KIPRJMOD} refs still drop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017UjpnviP3ZDxTM1Ap63Sqv
This commit is contained in:
Gergő Törcsvári 2026-07-10 08:44:55 +02:00
commit 720cff54ba
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 557 additions and 10 deletions

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
collectBoardModelFiles,
ensureModelInMemfs,
installModel3dHandler,
normalizeModelRef,
@ -95,6 +96,50 @@ describe("ensureModelInMemfs format fallback", () => {
});
});
describe("collectBoardModelFiles", () => {
function installFakes(available: (ref: string) => boolean) {
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) }),
readFile: (p: string) => files.get(p),
};
(globalThis as unknown as { window: unknown }).window ??= globalThis;
(globalThis as unknown as { FS: unknown }).FS = fs;
const source: Model3dSource = {
getModelBody: async (ref) =>
available(ref) ? new TextEncoder().encode(`body:${ref}`) : null,
hasModel: async (ref) => available(ref),
};
installModel3dHandler(source, () => {});
}
it("collects staged bodies under their REAL extension, deduped, misses skipped", async () => {
installFakes((r) => r.startsWith("ExportLibA") && r.endsWith(".step"));
const board = `
(model "\${KICAD10_3DMODEL_DIR}/ExportLibA.3dshapes/M1.wrl")
(model "\${KICAD10_3DMODEL_DIR}/ExportLibA.3dshapes/M1.step")
(model "\${KICAD10_3DMODEL_DIR}/ExportLibB.3dshapes/GONE.wrl")
(model "\${KIPRJMOD}/libs/3d_shapes/prj.wrl")
`;
// M1.wrl is served by the .step sibling; the M1.step ref materializes to
// the SAME file → one entry. The unservable ref is skipped; the
// project-local ref never enters the scan.
const models = await collectBoardModelFiles(board);
expect(models).toHaveLength(1);
expect(models[0]!.path).toBe("ExportLibA.3dshapes/M1.step");
expect(new TextDecoder().decode(models[0]!.bytes)).toBe(
"body:ExportLibA.3dshapes/M1.step",
);
});
it("returns empty for a board without lib model refs", async () => {
installFakes(() => true);
expect(await collectBoardModelFiles("(kicad_pcb (version 1))")).toEqual([]);
});
});
describe("scanModelRefs", () => {
it("finds, normalizes and dedupes board model refs", () => {
const board = `

View file

@ -66,7 +66,10 @@ export function scanModelRefs(sexprText: string): string[] {
return [...refs];
}
type ModelFS = Pick<EmscriptenFS, "mkdirTree" | "writeFile" | "analyzePath">;
type ModelFS = Pick<
EmscriptenFS,
"mkdirTree" | "writeFile" | "analyzePath" | "readFile"
>;
function toolFS(): ModelFS | null {
const fs = (window as ToolWindow).FS;
@ -184,6 +187,59 @@ export async function handleModel3dRequest(
}
}
/** One board model body ready to ship to the occ_service export worker. */
export interface BoardModelFile {
/** Lib-relative staged path ("<lib>.3dshapes/<name>.<ext>", REAL extension). */
path: string;
bytes: Uint8Array;
}
/**
* Prefetch + read back every lib model a board references, for shipping with
* an occ_service export request the worker is its own wasm module with its
* own MEMFS, so the editor-side files are invisible there. Reuses
* ensureModelInMemfs (IDB/R2-cached, coalesced, wrlstep format fallback);
* the returned paths carry the staged file's REAL extension, deduplicated
* (two refs can materialize to the same substituted body). Best-effort: a
* ref the source can't serve is skipped (the exporter reports it missing).
* Returns [] when 3D model delivery is not configured.
*/
export async function collectBoardModelFiles(
boardText: string,
concurrency = 6,
): Promise<BoardModelFile[]> {
const fs = toolFS();
if (!installedSource || !fs) return [];
const refs = scanModelRefs(boardText);
if (!refs.length) return [];
const out: BoardModelFile[] = [];
const seen = new Set<string>();
let idx = 0;
const worker = async (): Promise<void> => {
while (idx < refs.length) {
const ref = refs[idx++]!;
try {
const abs = await ensureModelInMemfs(ref);
if (!abs || seen.has(abs)) continue;
seen.add(abs);
// FS.readFile copies out of the wasm heap — the buffer is safely
// transferable to the worker.
const bytes = fs.readFile(abs) as Uint8Array;
out.push({ path: abs.slice(MODELS_3D_ROOT.length + 1), bytes });
} catch {
// best-effort: a missing body surfaces as the exporter's own
// "Could not add 3D model" report warning, never a failed export
}
}
};
await Promise.all(
Array.from({ length: Math.min(concurrency, refs.length) }, () => worker()),
);
installedLog(`[3d] export prefetch: ${out.length}/${refs.length} board model(s)`);
return out;
}
/**
* 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

View file

@ -1,4 +1,5 @@
import { downloadBytes } from "@/lib/download";
import { collectBoardModelFiles, type BoardModelFile } from "./libs/models-bridge";
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
// also injected by the e2e harness stub (tests/kicad/utils/occ-service.ts).
import occWorkerSource from "./occ-worker.js?raw";
@ -30,6 +31,9 @@ interface OccExportRequest {
board: Uint8Array;
jobJson: string;
fileName: string;
/** Board lib model bodies, prefetched here (R2/IDB) and staged worker-side
* under its MEMFS model root the export worker has no delivery of its own. */
models?: BoardModelFile[];
}
interface OccLoadModelRequest {
@ -121,7 +125,9 @@ export function installOccService(log: (msg: string) => void): void {
const post = (worker: Worker, req: OccRequest): Promise<OccResponse> => {
const id = nextId++;
const transfer: Transferable[] =
req.kind === "export" ? [req.board.buffer] : [req.bytes.buffer];
req.kind === "export"
? [req.board.buffer, ...(req.models ?? []).map((m) => m.bytes.buffer)]
: [req.bytes.buffer];
return new Promise<OccResponse>((resolve) => {
pending.set(id, resolve);
worker.postMessage({ id, req }, transfer);
@ -129,6 +135,23 @@ export function installOccService(log: (msg: string) => void): void {
};
const request = async (req: OccRequest): Promise<OccResponse> => {
if (req.kind === "export") {
// Ship the board's lib model bodies with the request: the worker's
// EXPORTER_STEP resolves them from its own MEMFS (delivery gap doc:
// docs/features/3d-models/0007). Best-effort — an export without
// models still succeeds, each miss reported by the exporter.
try {
req.models = await collectBoardModelFiles(
new TextDecoder().decode(req.board),
);
if (req.models.length)
log(`[occ] shipping ${req.models.length} board model(s) with the export`);
} catch (e) {
log(`[occ] model prefetch failed (exporting without models): ${e}`);
req.models = [];
}
}
let worker: Worker;
try {
worker = await ensureWorker();

View file

@ -49,7 +49,9 @@ onmessage = async (e) => {
const mod = await modP;
if (req.kind === "export") {
const board = new TextDecoder().decode(req.board);
res = mod.occExport(board, req.jobJson);
// models: host-prefetched [{ path, bytes }] lib model bodies, staged by
// the module under its MEMFS model root for the exporter's probe.
res = mod.occExport(board, req.jobJson, req.models ?? []);
} else if (req.kind === "loadModel") {
res = mod.occLoadModel(req.bytes, req.ext);
} else {