UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

334 lines (283 loc) 11.4 kB
/* * Copyright (c) 2015-2018, IGN France. * Copyright (c) 2018-2026, Giro3D team. * SPDX-License-Identifier: MIT */ import type GUI from 'lil-gui'; import type { Object3D } from 'three'; import { Color, Plane, PlaneHelper, Vector3, type ColorRepresentation } from 'three'; import type HasDefaultPointOfView from '../core/HasDefaultPointOfView'; import type Instance from '../core/Instance'; import type PointOfView from '../core/PointOfView'; import type Entity3D from '../entities/Entity3D'; import { hasDefaultPointOfView } from '../core/HasDefaultPointOfView'; import * as MemoryUsage from '../core/MemoryUsage'; import Helpers, { hasBoundingVolumeHelper } from '../helpers/Helpers'; import { isMaterial, isVector3 } from '../utils/predicates'; import Panel from './Panel'; const _tempArray: Object3D[] = []; /** * Traverses the object hierarchy exactly once per object, * even if the hierarchy is modified during the traversal. * * In other words, objects can be safely added * to the hierarchy without causing infinite recursion. * * @param callback - The callback to call for each visited object. */ function traverseOnce(root: Object3D, callback: (obj: Object3D) => void): void { root.traverse((o: Object3D) => _tempArray.push(o)); while (_tempArray.length > 0) { const obj = _tempArray.pop(); if (obj) { callback(obj); } } } class ClippingPlanePanel extends Panel { public entity: Entity3D; public enableClippingPlane: boolean; public normal: Vector3; public distance: number; public helperSize: number; public negate: boolean; public planeHelper?: PlaneHelper; public constructor(entity: Entity3D, parentGui: GUI, instance: Instance) { super(parentGui, instance, 'Clipping plane'); this.entity = entity; this.enableClippingPlane = false; this.normal = new Vector3(0, 0, 1); this.distance = 0; this.helperSize = 5; this.negate = false; this.addController(this, 'enableClippingPlane') .name('Enable') .onChange(() => this.updateClippingPlane()); this.addController(this.normal, 'x') .name('Plane normal X') .onChange(() => this.updateClippingPlane()); this.addController(this.normal, 'y') .name('Plane normal Y') .onChange(() => this.updateClippingPlane()); this.addController(this.normal, 'z') .name('Plane normal Z') .onChange(() => this.updateClippingPlane()); this.addController(this, 'distance') .name('Distance') .onChange(() => this.updateClippingPlane()); this.addController(this, 'helperSize') .name('Helper size') .onChange(() => this.updateClippingPlane()); this.addController(this, 'negate') .name('Negate plane') .onChange(() => this.updateClippingPlane()); } public updateClippingPlane(): void { this.planeHelper?.removeFromParent(); this.planeHelper?.dispose(); if (this.enableClippingPlane) { const plane = new Plane(this.normal.clone(), this.distance); if (this.negate) { plane.negate(); } this.entity.clippingPlanes = [plane]; this.planeHelper = new PlaneHelper(plane, this.helperSize, 0xff0000); this.planeHelper.name = `Clipping plane for ${this.entity.id}`; this.instance.scene.add(this.planeHelper); this.planeHelper.updateMatrixWorld(); } else { this.entity.clippingPlanes = null; } this.notify(this.entity); } public override dispose(): void { this.planeHelper?.removeFromParent(); this.planeHelper?.dispose(); } } interface EntityInspectorOptions { /** Display the bounding box checkbox. */ boundingBoxes?: boolean; /** Display the bounding box color checkbox. */ boundingBoxColor?: boolean; /** Display the opacity slider. */ opacity?: boolean; /** Display the visibility checkbox. */ visibility?: boolean; } function getTitle(entity: Entity3D): string { if (entity.name != null) { return `${entity.name} (${entity.type})`; } return entity.type; } /** * Base class for entity inspectors. To implement a custom inspector * for an entity type, you can inherit this class. */ class EntityInspector<T extends Entity3D = Entity3D> extends Panel { /** The inspected entity. */ public entity: T; /** The root object of the entity's hierarchy. */ public rootObject: Object3D; /** Toggle the visibility of the entity. */ public visible: boolean; /** Toggle the visibility of the bounding boxes. */ public boundingBoxes: boolean; public boundingBoxColor: string; public state: string; public clippingPlanePanel: ClippingPlanePanel; public cpuMemoryUsage = 'unknown'; public gpuMemoryUsage = 'unknown'; /** * @param parentGui - The parent GUI. * @param instance - The Giro3D instance. * @param entity - The entity to inspect. * @param options - The options. */ public constructor( parentGui: GUI, instance: Instance, entity: T, options: EntityInspectorOptions = {}, ) { super(parentGui, instance, getTitle(entity)); this.entity = entity; this.rootObject = entity.object3d; this.visible = entity.visible; this.boundingBoxes = false; this.boundingBoxColor = '#FFFF00'; this.state = 'idle'; this.addController(this.entity, 'id').name('Identifier'); this.addController(this, 'cpuMemoryUsage').name('Memory usage (CPU)'); this.addController(this, 'gpuMemoryUsage').name('Memory usage (GPU)'); this.addController(this, 'state').name('Status'); this.addController(this.entity, 'renderOrder') .name('Render order') .onChange(() => this.notify(this.entity)); this.clippingPlanePanel = new ClippingPlanePanel(entity, this.gui, instance); if (options.visibility === true) { this.addController(this, 'visible') .name('👁️ Visible') .onChange(v => this.toggleVisibility(v)); } this.addController(this.entity, 'frozen') .name('❄️ Freeze') .onChange(() => this.notify(this.entity)); if (options.opacity === true) { this.addController(this.entity, 'opacity') .name('🪟 Opacity') .min(0) .max(1) .onChange(() => this.notify(this.entity)); } if (options.boundingBoxes === true) { this.addController(this, 'boundingBoxes') .name('🔳 Show volumes') .onChange(v => this.toggleBoundingBoxes(v)); if (options.boundingBoxColor === true) { this.addColorController(this, 'boundingBoxColor') .name('Volume color') .onChange(v => this.updateBoundingBoxColor(v)); } } if (hasDefaultPointOfView(entity)) { this.addController(this, 'goToEntity').name('👣 Go to'); this.addController(this, 'lookAt').name('🎥 Look at'); } this.addController(this, 'deleteEntity').name('❌ Delete entity'); this.addController(this.entity.distance, 'min').name('Min view distance').decimals(1); this.addController(this.entity.distance, 'max').name('Max view distance').decimals(1); } private updateControlsWithDefaultView(defaultView: PointOfView | null): void { const controls = this.instance.view.controls; if (defaultView && controls && 'target' in controls && isVector3(controls.target)) { controls.target.copy(defaultView.target); } } public goToEntity(): void { const cast = this.entity as unknown as HasDefaultPointOfView; const defaultView = this.instance.view.goTo(cast); this.updateControlsWithDefaultView(defaultView); this.notify(); } public lookAt(): void { const cast = this.entity as unknown as HasDefaultPointOfView; const defaultView = this.instance.view.goTo(cast, { allowTranslation: false }); this.updateControlsWithDefaultView(defaultView); this.notify(); } public deleteEntity(): void { this.instance.remove(this.entity); } public override dispose(): void { this.toggleBoundingBoxes(false); this.clippingPlanePanel.dispose(); } public override updateValues(): void { const ctx: MemoryUsage.GetMemoryUsageContext = { renderer: this.instance.renderer, objects: new Map(), }; this.entity.getMemoryUsage(ctx); const memUsage = MemoryUsage.aggregateMemoryUsage(ctx); this.cpuMemoryUsage = MemoryUsage.format(memUsage.cpuMemory); this.gpuMemoryUsage = MemoryUsage.format(memUsage.gpuMemory); this.state = this.entity.loading ? `loading (${Math.round(this.entity.progress * 100)}%)` : 'idle'; if (this.boundingBoxes) { this.toggleBoundingBoxes(true); } } /** * Toggles the visibility of the entity in the scene. * You may override this method if the entity's visibility is not directly related * to its root object visibility. * * @param visible - The new visibility. */ public toggleVisibility(visible: boolean): void { this.entity.visible = visible; this.notify(this.entity); } /** * Toggles the visibility of the bounding boxes. * You may override this method to use custom bounding boxes. * * @param visible - The new state. */ public toggleBoundingBoxes(visible: boolean): void { const color = new Color(this.boundingBoxColor); // by default, adds axis-oriented bounding boxes to each object in the hierarchy. // custom implementations may override this to have a different behaviour. traverseOnce(this.rootObject, obj => this.addOrRemoveBoundingBox(obj, visible, color)); this.notify(this.entity); } /** * @param obj - The object to decorate. * @param add - If true, bounding box is added, otherwise it is removed. * @param color - The bounding box color. */ public addOrRemoveBoundingBox(obj: Object3D, add: boolean, color: Color): void { if (add) { if ('material' in obj && isMaterial(obj.material)) { if (obj.visible && obj.material != null && obj.material.visible) { Helpers.addBoundingBox(obj, color); } } } else { Helpers.removeBoundingBox(obj); } } public updateBoundingBoxColor(colorHex: ColorRepresentation): void { const color = new Color(colorHex); this.rootObject.traverse(obj => { if (hasBoundingVolumeHelper(obj)) { obj.boundingVolumeHelper.object3d.material.color = color; } }); this.notify(this.entity); } } export default EntityInspector;