Explorar o código

Added new DevTools (#30870)

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Chris Smoak <chris.smoak+@gmail.com>
Co-authored-by: Jasiel Guillén <darkensses@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mrdoob hai 1 semana
pai
achega
2a4e27e261

+ 98 - 0
devtools/README.md

@@ -0,0 +1,98 @@
+# Three.js DevTools Extension
+
+This Chrome DevTools extension provides debugging capabilities for Three.js applications. It allows you to inspect scenes, objects, materials, and renderers.
+
+## Installation
+
+1. **Development Mode**:
+   - Open Chrome and navigate to `chrome://extensions/`
+   - Enable "Developer mode" (toggle in the top-right corner)
+   - Click "Load unpacked" and select the `devtools` directory
+   - The extension will now be available in Chrome DevTools when inspecting pages that use Three.js
+
+2. **Usage**:
+   - Open Chrome DevTools on a page using Three.js (F12 or Right-click > Inspect)
+   - Click on the "Three.js" tab in DevTools
+   - The panel will automatically detect and display Three.js scenes and renderers found on the page.
+
+## Code Flow Overview
+
+### Extension Architecture
+
+The extension follows a standard Chrome DevTools extension architecture:
+
+1. **Background Script** (`background.js`): Manages the extension lifecycle and communication ports between the panel and content script.
+2. **DevTools Script** (`devtools.js`): Creates the panel when the DevTools window opens.
+3. **Panel UI** (`panel/panel.html`, `panel/panel.js`, `panel/panel.css`): The DevTools panel interface that displays the data.
+4. **Content Script** (`content-script.js`): Injected into the web page. Relays messages between the background script and the bridge script.
+5. **Bridge Script** (`bridge.js`): Injected into the page's main world via the manifest. Directly interacts with the Three.js instance, detects objects, gathers data, and communicates back via the content script.
+
+### Initialization Flow
+
+1. When a page loads, Chrome injects `bridge.js` into the page's main world (including iframes).
+2. `bridge.js` creates the `window.__THREE_DEVTOOLS__` global object.
+3. When the DevTools panel is opened, `panel.js` connects to `background.js` (`init`) and immediately requests the current state (`request-state`).
+4. `background.js` relays the state request to `content-script.js`, which posts it to `bridge.js`.
+5. `bridge.js` responds by sending back observed renderer data (`renderer` message) and batched scene data (`scene` message).
+6. Three.js detects `window.__THREE_DEVTOOLS__` and sends registration/observation events to the bridge script as objects are created or the library initializes.
+
+### Bridge Operation (`bridge.js`)
+
+The bridge acts as the communication layer between the Three.js instance on the page and the DevTools panel:
+
+1. **Event Management**: Creates a custom event target (`DevToolsEventTarget`) to manage communication readiness and backlog events before the panel connects.
+2. **Object Tracking**:
+   - `getObjectData()`: Extracts essential data (UUID, type, name, parent, children, etc.) from Three.js objects.
+   - Maintains a local map (`devTools.objects`) of all observed objects.
+
+3. **Initial Observation & Batching**:
+   - When Three.js sends an `observe` event (via `window.__THREE_DEVTOOLS__.dispatchEvent`):
+     - If it's a renderer, its data is collected and sent immediately via a `'renderer'` message.
+     - If it's a scene, the bridge traverses the entire scene graph, collects data for the scene and all descendants, stores them locally, and sends them to the panel in a single `'scene'` batch message.
+
+4. **State Request Handling**:
+   - When the panel sends `request-state` (on load/reload), the bridge iterates its known objects and sends back the current renderer data (`'renderer'`) and scene data (`'scene'` batch).
+
+5. **Message Handling**:
+   - Listens for messages from the panel (relayed via content script) like `request-state`.
+
+### Panel Interface (`panel/`)
+
+The panel UI provides the visual representation of the Three.js objects:
+
+1. **Tree View**: Displays hierarchical representation of scenes and objects.
+2. **Renderer Details**: Shows properties and statistics for renderers in a collapsible section.
+
+## Key Features
+
+- **Scene Hierarchy Visualization**: Browse the complete scene graph.
+- **Object Inspection**: View basic object properties (type, name).
+- **Renderer Details**: View properties, render stats, and memory usage for `WebGLRenderer` instances.
+
+## Communication Flow
+
+1. **Panel ↔ Background ↔ Content Script**: Standard extension messaging for panel initialization and state requests (`init`, `request-state`).
+2. **Three.js → Bridge**: Three.js detects `window.__THREE_DEVTOOLS__` and uses its `dispatchEvent` method (sending `'register'`, `'observe'`).
+3. **Bridge → Content Script**: Bridge uses `window.postMessage` to send data (`'register'`, `'renderer'`, `'scene'`, `'update'`) to the content script.
+4. **Content Script → Background**: Content script uses `chrome.runtime.sendMessage` to relay messages from the bridge to the background.
+5. **Background → Panel**: Background script uses the established port connection (`port.postMessage`) to send data to the panel.
+
+## Key Components
+
+- **DevToolsEventTarget**: Custom event system with backlogging for async loading.
+- **Object Observation & Batching**: Efficiently tracks and sends scene graph data.
+- **Renderer Property Display**: Shows detailed statistics for renderers.
+
+## Integration with Three.js
+
+The extension relies on Three.js having built-in support for DevTools. When Three.js detects the presence of `window.__THREE_DEVTOOLS__`, it interacts with it, primarily by dispatching events.
+
+The bridge script listens for these events, organizes the data, and provides it to the DevTools panel.
+
+## Development
+
+To modify the extension:
+
+1. Edit the relevant files in the `devtools` directory.
+2. Go to `chrome://extensions/`, find the unpacked extension, and click the reload icon.
+3. Close and reopen DevTools on the inspected page to see your changes.

+ 185 - 0
devtools/background.js

@@ -0,0 +1,185 @@
+/* global chrome */
+
+importScripts( 'constants.js' );
+
+// Map tab IDs to connections
+const connections = new Map();
+
+// Handle extension icon clicks in the toolbar
+chrome.action.onClicked.addListener( ( tab ) => {
+
+	// Send scroll-to-canvas message to the content script (no UUID = scroll to first canvas)
+	chrome.tabs.sendMessage( tab.id, {
+		name: MESSAGE_SCROLL_TO_CANVAS,
+		tabId: tab.id
+	} ).catch( () => {
+
+		// Ignore error - tab might not have the content script injected
+		console.log( 'Could not send scroll-to-canvas message to tab', tab.id );
+
+	} );
+
+} );
+
+// Listen for connections from the devtools panel
+chrome.runtime.onConnect.addListener( port => {
+
+	let tabId;
+
+	// Messages that should be forwarded to content script
+	const forwardableMessages = new Set( [
+		MESSAGE_REQUEST_STATE,
+		MESSAGE_REQUEST_OBJECT_DETAILS,
+		MESSAGE_SCROLL_TO_CANVAS,
+		MESSAGE_HIGHLIGHT_OBJECT,
+		MESSAGE_UNHIGHLIGHT_OBJECT
+	] );
+
+	// Listen for messages from the devtools panel
+	port.onMessage.addListener( message => {
+
+		if ( message.name === MESSAGE_INIT ) {
+
+			tabId = message.tabId;
+			connections.set( tabId, port );
+
+		} else if ( forwardableMessages.has( message.name ) && tabId ) {
+
+			chrome.tabs.sendMessage( tabId, message );
+
+		} else if ( tabId === undefined ) {
+
+			console.warn( 'Background: Message received from panel before init:', message );
+
+		}
+
+	} );
+
+	// Clean up when devtools is closed
+	port.onDisconnect.addListener( () => {
+
+		if ( tabId ) {
+
+			connections.delete( tabId );
+
+		}
+
+	} );
+
+} );
+
+// Listen for messages from the content script
+chrome.runtime.onMessage.addListener( ( message, sender, sendResponse ) => {
+
+	if ( message.scheme ) {
+
+		chrome.action.setIcon( {
+			path: {
+				128: `icons/128-${message.scheme}.png`
+			}
+		} );
+
+	}
+
+	if ( sender.tab ) {
+
+		const tabId = sender.tab.id;
+
+		// If three.js is detected, show a badge
+		if ( message.name === MESSAGE_REGISTER && message.detail && message.detail.revision ) {
+
+			const revision = String( message.detail.revision );
+			const number = revision.replace( /\D+$/, '' );
+			const isDev = revision.includes( 'dev' );
+
+			chrome.action.setBadgeText( { tabId: tabId, text: number } ).catch( () => {
+
+				// Ignore error - tab might have been closed
+
+			} );
+			chrome.action.setBadgeTextColor( { tabId: tabId, color: '#ffffff' } ).catch( () => {
+
+				// Ignore error - tab might have been closed
+
+			} );
+			chrome.action.setBadgeBackgroundColor( { tabId: tabId, color: isDev ? '#ff0098' : '#049ef4' } ).catch( () => {
+
+				// Ignore error - tab might have been closed
+
+			} );
+
+		}
+
+		const port = connections.get( tabId );
+		if ( port ) {
+
+			// Forward the message to the devtools panel
+			try {
+
+				port.postMessage( message );
+				// Send immediate response to avoid "message channel closed" error
+				sendResponse( { received: true } );
+
+			} catch ( e ) {
+
+				console.error( 'Error posting message to devtools:', e );
+				// If the port is broken, clean up the connection
+				connections.delete( tabId );
+
+			}
+
+		}
+
+	}
+
+	return false; // Return false to indicate synchronous handling
+
+} );
+
+// Listen for page navigation events
+chrome.webNavigation.onCommitted.addListener( details => {
+
+	const { tabId, frameId } = details;
+
+	// Clear badge on navigation, only for top-level navigation
+	if ( frameId === 0 ) {
+
+		chrome.action.setBadgeText( { tabId: tabId, text: '' } ).catch( () => {
+
+			// Ignore error - tab might have been closed
+
+		} );
+
+	}
+
+	const port = connections.get( tabId );
+
+	if ( port ) {
+
+		port.postMessage( {
+			id: MESSAGE_ID,
+			name: MESSAGE_COMMITTED,
+			frameId: frameId
+		} );
+
+	}
+
+} );
+
+// Clear badge when a tab is closed
+chrome.tabs.onRemoved.addListener( ( tabId ) => {
+
+	chrome.action.setBadgeText( { tabId: tabId, text: '' } ).catch( () => {
+
+		// Ignore error - tab is already gone
+
+	} );
+
+	// Clean up connection if it exists for the closed tab
+	if ( connections.has( tabId ) ) {
+
+		connections.delete( tabId );
+
+	}
+
+} );

+ 630 - 0
devtools/bridge.js

@@ -0,0 +1,630 @@
+/**
+ * This script injected by the installed three.js developer
+ * tools extension.
+ */
+
+( function () {
+
+	const HIGHLIGHT_OVERLAY_DURATION = 1000;
+
+	// Only initialize if not already initialized
+	if ( ! window.__THREE_DEVTOOLS__ ) {
+
+		// Create our custom EventTarget with logging
+		class DevToolsEventTarget extends EventTarget {
+
+			constructor() {
+
+				super();
+				this._ready = false;
+				this._backlog = [];
+				this.objects = new Map();
+
+			}
+
+			addEventListener( type, listener, options ) {
+
+				super.addEventListener( type, listener, options );
+
+				// If this is the first listener for a type, and we have backlogged events,
+				// check if we should process them
+				if ( type !== EVENT_DEVTOOLS_READY && this._backlog.length > 0 ) {
+
+					this.dispatchEvent( new CustomEvent( EVENT_DEVTOOLS_READY ) );
+
+				}
+
+			}
+
+			dispatchEvent( event ) {
+
+				if ( this._ready || event.type === EVENT_DEVTOOLS_READY ) {
+
+					if ( event.type === EVENT_DEVTOOLS_READY ) {
+
+						this._ready = true;
+						const backlog = this._backlog;
+						this._backlog = [];
+						backlog.forEach( e => super.dispatchEvent( e ) );
+
+					}
+
+					return super.dispatchEvent( event );
+
+				} else {
+
+					this._backlog.push( event );
+					return false; // Return false to indicate synchronous handling
+
+				}
+
+			}
+
+			reset() {
+
+
+				// Clear objects map
+				this.objects.clear();
+
+				// Clear backlog
+				this._backlog = [];
+
+				// Reset ready state
+				this._ready = false;
+
+				// Clear observed arrays
+				observedScenes.length = 0;
+				observedRenderers.length = 0;
+
+			}
+
+		}
+
+		// Create and expose the __THREE_DEVTOOLS__ object
+		const devTools = new DevToolsEventTarget();
+		Object.defineProperty( window, '__THREE_DEVTOOLS__', {
+			value: devTools,
+			configurable: false,
+			enumerable: true,
+			writable: false
+		} );
+
+		// Declare arrays for tracking observed objects
+		const observedScenes = [];
+		const observedRenderers = [];
+		const sceneObjectCountCache = new Map(); // Cache for object counts per scene
+
+		// Shared tree traversal function
+		function traverseObjectTree( rootObject, callback, skipDuplicates = false ) {
+
+			const processedUUIDs = skipDuplicates ? new Set() : null;
+
+			function traverse( object ) {
+
+				if ( ! object || ! object.uuid ) return;
+
+				// Skip DevTools highlight objects
+				if ( object.name === '__THREE_DEVTOOLS_HIGHLIGHT__' ) return;
+
+				// Skip if already processed (when duplicate prevention is enabled)
+				if ( processedUUIDs && processedUUIDs.has( object.uuid ) ) return;
+				if ( processedUUIDs ) processedUUIDs.add( object.uuid );
+
+				// Execute callback for this object
+				callback( object );
+
+				// Process children recursively
+				if ( object.children && Array.isArray( object.children ) ) {
+
+					object.children.forEach( child => traverse( child ) );
+
+				}
+
+			}
+
+			traverse( rootObject );
+
+		}
+
+		// Function to get renderer data
+		function getRendererData( renderer ) {
+
+			try {
+
+				const data = {
+					uuid: renderer.uuid || generateUUID(),
+					type: renderer.isWebGLRenderer ? 'WebGLRenderer' : 'WebGPURenderer',
+					name: '',
+					properties: getRendererProperties( renderer ),
+					canvasInDOM: renderer.domElement && document.contains( renderer.domElement )
+				};
+				return data;
+
+			} catch ( error ) {
+
+				console.warn( 'DevTools: Error getting renderer data:', error );
+				return null;
+
+			}
+
+		}
+
+		// Function to get object hierarchy
+		function getObjectData( obj ) {
+
+			try {
+
+				// Special case for WebGLRenderer
+				if ( obj.isWebGLRenderer === true || obj.isWebGPURenderer === true ) {
+
+					return getRendererData( obj );
+
+				}
+
+				// Special case for InstancedMesh
+				const type = obj.isInstancedMesh ? 'InstancedMesh' : obj.type || obj.constructor.name;
+
+				// Get descriptive name for the object
+				let name = obj.name || type || obj.constructor.name;
+				if ( obj.isMesh ) {
+
+					const geoType = obj.geometry ? obj.geometry.type : 'Unknown';
+					const matType = obj.material ?
+						( Array.isArray( obj.material ) ?
+							obj.material.map( m => m.type ).join( ', ' ) :
+							obj.material.type ) :
+						'Unknown';
+					if ( obj.isInstancedMesh ) {
+
+						name = `${name} [${obj.count}]`;
+
+					}
+
+					name = `${name} <span class="object-details">${geoType} ${matType}</span>`;
+
+				}
+
+				const data = {
+					uuid: obj.uuid,
+					name: name,
+					type: type,
+					visible: obj.visible !== undefined ? obj.visible : true,
+					isScene: obj.isScene === true,
+					isObject3D: obj.isObject3D === true,
+					isCamera: obj.isCamera === true,
+					isLight: obj.isLight === true,
+					isMesh: obj.isMesh === true,
+					isInstancedMesh: obj.isInstancedMesh === true,
+					parent: obj.parent ? obj.parent.uuid : null,
+					children: obj.children ? obj.children.map( child => child.uuid ) : []
+				};
+
+				return data;
+
+			} catch ( error ) {
+
+				console.warn( 'DevTools: Error getting object data:', error );
+				return null;
+
+			}
+
+		}
+
+		// Generate a UUID for objects that don't have one
+		function generateUUID() {
+
+			const array = new Uint8Array( 16 );
+			crypto.getRandomValues( array );
+			array[ 6 ] = ( array[ 6 ] & 0x0f ) | 0x40; // Set version to 4
+			array[ 8 ] = ( array[ 8 ] & 0x3f ) | 0x80; // Set variant to 10
+			return [ ...array ].map( ( b, i ) => ( i === 4 || i === 6 || i === 8 || i === 10 ? '-' : '' ) + b.toString( 16 ).padStart( 2, '0' ) ).join( '' );
+
+		}
+
+		// Listen for Three.js registration
+		devTools.addEventListener( EVENT_REGISTER, ( event ) => {
+
+			dispatchEvent( EVENT_REGISTER, event.detail );
+
+		} );
+
+		// Listen for object observations
+		devTools.addEventListener( EVENT_OBSERVE, ( event ) => {
+
+			const obj = event.detail;
+			if ( ! obj ) {
+
+				console.warn( 'DevTools: Received observe event with null/undefined detail' );
+				return;
+
+			}
+
+			// Generate UUID if needed
+			if ( ! obj.uuid ) {
+
+				obj.uuid = generateUUID();
+
+			}
+
+			// Skip if already registered (essential to prevent loops with batching)
+			if ( devTools.objects.has( obj.uuid ) ) {
+
+				return;
+
+			}
+
+			if ( obj.isWebGLRenderer || obj.isWebGPURenderer ) {
+
+				const data = getObjectData( obj );
+
+				if ( data ) {
+
+					data.properties = getRendererProperties( obj );
+					observedRenderers.push( obj );
+					devTools.objects.set( obj.uuid, data );
+
+					dispatchEvent( EVENT_RENDERER, data );
+
+				}
+
+			} else if ( obj.isScene ) {
+
+				observedScenes.push( obj );
+
+				const batchObjects = [];
+
+				traverseObjectTree( obj, ( currentObj ) => {
+
+					const objectData = getObjectData( currentObj );
+					if ( objectData ) {
+
+						batchObjects.push( objectData );
+						devTools.objects.set( currentObj.uuid, objectData ); // Update local cache during batch creation
+
+					}
+
+				}, true );
+
+				dispatchEvent( EVENT_SCENE, { sceneUuid: obj.uuid, objects: batchObjects } );
+
+			}
+
+		} );
+
+		// Function to get renderer properties
+		function getRendererProperties( renderer ) {
+
+			const parameters = renderer.getContextAttributes ? renderer.getContextAttributes() : {};
+
+			return {
+				width: renderer.domElement ? renderer.domElement.clientWidth : 0,
+				height: renderer.domElement ? renderer.domElement.clientHeight : 0,
+				alpha: parameters.alpha || false,
+				antialias: parameters.antialias || false,
+				outputColorSpace: renderer.outputColorSpace,
+				toneMapping: renderer.toneMapping,
+				toneMappingExposure: renderer.toneMappingExposure !== undefined ? renderer.toneMappingExposure : 1,
+				shadows: renderer.shadowMap ? renderer.shadowMap.enabled : false,
+				autoClear: renderer.autoClear,
+				autoClearColor: renderer.autoClearColor,
+				autoClearDepth: renderer.autoClearDepth,
+				autoClearStencil: renderer.autoClearStencil,
+				localClipping: renderer.localClippingEnabled,
+				physicallyCorrectLights: renderer.physicallyCorrectLights || false, // Assuming false is default if undefined
+				info: {
+					render: {
+						frame: renderer.info.render.frame,
+						calls: renderer.isWebGPURenderer ? renderer.info.render.drawCalls : renderer.info.render.calls,
+						triangles: renderer.info.render.triangles,
+						points: renderer.info.render.points,
+						lines: renderer.info.render.lines,
+						geometries: renderer.info.render.geometries,
+						sprites: renderer.info.render.sprites
+					},
+					memory: {
+						geometries: renderer.info.memory.geometries,
+						textures: renderer.info.memory.textures,
+						programs: renderer.info.programs ? renderer.info.programs.length : 0,
+						renderLists: renderer.info.memory.renderLists,
+						renderTargets: renderer.info.memory.renderTargets
+					}
+				}
+			};
+
+		}
+
+
+		// Function to check if bridge is available
+		function checkBridgeAvailability() {
+
+			const devToolsValue = window.__THREE_DEVTOOLS__;
+
+			// If we have devtools and we're interactive or complete, trigger ready
+			if ( devToolsValue && ( document.readyState === 'interactive' || document.readyState === 'complete' ) ) {
+
+				devTools.dispatchEvent( new CustomEvent( EVENT_DEVTOOLS_READY ) );
+
+			}
+
+		}
+
+		// Watch for readyState changes
+		document.addEventListener( 'readystatechange', () => {
+
+			if ( document.readyState === 'loading' ) {
+
+				devTools.reset();
+
+			}
+
+			checkBridgeAvailability();
+
+		} );
+
+		// Check if THREE is in the global scope (Old versions)
+		window.addEventListener( 'load', () => {
+
+			if ( window.THREE && window.THREE.REVISION ) {
+
+				dispatchEvent( EVENT_REGISTER, { revision: window.THREE.REVISION } );
+
+			}
+
+		} );
+
+		// Watch for page unload to reset state
+		window.addEventListener( 'beforeunload', () => {
+
+			devTools.reset();
+
+		} );
+
+		// Listen for messages from the content script
+		window.addEventListener( 'message', function ( event ) {
+
+			// Only accept messages from the same frame
+			if ( event.source !== window ) return;
+
+			const message = event.data;
+			if ( ! message || message.id !== MESSAGE_ID ) return;
+
+			// Handle request for initial state from panel
+			if ( message.name === MESSAGE_REQUEST_STATE ) {
+
+				sendState();
+
+			} else if ( message.name === MESSAGE_REQUEST_OBJECT_DETAILS ) {
+
+				sendObjectDetails( message.uuid );
+
+			} else if ( message.name === MESSAGE_SCROLL_TO_CANVAS ) {
+
+				scrollToCanvas( message.uuid );
+
+			} else if ( message.name === MESSAGE_HIGHLIGHT_OBJECT ) {
+
+				devTools.dispatchEvent( new CustomEvent( 'highlight-object', { detail: { uuid: message.uuid } } ) );
+
+			} else if ( message.name === MESSAGE_UNHIGHLIGHT_OBJECT ) {
+
+				devTools.dispatchEvent( new CustomEvent( 'unhighlight-object' ) );
+
+			}
+
+		} );
+
+		function sendState() {
+
+			// Send current renderers
+			for ( const observedRenderer of observedRenderers ) {
+
+				const data = getObjectData( observedRenderer );
+				if ( data ) {
+
+					data.properties = getRendererProperties( observedRenderer );
+					dispatchEvent( EVENT_RENDERER, data );
+
+				}
+
+			}
+
+			// Send current scenes
+			for ( const observedScene of observedScenes ) {
+
+				reloadSceneObjects( observedScene );
+
+			}
+
+		}
+
+		function findObjectInScenes( uuid ) {
+
+			for ( const scene of observedScenes ) {
+
+				// Check if we're looking for the scene itself
+				if ( scene.uuid === uuid ) return scene;
+
+				const found = scene.getObjectByProperty( 'uuid', uuid );
+				if ( found ) return found;
+
+			}
+
+			return null;
+
+		}
+
+		// Expose utilities for highlight.js in a clean namespace
+		devTools.utils = {
+			findObjectInScenes,
+			generateUUID
+		};
+
+		// Expose renderers array for highlight.js
+		devTools.renderers = observedRenderers;
+
+		function createHighlightOverlay( targetElement ) {
+
+			const overlay = document.createElement( 'div' );
+			overlay.style.cssText = `
+				position: absolute;
+				top: 0;
+				left: 0;
+				width: 100%;
+				height: 100%;
+				background-color: rgba(0, 122, 204, 0.3);
+				pointer-events: none;
+				z-index: 999999;
+			`;
+
+			// Position the overlay relative to the target
+			const parent = targetElement.parentElement || document.body;
+
+			if ( getComputedStyle( parent ).position === 'static' ) {
+
+				parent.style.position = 'relative';
+
+			}
+
+			parent.appendChild( overlay );
+
+			// Auto-remove after duration
+			setTimeout( () => {
+
+				if ( overlay.parentElement ) {
+
+					overlay.parentElement.removeChild( overlay );
+
+				}
+
+			}, HIGHLIGHT_OVERLAY_DURATION );
+
+		}
+
+		function sendObjectDetails( uuid ) {
+
+			const object = findObjectInScenes( uuid );
+
+			if ( object ) {
+
+				const details = {
+					uuid: object.uuid,
+					type: object.type,
+					name: object.name,
+					position: {
+						x: object.position.x,
+						y: object.position.y,
+						z: object.position.z
+					},
+					rotation: {
+						x: object.rotation.x,
+						y: object.rotation.y,
+						z: object.rotation.z
+					},
+					scale: {
+						x: object.scale.x,
+						y: object.scale.y,
+						z: object.scale.z
+					}
+				};
+
+				dispatchEvent( EVENT_OBJECT_DETAILS, details );
+
+			}
+
+		}
+
+		function scrollToCanvas( uuid ) {
+
+			let renderer = null;
+
+			if ( uuid ) {
+
+				// Find the renderer with the given UUID
+				renderer = observedRenderers.find( r => r.uuid === uuid );
+
+			} else {
+
+				// If no UUID provided, find the first available renderer whose canvas is in the DOM
+				renderer = observedRenderers.find( r => r.domElement && document.body.contains( r.domElement ) );
+
+			}
+
+			if ( renderer ) {
+
+				// Scroll the canvas element into view
+				renderer.domElement.scrollIntoView( {
+					behavior: 'smooth',
+					block: 'center',
+					inline: 'center'
+				} );
+
+				// Add a brief blue overlay flash effect
+				createHighlightOverlay( renderer.domElement );
+
+			}
+
+		}
+
+		function dispatchEvent( name, detail ) {
+
+			try {
+
+				window.postMessage( {
+					id: MESSAGE_ID,
+					name: name,
+					detail: detail
+				}, '*' );
+
+			} catch ( error ) {
+
+				// If we get an "Extension context invalidated" error, stop all monitoring
+				if ( error.message.includes( 'Extension context invalidated' ) ) {
+
+					console.log( 'DevTools: Extension context invalidated, stopping monitoring' );
+					devTools.reset();
+					return;
+
+				}
+
+				console.warn( 'DevTools: Error dispatching event:', error );
+
+			}
+
+		}
+
+		// Function to manually reload scene objects
+		function reloadSceneObjects( scene ) {
+
+			const batchObjects = [];
+
+			traverseObjectTree( scene, ( object ) => {
+
+				const objectData = getObjectData( object );
+				if ( objectData ) {
+
+					batchObjects.push( objectData ); // Add to batch
+					// Update or add to local cache immediately
+					devTools.objects.set( object.uuid, objectData );
+
+				}
+
+			} );
+
+			// --- Caching Logic ---
+			const currentObjectCount = batchObjects.length;
+			const previousObjectCount = sceneObjectCountCache.get( scene.uuid );
+
+			if ( currentObjectCount !== previousObjectCount ) {
+
+				// Dispatch the batch update for the panel
+				dispatchEvent( EVENT_SCENE, { sceneUuid: scene.uuid, objects: batchObjects } );
+				// Update the cache
+				sceneObjectCountCache.set( scene.uuid, currentObjectCount );
+
+			}
+
+		}
+
+	}
+
+} )();

+ 22 - 0
devtools/constants.js

@@ -0,0 +1,22 @@
+// Shared protocol constants for Three.js DevTools
+
+var MESSAGE_ID = 'three-devtools';
+
+// Chrome extension messages
+var MESSAGE_INIT = 'init';
+var MESSAGE_REQUEST_STATE = 'request-state';
+var MESSAGE_REQUEST_OBJECT_DETAILS = 'request-object-details';
+var MESSAGE_SCROLL_TO_CANVAS = 'scroll-to-canvas';
+var MESSAGE_HIGHLIGHT_OBJECT = 'highlight-object';
+var MESSAGE_UNHIGHLIGHT_OBJECT = 'unhighlight-object';
+var MESSAGE_REGISTER = 'register';
+var MESSAGE_COMMITTED = 'committed';
+
+// Bridge/DevTools events
+var EVENT_REGISTER = 'register';
+var EVENT_OBSERVE = 'observe';
+var EVENT_RENDERER = 'renderer';
+var EVENT_SCENE = 'scene';
+var EVENT_OBJECT_DETAILS = 'object-details';
+var EVENT_DEVTOOLS_READY = 'devtools-ready';
+var EVENT_COMMITTED = 'committed';

+ 80 - 0
devtools/content-script.js

@@ -0,0 +1,80 @@
+/* global chrome */
+
+// Constants
+const MESSAGE_ID = 'three-devtools';
+const MESSAGE_REQUEST_STATE = 'request-state';
+const MESSAGE_REQUEST_OBJECT_DETAILS = 'request-object-details';
+const MESSAGE_SCROLL_TO_CANVAS = 'scroll-to-canvas';
+const MESSAGE_HIGHLIGHT_OBJECT = 'highlight-object';
+const MESSAGE_UNHIGHLIGHT_OBJECT = 'unhighlight-object';
+
+// Helper to check if extension context is valid
+function isExtensionContextValid() {
+
+	try {
+
+		chrome.runtime.getURL( '' );
+		return true;
+
+	} catch ( error ) {
+
+		return false;
+
+	}
+
+}
+
+// Unified message handler for window messages
+function handleWindowMessage( event ) {
+
+	// Only accept messages with the correct id
+	if ( ! event.data || event.data.id !== MESSAGE_ID ) return;
+
+	// Determine source: 'main' for window, 'iframe' otherwise
+	const source = event.source === window ? 'main' : 'iframe';
+
+	if ( ! isExtensionContextValid() ) {
+
+		console.warn( 'Extension context invalidated, cannot send message' );
+		return;
+
+	}
+
+	event.data.source = source;
+	chrome.runtime.sendMessage( event.data );
+
+}
+
+// Listener for messages from the background script (originating from panel)
+function handleBackgroundMessage( message ) {
+
+	const forwardableMessages = new Set( [
+		MESSAGE_REQUEST_STATE,
+		MESSAGE_REQUEST_OBJECT_DETAILS,
+		MESSAGE_SCROLL_TO_CANVAS,
+		MESSAGE_HIGHLIGHT_OBJECT,
+		MESSAGE_UNHIGHLIGHT_OBJECT
+	] );
+
+	if ( forwardableMessages.has( message.name ) ) {
+
+		message.id = MESSAGE_ID;
+		window.postMessage( message, '*' );
+
+	}
+
+}
+
+// Add event listeners
+window.addEventListener( 'message', handleWindowMessage, false );
+chrome.runtime.onMessage.addListener( handleBackgroundMessage );
+
+// Icon color scheme
+const isLightTheme = window.matchMedia( '(prefers-color-scheme: light)' ).matches;
+chrome.runtime.sendMessage( { scheme: isLightTheme ? 'light' : 'dark' } );
+window.matchMedia( '(prefers-color-scheme: light)' ).onchange = event => {
+
+	chrome.runtime.sendMessage( { scheme: event.matches ? 'light' : 'dark' } );
+
+};
+

+ 13 - 0
devtools/devtools.js

@@ -0,0 +1,13 @@
+try {
+
+	chrome.devtools.panels.create(
+		'Three.js',
+		null,
+		'panel/panel.html'
+	);
+
+} catch ( error ) {
+
+	console.error( 'Failed to create Three.js panel:', error );
+
+}

+ 194 - 0
devtools/highlight.js

@@ -0,0 +1,194 @@
+/* global __THREE_DEVTOOLS__ */
+
+// This script handles highlighting of Three.js objects in the 3D scene
+
+( function () {
+
+	'use strict';
+
+	let highlightObject = null;
+
+	function cloneMaterial( material ) {
+
+		// Skip MeshNormalMaterial
+		if ( material.isMeshNormalMaterial ) {
+
+			return material;
+
+		}
+
+		// Handle ShaderMaterial and RawShaderMaterial
+		if ( material.isShaderMaterial || material.isRawShaderMaterial ) {
+
+			// Create new material of the same type
+			const cloned = new material.constructor();
+
+			// Override shaders with simple yellow output
+			const vertexShader = `
+				${ material.isRawShaderMaterial ? `attribute vec3 position;
+				uniform mat4 modelViewMatrix;
+				uniform mat4 projectionMatrix;
+				` : '' }void main() {
+					gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
+				}
+			`;
+
+			const fragmentShader = `
+				${ material.isRawShaderMaterial ? `precision highp float;
+				` : '' }void main() {
+					gl_FragColor = vec4( 1.0, 1.0, 0.0, 1.0 );
+				}
+			`;
+
+			cloned.vertexShader = vertexShader;
+			cloned.fragmentShader = fragmentShader;
+
+			// Override with yellow wireframe settings
+			cloned.wireframe = true;
+			cloned.depthTest = false;
+			cloned.depthWrite = false;
+			cloned.transparent = true;
+			cloned.opacity = 1;
+			cloned.toneMapped = false;
+			cloned.fog = false;
+
+			return cloned;
+
+		}
+
+		// Create new material of the same type
+		const cloned = new material.constructor();
+
+		// Set yellow color
+		if ( cloned.color ) {
+
+			cloned.color.r = 1;
+			cloned.color.g = 1;
+			cloned.color.b = 0;
+
+		}
+
+		// If material has emissive, set it to yellow
+		if ( 'emissive' in cloned ) {
+
+			cloned.emissive.r = 1;
+			cloned.emissive.g = 1;
+			cloned.emissive.b = 0;
+
+		}
+
+		// Enable wireframe if the material supports it
+		if ( 'wireframe' in cloned ) {
+
+			cloned.wireframe = true;
+
+		}
+
+		// Render on top, ignoring depth
+		cloned.depthTest = false;
+		cloned.depthWrite = false;
+		cloned.transparent = true;
+		cloned.opacity = 1;
+
+		// Disable tone mapping and fog
+		cloned.toneMapped = false;
+		cloned.fog = false;
+
+		return cloned;
+
+	}
+
+	function highlight( uuid ) {
+
+		const object = __THREE_DEVTOOLS__.utils.findObjectInScenes( uuid );
+		if ( ! object ) {
+
+			// Object not in scene (e.g., renderer) - hide highlight
+			if ( highlightObject ) highlightObject.visible = false;
+			return;
+
+		}
+
+		// Skip helpers, existing highlights, and objects without geometry
+		if ( object.type.includes( 'Helper' ) || object.name === '__THREE_DEVTOOLS_HIGHLIGHT__' || ! object.geometry ) {
+
+			if ( highlightObject ) highlightObject.visible = false;
+			return;
+
+		}
+
+		// Remove old highlight if it exists
+		if ( highlightObject && highlightObject.parent ) {
+
+			highlightObject.parent.remove( highlightObject );
+
+		}
+
+		// Clone the object to preserve all properties (skeleton, bindMatrix, etc)
+		highlightObject = object.clone();
+		highlightObject.name = '__THREE_DEVTOOLS_HIGHLIGHT__';
+
+		// Apply yellow wireframe material
+		if ( highlightObject.material ) {
+
+			if ( Array.isArray( highlightObject.material ) ) {
+
+				highlightObject.material = highlightObject.material.map( cloneMaterial );
+
+			} else {
+
+				highlightObject.material = cloneMaterial( highlightObject.material );
+
+			}
+
+		}
+
+		// Disable shadows
+		highlightObject.castShadow = false;
+		highlightObject.receiveShadow = false;
+
+		// Render on top of everything
+		highlightObject.renderOrder = Infinity;
+
+		// Disable auto update before adding to scene
+		highlightObject.matrixAutoUpdate = false;
+		highlightObject.matrixWorldAutoUpdate = false;
+
+		// Find the scene and add at root
+		let scene = object;
+		while ( scene.parent ) scene = scene.parent;
+
+		scene.add( highlightObject );
+
+		// Reuse the matrixWorld from original object (after adding to scene)
+		highlightObject.matrixWorld = object.matrixWorld;
+
+		// Make sure it's visible
+		highlightObject.visible = true;
+
+	}
+
+	function unhighlight() {
+
+		if ( highlightObject ) {
+
+			highlightObject.visible = false;
+
+		}
+
+	}
+
+	// Listen for highlight events from bridge.js
+	__THREE_DEVTOOLS__.addEventListener( 'highlight-object', ( event ) => {
+
+		highlight( event.detail.uuid );
+
+	} );
+
+	__THREE_DEVTOOLS__.addEventListener( 'unhighlight-object', () => {
+
+		unhighlight();
+
+	} );
+
+} )();

BIN=BIN
devtools/icons/128-dark.png


BIN=BIN
devtools/icons/128-light.png


+ 9 - 0
devtools/index.html

@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="utf-8">
+	<script src="devtools.js"></script>
+</head>
+<body>
+</body>
+</html>

+ 32 - 0
devtools/manifest.json

@@ -0,0 +1,32 @@
+{
+	"manifest_version": 3,
+	"name": "Three.js DevTools",
+	"version": "1.15",
+	"description": "Developer tools extension for Three.js",
+	"icons": {
+		"128": "icons/128-light.png"
+	},
+	"action": {},
+	"devtools_page": "index.html",
+	"background": {
+		"service_worker": "background.js"
+	},
+	"content_scripts": [{
+		"matches": ["<all_urls>"],
+		"js": ["constants.js", "bridge.js", "highlight.js"],
+		"all_frames": true,
+		"run_at": "document_start",
+		"world": "MAIN",
+		"match_about_blank": true
+	}, {
+		"matches": ["<all_urls>"],
+		"js": ["content-script.js"],
+		"all_frames": true,
+		"run_at": "document_start",
+		"match_about_blank": true
+	}],
+	"permissions": [
+		"activeTab",
+		"webNavigation"
+	]
+} 

+ 200 - 0
devtools/panel/panel.css

@@ -0,0 +1,200 @@
+:root {
+	color-scheme: light dark;
+}
+
+body {
+	background: light-dark( #fff, #333 );
+	color: light-dark( #333, #e0e0e0 );
+	margin: 0;
+	padding: 10px;
+	font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+	font-size: 12px;
+}
+
+hr {
+	color: light-dark( #333, #e0e0e0 );
+}
+
+#scene-tree {
+	width: 100%;
+	height: 100%;
+	overflow: auto;
+}
+
+.header {
+	padding: 8px 12px;
+	background: light-dark( #f5f5f5, #333 );
+	border-radius: 4px;
+	margin-bottom: 16px;
+	font-family: monospace;
+	color: light-dark( #666, #aaa );
+}
+	.header a {
+		color: light-dark( #666, #aaa );
+		text-decoration: none;
+	}
+	.header a:hover {
+		color: light-dark( #333, #e0e0e0 );
+	}
+
+.section {
+	margin-bottom: 24px;
+}
+
+	.section h3 {
+		margin: 0 0 8px 0;
+		font-size: 11px;
+		text-transform: uppercase;
+		color: light-dark( #666, #aaa );
+		font-weight: 500;
+		border-bottom: 1px solid light-dark( #eee, #444 );
+		padding-bottom: 4px;
+	}
+
+.tree-item {
+	padding: 4px;
+	cursor: pointer;
+	display: flex;
+	align-items: center;
+}
+.tree-item:hover {
+	background: light-dark( #f0f0f0, #555 );
+}
+.tree-item .icon {
+	margin-right: 4px;
+	opacity: 0.7;
+}
+.tree-item .label {
+	flex: 1;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
+}
+.tree-item .label .object-details {
+	color: #aaa;
+	margin-left: 4px;
+	font-weight: normal;
+}
+.tree-item .type {
+	margin-left: 8px;
+	opacity: 0.5;
+	font-size: 0.9em;
+}
+
+.children {
+	margin-left: 0;
+}
+
+/* Style for clickable renderer summary */
+.renderer-summary {
+	cursor: pointer;
+}
+.renderer-summary:hover {
+	background: light-dark( #f0f0f0, #555 );
+}
+
+/* Hide default details marker when using custom summary */
+details.renderer-container > summary.renderer-summary { /* Target summary */
+	list-style: none; /* Hide default arrow */
+	cursor: pointer; /* Make the summary div look clickable */
+}
+details.renderer-container > summary.renderer-summary::-webkit-details-marker {
+	display: none; /* Hide default arrow in WebKit */
+}
+
+/* Style for the toggle icon */
+.toggle-icon::before {
+	content: '▶'; /* Default: collapsed */
+	display: inline-block;
+	width: 1em;
+	margin-right: 2px;
+	opacity: 0.7;
+}
+details.renderer-container[open] > summary.renderer-summary .toggle-icon::before {
+	content: '▼'; /* Expanded */
+}
+
+/* Floating object details panel */
+.floating-details {
+	position: fixed;
+	z-index: 1000;
+	background: light-dark( #fff, #2a2a2a );
+	border: 1px solid light-dark( #ccc, #555 );
+	border-radius: 6px;
+	padding: 12px;
+	box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+	max-width: 300px;
+	min-width: 200px;
+	font-size: 11px;
+	pointer-events: none; /* Prevent interfering with mouse interactions */
+	opacity: 0;
+	transform: translateY(10px);
+	transition: opacity 0.2s ease, transform 0.2s ease;
+}
+
+.floating-details.visible {
+	opacity: 1;
+	transform: translateY(0);
+}
+
+.floating-details h4 {
+	margin: 8px 0 4px 0;
+	font-size: 10px;
+	text-transform: uppercase;
+	color: light-dark( #666, #aaa );
+	border-bottom: 1px solid light-dark( #eee, #444 );
+	padding-bottom: 2px;
+}
+
+.floating-details .property-row {
+	margin-bottom: 1px;
+	font-size: 10px;
+}
+
+/* Scroll to canvas button */
+.scroll-to-canvas-btn {
+	background: none;
+	border: none;
+	cursor: pointer;
+	font-size: 12px;
+	margin-left: 8px;
+	padding: 2px 4px;
+	border-radius: 3px;
+	opacity: 0.6;
+	transition: opacity 0.2s ease, background 0.2s ease;
+}
+
+.scroll-to-canvas-btn:hover {
+	opacity: 1;
+	background: light-dark( rgba(0,0,0,0.1), rgba(255,255,255,0.1) );
+}
+
+.scroll-to-canvas-placeholder {
+	font-size: 12px;
+	margin-left: 8px;
+	padding: 2px 4px;
+	opacity: 0.3;
+}
+
+/* Two-column layout for wide panel */
+@media (min-width: 1000px) {
+	.sections-container {
+		display: flex;
+		gap: 20px;
+		align-items: flex-start;
+	}
+	
+	.sections-container .section {
+		flex: 1;
+		margin-bottom: 0;
+	}
+	
+	/* Ensure sections have equal width */
+	.sections-container .section:first-child {
+		min-width: 0; /* Allow flexbox to shrink */
+	}
+	
+	.sections-container .section:last-child {
+		min-width: 0; /* Allow flexbox to shrink */
+	}
+}

+ 13 - 0
devtools/panel/panel.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="utf-8">
+	<title>Three.js DevTools</title>
+	<link rel="stylesheet" href="panel.css">
+</head>
+<body>
+	<div id="scene-tree"></div>
+	<script src="../constants.js"></script>
+	<script src="panel.js"></script>
+</body>
+</html> 

+ 712 - 0
devtools/panel/panel.js

@@ -0,0 +1,712 @@
+/* global chrome */
+
+const CONNECTION_NAME = 'three-devtools';
+const STATE_POLLING_INTERVAL = 1000;
+
+// --- Utility Functions ---
+function getObjectIcon( obj ) {
+
+	if ( obj.isScene ) return '🌍';
+	if ( obj.isCamera ) return '📷';
+	if ( obj.isLight ) return '💡';
+	if ( obj.isInstancedMesh ) return '🔸';
+	if ( obj.isMesh ) return '🔷';
+	if ( obj.type === 'Group' ) return '📁';
+	return '📦';
+
+}
+
+function createPropertyRow( label, value ) {
+
+	const row = document.createElement( 'div' );
+	row.className = 'property-row';
+	row.style.display = 'flex';
+	row.style.justifyContent = 'space-between';
+	row.style.marginBottom = '2px';
+
+	const labelSpan = document.createElement( 'span' );
+	labelSpan.className = 'property-label';
+	labelSpan.textContent = `${label}`;
+	labelSpan.style.marginRight = '10px';
+	labelSpan.style.whiteSpace = 'nowrap';
+
+	const valueSpan = document.createElement( 'span' );
+	valueSpan.className = 'property-value';
+	const displayValue = ( value === undefined || value === null )
+		? '–'
+		: ( typeof value === 'number' ? value.toLocaleString() : value );
+	valueSpan.textContent = displayValue;
+	valueSpan.style.textAlign = 'right';
+
+	row.appendChild( labelSpan );
+	row.appendChild( valueSpan );
+	return row;
+
+}
+
+function createVectorRow( label, vector ) {
+
+	const row = document.createElement( 'div' );
+	row.className = 'property-row';
+	row.style.marginBottom = '2px';
+
+	// Pad label to ensure consistent alignment
+	const paddedLabel = label.padEnd( 16, ' ' ); // Pad to 16 characters
+	const content = `${paddedLabel} ${vector.x.toFixed( 3 )}\t${vector.y.toFixed( 3 )}\t${vector.z.toFixed( 3 )}`;
+	row.textContent = content;
+	row.style.fontFamily = 'monospace';
+	row.style.whiteSpace = 'pre';
+
+	return row;
+
+}
+
+// --- State ---
+const state = {
+	revision: null,
+	scenes: new Map(),
+	renderers: new Map(),
+	objects: new Map(),
+	selectedObject: null
+};
+
+// Floating details panel
+let floatingPanel = null;
+let mousePosition = { x: 0, y: 0 };
+
+
+// Create a connection to the background page
+const backgroundPageConnection = chrome.runtime.connect( {
+	name: CONNECTION_NAME
+} );
+
+// Initialize the connection with the inspected tab ID
+backgroundPageConnection.postMessage( {
+	name: MESSAGE_INIT,
+	tabId: chrome.devtools.inspectedWindow.tabId
+} );
+
+// Request the initial state from the bridge script
+backgroundPageConnection.postMessage( {
+	name: MESSAGE_REQUEST_STATE,
+	tabId: chrome.devtools.inspectedWindow.tabId
+} );
+
+// Function to scroll to canvas element
+function scrollToCanvas( rendererUuid ) {
+	backgroundPageConnection.postMessage( {
+		name: MESSAGE_SCROLL_TO_CANVAS,
+		uuid: rendererUuid,
+		tabId: chrome.devtools.inspectedWindow.tabId
+	} );
+}
+
+const intervalId = setInterval( () => {
+
+	backgroundPageConnection.postMessage( {
+		name: MESSAGE_REQUEST_STATE,
+		tabId: chrome.devtools.inspectedWindow.tabId
+	} );
+
+}, STATE_POLLING_INTERVAL );
+
+backgroundPageConnection.onDisconnect.addListener( () => {
+
+	clearInterval( intervalId );
+	clearState();
+
+} );
+
+// Function to request object details from the bridge
+function requestObjectDetails( uuid ) {
+	backgroundPageConnection.postMessage( {
+		name: MESSAGE_REQUEST_OBJECT_DETAILS,
+		uuid: uuid,
+		tabId: chrome.devtools.inspectedWindow.tabId
+	} );
+}
+
+// Function to highlight object in 3D scene
+function requestObjectHighlight( uuid ) {
+	backgroundPageConnection.postMessage( {
+		name: MESSAGE_HIGHLIGHT_OBJECT,
+		uuid: uuid,
+		tabId: chrome.devtools.inspectedWindow.tabId
+	} );
+}
+
+// Function to remove highlight from 3D scene
+function requestObjectUnhighlight() {
+	backgroundPageConnection.postMessage( {
+		name: MESSAGE_UNHIGHLIGHT_OBJECT,
+		tabId: chrome.devtools.inspectedWindow.tabId
+	} );
+}
+
+
+// Store renderer collapse states
+const rendererCollapsedState = new Map();
+
+// Static DOM elements (created once in initUI)
+let renderersSection = null;
+let scenesSection = null;
+let sceneDirty = true;
+
+// Helper function to create properties column for renderer
+function createRendererPropertiesColumn( props ) {
+
+	const propsCol = document.createElement( 'div' );
+	propsCol.className = 'properties-column';
+	const propsTitle = document.createElement( 'h4' );
+	propsTitle.textContent = 'Properties';
+	propsCol.appendChild( propsTitle );
+	propsCol.appendChild( createPropertyRow( 'Size', `${props.width}x${props.height}` ) );
+	propsCol.appendChild( createPropertyRow( 'Alpha', props.alpha ) );
+	propsCol.appendChild( createPropertyRow( 'Antialias', props.antialias ) );
+	propsCol.appendChild( createPropertyRow( 'Output Color Space', props.outputColorSpace ) );
+	propsCol.appendChild( createPropertyRow( 'Tone Mapping', props.toneMapping ) );
+	propsCol.appendChild( createPropertyRow( 'Tone Mapping Exposure', props.toneMappingExposure ) );
+	propsCol.appendChild( createPropertyRow( 'Shadows', props.shadows ? 'enabled' : 'disabled' ) );
+	propsCol.appendChild( createPropertyRow( 'Auto Clear', props.autoClear ) );
+	propsCol.appendChild( createPropertyRow( 'Auto Clear Color', props.autoClearColor ) );
+	propsCol.appendChild( createPropertyRow( 'Auto Clear Depth', props.autoClearDepth ) );
+	propsCol.appendChild( createPropertyRow( 'Auto Clear Stencil', props.autoClearStencil ) );
+	propsCol.appendChild( createPropertyRow( 'Local Clipping', props.localClipping ) );
+	propsCol.appendChild( createPropertyRow( 'Physically Correct Lights', props.physicallyCorrectLights ) );
+
+	return propsCol;
+
+}
+
+// Helper function to create stats column for renderer
+function createRendererStatsColumn( info ) {
+
+	const statsCol = document.createElement( 'div' );
+	statsCol.className = 'stats-column';
+
+	// Render Stats
+	const renderTitle = document.createElement( 'h4' );
+	renderTitle.textContent = 'Render Stats';
+	statsCol.appendChild( renderTitle );
+	statsCol.appendChild( createPropertyRow( 'Frame', info.render.frame ) );
+	statsCol.appendChild( createPropertyRow( 'Draw Calls', info.render.calls ) );
+	statsCol.appendChild( createPropertyRow( 'Triangles', info.render.triangles ) );
+	statsCol.appendChild( createPropertyRow( 'Points', info.render.points ) );
+	statsCol.appendChild( createPropertyRow( 'Lines', info.render.lines ) );
+
+	// Memory
+	const memoryTitle = document.createElement( 'h4' );
+	memoryTitle.textContent = 'Memory';
+	memoryTitle.style.marginTop = '10px';
+	statsCol.appendChild( memoryTitle );
+	statsCol.appendChild( createPropertyRow( 'Geometries', info.memory.geometries ) );
+	statsCol.appendChild( createPropertyRow( 'Textures', info.memory.textures ) );
+	statsCol.appendChild( createPropertyRow( 'Shader Programs', info.memory.programs ) );
+
+	return statsCol;
+
+}
+
+// Helper function to process scene batch updates
+function processSceneBatch( sceneUuid, batchObjects ) {
+
+	// 1. Identify UUIDs in the new batch
+	const newObjectUuids = new Set( batchObjects.map( obj => obj.uuid ) );
+
+	// 2. Identify current object UUIDs associated with this scene that are NOT renderers
+	const currentSceneObjectUuids = new Set();
+	state.objects.forEach( ( obj, uuid ) => {
+
+		// Use the _sceneUuid property we'll add below, or check if it's the scene root itself
+		if ( obj._sceneUuid === sceneUuid || uuid === sceneUuid ) {
+
+			currentSceneObjectUuids.add( uuid );
+
+		}
+
+	} );
+
+	// 3. Find UUIDs to remove (in current state for this scene, but not in the new batch)
+	const uuidsToRemove = new Set();
+	currentSceneObjectUuids.forEach( uuid => {
+
+		if ( ! newObjectUuids.has( uuid ) ) {
+
+			uuidsToRemove.add( uuid );
+
+		}
+
+	} );
+
+	// 4. Remove stale objects from state
+	uuidsToRemove.forEach( uuid => {
+
+		state.objects.delete( uuid );
+		// If a scene object itself was somehow removed (unlikely for root), clean up scenes map too
+		if ( state.scenes.has( uuid ) ) {
+
+			state.scenes.delete( uuid );
+
+		}
+
+	} );
+
+	// 5. Process the new batch: Add/Update objects and mark their scene association
+	batchObjects.forEach( objData => {
+
+		objData._sceneUuid = sceneUuid;
+		state.objects.set( objData.uuid, objData );
+
+		if ( objData.isScene && objData.uuid === sceneUuid ) {
+
+			state.scenes.set( objData.uuid, objData );
+
+		}
+
+	} );
+
+	sceneDirty = true;
+
+}
+
+// Clear state when panel is reloaded
+function clearState() {
+
+	state.revision = null;
+	state.scenes.clear();
+	state.renderers.clear();
+	state.objects.clear();
+	sceneDirty = true;
+
+	// Hide floating panel
+	if ( floatingPanel ) {
+
+		floatingPanel.classList.remove( 'visible' );
+
+	}
+
+}
+
+// Listen for messages from the background page
+backgroundPageConnection.onMessage.addListener( function ( message ) {
+
+	if ( message.id === MESSAGE_ID ) {
+
+		handleThreeEvent( message );
+
+	}
+
+} );
+
+function handleThreeEvent( message ) {
+
+	switch ( message.name ) {
+
+		case EVENT_REGISTER:
+			state.revision = message.detail.revision;
+			break;
+
+		case EVENT_RENDERER:
+			const detail = message.detail;
+			state.renderers.set( detail.uuid, detail );
+			state.objects.set( detail.uuid, detail );
+			updateRenderers();
+			break;
+
+		case EVENT_OBJECT_DETAILS:
+			state.selectedObject = message.detail;
+			showFloatingDetails( message.detail );
+			break;
+
+		case EVENT_SCENE:
+			const { sceneUuid, objects: batchObjects } = message.detail;
+			processSceneBatch( sceneUuid, batchObjects );
+			updateSceneTree();
+			break;
+
+		case EVENT_COMMITTED:
+			clearState();
+			updateRenderers();
+			updateSceneTree();
+			break;
+
+	}
+
+}
+
+function renderRenderer( obj, container ) {
+
+	// Create <details> element as the main container
+	const detailsElement = document.createElement( 'details' );
+	detailsElement.className = 'renderer-container';
+	detailsElement.setAttribute( 'data-uuid', obj.uuid );
+
+	// Set initial state
+	detailsElement.open = rendererCollapsedState.get( obj.uuid ) || false;
+
+	// Add toggle listener to save state
+	detailsElement.addEventListener( 'toggle', () => {
+
+		rendererCollapsedState.set( obj.uuid, detailsElement.open );
+
+	} );
+
+	// Create the summary element (clickable header) - THIS IS THE FIRST CHILD
+	const summaryElem = document.createElement( 'summary' ); // USE <summary> tag
+	summaryElem.className = 'tree-item renderer-summary'; // Acts as summary
+
+	// Update display name in the summary line
+	const props = obj.properties;
+	const details = [ `${props.width}x${props.height}` ];
+	if ( props.info ) {
+
+		details.push( `${props.info.render.calls} draws` );
+		details.push( `${props.info.render.triangles.toLocaleString()} triangles` );
+
+	}
+
+	const displayName = `${obj.type} <span class="object-details">${details.join( ' ・ ' )}</span>`;
+
+	// Use toggle icon instead of paint icon
+	const scrollButton = obj.canvasInDOM ? 
+		`<button class="scroll-to-canvas-btn" data-canvas-uuid="${obj.uuid}" title="Scroll to canvas">🙂</button>` : 
+		`<span class="scroll-to-canvas-placeholder" title="Canvas not in DOM">󠀠🫥</span>`;
+	summaryElem.innerHTML = `<span class="icon toggle-icon"></span> 
+		<span class="label">${displayName}</span>
+		<span class="type">${obj.type}</span>
+		${scrollButton}`;
+	detailsElement.appendChild( summaryElem );
+
+	const propsContainer = document.createElement( 'div' );
+	propsContainer.className = 'properties-list';
+	// Adjust padding calculation if needed, ensure it's a number before adding
+	const summaryPaddingLeft = parseFloat( summaryElem.style.paddingLeft ) || 0;
+	propsContainer.style.paddingLeft = `${summaryPaddingLeft + 20}px`; // Indent further
+
+	propsContainer.innerHTML = ''; // Clear placeholder
+
+	if ( obj.properties ) {
+
+		const props = obj.properties;
+		const info = props.info || { render: {}, memory: {} }; // Default empty objects if info is missing
+
+		const gridContainer = document.createElement( 'div' );
+		gridContainer.style.display = 'grid';
+		gridContainer.style.gridTemplateColumns = 'repeat(auto-fit, minmax(200px, 1fr))'; // Responsive columns
+		gridContainer.style.gap = '10px 20px'; // Row and column gap
+
+		gridContainer.appendChild( createRendererPropertiesColumn( props ) );
+		gridContainer.appendChild( createRendererStatsColumn( info ) );
+		propsContainer.appendChild( gridContainer );
+
+	} else {
+
+		propsContainer.textContent = 'No properties available.';
+
+	}
+
+	detailsElement.appendChild( propsContainer );
+
+	// Add click handler for scroll to canvas button
+	const scrollBtn = detailsElement.querySelector( '.scroll-to-canvas-btn' );
+	if ( scrollBtn ) {
+		scrollBtn.addEventListener( 'click', ( event ) => {
+			event.preventDefault();
+			event.stopPropagation();
+			scrollToCanvas( obj.uuid );
+		} );
+	}
+
+	container.appendChild( detailsElement ); // Append details to the main container
+
+}
+
+// Function to render an object and its children
+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 );
+
+	// Apply opacity for invisible objects or if parent is invisible
+	if ( obj.visible === false || parentInvisible ) {
+
+		elem.style.opacity = '0.5';
+
+	}
+
+	let labelContent = `<span class="icon">${icon}</span>
+		<span class="label">${displayName}</span>
+		<span class="type">${obj.type}</span>`;
+
+	if ( obj.isScene ) {
+
+		// Add object count for scenes
+		let objectCount = - 1;
+		function countObjects( uuid ) {
+
+			const object = state.objects.get( uuid );
+			if ( object && object.name !== '__THREE_DEVTOOLS_HIGHLIGHT__' ) {
+
+				objectCount ++; // Increment count for the object itself
+				if ( object.children ) {
+
+					object.children.forEach( childId => countObjects( childId ) );
+
+				}
+
+			}
+
+		}
+
+		countObjects( obj.uuid );
+		displayName = `${obj.name || obj.type} <span class="object-details">${objectCount} objects</span>`;
+		labelContent = `<span class="icon">${icon}</span>
+			<span class="label">${displayName}</span>
+			<span class="type">${obj.type}</span>`;
+
+	}
+
+	elem.innerHTML = labelContent;
+
+	// 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 ) {
+
+			requestObjectHighlight( obj.uuid );
+
+		}
+	} );
+
+	// Add mouseleave handler to remove 3D highlight
+	elem.addEventListener( 'mouseleave', () => {
+		requestObjectUnhighlight();
+	} );
+
+	container.appendChild( elem );
+
+	// Handle children (excluding children of renderers, as properties are shown in details)
+	if ( ! obj.isRenderer && obj.children && obj.children.length > 0 ) {
+
+		// Create a container for children
+		const childContainer = document.createElement( 'div' );
+		childContainer.className = 'children';
+		container.appendChild( childContainer );
+
+		// 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 aOrder = getTypeOrder( a );
+				const bOrder = getTypeOrder( b );
+
+				return aOrder - bOrder;
+
+			} );
+
+		// Render each child
+		children.forEach( child => {
+
+			renderObject( child, childContainer, level + 1, parentInvisible || obj.visible === false );
+
+		} );
+
+	}
+
+}
+
+// Build the static DOM shell (called once)
+function initUI() {
+
+	const container = document.getElementById( 'scene-tree' );
+
+	const header = document.createElement( 'div' );
+	header.className = 'header';
+	header.style.display = 'flex';
+	header.style.justifyContent = 'space-between';
+
+	const miscSpan = document.createElement( 'span' );
+	miscSpan.innerHTML = '<a href="https://docs.google.com/forms/d/e/1FAIpQLSdw1QcgXNiECYiPx6k0vSQRiRe0FmByrrojV4fgeL5zzXIiCw/viewform?usp=preview" target="_blank">+</a>';
+
+	const manifest = chrome.runtime.getManifest();
+
+	const manifestVersionSpan = document.createElement( 'span' );
+	manifestVersionSpan.textContent = `${manifest.version}`;
+	manifestVersionSpan.style.opacity = '0.5';
+
+	header.appendChild( miscSpan );
+	header.appendChild( manifestVersionSpan );
+	container.appendChild( header );
+
+	const sectionsContainer = document.createElement( 'div' );
+	sectionsContainer.className = 'sections-container';
+	container.appendChild( sectionsContainer );
+
+	renderersSection = document.createElement( 'div' );
+	renderersSection.className = 'section';
+	renderersSection.style.display = 'none';
+	sectionsContainer.appendChild( renderersSection );
+
+	scenesSection = document.createElement( 'div' );
+	scenesSection.className = 'section';
+	scenesSection.style.display = 'none';
+	sectionsContainer.appendChild( scenesSection );
+
+}
+
+// Update only the renderers section
+function updateRenderers() {
+
+	if ( state.renderers.size > 0 ) {
+
+		renderersSection.style.display = '';
+		renderersSection.innerHTML = '<h3>Renderers</h3>';
+
+		state.renderers.forEach( renderer => {
+
+			renderRenderer( renderer, renderersSection );
+
+		} );
+
+	} else {
+
+		renderersSection.style.display = 'none';
+
+	}
+
+}
+
+// Rebuild the scene tree only when dirty
+function updateSceneTree() {
+
+	if ( ! sceneDirty ) return;
+
+	sceneDirty = false;
+
+	if ( state.scenes.size > 0 ) {
+
+		scenesSection.style.display = '';
+		scenesSection.innerHTML = '<h3>Scenes</h3>';
+
+		state.scenes.forEach( scene => {
+
+			renderObject( scene, scenesSection );
+
+		} );
+
+	} else {
+
+		scenesSection.style.display = 'none';
+
+	}
+
+}
+
+// Create floating details panel
+function createFloatingPanel() {
+
+	if ( floatingPanel ) return floatingPanel;
+
+	floatingPanel = document.createElement( 'div' );
+	floatingPanel.className = 'floating-details';
+	document.body.appendChild( floatingPanel );
+
+	return floatingPanel;
+
+}
+
+// Show floating details panel
+function showFloatingDetails( objectData ) {
+
+	const panel = createFloatingPanel();
+	
+	// Clear previous content
+	panel.innerHTML = '';
+
+	if ( objectData.position ) {
+
+		panel.appendChild( createVectorRow( 'Position', objectData.position ) );
+
+	}
+
+	if ( objectData.rotation ) {
+
+		panel.appendChild( createVectorRow( 'Rotation', objectData.rotation ) );
+
+	}
+
+	if ( objectData.scale ) {
+
+		panel.appendChild( createVectorRow( 'Scale', objectData.scale ) );
+
+	}
+
+	// Position panel near mouse
+	updateFloatingPanelPosition();
+	
+	// Show panel
+	panel.classList.add( 'visible' );
+
+}
+
+// Update floating panel position
+function updateFloatingPanelPosition() {
+
+	if ( ! floatingPanel || ! floatingPanel.classList.contains( 'visible' ) ) return;
+
+	const offset = 15; // Offset from cursor
+	let x = mousePosition.x + offset;
+	let y = mousePosition.y + offset;
+
+	// Prevent panel from going off-screen
+	const panelRect = floatingPanel.getBoundingClientRect();
+	const maxX = window.innerWidth - panelRect.width - 10;
+	const maxY = window.innerHeight - panelRect.height - 10;
+
+	if ( x > maxX ) x = mousePosition.x - panelRect.width - offset;
+	if ( y > maxY ) y = mousePosition.y - panelRect.height - offset;
+
+	floatingPanel.style.left = `${Math.max( 10, x )}px`;
+	floatingPanel.style.top = `${Math.max( 10, y )}px`;
+
+}
+
+// Track mouse position
+document.addEventListener( 'mousemove', ( event ) => {
+
+	mousePosition.x = event.clientX;
+	mousePosition.y = event.clientY;
+	updateFloatingPanelPosition();
+
+} );
+
+// Hide panel when mouse leaves the tree area
+document.addEventListener( 'mouseover', ( event ) => {
+
+	if ( floatingPanel && ! event.target.closest( '.tree-item' ) ) {
+
+		floatingPanel.classList.remove( 'visible' );
+
+	}
+
+} );
+
+// Initial UI setup
+initUI();

BIN=BIN
devtools/screenshot.png


粤ICP备19079148号