Skip to main content

AngleMeasurement

Source

Copying and pasting? We've got you covered! You can find the full source code of this tutorial here.

📐 Measuring Angles​


Checking that a ramp meets accessibility requirements, verifying a roof pitch, or confirming a structural connection angle all require measuring angles between arbitrary points in a 3D model — and doing that by hand from a screenshot is inaccurate and slow. The angle measurement tool lets users place three points in the scene to define an angle and displays the result persistently as part of the 3D view. This tutorial covers configuring the tool with a color and snap mode; placing angle measurements with three double-clicks (start, vertex, end); deleting individual measurements by hovering and pressing Delete; reading all angle values from the measurements list; and clearing all measurements at once. By the end, you'll have a fully working angle measurement tool with persistent results, keyboard deletion, and configurable picking performance.

🖖 Importing our Libraries​

First things first, let's install all necessary dependencies to make this example work:

import * as THREE from "three";
import Stats from "stats.js";
import * as OBC from "@thatopen/components";
import * as BUI from "@thatopen/ui";
import * as FRAGS from "@thatopen/fragments";
// Local worker bundle so the GPU picker decodes localId from tile.ids
// correctly. Swap to `OBC.FragmentsManager.getWorker()` for the
// unpkg-hosted bundle once published.
import workerUrl from "@thatopen/fragments/worker?url";
// You have to import * as OBF from "@thatopen/components-front"
import * as OBF from "../..";

🌎 Setting up a Simple Scene​

To get started, let's set up a basic ThreeJS scene. This will serve as the foundation for our application and allow us to visualize the 3D models effectively:

const components = new OBC.Components();

const worlds = components.get(OBC.Worlds);
const world = worlds.create<
OBC.SimpleScene,
OBC.OrthoPerspectiveCamera,
OBF.PostproductionRenderer
>();

world.scene = new OBC.SimpleScene(components);
world.scene.setup();
world.scene.three.background = null;

const container = document.getElementById("container")!;
world.renderer = new OBF.PostproductionRenderer(components, container);
world.camera = new OBC.OrthoPerspectiveCamera(components);
await world.camera.controls.setLookAt(68, 23, -8.5, 21.5, -5.5, 23);

components.init();

🛠️ Setting Up Fragments​

Now, let's configure the FragmentsManager. This will allow us to load models effortlessly and start manipulating them with ease:

// Local worker imported above. To fall back to unpkg, replace the
// import with `const workerUrl = await OBC.FragmentsManager.getWorker();`.
const fragments = components.get(OBC.FragmentsManager);
fragments.init(workerUrl);

world.camera.controls.addEventListener("update", () => fragments.core.update());

world.onCameraChanged.add((camera) => {
for (const [, model] of fragments.list) {
model.useCamera(camera.three);
}
fragments.core.update(true);
});

fragments.list.onItemSet.add(({ value: model }) => {
model.useCamera(world.camera.three);
world.scene.three.add(model.object);
fragments.core.update(true);
});

// Remove z fighting
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();
}
});

📂 Loading Fragments Models​

With the core setup complete, it's time to load a Fragments model into our scene. Fragments are optimized for fast loading and rendering, making them ideal for large-scale 3D models.

Where can I find Fragment files?

You can use the sample Fragment files available in our repository for testing. If you have an IFC model you'd like to convert to Fragments, check out the IfcImporter tutorial for detailed instructions.

const fragPaths = ["https://thatopen.github.io/engine_components/resources/frags/school_arq.frag"];
await Promise.all(
fragPaths.map(async (path) => {
const modelId = path.split("/").pop()?.split(".").shift();
if (!modelId) return null;
const file = await fetch(path);
const buffer = await file.arrayBuffer();
return fragments.core.load(buffer, { modelId });
}),
);

✨ Using The Angle Measurement Component​

Measuring angles with That Open Engine is straightforward. First, retrieve the corresponding component and configure it. The angle measurement requires 3 clicks: the first point, the vertex (center of the angle), and the end point:

const measurer = components.get(OBF.AngleMeasurement);
// Provide a world to create dimensions inside
measurer.world = world;
measurer.color = new THREE.Color("#494cb6");
// As a best practice, always set the enabled state after the initial config
measurer.enabled = true;
measurer.snappings = [FRAGS.SnappingClass.POINT];

You can create angle measurements by user interaction. Let's configure an event listener to create them when the user double clicks on the viewer container. Each angle measurement needs 3 double-clicks: start point, vertex, and end point:

container.ondblclick = () => measurer.create();

📄 The Measurements List​

Whenever you create a measurement using the component, it is automatically added to a list that keeps track of all measurements. This centralized list allows you to perform various operations, such as deleting measurements, reporting all angles, and more:

const deleteMeasurements = () => {
measurer.list.clear();
};

const getAllValues = () => {
const angles: number[] = [];
for (const angle of measurer.list) {
angles.push(angle.value);
}
return angles;
};

We'll also allow deleting individual measurements by pressing the Delete or Backspace key when hovering over them:

window.onkeydown = (event) => {
if (event.code === "Delete" || event.code === "Backspace") {
measurer.delete();
}
};

We will use the @thatopen/ui library to add some simple and cool UI elements to our app. First, we need to call the init method of the BUI.Manager class to initialize the library:

BUI.Manager.init();

// Reusable snap-class + range controls. The measurer's `snappings`
// array holds the snap classes the picker considers; toggling a
// checkbox adds / removes the corresponding class. The range slider
// drives the shared `SnapResolver.maxDistance` — distance from the
// pick point at which a vertex / edge / face is considered a snap
// candidate, in model units (typically meters).
const snapResolver = components.get(OBC.SnapResolvers).get();
const hasSnap = (cls: FRAGS.SnappingClass) =>
measurer.snappings?.includes(cls) ?? false;
const toggleSnap = (cls: FRAGS.SnappingClass, on: boolean) => {
const current = new Set(measurer.snappings ?? []);
if (on) current.add(cls);
else current.delete(cls);
measurer.snappings = Array.from(current);
};
const snapControls = () => BUI.html`
<bim-checkbox label="Snap: Point" ?checked=${hasSnap(FRAGS.SnappingClass.POINT)}
@change="${({ target }: { target: BUI.Checkbox }) =>
toggleSnap(FRAGS.SnappingClass.POINT, target.value)}">
</bim-checkbox>
<bim-checkbox label="Snap: Line" ?checked=${hasSnap(FRAGS.SnappingClass.LINE)}
@change="${({ target }: { target: BUI.Checkbox }) =>
toggleSnap(FRAGS.SnappingClass.LINE, target.value)}">
</bim-checkbox>
<bim-checkbox label="Snap: Face" ?checked=${hasSnap(FRAGS.SnappingClass.FACE)}
@change="${({ target }: { target: BUI.Checkbox }) =>
toggleSnap(FRAGS.SnappingClass.FACE, target.value)}">
</bim-checkbox>
<bim-number-input slider step="0.05" label="Snap Range"
value="${snapResolver.maxDistance}" min="0.05" max="5"
@change="${({ target }: { target: BUI.NumberInput }) => {
snapResolver.maxDistance = target.value;
}}">
</bim-number-input>
`;

Now we will add some UI to play around with the actions in this tutorial. For more information about the UI library, you can check the specific documentation for it!

const panel = BUI.Component.create<BUI.PanelSection>(() => {
const onLogValues = () => {
const data = getAllValues();
console.log(data);
};

return BUI.html`
<bim-panel active label="Angle Measurement Tutorial" class="options-menu">
<bim-panel-section label="Controls">
<bim-label>Create angle: Double click 3 points</bim-label>
<bim-label>Delete measurement: Delete</bim-label>
</bim-panel-section>

<bim-panel-section label="Measurer">
<bim-checkbox checked label="Enabled"
@change="${({ target }: { target: BUI.Checkbox }) => {
measurer.enabled = target.value;
}}">
</bim-checkbox>
<bim-checkbox checked label="Measurements Visible"
@change="${({ target }: { target: BUI.Checkbox }) => {
measurer.visible = target.value;
}}">
</bim-checkbox>

<bim-number-input
slider step="1" label="Picker Size" value="${measurer.pickerSize}" min="2" max="20"
@change="${({ target }: { target: BUI.NumberInput }) => {
measurer.pickerSize = target.value;
}}">
</bim-number-input>

<bim-color-input
label="Color" color=#${measurer.linesMaterial.color.getHexString()}
@input="${({ target }: { target: BUI.ColorInput }) => {
measurer.color = new THREE.Color(target.color);
}}">
</bim-color-input>

${snapControls()}

<bim-dropdown
label="Units" required
@change="${({ target }: { target: BUI.Dropdown }) => {
const [units] = target.value;
measurer.units = units;
}}">
${measurer.unitsList.map(
(unit) =>
BUI.html`<bim-option label=${unit} value=${unit} ?checked=${unit === measurer.units}></bim-option>`,
)}
</bim-dropdown>

<bim-dropdown
label="Precision" required
@change="${({ target }: { target: BUI.Dropdown }) => {
const [rounding] = target.value;
measurer.rounding = rounding;
}}">
<bim-option label="0" value=0></bim-option>
<bim-option label="1" value=1></bim-option>
<bim-option label="2" value=2 checked></bim-option>
<bim-option label="3" value=3></bim-option>
<bim-option label="4" value=4></bim-option>
<bim-option label="5" value=5></bim-option>
</bim-dropdown>

<bim-dropdown
label="Line type" required
@change="${({ target }: { target: BUI.Dropdown }) => {
measurer.lineType = target.value[0] as OBF.MeasurementLineType;
}}">
<bim-option label="Basic" value="${OBF.MeasurementLineType.Basic}" checked></bim-option>
<bim-option label="Fat" value="${OBF.MeasurementLineType.Fat}"></bim-option>
</bim-dropdown>

<bim-number-input
slider label="Line width" value="${measurer.lineWidth}"
min="1" max="10" step="0.5"
@change="${({ target }: { target: BUI.NumberInput }) => {
measurer.lineWidth = target.value;
}}">
</bim-number-input>

<bim-button label="Delete all" @click=${() => deleteMeasurements()}></bim-button>

<bim-button label="Log Values" @click=${onLogValues}></bim-button>
</bim-panel-section>
</bim-panel>
`;
});

document.body.append(panel);

And we will make some logic that adds a button to the screen when the user is visiting our app from their phone, allowing to show or hide the menu. Otherwise, the menu would make the app unusable.

const button = BUI.Component.create<BUI.PanelSection>(() => {
return BUI.html`
<bim-button class="phone-menu-toggler" icon="solar:settings-bold"
@click="${() => {
if (panel.classList.contains("options-menu-visible")) {
panel.classList.remove("options-menu-visible");
} else {
panel.classList.add("options-menu-visible");
}
}}">
</bim-button>
`;
});

document.body.append(button);

⏱️ Measuring the performance (optional)​

We'll use the Stats.js to measure the performance of our app. We will add it to the top left corner of the viewport. This way, we'll make sure that the memory consumption and the FPS of our app are under control.

const stats = new Stats();
stats.showPanel(2);
document.body.append(stats.dom);
stats.dom.style.left = "0px";
stats.dom.style.zIndex = "unset";
world.renderer.onBeforeUpdate.add(() => stats.begin());
world.renderer.onAfterUpdate.add(() => stats.end());

🎉 Wrap up​

That's it! Now you're able to measure angles in your BIM application using the AngleMeasurement component. Congratulations! Keep going with more tutorials in the documentation.