diff --git a/.gitignore b/.gitignore
index 260067c05..ea98b011b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -147,3 +147,12 @@ node-app
.claude/
resources/frags/test/
+
+# local IFC projects: models and the generated gallery are not committed
+/projects/*/
+/projects/index.html
+/projects/.dev-server.*
+
+# pnpm artifacts (local setup; the repo installs with yarn)
+pnpm-lock.yaml
+pnpm-workspace.yaml
diff --git a/index.html b/index.html
index 0e3ff4072..c68133912 100644
--- a/index.html
+++ b/index.html
@@ -23,44 +23,46 @@
diff --git a/package.json b/package.json
index 57fdae2d0..6804a1f25 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"eslint-plugin-prettier": "5.1.3",
"glob": "latest",
"prettier": "3.2.5",
+ "three": "0.184.0",
"typescript": "5.4.2",
"vite": "7.3.6",
"vite-plugin-dts": "4.5.4"
diff --git a/packages/front/src/core/Marker/src/renderer-with-2d.ts b/packages/front/src/core/Marker/src/renderer-with-2d.ts
index a924e61de..59e392c20 100644
--- a/packages/front/src/core/Marker/src/renderer-with-2d.ts
+++ b/packages/front/src/core/Marker/src/renderer-with-2d.ts
@@ -46,7 +46,13 @@ export class RendererWith2D extends SimpleRenderer {
this.three2D.domElement.style.pointerEvents = "none";
if (this.container) {
this.container.appendChild(this.three2D.domElement);
- this.container.style.position = "relative";
+ // Only set the positioning context when the app has not established
+ // one already — clobbering e.g. an absolutely positioned full-screen
+ // container would break the app layout.
+ const computed = window.getComputedStyle(this.container);
+ if (computed.position === "static") {
+ this.container.style.position = "relative";
+ }
}
}
}
diff --git a/packages/front/src/measurement/AngleMeasurement/index.ts b/packages/front/src/measurement/AngleMeasurement/index.ts
index 6c63f1c0c..2728ccd78 100644
--- a/packages/front/src/measurement/AngleMeasurement/index.ts
+++ b/packages/front/src/measurement/AngleMeasurement/index.ts
@@ -8,11 +8,7 @@ import { Mark } from "../../core";
import { Measurement } from "../Measurement";
import { MeasureLine } from "./src/types";
import { newDimensionMark, newEndPoint } from "../utils";
-import {
- AngleMeasurerModes,
- AngleMeasurerTempData,
- AngleVisual,
-} from "./src";
+import { AngleMeasurerModes, AngleMeasurerTempData, AngleVisual } from "./src";
const ARC_SEGMENTS = 32;
const ARC_RADIUS_FACTOR = 0.3;
@@ -155,6 +151,7 @@ export class AngleMeasurement extends Measurement {
} else if (this._temp.clickCount === 2) {
// Third click: set end and finalize
this._temp.angle.end.copy(point);
+ this._temp.clickCount = 3;
this.endCreation();
}
};
@@ -162,7 +159,10 @@ export class AngleMeasurement extends Measurement {
/** {@link OBC.Createable.endCreation}. Finalizes the current angle after the third point is set. */
endCreation = () => {
if (!this.enabled) return;
- if (this._temp.clickCount !== 2) return;
+ // Only commit once the third point has been set: an external
+ // Enter press after just two points would otherwise finalize an
+ // angle whose end is still the origin.
+ if (this._temp.clickCount !== 3) return;
this.disposeAllPreview();
this.list.add(this._temp.angle.clone());
@@ -526,6 +526,8 @@ export class AngleMeasurement extends Measurement {
normal.normalize();
const midDir = normalizedA.clone().applyAxisAngle(normal, angle / 2);
- return vertex.clone().add(midDir.multiplyScalar(radius * LABEL_OFFSET_FACTOR));
+ return vertex
+ .clone()
+ .add(midDir.multiplyScalar(radius * LABEL_OFFSET_FACTOR));
}
}
diff --git a/packages/front/src/measurement/AreaMeasurement/index.ts b/packages/front/src/measurement/AreaMeasurement/index.ts
index 700399dd5..6093b7a11 100644
--- a/packages/front/src/measurement/AreaMeasurement/index.ts
+++ b/packages/front/src/measurement/AreaMeasurement/index.ts
@@ -230,12 +230,23 @@ export class AreaMeasurement extends Measurement {
this._temp.isDragging = true;
}
- if (area.points.size === 0) {
- point.copy(pickerData.point);
- area.points.add(point.clone());
- } else {
- area.points.add(point.clone());
+ // Always consume the click-time pick (`pickerData`). The preview
+ // point (`_temp.point`) is driven by the pointermove pick, which
+ // can be stale right after camera interaction — committing it
+ // would place the vertex where the cursor *was*, not under the
+ // double-click. Project onto the in-progress plane when the point
+ // falls within the tolerance so the polygon stays planar.
+ point.copy(pickerData.point);
+ const { plane } = area;
+ if (plane) {
+ const distance = plane.distanceToPoint(point);
+ if (Math.abs(distance) < this.pickTolerance) {
+ const projection = new THREE.Vector3();
+ plane.projectPoint(point, projection);
+ point.copy(projection);
+ }
}
+ area.points.add(point.clone());
if (this.mode === "square" && area.points.size === 2 && pickerData.normal) {
const [p1, p3] = area.points;
diff --git a/packages/front/src/measurement/LengthMeasurement/index.ts b/packages/front/src/measurement/LengthMeasurement/index.ts
index 3e3a6c6a5..90f9b5500 100644
--- a/packages/front/src/measurement/LengthMeasurement/index.ts
+++ b/packages/front/src/measurement/LengthMeasurement/index.ts
@@ -28,7 +28,6 @@ export class LengthMeasurement extends Measurement {
private _edgeMissStreak = 0;
private static readonly EDGE_MISS_TOLERANCE = 3;
-
/**
* The possible modes in which a measurement of this type may be created.
*/
@@ -90,12 +89,15 @@ export class LengthMeasurement extends Measurement {
throw new Error("Measurement: world is need!");
}
- // Prefer the latest per-frame pick (matches what the snap marker
- // is showing), fall back to a fresh pick when the user clicks
- // before any pointer-move has fired in this session.
- const pickResult =
- this.lastPick ??
- (await this._vertexPicker.get({ snappingClasses: this.snappings }));
+ // Always pick fresh at click time. `lastPick` is the last RAF pick
+ // driven by pointermove, which goes stale whenever the camera
+ // moves without the cursor moving (wheel zoom suspends picks for
+ // `cameraStopDelay` ms); reusing it would anchor the measurement
+ // to where the cursor *was*, not to the surface under the
+ // double-click.
+ const pickResult = await this._vertexPicker.get({
+ snappingClasses: this.snappings,
+ });
if (this.mode === "free") {
if (!pickResult?.point) return;
@@ -196,6 +198,17 @@ export class LengthMeasurement extends Measurement {
await this.initPreview();
return;
}
+ // Refresh from a click-time pick before committing: the preview
+ // follows `lastPick` (pointermove-driven), which can lag or be
+ // stale right after camera interaction, so committing the preview
+ // as-is would anchor the endpoint away from where the user
+ // actually double-clicked. `updatePreviewLine` consumes the fresh
+ // `lastPick` in both modes (free copies the point into the line,
+ // edge re-snaps the edge) and the edge commit guard reads it too.
+ this.lastPick = await this._vertexPicker.get({
+ snappingClasses: this.snappings,
+ });
+ this.updatePreviewLine();
this.endCreation();
};
diff --git a/projects/README.md b/projects/README.md
new file mode 100644
index 000000000..1e0546326
--- /dev/null
+++ b/projects/README.md
@@ -0,0 +1,41 @@
+# Projects
+
+Drop IFC models into this folder to load them from the dev server.
+
+## Layout
+
+```
+projects/
+├── viewer.html / viewer.ts # multi-model IFC viewer
+├── /
+│ ├── model.ifc # one directory per project
+│ └── project.title # optional display title (first line)
+└── index.html # optional gallery, generated by your tooling
+```
+
+Every directory that contains an `.ifc` file is listed on the dev server home
+page and opens in `viewer.html`.
+
+## Viewer
+
+- **Add IFC…** — the only load button: pick one or more IFC files and append
+ them to the current session. Works on an empty scene too (empty session =
+ open `viewer.html` with no query params, then add files).
+- **Models panel** — list of loaded models with per-model hide/unload and a
+ coordinate mode:
+ - `shared (first model)` (default) — every model is moved into the
+ coordinate system of the first loaded one
+ (`position = coords_first − coords_i`, read from `model.getCoordinates()`);
+ - `from each file` — keeps the per-file normalization web-ifc applied while
+ loading (`COORDINATE_TO_ORIGIN`).
+- Drag & drop also appends files.
+- The page validates the IFC signature (`ISO-10303-21`) before conversion, so
+ a wrong URL or a broken file shows an error instead of crashing the tab.
+
+Models can be loaded straight from the URL, one `?model=` parameter per file:
+
+```
+projects/viewer.html?model=name-a/model.ifc&model=name-b/model.ifc&title=Project
+```
+
+For debugging, `window.__bim` exposes `{ components, world, fragments, ifcLoader }`.
diff --git a/projects/addons.ts b/projects/addons.ts
new file mode 100644
index 000000000..78c54a9e0
--- /dev/null
+++ b/projects/addons.ts
@@ -0,0 +1,1987 @@
+/* eslint-disable import/no-extraneous-dependencies */
+/* eslint-disable no-use-before-define */
+import * as THREE from "three";
+import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
+import * as OBC from "../packages/core/src/index";
+import { Highlighter } from "../packages/front/src/fragments/Highlighter";
+import { Hoverer } from "../packages/front/src/fragments/Hoverer";
+import { Mesher } from "../packages/front/src/fragments/Mesher";
+import { ClipStyler } from "../packages/front/src/core/ClipStyler";
+import { Marker } from "../packages/front/src/core/Marker";
+import { LengthMeasurement } from "../packages/front/src/measurement/LengthMeasurement";
+import { AreaMeasurement } from "../packages/front/src/measurement/AreaMeasurement";
+import { AngleMeasurement } from "../packages/front/src/measurement/AngleMeasurement";
+import { VolumeMeasurement } from "../packages/front/src/measurement/VolumeMeasurement";
+import { formatUnits } from "../packages/front/src/utils/format-units";
+
+export type AddOnHandle = {
+ enable: () => Promise;
+ disable: () => Promise;
+ onModelRemoved?: (modelId: string) => Promise | void;
+ component?: unknown;
+};
+
+export type AddOnPanel = {
+ update: () => void;
+ dispose: () => void;
+};
+
+export type CategoryInfo = { name: string; count: number };
+
+export type AddOnPanelHelpers = {
+ hasModels: () => boolean;
+ setStatus: (text: string, isError?: boolean) => void;
+ categories: () => Promise;
+};
+
+export type AddOnDef = {
+ id: string;
+ name: string;
+ create: () => AddOnHandle;
+ panel: (root: HTMLElement, helpers: AddOnPanelHelpers) => AddOnPanel;
+};
+
+export type AddOnsContext = {
+ components: OBC.Components;
+ world: OBC.World;
+ fragments: OBC.FragmentsManager;
+ setStatus: (text: string, isError?: boolean) => void;
+};
+
+export type AddOnsManager = {
+ defs: AddOnDef[];
+ isEnabled: (id: string) => boolean;
+ getComponent: (id: string) => unknown;
+ toggle: (id: string, on: boolean, silent?: boolean) => Promise;
+ onModelRemoved: (modelId: string) => Promise;
+};
+
+// eslint-disable-next-line no-undef -- DOM lib type, known to TypeScript
+const el = (
+ tag: K,
+ className?: string,
+ text?: string,
+) => {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+};
+
+const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
+
+const attrText = (data: Record | undefined, name: string) => {
+ const attr = data?.[name];
+ if (attr && typeof attr === "object" && "value" in attr) {
+ return String(attr.value);
+ }
+ return "";
+};
+
+const propValueText = (prop: Record) => {
+ for (const key of Object.keys(prop)) {
+ if (key.startsWith("_") || !/value/i.test(key)) continue;
+ const attr = prop[key];
+ if (
+ attr &&
+ typeof attr === "object" &&
+ !Array.isArray(attr) &&
+ "value" in attr
+ ) {
+ return String(attr.value);
+ }
+ }
+ return "";
+};
+
+const countSelection = (selection: OBC.ModelIdMap | undefined) => {
+ let total = 0;
+ for (const ids of Object.values(selection ?? {})) total += ids.size;
+ return total;
+};
+
+const singleSelection = (selection: OBC.ModelIdMap | undefined) => {
+ for (const [modelId, ids] of Object.entries(selection ?? {})) {
+ if (ids.size === 1) return { modelId, localId: [...ids][0] };
+ }
+ return null;
+};
+
+const categoryRegex = (category: string) =>
+ new RegExp(`^${category.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`);
+
+const collectCategories = async (
+ fragments: OBC.FragmentsManager,
+): Promise => {
+ const counts = new Map();
+ const names = new Set();
+ for (const model of fragments.list.values()) {
+ const categories = (await model.getItemsWithGeometryCategories()).filter(
+ (category): category is string => Boolean(category),
+ );
+ if (!categories.length) continue;
+ const byCategory = await model.getItemsOfCategories(
+ categories.map(categoryRegex),
+ );
+ for (const [category, localIds] of Object.entries(byCategory)) {
+ names.add(category);
+ counts.set(category, (counts.get(category) ?? 0) + localIds.length);
+ }
+ }
+ return [...names]
+ .sort()
+ .map((name) => ({ name, count: counts.get(name) ?? 0 }));
+};
+
+const itemsOfCategory = async (
+ fragments: OBC.FragmentsManager,
+ category: string,
+): Promise => {
+ const modelIdMap: OBC.ModelIdMap = {};
+ const regex = categoryRegex(category);
+ for (const model of fragments.list.values()) {
+ const byCategory = await model.getItemsOfCategories([regex]);
+ const localIds = byCategory[category];
+ if (localIds?.length) modelIdMap[model.modelId] = new Set(localIds);
+ }
+ return modelIdMap;
+};
+
+export function createAddOns(
+ ctx: AddOnsContext,
+ dom: { listEl: HTMLElement; panelEl: HTMLElement },
+): AddOnsManager {
+ const helpers: AddOnPanelHelpers = {
+ hasModels: () => ctx.fragments.list.size > 0,
+ setStatus: ctx.setStatus,
+ categories: () => collectCategories(ctx.fragments),
+ };
+
+ const defs: AddOnDef[] = [
+ createHighlighterAddOn(ctx),
+ createHovererAddOn(ctx),
+ createHiderAddOn(ctx),
+ createClipperAddOn(ctx),
+ createMeasurementsAddOn(ctx),
+ createItemsFinderAddOn(ctx),
+ createClassifierAddOn(ctx),
+ createBoundingBoxerAddOn(ctx),
+ createMesherAddOn(ctx),
+ createViewsAddOn(ctx),
+ createViewpointsAddOn(ctx),
+ createMarkerAddOn(ctx),
+ ];
+
+ const handles = new Map();
+ const enabledIds: string[] = [];
+ let activeId: string | null = null;
+ let activePanel: AddOnPanel | null = null;
+
+ const renderList = () => {
+ dom.listEl.replaceChildren();
+ for (const def of defs) {
+ const item = el("li");
+ const label = el("label", "addon-check");
+ label.title = "Add-ons are exclusive: pick one, click it again to stop";
+ const radio = document.createElement("input");
+ radio.type = "radio";
+ radio.name = "bim-viewer-addon";
+ radio.value = def.id;
+ radio.checked = enabledIds.includes(def.id);
+ // Add-ons are mutually exclusive (R-012): a radio button turns one on
+ // and all the others off. Native radios cannot be unchecked, so a
+ // second click on the active one switches the add-on off.
+ radio.addEventListener("click", () => {
+ const wasEnabled = enabledIds.includes(def.id);
+ toggle(def.id, !wasEnabled).catch((e) =>
+ ctx.setStatus(errorText(e), true),
+ );
+ });
+ const name = el("span", undefined, def.name);
+ label.append(radio, name);
+ item.append(label);
+ dom.listEl.append(item);
+ }
+ };
+
+ const showPanel = (id: string | null) => {
+ if (activePanel) {
+ try {
+ activePanel.dispose();
+ } catch (e) {
+ ctx.setStatus(errorText(e), true);
+ }
+ activePanel = null;
+ }
+ dom.panelEl.replaceChildren();
+ activeId = null;
+ if (!id) {
+ dom.panelEl.append(
+ el("p", "addon-hint", "Enable an add-on to see its tools"),
+ );
+ return;
+ }
+ const def = defs.find((candidate) => candidate.id === id);
+ if (!def) return;
+ const root = el("div", "addon-root");
+ dom.panelEl.append(el("div", "addon-title", def.name), root);
+ activeId = id;
+ try {
+ activePanel = def.panel(root, helpers);
+ activePanel.update();
+ } catch (e) {
+ activePanel = null;
+ root.replaceChildren(el("p", "addon-hint", errorText(e)));
+ ctx.setStatus(`"${def.name}" panel failed: ${errorText(e)}`, true);
+ }
+ };
+
+ const updateActivePanel = () => {
+ if (!activePanel) return;
+ try {
+ activePanel.update();
+ } catch (e) {
+ ctx.setStatus(errorText(e), true);
+ }
+ };
+
+ async function toggle(id: string, on: boolean, silent = false) {
+ const def = defs.find((candidate) => candidate.id === id);
+ if (!def) return;
+ if (on) {
+ if (enabledIds.includes(id)) return;
+ // Radio-button behaviour: enabling one add-on disables every other.
+ for (const other of defs) {
+ if (other.id !== id && enabledIds.includes(other.id)) {
+ await toggle(other.id, false, true);
+ }
+ }
+ let handle = handles.get(id);
+ if (!handle) {
+ try {
+ handle = await def.create();
+ } catch (e) {
+ renderList();
+ ctx.setStatus(`"${def.name}" failed to start: ${errorText(e)}`, true);
+ return;
+ }
+ handles.set(id, handle);
+ }
+ try {
+ await handle.enable();
+ } catch (e) {
+ renderList();
+ ctx.setStatus(`"${def.name}" failed to enable: ${errorText(e)}`, true);
+ return;
+ }
+ enabledIds.push(id);
+ renderList();
+ showPanel(id);
+ if (!silent) ctx.setStatus(`${def.name} enabled`);
+ return;
+ }
+ const index = enabledIds.indexOf(id);
+ if (index === -1) return;
+ enabledIds.splice(index, 1);
+ const handle = handles.get(id);
+ if (handle) {
+ try {
+ await handle.disable();
+ } catch (e) {
+ ctx.setStatus(`"${def.name}" failed to disable: ${errorText(e)}`, true);
+ }
+ }
+ if (!silent) {
+ if (activeId === id) showPanel(enabledIds[enabledIds.length - 1] ?? null);
+ renderList();
+ ctx.setStatus(`${def.name} disabled`);
+ }
+ }
+
+ ctx.fragments.list.onItemSet.add(() => updateActivePanel());
+ ctx.fragments.list.onItemDeleted.add(() => updateActivePanel());
+
+ renderList();
+ showPanel(null);
+
+ return {
+ defs,
+ isEnabled: (id) => enabledIds.includes(id),
+ getComponent: (id) => handles.get(id)?.component,
+ toggle,
+ onModelRemoved: async (modelId) => {
+ for (const handle of handles.values()) {
+ if (!handle.onModelRemoved) continue;
+ try {
+ await handle.onModelRemoved(modelId);
+ } catch (e) {
+ ctx.setStatus(errorText(e), true);
+ }
+ }
+ updateActivePanel();
+ },
+ };
+}
+
+function createHighlighterAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let highlighter: Highlighter | null = null;
+
+ return {
+ id: "highlighter",
+ name: "Highlighter",
+ create: () => {
+ highlighter = ctx.components.get(Highlighter);
+ highlighter.setup({
+ world: ctx.world,
+ selectMaterialDefinition: {
+ color: new THREE.Color("#BCF124"),
+ opacity: 1,
+ transparent: false,
+ renderedFaces: 0,
+ },
+ });
+ highlighter.events.select.onHighlight.add(() => {
+ updateRequested.trigger();
+ });
+ highlighter.events.select.onClear.add(() => {
+ updateRequested.trigger();
+ });
+ return {
+ component: highlighter,
+ enable: async () => {
+ highlighter.enabled = true;
+ },
+ disable: async () => {
+ await highlighter.clear();
+ highlighter.enabled = false;
+ },
+ onModelRemoved: async (modelId) => {
+ if (!highlighter) return;
+ const ids = highlighter.selection.select?.[modelId];
+ if (!ids || !ids.size) return;
+ await highlighter.clear("select", { [modelId]: ids });
+ updateRequested.trigger();
+ },
+ };
+ },
+ panel: (root, panelHelpers) => {
+ let disposed = false;
+ let renderGen = 0;
+
+ const render = async () => {
+ const gen = ++renderGen;
+ root.replaceChildren();
+ if (!highlighter) return;
+ const selection = highlighter.selection.select;
+ const total = countSelection(selection);
+ if (!panelHelpers.hasModels()) {
+ root.append(el("p", "addon-hint", "Load an IFC to use this tool"));
+ return;
+ }
+ if (total === 0) {
+ root.append(
+ el("p", "addon-hint", "Click an element to see its properties"),
+ );
+ return;
+ }
+ if (total > 1) {
+ root.append(el("p", "addon-hint", `${total} elements selected`));
+ return;
+ }
+ const single = singleSelection(selection);
+ if (!single) return;
+ const model = ctx.fragments.list.get(single.modelId);
+ if (!model) {
+ root.append(el("p", "addon-hint", "Model not found"));
+ return;
+ }
+ root.append(el("p", "addon-hint", "Loading properties…"));
+ let data: Record | undefined;
+ try {
+ [data] = await model.getItemsData([single.localId], {
+ attributesDefault: true,
+ relations: {
+ IsDefinedBy: { attributes: true, relations: true },
+ },
+ });
+ } catch (e) {
+ if (disposed || gen !== renderGen) return;
+ root.replaceChildren(el("p", "addon-hint", errorText(e)));
+ return;
+ }
+ if (disposed || gen !== renderGen) return;
+ if (!data) {
+ root.replaceChildren(el("p", "addon-hint", "Element data not found"));
+ return;
+ }
+ root.replaceChildren();
+ const header = el("p", "addon-hint", "Selected: 1 element");
+ root.append(header);
+ const rows = el("div", "addon-rows");
+ const addRow = (container: HTMLElement, key: string, value: string) => {
+ const row = el("div", "addon-row");
+ const keyEl = el("span", "addon-key", key);
+ const valueEl = el("span", "addon-value", value);
+ valueEl.title = value;
+ row.append(keyEl, valueEl);
+ container.append(row);
+ };
+ addRow(rows, "expressID", String(single.localId));
+ addRow(rows, "localId", String(single.localId));
+ addRow(rows, "GlobalId", attrText(data, "_guid"));
+ addRow(rows, "Name", attrText(data, "Name"));
+ addRow(rows, "Type", attrText(data, "_category"));
+ root.append(rows);
+ const psets = Array.isArray(data.IsDefinedBy) ? data.IsDefinedBy : [];
+ const titledPsets = psets.filter(
+ (set: Record) =>
+ Array.isArray(set.HasProperties) ||
+ Array.isArray(set.Quantities) ||
+ Array.isArray(set.HasPropertySets),
+ );
+ if (!titledPsets.length) return;
+ root.append(el("div", "addon-section", "Property Sets"));
+ const addPropertySet = (
+ set: Record,
+ container: HTMLElement,
+ ) => {
+ const setName = attrText(set, "Name") || attrText(set, "_category");
+ let props: any[] = [];
+ if (Array.isArray(set.HasProperties)) props = set.HasProperties;
+ else if (Array.isArray(set.Quantities)) props = set.Quantities;
+ if (!props.length) return;
+ container.append(el("div", "addon-pset-name", setName));
+ const propRows = el("div", "addon-rows");
+ for (const prop of props) {
+ const propName = attrText(prop, "Name");
+ if (!propName) continue;
+ addRow(propRows, propName, propValueText(prop));
+ }
+ container.append(propRows);
+ };
+ const list = el("div", "addon-psets");
+ for (const set of titledPsets) {
+ addPropertySet(set, list);
+ if (Array.isArray(set.HasPropertySets)) {
+ for (const typeSet of set.HasPropertySets) {
+ addPropertySet(typeSet, list);
+ }
+ }
+ }
+ root.append(list);
+ };
+
+ const requestRender = () => {
+ render().catch((e) => {
+ if (!disposed) ctx.setStatus(errorText(e), true);
+ });
+ };
+ updateRequested.add(requestRender);
+ requestRender();
+ return {
+ update: requestRender,
+ dispose: () => {
+ disposed = true;
+ updateRequested.remove(requestRender);
+ },
+ };
+ },
+ };
+}
+
+function createHovererAddOn(ctx: AddOnsContext): AddOnDef {
+ let hoverer: Hoverer | null = null;
+ let color = "#ff9f43";
+
+ const applyColor = () => {
+ if (!hoverer) return;
+ hoverer.material = new THREE.MeshBasicMaterial({
+ color: new THREE.Color(color),
+ transparent: true,
+ opacity: 0.45,
+ depthTest: false,
+ side: THREE.DoubleSide,
+ });
+ };
+
+ return {
+ id: "hoverer",
+ name: "Hoverer",
+ create: () => {
+ hoverer = ctx.components.get(Hoverer);
+ hoverer.world = ctx.world;
+ applyColor();
+ return {
+ component: hoverer,
+ enable: async () => {
+ hoverer.enabled = true;
+ },
+ disable: async () => {
+ hoverer.clear();
+ hoverer.enabled = false;
+ },
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const row = el("div", "addon-row");
+ row.append(el("span", "addon-key", "Color"));
+ const input = document.createElement("input");
+ input.type = "color";
+ input.className = "addon-color";
+ input.value = color;
+ input.addEventListener("input", () => {
+ color = input.value;
+ applyColor();
+ });
+ row.append(input);
+ const hint = el("p", "addon-hint");
+ root.append(row, hint);
+ return {
+ update: () => {
+ hint.textContent = panelHelpers.hasModels()
+ ? "Move the cursor over an element to preview it"
+ : "Load an IFC to use this tool";
+ },
+ dispose: () => {},
+ };
+ },
+ };
+}
+
+function createHiderAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let hider: OBC.Hider | null = null;
+ let categories: CategoryInfo[] = [];
+ const hidden = new Set();
+
+ const setCategoryVisible = async (category: string, visible: boolean) => {
+ if (!hider) return;
+ const modelIdMap = await itemsOfCategory(ctx.fragments, category);
+ await hider.set(visible, modelIdMap);
+ if (visible) hidden.delete(category);
+ else hidden.add(category);
+ updateRequested.trigger();
+ };
+
+ return {
+ id: "hider",
+ name: "Hider",
+ create: () => {
+ hider = ctx.components.get(OBC.Hider);
+ return {
+ component: hider,
+ enable: async () => {
+ categories = await collectCategories(ctx.fragments);
+ },
+ disable: async () => {
+ hidden.clear();
+ await hider.set(true);
+ },
+ onModelRemoved: async () => {
+ hidden.clear();
+ await hider.set(true);
+ categories = await collectCategories(ctx.fragments);
+ },
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const listRoot = el("div", "addon-rows");
+ root.append(listRoot);
+ let disposed = false;
+
+ const render = async () => {
+ listRoot.replaceChildren();
+ if (!panelHelpers.hasModels()) {
+ listRoot.append(
+ el("p", "addon-hint", "Load an IFC to use this tool"),
+ );
+ return;
+ }
+ categories = await panelHelpers.categories();
+ if (disposed) return;
+ listRoot.replaceChildren();
+ if (!categories.length) {
+ listRoot.append(el("p", "addon-hint", "No categories found"));
+ return;
+ }
+ for (const category of categories) {
+ const label = el("label", "addon-check");
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.checked = !hidden.has(category.name);
+ checkbox.addEventListener("change", () => {
+ setCategoryVisible(category.name, checkbox.checked).catch((e) =>
+ panelHelpers.setStatus(errorText(e), true),
+ );
+ });
+ label.append(
+ checkbox,
+ el("span", undefined, `${category.name} (${category.count})`),
+ );
+ listRoot.append(label);
+ }
+ };
+
+ const requestRender = () => {
+ render().catch((e) => {
+ if (!disposed) panelHelpers.setStatus(errorText(e), true);
+ });
+ };
+ updateRequested.add(requestRender);
+ requestRender();
+ return {
+ update: requestRender,
+ dispose: () => {
+ disposed = true;
+ updateRequested.remove(requestRender);
+ },
+ };
+ },
+ };
+}
+
+function createClipperAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let clipper: OBC.Clipper | null = null;
+ let clipStyler: ClipStyler | null = null;
+ const planeBases = new Map();
+ let canvas: HTMLCanvasElement | null = null;
+
+ // A newly created plane clips the side the clicked surface faces away from
+ // (the engine negates the hit normal). That is right when you click a roof
+ // from above, but if you click a floor from below/inside the plane faces
+ // down and clips the camera's own side, so nothing is rendered. After each
+ // creation, make sure the camera stays on the kept side; flip the plane if
+ // it would otherwise clip the camera away.
+ const keepCameraSide = (plane: OBC.SimplePlane) => {
+ const camera = ctx.world.camera?.three;
+ if (!camera) return;
+ // A THREE.Plane keeps points where normal·p + constant >= 0.
+ const onKeptSide =
+ plane.three.normal.dot(camera.position) + plane.three.constant >= 0;
+ if (onKeptSide) return;
+ plane.setFromNormalAndCoplanarPoint(
+ plane.normal.clone().negate(),
+ plane.origin.clone(),
+ );
+ };
+
+ // The plane must stay PARALLEL to the face that was double-clicked (the engine
+ // already gives us that normal). The engine anchors the plane to the clicked
+ // surface, so a wall click hides just the thin slice behind that wall. Slide
+ // the plane (keeping its orientation) until it passes through the model's
+ // bounding-box center, so the model visibly splits into two halves that are
+ // still parallel to the clicked face.
+ const bisectThroughModelCenter = (plane: OBC.SimplePlane) => {
+ let cx = 0;
+ let cy = 0;
+ let cz = 0;
+ let count = 0;
+ for (const model of ctx.fragments.list.values()) {
+ const box = (model as unknown as { box?: THREE.Box3 }).box;
+ if (!box) continue;
+ cx += (box.min.x + box.max.x) / 2;
+ cy += (box.min.y + box.max.y) / 2;
+ cz += (box.min.z + box.max.z) / 2;
+ count++;
+ }
+ if (!count) return;
+ const center = new THREE.Vector3(cx / count, cy / count, cz / count);
+ plane.setFromNormalAndCoplanarPoint(plane.normal.clone(), center);
+ };
+
+ // Force the fragment worker to re-read the clipping planes after every plane
+ // mutation. The worker only pulls `getClippingPlanesEvent` when a fresh view
+ // is pushed, and adding/removing a plane does not move the camera. The
+ // forced update's FINISH fence can hang forever when the worker is idle, so
+ // this is fire-and-forget.
+ const refreshModels = () => {
+ ctx.fragments.core.update(true).catch(() => {});
+ };
+
+ // The renderer clips through the GLOBAL `three.clippingPlanes` list: local
+ // per-material planes don't clip fragment meshes (their shaders are baked
+ // once at load, before any plane exists). three.js applies global planes to
+ // every scene object at draw time, so a plane pushed into
+ // `renderer.three.clippingPlanes` reliably cuts the fragment model in two.
+ const onDblClick = () => {
+ if (!clipper) return;
+ clipper
+ .create(ctx.world)
+ .then((plane) => {
+ if (!plane) return;
+ keepCameraSide(plane);
+ refreshModels();
+ updateRequested.trigger();
+ ctx.setStatus(
+ "Section plane added. Half of the model is hidden; drag the plane to move it, or press Delete / use Delete all to restore the model.",
+ );
+ })
+ .catch((e) => ctx.setStatus(errorText(e), true));
+ };
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Delete" && event.key !== "Backspace") return;
+ const target = event.target as HTMLElement | null;
+ if (
+ target &&
+ (target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable)
+ ) {
+ return;
+ }
+ if (!clipper) return;
+ clipper
+ .delete(ctx.world)
+ .then(() => {
+ refreshModels();
+ updateRequested.trigger();
+ })
+ .catch((e) => ctx.setStatus(errorText(e), true));
+ };
+
+ return {
+ id: "clipper",
+ name: "Clipper",
+ create: () => {
+ clipper = ctx.components.get(OBC.Clipper);
+ // Use the renderer's GLOBAL clipping list: three.js clips fragment meshes
+ // reliably through `renderer.three.clippingPlanes` (their shaders are
+ // compiled once at load with the clipping chunk because the renderer
+ // keeps `localClippingEnabled`, so global planes apply at draw time).
+ // Per-material local planes don't cut fragment tiles, so keep the engine
+ // default (global).
+ clipper.localClippingPlanes = false;
+ clipStyler = ctx.components.get(ClipStyler);
+ clipStyler.world = ctx.world;
+ clipStyler.styles.set("Section", {
+ linesMaterial: new LineMaterial({ color: "#14181d", linewidth: 2 }),
+ fillsMaterial: new THREE.MeshBasicMaterial({
+ color: "#c5cbd4",
+ side: THREE.DoubleSide,
+ }),
+ });
+
+ // Feed the planes to the fragment worker too: it culls tile boxes behind
+ // the cut once it learns about them on a view refresh.
+ const setModelPlanes = (model: { getClippingPlanesEvent?: unknown }) => {
+ model.getClippingPlanesEvent = () =>
+ Array.from(ctx.world.renderer?.clippingPlanes ?? []);
+ };
+ for (const model of ctx.fragments.list.values()) setModelPlanes(model);
+ const onModelSet = ({
+ value,
+ }: {
+ value: { getClippingPlanesEvent?: unknown };
+ }) => setModelPlanes(value);
+ ctx.fragments.list.onItemSet.add(onModelSet);
+
+ const onAfterCreate = (plane: OBC.SimplePlane) => {
+ if (!clipper || !clipStyler) return;
+ const key = clipper.list.getKey(plane);
+ if (!key) return;
+ // Keep the plane parallel to the clicked face but slide it through the
+ // model center so the cut is clearly visible, then generate the section.
+ bisectThroughModelCenter(plane);
+ planeBases.set(key, plane.origin.clone());
+ plane.onDraggingEnded.add(() => {
+ planeBases.set(key, plane.origin.clone());
+ refreshModels();
+ updateRequested.trigger();
+ });
+ try {
+ clipStyler.createFromClipping(key, {
+ items: { All: { style: "Section" } },
+ });
+ } catch (e) {
+ ctx.setStatus(errorText(e), true);
+ }
+ updateRequested.trigger();
+ };
+ clipper.onAfterCreate.add(onAfterCreate);
+ clipper.list.onItemDeleted.add(() => updateRequested.trigger());
+
+ return {
+ component: clipper,
+ enable: async () => {
+ canvas = ctx.world.renderer?.three.domElement ?? null;
+ canvas?.addEventListener("dblclick", onDblClick);
+ window.addEventListener("keydown", onKeyDown);
+ clipper.enabled = true;
+ },
+ disable: async () => {
+ canvas?.removeEventListener("dblclick", onDblClick);
+ window.removeEventListener("keydown", onKeyDown);
+ clipper.deleteAll();
+ clipper.enabled = false;
+ planeBases.clear();
+ refreshModels();
+ },
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const listRoot = el("div", "addon-rows");
+ const hint = el(
+ "p",
+ "addon-hint",
+ "Double-click a surface to add a plane; press Delete to remove the plane under the cursor",
+ );
+ const actions = el("div", "addon-actions");
+ const deleteAllBtn = el("button", "model-btn", "Delete all");
+ deleteAllBtn.type = "button";
+ deleteAllBtn.addEventListener("click", () => {
+ if (!clipper) return;
+ try {
+ clipper.deleteAll();
+ planeBases.clear();
+ refreshModels();
+ updateRequested.trigger();
+ } catch (e) {
+ panelHelpers.setStatus(errorText(e), true);
+ }
+ });
+ actions.append(deleteAllBtn);
+ root.append(hint, listRoot, actions);
+
+ const render = () => {
+ listRoot.replaceChildren();
+ if (!panelHelpers.hasModels()) {
+ listRoot.append(
+ el("p", "addon-hint", "Load an IFC to use this tool"),
+ );
+ return;
+ }
+ if (!clipper || !clipper.list.size) {
+ listRoot.append(el("p", "addon-hint", "No planes yet"));
+ return;
+ }
+ const clipperRef = clipper;
+ let index = 0;
+ for (const [key, plane] of clipperRef.list) {
+ index += 1;
+ const row = el("div", "addon-plane");
+ const enabled = document.createElement("input");
+ enabled.type = "checkbox";
+ enabled.checked = plane.enabled;
+ enabled.title = "Toggle plane";
+ enabled.addEventListener("change", () => {
+ plane.enabled = enabled.checked;
+ updateRequested.trigger();
+ });
+ row.append(enabled, el("span", undefined, `Plane ${index}`));
+ const slider = document.createElement("input");
+ slider.type = "range";
+ slider.min = "-20";
+ slider.max = "20";
+ slider.step = "0.1";
+ slider.value = "0";
+ slider.title = "Position along the plane normal";
+ slider.addEventListener("input", () => {
+ const base = planeBases.get(key) ?? plane.origin.clone();
+ const offset = Number(slider.value);
+ const point = base
+ .clone()
+ .addScaledVector(plane.normal.clone(), offset);
+ plane.setFromNormalAndCoplanarPoint(plane.normal.clone(), point);
+ });
+ row.append(slider);
+ const remove = el("button", "model-btn", "×");
+ remove.type = "button";
+ remove.title = "Remove plane";
+ remove.addEventListener("click", () => {
+ clipperRef
+ .delete(ctx.world, key)
+ .then(() => updateRequested.trigger())
+ .catch((e) => panelHelpers.setStatus(errorText(e), true));
+ });
+ row.append(remove);
+ listRoot.append(row);
+ }
+ };
+ updateRequested.add(render);
+ render();
+ return {
+ update: render,
+ dispose: () => updateRequested.remove(render),
+ };
+ },
+ };
+}
+
+type AnyMeasurer = {
+ world: OBC.World | null;
+ enabled: boolean;
+ units: string;
+ unitsList: string[];
+ list: Set & {
+ clear: () => void;
+ delete: (item: any) => boolean;
+ onItemAdded: {
+ add: (fn: () => void) => void;
+ remove: (fn: () => void) => void;
+ };
+ onItemDeleted: {
+ add: (fn: () => void) => void;
+ remove: (fn: () => void) => void;
+ };
+ onCleared: {
+ add: (fn: () => void) => void;
+ remove: (fn: () => void) => void;
+ };
+ };
+ create: () => Promise | void;
+ endCreation: () => void;
+ cancelCreation: () => void;
+};
+
+function createMeasurementsAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ const measurers = new Map();
+ let active: "length" | "area" | "angle" | "volume" = "length";
+ let enabled = false;
+ let canvas: HTMLCanvasElement | null = null;
+
+ const getMeasurer = (id: string): AnyMeasurer => {
+ let measurer = measurers.get(id);
+ if (!measurer) {
+ if (id === "length") {
+ measurer = ctx.components.get(LengthMeasurement) as AnyMeasurer;
+ } else if (id === "area") {
+ measurer = ctx.components.get(AreaMeasurement) as AnyMeasurer;
+ } else if (id === "angle") {
+ measurer = ctx.components.get(AngleMeasurement) as AnyMeasurer;
+ } else {
+ measurer = ctx.components.get(VolumeMeasurement) as AnyMeasurer;
+ }
+ measurer.world = ctx.world;
+ measurer.list.onItemAdded.add(() => updateRequested.trigger());
+ measurer.list.onItemDeleted.add(() => updateRequested.trigger());
+ measurer.list.onCleared.add(() => updateRequested.trigger());
+ measurers.set(id, measurer);
+ }
+ return measurer;
+ };
+
+ const switchType = (id: "length" | "area" | "angle" | "volume") => {
+ if (id === active) return;
+ const previous = measurers.get(active);
+ if (previous && enabled) {
+ previous.enabled = false;
+ previous.cancelCreation();
+ }
+ active = id;
+ if (enabled) getMeasurer(active).enabled = true;
+ updateRequested.trigger();
+ };
+
+ const onDblClick = () => {
+ const measurer = getMeasurer(active);
+ Promise.resolve(measurer.create()).catch((e) =>
+ ctx.setStatus(errorText(e), true),
+ );
+ };
+
+ // Area (free mode) and Volume commit their result only through
+ // `endCreation()`: dblclick alone just accumulates points/items in
+ // a temporary preview, so without Enter the measurement never
+ // reaches `list` — no value, no fill, nothing to delete. Length
+ // and Angle finalize through their own dblclick sequences, so
+ // Enter is wired only for the two tools that need it.
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (!(e.key === "Enter" || e.code === "NumpadEnter")) return;
+ if (active !== "area" && active !== "volume") return;
+ const measurer = getMeasurer(active);
+ try {
+ measurer.endCreation();
+ } catch (err) {
+ ctx.setStatus(errorText(err), true);
+ }
+ };
+
+ return {
+ id: "measurements",
+ name: "Measurements",
+ create: () => ({
+ enable: async () => {
+ enabled = true;
+ getMeasurer(active).enabled = true;
+ canvas = ctx.world.renderer?.three.domElement ?? null;
+ canvas?.addEventListener("dblclick", onDblClick);
+ window.addEventListener("keydown", onKeyDown);
+ },
+ disable: async () => {
+ enabled = false;
+ canvas?.removeEventListener("dblclick", onDblClick);
+ window.removeEventListener("keydown", onKeyDown);
+ for (const measurer of measurers.values()) {
+ measurer.enabled = false;
+ measurer.cancelCreation();
+ measurer.list.clear();
+ }
+ },
+ }),
+ panel: (root, panelHelpers) => {
+ const typesRow = el("div", "addon-types");
+ const typeDefs = [
+ ["length", "Length"],
+ ["area", "Area"],
+ ["angle", "Angle"],
+ ["volume", "Volume"],
+ ] as const;
+ const buttons = new Map();
+ for (const [id, label] of typeDefs) {
+ const button = el("button", "addon-type", label);
+ button.type = "button";
+ button.addEventListener("click", () => switchType(id));
+ buttons.set(id, button);
+ typesRow.append(button);
+ }
+ const unitsRow = el("div", "addon-row");
+ unitsRow.append(el("span", "addon-key", "Units"));
+ const unitsSelect = document.createElement("select");
+ unitsSelect.className = "addon-select";
+ unitsRow.append(unitsSelect);
+ const hint = el("p", "addon-hint");
+ const section = el("div", "addon-section");
+ const listRoot = el("div", "addon-rows");
+ const actions = el("div", "addon-actions");
+ const clearBtn = el("button", "model-btn", "Clear all");
+ clearBtn.type = "button";
+ clearBtn.addEventListener("click", () => {
+ getMeasurer(active).list.clear();
+ updateRequested.trigger();
+ });
+ actions.append(clearBtn);
+ root.append(typesRow, unitsRow, hint, section, listRoot, actions);
+
+ let disposed = false;
+ let renderGen = 0;
+
+ const render = async () => {
+ const gen = ++renderGen;
+ for (const [id, button] of buttons) {
+ button.classList.toggle("active", id === active);
+ }
+ if (!panelHelpers.hasModels()) {
+ hint.textContent = "Load an IFC to use this tool";
+ unitsRow.style.display = "none";
+ section.textContent = "";
+ listRoot.replaceChildren();
+ actions.style.display = "none";
+ return;
+ }
+ unitsRow.style.display = "";
+ actions.style.display = "";
+ hint.textContent =
+ "Double-click in the scene to place measurement points; press Enter to finish area/volume";
+ const measurer = getMeasurer(active);
+ unitsSelect.replaceChildren();
+ for (const unit of measurer.unitsList) {
+ const option = document.createElement("option");
+ option.value = unit;
+ option.textContent = formatUnits(unit);
+ option.selected = unit === measurer.units;
+ unitsSelect.append(option);
+ }
+ // Read the values first, then build the DOM in one pass: the
+ // loop is async (Volume resolves its value from the worker),
+ // and interleaving awaits with row creation would let a newer
+ // render's rows land inside an older render's list.
+ const rows: { item: any; text: string }[] = [];
+ for (const item of measurer.list) {
+ let value: unknown;
+ try {
+ if (typeof item?.value === "number") value = item.value;
+ else if (typeof item?.getValue === "function") {
+ value = await item.getValue();
+ }
+ } catch {
+ value = "?";
+ }
+ rows.push({ item, text: `${value} ${formatUnits(measurer.units)}` });
+ }
+ if (disposed || gen !== renderGen) return;
+ section.textContent = rows.length
+ ? `${rows.length} measurement${rows.length === 1 ? "" : "s"}`
+ : "No measurements yet";
+ listRoot.replaceChildren();
+ const measurerRef = measurer;
+ rows.forEach(({ item, text }, index) => {
+ const row = el("div", "addon-plane");
+ row.append(el("span", "addon-value", `${index + 1}. ${text}`));
+ const remove = el("button", "model-btn", "×");
+ remove.type = "button";
+ remove.title = "Remove measurement";
+ // The measurer's `list.onItemDeleted` subscription re-renders
+ // the panel, so no manual trigger here.
+ remove.addEventListener("click", () => {
+ measurerRef.list.delete(item);
+ });
+ row.append(remove);
+ listRoot.append(row);
+ });
+ };
+
+ const requestRender = () => {
+ render().catch((e) => {
+ if (!disposed) panelHelpers.setStatus(errorText(e), true);
+ });
+ };
+ unitsSelect.addEventListener("change", () => {
+ getMeasurer(active).units = unitsSelect.value;
+ requestRender();
+ });
+ updateRequested.add(requestRender);
+ requestRender();
+ return {
+ update: requestRender,
+ dispose: () => {
+ disposed = true;
+ updateRequested.remove(requestRender);
+ },
+ };
+ },
+ };
+}
+
+function createItemsFinderAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let finder: OBC.ItemsFinder | null = null;
+
+ return {
+ id: "items-finder",
+ name: "ItemsFinder",
+ create: () => {
+ finder = ctx.components.get(OBC.ItemsFinder);
+ return {
+ component: finder,
+ enable: async () => {},
+ disable: async () => {
+ finder.list.clear();
+ updateRequested.trigger();
+ },
+ onModelRemoved: async () => {
+ finder.list.clear();
+ updateRequested.trigger();
+ },
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const controls = el("div", "addon-row");
+ controls.append(el("span", "addon-key", "Category"));
+ const select = document.createElement("select");
+ select.className = "addon-select";
+ controls.append(select);
+
+ const actions = el("div", "addon-actions");
+ const findBtn = el("button", "model-btn", "Find");
+ findBtn.type = "button";
+ const clearBtn = el("button", "model-btn", "Clear queries");
+ clearBtn.type = "button";
+ actions.append(findBtn, clearBtn);
+
+ const hint = el("p", "addon-hint");
+ const results = el("div", "addon-rows");
+ root.append(controls, actions, hint, results);
+
+ const runFind = () => {
+ if (!finder) return;
+ const category = select.value;
+ if (!category) return;
+ finder.create(category, [{ categories: [categoryRegex(category)] }]);
+ const instance = finder.list.get(category);
+ if (!instance) return;
+ instance
+ .test()
+ .then(() => updateRequested.trigger())
+ .catch((e) => panelHelpers.setStatus(errorText(e), true));
+ };
+
+ findBtn.addEventListener("click", runFind);
+ clearBtn.addEventListener("click", () => {
+ finder?.list.clear();
+ updateRequested.trigger();
+ });
+
+ let disposed = false;
+
+ const render = async () => {
+ results.replaceChildren();
+ if (!panelHelpers.hasModels()) {
+ hint.textContent = "Load an IFC to use this tool";
+ select.replaceChildren();
+ return;
+ }
+ const categories = await panelHelpers.categories();
+ if (disposed) return;
+ const current = select.value;
+ select.replaceChildren();
+ for (const category of categories) {
+ const option = document.createElement("option");
+ option.value = category.name;
+ option.textContent = `${category.name} (${category.count})`;
+ select.append(option);
+ }
+ if (current && categories.some((item) => item.name === current)) {
+ select.value = current;
+ }
+ hint.textContent =
+ "Pick a category and press Find to run an items query";
+ results.replaceChildren();
+ if (!finder || !finder.list.size) {
+ results.append(el("p", "addon-hint", "No queries yet"));
+ return;
+ }
+ for (const [name, instance] of finder.list) {
+ const total = countSelection(instance.result ?? undefined);
+ results.append(
+ el("p", "addon-pset-name", `${name}: ${total} elements`),
+ );
+ }
+ };
+
+ const requestRender = () => {
+ render().catch((e) => {
+ if (!disposed) panelHelpers.setStatus(errorText(e), true);
+ });
+ };
+ updateRequested.add(requestRender);
+ requestRender();
+ return {
+ update: requestRender,
+ dispose: () => {
+ disposed = true;
+ updateRequested.remove(requestRender);
+ },
+ };
+ },
+ };
+}
+
+function createClassifierAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let classifier: OBC.Classifier | null = null;
+
+ return {
+ id: "classifier",
+ name: "Classifier",
+ create: () => {
+ classifier = ctx.components.get(OBC.Classifier);
+ return {
+ component: classifier,
+ enable: async () => {},
+ disable: async () => {
+ classifier.list.clear();
+ updateRequested.trigger();
+ },
+ onModelRemoved: async () => updateRequested.trigger(),
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const actions = el("div", "addon-actions");
+ const addButton = (label: string, run: () => Promise) => {
+ const button = el("button", "model-btn", label);
+ button.type = "button";
+ button.addEventListener("click", () => {
+ button.classList.add("disabled");
+ run()
+ .then(() => updateRequested.trigger())
+ .catch((e) => panelHelpers.setStatus(errorText(e), true))
+ .finally(() => button.classList.remove("disabled"));
+ });
+ actions.append(button);
+ };
+ addButton("By category", async () => {
+ await classifier?.byCategory();
+ });
+ addButton("By storey", async () => {
+ await classifier?.byIfcBuildingStorey();
+ });
+ addButton("By model", async () => {
+ await classifier?.byModel();
+ });
+ const clearBtn = el("button", "model-btn", "Clear");
+ clearBtn.type = "button";
+ clearBtn.addEventListener("click", () => {
+ classifier?.list.clear();
+ updateRequested.trigger();
+ });
+ actions.append(clearBtn);
+
+ const hint = el("p", "addon-hint");
+ const tree = el("div", "addon-rows");
+ root.append(actions, hint, tree);
+
+ let disposed = false;
+ let renderGen = 0;
+
+ const render = async () => {
+ const gen = ++renderGen;
+ tree.replaceChildren();
+ if (!panelHelpers.hasModels()) {
+ hint.textContent = "Load an IFC to use this tool";
+ return;
+ }
+ if (!classifier || !classifier.list.size) {
+ hint.textContent = "Group the elements with the buttons above";
+ return;
+ }
+ hint.textContent = "Groups of the created classifications";
+ for (const [classification, groups] of classifier.list) {
+ if (disposed || gen !== renderGen) return;
+ tree.append(el("div", "addon-section", classification));
+ for (const [name, groupData] of groups) {
+ if (disposed || gen !== renderGen) return;
+ let total = countSelection(groupData.map);
+ if (groupData.query) {
+ try {
+ total = countSelection(await groupData.get());
+ } catch (e) {
+ if (disposed || gen !== renderGen) return;
+ tree.append(el("p", "addon-hint", `${name}: ${errorText(e)}`));
+ continue;
+ }
+ }
+ if (disposed || gen !== renderGen) return;
+ tree.append(el("p", "addon-pset-name", `${name} (${total})`));
+ }
+ }
+ };
+
+ const requestRender = () => {
+ render().catch((e) => {
+ if (!disposed) panelHelpers.setStatus(errorText(e), true);
+ });
+ };
+ updateRequested.add(requestRender);
+ requestRender();
+ return {
+ update: requestRender,
+ dispose: () => {
+ disposed = true;
+ updateRequested.remove(requestRender);
+ },
+ };
+ },
+ };
+}
+
+function createBoundingBoxerAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let boxer: OBC.BoundingBoxer | null = null;
+
+ const getModelsBox = () => {
+ if (!boxer) return null;
+ boxer.list.clear();
+ boxer.addFromModels();
+ const box = boxer.get();
+ boxer.list.clear();
+ return box;
+ };
+
+ return {
+ id: "bounding-boxer",
+ name: "BoundingBoxer",
+ create: () => {
+ boxer = ctx.components.get(OBC.BoundingBoxer);
+ return {
+ component: boxer,
+ enable: async () => {},
+ disable: async () => {
+ boxer.list.clear();
+ },
+ onModelRemoved: async () => updateRequested.trigger(),
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const hint = el(
+ "p",
+ "addon-hint",
+ "Measure the bounding box of the loaded models or jump the camera to a side",
+ );
+ const rows = el("div", "addon-rows");
+ const orientations = el("div", "addon-types");
+ root.append(hint, rows, orientations);
+
+ const addRow = (key: string, value: string) => {
+ const row = el("div", "addon-row");
+ row.append(
+ el("span", "addon-key", key),
+ el("span", "addon-value", value),
+ );
+ rows.append(row);
+ };
+
+ const boxerRef = boxer;
+ for (const orientation of [
+ "front",
+ "back",
+ "left",
+ "right",
+ "top",
+ "bottom",
+ ] as const) {
+ const button = el("button", "addon-type", orientation);
+ button.type = "button";
+ button.addEventListener("click", async () => {
+ if (!boxerRef) return;
+ button.classList.add("disabled");
+ try {
+ const { position, target } =
+ await boxerRef.getCameraOrientation(orientation);
+ await ctx.world.camera.controls.setLookAt(
+ position.x,
+ position.y,
+ position.z,
+ target.x,
+ target.y,
+ target.z,
+ true,
+ );
+ ctx.fragments.core.update();
+ panelHelpers.setStatus(`Camera moved to the ${orientation} side`);
+ } catch (e) {
+ panelHelpers.setStatus(errorText(e), true);
+ } finally {
+ button.classList.remove("disabled");
+ }
+ });
+ orientations.append(button);
+ }
+
+ let disposed = false;
+
+ const render = () => {
+ rows.replaceChildren();
+ if (!panelHelpers.hasModels()) {
+ hint.textContent = "Load an IFC to use this tool";
+ return;
+ }
+ hint.textContent =
+ "Measure the bounding box of the loaded models or jump the camera to a side";
+ const box = getModelsBox();
+ if (!box || box.isEmpty()) {
+ rows.append(el("p", "addon-hint", "The models have no bounding box"));
+ return;
+ }
+ const size = box.getSize(new THREE.Vector3());
+ addRow(
+ "Size",
+ `${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)}`,
+ );
+ addRow(
+ "Min",
+ `${box.min.x.toFixed(2)}, ${box.min.y.toFixed(2)}, ${box.min.z.toFixed(2)}`,
+ );
+ addRow(
+ "Max",
+ `${box.max.x.toFixed(2)}, ${box.max.y.toFixed(2)}, ${box.max.z.toFixed(2)}`,
+ );
+ };
+
+ const requestRender = () => {
+ try {
+ render();
+ } catch (e) {
+ if (!disposed) panelHelpers.setStatus(errorText(e), true);
+ }
+ };
+ updateRequested.add(requestRender);
+ requestRender();
+ return {
+ update: requestRender,
+ dispose: () => {
+ disposed = true;
+ updateRequested.remove(requestRender);
+ },
+ };
+ },
+ };
+}
+
+function createMesherAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let mesher: Mesher | null = null;
+ const created: THREE.Mesh[] = [];
+ let maxItems = 100;
+
+ const clearMeshes = () => {
+ for (const mesh of created) mesh.removeFromParent();
+ created.length = 0;
+ mesher?.remove();
+ ctx.fragments.core.update(true);
+ };
+
+ const createFromCategory = async (category: string) => {
+ if (!mesher) return;
+ const modelIdMap: OBC.ModelIdMap = {};
+ let total = 0;
+ for (const [modelId, model] of ctx.fragments.list) {
+ const byCategory = await model.getItemsOfCategories([
+ categoryRegex(category),
+ ]);
+ const localIds = byCategory[category] ?? [];
+ const room = Math.max(0, maxItems - total);
+ const capped = localIds.slice(0, room);
+ if (capped.length) modelIdMap[modelId] = new Set(capped);
+ total += capped.length;
+ if (total >= maxItems) break;
+ }
+ if (!total) {
+ ctx.setStatus(`No elements found in "${category}"`, true);
+ return;
+ }
+ const material = new THREE.MeshLambertMaterial({ color: "#8ab4ff" });
+ const result = await mesher.get(modelIdMap, { material });
+ for (const modelMeshes of result.values()) {
+ for (const meshes of modelMeshes.values()) {
+ for (const mesh of meshes) {
+ ctx.world.scene.three.add(mesh);
+ created.push(mesh);
+ }
+ }
+ }
+ ctx.fragments.core.update(true);
+ updateRequested.trigger();
+ ctx.setStatus(`Created ${created.length} meshes from "${category}"`);
+ };
+
+ return {
+ id: "mesher",
+ name: "Mesher",
+ create: () => {
+ mesher = ctx.components.get(Mesher);
+ return {
+ component: mesher,
+ enable: async () => {},
+ disable: async () => clearMeshes(),
+ onModelRemoved: async () => clearMeshes(),
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const controls = el("div", "addon-row");
+ controls.append(el("span", "addon-key", "Category"));
+ const select = document.createElement("select");
+ select.className = "addon-select";
+ controls.append(select);
+
+ const limitRow = el("div", "addon-row");
+ limitRow.append(el("span", "addon-key", "Max items"));
+ const limitInput = document.createElement("input");
+ limitInput.type = "number";
+ limitInput.className = "addon-number";
+ limitInput.min = "1";
+ limitInput.max = "5000";
+ limitInput.value = String(maxItems);
+ limitInput.addEventListener("change", () => {
+ const value = Number(limitInput.value);
+ if (Number.isFinite(value) && value > 0) maxItems = Math.floor(value);
+ limitInput.value = String(maxItems);
+ });
+ limitRow.append(limitInput);
+
+ const actions = el("div", "addon-actions");
+ const createBtn = el("button", "model-btn", "Create meshes");
+ createBtn.type = "button";
+ createBtn.addEventListener("click", () => {
+ const category = select.value;
+ if (!category) return;
+ createBtn.classList.add("disabled");
+ createFromCategory(category)
+ .catch((e) => panelHelpers.setStatus(errorText(e), true))
+ .finally(() => createBtn.classList.remove("disabled"));
+ });
+ const removeBtn = el("button", "model-btn", "Remove meshes");
+ removeBtn.type = "button";
+ removeBtn.addEventListener("click", () => {
+ clearMeshes();
+ updateRequested.trigger();
+ });
+ actions.append(createBtn, removeBtn);
+
+ const hint = el("p", "addon-hint");
+ root.append(controls, limitRow, actions, hint);
+
+ let disposed = false;
+
+ const render = async () => {
+ if (!panelHelpers.hasModels()) {
+ hint.textContent = "Load an IFC to use this tool";
+ select.replaceChildren();
+ return;
+ }
+ const categories = await panelHelpers.categories();
+ if (disposed) return;
+ const current = select.value;
+ select.replaceChildren();
+ for (const category of categories) {
+ const option = document.createElement("option");
+ option.value = category.name;
+ option.textContent = `${category.name} (${category.count})`;
+ select.append(option);
+ }
+ if (current && categories.some((item) => item.name === current)) {
+ select.value = current;
+ }
+ hint.textContent = created.length
+ ? `${created.length} meshes in the scene`
+ : "Build standalone meshes for the elements of a category";
+ };
+
+ const requestRender = () => {
+ render().catch((e) => {
+ if (!disposed) panelHelpers.setStatus(errorText(e), true);
+ });
+ };
+ updateRequested.add(requestRender);
+ requestRender();
+ return {
+ update: requestRender,
+ dispose: () => {
+ disposed = true;
+ updateRequested.remove(requestRender);
+ },
+ };
+ },
+ };
+}
+
+function createViewsAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let views: OBC.Views | null = null;
+
+ return {
+ id: "views",
+ name: "Views",
+ create: () => {
+ views = ctx.components.get(OBC.Views);
+ views.world = ctx.world;
+ views.list.onItemSet.add(() => updateRequested.trigger());
+ views.list.onItemDeleted.add(() => updateRequested.trigger());
+ views.list.onItemUpdated.add(() => updateRequested.trigger());
+ return {
+ component: views,
+ enable: async () => {
+ views.world = ctx.world;
+ },
+ disable: async () => {
+ views.close();
+ },
+ onModelRemoved: async () => updateRequested.trigger(),
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const hint = el(
+ "p",
+ "addon-hint",
+ "Create plan views from the storeys or elevation views from the bounding boxes",
+ );
+ const actions = el("div", "addon-actions");
+ const storeysBtn = el("button", "model-btn", "Storey views");
+ storeysBtn.type = "button";
+ storeysBtn.addEventListener("click", () => {
+ if (!views) return;
+ storeysBtn.classList.add("disabled");
+ Promise.resolve(views.createFromIfcStoreys())
+ .then((created) => {
+ panelHelpers.setStatus(`${created.length} storey views created`);
+ updateRequested.trigger();
+ })
+ .catch((e) => panelHelpers.setStatus(errorText(e), true))
+ .finally(() => storeysBtn.classList.remove("disabled"));
+ });
+ const elevationsBtn = el("button", "model-btn", "Elevations");
+ elevationsBtn.type = "button";
+ elevationsBtn.addEventListener("click", () => {
+ if (!views) return;
+ try {
+ const created = views.createElevations({ combine: true });
+ panelHelpers.setStatus(`${created.length} elevation views created`);
+ updateRequested.trigger();
+ } catch (e) {
+ panelHelpers.setStatus(errorText(e), true);
+ }
+ });
+ const clearBtn = el("button", "model-btn", "Clear");
+ clearBtn.type = "button";
+ clearBtn.addEventListener("click", () => {
+ if (!views) return;
+ for (const id of [...views.list.keys()]) views.list.delete(id);
+ updateRequested.trigger();
+ });
+ actions.append(storeysBtn, elevationsBtn, clearBtn);
+
+ const listRoot = el("div", "addon-rows");
+ root.append(hint, actions, listRoot);
+
+ const render = () => {
+ listRoot.replaceChildren();
+ if (!panelHelpers.hasModels()) {
+ listRoot.append(
+ el("p", "addon-hint", "Load an IFC to use this tool"),
+ );
+ return;
+ }
+ if (!views || !views.list.size) {
+ listRoot.append(el("p", "addon-hint", "No views yet"));
+ return;
+ }
+ const viewsRef = views;
+ for (const [id, view] of viewsRef.list) {
+ const row = el("div", "addon-row");
+ row.append(el("span", "addon-value", id));
+ const openBtn = el(
+ "button",
+ "model-btn",
+ view.open ? "Close" : "Open",
+ );
+ openBtn.type = "button";
+ openBtn.addEventListener("click", () => {
+ try {
+ if (view.open) viewsRef.close(id);
+ else viewsRef.open(id);
+ updateRequested.trigger();
+ } catch (e) {
+ panelHelpers.setStatus(errorText(e), true);
+ }
+ });
+ const remove = el("button", "model-btn", "×");
+ remove.type = "button";
+ remove.title = "Delete view";
+ remove.addEventListener("click", () => {
+ viewsRef.list.delete(id);
+ updateRequested.trigger();
+ });
+ row.append(openBtn, remove);
+ listRoot.append(row);
+ }
+ };
+
+ updateRequested.add(render);
+ render();
+ return {
+ update: render,
+ dispose: () => updateRequested.remove(render),
+ };
+ },
+ };
+}
+
+function createViewpointsAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let viewpoints: OBC.Viewpoints | null = null;
+
+ return {
+ id: "viewpoints",
+ name: "Viewpoints",
+ create: () => {
+ viewpoints = ctx.components.get(OBC.Viewpoints);
+ viewpoints.world = ctx.world;
+ viewpoints.list.onItemSet.add(() => updateRequested.trigger());
+ viewpoints.list.onItemDeleted.add(() => updateRequested.trigger());
+ viewpoints.list.onItemUpdated.add(() => updateRequested.trigger());
+ return {
+ component: viewpoints,
+ enable: async () => {
+ viewpoints.world = ctx.world;
+ },
+ disable: async () => {},
+ onModelRemoved: async () => updateRequested.trigger(),
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const hint = el(
+ "p",
+ "addon-hint",
+ "Save the current camera as a viewpoint and jump back to it later",
+ );
+ const actions = el("div", "addon-actions");
+ const saveBtn = el("button", "model-btn", "Save viewpoint");
+ saveBtn.type = "button";
+ saveBtn.addEventListener("click", () => {
+ if (!viewpoints) return;
+ saveBtn.classList.add("disabled");
+ const viewpoint = viewpoints.create();
+ viewpoint.title = `Viewpoint ${viewpoints.list.size}`;
+ viewpoint
+ .updateCamera(false)
+ .then(() => {
+ panelHelpers.setStatus(`"${viewpoint.title}" saved`);
+ updateRequested.trigger();
+ })
+ .catch((e) => panelHelpers.setStatus(errorText(e), true))
+ .finally(() => saveBtn.classList.remove("disabled"));
+ });
+ const clearBtn = el("button", "model-btn", "Clear");
+ clearBtn.type = "button";
+ clearBtn.addEventListener("click", () => {
+ if (!viewpoints) return;
+ for (const guid of [...viewpoints.list.keys()]) {
+ viewpoints.list.delete(guid);
+ }
+ viewpoints.snapshots.clear();
+ updateRequested.trigger();
+ });
+ actions.append(saveBtn, clearBtn);
+
+ const listRoot = el("div", "addon-rows");
+ root.append(hint, actions, listRoot);
+
+ const render = () => {
+ listRoot.replaceChildren();
+ if (!panelHelpers.hasModels()) {
+ listRoot.append(
+ el("p", "addon-hint", "Load an IFC to use this tool"),
+ );
+ return;
+ }
+ if (!viewpoints || !viewpoints.list.size) {
+ listRoot.append(el("p", "addon-hint", "No viewpoints yet"));
+ return;
+ }
+ const viewpointsRef = viewpoints;
+ for (const [guid, viewpoint] of viewpointsRef.list) {
+ const row = el("div", "addon-row");
+ row.append(
+ el("span", "addon-value", viewpoint.title ?? guid.slice(0, 8)),
+ );
+ const goBtn = el("button", "model-btn", "Go");
+ goBtn.type = "button";
+ goBtn.addEventListener("click", () => {
+ goBtn.classList.add("disabled");
+ viewpoint
+ .go()
+ .then(() => panelHelpers.setStatus("Viewpoint applied"))
+ .catch((e) => panelHelpers.setStatus(errorText(e), true))
+ .finally(() => goBtn.classList.remove("disabled"));
+ });
+ const remove = el("button", "model-btn", "×");
+ remove.type = "button";
+ remove.title = "Delete viewpoint";
+ remove.addEventListener("click", () => {
+ viewpointsRef.list.delete(guid);
+ updateRequested.trigger();
+ });
+ row.append(goBtn, remove);
+ listRoot.append(row);
+ }
+ };
+
+ updateRequested.add(render);
+ render();
+ return {
+ update: render,
+ dispose: () => updateRequested.remove(render),
+ };
+ },
+ };
+}
+
+function createMarkerAddOn(ctx: AddOnsContext): AddOnDef {
+ const updateRequested = new OBC.Event();
+ let marker: Marker | null = null;
+ let threshold = 50;
+
+ const worldMarkers = () => marker?.list.get(ctx.world.uuid)?.size ?? 0;
+
+ const clearMarkers = () => {
+ marker?.dispose();
+ ctx.fragments.core.update(true);
+ };
+
+ const markModels = () => {
+ if (!marker) return;
+ for (const [modelId, model] of ctx.fragments.list) {
+ const element = document.createElement("div");
+ element.textContent = modelId;
+ element.style.cssText =
+ "background:#27405f;color:#cfe1ff;border:1px solid #3f6fae;" +
+ "border-radius:6px;padding:2px 6px;font-size:11px;white-space:nowrap;" +
+ "pointer-events:none;";
+ marker.create(
+ ctx.world,
+ element,
+ model.box.getCenter(new THREE.Vector3()),
+ );
+ }
+ marker.cluster(ctx.world);
+ ctx.fragments.core.update(true);
+ };
+
+ return {
+ id: "marker",
+ name: "Marker",
+ create: () => {
+ marker = ctx.components.get(Marker);
+ marker.threshold = threshold;
+ return {
+ component: marker,
+ enable: async () => {
+ marker.threshold = threshold;
+ },
+ disable: async () => clearMarkers(),
+ onModelRemoved: async () => {
+ clearMarkers();
+ updateRequested.trigger();
+ },
+ };
+ },
+ panel: (root, panelHelpers) => {
+ const thresholdRow = el("div", "addon-row");
+ thresholdRow.append(el("span", "addon-key", "Threshold"));
+ const thresholdInput = document.createElement("input");
+ thresholdInput.type = "number";
+ thresholdInput.className = "addon-number";
+ thresholdInput.min = "5";
+ thresholdInput.max = "500";
+ thresholdInput.value = String(threshold);
+ thresholdInput.addEventListener("change", () => {
+ const value = Number(thresholdInput.value);
+ if (Number.isFinite(value) && value > 0) threshold = value;
+ thresholdInput.value = String(threshold);
+ if (marker) marker.threshold = threshold;
+ });
+ thresholdRow.append(thresholdInput);
+
+ const actions = el("div", "addon-actions");
+ const markBtn = el("button", "model-btn", "Mark models");
+ markBtn.type = "button";
+ markBtn.addEventListener("click", () => {
+ try {
+ markModels();
+ updateRequested.trigger();
+ panelHelpers.setStatus(`${worldMarkers()} markers in the scene`);
+ } catch (e) {
+ panelHelpers.setStatus(errorText(e), true);
+ }
+ });
+ const clearBtn = el("button", "model-btn", "Clear");
+ clearBtn.type = "button";
+ clearBtn.addEventListener("click", () => {
+ clearMarkers();
+ updateRequested.trigger();
+ });
+ actions.append(markBtn, clearBtn);
+
+ const hint = el("p", "addon-hint");
+ root.append(thresholdRow, actions, hint);
+
+ return {
+ update: () => {
+ const count = worldMarkers();
+ if (!panelHelpers.hasModels()) {
+ hint.textContent = "Load an IFC to use this tool";
+ } else if (count) {
+ hint.textContent = `${count} markers (labels merge below the threshold)`;
+ } else {
+ hint.textContent =
+ "Press Mark models to drop a label on every model";
+ }
+ },
+ dispose: () => {},
+ };
+ },
+ };
+}
diff --git a/projects/viewer.html b/projects/viewer.html
new file mode 100644
index 000000000..7d0a30860
--- /dev/null
+++ b/projects/viewer.html
@@ -0,0 +1,542 @@
+
+
+
+
+
+ IFC Viewer
+
+
+
+
+
+
+
+
+
+ Drop IFC files here
+
+
+
diff --git a/projects/viewer.ts b/projects/viewer.ts
new file mode 100644
index 000000000..d224a9a97
--- /dev/null
+++ b/projects/viewer.ts
@@ -0,0 +1,419 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import * as THREE from "three";
+import * as OBC from "../packages/core/src/index";
+import { RendererWith2D } from "../packages/front/src/core/Marker";
+import { createAddOns } from "./addons";
+
+const container = document.getElementById("container")!;
+const titleEl = document.getElementById("project-title")!;
+const statusEl = document.getElementById("status")!;
+const dropEl = document.getElementById("drop")!;
+const fitBtn = document.getElementById("fit") as HTMLButtonElement;
+const addInput = document.getElementById("ifc-add") as HTMLInputElement;
+const lblAdd = document.getElementById("lbl-add")!;
+const modelsList = document.getElementById("models-list")!;
+const modelsCount = document.getElementById("models-count")!;
+const alignSelect = document.getElementById("align") as HTMLSelectElement;
+
+const PROCESS_NAMES: Record = {
+ geometries: "geometry",
+ attributes: "attributes",
+ relations: "relations",
+ conversion: "conversion",
+};
+
+const setStatus = (text: string, isError = false) => {
+ statusEl.textContent = text;
+ statusEl.classList.toggle("visible", text.length > 0);
+ statusEl.classList.toggle("error", isError);
+};
+
+let busy = false;
+
+const setBusy = (value: boolean) => {
+ busy = value;
+ lblAdd.classList.toggle("disabled", value);
+};
+
+const components = new OBC.Components();
+const worlds = components.get(OBC.Worlds);
+// RendererWith2D is SimpleRenderer + a CSS2D overlay: the Marker add-on
+// (ТЗ-002, R-011) needs it. PostproductionRenderer stays out of scope.
+const world = worlds.create<
+ OBC.SimpleScene,
+ OBC.OrthoPerspectiveCamera,
+ RendererWith2D
+>();
+
+world.scene = new OBC.SimpleScene(components);
+world.scene.setup();
+world.scene.three.background = null;
+
+world.renderer = new RendererWith2D(components, container);
+world.camera = new OBC.OrthoPerspectiveCamera(components);
+await world.camera.controls.setLookAt(30, 30, 30, 0, 0, 0);
+
+components.init();
+
+components.get(OBC.Grids).create(world);
+
+const ifcLoader = components.get(OBC.IfcLoader);
+await ifcLoader.setup({
+ autoSetWasm: false,
+ wasm: {
+ path: "https://unpkg.com/web-ifc@0.0.77/",
+ absolute: true,
+ },
+});
+
+const workerUrl = await OBC.FragmentsManager.getWorker();
+const fragments = components.get(OBC.FragmentsManager);
+fragments.init(workerUrl);
+
+// @thatopen/fragments 3.4.7: update(true) waits on a worker "FINISH" fence
+// (forceUpdateFinish) that never settles once the last model has been disposed,
+// because the worker has nothing left to report. The promise never resolves and
+// the whole app freezes with busy=true. A forced update on an empty scene has
+// no meshes to refresh, so it is safe to skip.
+const rawCoreUpdate = fragments.core.update.bind(fragments.core);
+fragments.core.update = (force?: boolean) =>
+ force && !fragments.list.size ? Promise.resolve() : rawCoreUpdate(force);
+
+world.camera.controls.addEventListener("update", () => fragments.core.update());
+
+fragments.list.onItemSet.add(({ value: model }) => {
+ model.useCamera(world.camera.three);
+ world.scene.three.add(model.object);
+ fragments.core.update(true);
+});
+
+fragments.core.models.materials.list.onItemSet.add(({ value: material }) => {
+ if (!("isLodMaterial" in material && material.isLodMaterial)) {
+ material.polygonOffset = true;
+ material.polygonOffsetUnits = 1;
+ material.polygonOffsetFactor = Math.random();
+ }
+});
+
+const addons = createAddOns(
+ { components, world, fragments, setStatus },
+ {
+ listEl: document.getElementById("addons-list")!,
+ panelEl: document.getElementById("addon-panel")!,
+ },
+);
+
+(window as any).__bim = {
+ components,
+ world,
+ fragments,
+ ifcLoader,
+ get highlighter() {
+ return addons.getComponent("highlighter");
+ },
+ addons,
+};
+
+await addons.toggle("highlighter", true, true);
+
+const modelWord = (count: number) => (count === 1 ? "model" : "models");
+
+let projectTitle: string | null = null;
+
+const updateTitle = () => {
+ let text = "IFC Viewer";
+ if (projectTitle) {
+ text = projectTitle;
+ } else {
+ const ids = [...fragments.list.keys()];
+ if (ids.length === 1) [text] = ids;
+ else if (ids.length > 1) text = `${ids.length} ${modelWord(ids.length)}`;
+ }
+ titleEl.textContent = text;
+ document.title = `${text} — IFC Viewer`;
+};
+
+const uniqueModelId = (base: string) => {
+ let id = base.trim() || "model";
+ let n = 1;
+ while (fragments.list.has(id)) {
+ n += 1;
+ id = `${base}-${n}`;
+ }
+ return id;
+};
+
+const fitToModels = async () => {
+ const box = new THREE.Box3();
+ for (const model of fragments.list.values()) {
+ if (!model.object.visible) continue;
+ box.union(model.box);
+ }
+ if (box.isEmpty()) return;
+ const sphere = box.getBoundingSphere(new THREE.Sphere());
+ await world.camera.controls.fitToSphere(sphere, true);
+};
+
+const applyAlignment = async () => {
+ const models = [...fragments.list.values()];
+ let base: number[] | null = null;
+ if (alignSelect.value === "common" && models.length) {
+ base = (await models[0].getCoordinates()).slice(0, 3);
+ }
+ for (const model of models) {
+ const coords = await model.getCoordinates();
+ if (base) {
+ model.object.position.set(
+ base[0] - coords[0],
+ base[1] - coords[1],
+ base[2] - coords[2],
+ );
+ } else {
+ model.object.position.set(0, 0, 0);
+ }
+ }
+ fragments.core.update(true);
+};
+
+let refreshModels: () => Promise = async () => {};
+
+const removeModel = async (modelId: string) => {
+ if (busy) {
+ setStatus("Wait for the current models to finish loading", true);
+ return;
+ }
+ const model = fragments.list.get(modelId);
+ if (!model) return;
+ setBusy(true);
+ try {
+ await fragments.core.disposeModel(modelId);
+ world.scene.three.remove(model.object);
+ await addons.onModelRemoved(modelId);
+ fragments.core.update(true);
+ await refreshModels();
+ await fitToModels();
+ setStatus(`Model "${modelId}" unloaded`);
+ } finally {
+ setBusy(false);
+ }
+};
+
+const renderModelList = () => {
+ const models = [...fragments.list.values()];
+ modelsCount.textContent = String(models.length);
+ modelsList.replaceChildren();
+ if (!models.length) {
+ const empty = document.createElement("li");
+ empty.className = "empty";
+ empty.textContent = 'No models yet — click "Add IFC…"';
+ modelsList.append(empty);
+ updateTitle();
+ return;
+ }
+ for (const model of models) {
+ const { modelId } = model;
+ const visible = model.object.visible;
+
+ const item = document.createElement("li");
+
+ const name = document.createElement("span");
+ name.className = "model-name";
+ name.classList.toggle("hidden-model", !visible);
+ name.textContent = modelId;
+ name.title = modelId;
+
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "model-btn";
+ toggle.textContent = visible ? "Hide" : "Show";
+ toggle.addEventListener("click", () => {
+ const current = fragments.list.get(modelId);
+ if (!current) return;
+ current.object.visible = !current.object.visible;
+ fragments.core.update(true);
+ renderModelList();
+ setStatus(
+ `Model "${modelId}" ${current.object.visible ? "shown" : "hidden"}`,
+ );
+ });
+
+ const remove = document.createElement("button");
+ remove.type = "button";
+ remove.className = "model-btn danger";
+ remove.textContent = "×";
+ remove.title = "Unload model";
+ remove.addEventListener("click", () => {
+ removeModel(modelId).catch((e) => setStatus(String(e), true));
+ });
+
+ item.append(name, toggle, remove);
+ modelsList.append(item);
+ }
+ updateTitle();
+};
+
+refreshModels = async () => {
+ renderModelList();
+ await applyAlignment();
+};
+
+fitBtn.addEventListener("click", () => {
+ fitToModels().catch((e) => setStatus(String(e), true));
+});
+
+alignSelect.addEventListener("change", () => {
+ applyAlignment()
+ .then(fitToModels)
+ .then(() =>
+ setStatus(
+ alignSelect.value === "common"
+ ? "Models aligned to shared coordinates"
+ : "Each model kept the coordinates of its own file",
+ ),
+ )
+ .catch((e) => setStatus(String(e), true));
+});
+
+type LoadSource = { label: string; read: () => Promise };
+
+const decoder = new TextDecoder("utf-8", { fatal: false });
+
+const assertIfc = (data: Uint8Array, label: string) => {
+ const head = decoder.decode(data.slice(0, 128)).trim();
+ if (!head.startsWith("ISO-10303-21")) {
+ throw new Error(`"${label}" does not look like an IFC file`);
+ }
+};
+
+const fileSources = (files: File[]): LoadSource[] =>
+ files.map((file) => ({
+ label: file.name,
+ read: async () => {
+ const data = new Uint8Array(await file.arrayBuffer());
+ assertIfc(data, file.name);
+ return data;
+ },
+ }));
+
+const urlSource = (url: string): LoadSource => ({
+ label: decodeURIComponent(url.split("?")[0].split("/").pop() ?? url),
+ read: async () => {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Could not download the file (HTTP ${response.status})`);
+ }
+ const type = response.headers.get("content-type") ?? "";
+ if (type.includes("text/html")) {
+ throw new Error(`File "${url}" not found (server returned HTML)`);
+ }
+ const data = new Uint8Array(await response.arrayBuffer());
+ assertIfc(data, url);
+ return data;
+ },
+});
+
+const ifcFiles = (list: FileList | null) =>
+ [...(list ?? [])].filter((file) => file.name.toLowerCase().endsWith(".ifc"));
+
+const importModels = async (sources: LoadSource[]) => {
+ if (busy) {
+ setStatus("Wait for the current models to finish loading", true);
+ return;
+ }
+ if (!sources.length) return;
+ setBusy(true);
+ const started = performance.now();
+ try {
+ let ok = 0;
+ let lastError = "";
+ for (let i = 0; i < sources.length; i++) {
+ const source = sources[i];
+ const prefix = sources.length > 1 ? `[${i + 1}/${sources.length}] ` : "";
+ try {
+ const data = await source.read();
+ const modelId = uniqueModelId(source.label.replace(/\.ifc$/i, ""));
+ setStatus(`${prefix}Converting "${source.label}"…`);
+ await ifcLoader.load(data, false, modelId, {
+ processData: {
+ progressCallback: (progress, info) => {
+ const pct =
+ progress <= 1
+ ? Math.round(progress * 100)
+ : Math.round(progress);
+ const stage = PROCESS_NAMES[info.process] ?? info.process;
+ setStatus(`${prefix}${stage}: ${pct}%`);
+ },
+ },
+ });
+ fragments.core.update(true);
+ ok += 1;
+ } catch (e) {
+ lastError = e instanceof Error ? e.message : String(e);
+ setStatus(
+ `${prefix}Error loading "${source.label}": ${lastError}`,
+ true,
+ );
+ }
+ await refreshModels();
+ }
+ await fitToModels();
+ const seconds = ((performance.now() - started) / 1000).toFixed(1);
+ if (ok === 0) {
+ setStatus(lastError || "Could not load the models", true);
+ } else if (ok === sources.length) {
+ setStatus(`Added ${ok} ${modelWord(ok)} in ${seconds}s`);
+ } else {
+ setStatus(`Loaded ${ok} of ${sources.length}: ${lastError}`, true);
+ }
+ } catch (e) {
+ setStatus(e instanceof Error ? e.message : String(e), true);
+ } finally {
+ setBusy(false);
+ }
+};
+
+addInput.addEventListener("change", () => {
+ const files = ifcFiles(addInput.files);
+ addInput.value = "";
+ if (!files.length) {
+ setStatus("An .ifc file is required", true);
+ return;
+ }
+ importModels(fileSources(files));
+});
+
+let dragDepth = 0;
+window.addEventListener("dragenter", (e) => {
+ e.preventDefault();
+ dragDepth++;
+ dropEl.classList.add("visible");
+});
+window.addEventListener("dragleave", (e) => {
+ e.preventDefault();
+ dragDepth = Math.max(0, dragDepth - 1);
+ if (!dragDepth) dropEl.classList.remove("visible");
+});
+window.addEventListener("dragover", (e) => e.preventDefault());
+window.addEventListener("drop", (e) => {
+ e.preventDefault();
+ dragDepth = 0;
+ dropEl.classList.remove("visible");
+ const files = ifcFiles(e.dataTransfer?.files ?? null);
+ if (!files.length) {
+ setStatus("An .ifc file is required", true);
+ return;
+ }
+ importModels(fileSources(files));
+});
+
+const params = new URLSearchParams(window.location.search);
+projectTitle = params.get("title");
+const modelUrls = params.getAll("model");
+
+await refreshModels();
+
+if (modelUrls.length) {
+ importModels(modelUrls.map((url) => urlSource(url)));
+} else {
+ setStatus('No models loaded — click "Add IFC…" or drop files on this page');
+}
diff --git a/vite.config.ts b/vite.config.ts
index 68adaa7d0..0e6aa0c38 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,15 +1,41 @@
/* eslint-disable import/no-extraneous-dependencies */
import * as fs from "fs";
import * as path from "path";
-import { defineConfig } from "vite";
+import { defineConfig, type ViteDevServer } from "vite";
import { globSync } from "glob";
+const writeProjectsHTML = () => {
+ const projectsDir = path.join(process.cwd(), "projects");
+ if (!fs.existsSync(projectsDir)) return "";
+ // Empty session: the viewer with no ?model= params — load IFC files from
+ // the UI (Add IFC…) instead of preloading them on disk.
+ let links = `New empty session (multimodel)\n`;
+ const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const projectDir = path.join(projectsDir, entry.name);
+ const ifc = fs
+ .readdirSync(projectDir)
+ .find((file) => file.toLowerCase().endsWith(".ifc"));
+ if (!ifc) continue;
+ let title = entry.name;
+ const titleFile = path.join(projectDir, "project.title");
+ if (fs.existsSync(titleFile)) {
+ const custom = fs.readFileSync(titleFile, "utf8").split("\n")[0].trim();
+ if (custom) title = custom;
+ }
+ const model = encodeURIComponent(`${entry.name}/${ifc}`);
+ links += `${title}\n`;
+ }
+ return `Projects
\n${links}`;
+};
+
const writeIndexHTML = () => {
let links: string = "";
const examplePaths = globSync("packages/**/src/**/example.html");
for (const examplePath of examplePaths) {
const directory = path.dirname(examplePath);
- const packageNameMatch = directory.match(/packages[\\\/]([^\\\/]+)/);
+ const packageNameMatch = directory.match(/packages[\\/]([^\\/]+)/);
if (!(packageNameMatch && packageNameMatch.length > 1)) continue;
const packageName = packageNameMatch[1];
const exampleName = path.basename(directory);
@@ -40,7 +66,7 @@ const writeIndexHTML = () => {
-
Choose an example
+ ${writeProjectsHTML()}Choose an example
${links}
@@ -52,9 +78,20 @@ const writeIndexHTML = () => {
const createIndex = () => ({
name: "create-index",
- configureServer() {
+ configureServer(server: ViteDevServer) {
// fs.watch("./packages", { recursive: true }, writeIndexHTML);
writeIndexHTML();
+ // Projects are added and removed by external tooling — rebuild the home
+ // page so new IFCs show up in the list without restarting the server.
+ const projectsDir = path.join(process.cwd(), "projects");
+ server.watcher.add(projectsDir);
+ const onProjectsChange = (file: string) => {
+ if (!file.startsWith(projectsDir)) return;
+ writeIndexHTML();
+ };
+ server.watcher.on("add", onProjectsChange);
+ server.watcher.on("change", onProjectsChange);
+ server.watcher.on("unlink", onProjectsChange);
},
});
@@ -65,5 +102,15 @@ export default defineConfig({
// server serves two or three instances and three.js prints the
// "Multiple instances of Three.js being imported" warning.
dedupe: ["three"],
+ // `packages/front` imports `@thatopen/components`; pnpm would resolve
+ // the registry copy (3.4.8) while our viewer runs the local core fork
+ // (FastModelPicker etc.) — two component registries in one page.
+ // Force every import (front included) to the local core sources.
+ alias: {
+ "@thatopen/components": path.resolve(
+ process.cwd(),
+ "packages/core/src/index",
+ ),
+ },
},
});