@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
265 lines (210 loc) • 9.74 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import { Box3, MathUtils, Matrix3, Matrix4, Plane, type Sphere, Vector2, Vector3 } from 'three';
import { OBB } from 'three/examples/jsm/Addons.js';
import type ElevationRange from '../../core/ElevationRange';
import type Ellipsoid from '../../core/geographic/Ellipsoid';
import type Extent from '../../core/geographic/Extent';
import Coordinates from '../../core/geographic/Coordinates';
import CoordinateSystem from '../../core/geographic/CoordinateSystem';
import TileVolume from './TileVolume';
const vec3 = new Vector3();
const vec2 = new Vector2();
const coord = new Coordinates(CoordinateSystem.epsg4326, 0, 0);
const matrix4 = new Matrix4();
export default class EllipsoidTileVolume extends TileVolume {
private readonly _ellipsoid: Ellipsoid;
private readonly _extent: Extent;
private _range: ElevationRange = { min: -1, max: +1 };
private _obb: OBB | null = null;
private _corners: Vector3[] | null = null;
private _max = 0;
private _min = 0;
private _origin: Vector3 | undefined = undefined;
public get extent(): Readonly<Extent> {
return this._extent;
}
public get ellipsoid(): Readonly<Ellipsoid> {
return this._ellipsoid;
}
public get origin(): Vector3 {
if (this._origin == null) {
const { x, y } = this.extent.centerAsVector2();
this._origin = this.ellipsoid.toCartesian(x, y, 0, this._origin);
}
return this._origin;
}
public constructor(options: { extent: Extent; range: ElevationRange; ellipsoid: Ellipsoid }) {
super();
this._extent = options.extent;
this._range = options.range;
this._ellipsoid = options.ellipsoid;
}
public getWorldSpaceCorners(matrix?: Matrix4): Vector3[] {
if (this._corners == null) {
const dims = this._extent.dimensions(vec2);
const xCount = MathUtils.clamp(Math.round(dims.width / 5) + 1, 2, 6);
const yCount = MathUtils.clamp(Math.round(dims.height / 5) + 1, 2, 6);
this._corners = new Array(xCount * yCount);
const uStep = 1 / (xCount - 1);
const jStep = 1 / (yCount - 1);
let index = 0;
for (let i = 0; i < xCount; i++) {
for (let j = 0; j < yCount; j++) {
const u = i * uStep;
const v = j * jStep;
const { latitude, longitude } = this._extent.sampleUV(u, v, coord);
const p0 = this._ellipsoid.toCartesian(latitude, longitude, this._min);
const p1 = this._ellipsoid.toCartesian(latitude, longitude, this._max);
if (matrix) {
p0.applyMatrix4(matrix);
p1.applyMatrix4(matrix);
}
this._corners[index++] = p0;
this._corners[index++] = p1;
}
}
}
return this._corners;
}
public override getOBB(): OBB {
if (this._obb == null) {
const center = this._extent.center(coord);
const cartesianCenter = this.ellipsoid.toCartesian(
center.latitude,
center.longitude,
0,
);
// To help us compute the thickness (on the local Z axis) of the box,
// we create a tangent plane on the ellipsoid, then measure the distance from
// the corners to this plane.
const distanceFromOrigin = cartesianCenter.length();
const normal = this.ellipsoid.getNormalFromCartesian(cartesianCenter);
const plane = new Plane(normal, -distanceFromOrigin);
const corners = this.getWorldSpaceCorners();
let localMax = 0;
let localMin = 0;
for (let i = 0; i < corners.length; i++) {
const element = corners[i];
const distance = plane.distanceToPoint(element);
if (distance >= 0) {
localMax = Math.max(localMax, distance);
} else {
localMin = Math.min(localMin, distance);
}
}
const thickness = Math.abs(localMax - localMin);
let horizontalDistance: number;
// To compute the width and height of the box, we
// measure the arc chords associated to the vertical
// and horizontal sides of the extent (which looks like a curved sheet).
const sw = this.extent.sampleUV(0, 0);
const se = this.extent.sampleUV(1, 0);
const nw = this.extent.sampleUV(0, 1);
const ne = this.extent.sampleUV(1, 1);
// We have to use the greatest chord length for the horizontal chord,
// since tiles that touch the pole have a chord length of zero at the pole.
if (center.latitude > 0) {
// Northern hemisphere
horizontalDistance = this._ellipsoid
.toCartesian(sw.latitude, sw.longitude, 0)
.distanceTo(this._ellipsoid.toCartesian(se.latitude, se.longitude, 0));
} else {
// Southern hemisphere
horizontalDistance = this._ellipsoid
.toCartesian(nw.latitude, nw.longitude, 0)
.distanceTo(this._ellipsoid.toCartesian(ne.latitude, ne.longitude, 0));
}
// The vertical distance is simpler, since
// it is the same on both sides of the extent.
const verticalDistance = this._ellipsoid
.toCartesian(sw.latitude, sw.longitude, 0)
.distanceTo(this._ellipsoid.toCartesian(nw.latitude, nw.longitude, 0));
const halfSize = new Vector3(
horizontalDistance / 2,
verticalDistance / 2,
thickness / 2,
);
// The center is located on the tangent plane, but it's incorrect:
// we have to move it down so that it is located at the actual
// center of the computed volume.
const midHeight = MathUtils.lerp(localMax, localMin, 0.5);
cartesianCenter.addScaledVector(normal, midHeight);
// Finally, the rotation component of the OBB is simply the ENU matrix at the center.
const rotation = new Matrix3().setFromMatrix4(
this.ellipsoid.getEastNorthUpMatrixFromCartesian(cartesianCenter, matrix4),
);
this._obb = new OBB(cartesianCenter, halfSize, rotation);
}
return this._obb;
}
protected override computeLocalBox(): Box3 {
const extent = this._extent;
const min = this._range.min;
const max = this._range.max;
const p0 = this._ellipsoid.toCartesian(extent.maxY, extent.minX, min);
const p1 = this._ellipsoid.toCartesian(extent.maxY, extent.minX, max);
const p2 = this._ellipsoid.toCartesian(extent.minY, extent.minX, min);
const p3 = this._ellipsoid.toCartesian(extent.minY, extent.minX, max);
const p4 = this._ellipsoid.toCartesian(extent.minY, extent.maxX, min);
const p5 = this._ellipsoid.toCartesian(extent.minY, extent.maxX, max);
const p6 = this._ellipsoid.toCartesian(extent.maxY, extent.maxX, min);
const p7 = this._ellipsoid.toCartesian(extent.maxY, extent.maxX, max);
const center = extent.center(coord);
const p8 = this._ellipsoid.toCartesian(center.latitude, center.longitude, min);
const p9 = this._ellipsoid.toCartesian(center.latitude, center.longitude, max);
const p10 = this._ellipsoid.toCartesian(extent.north, center.longitude, min);
const p11 = this._ellipsoid.toCartesian(extent.south, center.longitude, max);
const p12 = this._ellipsoid.toCartesian(center.latitude, extent.minX, min);
const p13 = this._ellipsoid.toCartesian(center.latitude, extent.maxX, max);
const worldBox = new Box3().setFromPoints([
p0,
p1,
p2,
p3,
p4,
p5,
p6,
p7,
p8,
p9,
p10,
p11,
p12,
p13,
]);
return worldBox.setFromCenterAndSize(
worldBox.getCenter(vec3).sub(p0),
worldBox.getSize(new Vector3()),
);
}
public setElevationRange(range: ElevationRange): void {
let { min, max } = range;
if (!Number.isFinite(min) || !Number.isFinite(max)) {
min = 0;
max = 0;
}
this._range = { min, max };
if (this._min !== min || this._max !== max) {
this._min = min;
this._max = max;
this._localBox = this.computeLocalBox();
this._corners = null;
this._obb = null;
}
}
public override getWorldSpaceBoundingSphere(target: Sphere): Sphere {
const obb = this.getOBB();
// Technically not a bounding sphere because we are selecting
// the smallest side for the radius, but since this is used
// for LOD computation rather than culling, it's fine.
// The reason we are picking the smallest radius is to avoid having
// giant spheres for very elongated tiles near the poles, leading to
// very incorrect LOD selection for those regions.
const radius = Math.min(obb.halfSize.x, obb.halfSize.y);
return target.set(obb.center, radius);
}
}