@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
471 lines (443 loc) • 15.5 kB
JavaScript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import { Box3, EventDispatcher, Frustum, MathUtils, Matrix4, Object3D, PerspectiveCamera, Sphere, Vector3 } from 'three';
import Coordinates from '../core/geographic/Coordinates';
import Ellipsoid from '../core/geographic/Ellipsoid';
import { hasDefaultPointOfView } from '../core/HasDefaultPointOfView';
import { isPointOfView } from '../core/PointOfView';
import { isBox3, isOrthographicCamera, isPerspectiveCamera } from '../utils/predicates';
const ZERO = new Vector3(0, 0, 0);
const tmp = {
vec3: new Vector3(),
frustum: new Frustum(),
matrix: new Matrix4(),
obbMatrix: new Matrix4(),
box3: new Box3(),
up: new Vector3(),
sphere: new Sphere()
};
const points = [new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3()];
const IDENTITY = new Matrix4();
export const DEFAULT_MIN_NEAR_PLANE = 2;
export const DEFAULT_MAX_FAR_PLANE = 2_000_000_000;
/**
* Returns the distance from the center of the bounding sphere so
* that the perspective camera's frustum view fits the sphere.
* @param camera - The perspective camera.
* @param radius - The sphere radius.
*/
export function computeDistanceToFitSphere(camera, radius) {
// Simple trigonometry
const halfFov = camera.fov / 2;
const theta = MathUtils.degToRad(halfFov);
const adjacent = radius / Math.tan(theta);
return adjacent;
}
/**
* Computes the zoom value so that the sphere fits in the orthographic camera's frustum.
* @param camera - The orthographic camera.
* @param radius - The sphere radius.
*/
export function computeZoomToFitSphere(camera, radius) {
const camWidth = camera.right - camera.left;
const camHeight = camera.top - camera.bottom;
const camSize = Math.max(camWidth, camHeight);
return camSize / (radius * 2) / 2;
}
/**
* Represent the users's point of view. Internally this encapsulate a three.js camera.
*/
class View extends EventDispatcher {
_maxFar = DEFAULT_MAX_FAR_PLANE;
_minNear = DEFAULT_MIN_NEAR_PLANE;
_controls = null;
_onControlsUpdated = () => this.dispatchEvent({
type: 'change'
});
_frustum = new Frustum();
/**
* The width, in pixels, of this view.
*/
get width() {
return this._width;
}
/**
* The height, in pixels, of this view.
*/
get height() {
return this._height;
}
/**
* Gets or sets the current camera.
*/
get camera() {
return this._camera;
}
set camera(c) {
if (c != null) {
this._camera = c;
} else {
throw new Error('a camera is required');
}
}
/**
* @param params - The constructor parameters.
*/
constructor(params) {
super();
const {
width,
height,
crs
} = params;
this._coordinateSystem = crs;
this._camera = params.camera ?? new PerspectiveCamera(30, width / height);
this._camera.near = DEFAULT_MIN_NEAR_PLANE;
this._camera.far = DEFAULT_MAX_FAR_PLANE;
this._camera.updateProjectionMatrix();
this._viewMatrix = new Matrix4();
this._width = width;
this._height = height;
this._preSSE = Infinity;
}
get crs() {
return this._coordinateSystem;
}
get preSSE() {
return this._preSSE;
}
set preSSE(value) {
this._preSSE = value;
}
get viewMatrix() {
return this._viewMatrix;
}
get near() {
return this.camera.near;
}
get frustum() {
return this._frustum;
}
/**
* Gets or sets the distance to the near plane. The distance will be clamped to be within
* the bounds defined by {@link minNearPlane} and {@link maxFarPlane}.
*/
set near(distance) {
if (!Number.isFinite(distance) || distance < 0) {
console.warn(`Invalid near plane distance: ${distance}`);
return;
}
this.camera.near = MathUtils.clamp(distance, this.minNearPlane, this.maxFarPlane);
}
get far() {
return this.camera.far;
}
/**
* Gets or sets the distance to the far plane. The distance will be clamped to be within
* the bounds defined by {@link minNearPlane} and {@link maxFarPlane}.
*/
set far(distance) {
if (!Number.isFinite(distance) || distance < 0) {
console.warn(`Invalid far plane distance: ${distance}`);
return;
}
this.camera.far = MathUtils.clamp(distance, this.minNearPlane, this.maxFarPlane);
}
/**
* Gets or sets the maximum distance allowed for the camera far plane.
*/
get maxFarPlane() {
return this._maxFar;
}
set maxFarPlane(distance) {
this._maxFar = distance;
this.camera.far = Math.min(this.camera.far, distance);
}
/**
* Gets or sets the minimum distance allowed for the camera near plane.
*/
get minNearPlane() {
return this._minNear;
}
set minNearPlane(distance) {
this._minNear = distance;
this.camera.near = Math.max(this.camera.near, distance);
}
/**
* Gets the currently registered controls, if any.
*
* Note: To register controls, use {@link setControls}.
*/
get controls() {
return this._controls;
}
/**
* Registers external controls that must be udpated periodically.
*
* Note: this is the case of simple controls in the `examples/{js,jsm}/controls` folder
* of THREE.js (e.g `MapControls`):
*
* - they fire `'change'` events when the controls' state has changed and the view must be rendered,
* - they have an `update()` method to update the controls' state.
*
* For more complex controls, such as the package [`camera-controls`](https://www.npmjs.com/package/camera-controls),
* a more complex logic is required. Please refer to the appropriate examples for a detailed
* documentation on how to bind Giro3D and those controls.
*
* @param controls - The controls to register. If `null`, currently registered controls
* are unregistered (they are not disabled however).
*/
setControls(controls) {
if (controls != null) {
controls.addEventListener('change', this._onControlsUpdated);
} else {
this._controls?.removeEventListener('change', this._onControlsUpdated);
}
this._controls = controls;
}
/**
* Resets the near and far planes to their default value.
*/
resetPlanes() {
this.near = this.minNearPlane;
this.far = this.maxFarPlane;
}
/**
* @internal
*/
update() {
this._controls?.update();
// update matrix
this.camera.updateMatrixWorld();
this.camera.updateProjectionMatrix();
// keep our visibility testing matrix ready
this._viewMatrix.multiplyMatrices(this.camera.projectionMatrix, this.camera.matrixWorldInverse);
this._frustum.setFromProjectionMatrix(this._viewMatrix);
}
setSize(width, height) {
if (width != null && height != null) {
this._width = width;
this._height = height;
const ratio = width / height;
if (isPerspectiveCamera(this.camera)) {
if (this.camera.aspect !== ratio) {
this.camera.aspect = ratio;
}
} else if (isOrthographicCamera(this.camera)) {
const orthographic = this.camera;
const orthoWidth = orthographic.right - orthographic.left;
const orthoHeight = orthoWidth / ratio;
orthographic.top = orthoHeight / 2;
orthographic.bottom = -orthoHeight / 2;
}
}
this.camera.updateProjectionMatrix();
}
/**
* Return the position in the requested CRS, or in camera's CRS if undefined.
*
* @param crs - if defined (e.g 'EPSG:4236') the camera position will be
* returned in this CRS
* @returns Coordinates object holding camera's position
*/
position(crs) {
return new Coordinates(this.crs, this.camera.position).as(crs ?? this.crs);
}
isOBBVisible(worldOBB) {
const box = tmp.box3.setFromCenterAndSize(ZERO, worldOBB.getSize(tmp.vec3));
const obbMatrix = tmp.obbMatrix.setFromMatrix3(worldOBB.rotation).setPosition(worldOBB.center);
const matrix = tmp.matrix.multiplyMatrices(this._viewMatrix, obbMatrix);
tmp.frustum.setFromProjectionMatrix(matrix);
return tmp.frustum.intersectsBox(box);
}
isBox3Visible(box3, matrixWorld) {
if (matrixWorld && !matrixWorld.equals(IDENTITY)) {
tmp.matrix.multiplyMatrices(this._viewMatrix, matrixWorld);
tmp.frustum.setFromProjectionMatrix(tmp.matrix);
return tmp.frustum.intersectsBox(box3);
} else {
return this._frustum.intersectsBox(box3);
}
}
isSphereVisible(sphere, matrixWorld) {
if (matrixWorld && !matrixWorld.equals(IDENTITY)) {
tmp.matrix.multiplyMatrices(this._viewMatrix, matrixWorld);
tmp.frustum.setFromProjectionMatrix(tmp.matrix);
return tmp.frustum.intersectsSphere(sphere);
} else {
return this._frustum.intersectsSphere(sphere);
}
}
box3SizeOnScreen(box3, matrixWorld) {
const pts = this.projectBox3PointsInCameraSpace(box3, matrixWorld);
// All points are in front of the near plane -> box3 is invisible
if (!pts) {
return tmp.box3.makeEmpty();
}
// Project points on screen
for (let i = 0; i < 8; i++) {
pts[i].applyMatrix4(this.camera.projectionMatrix);
}
return tmp.box3.setFromPoints(pts);
}
/**
* Returns the "up" vector at a given coordinates.
*
* The "up" vector is generally used to orient objects and cameras.
*
* If a custom function was specified in the constructor of the instance, this will be used.
*
* Otherwise, the default implementation is used:
*
* - For projected coordinate systems, this is equal to the vertical axis (typically Z).
* - For the ECEF geocentric coordinate system (EPSG:4878), this is equal to the normal
* of the WGS84 ellipsoid at this location.
*
* @param coordinate - The coordinate of the point for which to compute the vector.
* @param target - The vector to store the result. If unspecified, a new one is created.
* @returns The up vector at this location.
*/
getUpVector(coordinate, target) {
if (this._coordinateSystem.isEpsg(4978)) {
return Ellipsoid.WGS84.getNormalFromCartesian(coordinate, target);
}
target = target ?? new Vector3();
// We expect that DEFAULT_UP was set properly during construction of the instance.
return target.copy(Object3D.DEFAULT_UP);
}
/**
* Computes a {@link PointOfView} for the given object.
* @param obj - The object to compute the point of view, or a world space bounding box.
* @param options - Optional parameters.
* @returns The readonly point of view if it could be computed, `null` otherwise.
*/
getDefaultPointOfView(obj, options) {
if (obj == null) {
return null;
}
const box = isBox3(obj) ? obj : new Box3().setFromObject(obj);
const sphere = box.getBoundingSphere(tmp.sphere);
const target = sphere.center;
const camera = options?.camera ?? this.camera;
const up = this.getUpVector(target, tmp.up);
const radius = sphere.radius * 1.2;
let distance = 0;
let orthographicZoom = 1;
if (isPerspectiveCamera(camera)) {
distance = computeDistanceToFitSphere(camera, radius);
} else if (isOrthographicCamera(camera)) {
orthographicZoom = computeZoomToFitSphere(camera, radius);
distance = radius * 4;
} else {
return null;
}
const origin = target.clone().addScaledVector(up, distance);
const result = {
origin,
target,
orthographicZoom
};
Object.freeze(result);
return result;
}
applyPointOfView(pov, allowTranslation) {
if (pov != null) {
if (allowTranslation) {
this.camera.position.copy(pov.origin);
}
let actualTarget = pov.target;
if (this.camera.position.x === pov.target.x && this.camera.position.y === pov.target.y) {
// Since we have a perfectly vertical line of sight, we cannot set the camera position
// to the exact same XY coordinates as the target, otherwise we run into the
// typical gimbal lock problem. That can be easly solved by adding a slight
// offset on any axis. Here we arbitrarily choose the Y axis.
actualTarget = pov.target.clone().setY(pov.target.y + -0.001);
}
this.camera.lookAt(actualTarget);
if (isOrthographicCamera(this.camera)) {
this.camera.zoom = pov.orthographicZoom;
}
this.camera.updateMatrixWorld(true);
}
this.dispatchEvent({
type: 'change'
});
}
/**
* Setup the camera to match the specified object or point of view.
*
* - If the argument is a {@link PointOfView}, this will be used directly.
* - If the object implements {@link HasDefaultPointOfView}, this will be used to compute the point of view.
* - Otherwise, a default point of view is computed from the object's bounding box.
*
* **Important note:** this method does not update any camera controls that are controlling the camera.
* Those controls have to be updated manually so they do not override the new camera position. For example,
* controls that have a target must be updated so that the target position matches the one returned by this method.
* @param obj - The object to go to.
* @param options - The options.
* @returns The immutable {@link PointOfView} that was used to setup the camera, or `null` if it couldn't be computed.
*/
goTo(obj, options) {
if (obj == null) {
return null;
}
let pov = null;
if (isPointOfView(obj)) {
pov = {
...obj
};
} else if (hasDefaultPointOfView(obj)) {
pov = obj.getDefaultPointOfView({
camera: this.camera
});
} else {
pov = this.getDefaultPointOfView(obj);
}
if (pov != null) {
this.applyPointOfView(pov, options?.allowTranslation ?? true);
}
// Ensure that the point of view is immutable to emphasize
// the fact that it was created under specific camera conditions
// and it not applicable to any camera.
return Object.freeze(pov);
}
projectBox3PointsInCameraSpace(box3, matrixWorld) {
if (!('near' in this.camera)) {
return undefined;
}
// Projects points in camera space
// We don't project directly on screen to avoid artifacts when projecting
// points behind the near plane.
let m = this.camera.matrixWorldInverse;
if (matrixWorld) {
m = tmp.matrix.multiplyMatrices(this.camera.matrixWorldInverse, matrixWorld);
}
points[0].set(box3.min.x, box3.min.y, box3.min.z).applyMatrix4(m);
points[1].set(box3.min.x, box3.min.y, box3.max.z).applyMatrix4(m);
points[2].set(box3.min.x, box3.max.y, box3.min.z).applyMatrix4(m);
points[3].set(box3.min.x, box3.max.y, box3.max.z).applyMatrix4(m);
points[4].set(box3.max.x, box3.min.y, box3.min.z).applyMatrix4(m);
points[5].set(box3.max.x, box3.min.y, box3.max.z).applyMatrix4(m);
points[6].set(box3.max.x, box3.max.y, box3.min.z).applyMatrix4(m);
points[7].set(box3.max.x, box3.max.y, box3.max.z).applyMatrix4(m);
// In camera space objects are along the -Z axis
// So if min.z is > -near, the object is invisible
let atLeastOneInFrontOfNearPlane = false;
for (let i = 0; i < 8; i++) {
if (points[i].z <= -this.camera.near) {
atLeastOneInFrontOfNearPlane = true;
} else {
// Clamp to near plane
points[i].z = -this.camera.near;
}
}
return atLeastOneInFrontOfNearPlane ? points : undefined;
}
dispose() {
this.setControls(null);
}
}
export default View;