@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
676 lines (584 loc) • 22 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type { BufferGeometry } from 'three';
import {
Box3,
Color,
DoubleSide,
Euler,
Group,
InstancedMesh,
MathUtils,
Matrix4,
Mesh,
MeshBasicMaterial,
Sphere,
SphereGeometry,
SRGBColorSpace,
Vector3,
type ColorRepresentation,
type Vector2,
type Vector3Like,
} from 'three';
import type Context from '../core/Context';
import type HasDefaultPointOfView from '../core/HasDefaultPointOfView';
import type { HeadingPitchRollLike } from '../core/HeadingPitchRoll';
import type PickOptions from '../core/picking/PickOptions';
import type PickResult from '../core/picking/PickResult';
import type PointOfView from '../core/PointOfView';
import type { EntityUserData } from './Entity';
import type { Entity3DOptions, Entity3DEventMap } from './Entity3D';
import {
getGeometryMemoryUsage,
getMaterialMemoryUsage,
getObject3DMemoryUsage,
type GetMemoryUsageContext,
} from '../core/MemoryUsage';
import pickObjectsAt from '../core/picking/PickObjectsAt';
import Fetcher from '../utils/Fetcher';
import Entity3D from './Entity3D';
const DEFAULT_DISTANCE = 10;
export interface ImageSource {
/**
* The position of the camera, in the same coordinate system as the instance.
*/
position: Vector3Like;
/**
* The orientation of the camera.
*/
orientation: HeadingPitchRollLike;
/**
* The distance from the origin at which the image is displayed.
* @defaultValue 10
*/
distance: number;
/**
* The URL of the image. If undefined, the image is not displayed (but the wireframe and origin point can still be displayed)
*/
imageUrl?: string;
}
export interface ImageCollectionBaseSource<TSource extends ImageSource> {
images: TSource[];
}
interface ImageObject {
readonly mesh: Mesh;
readonly material: MeshBasicMaterial;
wasDisposed: boolean;
}
/**
* Constructor options for the {@link OrientedImageCollection} entity.
*/
export interface ImageCollectionBaseOptions<TSource extends ImageSource> extends Entity3DOptions {
/**
* The OrientedImageCollection source.
*/
source: ImageCollectionBaseSource<TSource>;
/**
* Location spheres show the location of the camera when an image was taken.
*/
locationSpheres?: {
/**
* Display the location spheres at the origin of each image.
* @defaultValue true
*/
visible?: boolean;
/**
* The radius of the location spheres, in CRS units.
* @defaultValue 0.5
*/
radius?: number;
/**
* The color of the location spheres.
* @defaultValue green
*/
color?: ColorRepresentation;
};
/**
* Wireframes represent the field of view of each image.
*/
wireframes?: {
/**
* Display the wireframe of each image.
* @defaultValue true
*/
visible?: boolean;
/**
* The color of the camera wireframes.
* @defaultValue green
*/
color?: ColorRepresentation;
};
images?: {
/**
* Display the actual images.
* Note, if the `.imageUrl` property is undefined, then a blank rectangle is displayed instead.
* @defaultValue false
*/
visible?: boolean;
/**
* The opacity of the image object.
* @defaultValue 1
*/
opacity?: number;
};
}
export interface ImageCollectionBasePickResult extends PickResult {
imageIndex: number;
}
/**
* Displays a collection of oriented images coming from a {@link ImageCollectionBaseSource} in the 3D space.
*
* Each oriented image is displayed as 3 distinct elements:
* - a sphere positioned at the location of the camera receptor
* - a wireframe to show the camera receptor
* - a texture plane on which the image is projected
*
* Each of these 3 elements can be made visible or invisible independently.
*
* If the collection contains images that are too spread out geographically, visual issues may occur.
* This is why we advise to group images that are relatively close together.
*/
export abstract class ImageCollectionBase<
TSource extends ImageSource = ImageSource,
TUserData extends EntityUserData = EntityUserData,
> extends Entity3D<Entity3DEventMap, TUserData> {
/** The source of this entity. */
public readonly source: ImageCollectionBaseSource<TSource>;
private readonly _container: Group;
private readonly _origin = new Vector3();
private readonly _images: {
readonly container: Group;
readonly bufferGeometry: BufferGeometry;
opacity: number;
objects: ImageObject[] | null;
};
private readonly _spheres: {
readonly bufferGeometry: BufferGeometry;
readonly material: MeshBasicMaterial;
instancedMesh: InstancedMesh | null;
};
private readonly _wireframes: {
readonly bufferGeometry: BufferGeometry;
readonly material: MeshBasicMaterial;
instancedMesh: InstancedMesh | null;
};
public constructor(
imageGeometry: BufferGeometry,
wireframeGeometry: BufferGeometry,
options: ImageCollectionBaseOptions<TSource>,
) {
super(options);
this._container = new Group();
this._container.name = 'OrientedImageCollection-container';
this.object3d.add(this._container);
this.source = options.source;
this._images = {
container: new Group(),
bufferGeometry: imageGeometry,
opacity: options.images?.opacity ?? 1,
objects: null,
};
this._images.container.name = 'images-container';
this._images.container.visible = false;
this._container.add(this._images.container);
this._spheres = {
bufferGeometry: new SphereGeometry(options.locationSpheres?.radius ?? 0.5, 6, 5),
material: new MeshBasicMaterial({ color: options.locationSpheres?.color ?? 0x00ff00 }),
instancedMesh: null,
};
this._wireframes = {
bufferGeometry: wireframeGeometry,
material: new MeshBasicMaterial({
color: options.wireframes?.color ?? 0x00ff00,
wireframe: true,
}),
instancedMesh: null,
};
if (this.source.images.length > 0) {
this._origin.set(Infinity, Infinity, Infinity);
for (const imageSource of this.source.images) {
this._origin.min(imageSource.position);
}
this._container.position.copy(this._origin);
}
this.showLocationSpheres = options.locationSpheres?.visible ?? true;
this.showWireframes = options.wireframes?.visible ?? true;
this.showImages = options.images?.visible ?? false;
this.object3d.updateMatrixWorld(true);
}
public override getMemoryUsage(context: GetMemoryUsageContext): void {
getGeometryMemoryUsage(context, this._images.bufferGeometry);
if (this._images.objects) {
for (const imageObject of this._images.objects) {
getObject3DMemoryUsage(context, imageObject.mesh);
}
}
getGeometryMemoryUsage(context, this._spheres.bufferGeometry);
getMaterialMemoryUsage(context, this._spheres.material);
if (this._spheres.instancedMesh) {
getObject3DMemoryUsage(context, this._spheres.instancedMesh);
}
getGeometryMemoryUsage(context, this._wireframes.bufferGeometry);
getMaterialMemoryUsage(context, this._wireframes.material);
if (this._wireframes.instancedMesh) {
getObject3DMemoryUsage(context, this._wireframes.instancedMesh);
}
}
/**
* Gets or sets the spheres visibility.
*
* @defaultValue true
*/
public get showLocationSpheres(): boolean {
return this._spheres.instancedMesh?.visible === true;
}
public set showLocationSpheres(visible: boolean) {
if (this.showLocationSpheres === visible) {
return;
}
if (this._spheres.instancedMesh) {
this._spheres.instancedMesh.visible = visible;
} else if (visible) {
this.computeSpheres();
}
this.notifyChange(this);
}
/**
* Gets or sets the wireframes visibility.
*
* @defaultValue true
*/
public get showWireframes(): boolean {
return this._wireframes.instancedMesh?.visible === true;
}
public set showWireframes(visible: boolean) {
if (this.showWireframes === visible) {
return;
}
if (this._wireframes.instancedMesh) {
this._wireframes.instancedMesh.visible = visible;
} else if (visible) {
this.computeWireframes();
}
this.notifyChange(this);
}
/**
* Gets or sets the images opacity.
*
* @defaultValue 1
*/
public get imageOpacity(): number {
return this._images.opacity;
}
public set imageOpacity(opacity: number) {
if (this._images.opacity === opacity) {
return;
}
this._images.opacity = opacity;
if (this._images.objects) {
const actualOpacity = this.opacity * this._images.opacity;
for (const imageObject of this._images.objects) {
const currentTransparent = imageObject.material.transparent;
imageObject.material.transparent = actualOpacity < 1;
imageObject.material.opacity = actualOpacity;
if (currentTransparent !== imageObject.material.transparent) {
imageObject.material.needsUpdate = true;
}
}
this.notifyChange(this);
}
}
/**
* Gets or sets the images visibility.
*
* @defaultValue false
*/
public get showImages(): boolean {
return !!this._images.objects && this._images.container.visible;
}
public set showImages(visible: boolean) {
if (this.showImages === visible) {
return;
}
this._images.container.visible = visible;
if (visible && !this._images.objects) {
const createImageObject = this.createImageObject.bind(this);
this._images.objects = this.source.images.map(createImageObject);
}
this.notifyChange(this);
}
public override updateOpacity(): void {
super.updateOpacity();
if (this._images.objects) {
const imagesOpacity = this.opacity * this._images.opacity;
for (const imageObject of this._images.objects) {
const currenTransparent = imageObject.material.transparent;
imageObject.material.transparent = imagesOpacity < 1;
imageObject.material.opacity = imagesOpacity;
if (currenTransparent !== imageObject.material.transparent) {
imageObject.material.needsUpdate = true;
}
}
}
}
/**
* Sets the projection distance of a specific image in the collection.
*/
public setImageProjectionDistance(imageIndex: number, distance: number): void {
const source = this.getImageSource(imageIndex);
if (source.distance === distance) {
return;
}
source.distance = distance;
if (this._images.objects || this._wireframes.instancedMesh) {
const wireframeMatrix = this.computeWireframeMatrix(source);
if (this._images.objects) {
const imageObject = this._images.objects[imageIndex];
imageObject.mesh.matrix.copy(wireframeMatrix);
imageObject.mesh.updateMatrixWorld(true);
}
if (this._wireframes.instancedMesh) {
this._wireframes.instancedMesh.setMatrixAt(imageIndex, wireframeMatrix);
this._wireframes.instancedMesh.instanceMatrix.needsUpdate = true;
}
}
this.notifyChange(this);
}
/**
* Gets the projection distance of a specific image in the collection.
*/
public getImageProjectionDistance(imageIndex: number): number {
return this.getImageSource(imageIndex).distance ?? DEFAULT_DISTANCE;
}
/**
* Gets the point of view of the first image if there is one.
*/
public override getDefaultPointOfView(
_params: Parameters<HasDefaultPointOfView['getDefaultPointOfView']>[0],
): ReturnType<HasDefaultPointOfView['getDefaultPointOfView']> {
const firstSource = this.source.images[0];
if (firstSource == null) {
return null;
}
return this.computePointOfView(firstSource);
}
/**
* Gets the point of view of a specific image in the collection.
*/
public getImagePointOfView(imageIndex: number): PointOfView {
const source = this.getImageSource(imageIndex);
return this.computePointOfView(source);
}
/**
* Disposes this entity and deletes unmanaged graphical resources.
*/
public override dispose(): void {
this._container.clear();
if (this._images.objects) {
for (const imageObject of this._images.objects) {
imageObject.material.map?.dispose();
imageObject.material.dispose();
imageObject.wasDisposed = true;
}
this._images.objects = null;
}
this._images.bufferGeometry.dispose();
this._spheres.bufferGeometry.dispose();
this._spheres.material.dispose();
this._spheres.instancedMesh?.dispose();
this._spheres.instancedMesh = null;
this._wireframes.bufferGeometry.dispose();
this._wireframes.material.dispose();
this._wireframes.instancedMesh?.dispose();
this._wireframes.instancedMesh = null;
super.dispose();
}
public override pick(
canvasCoords: Vector2,
options?: PickOptions,
): ImageCollectionBasePickResult[] {
const result = pickObjectsAt(this.instance, canvasCoords, this.object3d, options);
const hit = result[0];
if (hit == null) {
return [];
}
let imageIndex: number | null = null;
if (hit.instanceId != null) {
imageIndex = hit.instanceId;
delete hit.instanceId;
} else {
if (this._images.objects) {
this._images.objects.forEach((imageObject: ImageObject, index: number) => {
if (imageObject.mesh === hit.object) {
imageIndex = index;
}
});
}
if (imageIndex === null) {
return [];
}
}
return [{ ...hit, imageIndex: imageIndex }];
}
public override postUpdate(context: Context, _changeSources: Set<unknown>): void {
this.updateMinMaxDistance(context);
}
private computeSpheres(): void {
if (this._spheres.instancedMesh) {
return;
}
const instancedMesh = new InstancedMesh(
this._spheres.bufferGeometry,
this._spheres.material,
this.source.images.length,
);
this._spheres.instancedMesh = instancedMesh;
this._spheres.instancedMesh.name = 'spheres';
this._container.add(instancedMesh);
this.source.images.forEach((source: ImageSource, index: number) => {
const matrix = this.computeLocalTranslationMatrix(source.position);
instancedMesh.setMatrixAt(index, matrix);
});
instancedMesh.instanceMatrix.needsUpdate = true;
}
private computeWireframes(): void {
if (this._wireframes.instancedMesh) {
return;
}
const instancedMesh = new InstancedMesh(
this._wireframes.bufferGeometry,
this._wireframes.material,
this.source.images.length,
);
this._wireframes.instancedMesh = instancedMesh;
this._wireframes.instancedMesh.name = 'wireframes';
this._container.add(instancedMesh);
this.source.images.forEach((source: TSource, index: number) => {
const matrix = this.computeWireframeMatrix(source);
instancedMesh.setMatrixAt(index, matrix);
});
instancedMesh.instanceMatrix.needsUpdate = true;
}
private computeWireframeMatrix(source: TSource): Matrix4 {
const translationMatrix = this.computeLocalTranslationMatrix(source.position);
const rotationMatrix = this.computeLocalRotationMatrix(source.orientation);
const scaleMatrix = this.computeWireframeScaleMatrix(source);
return new Matrix4()
.multiply(translationMatrix)
.multiply(rotationMatrix)
.multiply(scaleMatrix);
}
protected abstract computeWireframeScaleMatrix(source: TSource): Matrix4;
private computeLocalRotationMatrix(orientation: HeadingPitchRollLike): Matrix4 {
const heading = orientation.heading ?? 0;
const pitch = orientation.pitch ?? 0;
const roll = orientation.roll ?? 0;
return new Matrix4().makeRotationFromEuler(
new Euler(
MathUtils.degToRad(pitch),
MathUtils.degToRad(roll),
MathUtils.degToRad(-heading),
'ZYX',
),
);
}
private computeLocalTranslationMatrix(position: Vector3Like): Matrix4 {
return new Matrix4().makeTranslation(
position.x - this._origin.x,
position.y - this._origin.y,
position.z - this._origin.z,
);
}
private computePointOfView(source: ImageSource): PointOfView {
const rotationMatrix = this.computeLocalRotationMatrix(source.orientation);
return {
origin: new Vector3().copy(source.position),
target: new Vector3(0, 1, 0).applyMatrix4(rotationMatrix).add(source.position),
orthographicZoom: 1,
};
}
private getImageSource(imageIndex: number): TSource {
const source = this.source.images[imageIndex];
if (source == null) {
throw new Error(
`OrientedImageCollection "${this.id}" does not have image index "${imageIndex}".`,
);
}
return source;
}
private updateMinMaxDistance(context: Context): void {
if (!this.visible) {
return;
}
const boundingBox = new Box3();
if (this._spheres.instancedMesh?.visible === true) {
boundingBox.expandByObject(this._spheres.instancedMesh);
}
if (this._wireframes.instancedMesh?.visible === true) {
boundingBox.expandByObject(this._wireframes.instancedMesh);
}
if (this._images.container.visible) {
boundingBox.expandByObject(this._images.container);
}
const boundingSphere = boundingBox.getBoundingSphere(new Sphere());
const distance = context.distance.plane.distanceToSphere(boundingSphere);
this._distance.min = distance;
this._distance.max = distance + 2 * boundingSphere.radius;
}
private createImageObject(imageSource: TSource, index: number): ImageObject {
const url = imageSource.imageUrl;
const hasUrl = url != null;
const actualOpacity = hasUrl ? this.opacity * this._images.opacity : this.opacity * 0.3;
const transparent = actualOpacity < 1;
const material = new MeshBasicMaterial({
side: DoubleSide,
transparent: true,
opacity: 0,
color: hasUrl ? undefined : this._wireframes.material.color,
});
const mesh = new Mesh(this._images.bufferGeometry, material);
mesh.name = `image-${index}`;
this._images.container.add(mesh);
mesh.matrix.copy(this.computeWireframeMatrix(imageSource));
mesh.matrixAutoUpdate = false;
mesh.updateMatrixWorld(true);
const imageObject: ImageObject = { mesh, material, wasDisposed: false };
if (hasUrl) {
// only trigger texture fetching once when the mesh is visible
mesh.onBeforeRender = async (): Promise<void> => {
mesh.onBeforeRender = (): void => {};
try {
const texture = await Fetcher.texture(url, {
flipY: true,
});
if (imageObject.wasDisposed) {
texture.dispose();
} else {
texture.generateMipmaps = true;
texture.colorSpace = SRGBColorSpace;
material.map = texture;
material.visible = true;
material.transparent = transparent;
material.opacity = actualOpacity;
material.needsUpdate = true;
this.notifyChange(this);
}
} catch (error: unknown) {
material.color = new Color('red');
material.visible = true;
material.opacity = 1;
console.error(`Failed to load texture "${imageSource.imageUrl}": `, error);
this.notifyChange(this);
}
};
} else {
material.color = this._wireframes.material.color;
material.opacity = actualOpacity;
}
return imageObject;
}
}
export default ImageCollectionBase;