pcbjam/tests/kicad/occ-export-models.spec.ts
Gergő Törcsvári 720cff54ba
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
2026-07-10 09:26:07 +02:00

292 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});
});