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})
+168 -1
View File
@@ -38,8 +38,11 @@
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unordered_set>
#include <vector>
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<std::uint32_t> idsFrom(const std::uint32_t* ids, int n) {
if (!ids || n <= 0) return {};
return std::vector<std::uint32_t>(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<std::uint32_t>& ids,
std::uint32_t* out, int max) {
std::vector<std::uint32_t> 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<std::uint32_t> 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;
+110
View File
@@ -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 <name>.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())
+70
View File
@@ -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;
Binary file not shown.
+250
View File
@@ -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([]);
});
+295 -10
View File
@@ -7,13 +7,26 @@
// <script>
// const viewer = await IfcViewer.create({ canvas: myCanvas });
// await viewer.ready; // GPU app is live
// viewer.onSelect(({ objectId, guid }) => …);
// await viewer.addFile(file, { replace: true });
// await viewer.addUrl('/model.ifcview'); // appends (federation)
//
// const objects = await viewer.getObjects(); // [{objectId, guid, name, type, model}]
// viewer.setSelection(['3vB2YO$MX4xv5uCqZZG05x']);
// viewer.setColor(objects.filter(o => o.type === 'IfcWall'), '#ff8800');
// viewer.setCamera({ yaw: 45, pitch: 30 });
// </script>
//
// 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);
+8 -1
View File
@@ -22,7 +22,7 @@
<body>
<div class="wrap">
<h1>IfcOpenShell web viewer</h1>
<p class="lead">Two examples of the same WebGPU viewer wasm (IfcViewerWeb.js),
<p class="lead">Three examples of the same WebGPU viewer wasm (IfcViewerWeb.js),
loaded through the small <code>ifcviewer.js</code> integration helper.</p>
<a class="card" href="IfcViewerWeb.html">
@@ -37,6 +37,13 @@
(file or URL), lists the loaded models with streaming progress, and shows
the GlobalId of whatever you click in the scene.</p>
</a>
<a class="card" href="scripting.html">
<h2>Scripting API →</h2>
<p>Drive the viewer from JavaScript: read and set the camera, read and set
multi-selection, list every object with its GlobalId and model, toggle
per-object visibility, and override object colours.</p>
</a>
</div>
</body>
</html>
+351
View File
@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>IfcViewer (web) — JavaScript API</title>
<style>
:root { color-scheme: dark; }
html, body { margin: 0; min-height: 100%; background: #0f1117; color: #c8ccd6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
header { padding: 12px 16px; border-bottom: 1px solid #232833; }
header h1 { margin: 0; font-size: 15px; font-weight: 600; }
header p { margin: 4px 0 0; font-size: 12px; color: #8a93a6; }
header a { color: #7f9bd6; }
.layout { display: flex; gap: 16px; padding: 16px; align-items: flex-start; flex-wrap: wrap; }
#viewer-box { position: relative; width: 640px; height: 460px; max-width: 100%;
border: 1px solid #232833; border-radius: 6px; overflow: hidden; background: #1a1d24; }
#viewer-canvas { display: block; width: 100%; height: 100%; outline: none; }
#marquee { position: absolute; display: none; z-index: 5; pointer-events: none;
border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); }
.sidebar { flex: 1 1 340px; min-width: 320px; display: flex; flex-direction: column; gap: 14px; }
.card { border: 1px solid #232833; border-radius: 6px; background: #151821; }
.card h2 { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 600;
letter-spacing: .04em; text-transform: uppercase; color: #8a93a6;
border-bottom: 1px solid #232833; }
.card .body { padding: 12px; }
.row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.row + .row { margin-top: 8px; }
button { background: #2b6cb0; color: #fff; border: none; padding: 6px 10px;
border-radius: 4px; font-size: 12px; cursor: pointer; }
button.secondary { background: #2d3748; color: #c8ccd6; }
button:hover { filter: brightness(1.15); }
button:disabled { opacity: .5; cursor: default; filter: none; }
input[type=number] { background: #0f1117; color: #c8ccd6; border: 1px solid #2b3244;
border-radius: 4px; padding: 5px 6px; font-size: 12px; width: 66px; }
input[type=color] { width: 34px; height: 27px; padding: 0; background: #0f1117;
border: 1px solid #2b3244; border-radius: 4px; cursor: pointer; }
label.field { display: flex; gap: 5px; align-items: center; font-size: 12px; color: #8a93a6; }
.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11px; }
.readout { background: #0f1117; border: 1px solid #232833; border-radius: 4px;
padding: 8px; line-height: 1.6; color: #9aa4b6; white-space: pre; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { text-align: left; font-weight: 600; color: #8a93a6; font-size: 11px;
padding: 6px 8px; border-bottom: 1px solid #232833; }
td { padding: 5px 8px; border-bottom: 1px solid #1c202b; }
tr[data-id] { cursor: pointer; }
tr[data-id]:hover td { background: #1a1e28; }
tr.selected td { background: #1b3350; }
.swatch { width: 18px; height: 18px; }
.hint { font-size: 11px; color: #6f7988; margin-top: 8px; }
.empty { color: #6f7988; font-size: 12px; padding: 10px 12px; }
.pill { font-size: 11px; color: #6f7988; font-weight: 400; text-transform: none;
letter-spacing: 0; }
</style>
</head>
<body>
<header>
<h1>IfcOpenShell web viewer — JavaScript API</h1>
<p>Every control below is plain DOM driving the <code>viewer</code> object from
<code>ifcviewer.js</code> — read this page's source, each button is one call.
It is also on <code>window.viewer</code>, so you can drive it from the devtools console.
&nbsp;<a href="index.html">↩ all examples</a></p>
</header>
<div class="layout">
<div>
<div id="viewer-box">
<canvas id="viewer-canvas" width="1280" height="920"></canvas>
<div id="marquee"></div>
</div>
<div class="hint">Drag to orbit · scroll to zoom · <b>right-click to select</b>
(drag right-click to box-select several, shift+right-click to add)</div>
<div class="row" style="margin-top:10px">
<button id="browse-btn" class="secondary">Add .ifcview…</button>
<input id="file-input" type="file" accept=".ifcview" multiple style="display:none">
<span class="pill" id="scene-hint">Starting WebGPU…</span>
</div>
</div>
<div class="sidebar">
<div class="card">
<h2>Camera <span class="pill">getCamera · setCamera</span></h2>
<div class="body">
<div class="readout mono" id="camera-readout"></div>
<div class="row" style="margin-top:10px">
<label class="field">yaw <input type="number" id="cam-yaw" step="15"></label>
<label class="field">pitch <input type="number" id="cam-pitch" step="15"></label>
<label class="field">dist <input type="number" id="cam-distance" step="1"></label>
<button id="cam-apply">Set</button>
</div>
<div class="row">
<button class="secondary" id="cam-top">Top</button>
<button class="secondary" id="cam-front">Front</button>
<button class="secondary" id="cam-ortho">Toggle ortho</button>
<button class="secondary" id="cam-save">Save view</button>
<button class="secondary" id="cam-restore" disabled>Restore</button>
</div>
</div>
</div>
<div class="card">
<h2>Selection <span class="pill">getSelection · setSelection</span></h2>
<div class="body">
<div class="readout mono" id="selection-readout">nothing selected</div>
<div class="row" style="margin-top:10px">
<button id="sel-walls">Select walls</button>
<button class="secondary" id="sel-add-beams">Add beams</button>
<button class="secondary" id="sel-all">Select all</button>
<button class="secondary" id="sel-clear">Clear</button>
<button class="secondary" id="sel-frame">Zoom to</button>
</div>
</div>
</div>
<div class="card">
<h2>Visibility &amp; colour <span class="pill">setVisible · setColor</span></h2>
<div class="body">
<div class="row">
<button class="secondary" id="vis-hide-sel">Hide selected</button>
<button class="secondary" id="vis-isolate">Isolate selected</button>
<button class="secondary" id="vis-hide-all">Hide all</button>
<button class="secondary" id="vis-show-all">Show all</button>
</div>
<div class="row">
<input type="color" id="colour-picker" value="#ff8800">
<button id="colour-apply">Colour selected</button>
<label class="field">alpha
<input type="number" id="colour-alpha" min="1" max="255" value="255" step="16"></label>
<button class="secondary" id="colour-by-type">Colour by type</button>
<button class="secondary" id="colour-clear">Reset colours</button>
</div>
<div class="hint" id="vis-hint">Nothing hidden.</div>
</div>
</div>
<div class="card">
<h2>Objects <span class="pill" id="object-count">getObjects</span></h2>
<table id="object-table">
<thead><tr><th>GlobalId</th><th>Name</th><th>Type</th><th>Model</th>
<th style="width:1%">Shown</th><th style="width:1%">Colour</th></tr></thead>
<tbody></tbody>
</table>
<div class="empty" id="object-empty">Loading the element table…</div>
</div>
</div>
</div>
<script src="IfcViewerWeb.js"></script>
<script src="ifcviewer.js"></script>
<script>
// ---------------------------------------------------------------------------
// Everything below is ordinary page code. It never touches the wasm module
// directly — only the `viewer` object that IfcViewer.create() hands back.
// ---------------------------------------------------------------------------
const $ = (id) => document.getElementById(id);
let viewer = null;
let objects = []; // the whole element table, from viewer.getObjects()
let models = []; // one name per loaded model, in load order
let savedView = null; // a getCamera() snapshot, for the Restore button
let cameraInputsSeeded = false;
// Resolve any CSS colour (including the hsl() below) to {r, g, b} channels —
// which is one of the shapes viewer.setColor() accepts.
const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
function toRgb(css) {
probe.fillStyle = css;
probe.fillRect(0, 0, 1, 1);
const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;
return { r, g, b };
}
// A stable colour per IFC type. Cheap hash → hue; the point is only that
// adjacent types read as different.
function colourForType(type) {
let h = 0;
for (const ch of type) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return toRgb(`hsl(${h % 360}, 65%, 55%)`);
}
const alpha = () => Number($('colour-alpha').value);
const ofType = (t) => objects.filter((o) => o.type === t);
// ---- Rendering the page from viewer state ---------------------------------
function renderCamera(c) {
const f = (n) => n.toFixed(1);
$('camera-readout').textContent =
`yaw ${f(c.yaw)}°\npitch ${f(c.pitch)}°\n` +
`target ${c.target.map(f).join(', ')}\n` +
`eye ${c.eye.map(f).join(', ')}\n` +
`dist ${f(c.distance)} (${c.ortho ? 'orthographic' : 'perspective'})`;
if (!cameraInputsSeeded) {
cameraInputsSeeded = true;
$('cam-yaw').value = c.yaw.toFixed(0);
$('cam-pitch').value = c.pitch.toFixed(0);
$('cam-distance').value = c.distance.toFixed(0);
}
}
function renderSelection(ids) {
if (!ids.length) {
$('selection-readout').textContent = 'nothing selected';
} else {
// getSelectedObjects() is getSelection() with the IFC identity attached.
const rows = viewer.getSelectedObjects().map(
(o) => `${o.guid || '(id ' + o.objectId + ')'} ${o.name || ''}${o.type ? ' · ' + o.type : ''}`);
$('selection-readout').textContent =
`${ids.length} selected · active ${viewer.getActiveObject() || 'none'}\n` + rows.join('\n');
}
// Keep the table's highlight in step with the viewport.
const selected = new Set(ids);
for (const tr of $('object-table').querySelectorAll('tr[data-id]')) {
tr.classList.toggle('selected', selected.has(Number(tr.dataset.id)));
}
}
function renderTable() {
const tbody = $('object-table').querySelector('tbody');
const hidden = new Set(viewer.getHidden());
const selected = new Set(viewer.getSelection());
$('object-empty').style.display = objects.length ? 'none' : 'block';
$('vis-hint').textContent = hidden.size ? `${hidden.size} object(s) hidden.` : 'Nothing hidden.';
tbody.replaceChildren();
for (const o of objects) {
const tr = document.createElement('tr');
tr.dataset.id = o.objectId;
tr.classList.toggle('selected', selected.has(o.objectId));
tr.innerHTML =
`<td class="mono">${o.guid}</td><td>${o.name || '—'}</td>` +
`<td>${o.type || '—'}</td><td>${models[o.model] || o.model}</td>` +
`<td><input type="checkbox" ${hidden.has(o.objectId) ? '' : 'checked'}></td>` +
`<td><input type="color" class="swatch" value="#cccccc"></td>`;
// Click the row to select it; shift-click adds, as in the viewport.
tr.onclick = (ev) => {
if (ev.target.tagName === 'INPUT') return;
if (ev.shiftKey) viewer.addToSelection(o.objectId);
else viewer.setSelection(o.objectId);
};
// Per-object visibility + colour, straight off the row.
tr.querySelector('input[type=checkbox]').onchange = (ev) => {
viewer.setVisible(o.objectId, ev.target.checked);
const n = viewer.getHidden().length;
$('vis-hint').textContent = n ? `${n} object(s) hidden.` : 'Nothing hidden.';
};
tr.querySelector('input[type=color]').oninput = (ev) => {
viewer.setColor(o.objectId, { ...toRgb(ev.target.value), a: alpha() });
};
tbody.appendChild(tr);
}
}
async function refreshObjects() {
objects = await viewer.getObjects();
$('object-count').textContent = `${objects.length} objects · ${models.length} model(s)`;
$('scene-hint').textContent = models.join(', ');
renderTable();
renderSelection(viewer.getSelection());
}
// ---- Boot ------------------------------------------------------------------
if (!navigator.gpu) $('scene-hint').textContent = 'navigator.gpu missing — needs a WebGPU browser';
$('viewer-canvas').addEventListener('contextmenu', (ev) => ev.preventDefault());
IfcViewer.create({
canvas: $('viewer-canvas'),
// The camera has no change event — it moves on every frame you drag — so
// the readout is the one thing worth polling. Everything else is event-driven.
onFrame: (v) => renderCamera(v.getCamera()),
}).then(async (v) => {
viewer = window.viewer = v;
await viewer.ready;
// The wasm ships with a small sample model (a slab, a wall and a beam), so
// there is something to drive before you add a file of your own.
models = ['sample.ifcview'];
await refreshObjects();
// ---- Camera: read (1) and set (2) ---------------------------------------
// setCamera takes any subset of what getCamera returns; omitted fields hold.
$('cam-apply').onclick = () => viewer.setCamera({
yaw: Number($('cam-yaw').value),
pitch: Number($('cam-pitch').value),
distance: Number($('cam-distance').value),
});
$('cam-top').onclick = () => viewer.setStandardView(4);
$('cam-front').onclick = () => viewer.setStandardView(0);
$('cam-ortho').onclick = () => viewer.setCamera({ ortho: !viewer.getCamera().ortho });
$('cam-save').onclick = () => {
savedView = viewer.getCamera();
$('cam-restore').disabled = false;
};
// A whole getCamera() result is valid input to setCamera, so a view round-trips.
$('cam-restore').onclick = () => viewer.setCamera(savedView);
// ---- Selection: read multiple (3), set / add / clear (4) ------------------
// Fires for clicks, box-selects AND the programmatic setters below, so the
// readout and the table highlight stay right whatever changed them.
viewer.onSelectionChange(renderSelection);
// The setters take objectIds, GlobalId strings, or whole element objects —
// these three lines each use a different one of those.
$('sel-walls').onclick = () => viewer.setSelection(ofType('IfcWall'));
$('sel-add-beams').onclick = () => viewer.addToSelection(ofType('IfcBeam').map((o) => o.guid));
$('sel-all').onclick = () => viewer.setSelection(objects.map((o) => o.objectId));
$('sel-clear').onclick = () => viewer.clearSelection();
$('sel-frame').onclick = () => viewer.frameSelection();
// ---- Visibility: per object (6), show all / hide all (7) ------------------
$('vis-hide-sel').onclick = () => { viewer.hideSelected(); renderTable(); };
$('vis-isolate').onclick = () => { viewer.isolateSelected(); renderTable(); };
$('vis-hide-all').onclick = () => { viewer.hideAll(); renderTable(); };
$('vis-show-all').onclick = () => { viewer.showAll(); renderTable(); };
// ---- Colour override (8) --------------------------------------------------
$('colour-apply').onclick = () => {
viewer.setColor(viewer.getSelection(), { ...toRgb($('colour-picker').value), a: alpha() });
renderTable();
};
$('colour-by-type').onclick = () => {
// One call per type, not per object: setColor takes a whole batch, and a
// batch costs one instance-buffer upload per model it touches.
for (const t of new Set(objects.map((o) => o.type))) {
viewer.setColor(ofType(t), { ...colourForType(t), a: alpha() });
}
renderTable();
};
$('colour-clear').onclick = () => { viewer.clearColors(); renderTable(); };
// ---- Objects (5): the table above, and federating in more models ----------
$('browse-btn').onclick = () => $('file-input').click();
$('file-input').onchange = async (ev) => {
for (const file of ev.target.files) {
await viewer.addFile(file);
models.push(file.name);
}
ev.target.value = '';
// The new model's metadata has to land before its objects can be listed;
// give the load a beat, then re-read the whole scene.
setTimeout(refreshObjects, 800);
};
});
</script>
</body>
</html>
+181 -47
View File
@@ -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<InstanceGpu> 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<void(bool)> 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<void(bool)> done) {
const std::vector<std::uint32_t> 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<void(bool)> done; };
auto join = std::make_shared<Join>(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<std::uint32_t> 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<std::uint32_t> ViewportCore::modelIdsInLoadOrder() const {
std::vector<std::uint32_t> 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<std::uint32_t> 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<std::uint32_t> 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::ElementRef> ViewportCore::elements() const {
std::vector<ElementRef> out;
const std::vector<std::uint32_t> 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<std::uint32_t>& 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<std::uint32_t>& object_ids,
std::uint32_t rgba8) {
if (object_ids.empty()) return;
const std::unordered_set<std::uint32_t> 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;
+73 -1
View File
@@ -43,6 +43,7 @@
#include <memory>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
@@ -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<void(bool)> 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<void(bool)> 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<ElementRef> 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<std::uint32_t>& 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<std::uint32_t>& object_ids, bool visible);
bool isObjectHidden(std::uint32_t object_id) const { return visibility_.isHidden(object_id); }
const std::unordered_set<std::uint32_t>& 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<std::uint32_t>& 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<std::uint32_t> modelIdsInLoadOrder() const;
public:
// Friend access for ViewportWindow's reference proxies. As each