UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

413 lines (360 loc) 14.7 kB
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;