feat(wasm): occ-split — lazy occ_service worker; kicad_editor drops OCC (−31%)
Move OpenCASCADE out of the merged editor image into occ_service: a separate
emscripten module (-sASYNCIFY=0, MODULARIZE, in-container -Oz finalize, 2N+8
pre-warmed pthread pool) booted lazily in a dedicated Web Worker on the first
STEP export or STEP/IGES model parse. kicad_editor.wasm ~190 MB -> 130 MB;
sessions that never touch OCC never fetch its 57 MB. STEP export works in the
browser for the first time: the unchanged desktop dialog runs EXPORTER_STEP,
whose wasm shadow suspends into globalThis.occService and the export bytes go
straight to a browser download (never entering the editor heap). STEP/IGES 3D
models parse in the worker via the oce shadow (S3D WriteCache/ReadCache wire).
- wasm/occ-service/: service CMake target (hooked from the kicad fork's
top-level CMakeLists, wasm/editor pattern), embind entry
(occExport/occLoadModel), wxConfig pre-js.
- wasm/stubs/{exporter_step,oce_plugin}_stub.cpp: EM_ASYNC_JS worker bridges
(callee-shadowing; no caller #ifdefs).
- web/standalone: provider installed whenever the kicad_editor bundle boots
(cross-face safe); ONE shared worker-boot source occ-worker.js (vite ?raw;
the e2e stub reads the same file) — blob worker with locateFile absolutized
against the glue URL; export download-name guard.
- deps: OCC builds with RapidJSON so its glTF/GLB writer exists — pinned to
the vcpkg master snapshot 2025-02-26 (24b5e7a8b27f), the same code official
KiCad consumes via vcpkg.json's opencascade[rapidjson]; rapidjson's latest
tag (v1.1.0, 2016) is ill-formed under modern clang.
- tests: occ-export dialog e2e (lazy-fetch boundary + STEP download bytes),
occ-probe incl. a 9-format matrix (step/stpz/brep/xao/ply/stl/glb/u3d/pdf),
3d-viewer-models hard-asserts the worker parse; occ provider stub installed
ambiently by the kicad fixtures.
Validated against desktop kicad-cli 10.0.4: geometric exact equality (bbox
delta 0 um, volume delta 0.0000%) for STEP/GLB/STL/BREP/STPZ across three
boards and option sweeps — with desktop OCC 7.9 vs wasm OCC 7.8; PLY/XAO/PDF
structurally equal; U3D same-size (quantizer float LSBs differ). Full kicad
e2e green on Firefox and Chromium; standalone verified end to end (lazy fetch
only on the Export click; export.step 60,628 B ISO-10303-21; loadModel 700 KB
STEP -> 569 KB scenegraph cache).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
96abe5d4a5
commit
db9d6ee04b
28 changed files with 2296 additions and 23 deletions
71
wasm/occ-service/CMakeLists.txt
Normal file
71
wasm/occ-service/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# occ_service — standalone OpenCASCADE 3D service for the browser build
|
||||
# (docs/features/occ-split/): STEP/3D export and STEP/IGES model tessellation
|
||||
# as a separate emscripten module run in a Web Worker, so the editor drops its
|
||||
# OCC link entirely.
|
||||
#
|
||||
# Added from the kicad fork's top-level CMakeLists.txt via
|
||||
# add_subdirectory( ${KICAD_WASM_LAYER}/occ-service ) when
|
||||
# EMSCRIPTEN AND KICAD_OCC_SERVICE_WASM — after pcbnew/, so the kiface library
|
||||
# list and the OCC exporter source list (both exported CACHE INTERNAL there)
|
||||
# exist here. Same pattern as the merged editor (wasm/editor/CMakeLists.txt).
|
||||
# Only the occ_service build tree configures with the option ON; its binary
|
||||
# dir doubles as the artifact subdir (build-wasm/kicad-occ_service/occ_service/).
|
||||
|
||||
# Mirror the pcbnew directory scope these sources were written for: the OCC
|
||||
# exporters and the service main are pcbnew-kiface code (board classes, 3d
|
||||
# resolver, exporter headers). OCC headers arrive via the top-level
|
||||
# include_directories( SYSTEM ${OCC_INCLUDE_DIR} ); nlohmann_json/gzip-hpp via
|
||||
# their INTERFACE targets.
|
||||
include_directories( BEFORE ${INC_BEFORE} )
|
||||
include_directories(
|
||||
${CMAKE_SOURCE_DIR}/pcbnew
|
||||
${CMAKE_SOURCE_DIR}/pcbnew/dialogs
|
||||
${CMAKE_SOURCE_DIR}/pcbnew/exporters
|
||||
${CMAKE_SOURCE_DIR}/pcbnew/specctra_import_export
|
||||
${CMAKE_SOURCE_DIR}/3d-viewer
|
||||
${CMAKE_SOURCE_DIR}/common
|
||||
${CMAKE_SOURCE_DIR}/common/dialogs
|
||||
${CMAKE_BINARY_DIR}/pcbnew
|
||||
${INC_AFTER}
|
||||
)
|
||||
|
||||
# The exporter sources expect the pcbnew compile environment.
|
||||
add_compile_definitions( PCBNEW )
|
||||
|
||||
# The OCC exporter sources are gated out of the kiface objects for EMSCRIPTEN
|
||||
# (pcbnew/CMakeLists.txt) — the service compiles the real ones itself.
|
||||
add_executable( occ_service
|
||||
occ_service_main.cpp
|
||||
${PCBNEW_OCC_EXPORTER_SRCS} )
|
||||
|
||||
target_link_libraries( occ_service
|
||||
PRIVATE
|
||||
${PCBNEW_KIFACE_LIBRARIES}
|
||||
nlohmann_json
|
||||
gzip-hpp # step_pcb_model.cpp (.stpZ/.gz) — kiface-PRIVATE, so restate
|
||||
)
|
||||
# Match the editors' WASM link shape: allow benign duplicate symbols, and
|
||||
# --whole-archive pcbcommon so RTTI-only-referenced vtables/typeinfo are
|
||||
# pulled in.
|
||||
target_link_options( occ_service PRIVATE
|
||||
"LINKER:--allow-multiple-definition"
|
||||
"LINKER:--whole-archive"
|
||||
"$<TARGET_FILE:pcbcommon>"
|
||||
"LINKER:--no-whole-archive"
|
||||
)
|
||||
|
||||
# Persistent worker embind module: no asyncify (neither OCC job suspends).
|
||||
# -Oz at link runs wasm-opt -Oz = whole-module dead-code elimination that
|
||||
# strips the unreachable editor code (same mechanism sym_convert documents
|
||||
# in eeschema/CMakeLists.txt); -g0 drops debug info so in-container
|
||||
# finalize doesn't OOM. MODULARIZE factory booted by the JS provider inside
|
||||
# a dedicated Worker; node kept in ENVIRONMENT so unit tests can drive it.
|
||||
# These override the browser-oriented values inherited from
|
||||
# CMAKE_EXE_LINKER_FLAGS (notably -sASYNCIFY=1). The inherited FULL pthread
|
||||
# pool ('navigator.hardwareConcurrency') is kept deliberately: the exporter
|
||||
# uses GetKiCadThreadPool (step_pcb_model.cpp), and a browser cannot spawn
|
||||
# workers on demand while the calling thread is blocked inside occExport —
|
||||
# an undersized pool deadlocks in Chromium.
|
||||
# --pre-js supplies the wxConfig JS hooks backed by an in-memory store.
|
||||
set_target_properties( occ_service PROPERTIES
|
||||
LINK_FLAGS "-Oz -g0 -sASYNCIFY=0 -sMODULARIZE=1 -sEXPORT_NAME=OccService -sENVIRONMENT=worker,node -sEXIT_RUNTIME=0 --pre-js ${CMAKE_CURRENT_SOURCE_DIR}/occ_service_pre.js" )
|
||||
391
wasm/occ-service/occ_service_main.cpp
Normal file
391
wasm/occ-service/occ_service_main.cpp
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
/*
|
||||
* occ_service — the OpenCASCADE 3D service for the browser build.
|
||||
*
|
||||
* A standalone WebAssembly module (own emscripten instance + memory) meant to
|
||||
* run in a dedicated Web Worker, so the editor can drop its OCC link entirely
|
||||
* (docs/features/occ-split/README.md). pcbnew reaches it through a postMessage
|
||||
* RPC; this module never suspends, so it builds -sASYNCIFY=0.
|
||||
*
|
||||
* Two embind entry points, both batch/synchronous:
|
||||
* occExport(boardSexpr, paramsJson) -> { 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.
|
||||
* 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 —
|
||||
* pcbnew's import shadow rebuilds it with S3D::ReadCache.
|
||||
*
|
||||
* main() runs once at module boot (default INVOKE_RUN; -sEXIT_RUNTIME=0 keeps
|
||||
* the runtime alive afterwards) and brings up wxBase + a minimal PGM the
|
||||
* kicad-cli way (SetPgm + InitPgm(headless)) — EXPORTER_STEP's
|
||||
* FILENAME_RESOLVER calls Pgm() unconditionally (exporter_step.cpp:154).
|
||||
* Settings land in the in-memory wxConfig store from occ_service_pre.js.
|
||||
*/
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <emscripten/bind.h>
|
||||
#include <emscripten/val.h>
|
||||
|
||||
#include <wx/init.h>
|
||||
#include <wx/string.h>
|
||||
#include <wx/ffile.h>
|
||||
#include <wx/filename.h>
|
||||
#include <wx/image.h>
|
||||
|
||||
#include <pgm_base.h>
|
||||
#include <settings/settings_manager.h>
|
||||
#include <kiplatform/environment.h>
|
||||
#include <reporter.h>
|
||||
#include <thread_pool.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <jobs/job_export_pcb_3d.h>
|
||||
|
||||
#include <board.h>
|
||||
#include <pcb_io/pcb_io_mgr.h>
|
||||
#include <exporters/step/exporter_step.h>
|
||||
|
||||
#include <plugins/3dapi/ifsg_api.h>
|
||||
|
||||
class SCENEGRAPH;
|
||||
|
||||
// The statically linked oce plugin's loader (plugins/3d/oce/oce.cpp, renamed
|
||||
// per-TU to oce3d_* by its EMSCRIPTEN CMake block; the registry in
|
||||
// 3d-viewer/3d_cache/pcbjam_static_3d_plugins.cpp declares the same surface).
|
||||
extern "C" SCENEGRAPH* oce3d_Load( char const* aFileName );
|
||||
|
||||
// The export dialog's browser seam (dialog_export_step.cpp, compiled into the
|
||||
// kiface objects this module links) feeds the full job JSON to the EDITOR-side
|
||||
// EXPORTER_STEP shadow through this hook. No dialog ever runs in the worker —
|
||||
// the service's EXPORTER_STEP is the real one — so it's a no-op here; the
|
||||
// symbol just has to resolve.
|
||||
extern "C" void Pcbjam_SetExportJobJson( const char* ) {}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Minimal headless PGM: MacOpenFile is PGM_BASE's only pure virtual.
|
||||
class PGM_OCC_SERVICE : public PGM_BASE
|
||||
{
|
||||
public:
|
||||
void MacOpenFile( const wxString& ) override {}
|
||||
};
|
||||
|
||||
PGM_OCC_SERVICE s_program;
|
||||
|
||||
const char* const TMP_BOARD = "/tmp/occ_service_board.kicad_pcb";
|
||||
const char* const TMP_CACHE = "/tmp/occ_service_model.3dc";
|
||||
|
||||
|
||||
bool writeFile( const wxString& aPath, const void* aData, size_t aLen )
|
||||
{
|
||||
wxFFile f( aPath, wxT( "wb" ) );
|
||||
return f.IsOpened() && f.Write( aData, aLen ) == aLen;
|
||||
}
|
||||
|
||||
|
||||
bool readFile( const wxString& aPath, std::vector<char>* aOut )
|
||||
{
|
||||
wxFFile f( aPath, wxT( "rb" ) );
|
||||
|
||||
if( !f.IsOpened() )
|
||||
return false;
|
||||
|
||||
wxFileOffset len = f.Length();
|
||||
aOut->resize( (size_t) len );
|
||||
return len >= 0 && f.Read( aOut->data(), (size_t) len ) == (size_t) len;
|
||||
}
|
||||
|
||||
|
||||
// Copy a std::vector's bytes into a fresh JS-owned Uint8Array (the
|
||||
// typed_memory_view itself only aliases this module's heap; the Uint8Array
|
||||
// constructor makes the copy that survives postMessage/transfer).
|
||||
emscripten::val toUint8Array( const std::vector<char>& aBytes )
|
||||
{
|
||||
emscripten::val view = emscripten::val( emscripten::typed_memory_view(
|
||||
aBytes.size(), reinterpret_cast<const uint8_t*>( aBytes.data() ) ) );
|
||||
return emscripten::val::global( "Uint8Array" ).new_( view );
|
||||
}
|
||||
|
||||
|
||||
// Headless board load from sexpr text — the pcbnew_scripting_helpers::LoadBoard
|
||||
// pattern (that file is KICAD_SCRIPTING-gated and the wasm stub returns null,
|
||||
// so the few needed lines are replicated here): default project from the PGM's
|
||||
// settings manager, PCB_IO_MGR parse, project attach.
|
||||
BOARD* loadBoardFromSexpr( const std::string& aSexpr, wxString* aErr )
|
||||
{
|
||||
if( !writeFile( wxString::FromUTF8( TMP_BOARD ), aSexpr.data(), aSexpr.size() ) )
|
||||
{
|
||||
*aErr = wxT( "occ_service: cannot stage board file in MEMFS" );
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
SETTINGS_MANAGER& mgr = Pgm().GetSettingsManager();
|
||||
PROJECT* project = mgr.GetProject( wxEmptyString );
|
||||
|
||||
if( !project )
|
||||
{
|
||||
mgr.LoadProject( wxEmptyString );
|
||||
project = mgr.GetProject( wxEmptyString );
|
||||
}
|
||||
|
||||
BOARD* brd = nullptr;
|
||||
|
||||
try
|
||||
{
|
||||
brd = PCB_IO_MGR::Load( PCB_IO_MGR::KICAD_SEXP, wxString::FromUTF8( TMP_BOARD ) );
|
||||
}
|
||||
catch( const std::exception& e )
|
||||
{
|
||||
*aErr = wxString::Format( wxT( "board parse failed: %s" ), e.what() );
|
||||
}
|
||||
catch( ... )
|
||||
{
|
||||
*aErr = wxT( "board parse failed" );
|
||||
}
|
||||
|
||||
if( brd && project )
|
||||
brd->SetProject( project );
|
||||
|
||||
return brd;
|
||||
}
|
||||
|
||||
|
||||
emscripten::val occExport( std::string aBoardSexpr, std::string aParamsJson )
|
||||
{
|
||||
emscripten::val ret = emscripten::val::object();
|
||||
ret.set( "ok", false );
|
||||
|
||||
// The official job JSON: JOB_EXPORT_PCB_3D registers every dialog/CLI field
|
||||
// as a JOB_PARAM (common/jobs/job_export_pcb_3d.cpp), so FromJson fills
|
||||
// m_3dparams + m_format directly.
|
||||
JOB_EXPORT_PCB_3D job;
|
||||
|
||||
try
|
||||
{
|
||||
job.FromJson( nlohmann::json::parse( aParamsJson ) );
|
||||
}
|
||||
catch( const std::exception& e )
|
||||
{
|
||||
ret.set( "report", std::string( "occ_service: bad paramsJson: " ) + e.what() );
|
||||
return ret;
|
||||
}
|
||||
|
||||
EXPORTER_STEP_PARAMS params = job.m_3dparams;
|
||||
|
||||
// Same format mapping as PCBNEW_JOBS_HANDLER::JobExportStep.
|
||||
switch( job.m_format )
|
||||
{
|
||||
case JOB_EXPORT_PCB_3D::FORMAT::STEP: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::STEP; break;
|
||||
case JOB_EXPORT_PCB_3D::FORMAT::STEPZ: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::STEPZ; break;
|
||||
case JOB_EXPORT_PCB_3D::FORMAT::BREP: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::BREP; break;
|
||||
case JOB_EXPORT_PCB_3D::FORMAT::XAO: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::XAO; break;
|
||||
case JOB_EXPORT_PCB_3D::FORMAT::GLB: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::GLB; break;
|
||||
case JOB_EXPORT_PCB_3D::FORMAT::PLY: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::PLY; break;
|
||||
case JOB_EXPORT_PCB_3D::FORMAT::STL: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::STL; break;
|
||||
case JOB_EXPORT_PCB_3D::FORMAT::U3D: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::U3D; break;
|
||||
case JOB_EXPORT_PCB_3D::FORMAT::PDF: params.m_Format = EXPORTER_STEP_PARAMS::FORMAT::PDF; break;
|
||||
default:
|
||||
// VRML exports run in-editor (EXPORTER_VRML, no OCC); anything else is a caller bug.
|
||||
ret.set( "report", std::string( "occ_service: unsupported format" ) );
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Phase breadcrumbs (stderr → worker console): the only field-visible
|
||||
// signal of where a hang/trap sits inside this synchronous call.
|
||||
std::fprintf( stderr, "[occ_service] export: parsing board (%zu bytes)\n",
|
||||
aBoardSexpr.size() );
|
||||
|
||||
wxString err;
|
||||
BOARD* brd = loadBoardFromSexpr( aBoardSexpr, &err );
|
||||
|
||||
if( !brd )
|
||||
{
|
||||
ret.set( "report", std::string( err.utf8_string() ) );
|
||||
return ret;
|
||||
}
|
||||
|
||||
std::fprintf( stderr, "[occ_service] export: board parsed, running EXPORTER_STEP (%s)\n",
|
||||
params.GetFormatName().utf8_string().c_str() );
|
||||
|
||||
WX_STRING_REPORTER reporter;
|
||||
EXPORTER_STEP exporter( brd, params, &reporter );
|
||||
|
||||
wxFileName outFn( wxT( "/tmp" ), wxT( "occ_service_out" ),
|
||||
params.GetDefaultExportExtension() );
|
||||
exporter.m_outputFile = outFn.GetFullPath();
|
||||
|
||||
bool ok = false;
|
||||
std::vector<char> outBytes;
|
||||
|
||||
try
|
||||
{
|
||||
ok = exporter.Export();
|
||||
}
|
||||
catch( const std::exception& e )
|
||||
{
|
||||
reporter.Report( wxString::Format( wxT( "export exception: %s" ), e.what() ),
|
||||
RPT_SEVERITY_ERROR );
|
||||
}
|
||||
catch( ... )
|
||||
{
|
||||
// OCCT throws Standard_Failure on malformed geometry internals.
|
||||
reporter.Report( wxT( "export exception (OCCT)" ), RPT_SEVERITY_ERROR );
|
||||
}
|
||||
|
||||
if( ok )
|
||||
ok = readFile( exporter.m_outputFile, &outBytes );
|
||||
|
||||
ret.set( "ok", ok );
|
||||
ret.set( "report", std::string( reporter.GetMessages().utf8_string() ) );
|
||||
ret.set( "fileName", std::string( outFn.GetFullName().utf8_string() ) );
|
||||
|
||||
if( ok )
|
||||
ret.set( "bytes", toUint8Array( outBytes ) );
|
||||
|
||||
wxRemoveFile( exporter.m_outputFile );
|
||||
wxRemoveFile( wxString::FromUTF8( TMP_BOARD ) );
|
||||
delete brd;
|
||||
|
||||
std::fprintf( stderr, "[occ_service] export %s (%zu bytes)\n",
|
||||
ok ? "ok" : "FAILED", outBytes.size() );
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
emscripten::val occLoadModel( emscripten::val aBytes, std::string aExt )
|
||||
{
|
||||
emscripten::val ret = emscripten::val::object();
|
||||
ret.set( "ok", false );
|
||||
|
||||
// Sanitize the extension — it picks the parser (STEP vs IGES) inside the
|
||||
// oce loader via the temp file name.
|
||||
std::string ext;
|
||||
|
||||
for( char c : aExt )
|
||||
{
|
||||
if( isalnum( (unsigned char) c ) )
|
||||
ext += (char) tolower( (unsigned char) c );
|
||||
}
|
||||
|
||||
if( ext.empty() )
|
||||
{
|
||||
ret.set( "report", std::string( "occ_service: empty model extension" ) );
|
||||
return ret;
|
||||
}
|
||||
|
||||
const size_t len = aBytes["byteLength"].as<size_t>();
|
||||
std::vector<uint8_t> buf( len );
|
||||
|
||||
// memcpy from the JS Uint8Array into this module's heap via a view .set().
|
||||
emscripten::val view =
|
||||
emscripten::val( emscripten::typed_memory_view( len, buf.data() ) );
|
||||
view.call<void>( "set", aBytes );
|
||||
|
||||
const wxString modelPath =
|
||||
wxString::FromUTF8( ( std::string( "/tmp/occ_service_model." ) + ext ).c_str() );
|
||||
|
||||
if( !writeFile( modelPath, buf.data(), buf.size() ) )
|
||||
{
|
||||
ret.set( "report", std::string( "occ_service: cannot stage model file" ) );
|
||||
return ret;
|
||||
}
|
||||
|
||||
SCENEGRAPH* sg = nullptr;
|
||||
|
||||
try
|
||||
{
|
||||
sg = oce3d_Load( modelPath.utf8_string().c_str() );
|
||||
}
|
||||
catch( ... )
|
||||
{
|
||||
// Standard_Failure on malformed STEP internals — treat as parse failure,
|
||||
// mirroring the per-model skip behavior in pcbjam_static_3d_plugins.cpp.
|
||||
sg = nullptr;
|
||||
}
|
||||
|
||||
wxRemoveFile( modelPath );
|
||||
|
||||
if( !sg )
|
||||
{
|
||||
ret.set( "report", std::string( "occ_service: model parse failed (." ) + ext + ")" );
|
||||
return ret;
|
||||
}
|
||||
|
||||
// SCENEGRAPH's first and only base is SGNODE (sg/scenegraph.h); the full
|
||||
// definition lives in 3d-viewer-internal headers, so cast at ABI level.
|
||||
SGNODE* node = reinterpret_cast<SGNODE*>( sg );
|
||||
|
||||
bool ok = S3D::WriteCache( TMP_CACHE, true, node, "pcbjam-occ_service:1" );
|
||||
std::vector<char> cacheBytes;
|
||||
|
||||
if( ok )
|
||||
ok = readFile( wxString::FromUTF8( TMP_CACHE ), &cacheBytes );
|
||||
|
||||
S3D::DestroyNode( node );
|
||||
wxRemoveFile( wxString::FromUTF8( TMP_CACHE ) );
|
||||
|
||||
ret.set( "ok", ok );
|
||||
|
||||
if( ok )
|
||||
ret.set( "bytes", toUint8Array( cacheBytes ) );
|
||||
else
|
||||
ret.set( "report", std::string( "occ_service: scenegraph cache write failed" ) );
|
||||
|
||||
std::fprintf( stderr, "[occ_service] loadModel .%s %s (%zu -> %zu bytes)\n", ext.c_str(),
|
||||
ok ? "ok" : "FAILED", len, cacheBytes.size() );
|
||||
return ret;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
EMSCRIPTEN_BINDINGS( occ_service )
|
||||
{
|
||||
emscripten::function( "occExport", &occExport );
|
||||
emscripten::function( "occLoadModel", &occLoadModel );
|
||||
}
|
||||
|
||||
|
||||
int main( int argc, char** argv )
|
||||
{
|
||||
// Bring up wxBase (no GUI): wxString/wxFileName/wxFFile need the library
|
||||
// initialized. Function-static so it lives for the module's lifetime
|
||||
// (-sEXIT_RUNTIME=0 keeps the runtime alive after main returns).
|
||||
static wxInitializer initializer( argc, argv );
|
||||
|
||||
if( !initializer.IsOk() )
|
||||
{
|
||||
std::fprintf( stderr, "[occ_service] wxWidgets initialisation failed\n" );
|
||||
return 1;
|
||||
}
|
||||
|
||||
KIPLATFORM::ENV::Init();
|
||||
SetPgm( &s_program );
|
||||
|
||||
// Headless, no python — the kicad-cli bootstrap (kicad_cli.cpp): creates the
|
||||
// settings manager + locale so Pgm()-dependent code (FILENAME_RESOLVER,
|
||||
// ADVANCED_CFG) works. Settings I/O lands in the pre-js in-memory store.
|
||||
if( !s_program.InitPgm( true, true ) )
|
||||
{
|
||||
std::fprintf( stderr, "[occ_service] InitPgm failed\n" );
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Boards may embed bitmap images in various formats.
|
||||
wxInitAllImageHandlers();
|
||||
|
||||
// Warm the KiCad thread pool NOW, while this thread's event loop is still
|
||||
// live. The exporter's first use (step_pcb_model.cpp CreatePCB) happens
|
||||
// inside the synchronous occExport call — and in a browser a blocked
|
||||
// thread cannot finish spawning Workers, which deadlocks the pool ctor in
|
||||
// Chromium (the same class as the raytracer pre-warm fix, root commit
|
||||
// 7630c7e). Constructing it here lets every std::thread start cleanly.
|
||||
GetKiCadThreadPool();
|
||||
|
||||
std::fprintf( stderr, "[occ_service] ready\n" );
|
||||
return 0;
|
||||
}
|
||||
117
wasm/occ-service/occ_service_pre.js
Normal file
117
wasm/occ-service/occ_service_pre.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Host shims for the occ_service worker module (emscripten --pre-js).
|
||||
//
|
||||
// KiCad's wxWidgets wasm port reads/writes settings through wxConfig, which
|
||||
// bridges to JS hooks (getConfigEntryLength, …). The web editor provides those
|
||||
// via wx.js, backed by the browser's localStorage. The service runs in a
|
||||
// dedicated Worker (or Node for unit runs) with no localStorage and needs no
|
||||
// persisted settings, so we back the same hooks with an in-memory store: every
|
||||
// read returns "absent" → KiCad falls back to defaults; writes live only for
|
||||
// the module lifetime. Semantics mirror wxwidgets/build/wasm/wx.js exactly
|
||||
// (sans persistence). Same shim as wasm/cli/sym_convert_pre.js.
|
||||
(function( g ) {
|
||||
if( !g.localStorage )
|
||||
{
|
||||
var store = new Map();
|
||||
g.localStorage = {
|
||||
get length() { return store.size; },
|
||||
key: function( i ) { var k = Array.from( store.keys() )[i]; return k === undefined ? null : k; },
|
||||
getItem: function( k ) { return store.has( k ) ? store.get( k ) : null; },
|
||||
setItem: function( k, v ) { store.set( String( k ), String( v ) ); },
|
||||
removeItem: function( k ) { store.delete( k ); },
|
||||
clear: function() { store.clear(); },
|
||||
};
|
||||
}
|
||||
|
||||
var ls = g.localStorage;
|
||||
|
||||
// Only reached once a stored value exists; with the empty in-memory store these
|
||||
// never run, but keep them correct in case settings are written then read back.
|
||||
var s2u = function( str, buf, len ) {
|
||||
var f = g.stringToUTF8 || ( g.Module && g.Module.stringToUTF8 );
|
||||
f( str, buf, len );
|
||||
};
|
||||
|
||||
g.hasConfigEntry = function( key ) { return ls.getItem( key ) !== null; };
|
||||
|
||||
g.hasConfigGroup = function( key ) {
|
||||
for( var i = 0; i < ls.length; i++ ) if( ls.key( i ).startsWith( key ) ) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
g.getConfigEntryCount = function( prefix, recurse ) {
|
||||
var n = 0;
|
||||
for( var i = 0; i < ls.length; i++ ) {
|
||||
var key = ls.key( i );
|
||||
if( key.startsWith( prefix ) ) {
|
||||
var end = key.indexOf( '/', prefix.length );
|
||||
if( end == -1 || recurse ) ++n;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
g.getConfigEntryIndex = function( prefix, index ) {
|
||||
var n = 0;
|
||||
for( var i = 0; i < ls.length; i++ ) {
|
||||
var key = ls.key( i );
|
||||
if( key.startsWith( prefix ) ) {
|
||||
var end = key.indexOf( '/', prefix.length );
|
||||
if( end == -1 ) { if( n >= index ) return i; else ++n; }
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
g.getConfigGroupCount = function( prefix, recurse ) {
|
||||
var c = new Set();
|
||||
for( var i = 0; i < ls.length; i++ ) {
|
||||
var key = ls.key( i );
|
||||
if( key.startsWith( prefix ) ) {
|
||||
var end = key.indexOf( '/', prefix.length );
|
||||
if( end != -1 ) { if( recurse ) end = key.lastIndexOf( '/' ); c.add( key.substring( prefix.length, end ) ); }
|
||||
}
|
||||
}
|
||||
return c.size;
|
||||
};
|
||||
|
||||
g.getConfigGroupIndex = function( prefix, index ) {
|
||||
var c = new Set();
|
||||
for( var i = 0; i < ls.length; i++ ) {
|
||||
var key = ls.key( i );
|
||||
if( key.startsWith( prefix ) ) {
|
||||
var end = key.indexOf( '/', prefix.length );
|
||||
if( end != -1 ) {
|
||||
var child = key.substring( prefix.length, end );
|
||||
if( !c.has( child ) ) { if( c.size >= index ) return i; else c.add( child ); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
g.getConfigKeyLength = function( index ) { var k = ls.key( index ); return k ? k.length : 0; };
|
||||
g.getConfigKey = function( index, buf, len ) { s2u( ls.key( index ), buf, len ); };
|
||||
|
||||
g.getConfigEntryLength = function( key ) { var v = ls.getItem( key ); return v === null ? -1 : v.length; };
|
||||
g.getConfigEntry = function( key, buf, len ) {
|
||||
var v = ls.getItem( key );
|
||||
if( v !== null ) { s2u( v, buf, len ); return true; }
|
||||
return false;
|
||||
};
|
||||
|
||||
g.setConfigEntry = function( key, value ) { ls.setItem( key, value ); };
|
||||
|
||||
g.renameConfigGroup = function( oldG, newG ) {
|
||||
var keys = [];
|
||||
for( var i = 0; i < ls.length; i++ ) {
|
||||
var key = ls.key( i );
|
||||
if( key.startsWith( oldG ) ) keys.push( key );
|
||||
else if( key.startsWith( newG ) ) return false;
|
||||
}
|
||||
for( var j = 0; j < keys.length; j++ ) {
|
||||
var nk = newG + keys[j].substring( oldG.length );
|
||||
ls.setItem( nk, ls.getItem( keys[j] ) ); ls.removeItem( keys[j] );
|
||||
}
|
||||
return keys.length > 0;
|
||||
};
|
||||
})( typeof globalThis !== 'undefined' ? globalThis : this );
|
||||
183
wasm/stubs/exporter_step_stub.cpp
Normal file
183
wasm/stubs/exporter_step_stub.cpp
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* WASM shadow of EXPORTER_STEP — the editor build compiles this INSTEAD of
|
||||
* exporters/step/exporter_step.cpp (no OCC in pcbnew.wasm; see
|
||||
* docs/features/occ-split/README.md). The class layout comes from the real
|
||||
* header; only the three symbols other TUs reference are defined here:
|
||||
* constructor, destructor, Export().
|
||||
*
|
||||
* Export() bridges to the occ_service worker: stage the live BOARD as sexpr
|
||||
* text in MEMFS, ship it + the official JOB_EXPORT_PCB_3D JSON through an
|
||||
* EM_ASYNC_JS suspend (globalThis.occService.request, installed by the web
|
||||
* app), and report the outcome. The exported file's bytes never enter this
|
||||
* module — the JS provider hands them straight to the browser download path.
|
||||
*
|
||||
* Callers stay untouched: PCBNEW_JOBS_HANDLER::JobExportStep and the browser
|
||||
* branch of DIALOG_EXPORT_STEP construct EXPORTER_STEP exactly as on desktop.
|
||||
*/
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
#include <emscripten.h>
|
||||
|
||||
#include <wx/filename.h>
|
||||
#include <wx/string.h>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <board.h>
|
||||
#include <reporter.h>
|
||||
#include <jobs/job_export_pcb_3d.h>
|
||||
#include <pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h>
|
||||
#include <exporters/step/exporter_step.h>
|
||||
|
||||
// Complete types for the unique_ptr members destroyed in ~EXPORTER_STEP (their
|
||||
// headers still exist in the tree/sysroot; only the OCC *link* is gone).
|
||||
#include <exporters/step/step_pcb_model.h>
|
||||
#include <filename_resolver.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
const char* const TMP_BOARD = "/tmp/pcbjam_occ_export_board.kicad_pcb";
|
||||
|
||||
// Optional side-channel: the dialog seam serializes the FULL job (including
|
||||
// fields EXPORTER_STEP_PARAMS doesn't carry, e.g. the assembly variant) right
|
||||
// before calling Export(). Consumed once. When unset (jobs-handler path), the
|
||||
// job JSON is reconstructed from m_params.
|
||||
std::string s_nextJobJson;
|
||||
} // namespace
|
||||
|
||||
// One-shot job-JSON override for the next EXPORTER_STEP::Export() call.
|
||||
extern "C" void Pcbjam_SetExportJobJson( const char* aJson )
|
||||
{
|
||||
s_nextJobJson = aJson ? aJson : "";
|
||||
}
|
||||
|
||||
// Suspends pcbnew (Asyncify; the __asyncjs__* import is auto-covered by
|
||||
// scripts/common/asyncify-imports.txt) while the worker exports. JS returns a
|
||||
// malloc'd JSON string: { ok, report } — the download already happened there.
|
||||
EM_ASYNC_JS( char*, js_occExportRequest,
|
||||
( const char* aBoardPath, const char* aJobJson, const char* aFileName ),
|
||||
{
|
||||
const boardPath = UTF8ToString( aBoardPath );
|
||||
const jobJson = UTF8ToString( aJobJson );
|
||||
const fileName = UTF8ToString( aFileName );
|
||||
let res;
|
||||
|
||||
try
|
||||
{
|
||||
const hook = globalThis.occService;
|
||||
|
||||
if( !hook || typeof hook.request !== 'function' )
|
||||
{
|
||||
res = { ok: false, report: 'occ_service provider not installed' };
|
||||
}
|
||||
else
|
||||
{
|
||||
const board = FS.readFile( boardPath ); // Uint8Array copy — transferable
|
||||
res = await hook.request( { kind: 'export', board, jobJson, fileName } );
|
||||
}
|
||||
}
|
||||
catch( e )
|
||||
{
|
||||
console.error( '[pcbjam-occ] export request failed:', e );
|
||||
res = { ok: false, report: 'occ_service request failed: ' + e };
|
||||
}
|
||||
|
||||
const s = JSON.stringify( res || { ok: false, report: 'occ_service: no response' } );
|
||||
const n = lengthBytesUTF8( s ) + 1;
|
||||
const p = _malloc( n );
|
||||
stringToUTF8( s, p, n );
|
||||
return p;
|
||||
} )
|
||||
|
||||
|
||||
EXPORTER_STEP::EXPORTER_STEP( BOARD* aBoard, const EXPORTER_STEP_PARAMS& aParams,
|
||||
REPORTER* aReporter ) :
|
||||
m_params( aParams ),
|
||||
m_reporter( aReporter ),
|
||||
m_board( aBoard ),
|
||||
m_platingThickness( 0 )
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
EXPORTER_STEP::~EXPORTER_STEP()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
bool EXPORTER_STEP::Export()
|
||||
{
|
||||
if( !m_board )
|
||||
return false;
|
||||
|
||||
// Stage the LIVE board (unsaved edits included) as sexpr text in MEMFS.
|
||||
try
|
||||
{
|
||||
PCB_IO_KICAD_SEXPR io;
|
||||
io.SaveBoard( wxString::FromUTF8( TMP_BOARD ), m_board );
|
||||
}
|
||||
catch( const std::exception& e )
|
||||
{
|
||||
if( m_reporter )
|
||||
{
|
||||
m_reporter->Report( wxString::Format( wxT( "Failed to stage board for export: %s" ),
|
||||
e.what() ),
|
||||
RPT_SEVERITY_ERROR );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// The official job JSON. Prefer the seam-provided full job; otherwise
|
||||
// rebuild one from the params we were constructed with.
|
||||
std::string jobJson;
|
||||
|
||||
if( !s_nextJobJson.empty() )
|
||||
{
|
||||
jobJson = std::move( s_nextJobJson );
|
||||
s_nextJobJson.clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
JOB_EXPORT_PCB_3D job;
|
||||
job.m_3dparams = m_params;
|
||||
job.SetStepFormat( m_params.m_Format );
|
||||
|
||||
nlohmann::json j;
|
||||
job.ToJson( j );
|
||||
jobJson = j.dump();
|
||||
}
|
||||
|
||||
const wxString downloadName = wxFileName( m_outputFile ).GetFullName();
|
||||
|
||||
char* response = js_occExportRequest( TMP_BOARD, jobJson.c_str(),
|
||||
downloadName.utf8_string().c_str() );
|
||||
|
||||
bool ok = false;
|
||||
|
||||
try
|
||||
{
|
||||
nlohmann::json res = nlohmann::json::parse( response ? response : "{}" );
|
||||
ok = res.value( "ok", false );
|
||||
|
||||
const std::string report = res.value( "report", std::string() );
|
||||
|
||||
if( m_reporter && !report.empty() )
|
||||
{
|
||||
m_reporter->Report( wxString::FromUTF8( report.c_str() ),
|
||||
ok ? RPT_SEVERITY_INFO : RPT_SEVERITY_ERROR );
|
||||
}
|
||||
}
|
||||
catch( ... )
|
||||
{
|
||||
if( m_reporter )
|
||||
m_reporter->Report( wxT( "occ_service: malformed response" ), RPT_SEVERITY_ERROR );
|
||||
}
|
||||
|
||||
std::free( response );
|
||||
wxRemoveFile( wxString::FromUTF8( TMP_BOARD ) );
|
||||
|
||||
return ok;
|
||||
}
|
||||
225
wasm/stubs/oce_plugin_stub.cpp
Normal file
225
wasm/stubs/oce_plugin_stub.cpp
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
/*
|
||||
* WASM shadow of the oce 3D plugin's flat-C surface (oce3d_*) — the editor
|
||||
* build links this INSTEAD of s3d_plugin_oce (no OCC in pcbnew.wasm; see
|
||||
* docs/features/occ-split/README.md). The static plugin registry
|
||||
* (kicad/3d-viewer/3d_cache/pcbjam_static_3d_plugins.cpp) consumes exactly
|
||||
* this surface, so the plugin manager, S3D_CACHE, and the scene build compile
|
||||
* untouched — .step/.iges footprint models keep loading, their parse just runs
|
||||
* in the occ_service worker.
|
||||
*
|
||||
* oce3d_Load: the model file is already in MEMFS (materialized by
|
||||
* PCBJAM_3D::EnsureModelFile before the plugin dispatch). Ship its bytes to
|
||||
* the worker (EM_ASYNC_JS suspend — legal here: EnsureModelFile already
|
||||
* suspends on this same S3D_CACHE::load path), get back the SCENEGRAPH
|
||||
* serialized in KiCad's own binary cache format, and rebuild it with
|
||||
* S3D::ReadCache.
|
||||
*
|
||||
* Metadata (extensions/filters/versions) mirrors plugins/3d/oce/oce.cpp and
|
||||
* include/plugins/3d/3d_plugin.h so the manager's handshake and extension map
|
||||
* are identical to the real plugin's.
|
||||
*/
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <emscripten.h>
|
||||
|
||||
#include <wx/string.h>
|
||||
#include <wx/filefn.h>
|
||||
|
||||
#include <plugins/3dapi/ifsg_api.h>
|
||||
|
||||
class SCENEGRAPH;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// Non-Windows extension/filter lists from plugins/3d/oce/oce.cpp (FILE_DATA).
|
||||
const std::vector<std::string> k_extensions = {
|
||||
"stp", "STP", "stpZ", "stpz", "STPZ", "step", "STEP", "stp.gz", "STP.GZ", "step.gz",
|
||||
"STEP.GZ", "igs", "IGS", "iges", "IGES"
|
||||
};
|
||||
|
||||
const std::vector<std::string> k_filters = {
|
||||
"STEP (*.stp;*.STP;*.stpZ;*.stpz;*.STPZ;*.step;*.STEP;*.stp.gz;*.STP.GZ;*.step.gz;"
|
||||
"*.STEP.GZ)|*.stp;*.STP;*.stpZ;*.stpz;*.STPZ;*.step;*.STEP;*.stp.gz;*.STP.GZ;"
|
||||
"*.step.gz;*.STEP.GZ",
|
||||
"IGES (*.igs;*.IGS;*.iges;*.IGES)|*.igs;*.IGS;*.iges;*.IGES"
|
||||
};
|
||||
|
||||
bool acceptAnyCacheTag( const char*, void* )
|
||||
{
|
||||
// The cache blob just crossed the worker boundary from our own writer —
|
||||
// there is no plugin-version drift to guard against.
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Suspends pcbnew while the worker parses + tessellates the model. JS returns
|
||||
// a malloc'd path string: the scenegraph-cache file it wrote into this
|
||||
// module's MEMFS ("" on failure).
|
||||
EM_ASYNC_JS( char*, js_occLoadModelRequest, ( const char* aModelPath ),
|
||||
{
|
||||
const modelPath = UTF8ToString( aModelPath );
|
||||
let cachePath = '';
|
||||
|
||||
try
|
||||
{
|
||||
const hook = globalThis.occService;
|
||||
|
||||
if( !hook || typeof hook.request !== 'function' )
|
||||
{
|
||||
console.error( '[pcbjam-occ] loadModel: occ_service provider not installed' );
|
||||
}
|
||||
else
|
||||
{
|
||||
const bytes = FS.readFile( modelPath ); // Uint8Array copy — transferable
|
||||
const dot = modelPath.lastIndexOf( '.' );
|
||||
const ext = dot >= 0 ? modelPath.slice( dot + 1 ) : 'step';
|
||||
const res = await hook.request( { kind: 'loadModel', bytes, ext } );
|
||||
|
||||
if( res && res.ok && res.bytes && res.bytes.length )
|
||||
{
|
||||
cachePath = '/tmp/pcbjam_occ_model_cache.3dc';
|
||||
FS.writeFile( cachePath, res.bytes );
|
||||
}
|
||||
else if( res && res.report )
|
||||
{
|
||||
console.error( '[pcbjam-occ] loadModel failed:', res.report );
|
||||
}
|
||||
}
|
||||
}
|
||||
catch( e )
|
||||
{
|
||||
console.error( '[pcbjam-occ] loadModel request failed:', e );
|
||||
cachePath = '';
|
||||
}
|
||||
|
||||
const n = lengthBytesUTF8( cachePath ) + 1;
|
||||
const p = _malloc( n );
|
||||
stringToUTF8( cachePath, p, n );
|
||||
return p;
|
||||
} )
|
||||
|
||||
|
||||
extern "C"
|
||||
{
|
||||
|
||||
// --- class handshake (include/plugins/3d/3d_plugin.h semantics) -------------
|
||||
|
||||
char const* oce3d_GetKicadPluginClass( void )
|
||||
{
|
||||
return "PLUGIN_3D";
|
||||
}
|
||||
|
||||
|
||||
void oce3d_GetClassVersion( unsigned char* Major, unsigned char* Minor, unsigned char* Patch,
|
||||
unsigned char* Revision )
|
||||
{
|
||||
if( Major )
|
||||
*Major = 1;
|
||||
|
||||
if( Minor )
|
||||
*Minor = 0;
|
||||
|
||||
if( Patch )
|
||||
*Patch = 0;
|
||||
|
||||
if( Revision )
|
||||
*Revision = 0;
|
||||
}
|
||||
|
||||
|
||||
bool oce3d_CheckClassVersion( unsigned char Major, unsigned char, unsigned char, unsigned char )
|
||||
{
|
||||
return Major == 1;
|
||||
}
|
||||
|
||||
|
||||
// --- plugin identity (plugins/3d/oce/oce.cpp values) -------------------------
|
||||
|
||||
const char* oce3d_GetKicadPluginName( void )
|
||||
{
|
||||
return "PLUGIN_3D_OCE";
|
||||
}
|
||||
|
||||
|
||||
void oce3d_GetPluginVersion( unsigned char* Major, unsigned char* Minor, unsigned char* Patch,
|
||||
unsigned char* Revision )
|
||||
{
|
||||
if( Major )
|
||||
*Major = 1;
|
||||
|
||||
if( Minor )
|
||||
*Minor = 4;
|
||||
|
||||
if( Patch )
|
||||
*Patch = 2;
|
||||
|
||||
if( Revision )
|
||||
*Revision = 0;
|
||||
}
|
||||
|
||||
|
||||
int oce3d_GetNExtensions( void )
|
||||
{
|
||||
return (int) k_extensions.size();
|
||||
}
|
||||
|
||||
|
||||
char const* oce3d_GetModelExtension( int aIndex )
|
||||
{
|
||||
if( aIndex < 0 || aIndex >= (int) k_extensions.size() )
|
||||
return nullptr;
|
||||
|
||||
return k_extensions[aIndex].c_str();
|
||||
}
|
||||
|
||||
|
||||
int oce3d_GetNFilters( void )
|
||||
{
|
||||
return (int) k_filters.size();
|
||||
}
|
||||
|
||||
|
||||
char const* oce3d_GetFileFilter( int aIndex )
|
||||
{
|
||||
if( aIndex < 0 || aIndex >= (int) k_filters.size() )
|
||||
return nullptr;
|
||||
|
||||
return k_filters[aIndex].c_str();
|
||||
}
|
||||
|
||||
|
||||
bool oce3d_CanRender( void )
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
SCENEGRAPH* oce3d_Load( char const* aFileName )
|
||||
{
|
||||
if( !aFileName )
|
||||
return nullptr;
|
||||
|
||||
char* cachePath = js_occLoadModelRequest( aFileName );
|
||||
|
||||
if( !cachePath || !*cachePath )
|
||||
{
|
||||
std::free( cachePath );
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ReadCache returns the top-level SCENEGRAPH as its SGNODE base.
|
||||
SGNODE* node = S3D::ReadCache( cachePath, nullptr, &acceptAnyCacheTag );
|
||||
|
||||
wxRemoveFile( wxString::FromUTF8( cachePath ) );
|
||||
std::free( cachePath );
|
||||
|
||||
return reinterpret_cast<SCENEGRAPH*>( node );
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
Loading…
Reference in a new issue