diff --git a/kicad b/kicad index 03b9e14..8364527 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit 03b9e149c17b27d96d93d867b7be28e2a3ea4fe7 +Subproject commit 83645275ac0da6d86c8526dfaa68c9e15d8d62a5 diff --git a/tests/kicad/occ-export-models.spec.ts b/tests/kicad/occ-export-models.spec.ts new file mode 100644 index 0000000..3ddb100 --- /dev/null +++ b/tests/kicad/occ-export-models.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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); + }); +}); diff --git a/tests/kicad/utils/occ-service.ts b/tests/kicad/utils/occ-service.ts index 744cedd..2686e0d 100644 --- a/tests/kicad/utils/occ-service.ts +++ b/tests/kicad/utils/occ-service.ts @@ -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 { 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(); + 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(); + 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 { 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 { 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 1–2; + // 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 }; } diff --git a/wasm/occ-service/occ_service_main.cpp b/wasm/occ-service/occ_service_main.cpp index 5e95abb..e6d7452 100644 --- a/wasm/occ-service/occ_service_main.cpp +++ b/wasm/occ-service/occ_service_main.cpp @@ -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 #include +#include <3d_cache/pcbjam_model_fetch.h> // PCBJAM_3D::MODELS_MEMFS_ROOT #include 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 (".3dshapes/."); +// anything absolute or traversing is skipped defensively. Returns the staged +// absolute paths so the caller can remove them after the export. +std::vector stageModelFiles( const emscripten::val& aModels ) +{ + std::vector staged; + + if( aModels.isNull() || aModels.isUndefined() || !aModels["length"].as() ) + return staged; + + const size_t count = aModels["length"].as(); + + 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(); + 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(); + std::vector buf( len ); + + emscripten::val view = + emscripten::val( emscripten::typed_memory_view( len, buf.data() ) ); + view.call( "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 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", diff --git a/web/standalone/src/wasm/libs/models-bridge.test.ts b/web/standalone/src/wasm/libs/models-bridge.test.ts index fdd2251..3fc356a 100644 --- a/web/standalone/src/wasm/libs/models-bridge.test.ts +++ b/web/standalone/src/wasm/libs/models-bridge.test.ts @@ -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(); + 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 = ` diff --git a/web/standalone/src/wasm/libs/models-bridge.ts b/web/standalone/src/wasm/libs/models-bridge.ts index 81deb7e..a0ad55e 100644 --- a/web/standalone/src/wasm/libs/models-bridge.ts +++ b/web/standalone/src/wasm/libs/models-bridge.ts @@ -66,7 +66,10 @@ export function scanModelRefs(sexprText: string): string[] { return [...refs]; } -type ModelFS = Pick; +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 (".3dshapes/.", 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 { + const fs = toolFS(); + if (!installedSource || !fs) return []; + const refs = scanModelRefs(boardText); + if (!refs.length) return []; + + const out: BoardModelFile[] = []; + const seen = new Set(); + let idx = 0; + const worker = async (): Promise => { + 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 diff --git a/web/standalone/src/wasm/occ-service.ts b/web/standalone/src/wasm/occ-service.ts index 2d627e9..905ca51 100644 --- a/web/standalone/src/wasm/occ-service.ts +++ b/web/standalone/src/wasm/occ-service.ts @@ -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 => { 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((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 => { + 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(); diff --git a/web/standalone/src/wasm/occ-worker.js b/web/standalone/src/wasm/occ-worker.js index 2e541b4..1bbc752 100644 --- a/web/standalone/src/wasm/occ-worker.js +++ b/web/standalone/src/wasm/occ-worker.js @@ -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 {