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:
parent
9315f56760
commit
720cff54ba
8 changed files with 557 additions and 10 deletions
|
|
@ -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 = `
|
||||
|
|
|
|||
|
|
@ -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, wrl→step 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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue