mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 01:41:57 +00:00
ifcviewer-web: stream remote sidecars over HTTP Range (?model=URL)
Adds a network byte-source alongside the local Blob one. The async-chunk
infra is source-agnostic — only the two JS primitives knew it was a Blob —
so this generalises them and reuses everything else:
- ifcvReadRangeInto: local → Blob.slice; remote → fetch() with a Range
header (206). If a server ignores Range and returns 200, the requested
window is sliced out so it still works (without the bandwidth saving).
- ifcvFileSize: Blob size, or the URL's total length resolved up front.
- ifcvBeginUrlSource: resolves total size (HEAD Content-Length, else a
0-0 ranged GET's Content-Range) then fires _ifcv_source_ready.
- The metadata bootstrap is extracted into a source-agnostic
loadSidecarMetadataWeb(label); loadSidecarFromBlobWeb / FromUrlWeb are
thin entries. streaming_from_blob → streaming_from_web (now covers both).
main_web exports load_sidecar_from_url_c(url); shell.html reads a
?model=URL query param and ccalls it once the app is live (same-origin
needs no CORS; cross-origin hosts must send CORS + Accept-Ranges).
Test: serve.mjs now answers HEAD + Range (206) and falls back to the
ifcviewer-web source dir for sample.ifcview (embedded in the wasm, not in
build-web). New smoke case loads ?model=/sample.ifcview and asserts it
renders via the Range path. 6/6 web smoke + 107/107 unit pass; desktop
unaffected (web-guarded; only the shared field rename touches it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -98,11 +98,14 @@ target_link_options(IfcViewerWeb PRIVATE
|
||||
"-sEXIT_RUNTIME=0"
|
||||
# Expose the C entry points to JS. _raf_tick_c drives the RAF loop
|
||||
# (shell.html); _load_sidecar_from_blob_c loads a user-picked File via
|
||||
# byte-range Blob.slice reads; _ifcv_on_range_done is the completion
|
||||
# callback the JS range reader invokes when a slice has landed in the
|
||||
# heap. EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but
|
||||
# doesn't add them to Module.
|
||||
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_blob_c','_ifcv_on_range_done']"
|
||||
# byte-range Blob.slice reads; _load_sidecar_from_url_c streams a remote
|
||||
# sidecar via HTTP Range; _ifcv_on_range_done / _ifcv_source_ready are the
|
||||
# JS→C completion callbacks for a landed range / a resolved URL size.
|
||||
# EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't
|
||||
# add them to Module. ccall lets shell.html pass a JS string (the ?model
|
||||
# URL) to load_sidecar_from_url_c without manual heap marshalling.
|
||||
"-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_blob_c','_load_sidecar_from_url_c','_ifcv_on_range_done','_ifcv_source_ready']"
|
||||
"-sEXPORTED_RUNTIME_METHODS=['ccall']"
|
||||
# Streaming + chunked geometry want a heap that can grow as buffers
|
||||
# arrive. 256 MB initial, 2 GB ceiling (matches the wasm32 pointer
|
||||
# cap; --shared64 / MEMORY64 would lift this later if we need it).
|
||||
|
||||
@@ -205,6 +205,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_blob_c() {
|
||||
g_app->core.loadSidecarFromBlobWeb();
|
||||
}
|
||||
|
||||
// Called from shell.html (e.g. a ?model=URL query param) to stream a sidecar
|
||||
// hosted at `url` via HTTP Range requests — the same per-chunk byte-range path
|
||||
// as the local File load, but the bytes come off the network instead of a
|
||||
// Blob. Asynchronous; the model frames itself once metadata lands. Exported
|
||||
// to JS via EXPORTED_FUNCTIONS in CMakeLists.txt.
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE void load_sidecar_from_url_c(const char* url) {
|
||||
if (!g_app || !g_app->ready || !url) return;
|
||||
g_app->core.resetScene();
|
||||
g_app->core.loadSidecarFromUrlWeb(url);
|
||||
}
|
||||
|
||||
int main(int /*argc*/, char** /*argv*/) {
|
||||
Log::info() << "ifcviewer-web: starting";
|
||||
g_app = new AppState();
|
||||
|
||||
@@ -71,11 +71,20 @@
|
||||
// chain — which is the configuration that stalls device-callback
|
||||
// delivery (verified during web bring-up).
|
||||
var collapsedOnce = false;
|
||||
var urlLoadTried = false;
|
||||
var modelUrl = new URLSearchParams(location.search).get('model');
|
||||
function shellTick() {
|
||||
if (Module._app_ptr && Module._raf_tick_c) {
|
||||
// First time the app goes live, collapse the log overlay so it
|
||||
// stops covering the viewport.
|
||||
if (!collapsedOnce) { statusEl.classList.add('ready'); collapsedOnce = true; }
|
||||
// ?model=URL streams a remote sidecar via HTTP Range once the app
|
||||
// is live (one-shot). Same-origin needs no CORS; cross-origin URLs
|
||||
// require the host to send CORS + Accept-Ranges headers.
|
||||
if (!urlLoadTried && modelUrl && Module.ccall) {
|
||||
urlLoadTried = true;
|
||||
Module.ccall('load_sidecar_from_url_c', null, ['string'], [modelUrl]);
|
||||
}
|
||||
Module._raf_tick_c(Module._app_ptr);
|
||||
}
|
||||
requestAnimationFrame(shellTick);
|
||||
|
||||
@@ -14,6 +14,10 @@ const ROOT = process.env.WEB_BUILD_DIR
|
||||
? path.resolve(process.env.WEB_BUILD_DIR)
|
||||
: path.resolve(__dirname, '../../../build-web');
|
||||
const PORT = Number(process.env.PORT || 8124);
|
||||
// Fallback root: the ifcviewer-web source dir holds sample.ifcview (which is
|
||||
// embedded in the wasm, not copied into build-web). Lets the remote-backend
|
||||
// test fetch http://localhost/sample.ifcview over real HTTP Range.
|
||||
const SRC = path.resolve(__dirname, '..');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
@@ -28,11 +32,48 @@ http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
let p = decodeURIComponent(url.pathname);
|
||||
if (p === '/') p = '/IfcViewerWeb.html';
|
||||
const file = path.join(ROOT, p);
|
||||
// Contain to ROOT.
|
||||
if (!file.startsWith(ROOT)) { res.writeHead(403).end(); return; }
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
|
||||
const inRoot = path.join(ROOT, p);
|
||||
const inSrc = path.join(SRC, p);
|
||||
// Contain to one of the two allowed roots.
|
||||
if (!inRoot.startsWith(ROOT) && !inSrc.startsWith(SRC)) { res.writeHead(403).end(); return; }
|
||||
let body;
|
||||
try { body = await readFile(inRoot); }
|
||||
catch { body = await readFile(inSrc); } // fall back to the source dir
|
||||
const ctype = MIME[path.extname(p)] || 'application/octet-stream';
|
||||
|
||||
// HEAD: headers only — lets the remote backend resolve total size.
|
||||
if (req.method === 'HEAD') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': ctype,
|
||||
'Content-Length': body.length,
|
||||
'Accept-Ranges': 'bytes',
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Range: serve 206 partial content so the HTTP-Range backend is exercised
|
||||
// exactly as a real Accept-Ranges host would (handles bytes=a-b and a-).
|
||||
const range = req.headers['range'];
|
||||
const m = range && /^bytes=(\d*)-(\d*)$/.exec(range.trim());
|
||||
if (m) {
|
||||
let start = m[1] === '' ? undefined : parseInt(m[1], 10);
|
||||
let end = m[2] === '' ? undefined : parseInt(m[2], 10);
|
||||
if (start === undefined) { start = body.length - end; end = body.length - 1; } // bytes=-N
|
||||
if (end === undefined || end > body.length - 1) end = body.length - 1; // bytes=a-
|
||||
if (Number.isNaN(start) || start > end) { res.writeHead(416).end(); return; }
|
||||
const slice = body.subarray(start, end + 1);
|
||||
res.writeHead(206, {
|
||||
'Content-Type': ctype,
|
||||
'Content-Range': `bytes ${start}-${end}/${body.length}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': slice.length,
|
||||
});
|
||||
res.end(slice);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': ctype, 'Accept-Ranges': 'bytes' });
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end('not found');
|
||||
|
||||
@@ -167,7 +167,7 @@ test('loads a user-picked sidecar through the Blob.slice byte-range path', async
|
||||
// side to confirm the blob load landed (logged to stderr → console).
|
||||
const samplePath = resolve(__dirname, '..', 'sample.ifcview');
|
||||
const loaded = page.waitForEvent('console', {
|
||||
predicate: (m) => /loaded blob sidecar/.test(m.text()),
|
||||
predicate: (m) => /loaded sidecar \(blob:/.test(m.text()),
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.locator('#file-input').setInputFiles(samplePath);
|
||||
@@ -194,6 +194,43 @@ test('loads a user-picked sidecar through the Blob.slice byte-range path', async
|
||||
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('streams a remote sidecar over HTTP Range (?model= URL backend)', async ({ page }) => {
|
||||
// The remote backend: ?model=URL resolves total size (HEAD), then reads the
|
||||
// metadata + per-chunk byte ranges via HTTP Range (206) — same async-chunk
|
||||
// path as the local Blob load, different byte source. serve.mjs answers
|
||||
// Range requests, so this exercises it end-to-end (same-origin, no CORS).
|
||||
const gpuErrors = [];
|
||||
page.on('console', (msg) => {
|
||||
if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(msg.text()))
|
||||
gpuErrors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message));
|
||||
|
||||
// Wait for the C side to confirm the URL-sourced load landed.
|
||||
const loaded = page.waitForEvent('console', {
|
||||
predicate: (m) => /loaded sidecar \(net:/.test(m.text()),
|
||||
timeout: 20_000,
|
||||
});
|
||||
await page.goto('/IfcViewerWeb.html?model=/sample.ifcview');
|
||||
await page.waitForFunction(
|
||||
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
|
||||
await loaded;
|
||||
await page.waitForTimeout(800); // stream the chunk + a few frames
|
||||
|
||||
// The remote-streamed model must render: centre patch (cube) != corner.
|
||||
const box = await page.locator('#viewer-canvas').boundingBox();
|
||||
const patch = (cx, cy) => page.screenshot({
|
||||
clip: { x: Math.round(cx - 12), y: Math.round(cy - 12), width: 24, height: 24 },
|
||||
});
|
||||
const center = await patch(box.x + box.width / 2, box.y + box.height / 2);
|
||||
const corner = await patch(box.x + 16, box.y + 16);
|
||||
expect(
|
||||
Buffer.compare(center, corner),
|
||||
'remote-streamed model did not render — HTTP Range path failed',
|
||||
).not.toBe(0);
|
||||
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
test('click selects an object and the highlight renders (async pick)', async ({ page }) => {
|
||||
// Exercises the async object-pick readback: a click maps the pick staging
|
||||
// buffer via a spontaneous callback (no blocking spin, which would hang the
|
||||
|
||||
@@ -277,11 +277,12 @@ struct ModelGpuData {
|
||||
std::string streaming_file_path;
|
||||
uint64_t streaming_vertex_section_offset = 0;
|
||||
uint64_t streaming_index_section_offset = 0;
|
||||
// Web only: chunk byte ranges come from the JS-registered File via
|
||||
// Blob.slice (async), not from a synchronous fopen on streaming_file_path.
|
||||
// Set by loadSidecarFromBlobWeb so driveStreamingLoads routes this model
|
||||
// through the async blob path instead of the MEMFS sync read.
|
||||
bool streaming_from_blob = false;
|
||||
// Web only: chunk byte ranges come from the JS-side source — a picked File
|
||||
// (Blob.slice) or a remote URL (HTTP Range) — read asynchronously, not via
|
||||
// a synchronous fopen on streaming_file_path. Set by loadSidecarMetadataWeb
|
||||
// so driveStreamingLoads routes this model through the async web path
|
||||
// instead of the MEMFS sync read.
|
||||
bool streaming_from_web = false;
|
||||
|
||||
// For each mesh in meshes[], the chunk it lives in plus the chunk-local
|
||||
// offsets into that chunk's vertex_storage and index_buffer. Populated
|
||||
|
||||
+126
-39
@@ -2313,12 +2313,12 @@ void ViewportCore::driveStreamingLoads() {
|
||||
// queue requests with nothing to drain them. Chunks would
|
||||
// never go resident.
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// Blob-sourced models read chunk bytes asynchronously via
|
||||
// Blob.slice (the whole file is never in the heap). The chunk goes
|
||||
// resident in the JS completion callback; hold is_loading until then
|
||||
// so it isn't re-issued every frame. The embedded MEMFS sample falls
|
||||
// through to the synchronous fopen path below.
|
||||
if (cand.m->streaming_from_blob) {
|
||||
// Web-sourced models (picked File or remote URL) read chunk bytes
|
||||
// asynchronously (Blob.slice / HTTP Range) — the whole file is never
|
||||
// in the heap. The chunk goes resident in the JS completion callback;
|
||||
// hold is_loading until then so it isn't re-issued every frame. The
|
||||
// embedded MEMFS sample falls through to the synchronous fopen path.
|
||||
if (cand.m->streaming_from_web) {
|
||||
c.is_loading = true;
|
||||
c.last_visible_frame_idx = streaming_frame_idx_;
|
||||
beginWebChunkLoad(cand.mid, cand.ci);
|
||||
@@ -3141,25 +3141,71 @@ std::uint32_t ViewportCore::loadSidecarFromPath(const std::string& path) {
|
||||
|
||||
namespace {
|
||||
|
||||
// Size of the JS-registered File in bytes, or 0 if none. Bounds the metadata
|
||||
// tail read (index-section end .. EOF).
|
||||
// Active byte-source size in bytes, or 0 if none. Bounds the metadata tail
|
||||
// read (index-section end .. EOF). The source is either a picked File
|
||||
// (Module.__ifcvFile, local) or a remote URL whose total length was resolved
|
||||
// up front (Module.__ifcvUrlSize). The File takes precedence if both are set.
|
||||
EM_JS(double, ifcvFileSize, (void), {
|
||||
return (Module["__ifcvFile"] && Module["__ifcvFile"].size)
|
||||
? Module["__ifcvFile"].size : 0;
|
||||
if (Module["__ifcvFile"] && Module["__ifcvFile"].size) return Module["__ifcvFile"].size;
|
||||
if (Module["__ifcvUrl"]) return Module["__ifcvUrlSize"] || 0;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Read [offset, offset+size) of the registered File into dst (which must hold
|
||||
// `size` bytes), then call back _ifcv_on_range_done(reqId, ok). Async — the
|
||||
// Blob is sliced and its ArrayBuffer copied into the wasm heap on resolve.
|
||||
// Read [offset, offset+size) of the active source into dst (which must hold
|
||||
// `size` bytes), then call back _ifcv_on_range_done(reqId, ok). Async. Local:
|
||||
// Blob.slice. Remote: an HTTP Range request — if a server ignores Range and
|
||||
// returns the whole body (200), slice out the requested window so it still
|
||||
// works (just without the bandwidth saving).
|
||||
EM_JS(void, ifcvReadRangeInto, (int reqId, double offset, double size, void* dst), {
|
||||
var deliver = function(ok, buf) {
|
||||
if (ok && buf) HEAPU8.set(new Uint8Array(buf), dst);
|
||||
Module["_ifcv_on_range_done"](reqId, ok ? 1 : 0);
|
||||
};
|
||||
var f = Module["__ifcvFile"];
|
||||
if (!f) { Module["_ifcv_on_range_done"](reqId, 0); return; }
|
||||
f.slice(offset, offset + size).arrayBuffer().then(function(buf) {
|
||||
HEAPU8.set(new Uint8Array(buf), dst);
|
||||
Module["_ifcv_on_range_done"](reqId, 1);
|
||||
}).catch(function(e) {
|
||||
Module["_ifcv_on_range_done"](reqId, 0);
|
||||
});
|
||||
if (f) {
|
||||
f.slice(offset, offset + size).arrayBuffer()
|
||||
.then(function(buf) { deliver(1, buf); })
|
||||
.catch(function(e) { deliver(0, null); });
|
||||
return;
|
||||
}
|
||||
var url = Module["__ifcvUrl"];
|
||||
if (url) {
|
||||
var end = offset + size - 1;
|
||||
fetch(url, { headers: { "Range": "bytes=" + offset + "-" + end } })
|
||||
.then(function(resp) {
|
||||
if (resp.status !== 206 && resp.status !== 200) { deliver(0, null); return; }
|
||||
var full = resp.status === 200;
|
||||
return resp.arrayBuffer().then(function(buf) {
|
||||
if (full && buf.byteLength > size) buf = buf.slice(offset, offset + size);
|
||||
deliver(1, buf);
|
||||
});
|
||||
})
|
||||
.catch(function(e) { deliver(0, null); });
|
||||
return;
|
||||
}
|
||||
Module["_ifcv_on_range_done"](reqId, 0);
|
||||
});
|
||||
|
||||
// Switch the active source to a remote URL and resolve its total byte length
|
||||
// (needed to bound the metadata tail read), then call _ifcv_source_ready(ok).
|
||||
// Tries a HEAD's Content-Length first, then a 0-0 ranged GET's Content-Range
|
||||
// total. Clears any local File so the URL source is used.
|
||||
EM_JS(void, ifcvBeginUrlSource, (const char* urlPtr), {
|
||||
var url = UTF8ToString(urlPtr);
|
||||
Module["__ifcvFile"] = null;
|
||||
Module["__ifcvUrl"] = url;
|
||||
Module["__ifcvUrlSize"] = 0;
|
||||
var ready = function(ok) { Module["_ifcv_source_ready"](ok ? 1 : 0); };
|
||||
fetch(url, { method: "HEAD" }).then(function(resp) {
|
||||
var len = resp.ok ? parseInt(resp.headers.get("Content-Length") || "0", 10) : 0;
|
||||
if (len > 0) { Module["__ifcvUrlSize"] = len; ready(1); return; }
|
||||
return fetch(url, { headers: { "Range": "bytes=0-0" } }).then(function(r2) {
|
||||
var cr = r2.headers.get("Content-Range"); // "bytes 0-0/12345"
|
||||
var total = cr ? parseInt(cr.split("/")[1] || "0", 10) : 0;
|
||||
Module["__ifcvUrlSize"] = total;
|
||||
ready(total > 0);
|
||||
});
|
||||
}).catch(function(e) { ready(0); });
|
||||
});
|
||||
|
||||
// One in-flight multi-range read: a sequence of coalesced plans, each read
|
||||
@@ -3176,6 +3222,11 @@ struct WebRangeRead {
|
||||
std::unordered_map<int, WebRangeRead> g_web_reads;
|
||||
int g_web_read_next = 1;
|
||||
|
||||
// The ViewportCore awaiting an async URL-source size resolution. Set by
|
||||
// loadSidecarFromUrlWeb; consumed by the ifcv_source_ready callback. One
|
||||
// ViewportCore exists on web, so a single slot suffices.
|
||||
ViewportCore* g_url_ready_core = nullptr;
|
||||
|
||||
// Issue the current plan's Blob.slice, or finish (success) if all plans done.
|
||||
void webIssueCurrentPlan(int id) {
|
||||
auto it = g_web_reads.find(id);
|
||||
@@ -3244,6 +3295,21 @@ extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_on_range_done(int reqId, int ok) {
|
||||
webIssueCurrentPlan(reqId);
|
||||
}
|
||||
|
||||
// JS callback once a remote URL source's total size has been resolved. Runs
|
||||
// the shared metadata bootstrap against the now-active URL source. Exported as
|
||||
// _ifcv_source_ready (see ifcviewer-web/CMakeLists.txt).
|
||||
extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_source_ready(int ok) {
|
||||
ViewportCore* core = g_url_ready_core;
|
||||
g_url_ready_core = nullptr;
|
||||
if (!core) return;
|
||||
if (!ok) {
|
||||
Log::warn() << "ifcviewer-web: could not resolve remote sidecar size "
|
||||
"(server must support HEAD or Range)";
|
||||
return;
|
||||
}
|
||||
core->loadSidecarMetadataWeb("net:model.ifcview");
|
||||
}
|
||||
|
||||
void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_idx) {
|
||||
auto it = models_gpu_.find(model_id);
|
||||
if (it == models_gpu_.end()) return;
|
||||
@@ -3293,36 +3359,40 @@ void ViewportCore::beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_i
|
||||
});
|
||||
}
|
||||
|
||||
void ViewportCore::loadSidecarFromBlobWeb() {
|
||||
// Source-agnostic metadata bootstrap. The active byte-source (local File or
|
||||
// remote URL) is already set on the JS side, so this reads via ifcvFileSize +
|
||||
// webReadRangesAsync without caring which it is: head (16 B) → num_vertex_bytes;
|
||||
// the 4-byte index count after the vertex section; then the metadata tail
|
||||
// (index-section end .. EOF). The bulk vertex/index sections are never read
|
||||
// here — they stream per chunk through beginWebChunkLoad. `source_label` is a
|
||||
// log/identity tag stored as file_path (chunk reads go through the JS source,
|
||||
// not this path).
|
||||
void ViewportCore::loadSidecarMetadataWeb(std::string source_label) {
|
||||
if (!device_ || !queue_) {
|
||||
Log::warn() << "loadSidecarFromBlobWeb: wgpu not initialised";
|
||||
Log::warn() << "loadSidecarMetadataWeb: wgpu not initialised";
|
||||
return;
|
||||
}
|
||||
const double fsize = ifcvFileSize();
|
||||
if (fsize <= 0.0) {
|
||||
Log::warn() << "loadSidecarFromBlobWeb: no File registered";
|
||||
Log::warn() << "loadSidecarMetadataWeb: no active source / zero size";
|
||||
return;
|
||||
}
|
||||
|
||||
// Head (16 B) → num_vertex_bytes; then the 4-byte index count after the
|
||||
// vertex section; then the metadata tail (index-section end .. EOF). Each
|
||||
// hop is a tiny Blob.slice; the bulk vertex/index sections are never read
|
||||
// here — they stream per chunk through beginWebChunkLoad.
|
||||
webReadRangesAsync(0, {{0, SIDECAR_HEAD_BYTES}},
|
||||
[this, fsize](bool ok, std::vector<std::uint8_t>&& head) {
|
||||
[this, fsize, source_label](bool ok, std::vector<std::uint8_t>&& head) {
|
||||
std::uint32_t nvb = 0;
|
||||
if (!ok || !parseSidecarHead(head.data(), head.size(), nvb)) {
|
||||
Log::warn() << "loadSidecarFromBlobWeb: bad sidecar head";
|
||||
Log::warn() << "loadSidecarMetadataWeb: bad sidecar head";
|
||||
return;
|
||||
}
|
||||
const std::uint64_t vsec = SIDECAR_HEAD_BYTES;
|
||||
const std::uint64_t idx_count_off = std::uint64_t(SIDECAR_HEAD_BYTES) + nvb;
|
||||
|
||||
webReadRangesAsync(0, {{idx_count_off, 4}},
|
||||
[this, fsize, nvb, vsec, idx_count_off]
|
||||
[this, fsize, nvb, vsec, idx_count_off, source_label]
|
||||
(bool ok2, std::vector<std::uint8_t>&& cnt) {
|
||||
if (!ok2 || cnt.size() < 4) {
|
||||
Log::warn() << "loadSidecarFromBlobWeb: short index count";
|
||||
Log::warn() << "loadSidecarMetadataWeb: short index count";
|
||||
return;
|
||||
}
|
||||
std::uint32_t num_indices = 0;
|
||||
@@ -3330,26 +3400,26 @@ void ViewportCore::loadSidecarFromBlobWeb() {
|
||||
const std::uint64_t isec = idx_count_off + 4;
|
||||
const std::uint64_t tail_off = isec + std::uint64_t(num_indices) * 4u;
|
||||
if (double(tail_off) > fsize) {
|
||||
Log::warn() << "loadSidecarFromBlobWeb: tail offset past EOF";
|
||||
Log::warn() << "loadSidecarMetadataWeb: tail offset past EOF";
|
||||
return;
|
||||
}
|
||||
const std::uint64_t tail_len = std::uint64_t(fsize) - tail_off;
|
||||
|
||||
webReadRangesAsync(0, {{tail_off, tail_len}},
|
||||
[this, vsec, nvb, isec, num_indices]
|
||||
[this, vsec, nvb, isec, num_indices, source_label]
|
||||
(bool ok3, std::vector<std::uint8_t>&& tail) {
|
||||
if (!ok3) {
|
||||
Log::warn() << "loadSidecarFromBlobWeb: tail read failed";
|
||||
Log::warn() << "loadSidecarMetadataWeb: tail read failed";
|
||||
return;
|
||||
}
|
||||
StreamingSidecar sc;
|
||||
sc.file_path = "blob:model.ifcview";
|
||||
sc.file_path = source_label;
|
||||
sc.vertex_section_offset = vsec;
|
||||
sc.vertex_total_bytes = nvb;
|
||||
sc.index_section_offset = isec;
|
||||
sc.index_total_count = num_indices;
|
||||
if (!parseSidecarTail(tail.data(), tail.size(), sc.meta)) {
|
||||
Log::warn() << "loadSidecarFromBlobWeb: bad metadata tail";
|
||||
Log::warn() << "loadSidecarMetadataWeb: bad metadata tail";
|
||||
return;
|
||||
}
|
||||
const std::size_t n_meshes = sc.meta.meshes.size();
|
||||
@@ -3358,16 +3428,33 @@ void ViewportCore::loadSidecarFromBlobWeb() {
|
||||
applyCachedModel(mid, std::move(sc));
|
||||
auto mit = models_gpu_.find(mid);
|
||||
if (mit != models_gpu_.end())
|
||||
mit->second.streaming_from_blob = true;
|
||||
mit->second.streaming_from_web = true;
|
||||
viewAll();
|
||||
host_->requestFrame();
|
||||
Log::info() << "ifcviewer-web: loaded blob sidecar (id "
|
||||
<< mid << ", " << n_meshes << " meshes, "
|
||||
Log::info() << "ifcviewer-web: loaded sidecar (" << source_label
|
||||
<< ", id " << mid << ", " << n_meshes << " meshes, "
|
||||
<< n_instances << " instances)";
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void ViewportCore::loadSidecarFromBlobWeb() {
|
||||
// The picked File is already on Module.__ifcvFile (set by shell.html); the
|
||||
// metadata bootstrap reads it via Blob.slice.
|
||||
loadSidecarMetadataWeb("blob:model.ifcview");
|
||||
}
|
||||
|
||||
void ViewportCore::loadSidecarFromUrlWeb(std::string url) {
|
||||
if (!device_ || !queue_) {
|
||||
Log::warn() << "loadSidecarFromUrlWeb: wgpu not initialised";
|
||||
return;
|
||||
}
|
||||
// Resolve the URL's total size first (async); ifcv_source_ready then runs
|
||||
// the shared bootstrap once Module.__ifcvUrl/__ifcvUrlSize are populated.
|
||||
g_url_ready_core = this;
|
||||
ifcvBeginUrlSource(url.c_str());
|
||||
}
|
||||
#endif // __EMSCRIPTEN__
|
||||
|
||||
void ViewportCore::finalizeModel(std::uint32_t model_id) {
|
||||
|
||||
@@ -357,19 +357,24 @@ public:
|
||||
std::uint32_t loadSidecarFromPath(const std::string& path);
|
||||
|
||||
#if defined(__EMSCRIPTEN__)
|
||||
// Web byte-range load (#88). Loads a sidecar from the JS-registered
|
||||
// File (Module.__ifcvFile) WITHOUT copying the whole file into the
|
||||
// wasm heap: the head + tail metadata are read via Blob.slice, the
|
||||
// streaming model is built, and it is tagged blob-sourced so each
|
||||
// chunk's vertex/index byte ranges are pulled lazily through the async
|
||||
// path. Asynchronous — returns immediately and frames the model from
|
||||
// the JS completion callback. resetScene() first to replace.
|
||||
// Web byte-range load (#88). Streams a sidecar from a JS-side source
|
||||
// WITHOUT copying the whole file into the wasm heap: head + tail metadata
|
||||
// are read via byte ranges, the streaming model is built, and it is tagged
|
||||
// web-sourced so each chunk's vertex/index ranges are pulled lazily.
|
||||
// Asynchronous — returns immediately and frames the model from the JS
|
||||
// completion callback. resetScene() first to replace.
|
||||
//
|
||||
// - Blob: the picked File on Module.__ifcvFile (Blob.slice).
|
||||
// - URL: a remote sidecar (HTTP Range); resolves total size first, then
|
||||
// runs the shared bootstrap via the ifcv_source_ready callback.
|
||||
void loadSidecarFromBlobWeb();
|
||||
void loadSidecarFromUrlWeb(std::string url);
|
||||
void loadSidecarMetadataWeb(std::string source_label);
|
||||
|
||||
// Kick off the async blob read of one chunk's vertex + index byte
|
||||
// ranges. applyStreamedChunk runs in the JS completion callback;
|
||||
// c.is_loading is held until then. No-op if the model/chunk vanished
|
||||
// mid-flight (e.g. a resetScene landed between issue and completion).
|
||||
// Kick off the async read of one chunk's vertex + index byte ranges (from
|
||||
// the active web source). applyStreamedChunk runs in the JS completion
|
||||
// callback; c.is_loading is held until then. No-op if the model/chunk
|
||||
// vanished mid-flight (e.g. a resetScene landed between issue and done).
|
||||
void beginWebChunkLoad(std::uint32_t model_id, std::size_t chunk_idx);
|
||||
#endif
|
||||
|
||||
|
||||
Reference in New Issue
Block a user