mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-31 00:46:36 +00:00
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:
@@ -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})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
@@ -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.
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
<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 & 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>
|
||||
Reference in New Issue
Block a user