mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-22 08:08:31 +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:
@@ -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