@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
988 lines (838 loc) • 34.1 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type GUI from 'lil-gui';
import type Feature from 'ol/Feature';
import type { Circle, Geometry, MultiPoint, Point, SimpleGeometry } from 'ol/geom';
import type { EventDispatcher, Object3D } from 'three';
import { type Coordinate } from 'ol/coordinate';
import { getCenter } from 'ol/extent';
import { LineString, MultiLineString, MultiPolygon, Polygon } from 'ol/geom';
import { Box3, Group, Sphere, Vector3 } from 'three';
import type ElevationProvider from '../core/ElevationProvider';
import type { FeatureExtrusionOffset, FeatureExtrusionOffsetCallback } from '../core/FeatureTypes';
import type Extent from '../core/geographic/Extent';
import type HasDefaultPointOfView from '../core/HasDefaultPointOfView';
import type Instance from '../core/Instance';
import type Layer from '../core/layer/Layer';
import type PointOfView from '../core/PointOfView';
import type {
BaseOptions,
LineOptions,
PointOptions,
PolygonOptions,
} from '../renderer/geometries/GeometryConverter';
import type LineStringMesh from '../renderer/geometries/LineStringMesh';
import type MultiLineStringMesh from '../renderer/geometries/MultiLineStringMesh';
import type PointMesh from '../renderer/geometries/PointMesh';
import type SimpleGeometryMesh from '../renderer/geometries/SimpleGeometryMesh';
import type SurfaceMesh from '../renderer/geometries/SurfaceMesh';
import type { FeatureSource, FeatureSourceEventMap } from '../sources/FeatureSource';
import type { MeshUserData } from './FeatureCollection';
import type { Tile } from './Map';
import {
mapGeometry,
type FeatureStyle,
type FeatureStyleCallback,
type LineMaterialGenerator,
type PointMaterialGenerator,
type SurfaceMaterialGenerator,
} from '../core/FeatureTypes';
import { isElevationLayer } from '../core/layer/ElevationLayer';
import EntityInspector from '../gui/EntityInspector';
import EntityPanel from '../gui/EntityPanel';
import GeometryConverter from '../renderer/geometries/GeometryConverter';
import { isLineStringMesh } from '../renderer/geometries/LineStringMesh';
import { isMultiPolygonMesh } from '../renderer/geometries/MultiPolygonMesh';
import { isPointMesh } from '../renderer/geometries/PointMesh';
import { isPolygonMesh } from '../renderer/geometries/PolygonMesh';
import { isSimpleGeometryMesh } from '../renderer/geometries/SimpleGeometryMesh';
import { isSurfaceMesh } from '../renderer/geometries/SurfaceMesh';
import { computeDistanceToFitSphere, computeZoomToFitSphere } from '../renderer/View';
import OLUtils from '../utils/OpenLayersUtils';
import { isOrthographicCamera, isPerspectiveCamera } from '../utils/predicates';
import { nonNull } from '../utils/tsutils';
import Entity3D from './Entity3D';
const tmpSphere = new Sphere();
interface MapLikeEventMap {
'elevation-loaded': { tile: Tile };
'layer-added': { layer: Layer };
'layer-removed': { layer: Layer };
'layer-visibility-changed': { layer: Layer };
'tile-created': { tile: Tile };
'tile-deleted': { tile: Tile };
}
/**
* Map-like object to drape features onto.
*/
export interface MapLike extends ElevationProvider, EventDispatcher<MapLikeEventMap> {
traverseTiles(callback: (tile: Tile) => void): void;
}
/**
* How the geometry should be draped on the terrain:
* - `per-feature`: the same elevation offset is applied to the entire feature.
* Suitable for level geometries, such as buildings, lakes, etc.
* - `per-vertex`: the elevation is applied to each vertex independently. Suitable for
* lines that must follow the terrain, such as roads.
* - `none`: no draping is done, the elevation of the feature is used as is. Suitable for
* geometries that should not be draped on the terrain, such as flight paths or flying objects,
* or for 3D geometries that already have a vertical elevation.
*
* Note: that `Point` geometries, having only one coordinate, will automatically use the `per-feature` mode.
*/
export type DrapingMode = 'per-feature' | 'per-vertex' | 'none';
/**
* A function to determine the {@link DrapingMode} for each feature.
*/
export type DrapingModeFunction = (feature: Feature) => DrapingMode;
/**
* Either returns the same geometry if it already has a XYZ layout, or create an equivalent geometry in the XYZ layout.
*/
function cloneAsXYZIfRequired<
G extends Polygon | LineString | MultiPoint | MultiLineString | MultiPolygon,
>(geometry: G): G {
if (geometry.getLayout() === 'XYZ') {
// No need to clone.
return geometry;
}
const stride = geometry.getStride();
const vertexCount = geometry.getFlatCoordinates().length / stride;
const flat = new Array<number>(vertexCount * 3);
switch (geometry.getType()) {
case 'LineString':
return new LineString(flat, 'XYZ') as G;
case 'Polygon': {
const ends = (geometry as Polygon).getEnds().map(end => (end / stride) * 3);
return new Polygon(flat, 'XYZ', ends) as G;
}
case 'MultiLineString': {
const ends = (geometry as MultiLineString).getEnds().map(end => (end / stride) * 3);
return new MultiLineString(flat, 'XYZ', ends) as G;
}
case 'MultiPolygon': {
const endss = (geometry as MultiPolygon)
.getEndss()
.map(ends => ends.map(end => (end / stride) * 3));
return new MultiPolygon(flat, 'XYZ', endss) as G;
}
}
throw new Error();
}
function getRootMesh(obj: Object3D): SimpleGeometryMesh<MeshUserData> | null {
let current = obj;
while (isSimpleGeometryMesh<MeshUserData>(current.parent)) {
current = current.parent;
}
if (isSimpleGeometryMesh<MeshUserData>(current)) {
return current;
}
return null;
}
function getFeatureElevation(geometry: SimpleGeometry, provider: ElevationProvider): number {
let center: Coordinate;
if (geometry.getType() === 'Point') {
center = (geometry as Point).getCoordinates();
} else if (geometry.getType() === 'Circle') {
center = (geometry as Circle).getCenter();
} else {
center = getCenter(geometry.getExtent());
}
const [x, y] = center;
const sample = provider.getElevationFast(x, y);
return sample?.elevation ?? 0;
}
type SupportedPerVertexGeometry =
| Polygon
| LineString
| MultiLineString
| MultiPoint
| MultiPolygon;
type SupportedGeometry = Point | SupportedPerVertexGeometry;
function isGeometrySupported(g: Geometry): g is SupportedGeometry {
switch (g.getType()) {
case 'Point':
case 'LineString':
case 'Polygon':
case 'MultiPoint':
case 'MultiLineString':
case 'MultiPolygon':
return true;
default:
return false;
}
}
function applyPerVertexDraping<G extends SupportedPerVertexGeometry>(
geometry: G,
provider: ElevationProvider,
): G {
const coordinates = geometry.getFlatCoordinates();
const stride = geometry.getStride();
// We have to possibly clone the geometry because OpenLayers does
// not allow changing the layout of an existing geometry, leading to issues.
const clone = cloneAsXYZIfRequired(geometry.clone());
const coordinateCount = coordinates.length / stride;
const xyz = new Array<number>(coordinateCount * 3);
let k = 0;
for (let i = 0; i < coordinates.length; i += stride) {
const x = coordinates[i + 0];
const y = coordinates[i + 1];
const sample = provider.getElevationFast(x, y);
const z = sample?.elevation ?? 0;
xyz[k + 0] = x;
xyz[k + 1] = y;
xyz[k + 2] = z;
k += 3;
}
clone.setFlatCoordinates('XYZ', xyz);
return clone as G;
}
export interface DrapedFeatureCollectionOptions {
/**
* The data source.
*/
source: FeatureSource;
/**
* The minimum tile LOD (level of detail) to display the features.
* If zero, then features are always displayed, since root tiles have LOD zero.
* @defaultValue 0
*/
minLod?: number;
/**
* How is draping computed for each feature.
*/
drapingMode?: DrapingMode | DrapingModeFunction;
/**
* An style or a callback returning a style to style the individual features.
* If an object is used, the informations it contains will be used to style every
* feature the same way. If a function is provided, it will be called with the feature.
* This allows to individually style each feature.
*/
style?: FeatureStyle | FeatureStyleCallback;
/**
* If set, this will cause 2D features to be extruded of the corresponding amount.
* If a single value is given, it will be used for all the vertices of every feature.
* If an array is given, each extruded vertex will use the corresponding value.
* If a callback is given, it allows to extrude each feature individually.
*/
extrusionOffset?: FeatureExtrusionOffset | FeatureExtrusionOffsetCallback;
/**
* An optional material generator for shaded surfaces.
*/
shadedSurfaceMaterialGenerator?: SurfaceMaterialGenerator;
/**
* An optional material generator for unshaded surfaces.
*/
unshadedSurfaceMaterialGenerator?: SurfaceMaterialGenerator;
/**
* An optional material generator for lines.
*/
lineMaterialGenerator?: LineMaterialGenerator;
/**
* An optional material generator for points.
*/
pointMaterialGenerator?: PointMaterialGenerator;
}
function getStableFeatureId(feature: Feature): string {
const existing = feature.getId();
if (existing != null) {
return existing.toString();
}
const fid = feature.get('fid');
if (fid != null) {
return `${fid}`;
}
throw new Error('not implemented');
}
type EventHandler<T> = (e: T) => void;
interface ObjectOptions {
castShadow: boolean;
receiveShadow: boolean;
}
interface FeaturesEntry {
feature: Feature;
originalZ: number;
extent: Extent;
mesh: SimpleGeometryMesh | undefined;
sampledLod: number;
}
/**
* Loads 3D features from a {@link FeatureSource} and displays them on top
* of a map or map-like entity, by taking terrain into account.
*
* To drape features on custom entities, they must implement the {@link MapLike} interface.
*
* ## Performance warning
*
* This entity is experimental and might suffer performance issues when loading many features.
* Notably be careful when setting the {@link minLod} value. If this value is too low, this could cause
* many features to be loaded (especially when used with streamed data, such as WFS servers).
*
* It is recommended to experiment with a high `minLod` value then decrease it progressively.
*
* @experimental
*/
export default class DrapedFeatureCollection extends Entity3D {
public override type = 'DrapedFeatureCollection' as const;
public readonly isDrapedFeatureCollection = true as const;
private _map: MapLike | null = null;
private readonly _drapingMode: DrapingMode | DrapingModeFunction;
private readonly _geometryConverter: GeometryConverter<MeshUserData>;
private readonly _activeTiles = new Map<Tile['id'], Tile>();
private readonly _objectOptions: ObjectOptions = {
castShadow: false,
receiveShadow: false,
};
private readonly _extrusionCallback:
| FeatureExtrusionOffset
| FeatureExtrusionOffsetCallback
| undefined;
private readonly _features: Map<string, FeaturesEntry> = new Map();
private readonly _source: FeatureSource;
private readonly _eventHandlers: {
onTileCreated: EventHandler<MapLikeEventMap['tile-created']>;
onTileDeleted: EventHandler<MapLikeEventMap['tile-deleted']>;
onLayerAdded: EventHandler<MapLikeEventMap['layer-added']>;
onLayerRemoved: EventHandler<MapLikeEventMap['layer-removed']>;
onLayerVisibilityChanged: EventHandler<MapLikeEventMap['layer-visibility-changed']>;
onElevationLoaded: EventHandler<MapLikeEventMap['elevation-loaded']>;
onSourceUpdated: EventHandler<FeatureSourceEventMap['updated']>;
onTextureLoaded: () => void;
};
private readonly _style: FeatureStyle | FeatureStyleCallback | undefined;
public get loadedFeatures(): number {
return this._features.size;
}
private _shouldCleanup = false;
private _sortedTiles: Tile[] | null = null;
private _minLod = 0;
/**
* The minimum tile LOD (level of detail) to display the features.
* If zero, then features are always displayed, since root tiles have LOD zero.
*/
public get minLod(): number {
return this._minLod;
}
public set minLod(v: number) {
this._minLod = v >= 0 ? v : 0;
}
public constructor(options: DrapedFeatureCollectionOptions) {
super(new Group());
this._drapingMode = options.drapingMode ?? 'per-vertex';
this._extrusionCallback = options.extrusionOffset;
this._source = options.source;
this._style = options.style;
this._minLod = options.minLod ?? this._minLod;
this._eventHandlers = {
onTileCreated: this.onTileCreated.bind(this),
onTileDeleted: this.onTileDeleted.bind(this),
onElevationLoaded: this.onElevationLoaded.bind(this),
onTextureLoaded: this.notifyChange.bind(this),
onSourceUpdated: this.onSourceUpdated.bind(this),
onLayerAdded: this.onLayerAdded.bind(this),
onLayerRemoved: this.onLayerRemoved.bind(this),
onLayerVisibilityChanged: this.onLayerVisibilityChanged.bind(this),
};
this._geometryConverter = new GeometryConverter<MeshUserData>({
shadedSurfaceMaterialGenerator: options.shadedSurfaceMaterialGenerator,
unshadedSurfaceMaterialGenerator: options.unshadedSurfaceMaterialGenerator,
lineMaterialGenerator: options.lineMaterialGenerator,
pointMaterialGenerator: options.pointMaterialGenerator,
});
this._geometryConverter.addEventListener(
'texture-loaded',
this._eventHandlers.onTextureLoaded,
);
this._source.addEventListener('updated', this._eventHandlers.onSourceUpdated);
}
public traverseGeometries(callback: (geom: SimpleGeometryMesh<MeshUserData>) => void): void {
this.traverse(obj => {
if (isSimpleGeometryMesh<MeshUserData>(obj)) {
callback(obj);
}
});
}
/**
* Updates the styles of the given objects, or all objects if unspecified.
* @param objects - The objects to update.
*/
public updateStyles(
objects?: (SimpleGeometryMesh<MeshUserData> | SurfaceMesh<MeshUserData>)[],
): void {
if (objects != null) {
objects.forEach(obj => {
if (obj.userData.parentEntity === this) {
this.updateStyle(getRootMesh(obj));
}
});
} else {
this._features.forEach(v => {
if (v.mesh) {
this.updateStyle(v.mesh);
}
});
}
// Make sure new materials have the correct opacity
this.updateOpacity();
this.notifyChange(this);
}
private updateStyle(obj: SimpleGeometryMesh<MeshUserData> | null): void {
if (!obj) {
return;
}
const feature = obj.userData.feature as Feature;
const style = this.getStyle(feature);
const commonOptions: BaseOptions = {
origin: obj.geometryOrigin,
};
switch (obj.type) {
case 'PointMesh':
this._geometryConverter.updatePointMesh(obj as PointMesh<MeshUserData>, {
...commonOptions,
...style?.point,
});
break;
case 'PolygonMesh':
case 'MultiPolygonMesh':
{
const extrusionOffset = this.getExtrusionOffset(feature);
const options = {
...commonOptions,
...style,
extrusionOffset,
};
if (isPolygonMesh(obj)) {
this._geometryConverter.updatePolygonMesh(obj, options);
} else if (isMultiPolygonMesh(obj)) {
this._geometryConverter.updateMultiPolygonMesh(obj, options);
}
}
break;
case 'LineStringMesh':
this._geometryConverter.updateLineStringMesh(obj as LineStringMesh<MeshUserData>, {
...commonOptions,
...style?.stroke,
});
break;
case 'MultiLineStringMesh':
this._geometryConverter.updateMultiLineStringMesh(
obj as MultiLineStringMesh<MeshUserData>,
{
...commonOptions,
...style?.stroke,
},
);
break;
}
// Since changing the style of the feature might create additional objects,
// we have to use this method again.
this.prepare(obj, feature, style);
}
private updateObjectOption<K extends keyof ObjectOptions>(
key: K,
value: ObjectOptions[K],
): void {
if (this._objectOptions[key] !== value) {
this._objectOptions[key] = value;
this.traverseGeometries(mesh => {
mesh.traverse(obj => {
obj.castShadow = this._objectOptions.castShadow;
obj.receiveShadow = this._objectOptions.receiveShadow;
});
});
this.notifyChange(this);
}
}
/**
* Toggles the `.castShadow` property on objects generated by this entity.
*
* Note: shadow maps require normal attributes on objects.
*/
public get castShadow(): boolean {
return this._objectOptions.castShadow;
}
public set castShadow(v: boolean) {
this.updateObjectOption('castShadow', v);
}
/**
* Toggles the `.receiveShadow` property on objects generated by this entity.
*
* Note: shadow maps require normal attributes on objects.
*/
public get receiveShadow(): boolean {
return this._objectOptions.receiveShadow;
}
public set receiveShadow(v: boolean) {
this.updateObjectOption('receiveShadow', v);
}
private onSourceUpdated(): void {
this._features.forEach(v => {
v.mesh?.dispose();
v.mesh?.removeFromParent();
});
this._features.clear();
for (const tile of [...this._activeTiles.values()]) {
this.registerTile(tile, true);
}
}
public override async preprocess(): Promise<void> {
await this._source.initialize({ targetCoordinateSystem: this.instance.coordinateSystem });
}
/**
* Sets the draping target.
*/
public attach(map: MapLike): this {
if (this._map != null) {
throw new Error('a map is already attached to this entity');
}
this._map = map;
map.addEventListener('tile-created', this._eventHandlers.onTileCreated);
map.addEventListener('tile-deleted', this._eventHandlers.onTileDeleted);
map.addEventListener('elevation-loaded', this._eventHandlers.onElevationLoaded);
map.addEventListener('layer-added', this._eventHandlers.onLayerAdded);
map.addEventListener('layer-removed', this._eventHandlers.onLayerRemoved);
map.addEventListener(
'layer-visibility-changed',
this._eventHandlers.onLayerVisibilityChanged,
);
map.traverseTiles(tile => {
this.registerTile(tile);
});
return this;
}
private getSortedTiles(): Tile[] {
if (this._sortedTiles == null) {
this._sortedTiles = [...this._activeTiles.values()];
this._sortedTiles.sort((t0, t1) => t0.lod - t1.lod);
}
return this._sortedTiles;
}
public detach(): this {
if (this._map == null) {
throw new Error('no map is attached to this entity');
}
this._map.removeEventListener('tile-created', this._eventHandlers.onTileCreated);
this._map.removeEventListener('tile-deleted', this._eventHandlers.onTileDeleted);
this._map.removeEventListener('elevation-loaded', this._eventHandlers.onElevationLoaded);
this._map.traverseTiles(tile => {
this.unregisterTile(tile);
});
this._map = null;
return this;
}
public override updateVisibility(): void {
super.updateVisibility();
if (this.visible) {
this.registerAllTiles();
}
}
private onLayerAdded({ layer }: MapLikeEventMap['layer-added']): void {
if (isElevationLayer(layer)) {
this.registerAllTiles(true);
}
}
private onLayerRemoved({ layer }: MapLikeEventMap['layer-removed']): void {
if (isElevationLayer(layer)) {
this.registerAllTiles(true);
}
}
private onLayerVisibilityChanged({ layer }: MapLikeEventMap['layer-visibility-changed']): void {
if (isElevationLayer(layer)) {
this.registerAllTiles(true);
}
}
private onTileCreated({ tile }: MapLikeEventMap['tile-created']): void {
this.registerTile(tile);
}
private onTileDeleted({ tile }: MapLikeEventMap['tile-deleted']): void {
this.unregisterTile(tile);
}
private onElevationLoaded({ tile }: MapLikeEventMap['elevation-loaded']): void {
this.registerTile(tile, true);
}
private registerAllTiles(forceRecreateMeshes = false): void {
if (this._map) {
this._map.traverseTiles(tile => {
this.registerTile(tile, forceRecreateMeshes);
});
}
}
private registerTile(tile: Tile, forceRecreateMeshes = false): void {
if (!this.visible || this.frozen) {
return;
}
if (!this._activeTiles.has(tile.id) || forceRecreateMeshes) {
this._activeTiles.set(tile.id, tile);
this._sortedTiles = null;
if (tile.lod >= this._minLod) {
this.loadFeaturesOnExtent(tile.extent).then(features => {
if (this._activeTiles.has(tile.id)) {
this.loadMeshes(features, tile.lod, forceRecreateMeshes);
}
});
}
}
}
private loadMeshes(
features: Readonly<Feature[]>,
lod: number,
forceRecreateMeshes = false,
): void {
for (const feature of features) {
const geometry = feature.getGeometry();
if (geometry) {
const id = getStableFeatureId(feature);
if (!this._features.has(id)) {
const extent = OLUtils.fromOLExtent(
geometry.getExtent(),
this.instance.coordinateSystem,
);
this._features.set(id, {
feature,
mesh: undefined,
originalZ: 0,
extent,
sampledLod: lod,
});
}
const existing = nonNull(this._features.get(id));
if (forceRecreateMeshes || !existing.mesh || existing.sampledLod < lod) {
this.loadFeatureMesh(id, existing);
existing.sampledLod = lod;
}
}
}
this.notifyChange();
}
private prepare(
mesh: SimpleGeometryMesh<MeshUserData>,
feature: Feature,
style: FeatureStyle | undefined,
): void {
mesh.traverse(obj => {
obj.userData.feature = feature;
obj.userData.style = style;
obj.castShadow = this._objectOptions.castShadow;
obj.receiveShadow = this._objectOptions.receiveShadow;
this.assignRenderOrder(obj);
});
this.onObjectCreated(mesh);
}
private getPointOptions(style?: FeatureStyle): PointOptions {
const pointStyle = style?.point;
return {
color: pointStyle?.color,
pointSize: pointStyle?.pointSize,
renderOrder: pointStyle?.renderOrder,
sizeAttenuation: pointStyle?.sizeAttenuation,
depthTest: pointStyle?.depthTest,
image: pointStyle?.image,
opacity: pointStyle?.opacity,
};
}
private getExtrusionOffset(feature: Feature): FeatureExtrusionOffset | undefined {
let extrusionOffset: FeatureExtrusionOffset | undefined = undefined;
if (this._extrusionCallback != null) {
extrusionOffset =
typeof this._extrusionCallback === 'function'
? this._extrusionCallback(feature)
: this._extrusionCallback;
}
return extrusionOffset;
}
private getPolygonOptions(feature: Feature, style?: FeatureStyle): PolygonOptions {
return {
fill: style?.fill,
stroke: style?.stroke,
extrusionOffset: this.getExtrusionOffset(feature),
};
}
private getLineOptions(style?: FeatureStyle): LineOptions {
return {
...style?.stroke,
};
}
private getStyle(feature: Feature): FeatureStyle | undefined {
if (typeof this._style === 'function') {
return this._style(feature);
}
return this._style;
}
private createMesh(feature: Feature, geometry: SimpleGeometry): SimpleGeometryMesh | undefined {
const style = this.getStyle(feature);
const converter = this._geometryConverter;
const result = mapGeometry<SimpleGeometryMesh>(geometry, {
processPoint: p => converter.build(p, this.getPointOptions(style)),
processPolygon: p => converter.build(p, this.getPolygonOptions(feature, style)),
processLineString: p => converter.build(p, this.getLineOptions(style)),
processMultiPolygon: p => converter.build(p, this.getPolygonOptions(feature, style)),
processMultiLineString: p => converter.build(p, this.getLineOptions(style)),
fallback: g => {
throw new Error(`unsupported geometry type: ${g.getType()}`);
},
});
if (result) {
this.prepare(result, feature, style);
}
return result;
}
// We override this because the render order of the features depends on their style,
// so we have to cumulate that with the render order of the entity.
protected override assignRenderOrder(obj: Object3D): void {
const renderOrder = this.renderOrder;
// Note that the final render order of the mesh is the sum of
// the entity's render order and the style's render order(s).
if (isSurfaceMesh<MeshUserData>(obj)) {
const relativeRenderOrder = obj.userData.style?.fill?.renderOrder ?? 0;
obj.renderOrder = renderOrder + relativeRenderOrder;
} else if (isLineStringMesh<MeshUserData>(obj)) {
const relativeRenderOrder = obj.userData.style?.stroke?.renderOrder ?? 0;
obj.renderOrder = renderOrder + relativeRenderOrder;
} else if (isPointMesh<MeshUserData>(obj)) {
const relativeRenderOrder = obj.userData.style?.point?.renderOrder ?? 0;
obj.renderOrder = renderOrder + relativeRenderOrder;
}
}
private getDrapingMode(feature: Feature): DrapingMode {
if (typeof this._drapingMode === 'function') {
return this._drapingMode(feature);
}
return this._drapingMode;
}
private loadFeatureMesh(id: string, existing: FeaturesEntry): void {
const geometry = existing.feature.getGeometry();
if (geometry == null) {
console.warn(`No geometry for feature ${id}`);
return;
}
if (!isGeometrySupported(geometry)) {
console.warn(`Unsupported geometry type for feature ${id} (${geometry.getType()})`);
return;
}
const drapingMode = this.getDrapingMode(existing.feature);
let actualGeometry = geometry;
let shouldReplaceMesh = false;
let verticalOffset = 0;
const map = nonNull(this._map);
if (
drapingMode === 'per-feature' ||
(drapingMode === 'per-vertex' && geometry.getType() === 'Point')
) {
// Note that point is necessarily per feature, since there is only one vertex
actualGeometry = geometry;
verticalOffset = getFeatureElevation(geometry, map);
} else if (drapingMode === 'per-vertex') {
shouldReplaceMesh = true;
actualGeometry = applyPerVertexDraping(geometry as SupportedPerVertexGeometry, map);
}
// We have to entirely recreate the mesh because
// the vertices will have different elevations
if (shouldReplaceMesh && existing.mesh) {
existing.mesh.dispose();
existing.mesh.removeFromParent();
existing.mesh = undefined;
}
// The mesh needs to be (re)created
if (existing.mesh === undefined) {
const newMesh = this.createMesh(existing.feature, actualGeometry);
existing.originalZ = newMesh?.position.z ?? 0;
if (newMesh) {
existing.mesh = newMesh;
existing.mesh.name = id;
this.object3d.add(existing.mesh);
}
}
if (existing.mesh) {
// When a single elevation value is applied to the entire mesh,
// then we can simply translate the Mesh itself, rather than recreate it.
if (verticalOffset !== 0) {
existing.mesh.position.setZ(existing.originalZ + verticalOffset);
}
existing.mesh.updateMatrix();
existing.mesh.updateMatrixWorld(true);
}
}
private unregisterTile(tile: Tile): void {
const actuallyDeleted = this._activeTiles.delete(tile.id);
if (actuallyDeleted) {
this._sortedTiles = null;
this._shouldCleanup = true;
this.notifyChange(this);
}
}
private async loadFeaturesOnExtent(extent: Extent): Promise<readonly Feature[]> {
const result = await this._source.getFeatures({ extent });
return result.features;
}
public override postUpdate(): void {
if (this._shouldCleanup) {
this._shouldCleanup = false;
this.cleanup();
}
}
public cleanup(): void {
const sorted = this.getSortedTiles();
const features = [...this._features.values()];
for (const block of features) {
let stillUsed = false;
for (const tile of sorted) {
if (tile.lod >= this._minLod && tile.extent.intersectsExtent(block.extent)) {
stillUsed = true;
break;
}
}
if (!stillUsed && block.mesh) {
block.mesh.dispose();
block.mesh.removeFromParent();
block.mesh = undefined;
}
}
}
public override getDefaultPointOfView({
camera,
}: Parameters<HasDefaultPointOfView['getDefaultPointOfView']>[0]): ReturnType<
HasDefaultPointOfView['getDefaultPointOfView']
> {
const bounds = new Box3().setFromObject(this.object3d);
const sphere = bounds.getBoundingSphere(tmpSphere);
let orthographicZoom = 1;
let distance: number;
if (isOrthographicCamera(camera)) {
orthographicZoom = computeZoomToFitSphere(camera, sphere.radius);
// In orthographic camera, the actual distance has no effect on the size
// of objects, but it does have an effect on clipping planes.
// Let's compute a reasonable distance to put the camera.
distance = sphere.radius;
} else if (isPerspectiveCamera(camera)) {
distance = computeDistanceToFitSphere(camera, sphere.radius);
} else {
return null;
}
// To avoid a perfectly vertical camera axis that would cause a gimbal lock.
const VERTICAL_OFFSET = 0.01;
const origin = new Vector3(sphere.center.x, sphere.center.y - VERTICAL_OFFSET, distance);
const target = sphere.center;
const result: PointOfView = { origin, target, orthographicZoom };
return Object.freeze(result);
}
public override dispose(): void {
this.detach();
this._geometryConverter.dispose({ disposeMaterials: true, disposeTextures: true });
this.traverseMeshes(mesh => {
mesh.geometry.dispose();
});
}
}
class DrapedFeatureCollectionInspector extends EntityInspector<DrapedFeatureCollection> {
public constructor(gui: GUI, instance: Instance, entity: DrapedFeatureCollection) {
super(gui, instance, entity, {
visibility: true,
opacity: true,
boundingBoxColor: true,
boundingBoxes: true,
});
this.addController(entity, 'loadedFeatures');
}
}
EntityPanel.registerInspector('DrapedFeatureCollection', DrapedFeatureCollectionInspector);