From 2dc6d9a62842cebfa7cd102408d50baf84325221 Mon Sep 17 00:00:00 2001 From: mrdoob Date: Thu, 27 Aug 2026 17:50:34 +0900 Subject: [PATCH 1/2] DevTools: Clean up, light count and 1.17. (#34387) Co-authored-by: Claude Fable 5 --- devtools/README.md | 111 ++--- devtools/background.js | 165 +++----- devtools/bridge.js | 747 +++++++++++---------------------- devtools/constants.js | 47 ++- devtools/content-script.js | 81 ++-- devtools/devtools.js | 14 +- devtools/highlight.js | 144 ++----- devtools/manifest.json | 2 +- devtools/panel/panel.css | 189 ++++----- devtools/panel/panel.html | 1 - devtools/panel/panel.js | 822 +++++++++++++------------------------ 11 files changed, 756 insertions(+), 1567 deletions(-) diff --git a/devtools/README.md b/devtools/README.md index e91db5a46e2d22..3c95d86f40a707 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -1,98 +1,57 @@ -# Three.js DevTools Extension +# Three.js DevTools -This Chrome DevTools extension provides debugging capabilities for Three.js applications. It allows you to inspect scenes, objects, materials, and renderers. +A Chrome DevTools extension for inspecting three.js applications: the scene graph, the objects in it and the renderers drawing it. ## 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 +1. Open `chrome://extensions/` and enable "Developer mode". +2. Click "Load unpacked" and select this `devtools` directory. +3. Open DevTools on a page that uses three.js and select the "Three.js" tab. -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. +The toolbar icon shows the three.js revision found on the page. Clicking it scrolls the page to the first canvas. -## Code Flow Overview +## Features -### Extension Architecture +- **Scenes**: a collapsible tree of every scene and its objects, with geometry and material types for meshes, and object and light counts per scene. +- **Objects**: hover an object to see its position, rotation and scale, and to highlight it in the page with a yellow wireframe. +- **Renderers**: properties, render stats and memory usage of every `WebGLRenderer` and `WebGPURenderer`, with a button to scroll to its canvas. -The extension follows a standard Chrome DevTools extension architecture: +## How it works -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. +three.js has built-in support for the extension: when `window.__THREE_DEVTOOLS__` exists, it dispatches a `register` event with its revision and an `observe` event for the objects it creates, of which the bridge picks the renderers and scenes. -### Initialization Flow +### Files -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. +- `manifest.json`: injects the page scripts into every frame at `document_start`. +- `bridge.js`: runs in the page's main world. Creates `window.__THREE_DEVTOOLS__`, collects data from the observed renderers and scenes, and answers the panel's requests. +- `highlight.js`: runs alongside the bridge. Adds a yellow wireframe clone of the hovered object to its scene. +- `content-script.js`: runs in the isolated world. Relays messages between the bridge and the background script. +- `background.js`: service worker. Routes messages between the panel and the content script of the inspected tab, and manages the toolbar badge. +- `devtools.js`: creates the "Three.js" panel. +- `panel/`: the panel UI. Keeps the state received from the bridge and renders it. +- `constants.js`: the message names, shared by every script except `content-script.js` (see Development). -### Bridge Operation (`bridge.js`) +### Message flow -The bridge acts as the communication layer between the Three.js instance on the page and the DevTools panel: +``` +three.js ──dispatchEvent──▶ bridge.js ──postMessage──▶ content-script.js ──runtime.sendMessage──▶ background.js ──port──▶ panel.js + bridge.js ◀──postMessage── content-script.js ◀──tabs.sendMessage──── background.js ◀──port── panel.js +``` -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. +The panel opens a port to the background script and identifies the inspected tab (`init`); the background script forwards its requests to that tab's content script. -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. +Events from the page: `register`, `renderer`, `scene`, `scene-removed`, `object-details`. Requests from the panel: `request-state`, `request-object-details`, `scroll-to-canvas`, `highlight-object`, `unhighlight-object`. -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). +### State -5. **Message Handling**: - - Listens for messages from the panel (relayed via content script) like `request-state`. +The panel requests the state when it opens and then once a second. On each request the bridge resends every renderer (their stats change every frame), and a scene only when its object count changed. Scenes have no `dispose()`, so a scene that stays empty for several requests is removed from the panel, and brought back if it gains children again. -### 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. +The background script tags every message with the frame it came from and reports navigations, so the panel drops the renderers and scenes of a navigated frame (all of them for a top-level navigation). ## Development -To modify the extension: +1. Edit the files in this directory. +2. Reload the extension in `chrome://extensions/`. +3. Close and reopen DevTools on the inspected page. -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 +`bridge.js` and `content-script.js` run in different worlds of the same page and can't share a file: Chrome injects each file only once per frame, so `content-script.js` keeps its own copy of the message id. diff --git a/devtools/background.js b/devtools/background.js index 91d52cf4245756..af7eca8f9673bd 100644 --- a/devtools/background.js +++ b/devtools/background.js @@ -1,56 +1,58 @@ -/* global chrome, importScripts, MESSAGE_ID, MESSAGE_INIT, MESSAGE_REGISTER, MESSAGE_REQUEST_STATE, MESSAGE_REQUEST_OBJECT_DETAILS, MESSAGE_SCROLL_TO_CANVAS, MESSAGE_HIGHLIGHT_OBJECT, MESSAGE_UNHIGHLIGHT_OBJECT, MESSAGE_COMMITTED */ +/* global chrome, importScripts, MESSAGE_ID, MESSAGE_INIT, MESSAGE_REQUEST_STATE, MESSAGE_REQUEST_OBJECT_DETAILS, MESSAGE_SCROLL_TO_CANVAS, MESSAGE_HIGHLIGHT_OBJECT, MESSAGE_UNHIGHLIGHT_OBJECT, EVENT_REGISTER, EVENT_COMMITTED */ importScripts( 'constants.js' ); -// Map tab IDs to connections +// Map tab IDs to devtools panel connections const connections = new Map(); -// Handle extension icon clicks in the toolbar -chrome.action.onClicked.addListener( ( tab ) => { +// Panel requests that are forwarded to the page +const FORWARDABLE_MESSAGES = new Set( [ + MESSAGE_REQUEST_STATE, + MESSAGE_REQUEST_OBJECT_DETAILS, + MESSAGE_SCROLL_TO_CANVAS, + MESSAGE_HIGHLIGHT_OBJECT, + MESSAGE_UNHIGHLIGHT_OBJECT +] ); - // 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( () => { +// Badge helpers. The tab may already be gone, so errors are ignored. +function setBadge( tabId, text, color ) { - // Ignore error - tab might not have the content script injected - console.log( 'Could not send scroll-to-canvas message to tab', tab.id ); + chrome.action.setBadgeText( { tabId: tabId, text: text } ).catch( () => {} ); + chrome.action.setBadgeTextColor( { tabId: tabId, color: '#ffffff' } ).catch( () => {} ); + chrome.action.setBadgeBackgroundColor( { tabId: tabId, color: color } ).catch( () => {} ); - } ); +} + +function clearBadge( tabId ) { + + chrome.action.setBadgeText( { tabId: tabId, text: '' } ).catch( () => {} ); + +} + +// Toolbar icon click scrolls the page to its first canvas +chrome.action.onClicked.addListener( ( tab ) => { + + // No content script in this tab (e.g. chrome:// pages) + chrome.tabs.sendMessage( tab.id, { name: MESSAGE_SCROLL_TO_CANVAS } ).catch( () => {} ); } ); // Listen for connections from the devtools panel -chrome.runtime.onConnect.addListener( port => { +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 => { + port.onMessage.addListener( ( message ) => { if ( message.name === MESSAGE_INIT ) { tabId = message.tabId; connections.set( tabId, port ); - } else if ( forwardableMessages.has( message.name ) && tabId ) { + } else if ( FORWARDABLE_MESSAGES.has( message.name ) ) { chrome.tabs.sendMessage( tabId, message ); - } else if ( tabId === undefined ) { - - console.warn( 'Background: Message received from panel before init:', message ); - } } ); @@ -58,18 +60,14 @@ chrome.runtime.onConnect.addListener( port => { // Clean up when devtools is closed port.onDisconnect.addListener( () => { - if ( tabId ) { - - connections.delete( tabId ); - - } + connections.delete( tabId ); } ); } ); -// Listen for messages from the content script -chrome.runtime.onMessage.addListener( ( message, sender, sendResponse ) => { +// Messages from the content script +chrome.runtime.onMessage.addListener( ( message, sender ) => { if ( message.scheme ) { @@ -81,104 +79,51 @@ chrome.runtime.onMessage.addListener( ( message, sender, sendResponse ) => { } - 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' ); + if ( sender.tab === undefined ) return; - chrome.action.setBadgeText( { tabId: tabId, text: number } ).catch( () => { + const tabId = sender.tab.id; - // Ignore error - tab might have been closed + // If three.js is detected, show its revision as a badge + if ( message.name === EVENT_REGISTER ) { - } ); - chrome.action.setBadgeTextColor( { tabId: tabId, color: '#ffffff' } ).catch( () => { + const revision = message.detail.revision; + const isDev = revision.includes( 'dev' ); - // Ignore error - tab might have been closed + setBadge( tabId, revision.replace( /\D+$/, '' ), isDev ? '#ff0098' : '#049ef4' ); - } ); - chrome.action.setBadgeBackgroundColor( { tabId: tabId, color: isDev ? '#ff0098' : '#049ef4' } ).catch( () => { + } - // Ignore error - tab might have been closed + const port = connections.get( tabId ); - } ); + if ( port !== undefined ) { - } + // The panel keeps track of which frame each renderer and scene came from + message.frameId = sender.frameId; - const port = connections.get( tabId ); - if ( port ) { + try { - // Forward the message to the devtools panel - try { + port.postMessage( message ); - port.postMessage( message ); - // Send immediate response to avoid "message channel closed" error - sendResponse( { received: true } ); + } catch ( error ) { - } catch ( e ) { - - console.error( 'Error posting message to devtools:', e ); - // If the port is broken, clean up the connection - connections.delete( tabId ); - - } + // Port already disconnected } } - return false; // Return false to indicate synchronous handling - } ); -// Listen for page navigation events -chrome.webNavigation.onCommitted.addListener( details => { - - const { tabId, frameId } = details; +// A navigated frame's renderers and scenes are gone, let the panel drop them +chrome.webNavigation.onCommitted.addListener( ( { tabId, frameId } ) => { - // 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 - - } ); - - } + if ( frameId === 0 ) clearBadge( tabId ); 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( () => { + if ( port !== undefined ) { - // Ignore error - tab is already gone - - } ); - - // Clean up connection if it exists for the closed tab - if ( connections.has( tabId ) ) { - - connections.delete( tabId ); + port.postMessage( { id: MESSAGE_ID, name: EVENT_COMMITTED, frameId: frameId } ); } diff --git a/devtools/bridge.js b/devtools/bridge.js index 0e4bd6adc8dcda..4dc7b56c7a1ee5 100644 --- a/devtools/bridge.js +++ b/devtools/bridge.js @@ -1,671 +1,386 @@ -/* global MESSAGE_ID, MESSAGE_REQUEST_STATE, MESSAGE_REQUEST_OBJECT_DETAILS, MESSAGE_SCROLL_TO_CANVAS, MESSAGE_HIGHLIGHT_OBJECT, MESSAGE_UNHIGHLIGHT_OBJECT, EVENT_REGISTER, EVENT_OBSERVE, EVENT_RENDERER, EVENT_SCENE, EVENT_SCENE_REMOVED, EVENT_OBJECT_DETAILS, EVENT_DEVTOOLS_READY */ +/* global MESSAGE_ID, HIGHLIGHT_NAME, MESSAGE_REQUEST_STATE, MESSAGE_REQUEST_OBJECT_DETAILS, MESSAGE_SCROLL_TO_CANVAS, MESSAGE_HIGHLIGHT_OBJECT, MESSAGE_UNHIGHLIGHT_OBJECT, EVENT_REGISTER, EVENT_OBSERVE, EVENT_RENDERER, EVENT_SCENE, EVENT_SCENE_REMOVED, EVENT_OBJECT_DETAILS */ /** - * This script injected by the installed three.js developer - * tools extension. + * Injected into the page by the three.js DevTools extension. Exposes + * window.__THREE_DEVTOOLS__ for three.js to report renderers and scenes, + * and answers the panel's requests. */ ( function () { - const HIGHLIGHT_OVERLAY_DURATION = 1000; + if ( window.__THREE_DEVTOOLS__ ) return; - // Only initialize if not already initialized - if ( ! window.__THREE_DEVTOOLS__ ) { + const CANVAS_FLASH_DURATION = 1000; + const SCENE_EMPTY_TICKS_THRESHOLD = 5; // ~5s at 1s polling - hide empty scene from the panel - // Create our custom EventTarget with logging - class DevToolsEventTarget extends EventTarget { + const observedScenes = []; + const observedRenderers = []; + const sceneObjectCountCache = new Map(); // Object count per scene at the last batch sent, absent while hidden from the panel + const sceneEmptyTicks = new Map(); // Consecutive sendState ticks each scene has been empty - constructor() { + // three.js reports its renderers and scenes by dispatching events on this + const devTools = new EventTarget(); + Object.defineProperty( window, '__THREE_DEVTOOLS__', { value: devTools, enumerable: true } ); - super(); - this._ready = false; - this._backlog = []; - this.objects = new Map(); + // Expose utilities for highlight.js + devTools.utils = { findObjectInScenes }; - } - - addEventListener( type, listener, options ) { - - super.addEventListener( type, listener, options ); + // Renderers have no uuid of their own + function generateUUID() { - // 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 ) ); - - } + 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( '' ); - } - - dispatchEvent( event ) { - - if ( this._ready || event.type === EVENT_DEVTOOLS_READY ) { + } - if ( event.type === EVENT_DEVTOOLS_READY ) { + function xyz( vector ) { - this._ready = true; - const backlog = this._backlog; - this._backlog = []; - backlog.forEach( e => super.dispatchEvent( e ) ); + return { x: vector.x, y: vector.y, z: vector.z }; - } + } - return super.dispatchEvent( event ); + // Send a message to the panel (relayed by the content script) + function postToPanel( name, detail ) { - } else { + window.postMessage( { id: MESSAGE_ID, name: name, detail: detail }, '/' ); - this._backlog.push( event ); - return false; // Return false to indicate synchronous handling + } + // --- Data extraction --- + + function getRendererProperties( renderer ) { + + const parameters = renderer.getContextAttributes ? renderer.getContextAttributes() : {}; + + return { + width: renderer.domElement.clientWidth, + height: renderer.domElement.clientHeight, + alpha: parameters.alpha || false, + antialias: parameters.antialias || false, + outputColorSpace: renderer.outputColorSpace, + toneMapping: renderer.toneMapping, + toneMappingExposure: renderer.toneMappingExposure, + shadows: renderer.shadowMap.enabled, + autoClear: renderer.autoClear, + autoClearColor: renderer.autoClearColor, + autoClearDepth: renderer.autoClearDepth, + autoClearStencil: renderer.autoClearStencil, + localClipping: renderer.localClippingEnabled, + info: { + render: { + frame: renderer.isWebGPURenderer ? renderer.info.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 + }, + memory: { + geometries: renderer.info.memory.geometries, + textures: renderer.info.memory.textures, + programs: renderer.info.programs ? renderer.info.programs.length : 0 } - } + }; - reset() { - + } - // Clear objects map - this.objects.clear(); + function getRendererData( renderer ) { - // Clear backlog - this._backlog = []; + try { - // Reset ready state - this._ready = false; + return { + uuid: renderer.uuid, + type: renderer.isWebGLRenderer ? 'WebGLRenderer' : 'WebGPURenderer', + properties: getRendererProperties( renderer ), + canvasInDOM: document.contains( renderer.domElement ) + }; - // Clear observed arrays - observedScenes.length = 0; - observedRenderers.length = 0; - sceneObjectCountCache.clear(); - sceneEmptyTicks.clear(); - removedScenes.clear(); + } catch ( error ) { - } + console.warn( 'DevTools: Error getting renderer data:', error ); + return null; } - // 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 - const sceneEmptyTicks = new Map(); // Consecutive sendState ticks each scene has been empty - const removedScenes = new Set(); // Scenes hidden from the panel; tracked so we can resurrect them - const SCENE_EMPTY_TICKS_THRESHOLD = 5; // ~5s at 1s polling — hide empty scene from the panel - - // 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 ) ) { + function getObjectData( object ) { + + try { + + const data = { + uuid: object.uuid, + name: object.name, + type: object.isInstancedMesh ? 'InstancedMesh' : object.type, // InstancedMesh doesn't set its own type + visible: object.visible, + isScene: object.isScene === true, + isCamera: object.isCamera === true, + isLight: object.isLight === true, + isGroup: object.isGroup === true, + isMesh: object.isMesh === true, + isInstancedMesh: object.isInstancedMesh === true, + children: object.children.map( child => child.uuid ) + }; - object.children.forEach( child => traverse( child ) ); + if ( object.isMesh ) { - } + data.geometryType = object.geometry.type; + data.materialType = Array.isArray( object.material ) ? object.material.map( m => m.type ).join( ', ' ) : object.material.type; } - traverse( rootObject ); + if ( object.isInstancedMesh ) { - } - - // 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; + data.count = object.count; } - } - - // Function to get object hierarchy - function getObjectData( obj ) { - - try { - - // Special case for WebGLRenderer - if ( obj.isWebGLRenderer === true || obj.isWebGPURenderer === true ) { + return data; - return getRendererData( obj ); + } catch ( error ) { - } - - // 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( '' ); + console.warn( 'DevTools: Error getting object data:', error ); + return null; } - // Listen for Three.js registration - devTools.addEventListener( EVENT_REGISTER, ( event ) => { + } - dispatchEvent( EVENT_REGISTER, event.detail ); + // Collect data for a scene and all of its descendants + function collectSceneObjects( scene ) { - } ); + const objects = []; - // Listen for object observations - devTools.addEventListener( EVENT_OBSERVE, ( event ) => { + ( function traverse( object ) { - const obj = event.detail; - if ( ! obj ) { + // Skip the highlight clone added by highlight.js + if ( object.name === HIGHLIGHT_NAME ) return; - console.warn( 'DevTools: Received observe event with null/undefined detail' ); - return; + const data = getObjectData( object ); + if ( data !== null ) objects.push( data ); - } + object.children.forEach( traverse ); - // Generate UUID if needed - if ( ! obj.uuid ) { + } )( scene ); - obj.uuid = generateUUID(); + return objects; - } - - // Skip if already registered (essential to prevent loops with batching) - if ( devTools.objects.has( obj.uuid ) ) { + } - return; + function findObjectInScenes( uuid ) { - } + for ( const scene of observedScenes ) { - if ( obj.isWebGLRenderer || obj.isWebGPURenderer ) { + const object = scene.getObjectByProperty( 'uuid', uuid ); + if ( object !== undefined ) return object; - const data = getObjectData( obj ); + } - if ( data ) { + return null; - data.properties = getRendererProperties( obj ); - observedRenderers.push( obj ); - devTools.objects.set( obj.uuid, data ); + } - dispatchEvent( EVENT_RENDERER, data ); + // --- Three.js events --- - } + devTools.addEventListener( EVENT_REGISTER, ( event ) => { - } else if ( obj.isScene ) { + postToPanel( EVENT_REGISTER, event.detail ); - observedScenes.push( obj ); + } ); - const batchObjects = []; + devTools.addEventListener( EVENT_OBSERVE, ( event ) => { - traverseObjectTree( obj, ( currentObj ) => { + const object = event.detail; - const objectData = getObjectData( currentObj ); - if ( objectData ) { + if ( object.isWebGLRenderer || object.isWebGPURenderer ) { - batchObjects.push( objectData ); - devTools.objects.set( currentObj.uuid, objectData ); // Update local cache during batch creation + if ( object.uuid === undefined ) object.uuid = generateUUID(); - } + const data = getRendererData( object ); - }, true ); + if ( data !== null ) { - dispatchEvent( EVENT_SCENE, { sceneUuid: obj.uuid, objects: batchObjects } ); + observedRenderers.push( object ); + postToPanel( EVENT_RENDERER, data ); } - } ); - - // Function to get renderer properties - function getRendererProperties( renderer ) { + } else if ( object.isScene ) { - 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 - } - } - }; + observedScenes.push( object ); + reloadSceneObjects( object ); } + } ); - // Function to check if bridge is available - function checkBridgeAvailability() { - - const devToolsValue = window.__THREE_DEVTOOLS__; + // Old three.js versions don't register themselves, detect the global instead + window.addEventListener( 'load', () => { - // If we have devtools and we're interactive or complete, trigger ready - if ( devToolsValue && ( document.readyState === 'interactive' || document.readyState === 'complete' ) ) { + if ( window.THREE && window.THREE.REVISION ) { - devTools.dispatchEvent( new CustomEvent( EVENT_DEVTOOLS_READY ) ); - - } + postToPanel( EVENT_REGISTER, { revision: window.THREE.REVISION } ); } - // Watch for readyState changes - document.addEventListener( 'readystatechange', () => { - - if ( document.readyState === 'loading' ) { + } ); - devTools.reset(); - - } + // --- Panel requests --- - checkBridgeAvailability(); + window.addEventListener( 'message', ( event ) => { - } ); + // Only accept messages from the same frame + if ( event.source !== window ) return; - // Check if THREE is in the global scope (Old versions) - window.addEventListener( 'load', () => { + const message = event.data; + if ( ! message || message.id !== MESSAGE_ID ) return; - 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 ) { + switch ( message.name ) { + case MESSAGE_REQUEST_STATE: sendState(); + break; - } else if ( message.name === MESSAGE_REQUEST_OBJECT_DETAILS ) { - + case MESSAGE_REQUEST_OBJECT_DETAILS: sendObjectDetails( message.uuid ); + break; - } else if ( message.name === MESSAGE_SCROLL_TO_CANVAS ) { - + case MESSAGE_SCROLL_TO_CANVAS: scrollToCanvas( message.uuid ); + break; - } 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() { + case MESSAGE_HIGHLIGHT_OBJECT: + devTools.dispatchEvent( new CustomEvent( MESSAGE_HIGHLIGHT_OBJECT, { detail: { uuid: message.uuid } } ) ); + break; - // Send current renderers - for ( const observedRenderer of observedRenderers ) { + case MESSAGE_UNHIGHLIGHT_OBJECT: + devTools.dispatchEvent( new CustomEvent( MESSAGE_UNHIGHLIGHT_OBJECT ) ); + break; - const data = getObjectData( observedRenderer ); - if ( data ) { - - data.properties = getRendererProperties( observedRenderer ); - dispatchEvent( EVENT_RENDERER, data ); - - } - - } + } - // Send current scenes. Three.js scenes have no dispose() method, so we - // approximate disposal: a scene that has stayed empty for several poll - // cycles is hidden from the panel; if it gains children again, we - // resurrect it (reloadSceneObjects re-dispatches the batch). - for ( const scene of observedScenes ) { + } ); - const isEmpty = scene.children.length === 0; - const wasRemoved = removedScenes.has( scene.uuid ); + function sendState() { - if ( isEmpty ) { + for ( const renderer of observedRenderers ) { - // Already hidden — nothing to send until children come back - if ( wasRemoved ) continue; + const data = getRendererData( renderer ); + if ( data !== null ) postToPanel( EVENT_RENDERER, data ); - const ticks = ( sceneEmptyTicks.get( scene.uuid ) || 0 ) + 1; + } - if ( ticks >= SCENE_EMPTY_TICKS_THRESHOLD ) { + // Scenes have no dispose(), so one that stays empty for several polls is + // hidden from the panel, and brought back if it gains children again + for ( const scene of observedScenes ) { - removedScenes.add( scene.uuid ); - sceneEmptyTicks.delete( scene.uuid ); - sceneObjectCountCache.delete( scene.uuid ); - dispatchEvent( EVENT_SCENE_REMOVED, { uuid: scene.uuid } ); - continue; + if ( scene.children.length === 0 ) { - } + // Already hidden, nothing to send until children come back + if ( ! sceneObjectCountCache.has( scene.uuid ) ) continue; - sceneEmptyTicks.set( scene.uuid, ticks ); + const ticks = ( sceneEmptyTicks.get( scene.uuid ) || 0 ) + 1; - } else { + if ( ticks >= SCENE_EMPTY_TICKS_THRESHOLD ) { sceneEmptyTicks.delete( scene.uuid ); - - // Repopulated after removal — bring it back into the panel - if ( wasRemoved ) removedScenes.delete( scene.uuid ); + sceneObjectCountCache.delete( scene.uuid ); + postToPanel( EVENT_SCENE_REMOVED, { uuid: scene.uuid } ); + continue; } - reloadSceneObjects( scene ); - - } - - } - - function findObjectInScenes( uuid ) { + sceneEmptyTicks.set( scene.uuid, ticks ); - for ( const scene of observedScenes ) { - - // Check if we're looking for the scene itself - if ( scene.uuid === uuid ) return scene; + } else { - const found = scene.getObjectByProperty( 'uuid', uuid ); - if ( found ) return found; + sceneEmptyTicks.delete( scene.uuid ); } - return null; + reloadSceneObjects( scene ); } - // 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 ); + // Send a scene batch when its object count changed (a hidden scene has no count, so it comes back) + function reloadSceneObjects( scene ) { - } + const objects = collectSceneObjects( scene ); - 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 ); + if ( objects.length !== sceneObjectCountCache.get( scene.uuid ) ) { - } + sceneObjectCountCache.set( scene.uuid, objects.length ); + postToPanel( EVENT_SCENE, { sceneUuid: scene.uuid, objects: objects } ); } - 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 ) { + function sendObjectDetails( uuid ) { - // Scroll the canvas element into view - renderer.domElement.scrollIntoView( { - behavior: 'smooth', - block: 'center', - inline: 'center' - } ); + const object = findObjectInScenes( uuid ); - // Add a brief blue overlay flash effect - createHighlightOverlay( renderer.domElement ); + if ( object ) { - } + postToPanel( EVENT_OBJECT_DETAILS, { + position: xyz( object.position ), + rotation: xyz( object.rotation ), + scale: xyz( object.scale ) + } ); } - 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' ) ) { + function scrollToCanvas( uuid ) { - console.log( 'DevTools: Extension context invalidated, stopping monitoring' ); - devTools.reset(); - return; + // Without a uuid, pick the first renderer whose canvas is in the DOM + const renderer = uuid ? + observedRenderers.find( r => r.uuid === uuid ) : + observedRenderers.find( r => document.contains( r.domElement ) ); - } + if ( renderer ) { - console.warn( 'DevTools: Error dispatching event:', error ); + renderer.domElement.scrollIntoView( { behavior: 'smooth', block: 'center', inline: 'center' } ); - } + flashCanvas( renderer.domElement ); } - // 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 ); + // Brief blue overlay on top of the canvas + function flashCanvas( canvas ) { - } + 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 canvas + const parent = canvas.parentElement || document.body; - // --- Caching Logic --- - const currentObjectCount = batchObjects.length; - const previousObjectCount = sceneObjectCountCache.get( scene.uuid ); + if ( getComputedStyle( parent ).position === 'static' ) { - if ( currentObjectCount !== previousObjectCount ) { + parent.style.position = 'relative'; - // Dispatch the batch update for the panel - dispatchEvent( EVENT_SCENE, { sceneUuid: scene.uuid, objects: batchObjects } ); - // Update the cache - sceneObjectCountCache.set( scene.uuid, currentObjectCount ); + } - } + parent.appendChild( overlay ); - } + setTimeout( () => overlay.remove(), CANVAS_FLASH_DURATION ); } diff --git a/devtools/constants.js b/devtools/constants.js index 62ed2341cf940f..b230e3e61c54c5 100644 --- a/devtools/constants.js +++ b/devtools/constants.js @@ -1,24 +1,29 @@ -/* eslint-disable no-unused-vars */ -// Shared protocol constants for Three.js DevTools +// Shared protocol constants for Three.js DevTools, assigned to the global object +// so every script that loads this file can read them +Object.assign( globalThis, { -var MESSAGE_ID = 'three-devtools'; + 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'; + // Name of the highlight clone highlight.js adds to the scene + HIGHLIGHT_NAME: '__THREE_DEVTOOLS_HIGHLIGHT__', -// 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'; -var EVENT_SCENE_REMOVED = 'scene-removed'; + // Requests from the panel (panel -> background -> content script -> bridge) + MESSAGE_INIT: 'init', + MESSAGE_REQUEST_STATE: 'request-state', + MESSAGE_REQUEST_OBJECT_DETAILS: 'request-object-details', + MESSAGE_SCROLL_TO_CANVAS: 'scroll-to-canvas', + MESSAGE_HIGHLIGHT_OBJECT: 'highlight-object', + MESSAGE_UNHIGHLIGHT_OBJECT: 'unhighlight-object', + + // Events dispatched by three.js on window.__THREE_DEVTOOLS__ + EVENT_REGISTER: 'register', + EVENT_OBSERVE: 'observe', + + // Events sent to the panel + EVENT_RENDERER: 'renderer', + EVENT_SCENE: 'scene', + EVENT_SCENE_REMOVED: 'scene-removed', + EVENT_OBJECT_DETAILS: 'object-details', + EVENT_COMMITTED: 'committed' + +} ); diff --git a/devtools/content-script.js b/devtools/content-script.js index 744df0c609c9a3..4dedf7ddfe5bc9 100644 --- a/devtools/content-script.js +++ b/devtools/content-script.js @@ -1,80 +1,45 @@ /* global chrome */ -// Constants +// Chrome injects each file only once per frame, so constants.js (already +// injected into the MAIN world for bridge.js) can't be reused here 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() { +// Relay bridge messages to the background script +window.addEventListener( 'message', ( event ) => { - try { - - chrome.runtime.getURL( '' ); - return true; - - } catch ( error ) { - - return false; - - } - -} - -// Unified message handler for window messages -function handleWindowMessage( event ) { + // Only accept messages from the same frame + if ( event.source !== window ) return; - // 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'; + try { + + chrome.runtime.sendMessage( event.data ); - if ( ! isExtensionContextValid() ) { + } catch ( error ) { - console.warn( 'Extension context invalidated, cannot send message' ); - return; + // Extension reloaded under a live page, chrome.runtime is gone } - event.data.source = source; - chrome.runtime.sendMessage( event.data ); +} ); -} +// Relay messages from the background script (panel requests, toolbar clicks) to the bridge +chrome.runtime.onMessage.addListener( ( message ) => { -// Listener for messages from the background script (originating from panel) -function handleBackgroundMessage( message ) { + message.id = MESSAGE_ID; + window.postMessage( 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 ) ) { +// Toolbar icon follows the page's color scheme +const lightScheme = window.matchMedia( '(prefers-color-scheme: light)' ); - message.id = MESSAGE_ID; - window.postMessage( message, '*' ); +function sendScheme() { - } + chrome.runtime.sendMessage( { scheme: lightScheme.matches ? 'light' : 'dark' } ); } -// 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' } ); - -}; - +sendScheme(); +lightScheme.onchange = sendScheme; diff --git a/devtools/devtools.js b/devtools/devtools.js index 5929e662c63671..05b42196246580 100644 --- a/devtools/devtools.js +++ b/devtools/devtools.js @@ -1,15 +1,3 @@ /* global chrome */ -try { - - chrome.devtools.panels.create( - 'Three.js', - null, - 'panel/panel.html' - ); - -} catch ( error ) { - - console.error( 'Failed to create Three.js panel:', error ); - -} +chrome.devtools.panels.create( 'Three.js', null, 'panel/panel.html' ); diff --git a/devtools/highlight.js b/devtools/highlight.js index cc36eb5ae80967..e5aa456ece605a 100644 --- a/devtools/highlight.js +++ b/devtools/highlight.js @@ -1,29 +1,25 @@ -// This script handles highlighting of Three.js objects in the 3D scene +/* global HIGHLIGHT_NAME, MESSAGE_HIGHLIGHT_OBJECT, MESSAGE_UNHIGHLIGHT_OBJECT */ -( function () { +// Highlights the object hovered in the panel with a yellow wireframe clone - 'use strict'; +( function () { let highlightObject = null; function cloneMaterial( material ) { - // Skip MeshNormalMaterial - if ( material.isMeshNormalMaterial ) { + // MeshNormalMaterial has no color to override + if ( material.isMeshNormalMaterial ) return material; - return material; - - } + const cloned = new material.constructor(); - // Handle ShaderMaterial and RawShaderMaterial - if ( material.isShaderMaterial || material.isRawShaderMaterial ) { + if ( material.isShaderMaterial ) { - // Create new material of the same type - const cloned = new material.constructor(); + // Replace the shaders with a flat yellow output + const raw = material.isRawShaderMaterial; - // Override shaders with simple yellow output - const vertexShader = ` - ${ material.isRawShaderMaterial ? `attribute vec3 position; + cloned.vertexShader = ` + ${ raw ? `attribute vec3 position; uniform mat4 modelViewMatrix; uniform mat4 projectionMatrix; ` : '' }void main() { @@ -31,64 +27,25 @@ } `; - const fragmentShader = ` - ${ material.isRawShaderMaterial ? `precision highp float; + cloned.fragmentShader = ` + ${ raw ? `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; + } else { - return cloned; + if ( cloned.color ) cloned.color.setRGB( 1, 1, 0 ); + if ( cloned.emissive ) cloned.emissive.setRGB( 1, 1, 0 ); } - // 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 + // Yellow wireframe drawn on top of everything + cloned.wireframe = true; cloned.depthTest = false; cloned.depthWrite = false; cloned.transparent = true; - cloned.opacity = 1; - - // Disable tone mapping and fog cloned.toneMapped = false; cloned.fog = false; @@ -99,71 +56,44 @@ 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 ) { + // Renderers, helpers, the highlight itself and objects without geometry can't be highlighted + if ( ! object || object.type.includes( 'Helper' ) || object.name === HIGHLIGHT_NAME || ! object.geometry ) { - if ( highlightObject ) highlightObject.visible = false; + unhighlight(); return; } - // Remove old highlight if it exists - if ( highlightObject && highlightObject.parent ) { - - highlightObject.parent.remove( highlightObject ); - - } + if ( highlightObject ) highlightObject.removeFromParent(); // Clone the object to preserve all properties (skeleton, bindMatrix, etc) highlightObject = object.clone(); - highlightObject.name = '__THREE_DEVTOOLS_HIGHLIGHT__'; + highlightObject.name = HIGHLIGHT_NAME; + highlightObject.castShadow = false; + highlightObject.receiveShadow = false; + highlightObject.renderOrder = Infinity; + highlightObject.visible = true; - // Apply yellow wireframe material if ( highlightObject.material ) { - if ( Array.isArray( highlightObject.material ) ) { - - highlightObject.material = highlightObject.material.map( cloneMaterial ); - - } else { - - highlightObject.material = cloneMaterial( highlightObject.material ); - - } + highlightObject.material = Array.isArray( highlightObject.material ) + ? highlightObject.material.map( cloneMaterial ) + : 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 + // Follow the original by sharing its matrixWorld highlightObject.matrixAutoUpdate = false; highlightObject.matrixWorldAutoUpdate = false; + highlightObject.matrixWorld = object.matrixWorld; - // Find the scene and add at root + // Add at the scene 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() { @@ -177,16 +107,12 @@ } // Listen for highlight events from bridge.js - __THREE_DEVTOOLS__.addEventListener( 'highlight-object', ( event ) => { + __THREE_DEVTOOLS__.addEventListener( MESSAGE_HIGHLIGHT_OBJECT, ( event ) => { highlight( event.detail.uuid ); } ); - __THREE_DEVTOOLS__.addEventListener( 'unhighlight-object', () => { - - unhighlight(); - - } ); + __THREE_DEVTOOLS__.addEventListener( MESSAGE_UNHIGHLIGHT_OBJECT, unhighlight ); } )(); diff --git a/devtools/manifest.json b/devtools/manifest.json index 2384a5ce216315..f42bbc5bf65138 100644 --- a/devtools/manifest.json +++ b/devtools/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Three.js DevTools", - "version": "1.16", + "version": "1.17", "description": "Developer tools extension for Three.js", "icons": { "128": "icons/128-light.png" diff --git a/devtools/panel/panel.css b/devtools/panel/panel.css index a5ac3df2ea1e9d..2bebe2b0dc07fb 100644 --- a/devtools/panel/panel.css +++ b/devtools/panel/panel.css @@ -11,17 +11,9 @@ body { font-size: 12px; } -hr { - color: light-dark( #333, #e0e0e0 ); -} - -#scene-tree { - width: 100%; - height: 100%; - overflow: auto; -} - .header { + display: flex; + justify-content: space-between; padding: 8px 12px; background: light-dark( #f5f5f5, #333 ); border-radius: 4px; @@ -29,27 +21,37 @@ hr { 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 ); - } +.header a { + color: light-dark( #666, #aaa ); + text-decoration: none; +} +.header a:hover { + color: light-dark( #333, #e0e0e0 ); +} +.header .version { + opacity: 0.5; +} .section { margin-bottom: 24px; } +.section:empty { + display: none; +} +.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; +} - .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; - } +/* Collapsible nodes (scene tree and renderers) */ +details > summary { + list-style: none; +} .tree-item { padding: 4px; @@ -60,6 +62,9 @@ hr { .tree-item:hover { background: light-dark( #f0f0f0, #555 ); } +.tree-item.invisible { + opacity: 0.5; +} .tree-item .icon { margin-right: 4px; opacity: 0.7; @@ -73,7 +78,6 @@ hr { .tree-item .label .object-details { color: #aaa; margin-left: 4px; - font-weight: normal; } .tree-item .type { margin-left: 8px; @@ -81,21 +85,8 @@ hr { font-size: 0.9em; } -.children { - margin-left: 0; -} - -/* Collapsible scene tree nodes */ -details.tree-node > summary.tree-item { - list-style: none; -} -details.tree-node > summary.tree-item::-webkit-details-marker { - display: none; -} - .tree-toggle, .tree-toggle-placeholder { - display: inline-block; width: 1em; margin-right: 2px; text-align: center; @@ -106,37 +97,53 @@ details.tree-node > summary.tree-item::-webkit-details-marker { font-size: 0.7em; opacity: 0.6; } -details.tree-node[open] > summary.tree-item .tree-toggle::before { +details[open] > summary .tree-toggle::before { content: '▼'; } -/* Style for clickable renderer summary */ -.renderer-summary { - cursor: pointer; +/* Renderer properties */ +.properties-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px 20px; + padding-left: 20px; } -.renderer-summary:hover { - background: light-dark( #f0f0f0, #555 ); +.properties-list h4:not(:first-child) { + margin-top: 10px; } - -/* 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 */ +.property-row { + display: flex; + justify-content: space-between; + margin-bottom: 2px; } -details.renderer-container > summary.renderer-summary::-webkit-details-marker { - display: none; /* Hide default arrow in WebKit */ +.property-label { + margin-right: 10px; + white-space: nowrap; +} +.property-value { + text-align: right; } -/* Style for the toggle icon */ -.toggle-icon::before { - content: '▶'; /* Default: collapsed */ - display: inline-block; - width: 1em; - margin-right: 2px; - opacity: 0.7; +/* 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; } -details.renderer-container[open] > summary.renderer-summary .toggle-icon::before { - content: '▼'; /* Expanded */ +.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 { + margin-left: 8px; + padding: 2px 4px; + opacity: 0.3; } /* Floating object details panel */ @@ -148,57 +155,20 @@ details.renderer-container[open] > summary.renderer-summary .toggle-icon::before 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; +.floating-details .vector-row { + margin-bottom: 2px; + font-family: monospace; 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; + white-space: pre; } /* Two-column layout for wide panel */ @@ -208,18 +178,9 @@ details.renderer-container[open] > summary.renderer-summary .toggle-icon::before gap: 20px; align-items: flex-start; } - .sections-container .section { flex: 1; + min-width: 0; 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 */ - } -} \ No newline at end of file +} diff --git a/devtools/panel/panel.html b/devtools/panel/panel.html index dcc7b9027db56f..1f8e933454f6df 100644 --- a/devtools/panel/panel.html +++ b/devtools/panel/panel.html @@ -6,7 +6,6 @@ -
diff --git a/devtools/panel/panel.js b/devtools/panel/panel.js index 3b452ee199a88a..a18242f657c369 100644 --- a/devtools/panel/panel.js +++ b/devtools/panel/panel.js @@ -1,757 +1,484 @@ -/* global chrome, MESSAGE_ID, MESSAGE_INIT, MESSAGE_REQUEST_STATE, MESSAGE_REQUEST_OBJECT_DETAILS, MESSAGE_SCROLL_TO_CANVAS, MESSAGE_HIGHLIGHT_OBJECT, MESSAGE_UNHIGHLIGHT_OBJECT, EVENT_REGISTER, EVENT_RENDERER, EVENT_OBJECT_DETAILS, EVENT_SCENE, EVENT_SCENE_REMOVED, EVENT_COMMITTED */ +/* global chrome, MESSAGE_ID, MESSAGE_INIT, MESSAGE_REQUEST_STATE, MESSAGE_REQUEST_OBJECT_DETAILS, MESSAGE_SCROLL_TO_CANVAS, MESSAGE_HIGHLIGHT_OBJECT, MESSAGE_UNHIGHLIGHT_OBJECT, EVENT_RENDERER, EVENT_OBJECT_DETAILS, EVENT_SCENE, EVENT_SCENE_REMOVED, EVENT_COMMITTED */ -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 + objects: new Map() }; -// Floating details panel +// Open/closed state of collapsible nodes (uuid -> boolean), kept across rebuilds +const openState = new Map(); + +// Static DOM elements (created once in initUI) +let renderersSection = null; +let scenesSection = null; let floatingPanel = null; + const mousePosition = { x: 0, y: 0 }; +// --- Connection --- -// Create a connection to the background page -const backgroundPageConnection = chrome.runtime.connect( { - name: CONNECTION_NAME -} ); +const port = chrome.runtime.connect(); +const intervalId = setInterval( () => send( MESSAGE_REQUEST_STATE ), STATE_POLLING_INTERVAL ); -// Initialize the connection with the inspected tab ID -backgroundPageConnection.postMessage( { - name: MESSAGE_INIT, - tabId: chrome.devtools.inspectedWindow.tabId -} ); +function send( name, data ) { -// Request the initial state from the bridge script -backgroundPageConnection.postMessage( { - name: MESSAGE_REQUEST_STATE, - tabId: chrome.devtools.inspectedWindow.tabId -} ); + try { -// Function to scroll to canvas element -function scrollToCanvas( rendererUuid ) { + port.postMessage( { name: name, ...data } ); - backgroundPageConnection.postMessage( { - name: MESSAGE_SCROLL_TO_CANVAS, - uuid: rendererUuid, - tabId: chrome.devtools.inspectedWindow.tabId - } ); + } catch ( error ) { -} + // Extension reloaded under an open panel, chrome.runtime is gone + clearInterval( intervalId ); -const intervalId = setInterval( () => { + } - backgroundPageConnection.postMessage( { - name: MESSAGE_REQUEST_STATE, - tabId: chrome.devtools.inspectedWindow.tabId - } ); +} -}, STATE_POLLING_INTERVAL ); +send( MESSAGE_INIT, { tabId: chrome.devtools.inspectedWindow.tabId } ); +send( MESSAGE_REQUEST_STATE ); -backgroundPageConnection.onDisconnect.addListener( () => { +port.onDisconnect.addListener( () => { clearInterval( intervalId ); clearState(); } ); -// Function to request object details from the bridge -function requestObjectDetails( uuid ) { +port.onMessage.addListener( ( message ) => { - backgroundPageConnection.postMessage( { - name: MESSAGE_REQUEST_OBJECT_DETAILS, - uuid: uuid, - tabId: chrome.devtools.inspectedWindow.tabId - } ); + if ( message.id !== MESSAGE_ID ) return; -} + const detail = message.detail; -// 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 - } ); - -} + switch ( message.name ) { + case EVENT_RENDERER: + detail._frameId = message.frameId; + state.renderers.set( detail.uuid, detail ); + updateRenderers(); + break; -// Store renderer collapse states -const rendererCollapsedState = new Map(); + case EVENT_OBJECT_DETAILS: + showFloatingDetails( detail ); + break; -// Store scene tree expanded states (uuid -> boolean). Defaults to expanded. -const treeExpandedState = new Map(); + case EVENT_SCENE: + processSceneBatch( detail.sceneUuid, detail.objects, message.frameId ); + updateSceneTree(); + break; -// 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; + case EVENT_SCENE_REMOVED: + removeScene( detail.uuid ); + updateSceneTree(); + break; -} + case EVENT_COMMITTED: + // A top-level navigation replaces every frame + if ( message.frameId === 0 ) clearState(); else removeFrame( message.frameId ); + updateRenderers(); + updateSceneTree(); + break; -// 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 ) { +// Replace the objects of a scene with a new batch from the bridge +function processSceneBatch( sceneUuid, objects, frameId ) { - // 1. Identify UUIDs in the new batch - const newObjectUuids = new Set( batchObjects.map( obj => obj.uuid ) ); + const uuids = new Set( objects.map( object => object.uuid ) ); - // 2. Identify current object UUIDs associated with this scene that are NOT renderers - const currentSceneObjectUuids = new Set(); - state.objects.forEach( ( obj, uuid ) => { + // Drop objects that are no longer in the scene + state.objects.forEach( ( object, uuid ) => { - // Use the _sceneUuid property we'll add below, or check if it's the scene root itself - if ( obj._sceneUuid === sceneUuid || uuid === sceneUuid ) { + if ( object._sceneUuid === sceneUuid && ! uuids.has( uuid ) ) { - currentSceneObjectUuids.add( uuid ); + state.objects.delete( uuid ); + openState.delete( 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 => { + for ( const object of objects ) { - if ( ! newObjectUuids.has( uuid ) ) { + object._sceneUuid = sceneUuid; + state.objects.set( object.uuid, object ); - 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 ); + const scene = state.objects.get( sceneUuid ); + scene._frameId = frameId; + state.scenes.set( sceneUuid, scene ); - } +} - } ); +// Drop a scene and all of its objects (the bridge has determined it was disposed) +function removeScene( sceneUuid ) { - // 5. Process the new batch: Add/Update objects and mark their scene association - batchObjects.forEach( objData => { + state.scenes.delete( sceneUuid ); - objData._sceneUuid = sceneUuid; - state.objects.set( objData.uuid, objData ); + state.objects.forEach( ( object, uuid ) => { - if ( objData.isScene && objData.uuid === sceneUuid ) { + if ( object._sceneUuid === sceneUuid ) { - state.scenes.set( objData.uuid, objData ); + state.objects.delete( uuid ); + openState.delete( uuid ); } } ); - sceneDirty = true; - } -// Drop a scene and all of its objects (the bridge has determined it was disposed) -function removeScene( sceneUuid ) { +// Drop the renderers and scenes of a sub-frame that navigated +function removeFrame( frameId ) { - state.scenes.delete( sceneUuid ); - - state.objects.forEach( ( obj, uuid ) => { + state.renderers.forEach( ( renderer, uuid ) => { - if ( uuid === sceneUuid || obj._sceneUuid === sceneUuid ) { + if ( renderer._frameId === frameId ) { - state.objects.delete( uuid ); - treeExpandedState.delete( uuid ); + state.renderers.delete( uuid ); + openState.delete( uuid ); } } ); - sceneDirty = true; + state.scenes.forEach( ( scene, uuid ) => { + + if ( scene._frameId === frameId ) removeScene( uuid ); + + } ); } -// Clear state when panel is reloaded +// Clear state when the page navigates or the connection drops function clearState() { - state.revision = null; state.scenes.clear(); state.renderers.clear(); state.objects.clear(); - treeExpandedState.clear(); - sceneDirty = true; - - // Hide floating panel - if ( floatingPanel ) { - - floatingPanel.classList.remove( 'visible' ); + openState.clear(); - } + floatingPanel.classList.remove( 'visible' ); } -// Listen for messages from the background page -backgroundPageConnection.onMessage.addListener( function ( message ) { - - if ( message.id === MESSAGE_ID ) { - - handleThreeEvent( message ); +// --- Rendering --- - } +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.isGroup ) return '📁'; + return '📦'; -function handleThreeEvent( message ) { +} - switch ( message.name ) { +// Sort order of children in the tree +function getObjectOrder( obj ) { - case EVENT_REGISTER: - state.revision = message.detail.revision; - break; + if ( obj.isCamera ) return 1; + if ( obj.isLight ) return 2; + if ( obj.isGroup ) return 3; + if ( obj.isMesh ) return 4; + return 5; - 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; +function createLabel( name, details ) { - case EVENT_SCENE: - const { sceneUuid, objects: batchObjects } = message.detail; - processSceneBatch( sceneUuid, batchObjects ); - updateSceneTree(); - break; + if ( details.length === 0 ) return name; - case EVENT_SCENE_REMOVED: - removeScene( message.detail.uuid ); - updateSceneTree(); - break; + return `${name} ${details.join( ' ・ ' )}`; - case EVENT_COMMITTED: - clearState(); - updateRenderers(); - updateSceneTree(); - break; +} - } +function createPropertyRow( label, value ) { -} + const row = document.createElement( 'div' ); + row.className = 'property-row'; -function renderRenderer( obj, container ) { + const labelSpan = document.createElement( 'span' ); + labelSpan.className = 'property-label'; + labelSpan.textContent = label; - // Create
element as the main container - const detailsElement = document.createElement( 'details' ); - detailsElement.className = 'renderer-container'; - detailsElement.setAttribute( 'data-uuid', obj.uuid ); + const valueSpan = document.createElement( 'span' ); + valueSpan.className = 'property-value'; + valueSpan.textContent = ( value === undefined || value === null ) + ? '–' + : ( typeof value === 'number' ? value.toLocaleString() : value ); - // Set initial state - detailsElement.open = rendererCollapsedState.get( obj.uuid ) || false; + row.appendChild( labelSpan ); + row.appendChild( valueSpan ); + return row; - // Add toggle listener to save state - detailsElement.addEventListener( 'toggle', () => { +} - rendererCollapsedState.set( obj.uuid, detailsElement.open ); +// A titled group of property rows +function createPropertyGroup( title, rows ) { - } ); + const fragment = document.createDocumentFragment(); - // 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 + const heading = document.createElement( 'h4' ); + heading.textContent = title; + fragment.appendChild( heading ); - // Update display name in the summary line - const props = obj.properties; - const details = [ `${props.width}x${props.height}` ]; - if ( props.info ) { + for ( const [ label, value ] of rows ) { - details.push( `${props.info.render.calls} draws` ); - details.push( `${props.info.render.triangles.toLocaleString()} triangles` ); + fragment.appendChild( createPropertyRow( label, value ) ); } - const displayName = `${obj.type} ${details.join( ' ・ ' )}`; + return fragment; - // Use toggle icon instead of paint icon - const scrollButton = obj.canvasInDOM ? - `` : - '󠀠🫥'; - summaryElem.innerHTML = ` - ${displayName} - ${obj.type} - ${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 +function createVectorRow( label, vector ) { - propsContainer.innerHTML = ''; // Clear placeholder + const row = document.createElement( 'div' ); + row.className = 'vector-row'; - if ( obj.properties ) { + // Pad label to ensure consistent alignment + const paddedLabel = label.padEnd( 16 ); + row.textContent = `${paddedLabel} ${vector.x.toFixed( 3 )}\t${vector.y.toFixed( 3 )}\t${vector.z.toFixed( 3 )}`; - const props = obj.properties; - const info = props.info || { render: {}, memory: {} }; // Default empty objects if info is missing + return row; - 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 ); +function renderRenderer( renderer, container ) { - } else { + const props = renderer.properties; + const info = props.info; - propsContainer.textContent = 'No properties available.'; + const node = document.createElement( 'details' ); + node.open = openState.get( renderer.uuid ) ?? false; + node.addEventListener( 'toggle', () => openState.set( renderer.uuid, node.open ) ); - } + const label = createLabel( renderer.type, [ + `${props.width}x${props.height}`, + `${info.render.calls} draws`, + `${info.render.triangles.toLocaleString()} triangles` + ] ); - detailsElement.appendChild( propsContainer ); + const scrollButton = renderer.canvasInDOM + ? '' + : '🫥'; - // Add click handler for scroll to canvas button - const scrollBtn = detailsElement.querySelector( '.scroll-to-canvas-btn' ); - if ( scrollBtn ) { + const summary = document.createElement( 'summary' ); + summary.className = 'tree-item'; + summary.innerHTML = `${label}${scrollButton}`; + node.appendChild( summary ); - scrollBtn.addEventListener( 'click', ( event ) => { + const button = summary.querySelector( '.scroll-to-canvas-btn' ); - event.preventDefault(); - event.stopPropagation(); - scrollToCanvas( obj.uuid ); + if ( button !== null ) { - } ); + button.addEventListener( 'click', () => send( MESSAGE_SCROLL_TO_CANVAS, { uuid: renderer.uuid } ) ); } - container.appendChild( detailsElement ); // Append details to the main container + const propertiesColumn = document.createElement( 'div' ); + propertiesColumn.appendChild( createPropertyGroup( 'Properties', [ + [ 'Size', `${props.width}x${props.height}` ], + [ 'Alpha', props.alpha ], + [ 'Antialias', props.antialias ], + [ 'Output Color Space', props.outputColorSpace ], + [ 'Tone Mapping', props.toneMapping ], + [ 'Tone Mapping Exposure', props.toneMappingExposure ], + [ 'Shadows', props.shadows ? 'enabled' : 'disabled' ], + [ 'Auto Clear', props.autoClear ], + [ 'Auto Clear Color', props.autoClearColor ], + [ 'Auto Clear Depth', props.autoClearDepth ], + [ 'Auto Clear Stencil', props.autoClearStencil ], + [ 'Local Clipping', props.localClipping ] + ] ) ); + + const statsColumn = document.createElement( 'div' ); + statsColumn.appendChild( createPropertyGroup( 'Render Stats', [ + [ 'Frame', info.render.frame ], + [ 'Draw Calls', info.render.calls ], + [ 'Triangles', info.render.triangles ], + [ 'Points', info.render.points ], + [ 'Lines', info.render.lines ] + ] ) ); + statsColumn.appendChild( createPropertyGroup( 'Memory', [ + [ 'Geometries', info.memory.geometries ], + [ 'Textures', info.memory.textures ], + [ 'Shader Programs', info.memory.programs ] + ] ) ); + + const properties = document.createElement( 'div' ); + properties.className = 'properties-list'; + properties.appendChild( propertiesColumn ); + properties.appendChild( statsColumn ); + node.appendChild( properties ); + + container.appendChild( node ); } -// Function to render an object and its children +// Render an object and its children function renderObject( obj, container, level = 0, parentInvisible = false ) { - const icon = getObjectIcon( obj ); - let displayName = obj.name || obj.type; - - // Collect renderable children (renderers do not show children in the tree) - const children = ( ! obj.isRenderer && obj.children ) - ? obj.children - .map( childId => state.objects.get( childId ) ) - .filter( child => child !== undefined && child.name !== '__THREE_DEVTOOLS_HIGHLIGHT__' ) - .sort( ( a, b ) => { - - const getTypeOrder = ( o ) => { - - if ( o.isCamera ) return 1; - if ( o.isLight ) return 2; - if ( o.isGroup ) return 3; - if ( o.isMesh ) return 4; - return 5; - - }; - - return getTypeOrder( a ) - getTypeOrder( b ); - - } ) - : []; + const children = obj.children + .map( uuid => state.objects.get( uuid ) ) + .filter( child => child !== undefined ) + .sort( ( a, b ) => getObjectOrder( a ) - getObjectOrder( b ) ); const hasChildren = children.length > 0; + const invisible = parentInvisible || obj.visible === false; - if ( obj.isScene ) { + let name = obj.name || obj.type; + const details = []; - // Add object count for scenes - let objectCount = - 1; - function countObjects( uuid ) { + if ( obj.isInstancedMesh ) name += ` [${obj.count}]`; + if ( obj.isMesh ) details.push( `${obj.geometryType} ${obj.materialType}` ); - const object = state.objects.get( uuid ); - if ( object && object.name !== '__THREE_DEVTOOLS_HIGHLIGHT__' ) { + if ( obj.isScene ) { - objectCount ++; // Increment count for the object itself - if ( object.children ) { + let objectCount = 0; + let lightCount = 0; - object.children.forEach( childId => countObjects( childId ) ); + state.objects.forEach( object => { - } + if ( object._sceneUuid !== obj.uuid || object.isScene ) return; - } + objectCount ++; + if ( object.isLight ) lightCount ++; - } + } ); - countObjects( obj.uuid ); - displayName = `${obj.name || obj.type} ${objectCount} objects`; + details.push( `${objectCount} objects` ); + if ( lightCount > 0 ) details.push( `${lightCount} lights` ); } - const togglePart = hasChildren + const toggle = hasChildren ? '' : ''; - const labelContent = `${togglePart}${icon} - ${displayName} + const item = document.createElement( hasChildren ? 'summary' : 'div' ); + item.className = 'tree-item'; + item.style.paddingLeft = `${level * 20}px`; + if ( invisible ) item.classList.add( 'invisible' ); + item.innerHTML = `${toggle}${getObjectIcon( obj )} + ${createLabel( name, details )} ${obj.type}`; - let header; // the element receiving hover/highlight handlers + item.addEventListener( 'mouseenter', () => { - if ( hasChildren ) { - - const node = document.createElement( 'details' ); - node.className = 'tree-node'; - node.setAttribute( 'data-uuid', obj.uuid ); + send( MESSAGE_REQUEST_OBJECT_DETAILS, { uuid: obj.uuid } ); - // Default to expanded unless the user has collapsed this node before - const stored = treeExpandedState.get( obj.uuid ); - node.open = stored === undefined ? true : stored; + // Only highlight if object and all parents are visible + if ( ! invisible ) send( MESSAGE_HIGHLIGHT_OBJECT, { uuid: obj.uuid } ); - node.addEventListener( 'toggle', () => { + } ); - treeExpandedState.set( obj.uuid, node.open ); + item.addEventListener( 'mouseleave', () => send( MESSAGE_UNHIGHLIGHT_OBJECT ) ); - } ); + if ( hasChildren ) { - const summary = document.createElement( 'summary' ); - summary.className = 'tree-item'; - summary.style.paddingLeft = `${level * 20}px`; + const node = document.createElement( 'details' ); - if ( obj.visible === false || parentInvisible ) { + // Default to expanded unless the user has collapsed this node before + node.open = openState.get( obj.uuid ) ?? true; + node.addEventListener( 'toggle', () => openState.set( obj.uuid, node.open ) ); - summary.style.opacity = '0.5'; + node.appendChild( item ); - } + for ( const child of children ) { - summary.innerHTML = labelContent; - node.appendChild( summary ); + renderObject( child, node, level + 1, invisible ); - const childContainer = document.createElement( 'div' ); - childContainer.className = 'children'; - node.appendChild( childContainer ); + } container.appendChild( node ); - children.forEach( child => { - - renderObject( child, childContainer, level + 1, parentInvisible || obj.visible === false ); - - } ); - - header = summary; - } else { - const elem = document.createElement( 'div' ); - elem.className = 'tree-item'; - elem.style.paddingLeft = `${level * 20}px`; - elem.setAttribute( 'data-uuid', obj.uuid ); - - if ( obj.visible === false || parentInvisible ) { - - elem.style.opacity = '0.5'; - - } - - elem.innerHTML = labelContent; - - container.appendChild( elem ); - - header = elem; + container.appendChild( item ); } - // Add mouseenter handler to request object details and highlight in 3D - header.addEventListener( 'mouseenter', () => { - - requestObjectDetails( obj.uuid ); - - // Only highlight if object and all parents are visible - if ( obj.visible !== false && ! parentInvisible ) { - - requestObjectHighlight( obj.uuid ); - - } - - } ); - - // Add mouseleave handler to remove 3D highlight - header.addEventListener( 'mouseleave', () => { - - requestObjectUnhighlight(); - - } ); - } // Build the static DOM shell (called once) 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'; + header.innerHTML = `+ + ${chrome.runtime.getManifest().version}`; + document.body.appendChild( header ); - 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'; - - header.appendChild( miscSpan ); - header.appendChild( manifestVersionSpan ); - container.appendChild( header ); - - const sectionsContainer = document.createElement( 'div' ); - sectionsContainer.className = 'sections-container'; - container.appendChild( sectionsContainer ); + const sections = document.createElement( 'div' ); + sections.className = 'sections-container'; + document.body.appendChild( sections ); renderersSection = document.createElement( 'div' ); renderersSection.className = 'section'; - renderersSection.style.display = 'none'; - sectionsContainer.appendChild( renderersSection ); + sections.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 = '

Renderers

'; - - 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 = '

Scenes

'; - - state.scenes.forEach( scene => { - - renderObject( scene, scenesSection ); - - } ); - - } else { - - scenesSection.style.display = 'none'; - - } - -} - -// Create floating details panel -function createFloatingPanel() { - - if ( floatingPanel ) return floatingPanel; + sections.appendChild( scenesSection ); floatingPanel = document.createElement( 'div' ); floatingPanel.className = 'floating-details'; document.body.appendChild( floatingPanel ); - return floatingPanel; - } -// Show floating details panel -function showFloatingDetails( objectData ) { +// Rebuild a section from a map of items (an empty section is hidden by CSS) +function renderSection( section, title, items, render ) { - const panel = createFloatingPanel(); + section.innerHTML = items.size > 0 ? `

${title}

` : ''; - // Clear previous content - panel.innerHTML = ''; + items.forEach( item => render( item, section ) ); - if ( objectData.position ) { +} - panel.appendChild( createVectorRow( 'Position', objectData.position ) ); +function updateRenderers() { - } + renderSection( renderersSection, 'Renderers', state.renderers, renderRenderer ); - if ( objectData.rotation ) { +} - panel.appendChild( createVectorRow( 'Rotation', objectData.rotation ) ); +function updateSceneTree() { - } + renderSection( scenesSection, 'Scenes', state.scenes, renderObject ); - if ( objectData.scale ) { +} - panel.appendChild( createVectorRow( 'Scale', objectData.scale ) ); +// --- Floating details panel --- - } +function showFloatingDetails( details ) { - // Position panel near mouse - updateFloatingPanelPosition(); + floatingPanel.innerHTML = ''; + floatingPanel.appendChild( createVectorRow( 'Position', details.position ) ); + floatingPanel.appendChild( createVectorRow( 'Rotation', details.rotation ) ); + floatingPanel.appendChild( createVectorRow( 'Scale', details.scale ) ); - // Show panel - panel.classList.add( 'visible' ); + floatingPanel.classList.add( 'visible' ); + updateFloatingPanelPosition(); } -// Update floating panel position function updateFloatingPanelPosition() { - if ( ! floatingPanel || ! floatingPanel.classList.contains( 'visible' ) ) return; + if ( ! floatingPanel.classList.contains( 'visible' ) ) return; const offset = 15; // Offset from cursor let x = mousePosition.x + offset; @@ -782,7 +509,7 @@ document.addEventListener( 'mousemove', ( event ) => { // Hide panel when mouse leaves the tree area document.addEventListener( 'mouseover', ( event ) => { - if ( floatingPanel && ! event.target.closest( '.tree-item' ) ) { + if ( ! event.target.closest( '.tree-item' ) ) { floatingPanel.classList.remove( 'visible' ); @@ -790,5 +517,4 @@ document.addEventListener( 'mouseover', ( event ) => { } ); -// Initial UI setup initUI(); From ae46171bedd9885f1019637fee6a86d2380eac01 Mon Sep 17 00:00:00 2001 From: Michael Herzog Date: Thu, 27 Aug 2026 12:09:32 +0200 Subject: [PATCH 2/2] Bindings: Fix stale storage buffer attributes. (#34388) --- src/renderers/common/Bindings.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/renderers/common/Bindings.js b/src/renderers/common/Bindings.js index 996d4be4903a5a..d2f7db1fb01bab 100644 --- a/src/renderers/common/Bindings.js +++ b/src/renderers/common/Bindings.js @@ -392,6 +392,8 @@ class Bindings extends DataMap { } + cacheKey += attribute.id + ','; + } if ( binding.isUniformBuffer ) {