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

@ -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