diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 724b0400d3..430f192f48 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -195,9 +195,10 @@ int fillIdsAscending(const std::unordered_set& ids, // Quote `s` as a JSON string literal. IFC names come straight from the model // and can hold quotes, backslashes and control characters; UTF-8 continuation // bytes are already legal JSON and pass through untouched. -std::string jsonString(const std::string& s) { - std::string out = "\""; - for (unsigned char c : s) { +void appendJsonString(std::string& out, const char* data, std::uint32_t length) { + out += '"'; + for (std::uint32_t i = 0; i < length; ++i) { + const unsigned char c = (unsigned char)data[i]; switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; @@ -216,9 +217,10 @@ std::string jsonString(const std::string& s) { } } } - return out + '"'; + out += '"'; } + NavKind classifyPress(const ViewportCore::NavBindings& b, int em_button, bool shift, bool ctrl, bool alt) { using MB = ViewportCore::MouseBtn; using M = ViewportCore::NavMod; @@ -822,27 +824,49 @@ extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_get_model_georef_c(int source_id, doubl // Promise the JS layer is holding. extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_request_objects_c(int token) { if (!g_app || !g_app->ready) { - EM_ASM({ if (Module.__ifcvOnObjects) Module.__ifcvOnObjects($0, '[]'); }, token); + EM_ASM({ if (Module.__ifcvOnObjectsDone) Module.__ifcvOnObjectsDone($0); }, token); return; } g_app->core.loadAllElementMetadataWeb([token](bool) { // Partial failures are not fatal: a model whose element block failed to // fetch simply contributes no rows, and the rest still resolve. - std::string json = "["; - bool first = true; - for (const ViewportCore::ElementRef& e : g_app->core.elements()) { - if (!first) json += ','; - first = false; - json += "{\"objectId\":" + std::to_string(e.object_id) - + ",\"model\":" + std::to_string(e.model_index) - + ",\"sourceId\":" + std::to_string(e.source_id) - + ",\"guid\":" + jsonString(e.guid) - + ",\"name\":" + jsonString(e.name) - + ",\"type\":" + jsonString(e.type) + '}'; + // + // Serialised one model per batch, straight from string-table slices. + // The whole-scene single-string version materialised three string + // copies per element plus a scene-sized JSON blob simultaneously — + // a 400+ MB transient at ~600k elements, and the wasm heap never + // returns pages, so that peak became the session's floor. Peak is + // now one model's JSON; the string keeps its capacity across models + // so it reallocates only up to the largest one. + std::string json; + const int model_count = g_app->core.streamingModelCount(); + for (int model_index = 0; model_index < model_count; ++model_index) { + json.clear(); + json += '['; + bool first = true; + g_app->core.visitModelElements(model_index, + [&](const ViewportCore::ElementSlices& e) { + if (!first) json += ','; + first = false; + json += "{\"objectId\":"; + json += std::to_string(e.object_id); + json += ",\"model\":"; + json += std::to_string(model_index); + json += ",\"sourceId\":"; + json += std::to_string(e.source_id); + json += ",\"guid\":"; + appendJsonString(json, e.guid, e.guid_len); + json += ",\"name\":"; + appendJsonString(json, e.name, e.name_len); + json += ",\"type\":"; + appendJsonString(json, e.type, e.type_len); + json += '}'; + }); + json += ']'; + EM_ASM({ if (Module.__ifcvOnObjectsBatch) Module.__ifcvOnObjectsBatch($0, UTF8ToString($1)); }, + token, json.c_str()); } - json += ']'; - EM_ASM({ if (Module.__ifcvOnObjects) Module.__ifcvOnObjects($0, UTF8ToString($1)); }, - token, json.c_str()); + EM_ASM({ if (Module.__ifcvOnObjectsDone) Module.__ifcvOnObjectsDone($0); }, token); }); } diff --git a/src/ifcviewer-web/web/ifcviewer.js b/src/ifcviewer-web/web/ifcviewer.js index dfa471fba0..b4cb80c28a 100644 --- a/src/ifcviewer-web/web/ifcviewer.js +++ b/src/ifcviewer-web/web/ifcviewer.js @@ -257,13 +257,22 @@ // Completion side of ifcv_request_objects_c: the element tables have all // landed and the scene's objects are ready as JSON. `token` matches the // request to its pending Promise. + // token -> {rows, resolve}. The wasm side streams the objects array one + // model per batch (so the whole-scene JSON never exists in one string on + // its heap); Done resolves with the accumulated rows. const pendingObjects = new Map(); let objectsToken = 0; - Module.__ifcvOnObjects = function (token, json) { - const resolve = pendingObjects.get(token); - if (!resolve) return; + Module.__ifcvOnObjectsBatch = function (token, json) { + const pending = pendingObjects.get(token); + if (!pending) return; + const rows = JSON.parse(json); + for (let i = 0; i < rows.length; ++i) pending.rows.push(rows[i]); + }; + Module.__ifcvOnObjectsDone = function (token) { + const pending = pendingObjects.get(token); + if (!pending) return; pendingObjects.delete(token); - resolve(JSON.parse(json)); + pending.resolve(pending.rows); }; // Some test harnesses / the fullscreen page want the raw module on window. @@ -442,7 +451,7 @@ getObjects: function () { const token = ++objectsToken; return new Promise(function (resolve) { - pendingObjects.set(token, resolve); + pendingObjects.set(token, { rows: [], resolve: resolve }); Module._ifcv_request_objects_c(token); }).then(function (objects) { objectIndex = new Map(); diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 3bb80a2ef2..2acc65d501 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -4514,6 +4514,36 @@ std::vector ViewportCore::elements() const { return out; } +void ViewportCore::visitModelElements( + int model_index, + const std::function& visit) const { + const std::vector ids = modelIdsInLoadOrder(); + if (model_index < 0 || std::size_t(model_index) >= ids.size()) return; + auto it = models_gpu_.find(ids[std::size_t(model_index)]); + if (it == models_gpu_.end()) return; + const ModelGpuData& m = it->second; + auto slice = [&m](std::uint32_t offset, std::uint32_t length, + const char*& out_ptr, std::uint32_t& out_len) { + // Out-of-range or zero-length offsets mean "no string was written". + if (length > 0 && std::size_t(offset) + length <= m.string_table.size()) { + out_ptr = m.string_table.data() + offset; + out_len = length; + } else { + out_ptr = nullptr; + out_len = 0; + } + }; + for (const ElementTableRecord& e : m.elements) { + ElementSlices out; + out.object_id = e.object_id; + out.source_id = m.web_source_id; + slice(e.guid_offset, e.guid_length, out.guid, out.guid_len); + slice(e.name_offset, e.name_length, out.name, out.name_len); + slice(e.type_offset, e.type_length, out.type, out.type_len); + visit(out); + } +} + bool ViewportCore::elementForObject(std::uint32_t object_id, ElementRef& out) const { InstanceCompose::InstanceLookup lk; if (!findInstance(object_id, lk)) return false; diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index e5015e99df..3890cce69c 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -597,6 +597,20 @@ public: // resident. On web that means calling loadAllElementMetadataWeb first — // models still lazily un-fetched simply contribute nothing. std::vector elements() const; + // One model's elements (by load-order index, same as modelProgress), + // handed out as slices into the model's string table — valid only for + // the duration of the visit, no per-element string copies. The web + // objects export serialises hundreds of thousands of elements straight + // from these; materialising ElementRefs there tripled the peak heap. + struct ElementSlices { + std::uint32_t object_id = 0; + int source_id = -1; + const char* guid = nullptr; std::uint32_t guid_len = 0; + const char* name = nullptr; std::uint32_t name_len = 0; + const char* type = nullptr; std::uint32_t type_len = 0; + }; + void visitModelElements(int model_index, + const std::function& visit) const; // The single element behind one object_id — the pick path's lookup, which // must not pay for materialising the whole table. Scans only the model that