diff --git a/devtools/panel/panel.css b/devtools/panel/panel.css
index 3de915c501ed77..a5ac3df2ea1e9d 100644
--- a/devtools/panel/panel.css
+++ b/devtools/panel/panel.css
@@ -85,6 +85,31 @@ hr {
margin-left: 0;
}
+/* Collapsible scene tree nodes */
+details.tree-node > summary.tree-item {
+ list-style: none;
+}
+details.tree-node > summary.tree-item::-webkit-details-marker {
+ display: none;
+}
+
+.tree-toggle,
+.tree-toggle-placeholder {
+ display: inline-block;
+ width: 1em;
+ margin-right: 2px;
+ text-align: center;
+ flex-shrink: 0;
+}
+.tree-toggle::before {
+ content: '▶';
+ font-size: 0.7em;
+ opacity: 0.6;
+}
+details.tree-node[open] > summary.tree-item .tree-toggle::before {
+ content: '▼';
+}
+
/* Style for clickable renderer summary */
.renderer-summary {
cursor: pointer;
diff --git a/devtools/panel/panel.js b/devtools/panel/panel.js
index 66411e17952c6f..2c142e767c0929 100644
--- a/devtools/panel/panel.js
+++ b/devtools/panel/panel.js
@@ -147,6 +147,9 @@ function requestObjectUnhighlight() {
// Store renderer collapse states
const rendererCollapsedState = new Map();
+// Store scene tree expanded states (uuid -> boolean). Defaults to expanded.
+const treeExpandedState = new Map();
+
// Static DOM elements (created once in initUI)
let renderersSection = null;
let scenesSection = null;
@@ -276,6 +279,7 @@ function clearState() {
state.scenes.clear();
state.renderers.clear();
state.objects.clear();
+ treeExpandedState.clear();
sceneDirty = true;
// Hide floating panel
@@ -427,22 +431,29 @@ function renderObject( obj, container, level = 0, parentInvisible = false ) {
const icon = getObjectIcon( obj );
let displayName = obj.name || obj.type;
- // Default rendering for other object types
- const elem = document.createElement( 'div' );
- elem.className = 'tree-item';
- elem.style.paddingLeft = `${level * 20}px`;
- elem.setAttribute( 'data-uuid', obj.uuid );
+ // Collect renderable children (renderers do not show children in the tree)
+ const children = ( ! obj.isRenderer && obj.children )
+ ? obj.children
+ .map( childId => state.objects.get( childId ) )
+ .filter( child => child !== undefined && child.name !== '__THREE_DEVTOOLS_HIGHLIGHT__' )
+ .sort( ( a, b ) => {
- // Apply opacity for invisible objects or if parent is invisible
- if ( obj.visible === false || parentInvisible ) {
+ const getTypeOrder = ( o ) => {
- elem.style.opacity = '0.5';
+ if ( o.isCamera ) return 1;
+ if ( o.isLight ) return 2;
+ if ( o.isGroup ) return 3;
+ if ( o.isMesh ) return 4;
+ return 5;
- }
+ };
- let labelContent = `${icon}
- ${displayName}
- ${obj.type}`;
+ return getTypeOrder( a ) - getTypeOrder( b );
+
+ } )
+ : [];
+
+ const hasChildren = children.length > 0;
if ( obj.isScene ) {
@@ -466,70 +477,104 @@ function renderObject( obj, container, level = 0, parentInvisible = false ) {
countObjects( obj.uuid );
displayName = `${obj.name || obj.type} ${objectCount} objects`;
- labelContent = `${icon}
- ${displayName}
- ${obj.type}`;
}
- elem.innerHTML = labelContent;
+ const togglePart = hasChildren
+ ? ''
+ : '';
- // Add mouseenter handler to request object details and highlight in 3D
- elem.addEventListener( 'mouseenter', () => {
- requestObjectDetails( obj.uuid );
- // Only highlight if object and all parents are visible
- if ( obj.visible !== false && ! parentInvisible ) {
+ const labelContent = `${togglePart}${icon}
+ ${displayName}
+ ${obj.type}`;
- requestObjectHighlight( obj.uuid );
+ let header; // the element receiving hover/highlight handlers
- }
- } );
+ if ( hasChildren ) {
- // Add mouseleave handler to remove 3D highlight
- elem.addEventListener( 'mouseleave', () => {
- requestObjectUnhighlight();
- } );
+ const node = document.createElement( 'details' );
+ node.className = 'tree-node';
+ node.setAttribute( 'data-uuid', obj.uuid );
- container.appendChild( elem );
+ // Default to expanded unless the user has collapsed this node before
+ const stored = treeExpandedState.get( obj.uuid );
+ node.open = stored === undefined ? true : stored;
- // Handle children (excluding children of renderers, as properties are shown in details)
- if ( ! obj.isRenderer && obj.children && obj.children.length > 0 ) {
+ node.addEventListener( 'toggle', () => {
- // Create a container for children
- const childContainer = document.createElement( 'div' );
- childContainer.className = 'children';
- container.appendChild( childContainer );
+ treeExpandedState.set( obj.uuid, node.open );
- // Get all children and sort them by type for better organization
- const children = obj.children
- .map( childId => state.objects.get( childId ) )
- .filter( child => child !== undefined && child.name !== '__THREE_DEVTOOLS_HIGHLIGHT__' )
- .sort( ( a, b ) => {
+ } );
- const getTypeOrder = ( obj ) => {
- if ( obj.isCamera ) return 1;
- if ( obj.isLight ) return 2;
- if ( obj.isGroup ) return 3;
- if ( obj.isMesh ) return 4;
- return 5;
- };
+ const summary = document.createElement( 'summary' );
+ summary.className = 'tree-item';
+ summary.style.paddingLeft = `${level * 20}px`;
+
+ if ( obj.visible === false || parentInvisible ) {
- const aOrder = getTypeOrder( a );
- const bOrder = getTypeOrder( b );
+ summary.style.opacity = '0.5';
- return aOrder - bOrder;
+ }
- } );
+ summary.innerHTML = labelContent;
+ node.appendChild( summary );
+
+ const childContainer = document.createElement( 'div' );
+ childContainer.className = 'children';
+ node.appendChild( childContainer );
+
+ container.appendChild( node );
- // Render each child
children.forEach( child => {
renderObject( child, childContainer, level + 1, parentInvisible || obj.visible === false );
} );
+ header = summary;
+
+ } else {
+
+ const elem = document.createElement( 'div' );
+ elem.className = 'tree-item';
+ elem.style.paddingLeft = `${level * 20}px`;
+ elem.setAttribute( 'data-uuid', obj.uuid );
+
+ if ( obj.visible === false || parentInvisible ) {
+
+ elem.style.opacity = '0.5';
+
+ }
+
+ elem.innerHTML = labelContent;
+
+ container.appendChild( elem );
+
+ header = elem;
+
}
+ // Add mouseenter handler to request object details and highlight in 3D
+ header.addEventListener( 'mouseenter', () => {
+
+ requestObjectDetails( obj.uuid );
+
+ // Only highlight if object and all parents are visible
+ if ( obj.visible !== false && ! parentInvisible ) {
+
+ requestObjectHighlight( obj.uuid );
+
+ }
+
+ } );
+
+ // Add mouseleave handler to remove 3D highlight
+ header.addEventListener( 'mouseleave', () => {
+
+ requestObjectUnhighlight();
+
+ } );
+
}
// Build the static DOM shell (called once)
diff --git a/src/materials/MeshBasicMaterial.js b/src/materials/MeshBasicMaterial.js
index e2f2aaa4ef099c..eb11ed13b5e413 100644
--- a/src/materials/MeshBasicMaterial.js
+++ b/src/materials/MeshBasicMaterial.js
@@ -62,7 +62,7 @@ class MeshBasicMaterial extends Material {
/**
* The light map. Requires a second set of UVs.
*
- * `lightMap` represents luminance data, and the texture must be assigned
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
* a {@link Texture#colorSpace}. Most `lightMap` textures set
* `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
* such as `.exr` or `.hdr`.
diff --git a/src/materials/MeshLambertMaterial.js b/src/materials/MeshLambertMaterial.js
index 7e27c162a01ed6..a27f5065c25503 100644
--- a/src/materials/MeshLambertMaterial.js
+++ b/src/materials/MeshLambertMaterial.js
@@ -72,7 +72,7 @@ class MeshLambertMaterial extends Material {
/**
* The light map. Requires a second set of UVs.
*
- * `lightMap` represents luminance data, and the texture must be assigned
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
* a {@link Texture#colorSpace}. Most `lightMap` textures set
* `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
* such as `.exr` or `.hdr`.
diff --git a/src/materials/MeshPhongMaterial.js b/src/materials/MeshPhongMaterial.js
index aa5b56bef4a329..182488cdb125f8 100644
--- a/src/materials/MeshPhongMaterial.js
+++ b/src/materials/MeshPhongMaterial.js
@@ -87,7 +87,7 @@ class MeshPhongMaterial extends Material {
/**
* The light map. Requires a second set of UVs.
*
- * `lightMap` represents luminance data, and the texture must be assigned
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
* a {@link Texture#colorSpace}. Most `lightMap` textures set
* `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
* such as `.exr` or `.hdr`.
diff --git a/src/materials/MeshStandardMaterial.js b/src/materials/MeshStandardMaterial.js
index 0bde1d771a38bb..ab9b81c3d97912 100644
--- a/src/materials/MeshStandardMaterial.js
+++ b/src/materials/MeshStandardMaterial.js
@@ -112,7 +112,7 @@ class MeshStandardMaterial extends Material {
/**
* The light map. Requires a second set of UVs.
*
- * `lightMap` represents luminance data, and the texture must be assigned
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
* a {@link Texture#colorSpace}. Most `lightMap` textures set
* `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
* such as `.exr` or `.hdr`.
diff --git a/src/materials/MeshToonMaterial.js b/src/materials/MeshToonMaterial.js
index 6169941859c7a4..7d3362852e299b 100644
--- a/src/materials/MeshToonMaterial.js
+++ b/src/materials/MeshToonMaterial.js
@@ -75,7 +75,7 @@ class MeshToonMaterial extends Material {
/**
* The light map. Requires a second set of UVs.
*
- * `lightMap` represents luminance data, and the texture must be assigned
+ * `lightMap` represents pre-baked illuminance data, and the texture must be assigned
* a {@link Texture#colorSpace}. Most `lightMap` textures set
* `texture.colorSpace = LinearSRGBColorSpace` and use float-type formats
* such as `.exr` or `.hdr`.