From 89bb3074de8332b500714d19a5310afa4a88918d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 14 Jul 2026 11:57:40 +1000 Subject: [PATCH] ifcviewer-web: JavaScript scripting API (camera, selection, visibility, colour) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give host pages a real API over the web viewer, not just "embed it and listen for picks": read/set the camera, read/set multi-selection, enumerate every object with its IFC identity, drive per-object visibility, and override object colours. The wasm boundary keeps to object_ids (u32 arrays marshalled through the heap, with an "ask twice" convention on the getters); web/ifcviewer.js layers IFC GlobalId resolution on top, from the element table getObjects() fetches. Every id-taking call accepts an objectId, a GlobalId, or an element object. Colour override needed no new mechanism: color_override_rgba8 was already plumbed through the sidecar, the instance SSBO, the WGSL shader and the opaque/transparent cull classifier, but nothing ever wrote a non-zero value into it. setObjectsColor is the missing writer, which is why an alpha below 255 correctly reclassifies the instance into the transparent pass. Two bugs surfaced while wiring this up: - wgpu_initialized_ was only ever set by the Qt desktop host, so on web every upload guarded on it was a silent no-op — including the pre-existing recomposeAndUploadModel that federation transforms depend on. The core now latches it in its own web init. - The demo pages were copied into the build dir by a POST_BUILD command on the wasm target, so they only refreshed when the wasm itself relinked; editing a page left a stale copy that the dev server (and the Playwright suite) kept serving. Each page now has its own copy rule with a real dependency, and sample.ifcview is a LINK_DEPENDS so regenerating it forces a relink. applyCachedModel also now keeps the element metadata it already parses on the path-based load (it was being dropped), so the embedded sample has GUIDs and the demo works with no file to pick. The sample model was three coincident cubes, which made per-object hide and colour look like no-ops — whatever you hid was still drawn by the box behind it. make_sample.py regenerates it as a slab, a wall and a beam in distinct places, so the fixture is reproducible rather than an opaque blob. Demoed by web/scripting.html (linked from the index; viewer is on window.viewer) and covered by tests/scripting.spec.mjs — 6 cases against a real GPU, asserting visibility and colour at the pixels, not just at the API. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer-web/CMakeLists.txt | 51 ++- src/ifcviewer-web/main_web.cpp | 169 +++++++++- src/ifcviewer-web/make_sample.py | 110 +++++++ src/ifcviewer-web/sample.ifc | 70 ++++ src/ifcviewer-web/sample.ifcview | Bin 534 -> 555 bytes src/ifcviewer-web/tests/scripting.spec.mjs | 250 +++++++++++++++ src/ifcviewer-web/web/ifcviewer.js | 305 +++++++++++++++++- src/ifcviewer-web/web/index.html | 9 +- src/ifcviewer-web/web/scripting.html | 351 +++++++++++++++++++++ src/ifcviewer/ViewportCore.cpp | 228 ++++++++++--- src/ifcviewer/ViewportCore.h | 74 ++++- 11 files changed, 1549 insertions(+), 68 deletions(-) create mode 100644 src/ifcviewer-web/make_sample.py create mode 100644 src/ifcviewer-web/sample.ifc create mode 100644 src/ifcviewer-web/tests/scripting.spec.mjs create mode 100644 src/ifcviewer-web/web/scripting.html 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 c6c3550cd844855145185a739e336f949a6b4e61..36d10f10c435a41b19ed2ed26ca3f2af965bdfab 100644 GIT binary patch delta 542 zcmbQnvYMqnJj~5gkb!}Lg_((QHjoAZjjj5B6B3wmnHZ8>Ik-PWGWXZ)P5HZiDw8SK z0xhuvUqk<|Xb){)m0WwZm*arE!~%sDo{*>W7#~bc<*{|s%B=Ce(`xQDWnN*E!-U73 zGBXt(Dj%rxdm{oib_2szMg}HM9!`M;^O&8*>Wk-y=->OZ!}@dix2JDkow|N;@^br6 zpC2FV-Y;C9FMn>IUW~1+#V^YbjEoEu`Z}|g9ELcN!GZFb20n{LfJ{eFKfOVc7RY4K#E1&mXp7jC<@;DSJ|oP=8B zkKHmo+iMaJ-P>lN*gSu)^>XFy(fPm5-8o|LLAcv_x(zpbV%Y5&)c=Fa8y+@B;wCt z^A{gQ&b^n`b!$$e)4yZem&E$U8-A%dcyrA!o^fAxR=fBZlAgXu@T;^aS!QUDc{^l$(G delta 502 zcmViSB)D)<7gFNAPa1uv+P1mM5|1$Wk< z88VCk5l{q>Kor112KWE}4b9_LnAgF;{;V4Q>Zw4ndF>c#$?=Wtm9n3FN;AiW4I(RG zyqiP~D77#BV6XsP0svA63keXQo&y#a0cQ$t-a121U*0fZuH5}4cSnA-8!z6@)QPXF zr}N?H=fVdEJ`5l*GcXTiV05x@sVUL5Ny;lPpMQDHYk8UZFL05t7JoGZ00M^$5#TKc zfCLL+jq2$S1f%-hI|V;zeuxe^5 zbTf%KzP*FM7j?m)6ue%FZ-lW26yl*SoC0F>c1L#?IEL%kZ4=%C#u@{j000000002j z000000000ewJ-f3*MF@905S+39dO$L{Qv(qQQn2wWOZzJZ6(2!5suK3U786*I|V5p zcPFWokGm@M#8BcQwi{72YwIcx$=pc_MR~`e`(>cK!VQ)+D``0CT%9{fi5$(+TzyJ- sW?AIC%{!%yXj)1B{| { + 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