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..ed7d85e0e89661 --- /dev/null +++ b/devtools/background.js @@ -0,0 +1,61 @@ +// 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 (sender.tab) { + const tabId = sender.tab.id; + 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; + const port = connections.get(tabId); + + if (port) { + port.postMessage({ + id: 'three-devtools', + type: 'committed', + frameId: frameId + }); + } +}); \ No newline at end of file diff --git a/devtools/bridge.js b/devtools/bridge.js new file mode 100644 index 00000000000000..e20f2e8ab25a06 --- /dev/null +++ b/devtools/bridge.js @@ -0,0 +1,478 @@ +/** + * 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(); + + }); + + // 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(type, detail) { + + try { + + window.postMessage({ + id: 'three-devtools', + type: type, + 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..f18eecadc5b1cf --- /dev/null +++ b/devtools/content-script.js @@ -0,0 +1,207 @@ +// 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 + +// Function to inject the bridge script +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; + +} + +// Also inject into any existing iframes +function injectIntoIframes() { + + const iframes = document.querySelectorAll( 'iframe' ); + iframes.forEach( iframe => { + + try { + + injectBridge( iframe.contentDocument ); + + } catch ( e ) { + + // Ignore cross-origin iframe errors + // console.log( 'DevTools: Could not inject into iframe:', e ); // Comment out + + } + + } ); + +} + +// Initial injection +injectBridge(); +injectIntoIframes(); + +// Watch for new iframes being added +const observer = new MutationObserver( mutations => { + + mutations.forEach( mutation => { + + mutation.addedNodes.forEach( node => { + + if ( node.tagName === 'IFRAME' ) { + + // Wait for iframe to load + node.addEventListener( 'load', () => { + + try { + + injectBridge( node.contentDocument ); + + } catch ( e ) { + + // Ignore cross-origin iframe errors + // console.log( 'DevTools: Could not inject into iframe:', e ); // Comment out + + } + + } ); + + } + + } ); + + } ); + +} ); + +observer.observe( document.documentElement, { + childList: true, + subtree: true +} ); + +// Helper function to check if extension context is valid +function isExtensionContextValid() { + + try { + + // This will throw if context is invalidated + chrome.runtime.getURL( '' ); + return true; + + } catch ( error ) { + + return false; + + } + +} + +// Handle messages from the main window +function handleMainWindowMessage( event ) { + + // Only accept messages from the same frame + if ( event.source !== window ) { + + return; + + } + + const message = event.data; + if ( ! message || message.id !== 'three-devtools' ) { + + return; + + } + + // Check extension context before sending message + if ( ! isExtensionContextValid() ) { + + console.warn( 'Extension context invalidated, cannot send message' ); + return; + + } + + // Add source information + const messageWithSource = { + ...event.data, + source: event.source === window ? 'main' : 'iframe' + }; + + // Forward to background page + chrome.runtime.sendMessage( messageWithSource ); + +} + +// Handle messages from iframes +function handleIframeMessage( event ) { + + // Skip messages from main window + if ( event.source === window ) { + + return; + + } + + const message = event.data; + if ( ! message || message.id !== 'three-devtools' ) { + + return; + + } + + // Check extension context before sending message + if ( ! isExtensionContextValid() ) { + + console.warn( 'Extension context invalidated, cannot send message' ); + return; + + } + + // Add source information + const messageWithSource = { + ...event.data, + source: 'iframe' + }; + + // Forward to background page + chrome.runtime.sendMessage( messageWithSource ); + +} + +// Listener for messages forwarded from the background script (originating from panel) +function handleBackgroundMessage( message, sender, sendResponse ) { + + // Check if the message is one we need to forward to the bridge + // Only forward request-state now + if ( message.name === 'request-state' ) { + + // console.log( 'Content script: Forwarding message to bridge:', message.name ); + // Ensure the message has the correct ID before forwarding to the page + message.id = 'three-devtools'; + window.postMessage( message, '*' ); // Forward the modified message to the page + + // Optional: Forward to iframes too, if needed (might cause duplicates if bridge is in iframe) + /* + const iframes = document.querySelectorAll('iframe'); + iframes.forEach(iframe => { + try { + iframe.contentWindow.postMessage(message, '*'); + } catch (e) {} + }); + */ + + } + // Keep channel open? No, this listener is synchronous for now. + // return true; + +} + +// Add event listeners +window.addEventListener( 'message', handleMainWindowMessage, false ); +window.addEventListener( 'message', handleIframeMessage, false ); +// chrome.runtime.onMessage.addListener( handleDevtoolsMessage ); // This seems redundant/incorrectly placed in original code + +// Use a single listener for messages from the background script +chrome.runtime.onMessage.addListener( handleBackgroundMessage ); 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/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..9017331555ae43 --- /dev/null +++ b/devtools/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "Three.js DevTools", + "version": "1.4", + "description": "Developer tools extension for Three.js", + "devtools_page": "index.html", + "background": { + "service_worker": "background.js", + "type": "module" + }, + "content_scripts": [{ + "matches": [""], + "js": ["content-script.js"], + "all_frames": true, + "run_at": "document_start" + }], + "web_accessible_resources": [{ + "resources": ["bridge.js"], + "matches": [""] + }], + "permissions": [ + "activeTab", + "webNavigation" + ] +} \ No newline at end of file diff --git a/devtools/panel/panel.css b/devtools/panel/panel.css new file mode 100644 index 00000000000000..913d0030d074c3 --- /dev/null +++ b/devtools/panel/panel.css @@ -0,0 +1,90 @@ +body { + margin: 0; + padding: 16px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 12px; + color: #333; + background: #fff; +} +.info-item { + padding: 8px 12px; + background: #f5f5f5; + border-radius: 4px; + margin-bottom: 16px; + font-family: monospace; + color: #666; +} +.section { + margin-bottom: 24px; +} +.section h3 { + margin: 0 0 8px 0; + font-size: 11px; + text-transform: uppercase; + color: #666; + font-weight: 500; + border-bottom: 1px solid #eee; + padding-bottom: 4px; +} +.tree-item { + display: flex; + align-items: center; + padding: 2px; + /* cursor: pointer; Let summary handle this */ + border-radius: 4px; +} +.tree-item:hover { + background: #e0e0e0; +} +.tree-item .icon { + margin-right: 4px; + opacity: 0.7; +} +.tree-item .label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.tree-item .label .object-details { + color: #aaa; + margin-left: 4px; + font-weight: normal; +} +.tree-item .type { + margin-left: 8px; + opacity: 0.5; + font-size: 0.9em; +} +.children { + margin-left: 0; +} + +/* Style for clickable renderer summary */ +.renderer-summary { + cursor: pointer; +} +.renderer-summary:hover { + background: #e0e0e0; +} + +/* Hide default details marker when using custom summary */ +details.renderer-container > summary.renderer-summary { /* Target summary */ + list-style: none; /* Hide default arrow */ + cursor: pointer; /* Make the summary div look clickable */ +} +details.renderer-container > summary.renderer-summary::-webkit-details-marker { + display: none; /* Hide default arrow in WebKit */ +} + +/* Style for the toggle icon */ +.toggle-icon::before { + content: '▶'; /* Default: collapsed */ + display: inline-block; + width: 1em; + margin-right: 2px; + opacity: 0.7; +} +details.renderer-container[open] > summary.renderer-summary .toggle-icon::before { + content: '▼'; /* Expanded */ +} \ No newline at end of file diff --git a/devtools/panel/panel.html b/devtools/panel/panel.html new file mode 100644 index 00000000000000..c28b1167d9768e --- /dev/null +++ b/devtools/panel/panel.html @@ -0,0 +1,53 @@ + + + + + Three.js DevTools + + + + +
+ + + \ No newline at end of file diff --git a/devtools/panel/panel.js b/devtools/panel/panel.js new file mode 100644 index 00000000000000..3c18f4e55d065d --- /dev/null +++ b/devtools/panel/panel.js @@ -0,0 +1,516 @@ +// Store the state of our inspector +const state = { + revision: null, + scenes: new Map(), + renderers: new Map(), + objects: new Map() +}; + +// console.log('Panel script loaded'); + +// Create a connection to the background page +const backgroundPageConnection = chrome.runtime.connect( { + name: 'three-devtools' +} ); + +// Initialize the connection with the inspected tab ID +backgroundPageConnection.postMessage( { + name: 'init', + tabId: chrome.devtools.inspectedWindow.tabId +} ); + +// Request the initial state from the bridge script +backgroundPageConnection.postMessage( { + name: 'request-state', + tabId: chrome.devtools.inspectedWindow.tabId +} ); + +const intervalId = setInterval( () => { + + backgroundPageConnection.postMessage( { + name: 'request-state', + tabId: chrome.devtools.inspectedWindow.tabId + } ); + +}, 1000 ); + +backgroundPageConnection.onDisconnect.addListener( () => { + + console.log( 'Panel: Connection to background page lost' ); + clearInterval( intervalId ); + clearState(); + +} ); + +// console.log('Connected to background page with tab ID:', chrome.devtools.inspectedWindow.tabId); + +// Store renderer collapse states +const rendererCollapsedState = new Map(); + +// Clear state when panel is reloaded +function clearState() { + + state.revision = null; + state.scenes.clear(); + state.renderers.clear(); + state.objects.clear(); + const container = document.getElementById( 'scene-tree' ); + if ( container ) { + + container.innerHTML = ''; + + } + +} + +// Listen for messages from the background page +backgroundPageConnection.onMessage.addListener( function ( message ) { + + if ( message.id === 'three-devtools' ) { + + handleThreeEvent( message ); + + } + +} ); + +function handleThreeEvent( message ) { + + // console.log('Handling event:', message.type); + switch ( message.type ) { + + case 'register': + state.revision = message.detail.revision; + updateUI(); + break; + + // Handle individual renderer observation + case 'renderer': + const detail = message.detail; + + // Only store each unique object once + if ( ! state.objects.has( detail.uuid ) ) { + + state.objects.set( detail.uuid, detail ); + state.renderers.set( detail.uuid, detail ); + + } + + // Update or add the renderer in the state map + state.renderers.set( detail.uuid, detail ); // Ensure the latest detail is always stored + // Also update the generic objects map if renderers are stored there too + state.objects.set( detail.uuid, detail ); + + // The DOM update logic previously here is redundant because updateUI() + // rebuilds the entire renderer element anyway, using the updated data + // from state.renderers and the persisted open/closed state. + + updateUI(); // Call updateUI to rebuild based on the new state + + break; + + // Handle a batch of objects for a specific scene + case 'scene': + const { sceneUuid, objects: batchObjects } = message.detail; + console.log( 'Panel: Received scene batch for', sceneUuid, 'with', batchObjects.length, 'objects' ); + + // 1. Identify UUIDs in the new batch + const newObjectUuids = new Set( batchObjects.map( obj => obj.uuid ) ); + + // 2. Identify current object UUIDs associated with this scene that are NOT renderers + const currentSceneObjectUuids = new Set(); + state.objects.forEach( ( obj, uuid ) => { + + // Use the _sceneUuid property we'll add below, or check if it's the scene root itself + if ( obj._sceneUuid === sceneUuid || uuid === sceneUuid ) { + + currentSceneObjectUuids.add( uuid ); + + } + + } ); + + // 3. Find UUIDs to remove (in current state for this scene, but not in the new batch) + const uuidsToRemove = new Set(); + currentSceneObjectUuids.forEach( uuid => { + + if ( ! newObjectUuids.has( uuid ) ) { + + uuidsToRemove.add( uuid ); + + } + + } ); + + // 4. Remove stale objects from state + uuidsToRemove.forEach( uuid => { + + state.objects.delete( uuid ); + // If a scene object itself was somehow removed (unlikely for root), clean up scenes map too + if ( state.scenes.has( uuid ) ) { + + state.scenes.delete( uuid ); + + } + + } ); + + // 5. Process the new batch: Add/Update objects and mark their scene association + batchObjects.forEach( objData => { + + // Add a private property to track which scene this object belongs to + objData._sceneUuid = sceneUuid; + state.objects.set( objData.uuid, objData ); + + // Ensure the scene root is in the scenes map + if ( objData.isScene && objData.uuid === sceneUuid ) { + + state.scenes.set( objData.uuid, objData ); + + } + // Note: Renderers are handled separately by 'renderer' events and shouldn't appear in scene batches. + + } ); + + // Update UI once after processing the entire batch + updateUI(); + break; + + case 'committed': + // Page was reloaded, clear state + clearState(); + break; + + } + +} + +// Function to get an object icon based on its type +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 renderRenderer( obj, container ) { + + // Create
element as the main container + const detailsElement = document.createElement( 'details' ); + detailsElement.className = 'renderer-container'; + detailsElement.setAttribute( 'data-uuid', obj.uuid ); + + // Set initial state + detailsElement.open = false; + + if ( rendererCollapsedState.has( obj.uuid ) ) { + + detailsElement.open = rendererCollapsedState.get( obj.uuid ); + + } + + // Add toggle listener to save state + detailsElement.addEventListener( 'toggle', () => { + + rendererCollapsedState.set( obj.uuid, detailsElement.open ); + + } ); + + // Create the summary element (clickable header) - THIS IS THE FIRST CHILD + const summaryElem = document.createElement( 'summary' ); // USE tag + summaryElem.className = 'tree-item renderer-summary'; // Acts as summary + + // Update display name in the summary line + const props = obj.properties; + const details = [ `${props.width}x${props.height}` ]; + if ( props.info ) { + + details.push( `${props.info.render.calls} draws` ); + details.push( `${props.info.render.triangles.toLocaleString()} triangles` ); + + } + + const displayName = `${obj.type} ${details.join( ' ・ ' )}`; + + // Use toggle icon instead of paint icon + summaryElem.innerHTML = ` + ${displayName} + ${obj.type}`; + detailsElement.appendChild( summaryElem ); + + const propsContainer = document.createElement( 'div' ); + propsContainer.className = 'properties-list'; + // Adjust padding calculation if needed, ensure it's a number before adding + const summaryPaddingLeft = parseFloat( summaryElem.style.paddingLeft ) || 0; + propsContainer.style.paddingLeft = `${summaryPaddingLeft + 20}px`; // Indent further + + propsContainer.innerHTML = ''; // Clear placeholder + + if ( obj.properties ) { + + const props = obj.properties; + const info = props.info || { render: {}, memory: {} }; // Default empty objects if info is missing + + const gridContainer = document.createElement( 'div' ); + gridContainer.style.display = 'grid'; + gridContainer.style.gridTemplateColumns = 'repeat(auto-fit, minmax(200px, 1fr))'; // Responsive columns + gridContainer.style.gap = '10px 20px'; // Row and column gap + + // --- Column 1: Properties --- + const propsCol = document.createElement( 'div' ); + propsCol.className = 'properties-column'; + const propsTitle = document.createElement( 'h4' ); + propsTitle.textContent = 'Properties'; + propsCol.appendChild( propsTitle ); + propsCol.appendChild( createPropertyRow( 'Size', `${props.width}x${props.height}` ) ); + propsCol.appendChild( createPropertyRow( 'Alpha', props.alpha ) ); + propsCol.appendChild( createPropertyRow( 'Antialias', props.antialias ) ); + propsCol.appendChild( createPropertyRow( 'Output Color Space', props.outputColorSpace ) ); + propsCol.appendChild( createPropertyRow( 'Tone Mapping', props.toneMapping ) ); + propsCol.appendChild( createPropertyRow( 'Tone Mapping Exposure', props.toneMappingExposure ) ); + propsCol.appendChild( createPropertyRow( 'Shadows', props.shadows ? 'enabled' : 'disabled' ) ); // Display string + propsCol.appendChild( createPropertyRow( 'Auto Clear', props.autoClear ) ); + propsCol.appendChild( createPropertyRow( 'Auto Clear Color', props.autoClearColor ) ); + propsCol.appendChild( createPropertyRow( 'Auto Clear Depth', props.autoClearDepth ) ); + propsCol.appendChild( createPropertyRow( 'Auto Clear Stencil', props.autoClearStencil ) ); + propsCol.appendChild( createPropertyRow( 'Local Clipping', props.localClipping ) ); + propsCol.appendChild( createPropertyRow( 'Physically Correct Lights', props.physicallyCorrectLights ) ); + gridContainer.appendChild( propsCol ); + + // --- Column 2: Render Stats & Memory --- + const statsCol = document.createElement( 'div' ); + statsCol.className = 'stats-column'; + + // Render Stats + const renderTitle = document.createElement( 'h4' ); + renderTitle.textContent = 'Render Stats'; + statsCol.appendChild( renderTitle ); + statsCol.appendChild( createPropertyRow( 'Frame', info.render.frame ) ); + statsCol.appendChild( createPropertyRow( 'Draw Calls', info.render.calls ) ); + statsCol.appendChild( createPropertyRow( 'Triangles', info.render.triangles ) ); + statsCol.appendChild( createPropertyRow( 'Points', info.render.points ) ); + statsCol.appendChild( createPropertyRow( 'Lines', info.render.lines ) ); + + // Memory + const memoryTitle = document.createElement( 'h4' ); + memoryTitle.textContent = 'Memory'; + memoryTitle.style.marginTop = '10px'; // Add space before Memory section + statsCol.appendChild( memoryTitle ); + statsCol.appendChild( createPropertyRow( 'Geometries', info.memory.geometries ) ); // Memory Geometries + statsCol.appendChild( createPropertyRow( 'Textures', info.memory.textures ) ); + statsCol.appendChild( createPropertyRow( 'Shader Programs', info.memory.programs ) ); + + gridContainer.appendChild( statsCol ); + propsContainer.appendChild( gridContainer ); + + } else { + + propsContainer.textContent = 'No properties available.'; + + } + + detailsElement.appendChild( propsContainer ); + + container.appendChild( detailsElement ); // Append details to the main container + +} + +// Function to render an object and its children +function renderObject( obj, container, level = 0 ) { + + const icon = getObjectIcon( obj ); + let displayName = obj.name || obj.type; + + // Default rendering for other object types + const elem = document.createElement( 'div' ); + elem.className = 'tree-item'; + elem.style.paddingLeft = `${level * 20}px`; + elem.setAttribute( 'data-uuid', obj.uuid ); + + let labelContent = `${icon} + ${displayName} + ${obj.type}`; + + if ( obj.isScene ) { + + // Add object count for scenes + let objectCount = - 1; + function countObjects( uuid ) { + + const object = state.objects.get( uuid ); + if ( object ) { + + objectCount ++; // Increment count for the object itself + if ( object.children ) { + + object.children.forEach( childId => countObjects( childId ) ); + + } + + } + + } + + countObjects( obj.uuid ); + displayName = `${obj.name || obj.type} ${objectCount} objects`; + labelContent = `${icon} + ${displayName} + ${obj.type}`; + + } + + elem.innerHTML = labelContent; + container.appendChild( elem ); + + // Handle children (excluding children of renderers, as properties are shown in details) + if ( ! obj.isRenderer && obj.children && obj.children.length > 0 ) { + + // Create a container for children + const childContainer = document.createElement( 'div' ); + childContainer.className = 'children'; + container.appendChild( childContainer ); + + // Get all children and sort them by type for better organization + const children = obj.children + .map( childId => state.objects.get( childId ) ) + .filter( child => child !== undefined ) + .sort( ( a, b ) => { + + // Sort order: Cameras, Lights, Groups, Meshes, Others + const typeOrder = { + isCamera: 1, + isLight: 2, + isGroup: 3, + isMesh: 4 + }; + // Refactored to avoid optional chaining parser error + const findOrder = ( obj ) => { + + const entry = Object.entries( typeOrder ).find( ( [ key ] ) => obj[ key ] ); + return entry ? entry[ 1 ] : 5; // Check if entry exists, then access index 1 (value) + + }; + + const aOrder = findOrder( a ); + const bOrder = findOrder( b ); + + return aOrder - bOrder; + + } ); + + // Render each child + children.forEach( child => { + + renderObject( child, childContainer, level + 1 ); + + } ); + + } + +} + +// Function to update the UI +function updateUI() { + + const container = document.getElementById( 'scene-tree' ); + container.innerHTML = ''; + + const versionInfo = document.createElement( 'div' ); + versionInfo.className = 'info-item'; + versionInfo.style.display = 'flex'; // Use flexbox + versionInfo.style.justifyContent = 'space-between'; // Align items left and right + + const threeVersionSpan = document.createElement( 'span' ); + + // TODO: Why it's not available? + if ( state.revision ) { + + threeVersionSpan.textContent = `Three.js r${state.revision}`; + + } + + const manifest = chrome.runtime.getManifest(); + + const manifestVersionSpan = document.createElement( 'span' ); + manifestVersionSpan.textContent = `${manifest.version}`; + manifestVersionSpan.style.opacity = '0.5'; // Make it less prominent + + versionInfo.appendChild( threeVersionSpan ); + versionInfo.appendChild( manifestVersionSpan ); + + container.appendChild( versionInfo ); + + // Add renderers section + if ( state.renderers.size > 0 ) { + + const renderersSection = document.createElement( 'div' ); + renderersSection.className = 'section'; + renderersSection.innerHTML = '

Renderers

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

Scenes

'; + + state.scenes.forEach( scene => { + + renderObject( scene, scenesSection ); + + } ); + + container.appendChild( scenesSection ); + + } + +} + +// Initial UI update +clearState(); +updateUI(); + +// Helper function to create a property row (Label: Value) +function createPropertyRow( label, value ) { + + const row = document.createElement( 'div' ); + row.className = 'property-row'; // Add class for potential styling + row.style.display = 'flex'; + row.style.justifyContent = 'space-between'; // Align label left, value right + row.style.marginBottom = '2px'; // Small gap between rows + + const labelSpan = document.createElement( 'span' ); + labelSpan.className = 'property-label'; + labelSpan.textContent = `${label}:`; + labelSpan.style.marginRight = '10px'; // Space between label and value + labelSpan.style.whiteSpace = 'nowrap'; // Prevent label wrapping + + const valueSpan = document.createElement( 'span' ); + valueSpan.className = 'property-value'; + // Format numbers nicely, handle undefined/null with '–' + const displayValue = ( value === undefined || value === null ) + ? '–' + : ( typeof value === 'number' ? value.toLocaleString() : value ); + valueSpan.textContent = displayValue; + valueSpan.style.textAlign = 'right'; // Align value text to the right + + row.appendChild( labelSpan ); + row.appendChild( valueSpan ); + return row; + +} diff --git a/devtools/screenshot.png b/devtools/screenshot.png new file mode 100644 index 00000000000000..8e81eba978f4b7 Binary files /dev/null and b/devtools/screenshot.png differ