@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
461 lines (388 loc) • 14 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 { BufferGeometry, Material, Mesh, Object3D, Scene } from 'three';
import { Color } from 'three';
import type Instance from '../../core/Instance';
import type Entity3D from '../../entities/Entity3D';
import type { BoundingBoxHelper } from '../../helpers/Helpers';
import Helpers from '../../helpers/Helpers';
import { isLight } from '../../utils/predicates';
import Panel from '../Panel';
import OutlinerPropertyView from './OutlinerPropertyView';
interface OutlinedObject3D extends Object3D {
// We use underscores to avoid potential naming conflicts with existing properties
___outlinerTreeviewVisible?: boolean;
___outlinerTreeviewCollapsed?: boolean;
}
type ClickHandler = (obj: OutlinedObject3D) => void;
interface Filter {
showHelpers: boolean;
showHiddenObjects: boolean;
searchRegex?: RegExp;
searchQuery: string;
}
interface TreeviewNode {
object: OutlinedObject3D;
root: HTMLElement;
collapseButton: HTMLElement;
name: HTMLParagraphElement;
textColor: string;
opacity?: string;
}
function getHash(scene: Scene): number {
let hash = 27 | 0;
scene.traverse(obj => {
hash = (13 * hash + obj.id) | 0;
});
return hash;
}
/**
* Returns the colors associated with the THREE object type.
*
* @param obj - the THREE object
* @returns the object containing foreground and background colors
*/
function selectColor(obj: OutlinedObject3D): { back: string; fore: string } {
const entity = isEntityRoot(obj);
if (entity) {
return { back: 'gold', fore: 'black' };
}
if (isLight(obj)) {
return { back: 'yellow', fore: 'black' };
}
switch (obj.type) {
case 'Mesh':
case 'TileMesh':
return { back: 'orange', fore: 'black' };
case 'Points':
return { back: 'red', fore: 'white' };
case 'Object3D':
return { back: 'gray', fore: 'white' };
case 'Scene':
return { back: '#CCCCCC', fore: 'black' };
case 'Group':
return { back: 'green', fore: 'white' };
default:
return { back: 'blue', fore: 'white' };
}
}
function isMesh(obj: Object3D): obj is Mesh {
return (obj as Mesh).isMesh;
}
function hasSingleMaterial(mesh: Mesh): mesh is Mesh<BufferGeometry, Material> {
return !Array.isArray(mesh.material);
}
function getMaterialVisibility(obj: Object3D): boolean {
if (isMesh(obj) && hasSingleMaterial(obj)) {
return obj.material.visible;
}
return true;
}
function isEntityRoot(obj: Object3D): Entity3D | null {
if (obj.userData.parentEntity != null) {
const entity: Entity3D = obj.userData.parentEntity;
if (entity.object3d === obj) {
return entity;
}
}
return null;
}
function getType(obj: Object3D): string {
const entity = isEntityRoot(obj);
if (entity != null) {
return entity.type;
}
return obj.type;
}
function getName(obj: Object3D): string {
const entity = isEntityRoot(obj);
if (entity != null) {
return entity.name ?? entity.id;
}
return obj.name;
}
function createTreeViewNode(
object: OutlinedObject3D,
marginLeft: number,
clickHandler: ClickHandler,
onUpdate: () => void,
): TreeviewNode {
const root = document.createElement('button');
root.style.width = 'unset';
root.style.textAlign = 'left';
root.onclick = (): void => clickHandler(object);
const collapseButton = document.createElement('button');
collapseButton.style.width = '1rem';
collapseButton.style.height = '1rem';
collapseButton.title = 'collapse sub-tree';
collapseButton.style.backgroundColor = 'grey';
collapseButton.style.margin = '2px';
collapseButton.style.borderRadius = '3px';
collapseButton.innerText = object.___outlinerTreeviewCollapsed === true ? '➕' : '➖';
collapseButton.onclick = function onclick(): void {
if (object.___outlinerTreeviewCollapsed == null) {
object.___outlinerTreeviewCollapsed = false;
}
object.___outlinerTreeviewCollapsed = !object.___outlinerTreeviewCollapsed;
collapseButton.innerText = object.___outlinerTreeviewCollapsed ? '➕' : '➖';
onUpdate();
};
const name = document.createElement('p');
name.style.marginLeft = `${marginLeft}px`;
name.style.marginTop = '0px';
name.style.marginBottom = '0px';
name.style.background = 'transparent';
const textColor = getMaterialVisibility(object) ? 'white' : 'rgba(222, 208, 105, 0.59)';
const { fore, back } = selectColor(object);
name.innerHTML = `<span style="border-radius: 6px; padding: 2px; font-family: monospace; background-color: ${back}; color: ${fore}">${getType(object)}</span> <span style="font-family: monospace; color: ${textColor}";>${getName(object)}</span>`;
root.appendChild(name);
return { root, collapseButton, name, object, textColor, opacity: undefined };
}
function updateNode(node: TreeviewNode): void {
const { root, object, name } = node;
const opacity = object.visible ? '100%' : '50%';
if (node.opacity == null || node.opacity !== opacity) {
root.style.opacity = opacity;
node.opacity = opacity;
}
const textColor = getMaterialVisibility(object) ? 'white' : 'rgba(222, 208, 105, 0.59)';
const { fore, back } = selectColor(object);
if (textColor !== node.textColor) {
node.textColor = textColor;
name.innerHTML = `<span style="border-radius: 6px; padding: 2px; font-family: monospace; background-color: ${back}; color: ${fore}">${object.type}</span> <span style="font-family: monospace; color: ${textColor}";>${object.name}</span>`;
}
}
/**
* Creates a treeview node for the specified object and its children.
*
* @param obj - the THREE object.
* @param clickHandler - the function to call when a node is clicked.
* @param level - the hierarchy level
*/
function createTreeViewNodeWithDescendants(
obj: OutlinedObject3D,
clickHandler: ClickHandler,
onUpdate: () => void,
map: Map<number, TreeviewNode>,
level = 0,
): HTMLDivElement | undefined {
if (obj.type !== 'Scene' && obj.___outlinerTreeviewVisible === false) {
return undefined;
}
const div = document.createElement('div');
div.style.background = 'transparent';
div.style.opacity = obj.visible ? '100%' : '50%';
// create the DOM element for the object itself
const marginLeft = level * 15;
const node = createTreeViewNode(obj, marginLeft, clickHandler, onUpdate);
map.set(obj.id, node);
div.appendChild(node.collapseButton);
div.appendChild(node.root);
if (obj.___outlinerTreeviewCollapsed === undefined) {
obj.___outlinerTreeviewCollapsed = false;
}
if (obj.___outlinerTreeviewCollapsed !== true) {
// recursively create the DOM elements for the children
const childLevel = level + 1;
obj.children.forEach((child: OutlinedObject3D) => {
const childNode = createTreeViewNodeWithDescendants(
child,
clickHandler,
onUpdate,
map,
childLevel,
);
if (childNode) {
div.appendChild(childNode);
}
});
}
return div;
}
function setAncestorsVisible(obj: OutlinedObject3D): void {
if (obj != null) {
obj.___outlinerTreeviewVisible = true;
setAncestorsVisible(obj.parent as OutlinedObject3D);
}
}
function isHelper(obj: Object3D): boolean {
return 'isHelper' in obj && obj.isHelper === true;
}
function matches(obj: Object3D, regex?: RegExp): boolean {
if (regex == null) {
return true;
}
if (regex.test(obj.name.toLowerCase())) {
return true;
}
if (regex.test(obj.type.toLowerCase())) {
return true;
}
return false;
}
function shouldBeDisplayedInTree(obj: OutlinedObject3D, filter: Filter): boolean {
if (isHelper(obj) && !filter.showHelpers) {
return false;
}
if (!obj.visible && !filter.showHiddenObjects) {
return false;
}
if (matches(obj, filter.searchRegex)) {
return true;
}
return false;
}
/**
* @param obj - the object to process
* @param filter - the search filter
*/
function applySearchFilter(obj: OutlinedObject3D, filter: Filter): void {
if (shouldBeDisplayedInTree(obj, filter)) {
setAncestorsVisible(obj);
} else {
obj.___outlinerTreeviewVisible = false;
}
if (obj.children != null) {
obj.children.forEach((c: OutlinedObject3D) => applySearchFilter(c, filter));
}
}
/**
* Provides a tree view of the three.js [scene](https://threejs.org/docs/index.html?q=scene#api/en/scenes/Scene).
*
*/
class Outliner extends Panel {
public filters: Filter;
public treeviewContainer: HTMLDivElement;
public treeview: HTMLDivElement;
public rootNode: HTMLDivElement | undefined;
public propView: OutlinerPropertyView;
public selectionHelper?: BoundingBoxHelper;
public sceneHash: number | undefined = undefined;
private readonly _nodes: Map<number, TreeviewNode> = new Map();
/**
* @param gui - The GUI.
* @param instance - The Giro3D instance.
*/
public constructor(gui: GUI, instance: Instance) {
super(gui, instance, 'Outliner');
this.filters = {
showHelpers: false,
showHiddenObjects: true,
searchQuery: '',
searchRegex: undefined,
};
this.treeviewContainer = document.createElement('div');
this.treeview = document.createElement('div');
this.treeview.style.background = '#424242';
this.treeview.id = 'treeview';
this.treeview.style.height = '350px';
this.treeview.style.overflow = 'auto';
// avoid wrapping ids and coordinates for deep-nested elements
this.treeview.style.whiteSpace = 'nowrap';
this.addController(this.filters, 'showHelpers')
.name('Show helpers')
.onChange(() => {
this.search();
this.instance.notifyChange();
});
this.addController(this.filters, 'showHiddenObjects')
.name('Show hidden objects')
.onChange(() => {
this.search();
this.instance.notifyChange();
});
this.addController(this.filters, 'searchQuery')
.name('Name filter')
.onChange(() => {
this.search();
this.instance.notifyChange();
});
this.treeviewContainer.appendChild(this.treeview);
// A little bit of DOM hacking to insert the treeview in the GUI.
const treeGui = this.gui.addFolder('Hierarchy');
const children = treeGui.domElement.getElementsByClassName('children');
children[0].appendChild(this.treeviewContainer);
this.updateTreeView();
this.propView = new OutlinerPropertyView(this.gui, this.instance);
}
public override updateValues(): void {
this.updateTreeView();
}
public onNodeClicked(obj: OutlinedObject3D): void {
this.select(obj);
this.propView.populateProperties(obj);
this.instance.notifyChange();
}
/**
* Selects the object by displaying a bright bounding box around it.
*
* @param obj - The object to select.
*/
public select(obj: OutlinedObject3D): void {
this.clearSelection();
if ((obj as unknown) === this.selectionHelper) {
return;
}
this.selectionHelper = Helpers.createSelectionBox(obj, new Color('#00FF00'));
this.selectionHelper.name = 'selection';
}
/**
* Unselect the currently selected object.
*/
public clearSelection(): void {
if (this.selectionHelper && this.selectionHelper.parent) {
this.selectionHelper.parent.remove(this.selectionHelper);
}
delete this.selectionHelper;
}
public search(): void {
this.filters.searchQuery = this.filters.searchQuery.trim().toLowerCase();
this.filters.searchRegex =
this.filters.searchQuery.length > 0 ? new RegExp(this.filters.searchQuery) : undefined;
this.sceneHash = undefined;
this.updateTreeView();
}
public updateObject(o: Object3D): void {
o.updateMatrixWorld(true);
this.instance.notifyChange();
}
private updateExistingNodes(): void {
this._nodes.forEach(n => updateNode(n));
}
public updateTreeView(): void {
if (this.isClosed()) {
// we don't want to refresh the treeview if the GUI is collapsed.
return;
}
const hash = getHash(this.instance.scene);
if (hash === this.sceneHash) {
this.updateExistingNodes();
} else {
this.sceneHash = hash;
if (this.rootNode) {
this.treeview.removeChild(this.rootNode);
}
applySearchFilter(this.instance.scene as unknown as OutlinedObject3D, this.filters);
this._nodes.clear();
const onUpdate = (): void =>
queueMicrotask(() => {
this.sceneHash = undefined;
this.updateTreeView();
});
this.rootNode = createTreeViewNodeWithDescendants(
this.instance.scene as unknown as OutlinedObject3D,
obj => this.onNodeClicked(obj),
onUpdate,
this._nodes,
);
if (this.rootNode) {
this.treeview.appendChild(this.rootNode);
}
}
}
}
export default Outliner;