diff --git a/devtools/README.md b/devtools/README.md new file mode 100644 index 00000000000000..47d93959fc4ebe --- /dev/null +++ b/devtools/README.md @@ -0,0 +1,98 @@ +# Three.js DevTools Extension + +This Chrome DevTools extension provides debugging capabilities for Three.js applications. It allows you to inspect scenes, objects, materials, and renderers. + +## Installation + +1. **Development Mode**: + - Open Chrome and navigate to `chrome://extensions/` + - Enable "Developer mode" (toggle in the top-right corner) + - Click "Load unpacked" and select the `devtools` directory + - The extension will now be available in Chrome DevTools when inspecting pages that use Three.js + +2. **Usage**: + - Open Chrome DevTools on a page using Three.js (F12 or Right-click > Inspect) + - Click on the "Three.js" tab in DevTools + - The panel will automatically detect and display Three.js scenes and renderers found on the page. + +## Code Flow Overview + +### Extension Architecture + +The extension follows a standard Chrome DevTools extension architecture: + +1. **Background Script** (`background.js`): Manages the extension lifecycle and communication ports between the panel and content script. +2. **DevTools Script** (`devtools.js`): Creates the panel when the DevTools window opens. +3. **Panel UI** (`panel/panel.html`, `panel/panel.js`, `panel/panel.css`): The DevTools panel interface that displays the data. +4. **Content Script** (`content-script.js`): Injected into the web page. Relays messages between the background script and the bridge script. +5. **Bridge Script** (`bridge.js`): Injected into the page's context by the content script. Directly interacts with the Three.js instance, detects objects, gathers data, and communicates back via the content script. + +### Initialization Flow + +1. When a page loads, `content-script.js` injects `bridge.js` into the page. +2. `bridge.js` creates the `window.__THREE_DEVTOOLS__` global object. +3. When the DevTools panel is opened, `panel.js` connects to `background.js` (`init`) and immediately requests the current state (`request-state`). +4. `background.js` relays the state request to `content-script.js`, which posts it to `bridge.js`. +5. `bridge.js` responds by sending back observed renderer data (`renderer` message) and batched scene data (`scene` message). +6. Three.js detects `window.__THREE_DEVTOOLS__` and sends registration/observation events to the bridge script as objects are created or the library initializes. + +### Bridge Operation (`bridge.js`) + +The bridge acts as the communication layer between the Three.js instance on the page and the DevTools panel: + +1. **Event Management**: Creates a custom event target (`DevToolsEventTarget`) to manage communication readiness and backlog events before the panel connects. +2. **Object Tracking**: + - `getObjectData()`: Extracts essential data (UUID, type, name, parent, children, etc.) from Three.js objects. + - Maintains a local map (`devTools.objects`) of all observed objects. + +3. **Initial Observation & Batching**: + - When Three.js sends an `observe` event (via `window.__THREE_DEVTOOLS__.dispatchEvent`): + - If it's a renderer, its data is collected and sent immediately via a `'renderer'` message. + - If it's a scene, the bridge traverses the entire scene graph, collects data for the scene and all descendants, stores them locally, and sends them to the panel in a single `'scene'` batch message. + +4. **State Request Handling**: + - When the panel sends `request-state` (on load/reload), the bridge iterates its known objects and sends back the current renderer data (`'renderer'`) and scene data (`'scene'` batch). + +5. **Message Handling**: + - Listens for messages from the panel (relayed via content script) like `request-state`. + +### Panel Interface (`panel/`) + +The panel UI provides the visual representation of the Three.js objects: + +1. **Tree View**: Displays hierarchical representation of scenes and objects. +2. **Renderer Details**: Shows properties and statistics for renderers in a collapsible section. + +## Key Features + +- **Scene Hierarchy Visualization**: Browse the complete scene graph. +- **Object Inspection**: View basic object properties (type, name). +- **Renderer Details**: View properties, render stats, and memory usage for `WebGLRenderer` instances. + +## Communication Flow + +1. **Panel ↔ Background ↔ Content Script**: Standard extension messaging for panel initialization and state requests (`init`, `request-state`). +2. **Three.js → Bridge**: Three.js detects `window.__THREE_DEVTOOLS__` and uses its `dispatchEvent` method (sending `'register'`, `'observe'`). +3. **Bridge → Content Script**: Bridge uses `window.postMessage` to send data (`'register'`, `'renderer'`, `'scene'`, `'update'`) to the content script. +4. **Content Script → Background**: Content script uses `chrome.runtime.sendMessage` to relay messages from the bridge to the background. +5. **Background → Panel**: Background script uses the established port connection (`port.postMessage`) to send data to the panel. + +## Key Components + +- **DevToolsEventTarget**: Custom event system with backlogging for async loading. +- **Object Observation & Batching**: Efficiently tracks and sends scene graph data. +- **Renderer Property Display**: Shows detailed statistics for renderers. + +## Integration with Three.js + +The extension relies on Three.js having built-in support for DevTools. When Three.js detects the presence of `window.__THREE_DEVTOOLS__`, it interacts with it, primarily by dispatching events. + +The bridge script listens for these events, organizes the data, and provides it to the DevTools panel. + +## Development + +To modify the extension: + +1. Edit the relevant files in the `devtools` directory. +2. Go to `chrome://extensions/`, find the unpacked extension, and click the reload icon. +3. Close and reopen DevTools on the inspected page to see your changes. \ No newline at end of file diff --git a/devtools/background.js b/devtools/background.js new file mode 100644 index 00000000000000..6028a0264cc487 --- /dev/null +++ b/devtools/background.js @@ -0,0 +1,138 @@ +/* global chrome */ + +// Map tab IDs to connections +const connections = new Map(); + +// Listen for connections from the devtools panel +chrome.runtime.onConnect.addListener( port => { + + let tabId; + + // Listen for messages from the devtools panel + port.onMessage.addListener( message => { + + if ( message.name === 'init' ) { + + tabId = message.tabId; + connections.set( tabId, port ); + + } else if ( message.name === 'request-state' && tabId ) { + + chrome.tabs.sendMessage( tabId, message ); + + } else if ( tabId === undefined ) { + + console.warn( 'Background: Message received from panel before init:', message ); + + } + + } ); + + // Clean up when devtools is closed + port.onDisconnect.addListener( () => { + + if ( tabId ) { + + connections.delete( tabId ); + + } + + } ); + +} ); + +// Listen for messages from the content script +chrome.runtime.onMessage.addListener( ( message, sender, sendResponse ) => { + + if ( message.scheme ) { + + chrome.action.setIcon( { + path: { + 128: `icons/128-${message.scheme}.png` + } + } ); + + } + + if ( sender.tab ) { + + const tabId = sender.tab.id; + + // If three.js is detected, show a badge + if ( message.name === 'register' && message.detail && message.detail.revision ) { + + const revision = String( message.detail.revision ); + const number = revision.replace( /\D+$/, '' ); + const isDev = revision.includes( 'dev' ); + + chrome.action.setBadgeText( { tabId: tabId, text: number } ).catch( () => { /* Tab might be gone */ } ); + chrome.action.setBadgeTextColor( { tabId: tabId, color: '#ffffff' } ).catch( () => { /* Tab might be gone */ } ); + chrome.action.setBadgeBackgroundColor( { tabId: tabId, color: isDev ? '#ff0098' : '#049ef4' } ).catch( () => { /* Tab might be gone */ } ); + + } + + const port = connections.get( tabId ); + if ( port ) { + + // Forward the message to the devtools panel + try { + + port.postMessage( message ); + // Send immediate response to avoid "message channel closed" error + sendResponse( { received: true } ); + + } catch ( e ) { + + console.error( 'Error posting message to devtools:', e ); + // If the port is broken, clean up the connection + connections.delete( tabId ); + + } + + } + + } + + return false; // Return false to indicate synchronous handling + +} ); + +// Listen for page navigation events +chrome.webNavigation.onCommitted.addListener( details => { + + const { tabId, frameId } = details; + + // Clear badge on navigation, only for top-level navigation + if ( frameId === 0 ) { + + chrome.action.setBadgeText( { tabId: tabId, text: '' } ).catch( () => { /* Tab might be gone */ } ); + + } + + const port = connections.get( tabId ); + + if ( port ) { + + port.postMessage( { + id: 'three-devtools', + name: 'committed', + frameId: frameId + } ); + + } + +} ); + +// Clear badge when a tab is closed +chrome.tabs.onRemoved.addListener( ( tabId ) => { + + chrome.action.setBadgeText( { tabId: tabId, text: '' } ).catch( () => { /* Tab might be gone */ } ); + + // Clean up connection if it exists for the closed tab + if ( connections.has( tabId ) ) { + + connections.delete( tabId ); + + } + +} ); diff --git a/devtools/bridge.js b/devtools/bridge.js new file mode 100644 index 00000000000000..7a756d9625dd8d --- /dev/null +++ b/devtools/bridge.js @@ -0,0 +1,489 @@ +/** + * This script injected by the installed three.js developer + * tools extension. + */ + +( function () { + + // Only initialize if not already initialized + if ( ! window.__THREE_DEVTOOLS__ ) { + + // Create our custom EventTarget with logging + class DevToolsEventTarget extends EventTarget { + + constructor() { + + super(); + this._ready = false; + this._backlog = []; + this.objects = new Map(); + + } + + addEventListener( type, listener, options ) { + + super.addEventListener( type, listener, options ); + + // If this is the first listener for a type, and we have backlogged events, + // check if we should process them + if ( type !== 'devtools-ready' && this._backlog.length > 0 ) { + + this.dispatchEvent( new CustomEvent( 'devtools-ready' ) ); + + } + + } + + dispatchEvent( event ) { + + if ( this._ready || event.type === 'devtools-ready' ) { + + if ( event.type === 'devtools-ready' ) { + + this._ready = true; + const backlog = this._backlog; + this._backlog = []; + backlog.forEach( e => super.dispatchEvent( e ) ); + + } + + return super.dispatchEvent( event ); + + } else { + + this._backlog.push( event ); + return false; // Return false to indicate synchronous handling + + } + + } + + reset() { + + // console.log('DevTools: Resetting state'); + + // Clear objects map + this.objects.clear(); + + // Clear backlog + this._backlog = []; + + // Reset ready state + this._ready = false; + + // Clear observed arrays + observedScenes.length = 0; + observedRenderers.length = 0; + + } + + } + + // Create and expose the __THREE_DEVTOOLS__ object + const devTools = new DevToolsEventTarget(); + Object.defineProperty( window, '__THREE_DEVTOOLS__', { + value: devTools, + configurable: false, + enumerable: true, + writable: false + } ); + + // Declare arrays for tracking observed objects + const observedScenes = []; + const observedRenderers = []; + const sceneObjectCountCache = new Map(); // Cache for object counts per scene + + // Function to get renderer data + function getRendererData( renderer ) { + + try { + + const data = { + uuid: renderer.uuid || generateUUID(), + type: renderer.isWebGLRenderer ? 'WebGLRenderer' : 'WebGPURenderer', + name: '', + properties: getRendererProperties( renderer ) + }; + return data; + + } catch ( error ) { + + console.warn( 'DevTools: Error getting renderer data:', error ); + return null; + + } + + } + + // Function to get object hierarchy + function getObjectData( obj ) { + + try { + + // Special case for WebGLRenderer + if ( obj.isWebGLRenderer === true || obj.isWebGPURenderer === true ) { + + return getRendererData( obj ); + + } + + // Special case for InstancedMesh + const type = obj.isInstancedMesh ? 'InstancedMesh' : obj.type || obj.constructor.name; + + // Get descriptive name for the object + let name = obj.name || type || obj.constructor.name; + if ( obj.isMesh ) { + + const geoType = obj.geometry ? obj.geometry.type : 'Unknown'; + const matType = obj.material ? + ( Array.isArray( obj.material ) ? + obj.material.map( m => m.type ).join( ', ' ) : + obj.material.type ) : + 'Unknown'; + if ( obj.isInstancedMesh ) { + + name = `${name} [${obj.count}]`; + + } + + name = `${name} ${geoType} ${matType}`; + + } + + const data = { + uuid: obj.uuid, + name: name, + type: type, + visible: obj.visible !== undefined ? obj.visible : true, + isScene: obj.isScene === true, + isObject3D: obj.isObject3D === true, + isCamera: obj.isCamera === true, + isLight: obj.isLight === true, + isMesh: obj.isMesh === true, + isInstancedMesh: obj.isInstancedMesh === true, + parent: obj.parent ? obj.parent.uuid : null, + children: obj.children ? obj.children.map( child => child.uuid ) : [] + }; + + return data; + + } catch ( error ) { + + console.warn( 'DevTools: Error getting object data:', error ); + return null; + + } + + } + + // Generate a UUID for objects that don't have one + function generateUUID() { + + const array = new Uint8Array( 16 ); + crypto.getRandomValues( array ); + array[ 6 ] = ( array[ 6 ] & 0x0f ) | 0x40; // Set version to 4 + array[ 8 ] = ( array[ 8 ] & 0x3f ) | 0x80; // Set variant to 10 + return [ ...array ].map( ( b, i ) => ( i === 4 || i === 6 || i === 8 || i === 10 ? '-' : '' ) + b.toString( 16 ).padStart( 2, '0' ) ).join( '' ); + + } + + // Listen for Three.js registration + devTools.addEventListener( 'register', ( event ) => { + + // console.log('DevTools: Three.js registered with revision:', event.detail.revision); + dispatchEvent( 'register', event.detail ); + + } ); + + // Listen for object observations + devTools.addEventListener( 'observe', ( event ) => { + + const obj = event.detail; + if ( ! obj ) { + + console.warn( 'DevTools: Received observe event with null/undefined detail' ); + return; + + } + + // Generate UUID if needed + if ( ! obj.uuid ) { + + obj.uuid = generateUUID(); + + } + + // Skip if already registered (essential to prevent loops with batching) + if ( devTools.objects.has( obj.uuid ) ) { + + return; + + } + + if ( obj.isWebGLRenderer || obj.isWebGPURenderer ) { + + const data = getObjectData( obj ); + + if ( data ) { + + data.properties = getRendererProperties( obj ); + observedRenderers.push( obj ); + devTools.objects.set( obj.uuid, data ); + + dispatchEvent( 'renderer', data ); + + } + + } else if ( obj.isScene ) { + + observedScenes.push( obj ); + + const batchObjects = []; + const processedUUIDs = new Set(); + + function traverseForBatch( currentObj ) { + + if ( ! currentObj || ! currentObj.uuid || processedUUIDs.has( currentObj.uuid ) ) return; + processedUUIDs.add( currentObj.uuid ); + + const objectData = getObjectData( currentObj ); + if ( objectData ) { + + batchObjects.push( objectData ); + devTools.objects.set( currentObj.uuid, objectData ); // Update local cache during batch creation + + } + + // Process children + if ( currentObj.children && Array.isArray( currentObj.children ) ) { + + currentObj.children.forEach( child => traverseForBatch( child ) ); + + } + + } + + traverseForBatch( obj ); // Start traversal from the scene + + dispatchEvent( 'scene', { sceneUuid: obj.uuid, objects: batchObjects } ); + + } + + } ); + + // Function to get renderer properties + function getRendererProperties( renderer ) { + + const parameters = renderer.getContextAttributes ? renderer.getContextAttributes() : {}; + + return { + width: renderer.domElement ? renderer.domElement.clientWidth : 0, + height: renderer.domElement ? renderer.domElement.clientHeight : 0, + alpha: parameters.alpha || false, + antialias: parameters.antialias || false, + outputColorSpace: renderer.outputColorSpace, + toneMapping: renderer.toneMapping, + toneMappingExposure: renderer.toneMappingExposure !== undefined ? renderer.toneMappingExposure : 1, + shadows: renderer.shadowMap ? renderer.shadowMap.enabled : false, + autoClear: renderer.autoClear, + autoClearColor: renderer.autoClearColor, + autoClearDepth: renderer.autoClearDepth, + autoClearStencil: renderer.autoClearStencil, + localClipping: renderer.localClippingEnabled, + physicallyCorrectLights: renderer.physicallyCorrectLights || false, // Assuming false is default if undefined + info: { + render: { + frame: renderer.info.render.frame, + calls: renderer.info.render.calls, + triangles: renderer.info.render.triangles, + points: renderer.info.render.points, + lines: renderer.info.render.lines, + geometries: renderer.info.render.geometries, + sprites: renderer.info.render.sprites + }, + memory: { + geometries: renderer.info.memory.geometries, + textures: renderer.info.memory.textures, + programs: renderer.info.programs ? renderer.info.programs.length : 0, + renderLists: renderer.info.memory.renderLists, + renderTargets: renderer.info.memory.renderTargets + } + } + }; + + } + + // Start periodic renderer checks + // console.log('DevTools: Starting periodic renderer checks'); + + // Function to check if bridge is available + function checkBridgeAvailability() { + + const hasDevTools = window.hasOwnProperty( '__THREE_DEVTOOLS__' ); + const devToolsValue = window.__THREE_DEVTOOLS__; + + // If we have devtools and we're interactive or complete, trigger ready + if ( hasDevTools && devToolsValue && ( document.readyState === 'interactive' || document.readyState === 'complete' ) ) { + + devTools.dispatchEvent( new CustomEvent( 'devtools-ready' ) ); + + } + + } + + // Watch for readyState changes + document.addEventListener( 'readystatechange', () => { + + if ( document.readyState === 'loading' ) { + + devTools.reset(); + + } + + checkBridgeAvailability(); + + } ); + + // Check if THREE is in the global scope (Old versions) + window.addEventListener( 'load', () => { + + if ( window.THREE && window.THREE.REVISION ) { + + dispatchEvent( 'register', { revision: THREE.REVISION } ); + + } + + } ); + + // Watch for page unload to reset state + window.addEventListener( 'beforeunload', () => { + + devTools.reset(); + + } ); + + // Listen for messages from the content script + window.addEventListener( 'message', function ( event ) { + + // Only accept messages from the same frame + if ( event.source !== window ) return; + + const message = event.data; + if ( ! message || message.id !== 'three-devtools' ) return; + + // Handle request for initial state from panel + if ( message.name === 'request-state' ) { + + sendState(); + + } + + } ); + + function sendState() { + + // Send current renderers + for ( const observedRenderer of observedRenderers ) { + + const data = getObjectData( observedRenderer ); + if ( data ) { + + data.properties = getRendererProperties( observedRenderer ); + dispatchEvent( 'renderer', data ); + + } + + } + + // Send current scenes + for ( const observedScene of observedScenes ) { + + reloadSceneObjects( observedScene ); + + } + + } + + function dispatchEvent( name, detail ) { + + try { + + window.postMessage( { + id: 'three-devtools', + name: name, + detail: detail + }, '*' ); + + } catch ( error ) { + + // If we get an "Extension context invalidated" error, stop all monitoring + if ( error.message.includes( 'Extension context invalidated' ) ) { + + console.log( 'DevTools: Extension context invalidated, stopping monitoring' ); + devTools.reset(); + return; + + } + + console.warn( 'DevTools: Error dispatching event:', error ); + + } + + } + + // Function to manually reload scene objects + function reloadSceneObjects( scene ) { + + const batchObjects = []; + + // Recursively observe all objects, collect data, update local cache + function observeAndBatchObject( object ) { + + if ( ! object || ! object.uuid ) return; // Simplified check + + // console.log('DevTools: Processing object during reload:', object.type || object.constructor.name, object.uuid); + + // Get object data + const objectData = getObjectData( object ); + if ( objectData ) { + + batchObjects.push( objectData ); // Add to batch + // Update or add to local cache immediately + devTools.objects.set( object.uuid, objectData ); + + } + + // Process children recursively + if ( object.children && Array.isArray( object.children ) ) { + + // console.log('DevTools: Processing', object.children.length, 'children of', object.type || object.constructor.name); + object.children.forEach( child => observeAndBatchObject( child ) ); + + } + + } + + // Start traversal from the scene itself + observeAndBatchObject( scene ); + + // --- Caching Logic --- + const currentObjectCount = batchObjects.length; + const previousObjectCount = sceneObjectCountCache.get( scene.uuid ); + + if ( currentObjectCount !== previousObjectCount ) { + + console.log( `DevTools: Scene ${scene.uuid} count changed (${previousObjectCount} -> ${currentObjectCount}), dispatching update.` ); + // Dispatch the batch update for the panel as 'scene' + dispatchEvent( 'scene', { sceneUuid: scene.uuid, objects: batchObjects } ); + // Update the cache + sceneObjectCountCache.set( scene.uuid, currentObjectCount ); + + } else { + // console.log(`DevTools: Scene ${scene.uuid} count unchanged (${currentObjectCount}), skipping dispatch.`); + } + + } + + } + +} )(); diff --git a/devtools/content-script.js b/devtools/content-script.js new file mode 100644 index 00000000000000..228e2cc527b6c2 --- /dev/null +++ b/devtools/content-script.js @@ -0,0 +1,129 @@ +/* global chrome */ + +// This script runs in the context of the web page +// console.log( 'Three.js DevTools: Content script loaded at document_readyState:', document.readyState ); // Comment out + +// Inject the bridge script into the main document or a target (e.g., iframe) +function injectBridge( target = document ) { + + const script = document.createElement( 'script' ); + script.src = chrome.runtime.getURL( 'bridge.js' ); + script.onload = function () { + + this.remove(); + + }; + + ( target.head || target.documentElement ).appendChild( script ); + return script; + +} + +// Inject bridge into all existing iframes +function injectIntoIframes() { + + document.querySelectorAll( 'iframe' ).forEach( iframe => { + + try { + + if ( iframe.contentDocument ) injectBridge( iframe.contentDocument ); + + } catch ( e ) { /* Ignore cross-origin errors */ } + + } ); + +} + +// Initial injection +injectBridge(); +injectIntoIframes(); + +// Watch for new iframes being added +new MutationObserver( mutations => { + + mutations.forEach( mutation => { + + mutation.addedNodes.forEach( node => { + + if ( node.tagName === 'IFRAME' ) { + + node.addEventListener( 'load', () => { + + try { + + if ( node.contentDocument ) injectBridge( node.contentDocument ); + + } catch ( e ) { /* Ignore cross-origin errors */ } + + } ); + + } + + } ); + + } ); + +} ).observe( document.documentElement, { childList: true, subtree: true } ); + +// Helper to check if extension context is valid +function isExtensionContextValid() { + + try { + + chrome.runtime.getURL( '' ); + return true; + + } catch ( error ) { + + return false; + + } + +} + +// Unified message handler for window messages +function handleWindowMessage( event ) { + + // Only accept messages with the correct id + if ( ! event.data || event.data.id !== 'three-devtools' ) return; + + // Determine source: 'main' for window, 'iframe' otherwise + const source = event.source === window ? 'main' : 'iframe'; + + if ( ! isExtensionContextValid() ) { + + console.warn( 'Extension context invalidated, cannot send message' ); + return; + + } + + const messageWithSource = { ...event.data, source }; + chrome.runtime.sendMessage( messageWithSource ); + +} + +// Listener for messages from the background script (originating from panel) +function handleBackgroundMessage( message ) { + + if ( message.name === 'request-state' ) { + + message.id = 'three-devtools'; + window.postMessage( message, '*' ); + + } + +} + +// Add event listeners +window.addEventListener( 'message', handleWindowMessage, false ); +chrome.runtime.onMessage.addListener( handleBackgroundMessage ); + +// Icon color scheme +const isLightTheme = window.matchMedia( '(prefers-color-scheme: light)' ).matches; +chrome.runtime.sendMessage( { scheme: isLightTheme ? 'light' : 'dark' } ); +window.matchMedia( '(prefers-color-scheme: light)' ).onchange = event => { + + chrome.runtime.sendMessage( { scheme: event.matches ? 'light' : 'dark' } ); + +}; + diff --git a/devtools/devtools.js b/devtools/devtools.js new file mode 100644 index 00000000000000..ab6742b968b111 --- /dev/null +++ b/devtools/devtools.js @@ -0,0 +1,18 @@ +try { + + chrome.devtools.panels.create( + 'Three.js', + null, + 'panel/panel.html', + function () { + + console.log( 'Three.js DevTools panel created' ); + + } + ); + +} catch ( error ) { + + console.error( 'Failed to create Three.js panel:', error ); + +} diff --git a/devtools/icons/128-dark.png b/devtools/icons/128-dark.png new file mode 100644 index 00000000000000..71d38580c79f2c Binary files /dev/null and b/devtools/icons/128-dark.png differ diff --git a/devtools/icons/128-light.png b/devtools/icons/128-light.png new file mode 100644 index 00000000000000..6d9aa909314269 Binary files /dev/null and b/devtools/icons/128-light.png differ diff --git a/devtools/index.html b/devtools/index.html new file mode 100644 index 00000000000000..c83ce6457297b2 --- /dev/null +++ b/devtools/index.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/devtools/manifest.json b/devtools/manifest.json new file mode 100644 index 00000000000000..209ffd50f60849 --- /dev/null +++ b/devtools/manifest.json @@ -0,0 +1,29 @@ +{ + "manifest_version": 3, + "name": "Three.js DevTools", + "version": "1.9", + "description": "Developer tools extension for Three.js", + "icons": { + "128": "icons/128-light.png" + }, + "action": {}, + "devtools_page": "index.html", + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [{ + "matches": ["