@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
413 lines (360 loc) • 14.7 kB
text/typescript
import type GUI from 'lil-gui';
import type { AxesHelper, GridHelper, Side } from 'three';
import { Color } from 'three';
import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
import type Instance from '../core/Instance';
import TileMesh from '../core/TileMesh';
import type Map from '../entities/Map';
import type { BoundingBoxHelper } from '../helpers/Helpers';
import Helpers from '../helpers/Helpers';
import RenderingState from '../renderer/RenderingState';
import { isMaterial } from '../utils/predicates';
import ColorimetryPanel from './ColorimetryPanel';
import ContourLinePanel from './ContourLinePanel';
import EntityInspector from './EntityInspector';
import GraticulePanel from './GraticulePanel';
import LayerInspector from './LayerInspector';
import MapLightingPanel from './MapLightingPanel';
import MapTerrainPanel from './MapTerrainPanel';
function createTileLabel() {
const text = document.createElement('div');
text.style.color = '#FFFFFF';
text.style.padding = '0.2em 1em';
text.style.textShadow = '2px 2px 2px black';
text.style.textAlign = 'center';
text.style.fontSize = '12px';
text.style.backgroundColor = 'rgba(0,0,0,0.5)';
return text;
}
type Sidedness = 'Front' | 'Back' | 'DoubleSide';
const sides: Sidedness[] = ['Front', 'Back', 'DoubleSide'];
class MapInspector extends EntityInspector<Map> {
/** Toggle the frozen property of the map. */
frozen: boolean;
showGrid: boolean;
renderState: string;
layerCount: number;
background: Color;
backgroundOpacity: number;
extentColor: Color;
showExtent: boolean;
showTileInfo: boolean;
extentHelper: BoundingBoxHelper | null;
labels: globalThis.Map<number, CSS2DObject>;
lightingPanel: MapLightingPanel;
contourLinePanel: ContourLinePanel;
colorimetryPanel: ColorimetryPanel;
graticulePanel: GraticulePanel;
/** The layer folder. */
layerFolder: GUI;
layers: LayerInspector[];
private _fillLayersCb: () => void;
grid?: GridHelper;
axes?: AxesHelper;
reachableTiles: number;
visibleTiles: number;
terrainPanel: MapTerrainPanel;
side: Sidedness = 'Front';
/**
* Creates an instance of MapInspector.
*
* @param parentGui - The parent GUI.
* @param instance - The Giro3D instance.
* @param map - The inspected Map.
*/
constructor(parentGui: GUI, instance: Instance, map: Map) {
super(parentGui, instance, map, {
visibility: true,
boundingBoxColor: true,
boundingBoxes: true,
opacity: true,
});
this.frozen = this.entity.frozen ?? false;
this.showGrid = false;
this.renderState = 'Normal';
this.side = sides[this.entity.side];
this.addController(this.entity, 'discardNoData')
.name('Discard no-data values')
.onChange(() => this.notify(this.entity));
this.layerCount = this.entity.layerCount;
this.background = new Color().copyLinearToSRGB(this.entity.backgroundColor);
this.backgroundOpacity = this.entity.backgroundOpacity;
this.extentColor = new Color('red');
this.showExtent = false;
this.showTileInfo = false;
this.extentHelper = null;
this.reachableTiles = 0;
this.visibleTiles = 0;
this.labels = new window.Map();
this.addController(this, 'side', sides)
.name('Sidedness')
.onChange(v => this.setSidedness(v));
this.addController(this.entity, 'depthTest')
.name('Depth test')
.onChange(() => this.notify(this.entity));
this.addController(this, 'visibleTiles').name('Visible tiles');
this.addController(this, 'reachableTiles').name('Reachable tiles');
this.addController(this.entity.allTiles, 'size').name('Loaded tiles');
if (this.entity.elevationRange) {
this.addController(this.entity.elevationRange, 'min')
.name('Elevation range minimum')
.onChange(() => this.notify(map));
this.addController(this.entity.elevationRange, 'max')
.name('Elevation range maximum')
.onChange(() => this.notify(map));
}
this.addController(this.entity, 'castShadow');
this.addController(this.entity, 'receiveShadow');
this.addController(this.entity.imageSize, 'width').name('Tile width (pixels)');
this.addController(this.entity.imageSize, 'height').name('Tile height (pixels)');
this.addController(this, 'showGrid')
.name('Show grid')
.onChange(v => this.toggleGrid(v));
this.addColorController(this, 'background')
.name('Background')
.onChange(v => this.updateBackgroundColor(v));
this.addController(this, 'backgroundOpacity')
.name('Background opacity')
.min(0)
.max(1)
.onChange(v => this.updateBackgroundOpacity(v));
this.addController(this.entity, 'showTileOutlines')
.name('Show tiles outlines')
.onChange(() => this.notify());
this.addColorController(this.entity, 'tileOutlineColor')
.name('Tile outline color')
.onChange(() => this.notify());
this.addController(this, 'showTileInfo')
.name('Show tile info')
.onChange(() => this.toggleBoundingBoxes());
this.addController(this, 'showExtent')
.name('Show extent')
.onChange(() => this.toggleExtent());
this.addColorController(this, 'extentColor')
.name('Extent color')
.onChange(() => this.updateExtentColor());
this.addController(this.entity, 'subdivisionThreshold')
.name('Subdivision threshold')
.min(0.1)
.max(3)
.step(0.1)
.onChange(() => this.notify());
this.terrainPanel = new MapTerrainPanel(this.entity, this.gui, instance);
this.lightingPanel = new MapLightingPanel(this.entity.lighting, this.gui, instance);
this.graticulePanel = new GraticulePanel(this.entity.graticule, this.gui, instance);
this.contourLinePanel = new ContourLinePanel(this.entity.contourLines, this.gui, instance);
this.colorimetryPanel = new ColorimetryPanel(this.entity.colorimetry, this.gui, instance);
this.addController(this, 'layerCount').name('Layer count');
this.addController(this, 'renderState', ['Normal', 'Picking'])
.name('Render state')
.onChange(v => this.setRenderState(v));
this.addController(this, 'dumpTiles').name('Dump tiles in console');
this.addController(this, 'disposeMapAndLayers').name('Dispose map and layers');
this.layerFolder = this.gui.addFolder('Layers');
this.layers = [];
this._fillLayersCb = () => this.fillLayers();
this.entity.addEventListener('layer-added', this._fillLayersCb);
this.entity.addEventListener('layer-removed', this._fillLayersCb);
this.entity.addEventListener('layer-order-changed', this._fillLayersCb);
this.fillLayers();
}
disposeMapAndLayers() {
const layers = this.entity.getLayers();
for (const layer of layers) {
this.entity.removeLayer(layer, { disposeLayer: true });
}
this.instance.remove(this.entity);
this.notify();
}
getOrCreateLabel(obj: TileMesh) {
let label = this.labels.get(obj.id);
if (!label) {
label = new CSS2DObject(createTileLabel());
label.name = 'MapInspector label';
obj.addEventListener('dispose', () => {
label?.element.remove();
label?.remove();
});
obj.add(label);
obj.updateMatrixWorld(true);
this.labels.set(obj.id, label);
}
return label;
}
getInfo(tile: TileMesh): string {
const layers: string[] = [];
for (const layer of this.entity.getLayers()) {
const info = layer.getInfo(tile);
layers.push(
`${layer.name ?? layer.id}: ${info.imageCount} img, ${info.state}, ${info.paintCount} paints)`,
);
}
return [
`Node #${tile.id} (${Math.ceil(tile.progress * 100)}%) - LOD=${tile.z}, x=${tile.x}, y=${tile.y}`,
...layers,
].join('\n');
}
updateLabel(tile: TileMesh, visible: boolean, color: Color) {
if (!visible) {
const label = this.labels.get(tile.id);
if (label) {
label.element.remove();
label.parent?.remove(label);
this.labels.delete(tile.id);
}
} else {
const isVisible = tile.visible && tile.material.visible;
const label = this.getOrCreateLabel(tile);
const element = label.element;
element.innerText = this.getInfo(tile);
element.style.color = `#${color.getHexString()}`;
element.style.opacity = isVisible ? '100%' : '0%';
tile.boundingBox.getCenter(label.position);
label.updateMatrixWorld();
}
}
toggleBoundingBoxes() {
const color = new Color(this.boundingBoxColor);
const noDataColor = new Color('gray');
// by default, adds axis-oriented bounding boxes to each object in the hierarchy.
// custom implementations may override this to have a different behaviour.
// @ts-expect-error monkey patched method
this.rootObject.traverseOnce(obj => {
if (obj instanceof TileMesh) {
const tile = obj as TileMesh;
let finalColor = new Color();
const layerCount = obj.material?.getLayerCount();
if (layerCount === 0) {
finalColor = noDataColor;
} else {
finalColor = color;
}
this.addOrRemoveBoundingBox(tile, this.boundingBoxes, finalColor);
this.updateLabel(tile, this.showTileInfo, finalColor);
}
});
this.notify(this.entity);
}
updateControllers(): void {
super.updateControllers();
this.layers.forEach(insp => insp.updateControllers());
}
updateBackgroundOpacity(a: number) {
this.backgroundOpacity = a;
this.entity.backgroundOpacity = a;
this.notify(this.entity);
}
updateBackgroundColor(srgb: Color) {
this.background.copy(srgb);
this.entity.backgroundColor.copySRGBToLinear(srgb);
this.notify(this.entity);
}
updateExtentColor() {
if (this.extentHelper) {
this.instance.threeObjects.remove(this.extentHelper);
if (isMaterial(this.extentHelper.material)) {
this.extentHelper.material.dispose();
}
this.extentHelper.geometry.dispose();
this.extentHelper = null;
}
this.toggleExtent();
}
toggleExtent() {
if (!this.extentHelper && this.showExtent) {
const { min, max } = this.entity.getElevationMinMax();
const box = this.entity.extent.toBox3(min, max);
this.extentHelper = Helpers.createBoxHelper(box, this.extentColor);
this.instance.threeObjects.add(this.extentHelper);
this.extentHelper.updateMatrixWorld(true);
}
if (this.extentHelper) {
this.extentHelper.visible = this.showExtent;
}
this.notify(this.entity);
}
setSidedness(side: Sidedness) {
this.entity.side = sides.indexOf(side) as Side;
this.notify(this.entity);
}
setRenderState(state: string) {
switch (state) {
case 'Normal':
this.entity.setRenderState(RenderingState.FINAL);
break;
case 'Picking':
this.entity.setRenderState(RenderingState.PICKING);
break;
default:
break;
}
this.notify(this.entity);
}
removeEventListeners() {
this.entity.removeEventListener('layer-added', this._fillLayersCb);
this.entity.removeEventListener('layer-removed', this._fillLayersCb);
this.entity.removeEventListener('layer-order-changed', this._fillLayersCb);
}
dispose() {
super.dispose();
this.removeEventListeners();
}
dumpTiles() {
console.log(this.entity.level0Nodes);
}
updateValues() {
super.updateValues();
this.toggleBoundingBoxes();
this.layerCount = this.entity.layerCount;
this.layers.forEach(l => l.updateValues());
this.reachableTiles = 0;
this.visibleTiles = 0;
this.entity.traverseTiles(t => {
if (t.material.visible) {
this.visibleTiles++;
}
this.reachableTiles++;
});
}
fillLayers() {
while (this.layers.length > 0) {
this.layers.pop()?.dispose();
}
// We reverse the order so that the layers are displayed in a natural order:
// top layers in the inspector are also on top in the composition.
this.entity
.getLayers()
.reverse()
.forEach(lyr => {
const gui = new LayerInspector(this.layerFolder, this.instance, this.entity, lyr);
this.layers.push(gui);
});
}
toggleGrid(value: boolean) {
if (!value) {
if (this.grid) {
this.grid.parent?.remove(this.grid);
}
if (this.axes) {
this.axes.parent?.remove(this.axes);
}
} else {
const dims = this.entity.extent.dimensions();
const size = Math.max(dims.x, dims.y) * 1.1;
const origin = this.entity.extent.centerAsVector3();
const grid = Helpers.createGrid(origin, size, 20);
this.instance.scene.add(grid);
grid.updateMatrixWorld(true);
this.grid = grid;
const axes = Helpers.createAxes(size * 0.05);
// We don't want to add the axes to the grid because the grid is rotated,
// which would rotate the axes too and give a wrong information about the vertical axis.
axes.position.copy(origin);
this.axes = axes;
this.axes.updateMatrixWorld(true);
this.instance.scene.add(axes);
}
this.notify();
}
}
export default MapInspector;