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..e15d57e7dba1a2 --- /dev/null +++ b/devtools/background.js @@ -0,0 +1,194 @@ +/* global chrome */ + +// Constants +const MESSAGE_ID = 'three-devtools'; +const MESSAGE_INIT = 'init'; +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'; +const MESSAGE_REGISTER = 'register'; +const MESSAGE_COMMITTED = 'committed'; + +// Map tab IDs to connections +const connections = new Map(); + +// Handle extension icon clicks in the toolbar +chrome.action.onClicked.addListener( ( tab ) => { + + // Send scroll-to-canvas message to the content script (no UUID = scroll to first canvas) + chrome.tabs.sendMessage( tab.id, { + name: MESSAGE_SCROLL_TO_CANVAS, + tabId: tab.id + } ).catch( () => { + + // Ignore error - tab might not have the content script injected + console.log( 'Could not send scroll-to-canvas message to tab', tab.id ); + + } ); + +} ); + +// Listen for connections from the devtools panel +chrome.runtime.onConnect.addListener( port => { + + let tabId; + + // Messages that should be forwarded to content script + const forwardableMessages = new Set( [ + MESSAGE_REQUEST_STATE, + MESSAGE_REQUEST_OBJECT_DETAILS, + MESSAGE_SCROLL_TO_CANVAS, + MESSAGE_HIGHLIGHT_OBJECT, + MESSAGE_UNHIGHLIGHT_OBJECT + ] ); + + // Listen for messages from the devtools panel + port.onMessage.addListener( message => { + + if ( message.name === MESSAGE_INIT ) { + + tabId = message.tabId; + connections.set( tabId, port ); + + } else if ( forwardableMessages.has( message.name ) && tabId ) { + + chrome.tabs.sendMessage( tabId, message ); + + } else if ( tabId === undefined ) { + + console.warn( 'Background: Message received from panel before init:', message ); + + } + + } ); + + // Clean up when devtools is closed + port.onDisconnect.addListener( () => { + + if ( tabId ) { + + connections.delete( tabId ); + + } + + } ); + +} ); + +// Listen for messages from the content script +chrome.runtime.onMessage.addListener( ( message, sender, sendResponse ) => { + + if ( message.scheme ) { + + chrome.action.setIcon( { + path: { + 128: `icons/128-${message.scheme}.png` + } + } ); + + } + + if ( sender.tab ) { + + const tabId = sender.tab.id; + + // If three.js is detected, show a badge + if ( message.name === MESSAGE_REGISTER && message.detail && message.detail.revision ) { + + const revision = String( message.detail.revision ); + const number = revision.replace( /\D+$/, '' ); + const isDev = revision.includes( 'dev' ); + + chrome.action.setBadgeText( { tabId: tabId, text: number } ).catch( () => { + + // Ignore error - tab might have been closed + + } ); + chrome.action.setBadgeTextColor( { tabId: tabId, color: '#ffffff' } ).catch( () => { + + // Ignore error - tab might have been closed + + } ); + chrome.action.setBadgeBackgroundColor( { tabId: tabId, color: isDev ? '#ff0098' : '#049ef4' } ).catch( () => { + + // Ignore error - tab might have been closed + + } ); + + } + + const port = connections.get( tabId ); + if ( port ) { + + // Forward the message to the devtools panel + try { + + port.postMessage( message ); + // Send immediate response to avoid "message channel closed" error + sendResponse( { received: true } ); + + } catch ( e ) { + + console.error( 'Error posting message to devtools:', e ); + // If the port is broken, clean up the connection + connections.delete( tabId ); + + } + + } + + } + + return false; // Return false to indicate synchronous handling + +} ); + +// Listen for page navigation events +chrome.webNavigation.onCommitted.addListener( details => { + + const { tabId, frameId } = details; + + // Clear badge on navigation, only for top-level navigation + if ( frameId === 0 ) { + + chrome.action.setBadgeText( { tabId: tabId, text: '' } ).catch( () => { + + // Ignore error - tab might have been closed + + } ); + + } + + const port = connections.get( tabId ); + + if ( port ) { + + port.postMessage( { + id: MESSAGE_ID, + name: MESSAGE_COMMITTED, + frameId: frameId + } ); + + } + +} ); + +// Clear badge when a tab is closed +chrome.tabs.onRemoved.addListener( ( tabId ) => { + + chrome.action.setBadgeText( { tabId: tabId, text: '' } ).catch( () => { + + // Ignore error - tab is already gone + + } ); + + // Clean up connection if it exists for the closed tab + if ( connections.has( tabId ) ) { + + connections.delete( tabId ); + + } + +} ); diff --git a/devtools/bridge.js b/devtools/bridge.js new file mode 100644 index 00000000000000..ac87ef4fae1f91 --- /dev/null +++ b/devtools/bridge.js @@ -0,0 +1,658 @@ +/** + * This script injected by the installed three.js developer + * tools extension. + */ + +( function () { + + // Constants + const MESSAGE_ID = 'three-devtools'; + const EVENT_REGISTER = 'register'; + const EVENT_OBSERVE = 'observe'; + const EVENT_RENDERER = 'renderer'; + const EVENT_SCENE = 'scene'; + const EVENT_OBJECT_DETAILS = 'object-details'; + const EVENT_DEVTOOLS_READY = 'devtools-ready'; + 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'; + const HIGHLIGHT_OVERLAY_DURATION = 1000; + + // Only initialize if not already initialized + if ( ! window.__THREE_DEVTOOLS__ ) { + + // Create our custom EventTarget with logging + class DevToolsEventTarget extends EventTarget { + + constructor() { + + super(); + this._ready = false; + this._backlog = []; + this.objects = new Map(); + + } + + addEventListener( type, listener, options ) { + + super.addEventListener( type, listener, options ); + + // If this is the first listener for a type, and we have backlogged events, + // check if we should process them + if ( type !== EVENT_DEVTOOLS_READY && this._backlog.length > 0 ) { + + this.dispatchEvent( new CustomEvent( EVENT_DEVTOOLS_READY ) ); + + } + + } + + dispatchEvent( event ) { + + if ( this._ready || event.type === EVENT_DEVTOOLS_READY ) { + + if ( event.type === EVENT_DEVTOOLS_READY ) { + + this._ready = true; + const backlog = this._backlog; + this._backlog = []; + backlog.forEach( e => super.dispatchEvent( e ) ); + + } + + return super.dispatchEvent( event ); + + } else { + + this._backlog.push( event ); + return false; // Return false to indicate synchronous handling + + } + + } + + reset() { + + + // Clear objects map + this.objects.clear(); + + // Clear backlog + this._backlog = []; + + // Reset ready state + this._ready = false; + + // Clear observed arrays + observedScenes.length = 0; + observedRenderers.length = 0; + + } + + } + + // Create and expose the __THREE_DEVTOOLS__ object + const devTools = new DevToolsEventTarget(); + Object.defineProperty( window, '__THREE_DEVTOOLS__', { + value: devTools, + configurable: false, + enumerable: true, + writable: false + } ); + + // Declare arrays for tracking observed objects + const observedScenes = []; + const observedRenderers = []; + const sceneObjectCountCache = new Map(); // Cache for object counts per scene + + // Shared tree traversal function + function traverseObjectTree( rootObject, callback, skipDuplicates = false ) { + + const processedUUIDs = skipDuplicates ? new Set() : null; + + function traverse( object ) { + + if ( ! object || ! object.uuid ) return; + + // Skip DevTools highlight objects + if ( object.name === '__THREE_DEVTOOLS_HIGHLIGHT__' ) return; + + // Skip if already processed (when duplicate prevention is enabled) + if ( processedUUIDs && processedUUIDs.has( object.uuid ) ) return; + if ( processedUUIDs ) processedUUIDs.add( object.uuid ); + + // Execute callback for this object + callback( object ); + + // Process children recursively + if ( object.children && Array.isArray( object.children ) ) { + + object.children.forEach( child => traverse( child ) ); + + } + + } + + traverse( rootObject ); + + } + + // Function to get renderer data + function getRendererData( renderer ) { + + try { + + const data = { + uuid: renderer.uuid || generateUUID(), + type: renderer.isWebGLRenderer ? 'WebGLRenderer' : 'WebGPURenderer', + name: '', + properties: getRendererProperties( renderer ), + canvasInDOM: renderer.domElement && document.contains( renderer.domElement ) + }; + return data; + + } catch ( error ) { + + console.warn( 'DevTools: Error getting renderer data:', error ); + return null; + + } + + } + + // Function to get object hierarchy + function getObjectData( obj ) { + + try { + + // Special case for WebGLRenderer + if ( obj.isWebGLRenderer === true || obj.isWebGPURenderer === true ) { + + return getRendererData( obj ); + + } + + // Special case for InstancedMesh + const type = obj.isInstancedMesh ? 'InstancedMesh' : obj.type || obj.constructor.name; + + // Get descriptive name for the object + let name = obj.name || type || obj.constructor.name; + if ( obj.isMesh ) { + + const geoType = obj.geometry ? obj.geometry.type : 'Unknown'; + const matType = obj.material ? + ( Array.isArray( obj.material ) ? + obj.material.map( m => m.type ).join( ', ' ) : + obj.material.type ) : + 'Unknown'; + if ( obj.isInstancedMesh ) { + + name = `${name} [${obj.count}]`; + + } + + name = `${name} ${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( EVENT_REGISTER, ( event ) => { + + dispatchEvent( EVENT_REGISTER, event.detail ); + + } ); + + // Listen for object observations + devTools.addEventListener( EVENT_OBSERVE, ( event ) => { + + const obj = event.detail; + if ( ! obj ) { + + console.warn( 'DevTools: Received observe event with null/undefined detail' ); + return; + + } + + // Generate UUID if needed + if ( ! obj.uuid ) { + + obj.uuid = generateUUID(); + + } + + // Skip if already registered (essential to prevent loops with batching) + if ( devTools.objects.has( obj.uuid ) ) { + + return; + + } + + if ( obj.isWebGLRenderer || obj.isWebGPURenderer ) { + + const data = getObjectData( obj ); + + if ( data ) { + + data.properties = getRendererProperties( obj ); + observedRenderers.push( obj ); + devTools.objects.set( obj.uuid, data ); + + // Intercept render method to track last camera + if ( ! obj.__devtools_render_wrapped ) { + + const originalRender = obj.render; + obj.render = function ( scene, camera ) { + + devTools.lastCamera = camera; + return originalRender.call( this, scene, camera ); + + }; + + obj.__devtools_render_wrapped = true; + + } + + dispatchEvent( EVENT_RENDERER, data ); + + } + + } else if ( obj.isScene ) { + + observedScenes.push( obj ); + + const batchObjects = []; + + traverseObjectTree( obj, ( currentObj ) => { + + const objectData = getObjectData( currentObj ); + if ( objectData ) { + + batchObjects.push( objectData ); + devTools.objects.set( currentObj.uuid, objectData ); // Update local cache during batch creation + + } + + }, true ); + + dispatchEvent( EVENT_SCENE, { sceneUuid: obj.uuid, objects: batchObjects } ); + + } + + } ); + + // Function to get renderer properties + function getRendererProperties( renderer ) { + + const parameters = renderer.getContextAttributes ? renderer.getContextAttributes() : {}; + + return { + width: renderer.domElement ? renderer.domElement.clientWidth : 0, + height: renderer.domElement ? renderer.domElement.clientHeight : 0, + alpha: parameters.alpha || false, + antialias: parameters.antialias || false, + outputColorSpace: renderer.outputColorSpace, + toneMapping: renderer.toneMapping, + toneMappingExposure: renderer.toneMappingExposure !== undefined ? renderer.toneMappingExposure : 1, + shadows: renderer.shadowMap ? renderer.shadowMap.enabled : false, + autoClear: renderer.autoClear, + autoClearColor: renderer.autoClearColor, + autoClearDepth: renderer.autoClearDepth, + autoClearStencil: renderer.autoClearStencil, + localClipping: renderer.localClippingEnabled, + physicallyCorrectLights: renderer.physicallyCorrectLights || false, // Assuming false is default if undefined + info: { + render: { + frame: renderer.info.render.frame, + calls: renderer.info.render.calls, + triangles: renderer.info.render.triangles, + points: renderer.info.render.points, + lines: renderer.info.render.lines, + geometries: renderer.info.render.geometries, + sprites: renderer.info.render.sprites + }, + memory: { + geometries: renderer.info.memory.geometries, + textures: renderer.info.memory.textures, + programs: renderer.info.programs ? renderer.info.programs.length : 0, + renderLists: renderer.info.memory.renderLists, + renderTargets: renderer.info.memory.renderTargets + } + } + }; + + } + + + // Function to check if bridge is available + function checkBridgeAvailability() { + + const devToolsValue = window.__THREE_DEVTOOLS__; + + // If we have devtools and we're interactive or complete, trigger ready + if ( devToolsValue && ( document.readyState === 'interactive' || document.readyState === 'complete' ) ) { + + devTools.dispatchEvent( new CustomEvent( EVENT_DEVTOOLS_READY ) ); + + } + + } + + // Watch for readyState changes + document.addEventListener( 'readystatechange', () => { + + if ( document.readyState === 'loading' ) { + + devTools.reset(); + + } + + checkBridgeAvailability(); + + } ); + + // Check if THREE is in the global scope (Old versions) + window.addEventListener( 'load', () => { + + if ( window.THREE && window.THREE.REVISION ) { + + dispatchEvent( EVENT_REGISTER, { revision: window.THREE.REVISION } ); + + } + + } ); + + // Watch for page unload to reset state + window.addEventListener( 'beforeunload', () => { + + devTools.reset(); + + } ); + + // Listen for messages from the content script + window.addEventListener( 'message', function ( event ) { + + // Only accept messages from the same frame + if ( event.source !== window ) return; + + const message = event.data; + if ( ! message || message.id !== MESSAGE_ID ) return; + + // Handle request for initial state from panel + if ( message.name === MESSAGE_REQUEST_STATE ) { + + sendState(); + + } else if ( message.name === MESSAGE_REQUEST_OBJECT_DETAILS ) { + + sendObjectDetails( message.uuid ); + + } else if ( message.name === MESSAGE_SCROLL_TO_CANVAS ) { + + scrollToCanvas( message.uuid ); + + } else if ( message.name === MESSAGE_HIGHLIGHT_OBJECT ) { + + devTools.dispatchEvent( new CustomEvent( 'highlight-object', { detail: { uuid: message.uuid } } ) ); + + } else if ( message.name === MESSAGE_UNHIGHLIGHT_OBJECT ) { + + devTools.dispatchEvent( new CustomEvent( 'unhighlight-object' ) ); + + } + + } ); + + function sendState() { + + // Send current renderers + for ( const observedRenderer of observedRenderers ) { + + const data = getObjectData( observedRenderer ); + if ( data ) { + + data.properties = getRendererProperties( observedRenderer ); + dispatchEvent( EVENT_RENDERER, data ); + + } + + } + + // Send current scenes + for ( const observedScene of observedScenes ) { + + reloadSceneObjects( observedScene ); + + } + + } + + function findObjectInScenes( uuid ) { + + for ( const scene of observedScenes ) { + + // Check if we're looking for the scene itself + if ( scene.uuid === uuid ) return scene; + + const found = scene.getObjectByProperty( 'uuid', uuid ); + if ( found ) return found; + + } + + return null; + + } + + // Expose utilities for highlight.js in a clean namespace + devTools.utils = { + findObjectInScenes, + generateUUID + }; + + // Expose renderers array for highlight.js + devTools.renderers = observedRenderers; + + function createHighlightOverlay( targetElement ) { + + const overlay = document.createElement( 'div' ); + overlay.style.cssText = ` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 122, 204, 0.3); + pointer-events: none; + z-index: 999999; + `; + + // Position the overlay relative to the target + const parent = targetElement.parentElement || document.body; + + if ( getComputedStyle( parent ).position === 'static' ) { + + parent.style.position = 'relative'; + + } + + parent.appendChild( overlay ); + + // Auto-remove after duration + setTimeout( () => { + + if ( overlay.parentElement ) { + + overlay.parentElement.removeChild( overlay ); + + } + + }, HIGHLIGHT_OVERLAY_DURATION ); + + } + + function sendObjectDetails( uuid ) { + + const object = findObjectInScenes( uuid ); + + if ( object ) { + + const details = { + uuid: object.uuid, + type: object.type, + name: object.name, + position: { + x: object.position.x, + y: object.position.y, + z: object.position.z + }, + rotation: { + x: object.rotation.x, + y: object.rotation.y, + z: object.rotation.z + }, + scale: { + x: object.scale.x, + y: object.scale.y, + z: object.scale.z + } + }; + + dispatchEvent( EVENT_OBJECT_DETAILS, details ); + + } + + } + + function scrollToCanvas( uuid ) { + + let renderer = null; + + if ( uuid ) { + + // Find the renderer with the given UUID + renderer = observedRenderers.find( r => r.uuid === uuid ); + + } else { + + // If no UUID provided, find the first available renderer whose canvas is in the DOM + renderer = observedRenderers.find( r => r.domElement && document.body.contains( r.domElement ) ); + + } + + if ( renderer ) { + + // Scroll the canvas element into view + renderer.domElement.scrollIntoView( { + behavior: 'smooth', + block: 'center', + inline: 'center' + } ); + + // Add a brief blue overlay flash effect + createHighlightOverlay( renderer.domElement ); + + } + + } + + function dispatchEvent( name, detail ) { + + try { + + window.postMessage( { + id: MESSAGE_ID, + name: name, + detail: detail + }, '*' ); + + } catch ( error ) { + + // If we get an "Extension context invalidated" error, stop all monitoring + if ( error.message.includes( 'Extension context invalidated' ) ) { + + console.log( 'DevTools: Extension context invalidated, stopping monitoring' ); + devTools.reset(); + return; + + } + + console.warn( 'DevTools: Error dispatching event:', error ); + + } + + } + + // Function to manually reload scene objects + function reloadSceneObjects( scene ) { + + const batchObjects = []; + + traverseObjectTree( scene, ( object ) => { + + const objectData = getObjectData( object ); + if ( objectData ) { + + batchObjects.push( objectData ); // Add to batch + // Update or add to local cache immediately + devTools.objects.set( object.uuid, objectData ); + + } + + } ); + + // --- Caching Logic --- + const currentObjectCount = batchObjects.length; + const previousObjectCount = sceneObjectCountCache.get( scene.uuid ); + + if ( currentObjectCount !== previousObjectCount ) { + + // Dispatch the batch update for the panel + dispatchEvent( EVENT_SCENE, { sceneUuid: scene.uuid, objects: batchObjects } ); + // Update the cache + sceneObjectCountCache.set( scene.uuid, currentObjectCount ); + + } + + } + + } + +} )(); diff --git a/devtools/content-script.js b/devtools/content-script.js new file mode 100644 index 00000000000000..fdff67e1666fdf --- /dev/null +++ b/devtools/content-script.js @@ -0,0 +1,163 @@ +/* global chrome */ + +// This script runs in the context of the web page + +// Constants +const MESSAGE_ID = 'three-devtools'; +const MESSAGE_REQUEST_STATE = 'request-state'; +const MESSAGE_REQUEST_OBJECT_DETAILS = 'request-object-details'; +const MESSAGE_SCROLL_TO_CANVAS = 'scroll-to-canvas'; +const MESSAGE_HIGHLIGHT_OBJECT = 'highlight-object'; +const MESSAGE_UNHIGHLIGHT_OBJECT = 'unhighlight-object'; + +// Inject the bridge script into the main document or a target (e.g., iframe) +function injectBridge( target = document ) { + + const bridgeScript = document.createElement( 'script' ); + bridgeScript.src = chrome.runtime.getURL( 'bridge.js' ); + bridgeScript.onload = function () { + + this.remove(); + + // Inject highlight.js after bridge.js loads + const highlightScript = document.createElement( 'script' ); + highlightScript.src = chrome.runtime.getURL( 'highlight.js' ); + highlightScript.onload = function () { + + this.remove(); + + }; + + ( target.head || target.documentElement ).appendChild( highlightScript ); + + }; + + ( target.head || target.documentElement ).appendChild( bridgeScript ); + return bridgeScript; + +} + +// 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 when accessing iframe content + + } + + } ); + +} + +// 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 when accessing iframe content + + } + + } ); + + } + + } ); + + } ); + +} ).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 !== MESSAGE_ID ) return; + + // Determine source: 'main' for window, 'iframe' otherwise + const source = event.source === window ? 'main' : 'iframe'; + + if ( ! isExtensionContextValid() ) { + + console.warn( 'Extension context invalidated, cannot send message' ); + return; + + } + + event.data.source = source; + chrome.runtime.sendMessage( event.data ); + +} + +// Listener for messages from the background script (originating from panel) +function handleBackgroundMessage( message ) { + + const forwardableMessages = new Set( [ + MESSAGE_REQUEST_STATE, + MESSAGE_REQUEST_OBJECT_DETAILS, + MESSAGE_SCROLL_TO_CANVAS, + MESSAGE_HIGHLIGHT_OBJECT, + MESSAGE_UNHIGHLIGHT_OBJECT + ] ); + + if ( forwardableMessages.has( message.name ) ) { + + message.id = MESSAGE_ID; + window.postMessage( message, '*' ); + + } + +} + +// Add event listeners +window.addEventListener( 'message', handleWindowMessage, false ); +chrome.runtime.onMessage.addListener( handleBackgroundMessage ); + +// Icon color scheme +const isLightTheme = window.matchMedia( '(prefers-color-scheme: light)' ).matches; +chrome.runtime.sendMessage( { scheme: isLightTheme ? 'light' : 'dark' } ); +window.matchMedia( '(prefers-color-scheme: light)' ).onchange = event => { + + chrome.runtime.sendMessage( { scheme: event.matches ? 'light' : 'dark' } ); + +}; + diff --git a/devtools/devtools.js b/devtools/devtools.js new file mode 100644 index 00000000000000..30df66741ea65d --- /dev/null +++ b/devtools/devtools.js @@ -0,0 +1,13 @@ +try { + + chrome.devtools.panels.create( + 'Three.js', + null, + 'panel/panel.html' + ); + +} catch ( error ) { + + console.error( 'Failed to create Three.js panel:', error ); + +} diff --git a/devtools/highlight.js b/devtools/highlight.js new file mode 100644 index 00000000000000..7e361c439c4b3c --- /dev/null +++ b/devtools/highlight.js @@ -0,0 +1,224 @@ +/* global __THREE_DEVTOOLS__ */ + +// This script handles highlighting of Three.js objects in the 3D scene + +( function () { + + 'use strict'; + + let highlightObject = null; + + function cloneMaterial( material ) { + + // Handle ShaderMaterial and RawShaderMaterial with custom yellow shaders + if ( material.isShaderMaterial || material.isRawShaderMaterial ) { + + const vertexShader = material.isRawShaderMaterial ? + ` + attribute vec3 position; + uniform mat4 modelViewMatrix; + uniform mat4 projectionMatrix; + void main() { + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + } + ` : + ` + void main() { + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + } + `; + + const fragmentShader = material.isRawShaderMaterial ? + ` + precision highp float; + void main() { + gl_FragColor = vec4( 1.0, 1.0, 0.0, 1.0 ); + } + ` : + ` + void main() { + gl_FragColor = vec4( 1.0, 1.0, 0.0, 1.0 ); + } + `; + + // Create new shader material with solid yellow color + const cloned = new material.constructor( { + vertexShader: vertexShader, + fragmentShader: fragmentShader, + wireframe: true, + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 1 + } ); + + return cloned; + + } + + // Clone the material + const cloned = material.clone(); + + // 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; + + } + + // Disable vertex colors + cloned.vertexColors = false; + + // Set to front side only (0 = FrontSide) if material supports it + if ( 'side' in cloned ) { + + cloned.side = 0; + + } + + // Clear all texture maps + if ( 'map' in cloned ) cloned.map = null; + if ( 'lightMap' in cloned ) cloned.lightMap = null; + if ( 'aoMap' in cloned ) cloned.aoMap = null; + if ( 'emissiveMap' in cloned ) cloned.emissiveMap = null; + if ( 'bumpMap' in cloned ) cloned.bumpMap = null; + if ( 'normalMap' in cloned ) cloned.normalMap = null; + if ( 'displacementMap' in cloned ) cloned.displacementMap = null; + if ( 'roughnessMap' in cloned ) cloned.roughnessMap = null; + if ( 'metalnessMap' in cloned ) cloned.metalnessMap = null; + if ( 'alphaMap' in cloned ) cloned.alphaMap = null; + if ( 'envMap' in cloned ) cloned.envMap = null; + + // Disable clipping + if ( cloned.clippingPlanes ) { + + cloned.clippingPlanes = []; + + } + + // Render on top, ignoring depth + cloned.depthTest = false; + cloned.depthWrite = false; + cloned.transparent = true; + cloned.opacity = 1; + + // Disable tone mapping and fog + cloned.toneMapped = false; + cloned.fog = false; + + return cloned; + + } + + function highlight( uuid ) { + + const object = __THREE_DEVTOOLS__.utils.findObjectInScenes( uuid ); + if ( ! object ) { + + // Object not in scene (e.g., renderer) - hide highlight + if ( highlightObject ) highlightObject.visible = false; + return; + + } + + // Skip helpers, existing highlights, and objects without geometry + if ( object.type.includes( 'Helper' ) || object.name === '__THREE_DEVTOOLS_HIGHLIGHT__' || ! object.geometry ) { + + if ( highlightObject ) highlightObject.visible = false; + return; + + } + + // Remove old highlight if it exists + if ( highlightObject && highlightObject.parent ) { + + highlightObject.parent.remove( highlightObject ); + + } + + // Clone the object to preserve all properties (skeleton, bindMatrix, etc) + highlightObject = object.clone(); + highlightObject.name = '__THREE_DEVTOOLS_HIGHLIGHT__'; + + // Apply yellow wireframe material + if ( highlightObject.material ) { + + if ( Array.isArray( highlightObject.material ) ) { + + highlightObject.material = highlightObject.material.map( cloneMaterial ); + + } else { + + highlightObject.material = cloneMaterial( highlightObject.material ); + + } + + } + + // Disable shadows + highlightObject.castShadow = false; + highlightObject.receiveShadow = false; + + // Render on top of everything + highlightObject.renderOrder = Infinity; + + // Disable auto update before adding to scene + highlightObject.matrixAutoUpdate = false; + highlightObject.matrixWorldAutoUpdate = false; + + // Find the scene and add at root + let scene = object; + while ( scene.parent ) scene = scene.parent; + + scene.add( highlightObject ); + + // Reuse the matrixWorld from original object (after adding to scene) + highlightObject.matrixWorld = object.matrixWorld; + + // Make sure it's visible + highlightObject.visible = true; + + } + + function unhighlight() { + + if ( highlightObject ) { + + highlightObject.visible = false; + + } + + } + + // Listen for highlight events from bridge.js + __THREE_DEVTOOLS__.addEventListener( 'highlight-object', ( event ) => { + + highlight( event.detail.uuid ); + + } ); + + __THREE_DEVTOOLS__.addEventListener( 'unhighlight-object', () => { + + unhighlight(); + + } ); + +} )(); 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..96d009ea75ebde --- /dev/null +++ b/devtools/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 3, + "name": "Three.js DevTools", + "version": "1.12", + "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": ["