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

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