diff --git a/devtools/README.md b/devtools/README.md new file mode 100644 index 00000000000000..47d93959fc4ebe --- /dev/null +++ b/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 context by the content script. 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, `content-script.js` injects `bridge.js` into the page. +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. \ No newline at end of file diff --git a/devtools/background.js b/devtools/background.js new file mode 100644 index 00000000000000..6028a0264cc487 --- /dev/null +++ b/devtools/background.js @@ -0,0 +1,138 @@ +/* global chrome */ + +// Map tab IDs to connections +const connections = new Map(); + +// Listen for connections from the devtools panel +chrome.runtime.onConnect.addListener( port => { + + let tabId; + + // Listen for messages from the devtools panel + port.onMessage.addListener( message => { + + if ( message.name === 'init' ) { + + tabId = message.tabId; + connections.set( tabId, port ); + + } else if ( message.name === 'request-state' && 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 === '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( () => { /* Tab might be gone */ } ); + chrome.action.setBadgeTextColor( { tabId: tabId, color: '#ffffff' } ).catch( () => { /* Tab might be gone */ } ); + chrome.action.setBadgeBackgroundColor( { tabId: tabId, color: isDev ? '#ff0098' : '#049ef4' } ).catch( () => { /* Tab might be gone */ } ); + + } + + 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( () => { /* Tab might be gone */ } ); + + } + + const port = connections.get( tabId ); + + if ( port ) { + + port.postMessage( { + id: 'three-devtools', + name: 'committed', + frameId: frameId + } ); + + } + +} ); + +// Clear badge when a tab is closed +chrome.tabs.onRemoved.addListener( ( tabId ) => { + + chrome.action.setBadgeText( { tabId: tabId, text: '' } ).catch( () => { /* Tab might be gone */ } ); + + // Clean up connection if it exists for the closed tab + if ( connections.has( tabId ) ) { + + connections.delete( tabId ); + + } + +} ); diff --git a/devtools/bridge.js b/devtools/bridge.js new file mode 100644 index 00000000000000..7a756d9625dd8d --- /dev/null +++ b/devtools/bridge.js @@ -0,0 +1,489 @@ +/** + * This script injected by the installed three.js developer + * tools extension. + */ + +( function () { + + // 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 !== 'devtools-ready' && this._backlog.length > 0 ) { + + this.dispatchEvent( new CustomEvent( 'devtools-ready' ) ); + + } + + } + + dispatchEvent( event ) { + + if ( this._ready || event.type === 'devtools-ready' ) { + + if ( event.type === '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() { + + // console.log('DevTools: Resetting state'); + + // 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 + + // Function to get renderer data + function getRendererData( renderer ) { + + try { + + const data = { + uuid: renderer.uuid || generateUUID(), + type: renderer.isWebGLRenderer ? 'WebGLRenderer' : 'WebGPURenderer', + name: '', + properties: getRendererProperties( renderer ) + }; + 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} ${geoType} ${matType}`; + + } + + 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( 'register', ( event ) => { + + // console.log('DevTools: Three.js registered with revision:', event.detail.revision); + dispatchEvent( 'register', event.detail ); + + } ); + + // Listen for object observations + devTools.addEventListener( '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( 'renderer', data ); + + } + + } else if ( obj.isScene ) { + + observedScenes.push( obj ); + + const batchObjects = []; + const processedUUIDs = new Set(); + + function traverseForBatch( currentObj ) { + + if ( ! currentObj || ! currentObj.uuid || processedUUIDs.has( currentObj.uuid ) ) return; + processedUUIDs.add( currentObj.uuid ); + + const objectData = getObjectData( currentObj ); + if ( objectData ) { + + batchObjects.push( objectData ); + devTools.objects.set( currentObj.uuid, objectData ); // Update local cache during batch creation + + } + + // Process children + if ( currentObj.children && Array.isArray( currentObj.children ) ) { + + currentObj.children.forEach( child => traverseForBatch( child ) ); + + } + + } + + traverseForBatch( obj ); // Start traversal from the scene + + dispatchEvent( '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.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 + } + } + }; + + } + + // Start periodic renderer checks + // console.log('DevTools: Starting periodic renderer checks'); + + // Function to check if bridge is available + function checkBridgeAvailability() { + + const hasDevTools = window.hasOwnProperty( '__THREE_DEVTOOLS__' ); + const devToolsValue = window.__THREE_DEVTOOLS__; + + // If we have devtools and we're interactive or complete, trigger ready + if ( hasDevTools && devToolsValue && ( document.readyState === 'interactive' || document.readyState === 'complete' ) ) { + + devTools.dispatchEvent( new CustomEvent( '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( 'register', { revision: 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 !== 'three-devtools' ) return; + + // Handle request for initial state from panel + if ( message.name === 'request-state' ) { + + sendState(); + + } + + } ); + + function sendState() { + + // Send current renderers + for ( const observedRenderer of observedRenderers ) { + + const data = getObjectData( observedRenderer ); + if ( data ) { + + data.properties = getRendererProperties( observedRenderer ); + dispatchEvent( 'renderer', data ); + + } + + } + + // Send current scenes + for ( const observedScene of observedScenes ) { + + reloadSceneObjects( observedScene ); + + } + + } + + function dispatchEvent( name, detail ) { + + try { + + window.postMessage( { + id: 'three-devtools', + 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 = []; + + // Recursively observe all objects, collect data, update local cache + function observeAndBatchObject( object ) { + + if ( ! object || ! object.uuid ) return; // Simplified check + + // console.log('DevTools: Processing object during reload:', object.type || object.constructor.name, object.uuid); + + // Get object data + 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 ); + + } + + // Process children recursively + if ( object.children && Array.isArray( object.children ) ) { + + // console.log('DevTools: Processing', object.children.length, 'children of', object.type || object.constructor.name); + object.children.forEach( child => observeAndBatchObject( child ) ); + + } + + } + + // Start traversal from the scene itself + observeAndBatchObject( scene ); + + // --- Caching Logic --- + const currentObjectCount = batchObjects.length; + const previousObjectCount = sceneObjectCountCache.get( scene.uuid ); + + if ( currentObjectCount !== previousObjectCount ) { + + console.log( `DevTools: Scene ${scene.uuid} count changed (${previousObjectCount} -> ${currentObjectCount}), dispatching update.` ); + // Dispatch the batch update for the panel as 'scene' + dispatchEvent( 'scene', { sceneUuid: scene.uuid, objects: batchObjects } ); + // Update the cache + sceneObjectCountCache.set( scene.uuid, currentObjectCount ); + + } else { + // console.log(`DevTools: Scene ${scene.uuid} count unchanged (${currentObjectCount}), skipping dispatch.`); + } + + } + + } + +} )(); diff --git a/devtools/content-script.js b/devtools/content-script.js new file mode 100644 index 00000000000000..228e2cc527b6c2 --- /dev/null +++ b/devtools/content-script.js @@ -0,0 +1,129 @@ +/* global chrome */ + +// This script runs in the context of the web page +// console.log( 'Three.js DevTools: Content script loaded at document_readyState:', document.readyState ); // Comment out + +// Inject the bridge script into the main document or a target (e.g., iframe) +function injectBridge( target = document ) { + + const script = document.createElement( 'script' ); + script.src = chrome.runtime.getURL( 'bridge.js' ); + script.onload = function () { + + this.remove(); + + }; + + ( target.head || target.documentElement ).appendChild( script ); + return script; + +} + +// Inject bridge into all existing iframes +function injectIntoIframes() { + + document.querySelectorAll( 'iframe' ).forEach( iframe => { + + try { + + if ( iframe.contentDocument ) injectBridge( iframe.contentDocument ); + + } catch ( e ) { /* Ignore cross-origin errors */ } + + } ); + +} + +// Initial injection +injectBridge(); +injectIntoIframes(); + +// Watch for new iframes being added +new MutationObserver( mutations => { + + mutations.forEach( mutation => { + + mutation.addedNodes.forEach( node => { + + if ( node.tagName === 'IFRAME' ) { + + node.addEventListener( 'load', () => { + + try { + + if ( node.contentDocument ) injectBridge( node.contentDocument ); + + } catch ( e ) { /* Ignore cross-origin errors */ } + + } ); + + } + + } ); + + } ); + +} ).observe( document.documentElement, { childList: true, subtree: true } ); + +// 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 !== 'three-devtools' ) 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; + + } + + const messageWithSource = { ...event.data, source }; + chrome.runtime.sendMessage( messageWithSource ); + +} + +// Listener for messages from the background script (originating from panel) +function handleBackgroundMessage( message ) { + + if ( message.name === 'request-state' ) { + + message.id = 'three-devtools'; + 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' } ); + +}; + diff --git a/devtools/devtools.js b/devtools/devtools.js new file mode 100644 index 00000000000000..ab6742b968b111 --- /dev/null +++ b/devtools/devtools.js @@ -0,0 +1,18 @@ +try { + + chrome.devtools.panels.create( + 'Three.js', + null, + 'panel/panel.html', + function () { + + console.log( 'Three.js DevTools panel created' ); + + } + ); + +} catch ( error ) { + + console.error( 'Failed to create Three.js panel:', error ); + +} diff --git a/devtools/icons/128-dark.png b/devtools/icons/128-dark.png new file mode 100644 index 00000000000000..71d38580c79f2c Binary files /dev/null and b/devtools/icons/128-dark.png differ diff --git a/devtools/icons/128-light.png b/devtools/icons/128-light.png new file mode 100644 index 00000000000000..6d9aa909314269 Binary files /dev/null and b/devtools/icons/128-light.png differ diff --git a/devtools/index.html b/devtools/index.html new file mode 100644 index 00000000000000..c83ce6457297b2 --- /dev/null +++ b/devtools/index.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/devtools/manifest.json b/devtools/manifest.json new file mode 100644 index 00000000000000..209ffd50f60849 --- /dev/null +++ b/devtools/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 3, + "name": "Three.js DevTools", + "version": "1.9", + "description": "Developer tools extension for Three.js", + "icons": { + "128": "icons/128-light.png" + }, + "action": {}, + "devtools_page": "index.html", + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [{ + "matches": [""], + "js": ["content-script.js"], + "all_frames": true, + "run_at": "document_start" + }], + "web_accessible_resources": [{ + "resources": ["bridge.js"], + "matches": [""] + }], + "permissions": [ + "activeTab", + "webNavigation" + ] +} \ No newline at end of file diff --git a/devtools/panel/panel.css b/devtools/panel/panel.css new file mode 100644 index 00000000000000..09bf6baa8dec0d --- /dev/null +++ b/devtools/panel/panel.css @@ -0,0 +1,115 @@ +: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 */ +} \ No newline at end of file diff --git a/devtools/panel/panel.html b/devtools/panel/panel.html new file mode 100644 index 00000000000000..f50e5d2ebee208 --- /dev/null +++ b/devtools/panel/panel.html @@ -0,0 +1,12 @@ + + + + + Three.js DevTools + + + +
+ + + \ No newline at end of file diff --git a/devtools/panel/panel.js b/devtools/panel/panel.js new file mode 100644 index 00000000000000..30e3439cc67f26 --- /dev/null +++ b/devtools/panel/panel.js @@ -0,0 +1,496 @@ +/* global chrome */ + +// --- 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; +} + +// --- State --- +const state = { + revision: null, + scenes: new Map(), + renderers: new Map(), + objects: new Map() +}; + +// console.log('Panel script loaded'); + +// Create a connection to the background page +const backgroundPageConnection = chrome.runtime.connect( { + name: 'three-devtools' +} ); + +// Initialize the connection with the inspected tab ID +backgroundPageConnection.postMessage( { + name: 'init', + tabId: chrome.devtools.inspectedWindow.tabId +} ); + +// Request the initial state from the bridge script +backgroundPageConnection.postMessage( { + name: 'request-state', + tabId: chrome.devtools.inspectedWindow.tabId +} ); + +const intervalId = setInterval( () => { + + backgroundPageConnection.postMessage( { + name: 'request-state', + tabId: chrome.devtools.inspectedWindow.tabId + } ); + +}, 1000 ); + +backgroundPageConnection.onDisconnect.addListener( () => { + + console.log( 'Panel: Connection to background page lost' ); + clearInterval( intervalId ); + clearState(); + +} ); + +// console.log('Connected to background page with tab ID:', chrome.devtools.inspectedWindow.tabId); + +// Store renderer collapse states +const rendererCollapsedState = new Map(); + +// Clear state when panel is reloaded +function clearState() { + + state.revision = null; + state.scenes.clear(); + state.renderers.clear(); + state.objects.clear(); + const container = document.getElementById( 'scene-tree' ); + if ( container ) { + + container.innerHTML = ''; + + } + +} + +// Listen for messages from the background page +backgroundPageConnection.onMessage.addListener( function ( message ) { + + if ( message.id === 'three-devtools' ) { + + handleThreeEvent( message ); + + } + +} ); + +function handleThreeEvent( message ) { + + switch ( message.name ) { + + case 'register': + state.revision = message.detail.revision; + break; + + // Handle individual renderer observation + case 'renderer': + const detail = message.detail; + + // Only store each unique object once + if ( ! state.objects.has( detail.uuid ) ) { + + state.objects.set( detail.uuid, detail ); + state.renderers.set( detail.uuid, detail ); + + } + + // Update or add the renderer in the state map + state.renderers.set( detail.uuid, detail ); // Ensure the latest detail is always stored + // Also update the generic objects map if renderers are stored there too + state.objects.set( detail.uuid, detail ); + + // The DOM update logic previously here is redundant because updateUI() + // rebuilds the entire renderer element anyway, using the updated data + // from state.renderers and the persisted open/closed state. + + break; + + // Handle a batch of objects for a specific scene + case 'scene': + const { sceneUuid, objects: batchObjects } = message.detail; + console.log( 'Panel: Received scene batch for', sceneUuid, 'with', batchObjects.length, 'objects' ); + + // 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 => { + + // Add a private property to track which scene this object belongs to + objData._sceneUuid = sceneUuid; + state.objects.set( objData.uuid, objData ); + + // Ensure the scene root is in the scenes map + if ( objData.isScene && objData.uuid === sceneUuid ) { + + state.scenes.set( objData.uuid, objData ); + + } + // Note: Renderers are handled separately by 'renderer' events and shouldn't appear in scene batches. + + } ); + + break; + + case 'committed': + // Page was reloaded, clear state + clearState(); + break; + + } + + updateUI(); + +} + +function renderRenderer( obj, container ) { + + // Create
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 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} ${details.join( ' ・ ' )}`; + + // Use toggle icon instead of paint icon + summaryElem.innerHTML = ` + ${displayName} + ${obj.type}`; + 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 + + // --- Column 1: Properties --- + 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' ) ); // Display string + 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 ) ); + gridContainer.appendChild( propsCol ); + + // --- Column 2: Render Stats & Memory --- + 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'; // Add space before Memory section + statsCol.appendChild( memoryTitle ); + statsCol.appendChild( createPropertyRow( 'Geometries', info.memory.geometries ) ); // Memory Geometries + statsCol.appendChild( createPropertyRow( 'Textures', info.memory.textures ) ); + statsCol.appendChild( createPropertyRow( 'Shader Programs', info.memory.programs ) ); + + gridContainer.appendChild( statsCol ); + propsContainer.appendChild( gridContainer ); + + } else { + + propsContainer.textContent = 'No properties available.'; + + } + + detailsElement.appendChild( propsContainer ); + + container.appendChild( detailsElement ); // Append details to the main container + +} + +// Function to render an object and its children +function renderObject( obj, container, level = 0 ) { + + 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 ); + + let labelContent = `${icon} + ${displayName} + ${obj.type}`; + + if ( obj.isScene ) { + + // Add object count for scenes + let objectCount = - 1; + function countObjects( uuid ) { + + const object = state.objects.get( uuid ); + if ( object ) { + + objectCount ++; // Increment count for the object itself + if ( object.children ) { + + object.children.forEach( childId => countObjects( childId ) ); + + } + + } + + } + + countObjects( obj.uuid ); + displayName = `${obj.name || obj.type} ${objectCount} objects`; + labelContent = `${icon} + ${displayName} + ${obj.type}`; + + } + + elem.innerHTML = labelContent; + 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 ) + .sort( ( a, b ) => { + + // Sort order: Cameras, Lights, Groups, Meshes, Others + const typeOrder = { + isCamera: 1, + isLight: 2, + isGroup: 3, + isMesh: 4 + }; + // Refactored to avoid optional chaining parser error + const findOrder = ( obj ) => { + + const entry = Object.entries( typeOrder ).find( ( [ key ] ) => obj[ key ] ); + return entry ? entry[ 1 ] : 5; // Check if entry exists, then access index 1 (value) + + }; + + const aOrder = findOrder( a ); + const bOrder = findOrder( b ); + + return aOrder - bOrder; + + } ); + + // Render each child + children.forEach( child => { + + renderObject( child, childContainer, level + 1 ); + + } ); + + } + +} + +// Function to update the UI +function updateUI() { + + const container = document.getElementById( 'scene-tree' ); + container.innerHTML = ''; + + const header = document.createElement( 'div' ); + header.className = 'header'; + header.style.display = 'flex'; // Use flexbox + header.style.justifyContent = 'space-between'; // Align items left and right + + const miscSpan = document.createElement( 'span' ); + miscSpan.innerHTML = '+'; + + const manifest = chrome.runtime.getManifest(); + + const manifestVersionSpan = document.createElement( 'span' ); + manifestVersionSpan.textContent = `${manifest.version}`; + manifestVersionSpan.style.opacity = '0.5'; // Make it less prominent + + header.appendChild( miscSpan ); + header.appendChild( manifestVersionSpan ); + + container.appendChild( header ); + + // Add renderers section + if ( state.renderers.size > 0 ) { + + const renderersSection = document.createElement( 'div' ); + renderersSection.className = 'section'; + renderersSection.innerHTML = '

Renderers

'; + + state.renderers.forEach( renderer => { + + renderRenderer( renderer, renderersSection ); + + } ); + + container.appendChild( renderersSection ); + + } + + // Add scenes section + if ( state.scenes.size > 0 ) { + + const scenesSection = document.createElement( 'div' ); + scenesSection.className = 'section'; + scenesSection.innerHTML = '

Scenes

'; + + state.scenes.forEach( scene => { + + renderObject( scene, scenesSection ); + + } ); + + container.appendChild( scenesSection ); + + } + +} + +// Initial UI update +clearState(); +updateUI(); diff --git a/devtools/screenshot.png b/devtools/screenshot.png new file mode 100644 index 00000000000000..8e81eba978f4b7 Binary files /dev/null and b/devtools/screenshot.png differ