@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
894 lines (793 loc) • 29.5 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type { OrthographicCamera, PerspectiveCamera } from 'three';
import type { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
import {
Clock,
EventDispatcher,
Group,
Object3D,
Scene,
Vector2,
Vector3,
type ColorRepresentation,
type WebGLRenderer,
type WebGLRendererParameters,
} from 'three';
import type Entity from '../entities/Entity';
import type Entity3D from '../entities/Entity3D';
import type RenderingOptions from '../renderer/RenderingOptions';
import type CoordinateSystem from './geographic/CoordinateSystem';
import type PickOptions from './picking/PickOptions';
import type PickResult from './picking/PickResult';
import type Progress from './Progress';
import { isEntity } from '../entities/Entity';
import { isEntity3D } from '../entities/Entity3D';
import C3DEngine from '../renderer/c3DEngine';
import { GlobalRenderTargetPool } from '../renderer/RenderTargetPool';
import View from '../renderer/View';
import { GlobalCache } from './Cache';
import { isDisposable } from './Disposable';
import MainLoop from './MainLoop';
import {
aggregateMemoryUsage,
getObject3DMemoryUsage,
type GetMemoryUsageContext,
type MemoryUsageReport,
} from './MemoryUsage';
import { isPickable } from './picking/Pickable';
import { isPickableFeatures } from './picking/PickableFeatures';
import pickObjectsAt from './picking/PickObjectsAt';
const vectors = {
pos: new Vector3(),
size: new Vector3(),
evtToCanvas: new Vector2(),
pickVec2: new Vector2(),
};
/** Frame event payload */
export interface FrameEventPayload {
/** The frame number. */
frame: number;
/** Time elapsed since previous update loop, in milliseconds */
dt: number;
/** `true` if the update loop restarted */
updateLoopRestarted: boolean;
}
/** Entity event payload */
export interface EntityEventPayload {
/** Entity */
entity: Entity;
}
/**
* Events supported by
* [`Instance.addEventListener()`](https://threejs.org/docs/#api/en/core/EventDispatcher.addEventListener)
* and
* [`Instance.removeEventListener()`](https://threejs.org/docs/#api/en/core/EventDispatcher.removeEventListener)
*/
export interface InstanceEvents {
/**
* Fires when an entity is added to the instance.
*/
'entity-added': unknown;
/**
* Fires when an entity is removed from the instance.
*/
'entity-removed': unknown;
/**
* Fires at the start of the update
*/
'update-start': FrameEventPayload;
/**
* Fires before the camera update
*/
'before-camera-update': { camera: View } & FrameEventPayload;
/**
* Fires after the camera update
*/
'after-camera-update': { camera: View } & FrameEventPayload;
/**
* Fires before the entity update
*/
'before-entity-update': EntityEventPayload & FrameEventPayload;
/**
* Fires after the entity update
*/
'after-entity-update': EntityEventPayload & FrameEventPayload;
/**
* Fires before the render
*/
'before-render': FrameEventPayload;
/**
* Fires after the render
*/
'after-render': FrameEventPayload;
/**
* Fires at the end of the update
*/
'update-end': FrameEventPayload;
'picking-start': unknown;
'picking-end': {
/**
* The duration of the picking, in seconds.
*/
elapsed: number;
/**
* The picking results.
*/
results?: PickResult<unknown>[];
};
/**
* Fires when the instance is disposed.
*/
dispose: unknown;
}
/** Options for creating Instance */
export interface InstanceOptions {
/**
* The container for the instance. May be either the `id` of an existing `<div>` element,
* or the element itself.
*/
target: string | HTMLDivElement;
/**
* The coordinate reference system of the scene.
* Must be a cartesian system.
* Must first be registered via {@link CoordinateSystem.register}
*/
crs: CoordinateSystem;
/**
* The [Three.js Scene](https://threejs.org/docs/#api/en/scenes/Scene) instance to use,
* otherwise a default one will be constructed
*/
scene3D?: Scene;
/**
* The background color of the canvas. If `null`, the canvas is transparent.
* If `undefined`, the default color is used.
* @defaultValue `'#030508'`
*/
backgroundColor?: ColorRepresentation | null;
/**
* The renderer to use. Might be either an instance of an existing {@link WebGLRenderer},
* or options to create one. If `undefined`, a new one will be created with default parameters.
*/
renderer?: WebGLRenderer | WebGLRendererParameters;
/**
* The THREE camera to use
*/
camera?: PerspectiveCamera | OrthographicCamera;
}
/**
* Options for picking objects from the Giro3D {@link Instance}.
*/
export interface PickObjectsAtOptions extends PickOptions {
/**
* List of entities to pick from.
* If not provided, will pick from all the objects in the scene.
* Strings consist in the IDs of the object.
*/
where?: (string | Object3D | Entity)[];
/**
* Indicates if the results should be sorted by distance, as Three.js raycasting does.
* This prevents the `limit` option to be fully used as it is applied after sorting,
* thus it may be slow and is disabled by default.
*
* @defaultValue false
*/
sortByDistance?: boolean;
/**
* Indicates if features information are also retrieved from the picked object.
* On complex objects, this may be slow, and therefore is disabled by default.
*
* @defaultValue false
*/
pickFeatures?: boolean;
}
function isObject3D(o: unknown): o is Object3D {
return (o as Object3D).isObject3D;
}
/**
* The instance is the core component of Giro3D. It encapsulates the 3D scene,
* the current camera and one or more {@link Entity | entities}. The instance displays
* the 3D scene in the DOM element specified by the `target` constructor property.
*
* ```js
* // Create a Giro3D instance in the EPSG:3857 coordinate system:
* const instance = new Instance({
* target: 'view',
* crs: CoordinateSystem.epsg3857,
* });
*
* const map = new Map(...);
*
* // Add an entity to the instance
* instance.add(map);
*
* // Bind an event listener on double click
* instance.domElement.addEventListener('dblclick', () => console.log('double click!'));
*
* // Get the camera position
* const position = instance.view.camera.position;
*
* // Set the camera position to be located 10,000 meters above the center of the coordinate system.
* instance.view.camera.position.set(0, 0, 10000);
* instance.view.camera.lookAt(0, 0, 0);
* ```
*/
class Instance extends EventDispatcher<InstanceEvents> implements Progress {
private readonly _referenceCrs: CoordinateSystem;
private readonly _viewport: HTMLDivElement;
private readonly _mainLoop: MainLoop;
private readonly _engine: C3DEngine;
private readonly _scene: Scene;
private readonly _threeObjects: Group;
private readonly _view: View;
private readonly _entities: Set<Entity>;
private readonly _resizeObserver?: ResizeObserver;
private readonly _pickingClock: Clock;
private readonly _onContextRestored: () => void;
private readonly _onContextLost: () => void;
private _resizeTimeout?: string | number | NodeJS.Timeout;
private _disposed = false;
/**
* Constructs a Giro3D Instance
*
* @param options - Options
*
* ```js
* const instance = new Instance({
* target: 'parentElement', // The id of the <div> to attach the instance
* crs: CoordinateSystem.epsg3857,
* });
* ```
*/
public constructor(options: InstanceOptions) {
super();
Object3D.DEFAULT_UP.set(0, 0, 1);
const target = options.target;
let viewerDiv: HTMLElement | null = null;
if (typeof target === 'string') {
viewerDiv = document.getElementById(target);
} else if (options.target instanceof HTMLElement) {
viewerDiv = options.target;
}
if (!viewerDiv || !(viewerDiv instanceof HTMLDivElement)) {
throw new Error('Invalid target parameter (must be a valid <div>)');
}
if (viewerDiv.childElementCount > 0) {
console.warn(
'Target element has children; Giro3D expects an empty element - this can lead to unexpected behaviors',
);
}
this._referenceCrs = options.crs;
this._viewport = viewerDiv;
// viewerDiv may have padding/borders, which is annoying when retrieving its size
// Wrap our canvas in a new div so we make sure the display
// is correct whatever the page layout is
// (especially when skrinking so there is no scrollbar/bleading)
this._viewport = document.createElement('div');
this._viewport.style.position = 'relative';
this._viewport.style.overflow = 'hidden'; // Hide overflow during resizing
this._viewport.style.width = '100%'; // Make sure it fills the space
this._viewport.style.height = '100%';
viewerDiv.appendChild(this._viewport);
this._engine = new C3DEngine(this._viewport, {
clearColor: options.backgroundColor,
renderer: options.renderer,
});
this._mainLoop = new MainLoop();
this._scene = options.scene3D || new Scene();
// will contain simple three objects that need to be taken into
// account, for example camera near / far calculation maybe it'll be
// better to do the contrary: having a group where *all* the Giro3D
// object will be added, and traverse all other objects for near far
// calculation but actually I'm not even sure near far calculation is
// worthy of this.
this._threeObjects = new Group();
this._threeObjects.name = 'threeObjects';
this._scene.add(this._threeObjects);
if (!options.scene3D) {
this._scene.matrixWorldAutoUpdate = false;
}
const windowSize = this._engine.getWindowSize();
this._view = new View({
crs: this._referenceCrs,
camera: options.camera,
width: windowSize.width,
height: windowSize.height,
});
this._view.addEventListener('change', () => this.notifyChange(this._view.camera));
this._entities = new Set();
if (window.ResizeObserver != null) {
this._resizeObserver = new ResizeObserver(() => {
this._updateRendererSize(this.viewport);
});
this._resizeObserver.observe(viewerDiv);
}
this._pickingClock = new Clock(false);
this._onContextRestored = this.onContextRestored.bind(this);
this._onContextLost = this.onContextLost.bind(this);
this.domElement.addEventListener('webglcontextlost', this._onContextLost);
this.domElement.addEventListener('webglcontextrestored', this._onContextRestored);
}
private onContextLost(): void {
this.getEntities().forEach(entity => {
if (isEntity3D(entity)) {
entity.onRenderingContextLost({ canvas: this.domElement });
}
});
}
private onContextRestored(): void {
this.getEntities().forEach(entity => {
if (isEntity3D(entity)) {
entity.onRenderingContextRestored({ canvas: this.domElement });
}
});
this.notifyChange();
}
/** Gets the canvas that this instance renders into. */
public get domElement(): HTMLCanvasElement {
return this._engine.renderer.domElement;
}
/** Gets the DOM element that contains the Giro3D viewport. */
public get viewport(): HTMLDivElement {
return this._viewport;
}
/** Gets the CRS used in this instance. */
public get coordinateSystem(): CoordinateSystem {
return this._referenceCrs;
}
/** Gets whether at least one entity is currently loading data. */
public get loading(): boolean {
const entities = this.getEntities();
return entities.some(e => e.loading);
}
/**
* Gets the progress (between 0 and 1) of the processing of the entire instance.
* This is the average of the progress values of all entities.
* Note: This value is only meaningful is {@link loading} is `true`.
* Note: if no entity is present in the instance, this will always return 1.
*/
public get progress(): number {
const entities = this.getEntities();
if (entities.length === 0) {
return 1;
}
const sum = entities.reduce((accum, entity) => accum + entity.progress, 0);
return sum / entities.length;
}
/** Gets the main loop */
public get mainLoop(): MainLoop {
return this._mainLoop;
}
/** Gets the rendering engine */
public get engine(): C3DEngine {
return this._engine;
}
/**
* Gets the rendering options.
*
* Note: you must call {@link notifyChange | notifyChange()} to take
* the changes into account.
*/
public get renderingOptions(): RenderingOptions {
return this._engine.renderingOptions;
}
/**
* Gets the underlying WebGL renderer.
*/
public get renderer(): WebGLRenderer {
return this._engine.renderer;
}
/**
* Gets the underlying CSS2DRenderer.
*/
public get css2DRenderer(): CSS2DRenderer {
return this._engine.labelRenderer;
}
/** Gets the [3D Scene](https://threejs.org/docs/#api/en/scenes/Scene). */
public get scene(): Scene {
return this._scene;
}
/** Gets the group containing native Three.js objects. */
public get threeObjects(): Group {
return this._threeObjects;
}
/** Gets the view. */
public get view(): View {
return this._view;
}
private _doUpdateRendererSize(div: HTMLDivElement): void {
this._engine.onWindowResize(div.clientWidth, div.clientHeight);
this.notifyChange(this._view.camera);
}
private _updateRendererSize(div: HTMLDivElement): void {
// Each time a canvas is resized, its content is erased and must be re-rendered.
// Since we are only interested in the last size, we must discard intermediate
// resizes to avoid the flickering effect due to the canvas going blank.
if (this._resizeTimeout != null) {
// If there's already a timeout in progress, discard it
clearTimeout(this._resizeTimeout);
}
// And add another one
this._resizeTimeout = setTimeout(() => this._doUpdateRendererSize(div), 50);
}
/**
* Dispose of this instance object. Free all memory used.
*
* Note: this *will not* dispose the following reusable objects:
* - controls (because they can be attached and detached). For THREE.js controls, use
* `controls.dispose()`
* - Inspectors, use `inspector.detach()`
* - any openlayers objects, please see their individual documentation
*
*/
public dispose(): void {
if (this._disposed) {
return;
}
this._disposed = true;
this.domElement.removeEventListener('webglcontextlost', this._onContextLost);
this.domElement.removeEventListener('webglcontextrestored', this._onContextRestored);
this._resizeObserver?.disconnect();
for (const obj of this.getObjects()) {
this.remove(obj);
}
this._scene.remove(this._threeObjects);
this._engine.dispose();
this._view.dispose();
this.viewport.remove();
this.dispatchEvent({ type: 'dispose' });
}
/**
* Add THREE object or Entity to the instance.
*
* If the object or entity has no parent, it will be added to the default tree (i.e under
* `.scene` for entities and under `.threeObjects` for regular Object3Ds.).
*
* If the object or entity already has a parent, then it will not be changed. Check that this
* parent is present in the scene graph (i.e has the `.scene` object as ancestor), otherwise it
* will **never be displayed**.
*
* @example
* // Add Map to instance
* instance.add(new Map(...);
*
* // Add Map to instance then wait for the map to be ready.
* instance.add(new Map(...).then(...);
* @param object - the object to add
* @returns a promise resolved with the new layer object when it is fully initialized
* or rejected if any error occurred.
*/
public async add<T extends Object3D | Entity>(object: T): Promise<T> {
if (object == null) {
throw new Error('object is undefined');
}
if (!isObject3D(object) && !isEntity(object)) {
throw new Error('object is not an instance of THREE.Object3D or Giro3D.Entity');
}
if (isObject3D(object)) {
// case of a simple THREE.js object3D
const object3d = object as Object3D;
if (object.parent == null) {
// Add to default scene graph
this._threeObjects.add(object3d);
}
this.notifyChange(object3d);
return object3d as T;
}
// We know it's an Entity
const entity = object as Entity;
const duplicate = this.getObjects(l => l.id === object.id);
if (duplicate.length > 0) {
throw new Error(`Invalid id '${object.id}': id already used`);
}
this._entities.add(entity);
await entity.initialize({
instance: this,
});
if (
isEntity3D(entity) &&
entity.object3d != null &&
entity.object3d.parent == null &&
entity.object3d !== this._scene
) {
// Add to default scene graph
this._scene.add(entity.object3d);
}
this.notifyChange(object, { needsRedraw: false });
this.dispatchEvent({ type: 'entity-added' });
return object;
}
/**
* Removes the entity or THREE object from the scene.
*
* @param object - the object to remove.
*/
public remove(object: Object3D | Entity): void {
if (isDisposable(object)) {
object.dispose();
}
if (isEntity(object)) {
if (isEntity3D(object)) {
object.object3d.removeFromParent();
}
this._entities.delete(object);
this.dispatchEvent({ type: 'entity-removed' });
} else if (isObject3D(object)) {
object.removeFromParent();
}
this.notifyChange(this._view.camera);
}
/**
* Notifies the scene it needs to be updated due to changes exterior to the
* scene itself (e.g. camera movement).
* non-interactive events (e.g: texture loaded)
*
* @param changeSources - The source(s) of the change. Might be a single object or an array.
* @param options - Notification options.
*/
public notifyChange(
changeSources: unknown | unknown[] = undefined,
options?: {
/**
* Should we render the scene?
* @defaultValue true
*/
needsRedraw?: boolean;
/**
* Should the update be immediate? If `false`, the update is deferred to the
* next animation frame.
* @defaultValue false
*/
immediate?: boolean;
},
): void {
this._mainLoop.scheduleUpdate(this, changeSources, options);
}
/**
* Get all top-level objects (entities and regular THREE objects), using an optional filter
* predicate.
*
* ```js
* // get all objects
* const allObjects = instance.getObjects();
* // get all object whose name includes 'foo'
* const fooObjects = instance.getObjects(obj => obj.name === 'foo');
* ```
* @param filter - the optional filter predicate.
* @returns an array containing the queried objects
*/
public getObjects(filter?: (obj: Object3D | Entity) => boolean): (Object3D | Entity)[] {
const result = [];
for (const obj of this._entities) {
if (!filter || filter(obj)) {
result.push(obj);
}
}
for (const obj of this._threeObjects.children) {
if (!filter || filter(obj)) {
result.push(obj);
}
}
return result;
}
/**
* Get all entities, with an optional predicate applied.
*
* ```js
* // get all entities
* const allEntities = instance.getEntities();
*
* // get all entities whose name contains 'building'
* const buildings = instance.getEntities(entity => entity.name.includes('building'));
* ```
* @param filter - the optional filter predicate
* @returns an array containing the queried entities
*/
public getEntities(filter?: (obj: Entity) => boolean): Entity[] {
const result = [];
for (const obj of this._entities) {
if (!filter || filter(obj)) {
result.push(obj);
}
}
return result;
}
/**
* Executes the rendering.
* Internal use only.
*
* @internal
*/
public render(): void {
this._engine.render(this._scene, this._view.camera);
}
/**
* Extract canvas coordinates from a mouse-event / touch-event.
*
* @param event - event can be a MouseEvent or a TouchEvent
* @param target - The target to set with the result.
* @param touchIdx - Touch index when using a TouchEvent (default: 0)
* @returns canvas coordinates (in pixels, 0-0 = top-left of the instance)
*/
public eventToCanvasCoords(
event: MouseEvent | TouchEvent,
target: Vector2,
touchIdx = 0,
): Vector2 {
if (window.TouchEvent != null && event instanceof TouchEvent) {
const touchEvent = event as TouchEvent;
const br = this.domElement.getBoundingClientRect();
return target.set(
touchEvent.touches[touchIdx].clientX - br.x,
touchEvent.touches[touchIdx].clientY - br.y,
);
}
const mouseEvent = event as MouseEvent;
if (mouseEvent.target === this.domElement) {
return target.set(mouseEvent.offsetX, mouseEvent.offsetY);
}
// Event was triggered outside of the canvas, probably a CSS2DElement
const br = this.domElement.getBoundingClientRect();
return target.set(mouseEvent.clientX - br.x, mouseEvent.clientY - br.y);
}
/**
* Extract normalized coordinates (NDC) from a mouse-event / touch-event.
*
* @param event - event can be a MouseEvent or a TouchEvent
* @param target - The target to set with the result.
* @param touchIdx - Touch index when using a TouchEvent (default: 0)
* @returns NDC coordinates (x and y are [-1, 1])
*/
public eventToNormalizedCoords(
event: MouseEvent | TouchEvent,
target: Vector2,
touchIdx = 0,
): Vector2 {
return this.canvasToNormalizedCoords(
this.eventToCanvasCoords(event, target, touchIdx),
target,
);
}
/**
* Convert canvas coordinates to normalized device coordinates (NDC).
*
* @param canvasCoords - (in pixels, 0-0 = top-left of the instance)
* @param target - The target to set with the result.
* @returns NDC coordinates (x and y are [-1, 1])
*/
public canvasToNormalizedCoords(canvasCoords: Vector2, target: Vector2): Vector2 {
target.x = 2 * (canvasCoords.x / this._view.width) - 1;
target.y = -2 * (canvasCoords.y / this._view.height) + 1;
return target;
}
/**
* Convert NDC coordinates to canvas coordinates.
*
* @param ndcCoords - The NDC coordinates to convert
* @param target - The target to set with the result.
* @returns canvas coordinates (in pixels, 0-0 = top-left of the instance)
*/
public normalizedToCanvasCoords(ndcCoords: Vector2, target: Vector2): Vector2 {
target.x = (ndcCoords.x + 1) * 0.5 * this._view.width;
target.y = (ndcCoords.y - 1) * -0.5 * this._view.height;
return target;
}
/**
* Gets the object by it's id property.
*
* @param objectId - Object id
* @returns Object found
* @throws Error if object cannot be found
*/
private objectIdToObject(objectId: string | number): Object3D | Entity {
const lookup = this.getObjects(l => l.id === objectId);
if (!lookup.length) {
throw new Error(`Invalid object id used as where argument (value = ${objectId})`);
}
return lookup[0];
}
/**
* Return objects from some layers/objects3d under the mouse in this instance.
*
* @param mouseOrEvt - mouse position in window coordinates, i.e [0, 0] = top-left,
* or `MouseEvent` or `TouchEvent`
* @param options - Options
* @returns An array of objects. Each element contains at least an object
* property which is the Object3D under the cursor. Then depending on the queried
* layer/source, there may be additionnal properties (coming from THREE.Raycaster
* for instance).
* If `options.pickFeatures` if `true`, `features` property may be set.
*
* ```js
* instance.pickObjectsAt(mouseEvent)
* instance.pickObjectsAt(mouseEvent, { radius: 1, where: [entity0, entity1] })
* instance.pickObjectsAt(mouseEvent, { radius: 3, where: [entity0] })
* ```
*/
public pickObjectsAt(
mouseOrEvt: Vector2 | MouseEvent | TouchEvent,
options: PickObjectsAtOptions = {},
): PickResult[] {
this.dispatchEvent({ type: 'picking-start' });
this._pickingClock.start();
let results: PickResult[] = [];
const sources =
options.where && options.where.length > 0 ? [...options.where] : this.getObjects();
const mouse =
mouseOrEvt instanceof Event
? this.eventToCanvasCoords(mouseOrEvt, vectors.evtToCanvas)
: mouseOrEvt;
const radius = options.radius ?? 0;
const limit = options.limit ?? Infinity;
const sortByDistance = options.sortByDistance ?? false;
const pickFeatures = options.pickFeatures ?? false;
for (const source of sources) {
const object = typeof source === 'string' ? this.objectIdToObject(source) : source;
if (!(object as Object3D | Entity3D).visible) {
continue;
}
const pickOptions = {
...options,
radius,
limit: limit - results.length,
vec2: vectors.pickVec2,
sortByDistance: false,
};
if (sortByDistance) {
pickOptions.limit = Infinity;
pickOptions.pickFeatures = false;
}
if (isPickable(object)) {
const res = object.pick(mouse, pickOptions);
results.push(...res);
} else if ((object as Object3D).isObject3D) {
const res = pickObjectsAt(this, mouse, object as Object3D, pickOptions);
results.push(...res);
}
if (results.length >= limit && !sortByDistance) {
break;
}
}
if (sortByDistance) {
results.sort((a, b) => a.distance - b.distance);
if (limit !== Infinity) {
results = results.slice(0, limit);
}
}
if (pickFeatures) {
const pickFeaturesOptions = options;
results.forEach(result => {
if (result.entity && isPickableFeatures(result.entity)) {
result.entity.pickFeaturesFrom(result, pickFeaturesOptions);
} else if (result.object != null && isPickableFeatures(result.object)) {
result.object.pickFeaturesFrom(result, pickFeaturesOptions);
}
});
}
const elapsed = this._pickingClock.getElapsedTime();
this._pickingClock.stop();
this.dispatchEvent({ type: 'picking-end', elapsed, results });
return results;
}
public getMemoryUsage(): MemoryUsageReport {
const context: GetMemoryUsageContext = {
renderer: this.renderer,
objects: new globalThis.Map(),
};
for (const entity of this._entities) {
if (isEntity3D(entity)) {
entity.getMemoryUsage(context);
}
}
this.threeObjects.traverse(obj => {
getObject3DMemoryUsage(context, obj);
});
GlobalRenderTargetPool.getMemoryUsage(context);
GlobalCache.getMemoryUsage(context);
return aggregateMemoryUsage(context);
}
}
export default Instance;