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

2
kicad

@ -1 +1 @@
Subproject commit 03b9e149c17b27d96d93d867b7be28e2a3ea4fe7
Subproject commit 83645275ac0da6d86c8526dfaa68c9e15d8d62a5

View file

@ -0,0 +1,292 @@
import * as fs from 'fs';
import * as path from 'path';
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { clickMenuBarItem, clickMenuItem, waitForEditorReady, waitUntil } from '../e2e/utils/element-tracker';
import { injectFromSubmodule } from './utils/fs-inject';
import { waitForBoardLoaded } from './utils/board-ready';
/**
* STEP export × 3D model delivery (docs/features/3d-models, 0007): File
* Export STEP must include the board's lib component models.
*
* The STEP export runs in the occ_service worker its own wasm module with
* its own MEMFS, where the editor's model files are invisible. Delivery
* (0007): the export request ships the board's prefetched lib model bodies
* (`models` array in the app collected via models-bridge from R2/IDB; here
* mirrored by the harness occ stub against the page's kicadLibs provider),
* the worker stages them under /pcbjam/3dmodels, and EXPORTER_STEP's
* staged-model probe (pcbjam_model_fetch.h FindStagedModel) resolves them on
* a resolver miss.
*
* The first test pins the preconditions (board really references lib models,
* the export chain itself works, the model provider serves any ref) so a
* failure of the second can only come from the delivery, not the harness.
*/
const KICAD_VERSION_DIR = '10.0';
const PROJECT_DIR_MEMFS = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`;
// Same JS-owned MEMFS model root as the standalone models-bridge
// (constants.ts MODELS_3D_ROOT) — where a delivery fix materializes bodies.
const MODELS_ROOT_MEMFS = '/pcbjam/3dmodels';
// Stand-in STEP bytes served for EVERY lib ref (geometry fidelity is
// irrelevant — the assertion is delivery, not looks).
const STEP_FIXTURE = 'kicad/demos/openair-max/Libraries/HRO_TYPE-C-31-M-12.step';
const DEMO = { name: 'pic_programmer', dir: 'pic_programmer', stem: 'pic_programmer' } as const;
declare global {
interface Window {
__modelEnsures?: Array<{ op: string; arg: string; kind: string }>;
__stepFixtureB64?: string;
}
}
interface ExportCapture {
name: string;
size: number;
magic: string;
report: string;
productCount: number;
}
/** Wait for a rendered popup menu to have its items (replaces a fixed post-menu-click sleep). */
async function waitForMenuItems(page: Page): Promise<void> {
await waitUntil(
page,
() => {
const r = window.wxElementRegistry;
if (!r?.findAllRendered) return false;
return r.findAllRendered({ elementType: 'menuitem' }).length > 3;
},
'popup menu items rendered',
);
}
/**
* Record every model3d bridge request and serve ALL of them from the fixture
* the delivery side is never the bottleneck in this spec (mirrors the serveAll
* stub in 3d-viewer-models.spec.ts).
*/
async function installModelProviderStub(page: Page): Promise<void> {
const fixtureAbs = path.resolve(__dirname, '..', '..', STEP_FIXTURE);
await page.evaluate(
(b64: string) => { window.__stepFixtureB64 = b64; },
fs.readFileSync(fixtureAbs).toString('base64'),
);
await page.evaluate((stockDir: string) => {
window.__modelEnsures = [];
(globalThis as any).kicadLibs = {
request: async (op: string, _lib: string, arg: string, kind: string) => {
if (kind !== 'model3d') return null;
window.__modelEnsures!.push({ op, arg, kind });
console.log(`[TEST-OCC-MODELS] ensure request: ${op} ${arg}`);
if (op !== 'ensure') return null;
const b64 = window.__stepFixtureB64!;
const binary = atob(b64);
const data = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) data[i] = binary.charCodeAt(i);
// Mirror models-bridge.ts ensureModelInMemfs: write under the
// JS-owned model root, answer with the ABSOLUTE path.
// @ts-expect-error — Emscripten FS lives on window
const FS = (window as any).FS;
const dest = `${stockDir}/${arg}`;
FS.mkdirTree(dest.slice(0, dest.lastIndexOf('/')));
FS.writeFile(dest, data);
return dest;
},
};
}, MODELS_ROOT_MEMFS);
}
async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors: string[] }): Promise<void> {
const pcbFilename = `${DEMO.stem}.kicad_pcb`;
const proFilename = `${DEMO.stem}.kicad_pro`;
await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${pcbFilename}`,
`${PROJECT_DIR_MEMFS}/${pcbFilename}`);
await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${proFilename}`,
`${PROJECT_DIR_MEMFS}/${proFilename}`);
// Project-local (${KIPRJMOD}) models — resolvable by the stock resolver in
// the EDITOR; the worker-side exporter must get them delivered too.
await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/libs/3d_shapes/textool_40.wrl`,
`${PROJECT_DIR_MEMFS}/libs/3d_shapes/textool_40.wrl`);
await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/libs/3d_shapes/adjustable_rx2v4.wrl`,
`${PROJECT_DIR_MEMFS}/libs/3d_shapes/adjustable_rx2v4.wrl`);
expect(await clickMenuBarItem(page, 'File'), 'File menu should be findable').toBe(true);
await waitForMenuItems(page);
expect(await clickMenuItem(page, 'Open...'), 'Open… menu item should be findable').toBe(true);
await page.waitForFunction(() => {
const registry = window.wxElementRegistry;
return !!registry && registry.findAll({ visible: true })
.some((el) => el.typeName === 'wxFileDialog');
}, null, { timeout: 15000 });
// Wait for the filename text input to paint (the dialog object exists before its
// inner controls register; replaces a fixed 1000ms).
await waitUntil(page, () => {
const r = window.wxElementRegistry;
return !!r && r.findAll({ visible: true }).some((el) => el.typeName === 'wxTextCtrl' && el.name === 'text');
}, 'file dialog filename input');
const filenameInput = await page.evaluate(() => {
const registry = window.wxElementRegistry;
if (!registry) return null;
const text = registry.findAll({ visible: true })
.find((el) => el.typeName === 'wxTextCtrl' && el.name === 'text');
return text ? { x: text.centerX, y: text.centerY } : null;
});
expect(filenameInput, 'filename text input should be visible').not.toBeNull();
if (!filenameInput) throw new Error('filename text input not found');
await page.mouse.click(filenameInput.x, filenameInput.y);
// Documented interaction dwells: focus + typed-text registration have no observable signal.
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
await page.keyboard.type(pcbFilename);
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.keyboard.press('Enter');
const result = await waitForBoardLoaded(page, testLogger, 60000);
console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`);
}
/** Click a visible wx button by label; returns whether it was found. */
async function clickWxButton(page: Page, label: string): Promise<boolean> {
const pos = await page.evaluate((wanted: string) => {
const registry = window.wxElementRegistry;
if (!registry) return null;
const el = registry.findAll({ visible: true })
.find((e) => (e.label === wanted || e.label === `&${wanted}`)
&& (e.typeName ?? '').includes('Button'));
return el ? { x: el.centerX, y: el.centerY } : null;
}, label);
if (!pos) return false;
await page.mouse.click(pos.x, pos.y);
return true;
}
/** Drive File → Export → STEP through the (unchanged) dialog; return the capture. */
async function runStepExport(page: Page): Promise<{ exp: ExportCapture; ensures: Array<{ op: string; arg: string }> }> {
expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true);
await waitForMenuItems(page);
expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true);
await waitForMenuItems(page);
expect(await clickMenuItem(page, 'STEP/GLB/BREP/XAO/PLY/STL...'),
'STEP export menu item').toBe(true);
await page.waitForFunction(() => {
const registry = window.wxElementRegistry;
return !!registry && registry.findAll({ visible: true })
.some((el) => (el.label === 'Export' || el.label === '&Export')
&& (el.typeName ?? '').includes('Button'));
}, null, { timeout: 20000 });
expect(await clickWxButton(page, 'Export'), 'Export button click').toBe(true);
await page.waitForFunction(
() => ((window as any).__occExports?.length ?? 0) > 0,
null, { timeout: 180000 });
const exports = await page.evaluate(() => (window as any).__occExports as ExportCapture[]);
expect(exports, 'exactly one export captured').toHaveLength(1);
const ensures = await page.evaluate(() => window.__modelEnsures ?? []);
return { exp: exports[0], ensures };
}
/** Parse the exporter report's missing-model warnings into lib / project refs. */
function missingModels(report: string): { lib: string[]; project: string[]; other: string[] } {
const out = { lib: [] as string[], project: [] as string[], other: [] as string[] };
const re = /Could not add 3D model for [^\n]+\n\s*File not found: ([^\n]+)/g;
for (let m = re.exec(report); m; m = re.exec(report)) {
const file = m[1].trim();
if (file.includes('.3dshapes')) out.lib.push(file);
else if (file.includes('KIPRJMOD') || file.includes('3d_shapes')) out.project.push(file);
else out.other.push(file);
}
return out;
}
test.describe('STEP export × 3D model delivery', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(240000);
// GREEN COMPANION — pins every precondition of the red repro below:
// the board really references lib models, the export chain works end to
// end, and the report channel carries the exporter's warnings.
test('export chain works and the board references lib models', async ({ page, testLogger }) => {
// Precondition: the demo board references ${KICAD*_3DMODEL_DIR} lib
// models AND ${KIPRJMOD} project models (counted from the source file,
// so a demo change can't silently hollow out the repro).
const pcbText = fs.readFileSync(
path.resolve(__dirname, '..', '..', `kicad/demos/${DEMO.dir}/${DEMO.stem}.kicad_pcb`), 'utf8');
const libRefs = pcbText.match(/\(model "\$\{KICAD[^"]*\.3dshapes\/[^"]+"/g) ?? [];
const prjRefs = pcbText.match(/\(model "\$\{KIPRJMOD\}[^"]+"/g) ?? [];
console.log(`[TEST] board model refs: ${libRefs.length} lib, ${prjRefs.length} project`);
expect(libRefs.length, 'board must reference lib 3D models').toBeGreaterThan(0);
expect(prjRefs.length, 'board must reference project-local 3D models').toBeGreaterThan(0);
await page.goto('/kicad/pcbnew.html');
await waitForEditorReady(page);
await installModelProviderStub(page);
await loadBoard(page, testLogger);
const { exp, ensures } = await runStepExport(page);
// The chain itself is healthy: a real STEP came back with a report.
expect(exp.name, 'download name from the dialog').toMatch(/\.step$/i);
expect(exp.magic.startsWith('ISO-10303-21'), 'STEP magic').toBe(true);
expect(exp.size, 'non-trivial STEP body').toBeGreaterThan(10_000);
expect(exp.productCount, 'PRODUCT entities parsed from the body').toBeGreaterThan(0);
// Diagnostics for the red test's failure readout.
const missing = missingModels(exp.report);
console.log(`[TEST] export report: ${missing.lib.length} lib + ${missing.project.length} project`
+ ` + ${missing.other.length} other missing models;`
+ ` products=${exp.productCount}, size=${exp.size}B,`
+ ` model3d ensure requests during export: ${ensures.length}`);
for (const f of missing.lib.slice(0, 5)) console.log(`[TEST] missing lib: ${f}`);
for (const f of missing.project) console.log(`[TEST] missing project: ${f}`);
// Dismiss the export report dialog (its appearance after the worker
// returns has no distinct registry signal to poll).
await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell
await clickWxButton(page, 'OK');
expect(testLogger.errors, 'no page errors during the export flow').toEqual([]);
});
// The delivery guard (docs/features/3d-models/0007). Assertions are
// OUTCOME-level (report + geometry), not tied to a delivery mechanism.
//
// Scope: LIB (`.3dshapes`) models — the R2/IDB-delivered kind. The two
// project-local (${KIPRJMOD}) refs are still dropped (logged by the
// companion above); asserting their delivery is the 0007 step-4
// fast-follow.
test('exported STEP includes the board lib component models', async ({ page, testLogger }) => {
await page.goto('/kicad/pcbnew.html');
await waitForEditorReady(page);
await installModelProviderStub(page);
await loadBoard(page, testLogger);
const { exp, ensures } = await runStepExport(page);
const missing = missingModels(exp.report);
console.log(`[TEST] model3d ensure requests during export: ${ensures.length}`);
// Every servable lib ref was delivered: none may be dropped from the
// assembly with a "File not found" report warning.
expect(missing.lib, 'no lib model may be missing from the export').toEqual([]);
// The prefetch really crossed the model bridge (the stub serves via
// kicadLibs, mirroring the app's models-bridge source).
expect(ensures.length, 'lib bodies were ensured for the export').toBeGreaterThan(0);
// The assembly carries per-component geometry: many PRODUCT entities,
// not just the bare board's 2.
expect(exp.productCount, 'exported STEP contains component products').toBeGreaterThan(5);
});
});

View file

@ -17,7 +17,15 @@ import type { Page } from '@playwright/test';
*
* Differences from the app provider, for assertability:
* - export results are captured into window.__occExports (name, size, magic
* prefix) instead of triggering a browser download;
* prefix, the exporter's report text, and a PRODUCT-entity count for STEP
* bodies the per-component geometry signal) instead of triggering a
* browser download;
* - the app's export model prefetch (occ-service.ts models-bridge
* collectBoardModelFiles) is mirrored against the page's `kicadLibs`
* provider: lib refs scanned from the board text are ensured (kind
* "model3d"), read back from the editor MEMFS, and shipped as the
* request's `models` array. Specs without a kicadLibs stub ship none
* the pre-delivery behavior.
* - installed as an init script (kicad fixtures do this for every page), so
* it exists from document start on every navigation standalone parity,
* where boot.ts installs the provider whenever the editor bundle boots.
@ -69,7 +77,36 @@ export async function installOccServiceStub(page: Page): Promise<void> {
return workerP;
};
// Mirror of the app's collectBoardModelFiles, against the page's
// kicadLibs provider (the specs' model stub): scan lib refs, ensure
// each into the editor MEMFS, read the staged bytes back.
const collectModels = async (boardText: string) => {
const hook = (globalThis as any).kicadLibs;
const FS = (window as any).FS;
if (!hook?.request || !FS) return [];
const ROOT = '/pcbjam/3dmodels';
const refs = new Set<string>();
const re = /\(\s*model\s+"((?:[^"\\]|\\.)*)"/g;
for (let m = re.exec(boardText); m; m = re.exec(boardText)) {
const raw = m[1].replace(/\\(.)/g, '$1');
const lib = raw.match(/^\$[{(](?:[^})]*3DMODEL_DIR|KISYS3DMOD)[})][/\\]+(.+)$/);
if (lib) refs.add(lib[1]);
}
const models: Array<{ path: string; bytes: Uint8Array }> = [];
const seen = new Set<string>();
for (const ref of refs) {
const abs = await hook.request('ensure', '', ref, 'model3d');
if (typeof abs !== 'string' || !abs.startsWith(`${ROOT}/`) || seen.has(abs)) continue;
seen.add(abs);
models.push({ path: abs.slice(ROOT.length + 1), bytes: FS.readFile(abs) });
}
console.log(`[TEST-OCC] shipping ${models.length} board model(s) with the export`);
return models;
};
const request = async (req: any) => {
if (req.kind === 'export')
req.models = await collectModels(new TextDecoder().decode(req.board));
let worker: Worker;
try {
worker = await ensureWorker();
@ -77,7 +114,9 @@ export async function installOccServiceStub(page: Page): Promise<void> {
return { ok: false, report: `occ_service unavailable: ${e}` };
}
const id = nextId++;
const transfer = req.kind === 'export' ? [req.board.buffer] : [req.bytes.buffer];
const transfer = req.kind === 'export'
? [req.board.buffer, ...(req.models ?? []).map((m: any) => m.bytes.buffer)]
: [req.bytes.buffer];
const res: any = await new Promise((resolve) => {
pending.set(id, resolve);
worker.postMessage({ id, req }, transfer);
@ -85,12 +124,23 @@ export async function installOccServiceStub(page: Page): Promise<void> {
if (req.kind === 'export') {
if (res.ok && res.bytes?.length) {
const magic = new TextDecoder().decode(res.bytes.slice(0, 16));
// STEP is a text format: `#n=PRODUCT('name',…)` entities count the
// distinct model bodies in the assembly (a bare board exports 12;
// component models add one each). The anchored `=PRODUCT(` match
// excludes PRODUCT_DEFINITION/PRODUCT_CONTEXT relatives.
let productCount = -1;
if (magic.startsWith('ISO-10303-21')) {
const text = new TextDecoder().decode(res.bytes);
productCount = (text.match(/=\s*PRODUCT\s*\(/g) ?? []).length;
}
(window as any).__occExports.push({
name: req.fileName || res.fileName,
size: res.bytes.length,
magic,
report: String(res.report ?? ''),
productCount,
});
console.log(`[TEST-OCC] export captured: ${req.fileName} ${res.bytes.length}B "${magic}"`);
console.log(`[TEST-OCC] export captured: ${req.fileName} ${res.bytes.length}B "${magic}" products=${productCount}`);
}
return { ok: res.ok, report: res.report, fileName: res.fileName };
}

View file

@ -7,10 +7,15 @@
* RPC; this module never suspends, so it builds -sASYNCIFY=0.
*
* Two embind entry points, both batch/synchronous:
* occExport(boardSexpr, paramsJson) -> { ok, report, fileName, bytes }
* occExport(boardSexpr, paramsJson, models) -> { ok, report, fileName, bytes }
* Parse the board text (KICAD_SEXP), map the official JOB_EXPORT_PCB_3D
* JSON onto EXPORTER_STEP_PARAMS (the pcbnew_jobs_handler mapping) and
* run EXPORTER_STEP STEP/STEPZ/BREP/XAO/GLB/PLY/STL out.
* run EXPORTER_STEP STEP/STEPZ/BREP/XAO/GLB/PLY/STL out. `models` is
* an array of { path, bytes } lib model bodies the host prefetched for
* this board (R2/IDB via the editor's models-bridge); they are staged
* under the shared MEMFS model root so the exporter's staged-model
* probe (pcbjam_model_fetch.h FindStagedModel) resolves them, and
* removed again after the export.
* occLoadModel(bytes, ext) -> { ok, report, bytes }
* Feed a STEP/IGES model to the (statically linked) oce plugin loader
* and return the resulting SCENEGRAPH serialized with S3D::WriteCache
@ -48,6 +53,7 @@
#include <pcb_io/pcb_io_mgr.h>
#include <exporters/step/exporter_step.h>
#include <3d_cache/pcbjam_model_fetch.h> // PCBJAM_3D::MODELS_MEMFS_ROOT
#include <plugins/3dapi/ifsg_api.h>
class SCENEGRAPH;
@ -154,7 +160,70 @@ BOARD* loadBoardFromSexpr( const std::string& aSexpr, wxString* aErr )
}
emscripten::val occExport( std::string aBoardSexpr, std::string aParamsJson )
// Stage the host-prefetched lib model bodies under the shared MEMFS model
// root (PCBJAM_3D::MODELS_MEMFS_ROOT) where EXPORTER_STEP's staged-model
// probe looks. Paths arrive as lib-relative refs ("<lib>.3dshapes/<n>.<ext>");
// anything absolute or traversing is skipped defensively. Returns the staged
// absolute paths so the caller can remove them after the export.
std::vector<wxString> stageModelFiles( const emscripten::val& aModels )
{
std::vector<wxString> staged;
if( aModels.isNull() || aModels.isUndefined() || !aModels["length"].as<bool>() )
return staged;
const size_t count = aModels["length"].as<size_t>();
for( size_t i = 0; i < count; ++i )
{
emscripten::val entry = aModels[i];
if( entry.isNull() || entry.isUndefined() )
continue;
const std::string rel = entry["path"].as<std::string>();
emscripten::val bytes = entry["bytes"];
if( rel.empty() || rel.front() == '/' || rel.find( ".." ) != std::string::npos
|| bytes.isNull() || bytes.isUndefined() )
{
std::fprintf( stderr, "[occ_service] models: skipping bad entry '%s'\n",
rel.c_str() );
continue;
}
const size_t len = bytes["byteLength"].as<size_t>();
std::vector<uint8_t> buf( len );
emscripten::val view =
emscripten::val( emscripten::typed_memory_view( len, buf.data() ) );
view.call<void>( "set", bytes );
const wxString path = wxString::FromUTF8(
std::string( PCBJAM_3D::MODELS_MEMFS_ROOT ) + "/" + rel );
if( !wxFileName( path ).Mkdir( wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL ) )
{
std::fprintf( stderr, "[occ_service] models: mkdir failed for '%s'\n",
rel.c_str() );
continue;
}
if( writeFile( path, buf.data(), buf.size() ) )
staged.push_back( path );
else
std::fprintf( stderr, "[occ_service] models: write failed for '%s'\n",
rel.c_str() );
}
std::fprintf( stderr, "[occ_service] models: staged %zu/%zu\n", staged.size(),
count );
return staged;
}
emscripten::val occExport( std::string aBoardSexpr, std::string aParamsJson,
emscripten::val aModels )
{
emscripten::val ret = emscripten::val::object();
ret.set( "ok", false );
@ -208,6 +277,10 @@ emscripten::val occExport( std::string aBoardSexpr, std::string aParamsJson )
return ret;
}
// Host-prefetched lib model bodies → the shared MEMFS model root, where
// the exporter's staged-model probe resolves resolver-miss refs.
const std::vector<wxString> stagedModels = stageModelFiles( aModels );
std::fprintf( stderr, "[occ_service] export: board parsed, running EXPORTER_STEP (%s)\n",
params.GetFormatName().utf8_string().c_str() );
@ -248,6 +321,12 @@ emscripten::val occExport( std::string aBoardSexpr, std::string aParamsJson )
wxRemoveFile( exporter.m_outputFile );
wxRemoveFile( wxString::FromUTF8( TMP_BOARD ) );
// Staged bodies are per-request (the host re-ships from its IDB/MEMFS
// cache); don't let them accumulate in the worker across exports.
for( const wxString& staged : stagedModels )
wxRemoveFile( staged );
delete brd;
std::fprintf( stderr, "[occ_service] export %s (%zu bytes)\n",

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 {