ifcviewer-web: JavaScript scripting API (camera, selection, visibility, colour)

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 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-14 11:57:40 +10:00
parent 4cd9d4b53a
commit 89bb3074de
11 changed files with 1549 additions and 68 deletions
+43 -8
View File
@@ -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} "$<TARGET_FILE_DIR:IfcViewerWeb>"
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
# $<TARGET_FILE_DIR:IfcViewerWeb>: 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})