diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 1299979f07..1a5b2ce29f 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -107,14 +107,18 @@ target_link_options(IfcViewerWeb PRIVATE # 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. + # The _ifcv_{get,set,apply}_* family is the scripting API (camera, selection, + # visibility, colour override, object enumeration) that web/ifcviewer.js + # wraps; _malloc/_free let it marshal id arrays into the wasm heap. # EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't # add them to Module. ccall lets the host page (web/ifcviewer.js) 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_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c']" + "-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c']" # ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c. # HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large - # sidecar streams by range instead of loading whole). Standard, zero-cost. - "-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8','UTF8ToString']" + # sidecar streams by range instead of loading whole). HEAPU32/HEAPF32: the + # scripting API marshals object-id arrays and the camera state through them. + "-sEXPORTED_RUNTIME_METHODS=['ccall','HEAPU8','HEAPU32','HEAPF32','UTF8ToString']" # 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). @@ -134,18 +138,49 @@ target_link_options(IfcViewerWeb PRIVATE # MODULARIZE emits IfcViewerWeb.js (the createIfcViewer factory) + .wasm. set_target_properties(IfcViewerWeb PROPERTIES SUFFIX ".js") +# --embed-file above is a link-time input, but it lives inside a link OPTION, so +# CMake cannot see it as a dependency: regenerating sample.ifcview (make_sample.py) +# would otherwise leave the previous model baked into the wasm with ninja +# reporting "no work to do". Name it explicitly so a changed sample relinks. +set_property(TARGET IfcViewerWeb APPEND + PROPERTY LINK_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/sample.ifcview") + # Copy the static example pages + the JS integration helper next to the wasm so # a plain `python3 -m http.server --directory build-web` serves the whole demo: # /IfcViewerWeb.html fullscreen example # /embedded.html embedded viewer + DOM model list / selection (JS API) -# /index.html links to both +# /scripting.html the full scripting API — camera, selection, visibility, colour +# /index.html links to all three set(IFCVIEWERWEB_STATIC "${CMAKE_CURRENT_SOURCE_DIR}/web/ifcviewer.js" "${CMAKE_CURRENT_SOURCE_DIR}/web/IfcViewerWeb.html" "${CMAKE_CURRENT_SOURCE_DIR}/web/embedded.html" + "${CMAKE_CURRENT_SOURCE_DIR}/web/scripting.html" "${CMAKE_CURRENT_SOURCE_DIR}/web/index.html" ) -add_custom_command(TARGET IfcViewerWeb POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - ${IFCVIEWERWEB_STATIC} "$" - COMMENT "Copying web example pages next to IfcViewerWeb.js") +# One copy rule per page, each DEPENDing on its own source, so editing a page or +# ifcviewer.js re-copies it. The previous POST_BUILD command on IfcViewerWeb only +# fired when the wasm itself relinked, which left a stale copy in the build dir — +# the served demo (and the Playwright tests, which run against it) kept the old +# file with ninja reporting "no work to do". +# +# The copies go next to the wasm, i.e. the executable's output directory. Pin +# that to the build root rather than reading it back through +# $: a generator expression in a custom command's +# OUTPUT may not reference a target. +set_target_properties(IfcViewerWeb PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}") + +set(IFCVIEWERWEB_STATIC_OUT) +foreach(static_src IN LISTS IFCVIEWERWEB_STATIC) + get_filename_component(static_name "${static_src}" NAME) + set(static_dst "${CMAKE_CURRENT_BINARY_DIR}/${static_name}") + add_custom_command( + OUTPUT "${static_dst}" + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${static_src}" "${static_dst}" + DEPENDS "${static_src}" + COMMENT "Copying ${static_name} next to IfcViewerWeb.js" + VERBATIM) + list(APPEND IFCVIEWERWEB_STATIC_OUT "${static_dst}") +endforeach() +add_custom_target(IfcViewerWebStatic ALL DEPENDS ${IFCVIEWERWEB_STATIC_OUT}) diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index c7404f032d..b2aac97ab8 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -38,8 +38,11 @@ #include #include #include +#include #include #include +#include +#include #include namespace { @@ -156,6 +159,62 @@ void canvasClientOrigin(double& left, double& top) { }); } +// Tell the page the selection changed; it pulls the new id set back through +// ifcv_get_selection_c. Every wasm-side mutation (single pick, marquee, hide- +// selected) fires this, so a host UI tracking multi-selection never has to poll. +// The JS API layer (web/ifcviewer.js) also fires it after its own programmatic +// mutations, so listeners see one event stream regardless of the source. +void notifySelectionChanged() { + EM_ASM({ if (Module.__ifcvOnSelectionChange) Module.__ifcvOnSelectionChange(); }); +} + +// The (pointer, count) id array the JS side marshals into the wasm heap. A null +// pointer with a zero count is a legitimate empty list — "clear the selection" +// arrives that way — so it must not be turned into pointer arithmetic on null. +std::vector idsFrom(const std::uint32_t* ids, int n) { + if (!ids || n <= 0) return {}; + return std::vector(ids, ids + n); +} + +// The reading half of the same convention: write `ids` ascending into `out` +// (at most `max` of them) and return the TOTAL, so a caller that passed a +// too-small buffer — or none at all — knows what to allocate and can ask again. +int fillIdsAscending(const std::unordered_set& ids, + std::uint32_t* out, int max) { + std::vector sorted(ids.begin(), ids.end()); + std::sort(sorted.begin(), sorted.end()); + const int n = std::min(int(sorted.size()), std::max(0, max)); + if (out && n > 0) std::copy_n(sorted.begin(), n, out); + return int(sorted.size()); +} + +// 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) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char esc[7]; + std::snprintf(esc, sizeof(esc), "\\u%04x", c); + out += esc; + } else { + out += char(c); + } + } + } + return out + '"'; +} + NavKind classifyPress(const ViewportCore::NavBindings& b, int em_button, bool shift, bool ctrl, bool alt) { using MB = ViewportCore::MouseBtn; using M = ViewportCore::NavMod; @@ -274,6 +333,7 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { app->core.picksInRectAsync(rx, ry, rw, rh, [app, add, remove](std::vector ids) { app->core.applyMarqueeToSelection(ids, add, remove); + notifySelectionChanged(); app->host.requestFrame(); }); } else { @@ -281,6 +341,7 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { const int px = int(app->down_x * dpr), py = int(app->down_y * dpr); app->core.pickObjectAtAsync(px, py, [app, add, remove](std::uint32_t id) { app->core.applyPickToSelection(id, add, remove); + notifySelectionChanged(); // Surface the pick to JS: resolve + emit the GUID for a real hit; // emit an empty selection when a plain click deselects (id 0). if (id != 0) { @@ -521,12 +582,118 @@ extern "C" EMSCRIPTEN_KEEPALIVE int fly_is_active_c() { } // Visibility + X-ray, for the toolbar (same ops as the H/Shift+H/Alt+H/Alt+X keys). -extern "C" EMSCRIPTEN_KEEPALIVE void hide_selected_c() { if (g_app && g_app->ready) g_app->core.hideSelected(); } +extern "C" EMSCRIPTEN_KEEPALIVE void hide_selected_c() { + if (!g_app || !g_app->ready) return; + g_app->core.hideSelected(); // hiding deselects + notifySelectionChanged(); +} extern "C" EMSCRIPTEN_KEEPALIVE void isolate_selected_c() { if (g_app && g_app->ready) g_app->core.isolateSelected(); } extern "C" EMSCRIPTEN_KEEPALIVE void show_all_c() { if (g_app && g_app->ready) g_app->core.showAll(); } +extern "C" EMSCRIPTEN_KEEPALIVE void hide_all_c() { if (g_app && g_app->ready) g_app->core.hideAll(); } extern "C" EMSCRIPTEN_KEEPALIVE void toggle_xray_c() { if (g_app && g_app->ready) g_app->core.toggleXray(); } extern "C" EMSCRIPTEN_KEEPALIVE int xray_is_active_c() { return (g_app && g_app->ready && g_app->core.xrayActive()) ? 1 : 0; } +// =========================================================================== +// Scripting API (web/ifcviewer.js wraps these into the IfcViewer object) +// =========================================================================== +// +// Arrays cross the boundary as (pointer, count) into the wasm heap; JS +// allocates with _malloc, fills HEAPU32, calls, frees. The getters follow the +// "ask twice" convention: they always return the TOTAL count and fill at most +// `max` entries, so a caller can size a buffer with (null, 0) and call again. +// Ids are object_ids — globally unique across federated models. The JS layer +// maps IFC GUIDs onto them from the element table (ifcv_request_objects_c). + +// Camera state, as 9 floats: target xyz, distance, yaw°, pitch°, eye xyz. Eye +// comes from the core rather than being re-derived in JS, so the orbit +// convention has exactly one definition. +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_get_camera_c(float* out) { + if (!g_app || !g_app->ready || !out) return; + const ViewportCore::CameraState s = g_app->core.cameraState(); + const Eigen::Vector3f eye = g_app->core.cameraEye(); + out[0] = s.target.x(); out[1] = s.target.y(); out[2] = s.target.z(); + out[3] = s.distance; out[4] = s.yaw; out[5] = s.pitch; + out[6] = eye.x(); out[7] = eye.y(); out[8] = eye.z(); +} +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_camera_c(float tx, float ty, float tz, + float dist, float yaw, float pitch) { + if (!g_app || !g_app->ready) return; + g_app->core.setCamera(tx, ty, tz, dist, yaw, pitch); +} +// toggleProjection is the only projection mutator in the core; drive it to the +// requested state so JS doesn't have to read-then-toggle. +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_ortho_c(int on) { + if (!g_app || !g_app->ready) return; + if (bool(on) != g_app->core.projectionOrtho()) g_app->core.toggleProjection(); +} + +// Selection. +extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_get_selection_c(std::uint32_t* out, int max) { + if (!g_app || !g_app->ready) return 0; + return fillIdsAscending(g_app->core.selection().selectionIds(), out, max); +} +extern "C" EMSCRIPTEN_KEEPALIVE std::uint32_t ifcv_get_active_object_c() { + return (g_app && g_app->ready) ? g_app->core.selection().activeId() : 0u; +} +// mode: 0 replace (n == 0 clears), 1 add, 2 remove. applyMarqueeToSelection is +// the core's selection primitive and already means exactly this. +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_apply_selection_c(const std::uint32_t* ids, + int n, int mode) { + if (!g_app || !g_app->ready) return; + g_app->core.applyMarqueeToSelection(idsFrom(ids, n), mode == 1, mode == 2); +} + +// Per-object visibility. show_all_c / hide_all_c above cover the bulk cases. +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_visible_c(const std::uint32_t* ids, + int n, int visible) { + if (!g_app || !g_app->ready) return; + g_app->core.setObjectsVisible(idsFrom(ids, n), visible != 0); +} +extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_get_hidden_c(std::uint32_t* out, int max) { + if (!g_app || !g_app->ready) return 0; + return fillIdsAscending(g_app->core.hiddenIds(), out, max); +} + +// Colour override. rgba8 is packed 0xAABBGGRR; 0 restores the baked colour. +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_color_c(const std::uint32_t* ids, int n, + std::uint32_t rgba8) { + if (!g_app || !g_app->ready) return; + g_app->core.setObjectsColor(idsFrom(ids, n), rgba8); +} +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_clear_colors_c() { + if (g_app && g_app->ready) g_app->core.clearObjectColors(); +} + +// Every object in the scene, as JSON. Asynchronous: the element tables are +// fetched lazily per model on web (first paint must not wait on them), so this +// makes sure they are all resident and only then hands the page its array via +// Module.__ifcvOnObjects(token, json). `token` correlates the reply with the +// 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); + 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) + + ",\"guid\":" + jsonString(e.guid) + + ",\"name\":" + jsonString(e.name) + + ",\"type\":" + jsonString(e.type) + '}'; + } + json += ']'; + EM_ASM({ if (Module.__ifcvOnObjects) Module.__ifcvOnObjects($0, UTF8ToString($1)); }, + token, json.c_str()); + }); +} + // Section-cut tool: toggle the drop-a-plane mode, clear all planes, query state. extern "C" EMSCRIPTEN_KEEPALIVE void toggle_section_c() { if (!g_app || !g_app->ready) return; diff --git a/src/ifcviewer-web/make_sample.py b/src/ifcviewer-web/make_sample.py new file mode 100644 index 0000000000..9aab28ab9d --- /dev/null +++ b/src/ifcviewer-web/make_sample.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Author the demo model the web viewer embeds (sample.ifcview). + +The web build bakes one small sidecar into MEMFS (--embed-file in +CMakeLists.txt) so every example page renders something before the user picks a +file. This script is how that fixture is produced, so it can be regenerated +rather than being an opaque committed blob: + + python3 make_sample.py # writes sample.ifc + sample.ifcview + ninja -C ../../build-web # re-embeds it + +It needs ifcopenshell (Python) to author the IFC, and the sidecar_bake tool from +the desktop build to convert it: + + ninja -C ../../build-viewer sidecar_bake + +Three elements — a slab, a wall and a beam — deliberately in DIFFERENT places +and different shapes. The scripting examples hide and re-colour objects one at a +time, so coincident geometry would make those calls look like no-ops: whatever +you hid would still be there, drawn by the object behind it. +""" + +import shutil +import subprocess +import sys +from pathlib import Path + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit + +HERE = Path(__file__).parent +BAKE = HERE / "../../build-viewer/ifcviewer/sidecar_bake" + +# (class, name, footprint w x d in m, height in m, placement x/y/z in m) +ELEMENTS = [ + ("IfcSlab", "Slab", (6.0, 6.0), 0.2, (-3.0, -3.0, 0.0)), + ("IfcWall", "Wall", (5.0, 0.3), 3.0, (-2.5, -2.5, 0.2)), + ("IfcBeam", "Beam", (0.3, 5.0), 0.3, (2.0, -2.5, 3.2)), +] + + +def build_ifc(path: Path) -> None: + f = ifcopenshell.api.project.create_file(version="IFC4") + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="Web viewer sample") + ifcopenshell.api.unit.assign_unit(f, length={"is_metric": True, "raw": "METERS"}) + body = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", + target_view="MODEL_VIEW", parent=body) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="Site") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground floor") + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=site) + + for ifc_class, name, (w, d), height, (x, y, z) in ELEMENTS: + element = ifcopenshell.api.root.create_entity(f, ifc_class=ifc_class, name=name) + ifcopenshell.api.spatial.assign_container( + f, products=[element], relating_structure=storey) + + # A rectangular profile extruded up — enough to be a recognisable, + # distinctly-placed solid without dragging in a whole modelling stack. + profile = f.create_entity( + "IfcRectangleProfileDef", ProfileType="AREA", XDim=w, YDim=d, + Position=f.create_entity( + "IfcAxis2Placement2D", + Location=f.create_entity("IfcCartesianPoint", Coordinates=(w / 2, d / 2)))) + solid = f.create_entity( + "IfcExtrudedAreaSolid", SweptArea=profile, Depth=height, + Position=f.create_entity( + "IfcAxis2Placement3D", + Location=f.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))), + ExtrudedDirection=f.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0))) + representation = f.create_entity( + "IfcShapeRepresentation", ContextOfItems=body, RepresentationIdentifier="Body", + RepresentationType="SweptSolid", Items=[solid]) + ifcopenshell.api.geometry.assign_representation( + f, product=element, representation=representation) + ifcopenshell.api.geometry.edit_object_placement( + f, product=element, + matrix=ifcopenshell.util.placement.a2p((x, y, z), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0))) + + f.write(str(path)) + + +def main() -> int: + if not BAKE.exists(): + sys.exit(f"{BAKE} not found — build it with: ninja -C ../../build-viewer sidecar_bake") + + ifc = HERE / "sample.ifc" + build_ifc(ifc) + subprocess.run([str(BAKE), str(ifc)], check=True) + + # sidecar_bake writes .ifcview next to the input. + baked = ifc.with_suffix(".ifcview") + if not baked.exists(): + sys.exit(f"sidecar_bake did not produce {baked}") + shutil.move(baked, HERE / "sample.ifcview") + print(f"wrote {HERE / 'sample.ifcview'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ifcviewer-web/sample.ifc b/src/ifcviewer-web/sample.ifc new file mode 100644 index 0000000000..0483685422 --- /dev/null +++ b/src/ifcviewer-web/sample.ifc @@ -0,0 +1,70 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); +FILE_NAME('/dev/null','2026-07-14T11:12:36+10:00',(''),(''),'IfcOpenShell 0.0.0','IfcOpenShell 0.0.0','Nobody'); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPROJECT('25w1yVg1T899knOFDT7GF4',$,'Web viewer sample',$,$,$,$,(#10),#5); +#2=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); +#3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#4=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); +#5=IFCUNITASSIGNMENT((#4,#2,#3)); +#6=IFCCARTESIANPOINT((0.,0.,0.)); +#7=IFCDIRECTION((0.,0.,1.)); +#8=IFCDIRECTION((1.,0.,0.)); +#9=IFCAXIS2PLACEMENT3D(#6,#7,#8); +#10=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0000000000000001E-05,#9,$); +#11=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#10,$,.MODEL_VIEW.,$); +#12=IFCSITE('31n6KRhg1CJRR_2GnxMK2C',$,'Site',$,$,$,$,$,$,$,$,$,$,$); +#13=IFCBUILDINGSTOREY('2OPYCT0uH8y99_46GIBF1S',$,'Ground floor',$,$,$,$,$,$,$); +#14=IFCRELAGGREGATES('2UQ_tvpc9CAvlRrhAnEotz',$,$,$,#1,(#12)); +#15=IFCRELAGGREGATES('3xl_DyScj2aQNKmJ17ulTc',$,$,$,#12,(#13)); +#16=IFCSLAB('3gVQKme9v8sg$$0VTyX93s',$,'Slab',$,$,#31,#26,$,$); +#17=IFCRELCONTAINEDINSPATIALSTRUCTURE('0RM$HmilPE6ALvEmMEsF8i',$,$,$,(#47,#32,#16),#13); +#18=IFCCARTESIANPOINT((3.,3.)); +#19=IFCAXIS2PLACEMENT2D(#18,$); +#20=IFCRECTANGLEPROFILEDEF(.AREA.,$,#19,6.,6.); +#21=IFCCARTESIANPOINT((0.,0.,0.)); +#22=IFCAXIS2PLACEMENT3D(#21,$,$); +#23=IFCDIRECTION((0.,0.,1.)); +#24=IFCEXTRUDEDAREASOLID(#20,#22,#23,0.20000000000000001); +#25=IFCSHAPEREPRESENTATION(#11,'Body','SweptSolid',(#24)); +#26=IFCPRODUCTDEFINITIONSHAPE($,$,(#25)); +#27=IFCCARTESIANPOINT((-3.,-3.,0.)); +#28=IFCDIRECTION((0.,0.,1.)); +#29=IFCDIRECTION((1.,0.,0.)); +#30=IFCAXIS2PLACEMENT3D(#27,#28,#29); +#31=IFCLOCALPLACEMENT($,#30); +#32=IFCWALL('2HL9ynl8T508xbd_ydwK8F',$,'Wall',$,$,#46,#41,$,$); +#33=IFCCARTESIANPOINT((2.5,0.14999999999999999)); +#34=IFCAXIS2PLACEMENT2D(#33,$); +#35=IFCRECTANGLEPROFILEDEF(.AREA.,$,#34,5.,0.29999999999999999); +#36=IFCCARTESIANPOINT((0.,0.,0.)); +#37=IFCAXIS2PLACEMENT3D(#36,$,$); +#38=IFCDIRECTION((0.,0.,1.)); +#39=IFCEXTRUDEDAREASOLID(#35,#37,#38,3.); +#40=IFCSHAPEREPRESENTATION(#11,'Body','SweptSolid',(#39)); +#41=IFCPRODUCTDEFINITIONSHAPE($,$,(#40)); +#42=IFCCARTESIANPOINT((-2.5,-2.5,0.20000000000000001)); +#43=IFCDIRECTION((0.,0.,1.)); +#44=IFCDIRECTION((1.,0.,0.)); +#45=IFCAXIS2PLACEMENT3D(#42,#43,#44); +#46=IFCLOCALPLACEMENT($,#45); +#47=IFCBEAM('2kfTOWRMj0NvT3RcC4doCF',$,'Beam',$,$,#61,#56,$,$); +#48=IFCCARTESIANPOINT((0.14999999999999999,2.5)); +#49=IFCAXIS2PLACEMENT2D(#48,$); +#50=IFCRECTANGLEPROFILEDEF(.AREA.,$,#49,0.29999999999999999,5.); +#51=IFCCARTESIANPOINT((0.,0.,0.)); +#52=IFCAXIS2PLACEMENT3D(#51,$,$); +#53=IFCDIRECTION((0.,0.,1.)); +#54=IFCEXTRUDEDAREASOLID(#50,#52,#53,0.29999999999999999); +#55=IFCSHAPEREPRESENTATION(#11,'Body','SweptSolid',(#54)); +#56=IFCPRODUCTDEFINITIONSHAPE($,$,(#55)); +#57=IFCCARTESIANPOINT((2.,-2.5,3.2000000000000002)); +#58=IFCDIRECTION((0.,0.,1.)); +#59=IFCDIRECTION((1.,0.,0.)); +#60=IFCAXIS2PLACEMENT3D(#57,#58,#59); +#61=IFCLOCALPLACEMENT($,#60); +ENDSEC; +END-ISO-10303-21; diff --git a/src/ifcviewer-web/sample.ifcview b/src/ifcviewer-web/sample.ifcview index c6c3550cd8..36d10f10c4 100644 Binary files a/src/ifcviewer-web/sample.ifcview and b/src/ifcviewer-web/sample.ifcview differ diff --git a/src/ifcviewer-web/tests/scripting.spec.mjs b/src/ifcviewer-web/tests/scripting.spec.mjs new file mode 100644 index 0000000000..0223bc4a09 --- /dev/null +++ b/src/ifcviewer-web/tests/scripting.spec.mjs @@ -0,0 +1,250 @@ +import { test, expect } from '@playwright/test'; + +// The JavaScript scripting API (web/ifcviewer.js + the _ifcv_* wasm exports), +// driven against the real GPU through /scripting.html — the demo page whose +// buttons ARE the API. Everything runs against the embedded sample sidecar +// (a slab, a wall and a beam), so no fixture file is needed. +// +// Each assertion pins a capability a host page depends on: read/set camera, +// read/set multi-selection, enumerate objects by GlobalId, per-object +// visibility, show/hide all, and colour override. Colour and visibility are +// checked at the PIXELS, not just at the API — the whole point of those two is +// that the GPU state actually changed. + +// Boot the scripting page and wait until the API can answer for the sample. +async function open(page) { + const errors = []; + page.on('console', (msg) => { + const t = msg.text(); + if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(t)) errors.push(t); + }); + page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); + + await page.goto('/scripting.html'); + // The page publishes the API as window.viewer; isLive() flips once the GPU + // device is up and the RAF loop is ticking. Timing out here means WebGPU + // never came up — a real failure, not a flake. + await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), null, + { timeout: 30_000 }); + // The object table fills once the (async) element-metadata fetch lands. + await page.waitForFunction( + () => document.querySelectorAll('#object-table tr[data-id]').length > 0, + null, { timeout: 30_000 }); + // Geometry streams in a few frames AFTER the model's metadata is up. The + // pixel assertions below all compare against a baseline shot, so wait until + // every chunk is resident — otherwise the baseline is an empty scene and, say, + // hideAll() "changes nothing" because it was already blank. + await page.waitForFunction(() => { + const v = window.viewer; + const p = v.modelProgress(0); + return v.modelCount() > 0 && p.total > 0 && p.resident === p.total; + }, null, { timeout: 30_000 }); + await page.waitForTimeout(500); // let the settle burst paint + return errors; +} + +const shot = (page) => page.locator('#viewer-canvas').screenshot(); + +test('enumerates every object with its GlobalId, name, type and model', async ({ page }) => { + const errors = await open(page); + + const objects = await page.evaluate(() => window.viewer.getObjects()); + expect(objects.length).toBe(3); // the sample: slab + wall + beam + expect(objects.map((o) => o.type).sort()).toEqual(['IfcBeam', 'IfcSlab', 'IfcWall']); + for (const o of objects) { + expect(o.guid, 'every object carries an IFC GlobalId').toMatch(/^[0-9A-Za-z_$]{22}$/); + expect(o.objectId).toBeGreaterThan(0); + expect(o.model, 'single model → load index 0').toBe(0); + } + // The table rendered from that same array. + await expect(page.locator('#object-table tr[data-id]')).toHaveCount(3); + expect(errors, errors.join('\n')).toEqual([]); +}); + +test('reads the camera and sets it back, round-tripping a saved view', async ({ page }) => { + const errors = await open(page); + + const start = await page.evaluate(() => window.viewer.getCamera()); + expect(start.yaw).toBeCloseTo(45, 1); + expect(start.pitch).toBeCloseTo(30, 1); + expect(start.distance).toBeGreaterThan(0); + expect(start.eye, 'eye is derived from target/distance/yaw/pitch').toHaveLength(3); + expect(start.ortho).toBe(false); + + // Set it somewhere else and read it back. + const moved = await page.evaluate(() => { + window.viewer.setCamera({ yaw: 120, pitch: -15, distance: 30, ortho: true }); + return window.viewer.getCamera(); + }); + expect(moved.yaw).toBeCloseTo(120, 1); + expect(moved.pitch).toBeCloseTo(-15, 1); + expect(moved.distance).toBeCloseTo(30, 1); + expect(moved.ortho).toBe(true); + + // A whole getCamera() result must be valid input to setCamera — this is what + // makes "save view / restore view" a two-liner for a host page. + const restored = await page.evaluate((v) => { + window.viewer.setCamera(v); + return window.viewer.getCamera(); + }, start); + expect(restored.yaw).toBeCloseTo(start.yaw, 1); + expect(restored.pitch).toBeCloseTo(start.pitch, 1); + expect(restored.distance).toBeCloseTo(start.distance, 1); + expect(restored.ortho).toBe(false); + + expect(errors, errors.join('\n')).toEqual([]); +}); + +test('sets, adds to, and clears a multi-object selection (by id and by GlobalId)', async ({ page }) => { + const errors = await open(page); + + const result = await page.evaluate(async () => { + const v = window.viewer; + const objects = await v.getObjects(); + const wall = objects.find((o) => o.type === 'IfcWall'); + const beam = objects.find((o) => o.type === 'IfcBeam'); + + // Every id-taking call accepts an objectId, a GlobalId string, or a whole + // element object. Exercise all three shapes. + const events = []; + v.onSelectionChange((ids) => events.push(ids.length)); + + v.setSelection(wall.objectId); // by objectId + const afterSingle = v.getSelection(); + v.addToSelection(beam.guid); // by GlobalId + const afterAdd = v.getSelection(); + const active = v.getActiveObject(); + const named = v.getSelectedObjects().map((o) => o.type).sort(); + + v.setSelection(objects); // by element objects + const afterAll = v.getSelection(); + v.removeFromSelection(wall); + const afterRemove = v.getSelection(); + v.clearSelection(); + const afterClear = v.getSelection(); + + return { wallId: wall.objectId, beamId: beam.objectId, afterSingle, afterAdd, active, + named, afterAll, afterRemove, afterClear, events }; + }); + + expect(result.afterSingle).toEqual([result.wallId]); + expect(result.afterAdd.sort()).toEqual([result.wallId, result.beamId].sort()); + expect(result.active, 'the last-added object is the active one').toBe(result.beamId); + expect(result.named, 'selected objects carry their IFC identity').toEqual(['IfcBeam', 'IfcWall']); + expect(result.afterAll).toHaveLength(3); + expect(result.afterRemove).toHaveLength(2); + expect(result.afterRemove).not.toContain(result.wallId); + expect(result.afterClear).toEqual([]); + // onSelectionChange fired once per mutation, including the programmatic ones. + expect(result.events).toEqual([1, 2, 3, 2, 0]); + + expect(errors, errors.join('\n')).toEqual([]); +}); + +test('hides one object, hides all, and shows all again — visibly', async ({ page }) => { + const errors = await open(page); + + const full = await shot(page); + + // Hide every object: the scene must empty out to background. + await page.evaluate(() => window.viewer.hideAll()); + await page.waitForTimeout(400); + const empty = await shot(page); + expect(Buffer.compare(full, empty), 'hideAll() did not change the render').not.toBe(0); + expect(await page.evaluate(() => window.viewer.getHidden().length)).toBe(3); + + // Show all: back to the original pixels. + await page.evaluate(() => window.viewer.showAll()); + await page.waitForTimeout(400); + expect(await page.evaluate(() => window.viewer.getHidden().length)).toBe(0); + const restored = await shot(page); + expect(Buffer.compare(full, restored), 'showAll() did not restore the render').toBe(0); + + // Hide a single object by GlobalId: different from both full and empty. + await page.evaluate(async () => { + const v = window.viewer; + const slab = (await v.getObjects()).find((o) => o.type === 'IfcSlab'); + v.hide(slab.guid); + }); + await page.waitForTimeout(400); + const oneHidden = await shot(page); + expect(await page.evaluate(() => window.viewer.getHidden().length)).toBe(1); + expect(Buffer.compare(oneHidden, full), 'hiding one object changed nothing').not.toBe(0); + expect(Buffer.compare(oneHidden, empty), 'hiding one object emptied the scene').not.toBe(0); + + expect(errors, errors.join('\n')).toEqual([]); +}); + +test('overrides object colour, and clears back to the baked colour', async ({ page }) => { + const errors = await open(page); + + const before = await shot(page); + + // Paint every object magenta. Nothing else about the scene changes, so any + // pixel difference is the override landing on the GPU. + await page.evaluate(async () => { + const v = window.viewer; + v.setColor(await v.getObjects(), '#ff00ff'); + }); + await page.waitForTimeout(400); + const painted = await shot(page); + expect(Buffer.compare(before, painted), 'setColor() did not change the render').not.toBe(0); + + // A translucent override reclassifies the instance into the transparent pass — + // a different render again, and the one that would silently do nothing if the + // cull classifier ignored the override's alpha byte. + await page.evaluate(async () => { + const v = window.viewer; + v.setColor(await v.getObjects(), { r: 255, g: 0, b: 255, a: 80 }); + }); + await page.waitForTimeout(400); + const translucent = await shot(page); + expect(Buffer.compare(painted, translucent), 'alpha in a colour override had no effect').not.toBe(0); + + // Clearing restores the model's own colours exactly. + await page.evaluate(() => window.viewer.clearColors()); + await page.waitForTimeout(400); + const cleared = await shot(page); + expect(Buffer.compare(before, cleared), 'clearColors() did not restore the baked colours').toBe(0); + + expect(errors, errors.join('\n')).toEqual([]); +}); + +test('the demo page buttons drive the same API', async ({ page }) => { + const errors = await open(page); + + // Select walls → the readout and the table highlight both follow. + await page.click('#sel-walls'); + await expect(page.locator('#selection-readout')).toContainText('IfcWall'); + await expect(page.locator('#object-table tr.selected')).toHaveCount(1); + + await page.click('#sel-add-beams'); + await expect(page.locator('#object-table tr.selected')).toHaveCount(2); + expect(await page.evaluate(() => window.viewer.getSelection().length)).toBe(2); + + // Hide the selection through the button, then show all again. + await page.click('#vis-hide-sel'); + await expect(page.locator('#vis-hint')).toContainText('2 object(s) hidden'); + await page.click('#vis-show-all'); + await expect(page.locator('#vis-hint')).toContainText('Nothing hidden'); + + // Colour-by-type paints all three, then reset drops every override. + const before = await shot(page); + await page.click('#colour-by-type'); + await page.waitForTimeout(400); + expect(Buffer.compare(before, await shot(page))).not.toBe(0); + await page.click('#colour-clear'); + await page.waitForTimeout(400); + expect(Buffer.compare(before, await shot(page))).toBe(0); + + // Camera buttons move the camera and the readout tracks it. + await page.click('#cam-top'); + await page.waitForTimeout(300); + expect(await page.evaluate(() => window.viewer.getCamera().pitch)).toBeCloseTo(90, 0); + await expect(page.locator('#camera-readout')).toContainText('perspective'); + await page.click('#cam-ortho'); + await page.waitForTimeout(300); + await expect(page.locator('#camera-readout')).toContainText('orthographic'); + + expect(errors, errors.join('\n')).toEqual([]); +}); diff --git a/src/ifcviewer-web/web/ifcviewer.js b/src/ifcviewer-web/web/ifcviewer.js index 4f76bf1f8e..62931aab46 100644 --- a/src/ifcviewer-web/web/ifcviewer.js +++ b/src/ifcviewer-web/web/ifcviewer.js @@ -7,13 +7,26 @@ // // // The canvas element MUST have id="viewer-canvas" — the wasm side hard-codes // that selector for its WebGPU surface and input handlers. +// +// Object identity. Everything the scripting API takes or returns is keyed by +// `objectId`: a u32 the renderer assigns, unique across the federation but only +// meaningful for this session. IFC GlobalIds are the stable identity, and every +// id-taking call also accepts them — resolved through the element table that +// getObjects() fetches. Because that fetch is lazy (per model, so first paint +// never waits on it), a GUID can only be resolved once getObjects() has +// resolved at least once; passing one before that throws rather than silently +// selecting nothing. (function (global) { 'use strict'; @@ -28,6 +41,30 @@ return cr ? parseInt(cr.split('/')[1] || '0', 10) : 0; } + // Pack a colour into the u32 the shader unpacks: 0xAABBGGRR. Accepts + // '#rgb' / '#rrggbb' / '#rrggbbaa', or {r, g, b, a} with 0-255 channels + // (alpha defaulting to opaque). An alpha of 0 is the "no override" sentinel + // on the wasm side, so a fully transparent colour is a clear, not a colour — + // hide the object instead if that is what you meant. + function packColor(color) { + if (color === null || color === undefined) return 0; + let r, g, b, a = 255; + if (typeof color === 'string') { + let hex = color.replace(/^#/, ''); + if (hex.length === 3) hex = hex.split('').map(function (c) { return c + c; }).join(''); + if (hex.length !== 6 && hex.length !== 8) throw new Error('bad colour: ' + color); + r = parseInt(hex.slice(0, 2), 16); + g = parseInt(hex.slice(2, 4), 16); + b = parseInt(hex.slice(4, 6), 16); + if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16); + } else { + r = color.r | 0; g = color.g | 0; b = color.b | 0; + if (color.a !== undefined) a = color.a | 0; + } + const clamp = function (v) { return Math.max(0, Math.min(255, v | 0)); }; + return ((clamp(a) << 24) | (clamp(b) << 16) | (clamp(g) << 8) | clamp(r)) >>> 0; + } + // Boot a viewer bound to `opts.canvas`. Resolves to the API object once the // wasm runtime is initialised; `api.ready` resolves once the GPU app is live. async function create(opts) { @@ -38,11 +75,18 @@ } const selectListeners = []; + const selectionListeners = []; let api = null; // built below; the RAF loop only reads it after that let live = false; let resolveReady; const ready = new Promise(function (r) { resolveReady = r; }); + // Both built from the last getObjects(); null until then. objectIndex backs + // GlobalId resolution (see the identity note at the top of the file); + // objectsById puts the IFC identity back onto a selection read. + let objectIndex = null; + let objectsById = null; + // The per-frame loop: poll for the app pointer (published once the GPU // device is ready), then drive the C tick. It is registered from // onRuntimeInitialized (a clean callback context) rather than after @@ -76,13 +120,80 @@ onRuntimeInitialized: function () { startLoop(this); }, }); + // ---- wasm heap marshalling --------------------------------------------- + // + // Arrays cross the boundary as (pointer, count) into the wasm heap. Both + // helpers own the malloc/free pair so no call site can leak one. + + // Copy `ids` into a scratch u32 buffer and hand (ptr, count) to `fn`. + function withIdArray(ids, fn) { + const n = ids.length; + if (n === 0) return fn(0, 0); + const ptr = Module._malloc(n * 4); + try { + Module.HEAPU32.set(Uint32Array.from(ids), ptr >>> 2); + return fn(ptr, n); + } finally { + Module._free(ptr); + } + } + + // Run an "ask twice" getter: `fn(ptr, max)` fills at most `max` u32s and + // returns the TOTAL count. Call it empty to size, then again to fill. + function readIdArray(fn) { + const total = fn(0, 0); + if (total <= 0) return []; + const ptr = Module._malloc(total * 4); + try { + fn(ptr, total); + return Array.from(Module.HEAPU32.subarray(ptr >>> 2, (ptr >>> 2) + total)); + } finally { + Module._free(ptr); + } + } + + // The one selection mutator: mode 0 replaces (an empty list clears), 1 adds, + // 2 removes. Notifies listeners itself, so a programmatic change reaches + // onSelectionChange through the same path a click does. + function applySelection(x, mode) { + withIdArray(resolveIds(x), function (ptr, n) { + Module._ifcv_apply_selection_c(ptr, n, mode); + }); + Module.__ifcvOnSelectionChange(); + } + + // Normalise whatever the caller passed into a flat array of objectIds. + // Accepts a number, an IFC GlobalId string, an object with an `objectId` + // or `guid` field (so an element straight out of getObjects() works), or + // an array of any of those. null/undefined is an empty selection. + function resolveIds(x) { + if (x === null || x === undefined) return []; + if (!Array.isArray(x)) x = [x]; + return x.map(function (item) { + if (typeof item === 'number') return item >>> 0; + if (item && typeof item === 'object') { + if (typeof item.objectId === 'number') return item.objectId >>> 0; + item = item.guid; + } + if (typeof item !== 'string') throw new Error('not an objectId or GlobalId: ' + item); + if (!objectIndex) { + throw new Error('cannot resolve GlobalId "' + item + + '" — await viewer.getObjects() first (it loads the element table)'); + } + const id = objectIndex.get(item); + if (id === undefined) throw new Error('unknown GlobalId: ' + item); + return id; + }); + } + // Byte-source registry the wasm reads lazily: a picked File (Blob.slice) or // a remote URL (HTTP Range). load_sidecar_from_source_c(sid) streams one. Module.__ifcvSources = Module.__ifcvSources || []; - // The wasm calls this on every pick; (0, '', -1) means the selection was - // cleared. modelIndex is the picked object's model in load order (matches - // the modelProgress index), or -1. + // The wasm calls this on every single-object pick; (0, '', -1) means the + // selection was cleared. modelIndex is the picked object's model in load + // order (matches the modelProgress index), or -1. A marquee box-select does + // NOT fire this (it has no single object) — use onSelectionChange for that. Module.__ifcvOnSelect = function (objectId, guid, modelIndex) { const detail = { objectId: objectId >>> 0, @@ -97,6 +208,32 @@ } catch (_) { /* older browsers */ } }; + // The wasm pings this whenever it mutates the selection (pick, marquee, + // hide-selected); the programmatic setters below call it too. It carries no + // payload — listeners pull the current id set back through getSelection(), + // so there is exactly one source of truth. + Module.__ifcvOnSelectionChange = function () { + const ids = api.getSelection(); + selectionListeners.forEach(function (cb) { + try { cb(ids); } catch (e) { console.error(e); } + }); + try { + document.dispatchEvent(new CustomEvent('ifcviewer:selectionchange', { detail: ids })); + } catch (_) { /* older browsers */ } + }; + + // 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. + const pendingObjects = new Map(); + let objectsToken = 0; + Module.__ifcvOnObjects = function (token, json) { + const resolve = pendingObjects.get(token); + if (!resolve) return; + pendingObjects.delete(token); + resolve(JSON.parse(json)); + }; + // Some test harnesses / the fullscreen page want the raw module on window. if (opts.exposeAsModuleGlobal) global.Module = Module; @@ -118,7 +255,10 @@ ready: ready, isLive: function () { return live; }, - // Register a selection listener; returns an unsubscribe function. + // ---- Events ---------------------------------------------------------- + + // Single-object picks (click). Fires with {objectId, guid, modelIndex}. + // Returns an unsubscribe function. onSelect: function (cb) { selectListeners.push(cb); return function () { @@ -127,10 +267,155 @@ }; }, - // Scene / camera passthroughs. - clearScene: function () { if (Module._clear_scene_c) Module._clear_scene_c(); }, - viewAll: function () { if (Module._view_all_c) Module._view_all_c(); }, - frameSelection: function () { if (Module._frame_selection_c) Module._frame_selection_c(); }, + // Any selection change — click, box-select, or a programmatic setter. + // Fires with the full array of selected objectIds. + onSelectionChange: function (cb) { + selectionListeners.push(cb); + return function () { + const i = selectionListeners.indexOf(cb); + if (i >= 0) selectionListeners.splice(i, 1); + }; + }, + + // ---- Camera ---------------------------------------------------------- + + // The orbit camera's full state. `eye` is derived from the other four by + // the wasm (it owns the orbit convention) and is read-only — to move the + // camera, set target/distance/yaw/pitch. + getCamera: function () { + const ptr = Module._malloc(9 * 4); + try { + Module._ifcv_get_camera_c(ptr); + const f = Module.HEAPF32.subarray(ptr >>> 2, (ptr >>> 2) + 9); + return { + target: [f[0], f[1], f[2]], + distance: f[3], + yaw: f[4], // degrees, about world +Z + pitch: f[5], // degrees above the XY plane, clamped to ±89.9 + eye: [f[6], f[7], f[8]], + ortho: Module._projection_is_ortho_c() !== 0, + }; + } finally { + Module._free(ptr); + } + }, + + // Set any subset of the camera state; omitted fields keep their current + // value, so `setCamera({ yaw: 90 })` is a pure turn. + setCamera: function (c) { + c = c || {}; + const cur = this.getCamera(); + const t = c.target || cur.target; + Module._ifcv_set_camera_c( + t[0], t[1], t[2], + c.distance !== undefined ? c.distance : cur.distance, + c.yaw !== undefined ? c.yaw : cur.yaw, + c.pitch !== undefined ? c.pitch : cur.pitch); + if (c.ortho !== undefined) Module._ifcv_set_ortho_c(c.ortho ? 1 : 0); + }, + + // id: 0 Front, 1 Back, 2 Left, 3 Right, 4 Top, 5 Bottom. + setStandardView: function (id) { Module._standard_view_c(id | 0); }, + viewAll: function () { Module._view_all_c(); }, + frameSelection: function () { Module._frame_selection_c(); }, + + // ---- Selection ------------------------------------------------------- + + // The selected objectIds, ascending. + getSelection: function () { + return readIdArray(function (ptr, max) { + return Module._ifcv_get_selection_c(ptr, max); + }); + }, + // The last single-clicked object — what a properties panel should show. + // 0 when nothing is selected. + getActiveObject: function () { return Module._ifcv_get_active_object_c() >>> 0; }, + + // Selected objects with their IFC identity attached. Requires the element + // table (getObjects()); falls back to bare ids before then. + getSelectedObjects: function () { + return api.getSelection().map(function (id) { + return (objectsById && objectsById.get(id)) || { objectId: id }; + }); + }, + + // Each takes an objectId, a GlobalId, an element from getObjects(), or an + // array of any of those. setSelection replaces (empty/null clears). + setSelection: function (x) { applySelection(x, 0); }, + addToSelection: function (x) { applySelection(x, 1); }, + removeFromSelection: function (x) { applySelection(x, 2); }, + clearSelection: function () { applySelection([], 0); }, + + // ---- Objects --------------------------------------------------------- + + // Every object in the scene: [{objectId, guid, name, type, model}], where + // `model` is the index into the load-ordered model list (same index as + // modelProgress). Asynchronous — the element tables are fetched lazily per + // model so first paint never waits on them. Resolving this is also what + // lets every other call accept GlobalIds; the result is cached for that. + getObjects: function () { + const token = ++objectsToken; + return new Promise(function (resolve) { + pendingObjects.set(token, resolve); + Module._ifcv_request_objects_c(token); + }).then(function (objects) { + objectIndex = new Map(); + objectsById = new Map(); + objects.forEach(function (o) { + if (o.guid) objectIndex.set(o.guid, o.objectId); + objectsById.set(o.objectId, o); + }); + return objects; + }); + }, + + // ---- Visibility ------------------------------------------------------ + + hide: function (x) { this.setVisible(x, false); }, + show: function (x) { this.setVisible(x, true); }, + showAll: function () { Module._show_all_c(); }, + hideAll: function () { Module._hide_all_c(); }, + setVisible: function (x, visible) { + withIdArray(resolveIds(x), function (ptr, n) { + Module._ifcv_set_visible_c(ptr, n, visible ? 1 : 0); + }); + }, + // The hidden objectIds, ascending. + getHidden: function () { + return readIdArray(function (ptr, max) { + return Module._ifcv_get_hidden_c(ptr, max); + }); + }, + // Selection-driven visibility, matching the H / Shift+H / Alt+H hotkeys. + hideSelected: function () { Module._hide_selected_c(); }, + isolateSelected: function () { Module._isolate_selected_c(); }, + + // ---- Colour ---------------------------------------------------------- + + // Paint objects a flat colour, replacing whatever the model baked in. + // `color` is '#rrggbb' / '#rrggbbaa' / {r,g,b,a}, or null to restore the + // model's own colour. An alpha below 255 makes the object translucent — + // it moves to the transparent pass on the next frame. + setColor: function (x, color) { + const rgba = packColor(color); + withIdArray(resolveIds(x), function (ptr, n) { + Module._ifcv_set_color_c(ptr, n, rgba); + }); + }, + // Drop every override in the scene at once. + clearColors: function () { Module._ifcv_clear_colors_c(); }, + + // ---- Scene ----------------------------------------------------------- + + // Drops every model. The element table goes with them, so GlobalIds stop + // resolving until the next getObjects(). + clearScene: function () { + objectIndex = null; + objectsById = null; + if (Module._clear_scene_c) Module._clear_scene_c(); + }, + toggleXray: function () { Module._toggle_xray_c(); }, + xrayActive: function () { return Module._xray_is_active_c() !== 0; }, // Model bookkeeping (ordered by load). Progress is per-model chunk counts. modelCount: function () { return Module._ifcv_model_count_c ? Module._ifcv_model_count_c() : 0; }, @@ -165,5 +450,5 @@ return api; } - global.IfcViewer = { create: create, sizeUrl: sizeUrl }; + global.IfcViewer = { create: create, sizeUrl: sizeUrl, packColor: packColor }; })(window); diff --git a/src/ifcviewer-web/web/index.html b/src/ifcviewer-web/web/index.html index c28f8cde09..8041b98085 100644 --- a/src/ifcviewer-web/web/index.html +++ b/src/ifcviewer-web/web/index.html @@ -22,7 +22,7 @@ diff --git a/src/ifcviewer-web/web/scripting.html b/src/ifcviewer-web/web/scripting.html new file mode 100644 index 0000000000..3f3ee901ae --- /dev/null +++ b/src/ifcviewer-web/web/scripting.html @@ -0,0 +1,351 @@ + + + + +IfcViewer (web) — JavaScript API + + + +
+

IfcOpenShell web viewer — JavaScript API

+

Every control below is plain DOM driving the viewer object from + ifcviewer.js — read this page's source, each button is one call. + It is also on window.viewer, so you can drive it from the devtools console. +  ↩ all examples

+
+ +
+
+
+ +
+
+
Drag to orbit · scroll to zoom · right-click to select + (drag right-click to box-select several, shift+right-click to add)
+
+ + + Starting WebGPU… +
+
+ + +
+ + + + + + diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 1010753feb..4602ada26d 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -264,18 +264,12 @@ float ViewportCore::chunkScreenAreaPx(const ModelGpuData::Chunk& c, return (xmax - xmin) * (ymax - ymin); } -void ViewportCore::recomposeAndUploadModel(uint32_t session_model_id) { - if (!wgpu_initialized_) return; - auto it = models_gpu_.find(session_model_id); - if (it == models_gpu_.end()) return; - ModelGpuData& m = it->second; - if (m.instances.empty() || m.instance_storage == nullptr) return; +void ViewportCore::uploadInstanceRecords(ModelGpuData& m) { + if (!wgpu_initialized_ || m.instances.empty() || m.instance_storage == nullptr) return; std::vector gpu(m.instances.size()); for (size_t i = 0; i < m.instances.size(); ++i) { - InstanceInfo& inst = m.instances[i]; - composeInstanceFromPlacement(inst, m); - + const InstanceInfo& inst = m.instances[i]; InstanceGpu& dst = gpu[i]; std::memcpy(dst.transform, inst.transform, sizeof(dst.transform)); dst.object_id = inst.object_id; @@ -285,6 +279,17 @@ void ViewportCore::recomposeAndUploadModel(uint32_t session_model_id) { } wgpuQueueWriteBuffer(queue_, m.instance_storage, 0, gpu.data(), gpu.size() * sizeof(InstanceGpu)); +} + +void ViewportCore::recomposeAndUploadModel(uint32_t session_model_id) { + if (!wgpu_initialized_) return; + auto it = models_gpu_.find(session_model_id); + if (it == models_gpu_.end()) return; + ModelGpuData& m = it->second; + if (m.instances.empty() || m.instance_storage == nullptr) return; + + for (auto& inst : m.instances) composeInstanceFromPlacement(inst, m); + uploadInstanceRecords(m); // Per-chunk world AABBs are derived from instance world AABBs; they // drive chunk-level frustum cull and the streaming priority, so they @@ -639,6 +644,10 @@ ViewportCore::CameraState ViewportCore::cameraState() const { return s; } +Eigen::Vector3f ViewportCore::cameraEye() const { + return orbitEye(camera_target_, camera_distance_, camera_yaw_deg_, camera_pitch_deg_); +} + bool ViewportCore::computeObjectAabb(uint32_t object_id, float mn[3], float mx[3]) const { bool any = false; @@ -1769,6 +1778,10 @@ void ViewportCore::initWgpuAsyncWeb(std::function on_complete) { Log::info() << "[web init] wgpu device + surface ready (format=" << int(c->core->surface_format_) << " view format=" << int(c->core->surface_view_format_) << ")"; + // Device + queue are live: the buffer-upload paths guarded on this + // (uploadInstanceRecords, recomposeAndUploadModel) are now safe. The + // desktop host latches the same flag after its own initWgpu returns. + c->core->wgpu_initialized_ = true; c->on_complete(true); delete c; }; @@ -3184,6 +3197,19 @@ void ViewportCore::applyCachedModel(std::uint32_t session_model_id, model_gpu_data.meshes = std::move(metadata.meta.meshes); model_gpu_data.instances = std::move(metadata.meta.instances); + // Element metadata, when the caller already read it. readSidecarMetadata + // parses the block up front, so a path-based load arrives with it in hand; + // the web byte-range path deliberately skips it (first paint must not wait + // on it) and fetches later via loadElementMetadataWeb, arriving here empty. + // Either way the rebase is the same and happens here — this function is the + // sole authority on object_id_base. + if (!metadata.meta.elements.empty()) { + model_gpu_data.elements = std::move(metadata.meta.elements); + model_gpu_data.string_table = std::move(metadata.meta.string_table); + for (auto& e : model_gpu_data.elements) e.object_id += object_id_base; + model_gpu_data.element_metadata_loaded = true; + } + // Streaming defers per-mesh vertex data until the owning chunk is // loaded. Both volumes + Area-tool CPU shadow fill in per-chunk // inside applyStreamedChunk as the bytes arrive. @@ -3731,43 +3757,39 @@ void ViewportCore::loadElementMetadataWeb(std::uint32_t session_model_id, }); } +void ViewportCore::loadAllElementMetadataWeb(std::function done) { + const std::vector ids = modelIdsInLoadOrder(); + if (ids.empty()) { if (done) done(true); return; } + // Fan out one lazy fetch per model and join on a shared counter. The + // fetches complete through the JS event loop, so `pending` is only ever + // touched from the main thread — no synchronisation needed. + struct Join { std::size_t pending; bool ok; std::function done; }; + auto join = std::make_shared(Join{ ids.size(), true, std::move(done) }); + for (std::uint32_t session_model_id : ids) { + loadElementMetadataWeb(session_model_id, [join](bool ok) { + join->ok = join->ok && ok; + if (--join->pending == 0 && join->done) join->done(join->ok); + }); + } +} + void ViewportCore::logSelectedObjectGuidWeb(std::uint32_t object_id) { InstanceCompose::InstanceLookup lk; if (!findInstance(object_id, lk)) return; // empty pick / unknown id const std::uint32_t session_model_id = lk.session_model_id; - loadElementMetadataWeb(session_model_id, [this, object_id, session_model_id](bool ok) { - if (!ok) { - Log::warn() << "pick: element metadata fetch failed for object " << object_id; + loadElementMetadataWeb(session_model_id, [this, object_id](bool ok) { + ElementRef e; + if (!ok || !elementForObject(object_id, e)) { + Log::warn() << "pick: no element metadata for object " << object_id; return; } - auto it = models_gpu_.find(session_model_id); - if (it == models_gpu_.end()) return; - const ModelGpuData& m = it->second; - for (const auto& e : m.elements) { - if (e.object_id != object_id) continue; - std::string guid = - (e.guid_length > 0 && - std::size_t(e.guid_offset) + e.guid_length <= m.string_table.size()) - ? m.string_table.substr(e.guid_offset, e.guid_length) - : std::string("(none)"); - Log::info() << "pick: object " << object_id << " GUID " << guid; - // Load-order index of the object's model (sorted by session id — the - // same order as streamingModelProgress and the JS model list); -1 if - // not found. Lets host pages show which model the pick belongs to. - std::vector model_ids; - model_ids.reserve(models_gpu_.size()); - for (const auto& [id, mm] : models_gpu_) model_ids.push_back(id); - std::sort(model_ids.begin(), model_ids.end()); - const auto pos = std::find(model_ids.begin(), model_ids.end(), session_model_id); - const int model_index = (pos != model_ids.end()) ? int(pos - model_ids.begin()) : -1; - // Surface the selection to JS so host pages can react (e.g. show the - // GUID + model). Fires Module.__ifcvOnSelect(object_id, guid, modelIndex). - EM_ASM({ - if (Module.__ifcvOnSelect) Module.__ifcvOnSelect($0, UTF8ToString($1), $2); - }, object_id, guid.c_str(), model_index); - return; - } - Log::info() << "pick: object " << object_id << " not in element table"; + Log::info() << "pick: object " << object_id << " GUID " << e.guid; + // Surface the selection to JS so host pages can react (e.g. show the + // GUID + model). Fires Module.__ifcvOnSelect(object_id, guid, modelIndex); + // model_index is the load-order slot, matching the JS model list. + EM_ASM({ + if (Module.__ifcvOnSelect) Module.__ifcvOnSelect($0, UTF8ToString($1), $2); + }, object_id, e.guid.c_str(), e.model_index); }); } #endif // __EMSCRIPTEN__ @@ -3787,18 +3809,26 @@ int ViewportCore::streamingModelCount() const { return int(models_gpu_.size()); } +std::vector ViewportCore::modelIdsInLoadOrder() const { + std::vector ids; + ids.reserve(models_gpu_.size()); + for (const auto& [session_model_id, m] : models_gpu_) ids.push_back(session_model_id); + std::sort(ids.begin(), ids.end()); + return ids; +} + +int ViewportCore::modelLoadIndex(std::uint32_t session_model_id) const { + const std::vector ids = modelIdsInLoadOrder(); + const auto it = std::find(ids.begin(), ids.end(), session_model_id); + return (it == ids.end()) ? -1 : int(it - ids.begin()); +} + void ViewportCore::streamingModelProgress(int idx, int& resident_chunks, int& total_chunks) const { resident_chunks = 0; total_chunks = 0; if (idx < 0 || idx >= int(models_gpu_.size())) return; - // Order by session_model_id (= load order) so a model keeps the same UI slot as it - // streams, instead of hopping with unordered_map iteration order. - std::vector ids; - ids.reserve(models_gpu_.size()); - for (const auto& [session_model_id, m] : models_gpu_) ids.push_back(session_model_id); - std::sort(ids.begin(), ids.end()); - auto it = models_gpu_.find(ids[std::size_t(idx)]); + auto it = models_gpu_.find(modelIdsInLoadOrder()[std::size_t(idx)]); if (it == models_gpu_.end()) return; for (const auto& c : it->second.chunks) { ++total_chunks; @@ -3806,6 +3836,57 @@ void ViewportCore::streamingModelProgress(int idx, int& resident_chunks, } } +namespace { + +// Resolve one element record against its model's string table. Offsets that run +// past the table (or carry zero length) yield an empty string rather than a +// fabricated one — the sidecar writes no string for an unnamed element. +ViewportCore::ElementRef makeElementRef(const ModelGpuData& m, int model_index, + const ElementTableRecord& e) { + auto str = [&m](std::uint32_t offset, std::uint32_t length) { + return (length > 0 && std::size_t(offset) + length <= m.string_table.size()) + ? m.string_table.substr(offset, length) + : std::string(); + }; + ViewportCore::ElementRef ref; + ref.object_id = e.object_id; + ref.model_index = model_index; + ref.guid = str(e.guid_offset, e.guid_length); + ref.name = str(e.name_offset, e.name_length); + ref.type = str(e.type_offset, e.type_length); + return ref; +} + +} // namespace + +std::vector ViewportCore::elements() const { + std::vector out; + const std::vector ids = modelIdsInLoadOrder(); + for (std::size_t model_index = 0; model_index < ids.size(); ++model_index) { + auto it = models_gpu_.find(ids[model_index]); + if (it == models_gpu_.end()) continue; + const ModelGpuData& m = it->second; + out.reserve(out.size() + m.elements.size()); + for (const ElementTableRecord& e : m.elements) + out.push_back(makeElementRef(m, int(model_index), e)); + } + return out; +} + +bool ViewportCore::elementForObject(std::uint32_t object_id, ElementRef& out) const { + InstanceCompose::InstanceLookup lk; + if (!findInstance(object_id, lk)) return false; + auto it = models_gpu_.find(lk.session_model_id); + if (it == models_gpu_.end()) return false; + const ModelGpuData& m = it->second; + for (const ElementTableRecord& e : m.elements) { + if (e.object_id != object_id) continue; + out = makeElementRef(m, modelLoadIndex(lk.session_model_id), e); + return true; + } + return false; +} + void ViewportCore::streamingByteProgress(std::uint64_t& total_bytes, std::uint64_t& needed_bytes, std::uint64_t& loaded_bytes) const { @@ -5189,6 +5270,59 @@ void ViewportCore::showAll() { host_->requestFrame(); } +void ViewportCore::hideAll() { + // Element-level hide of everything in a visible model — the inverse of + // showAll, and isolateSelected with an empty selection. Model-hidden + // models are already gone from the cull, so they contribute nothing. + for (const auto& [session_model_id, m] : models_gpu_) { + if (m.hidden) continue; + for (const InstanceInfo& inst : m.instances) visibility_.hide(inst.object_id); + } + Log::info().noquote().nospace() << "[wgpu] hid all (" << visibility_.hiddenCount() << ")"; + host_->requestFrame(); +} + +void ViewportCore::setObjectsVisible(const std::vector& object_ids, bool visible) { + for (std::uint32_t id : object_ids) { + if (visible) visibility_.show(id); + else visibility_.hide(id); + } + host_->requestFrame(); +} + +void ViewportCore::setObjectsColor(const std::vector& object_ids, + std::uint32_t rgba8) { + if (object_ids.empty()) return; + const std::unordered_set wanted(object_ids.begin(), object_ids.end()); + + // One pass per model: patch the CPU mirror, then re-upload that model's + // instance records only if it actually owned one of the ids. + for (auto& [session_model_id, m] : models_gpu_) { + bool touched = false; + for (InstanceInfo& inst : m.instances) { + if (inst.color_override_rgba8 == rgba8) continue; + if (wanted.find(inst.object_id) == wanted.end()) continue; + inst.color_override_rgba8 = rgba8; + touched = true; + } + if (touched) uploadInstanceRecords(m); + } + host_->requestFrame(); +} + +void ViewportCore::clearObjectColors() { + for (auto& [session_model_id, m] : models_gpu_) { + bool touched = false; + for (InstanceInfo& inst : m.instances) { + if (inst.color_override_rgba8 == 0u) continue; + inst.color_override_rgba8 = 0u; + touched = true; + } + if (touched) uploadInstanceRecords(m); + } + host_->requestFrame(); +} + void ViewportCore::toggleXray() { constexpr float kXrayOnCap = 0.3f; xray_alpha_cap_ = (xray_alpha_cap_ < 1.0f) ? 1.0f : kXrayOnCap; diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 797793214c..4825e9157e 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -43,6 +43,7 @@ #include #include #include +#include #include #include @@ -160,6 +161,12 @@ public: // completed. void recomposeAndUploadModel(uint32_t session_model_id); + // Re-pack m.instances into InstanceGpu[] and write the whole array back to + // m.instance_storage. Reads the already-composed inst.transform, so unlike + // recomposeAndUploadModel it does no matrix work and touches no AABB — + // it is the upload half, shared with the colour-override path. + void uploadInstanceRecords(ModelGpuData& m); + // ---- Camera math -------------------------------------------------------- // // buildViewProj feeds every cull, streaming, pick and render path @@ -277,6 +284,10 @@ public: bool projectionOrtho() const { return projection_ortho_; } std::string cameraString() const; CameraState cameraState() const; + // The orbit camera's world-space eye, derived from (target, distance, yaw, + // pitch). Exposed so hosts reporting camera position don't re-implement the + // orbit convention — buildViewProj feeds lookAt from exactly this point. + Eigen::Vector3f cameraEye() const; // Re-aim the orbit camera so [mn, mx] fits the view with `padding` // headroom (1.10 typical). Used by viewAll and focusOnSelectedObject. @@ -446,6 +457,11 @@ public: void loadElementMetadataWeb(std::uint32_t session_model_id, std::function done = {}); + // loadElementMetadataWeb fanned out over every model in the scene, firing + // done(ok) once the last one lands (ok = every model resolved). Backs the + // JS getObjects() API, which needs the whole federation's element tables. + void loadAllElementMetadataWeb(std::function done); + // Demo consumer of the element metadata fetch: on pick, ensure the owning model's // property block is loaded (loadElementMetadataWeb — fetched once, on // demand), then log the picked object's IFC GUID. The first pick triggers @@ -472,6 +488,32 @@ public: void streamingModelProgress(int idx, int& resident_chunks, int& total_chunks) const; + // A model's slot in that load order, i.e. the `idx` streamingModelProgress + // wants, for a session_model_id. -1 when the model is gone. The one place + // the session-id → UI-slot mapping is derived. + int modelLoadIndex(std::uint32_t session_model_id) const; + + // One row of the element table: the IFC identity behind a rendered + // object_id. `model_index` is the load-order slot (modelLoadIndex), so a + // host UI can attribute an object to the file it came from. + struct ElementRef { + std::uint32_t object_id = 0; + int model_index = -1; + std::string guid; + std::string name; + std::string type; + }; + + // Every object in the scene, across every model whose element metadata is + // resident. On web that means calling loadAllElementMetadataWeb first — + // models still lazily un-fetched simply contribute nothing. + std::vector elements() 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 + // owns the id. False when the id is unknown or its metadata isn't resident. + bool elementForObject(std::uint32_t object_id, ElementRef& out) const; + // Byte-level streaming progress for a combined loading bar. total = all // geometry bytes; needed = bytes the current view wants (contribution- // culled working set); loaded = the resident subset of needed. Lets the UI @@ -718,10 +760,17 @@ public: void applyPickToSelection(std::uint32_t object_id, bool add, bool remove); // Apply a marquee box-pick result to the selection: plain = replace with - // `ids`, add = union, remove = subtract. Schedules a frame. + // `ids`, add = union, remove = subtract. Schedules a frame. Also the + // programmatic selection primitive for host UIs (an empty `ids` with + // add=remove=false clears). void applyMarqueeToSelection(const std::vector& ids, bool add, bool remove); + // Selection accessor. Mirrors ViewportWindow::selection() so hosts can read + // selectionIds() / activeId(); mutation goes through the apply*ToSelection + // paths above (they own the dirty flag + frame scheduling). + const SelectionState& selection() const { return selection_; } + // Visibility + X-ray, shared by desktop (H / Shift+H / Alt+H / Alt+X) and // web. Hidden objects are skipped by the cull and xray_alpha_cap_ is read // by the frame uniform, both per frame — so each call just mutates state and @@ -729,7 +778,25 @@ public: void hideSelected(); // hide the selected objects, then clear selection void isolateSelected(); // hide everything that is NOT selected void showAll(); // clear the hidden set + void hideAll(); // hide every object in every loaded model + // Explicit per-object visibility, for host UIs driving a model tree / + // filter rather than the current selection. + void setObjectsVisible(const std::vector& object_ids, bool visible); + bool isObjectHidden(std::uint32_t object_id) const { return visibility_.isHidden(object_id); } + const std::unordered_set& hiddenIds() const { return visibility_.hiddenIds(); } size_t hiddenCount() const { return visibility_.hiddenCount(); } + + // Runtime colour override. `rgba8` is packed 0xAABBGGRR (the byte order the + // WGSL unpacks); 0 is the sentinel for "no override — use the baked vertex + // colour", so clearing is just setObjectsColor(ids, 0). An alpha below 255 + // routes the instance through the transparent pass on the next cull, which + // re-reads the byte every frame — nothing else to invalidate. + // + // Writes the CPU instance mirror and re-uploads the touched models' instance + // records. Cost is one buffer write per model that actually changed, so + // colouring a whole model is one upload, not one per object. + void setObjectsColor(const std::vector& object_ids, std::uint32_t rgba8); + void clearObjectColors(); // drop every override in every model // Global X-ray: translucent everything. Flips the frame uniform's alpha cap; // the cull classifier routes every instance through the transparent pass. void toggleXray(); @@ -865,6 +932,11 @@ public: private: bool createPool(); + // The scene's models in load order (ascending session_model_id). Every + // per-model API indexes against this, so a model keeps a stable UI slot + // instead of hopping with unordered_map iteration order. + std::vector modelIdsInLoadOrder() const; + public: // Friend access for ViewportWindow's reference proxies. As each