Repository: NASA-AMMOS/3DTilesRendererJS (js)

Issue:
DebugTilesPlugin: Add support for displaying parent bounds
Can be added efficiently without significant performance impact by traversing the ancestors when making the tile visible, adding all parent bounds, and then incrementing a reference counter per parent tile. When a tile is hidden the reference counter for each ancestor can be decremented and if it reaches 0 then the parent bounds can be removed.

Pull request description:
DebugTilesPlugin: add the displayParentBounds option (#730)
This PR fixes #730.

Adds the `.displayParentBounds` property to display the bounds of tiles between the root tile and the first visible tile.

The helpers for the intermediate bounds use ~a thicker line and~ semi-transparent materials to help distinguishing them from the "leaf" tiles.

![image](https://github.com/user-attachments/assets/f6c27f28-1b9a-4149-a944-5921061d2b23)

~Note: not all GPUs support thick lines (see the [comment in the three.js doc](https://threejs.org/docs/index.html?q=box3help#api/en/materials/LineBasicMaterial.linewidth)), so on some platforms the line will remain 1-pixel thick. The alternative would be to use different materials, but that would increase complexity and maintenance.~

This uses a reference counting mechanism as suggested in #730.


Patch:
diff --git a/example/index.js b/example/index.js
index 470942c..c5d8668 100644
--- a/example/index.js
+++ b/example/index.js
@@ -72,6 +72,7 @@ const params = {
 
 	up: hashUrl ? '+Z' : '+Y',
 	enableDebug: true,
+	displayParentBounds: false,
 	displayBoxBounds: false,
 	displaySphereBounds: false,
 	displayRegionBounds: false,
@@ -273,6 +274,7 @@ function init() {
 
 	const debug = gui.addFolder( 'Debug Options' );
 	debug.add( params, 'enableDebug' );
+	debug.add( params, 'displayParentBounds' );
 	debug.add( params, 'displayBoxBounds' );
 	debug.add( params, 'displaySphereBounds' );
 	debug.add( params, 'displayRegionBounds' );
@@ -489,6 +491,7 @@ function animate() {
 	const plugin = tiles.getPluginByName( 'DEBUG_TILES_PLUGIN' );
 	plugin.enabled = params.enableDebug;
 	plugin.displayBoxBounds = params.displayBoxBounds;
+	plugin.displayParentBounds = params.displayParentBounds;
 	plugin.displaySphereBounds = params.displaySphereBounds;
 	plugin.displayRegionBounds = params.displayRegionBounds;
 	plugin.colorMode = parseFloat( params.colorMode );
diff --git a/src/base/traverseFunctions.js b/src/base/traverseFunctions.js
index 8aafb13..29c3ee5 100644
--- a/src/base/traverseFunctions.js
+++ b/src/base/traverseFunctions.js
@@ -488,3 +488,28 @@ export function toggleTiles( tile, renderer ) {
 	}
 
 }
+
+/**
+ * Traverses the ancestry of the tile up to the root tile.
+ */
+export function traverseAncestors( tile, callback = null ) {
+
+	let current = tile;
+
+	while ( current ) {
+
+		const depth = current.__depth;
+		const parent = current.parent;
+
+		if ( callback ) {
+
+			callback( current, parent, depth );
+
+		}
+
+		current = parent;
+
+	}
+
+
+}
diff --git a/src/plugins/three/DebugTilesPlugin.js b/src/plugins/three/DebugTilesPlugin.js
index f721338..1ae0615 100644
--- a/src/plugins/three/DebugTilesPlugin.js
+++ b/src/plugins/three/DebugTilesPlugin.js
@@ -1,12 +1,13 @@
 import { Box3Helper, Group, MeshStandardMaterial, PointsMaterial, Sphere, Color } from 'three';
 import { SphereHelper } from './objects/SphereHelper.js';
 import { EllipsoidRegionLineHelper } from './objects/EllipsoidRegionHelper.js';
-import { traverseSet } from '../../base/traverseFunctions.js';
+import { traverseAncestors, traverseSet } from '../../base/traverseFunctions.js';
 
 const ORIGINAL_MATERIAL = Symbol( 'ORIGINAL_MATERIAL' );
 const HAS_RANDOM_COLOR = Symbol( 'HAS_RANDOM_COLOR' );
 const HAS_RANDOM_NODE_COLOR = Symbol( 'HAS_RANDOM_NODE_COLOR' );
 const LOAD_TIME = Symbol( 'LOAD_TIME' );
+const PARENT_BOUND_REF_COUNT = Symbol( 'PARENT_BOUND_REF_COUNT' );
 
 const _sphere = /* @__PURE__ */ new Sphere();
 const emptyRaycast = () => {};
@@ -46,6 +47,7 @@ export class DebugTilesPlugin {
 	constructor( options ) {
 
 		options = {
+			displayParentBounds: false,
 			displayBoxBounds: false,
 			displaySphereBounds: false,
 			displayRegionBounds: false,
@@ -69,6 +71,7 @@ export class DebugTilesPlugin {
 		this.regionGroup = null;
 
 		// options
+		this._displayParentBounds = options.displayParentBounds;
 		this.displayBoxBounds = options.displayBoxBounds;
 		this.displaySphereBounds = options.displaySphereBounds;
 		this.displayRegionBounds = options.displayRegionBounds;
@@ -116,6 +119,47 @@ export class DebugTilesPlugin {
 
 	}
 
+	get displayParentBounds() {
+
+		return this._displayParentBounds;
+
+	}
+
+	set displayParentBounds( v ) {
+
+		if ( this._displayParentBounds !== v ) {
+
+			this._displayParentBounds = v;
+
+			if ( ! v ) {
+
+				// Reset all ref counts
+				traverseSet( this.tiles.root, null, tile => {
+
+					tile[ PARENT_BOUND_REF_COUNT ] = null;
+					this._onTileVisibilityChange( tile, tile.__visible );
+
+				} );
+
+			} else {
+
+				// Initialize ref count for existing tiles
+				this.tiles.traverse( tile => {
+
+					if ( tile.__visible ) {
+
+						this._onTileVisibilityChange( tile, true );
+
+					}
+
+				} );
+
+			}
+
+		}
+
+	}
+
 	// initialize the groups for displaying helpers, register events, and initialize existing tiles
 	init( tiles ) {
 
@@ -520,64 +564,41 @@ export class DebugTilesPlugin {
 
 	_onTileVisibilityChange( tile, visible ) {
 
-		const cached = tile.cached;
-		const sphereGroup = this.sphereGroup;
-		const boxGroup = this.boxGroup;
-		const regionGroup = this.regionGroup;
-		const boxHelperGroup = cached.boxHelperGroup;
-		const sphereHelper = cached.sphereHelper;
-		const regionHelper = cached.regionHelper;
-
-		if ( ! visible ) {
-
-			if ( boxHelperGroup ) {
-
-				boxGroup.remove( boxHelperGroup );
-
-			}
-
-			if ( sphereHelper ) {
-
-				sphereGroup.remove( sphereHelper );
+		if ( this.displayParentBounds ) {
 
-			}
+			traverseAncestors( tile, current => {
 
-			if ( regionHelper ) {
+				if ( current[ PARENT_BOUND_REF_COUNT ] == null ) {
 
-				regionGroup.remove( regionHelper );
+					current[ PARENT_BOUND_REF_COUNT ] = 0;
 
-			}
+				}
 
-		} else {
+				if ( visible ) {
 
-			if ( boxHelperGroup ) {
+					current[ PARENT_BOUND_REF_COUNT ] ++;
 
-				boxGroup.add( boxHelperGroup );
-				boxHelperGroup.updateMatrixWorld( true );
+				} else if ( current[ PARENT_BOUND_REF_COUNT ] > 0 ) {
 
-			}
+					current[ PARENT_BOUND_REF_COUNT ] --;
 
-			if ( sphereHelper ) {
+				}
 
-				sphereGroup.add( sphereHelper );
-				sphereHelper.updateMatrixWorld( true );
+				const tileVisible = ( current === tile && visible ) || ( this.displayParentBounds && current[ PARENT_BOUND_REF_COUNT ] > 0 );
 
-			}
+				this._updateBoundHelper( current, tileVisible );
 
-			if ( regionHelper ) {
+			} );
 
-				regionGroup.add( regionHelper );
-				regionHelper.updateMatrixWorld( true );
+		} else {
 
-			}
+			this._updateBoundHelper( tile, visible );
 
 		}
 
 	}
 
-	_onLoadModel( scene, tile ) {
-
-		tile[ LOAD_TIME ] = performance.now();
+	_createBoundHelper( tile ) {
 
 		const tiles = this.tiles;
 		const cached = tile.cached;
@@ -648,6 +669,111 @@ export class DebugTilesPlugin {
 
 		}
 
+	}
+
+	_updateHelperMaterial( tile, material ) {
+
+		if ( tile.__visible || ! this.displayParentBounds ) {
+
+			material.opacity = 1;
+
+		} else {
+
+			material.opacity = 0.2;
+
+		}
+
+		const transparent = material.transparent;
+		material.transparent = material.opacity < 1;
+		if ( material.transparent !== transparent ) {
+
+			material.needsUpdate = true;
+
+		}
+
+	}
+
+	_updateBoundHelper( tile, visible ) {
+
+		const cached = tile.cached;
+
+		if ( ! cached ) {
+
+			return;
+
+		}
+
+		const sphereGroup = this.sphereGroup;
+		const boxGroup = this.boxGroup;
+		const regionGroup = this.regionGroup;
+
+		if ( visible && ( cached.boxHelperGroup == null && cached.sphereHelper == null && cached.regionHelper == null ) ) {
+
+			this._createBoundHelper( tile );
+
+		}
+
+		const boxHelperGroup = cached.boxHelperGroup;
+		const sphereHelper = cached.sphereHelper;
+		const regionHelper = cached.regionHelper;
+
+		if ( ! visible ) {
+
+			if ( boxHelperGroup ) {
+
+				boxGroup.remove( boxHelperGroup );
+
+			}
+
+			if ( sphereHelper ) {
+
+				sphereGroup.remove( sphereHelper );
+
+			}
+
+			if ( regionHelper ) {
+
+				regionGroup.remove( regionHelper );
+
+			}
+
+		} else {
+
+			if ( boxHelperGroup ) {
+
+				boxGroup.add( boxHelperGroup );
+				boxHelperGroup.updateMatrixWorld( true );
+
+				this._updateHelperMaterial( tile, boxHelperGroup.children[ 0 ].material );
+
+			}
+
+			if ( sphereHelper ) {
+
+				sphereGroup.add( sphereHelper );
+				sphereHelper.updateMatrixWorld( true );
+
+				this._updateHelperMaterial( tile, sphereHelper.material );
+
+			}
+
+			if ( regionHelper ) {
+
+				regionGroup.add( regionHelper );
+				regionHelper.updateMatrixWorld( true );
+
+				this._updateHelperMaterial( tile, regionHelper.material );
+
+			}
+
+		}
+
+	}
+
+	_onLoadModel( scene, tile ) {
+
+		tile[ LOAD_TIME ] = performance.now();
+
 		// Cache the original materials
 		scene.traverse( c => {
 
@@ -698,6 +824,7 @@ export class DebugTilesPlugin {
 			tiles.removeEventListener( 'load-model', this._onLoadModelCB );
 			tiles.removeEventListener( 'dispose-model', this._onDisposeModelCB );
 			tiles.removeEventListener( 'update-after', this._onUpdateAfterCB );
+			tiles.removeEventListener( 'tile-visibility-change', this._onTileVisibilityChangeCB );
 
 			// reset all materials
 			this.colorMode = NONE;
diff --git a/src/plugins/three/objects/EllipsoidRegionHelper.js b/src/plugins/three/objects/EllipsoidRegionHelper.js
index 2b9a091..8d866c7 100644
--- a/src/plugins/three/objects/EllipsoidRegionHelper.js
+++ b/src/plugins/three/objects/EllipsoidRegionHelper.js
@@ -45,7 +45,7 @@ function toGroupGeometry( geometry ) {
 
 }
 
-function getRegionGeometry( ellipsoidRegion ) {
+function getRegionGeometry( ellipsoidRegion, { computeNormals = false } = {} ) {
 
 	// retrieve the relevant fields
 	const {
@@ -81,8 +81,12 @@ function getRegionGeometry( ellipsoidRegion ) {
 
 	}
 
-	// compute the vertex normals so we can get the edge normals
-	geometry.computeVertexNormals();
+	if ( computeNormals ) {
+
+		// compute the vertex normals so we can get the edge normals
+		geometry.computeVertexNormals();
+
+	}
 
 	// compute the top and bottom cap normals
 	for ( let i = 0, l = refPosition.count; i < l; i ++ ) {
@@ -159,7 +163,7 @@ export class EllipsoidRegionHelper extends Mesh {
 		this.geometry.dispose();
 
 		// retrieve the relevant fields
-		const geometry = getRegionGeometry( this.ellipsoidRegion );
+		const geometry = getRegionGeometry( this.ellipsoidRegion, { computeNormals: true } );
 		const { lonStart, lonEnd } = this;
 
 		// exclude the side tris if the region wraps around


Test patch:
diff --git a/test/traverseFunctions.test.js b/test/traverseFunctions.test.js
index bc0d82d..5f309d6 100644
--- a/test/traverseFunctions.test.js
+++ b/test/traverseFunctions.test.js
@@ -1,4 +1,4 @@
-import { traverseSet } from '../src/base/traverseFunctions.js';
+import { traverseAncestors, traverseSet } from '../src/base/traverseFunctions.js';
 
 describe( 'traverseSet', () => {
 
@@ -99,3 +99,30 @@ describe( 'traverseSet', () => {
 	} );
 
 } );
+
+describe( 'traverseAncestors', () => {
+
+	function makeTile( name, parent = undefined ) {
+
+		return { name, parent };
+
+	}
+
+	it( 'visit all ancestry chain', () => {
+
+		const root = makeTile( 'root' );
+
+		const lod1 = makeTile( '1', root );
+		const lod2 = makeTile( '2', lod1 );
+		const lod3 = makeTile( '3', lod2 );
+		const lod4 = makeTile( '4', lod3 );
+
+		const visited = [];
+
+		traverseAncestors( lod4, tile => visited.push( tile.name ) );
+
+		expect( visited ).toEqual( [ '4', '3', '2', '1', 'root' ] );
+
+	} );
+
+} );