Skip to main content

OrthoPerspectiveCamera

Source

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

📄 Handling Fancy Cameras


BIM applications need two navigation modes: perspective for exploring the 3D model naturally, and orthographic for reading plans and sections with accurate proportions. Switching between them mid-session — without losing the camera position — is what the OrthoPerspectiveCamera is built for. This tutorial covers switching between perspective and orthographic projections, changing navigation modes (Orbit, First Person, and Plan), locking user input, and fitting the camera to the loaded model. By the end, you'll have a fully controllable camera ready for both 3D exploration and 2D plan navigation.

🖖 Importing our Libraries

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

import Stats from "stats.js";
import * as BUI from "@thatopen/ui";
// You have to import * as OBC from "@thatopen/components"
import * as OBC 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,
OBC.SimpleRenderer
>();

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

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

components.init();

const grid = components.get(OBC.Grids).create(world);

🛠️ Setting Up Fragments

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

// `FragmentsManager.getWorker()` fetches the matching worker for this library version from unpkg and returns a blob URL.
// You can also pass your own URL to `fragments.init(...)` if you'd rather host the worker yourself.
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 OrthoPerspectiveCamera Component

We have already created the camera while setting up the world, making it incredibly simple. However, the camera itself comes with some exciting features that can be triggered through the UI in this tutorial. Since the camera can switch between different projections, the world's grid needs to be updated accordingly:

world.camera.projection.onChanged.add(() => {
const projection = world.camera.projection.current;
grid.fade = projection === "Perspective";
});

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();

Now we will create a simple UI for the OrthoPerspectiveCamera. It will have 4 elements:

🎛️ Navigation mode

This will control the navigation mode of the OrthoPerspectiveCamera. It will have 3 options:

  • Orbit: for 3D orbiting around the scene.
  • FirstPerson: for navigating the scene in with the mouse wheel in first person.
  • Plan: for navigating 2d plans (blocking the orbit).

📐 Projections

Like its name implies, the OrthoPerspectiveCamera has 2 projections, and it's really easy to toggle between them. The camera position will remain the same, which is really convenient when you switch between different projections!

❌ Toggling user input

Sometimes you might want to remove control from the user. For example, imagine you are animating the camera and you don't want the user to move the camera around. You can use the setUserInput method to toggle this.

🔎 Focusing objects

The OrthoPerspectiveCamera has a fit method that will fit the camera to a list of meshes. This is really useful when you want to bring attention to a specific part of the scene, or for allowing your user to navigate the scene by focusing objects.

const panel = BUI.Component.create<BUI.PanelSection>(() => {
return BUI.html`
<bim-panel active label="OrthoPerspectiveCamera Tutorial" class="options-menu">
<bim-panel-section label="Section">
<bim-dropdown required label="Navigation Mode"
@change="${({ target }: { target: BUI.Dropdown }) => {
const selected = target.value[0] as OBC.NavModeID;

const { current } = world.camera.projection;
const isOrtho = current === "Orthographic";
const isFirstPerson = selected === "FirstPerson";
if (isOrtho && isFirstPerson) {
alert("First person is not compatible with ortho!");
target.value[0] = world.camera.mode.id;
return;
}
world.camera.set(selected);
}}">

<bim-option checked label="Orbit"></bim-option>
<bim-option label="FirstPerson"></bim-option>
<bim-option label="Plan"></bim-option>
</bim-dropdown>


<bim-dropdown required label="Projection"
@change="${({ target }: { target: BUI.Dropdown }) => {
const selected = target.value[0] as OBC.CameraProjection;
const isOrtho = selected === "Orthographic";
const isFirstPerson = world.camera.mode.id === "FirstPerson";
if (isOrtho && isFirstPerson) {
alert("First person is not compatible with ortho!");
target.value[0] = world.camera.projection.current;
return;
}
world.camera.projection.set(selected);
}}">
<bim-option checked label="Perspective"></bim-option>
<bim-option label="Orthographic"></bim-option>
</bim-dropdown>

<bim-checkbox
label="Allow User Input" checked
@change="${({ target }: { target: BUI.Checkbox }) => {
world.camera.setUserInput(target.checked);
}}">
</bim-checkbox>

<bim-button
label="Fit Model"
@click=${() => world.camera.fitToItems()}>
</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 use the OrthoPerspectiveCamera component effectively, toggle between projections, navigate your scene, and even fit the camera to the models. Congratulations! Keep exploring more tutorials in the documentation to enhance your skills further.