/* global chrome */ const CONNECTION_NAME = 'three-devtools'; const STATE_POLLING_INTERVAL = 1000; // --- Utility Functions --- function getObjectIcon( obj ) { if ( obj.isScene ) return '🌍'; if ( obj.isCamera ) return '📷'; if ( obj.isLight ) return '💡'; if ( obj.isInstancedMesh ) return '🔸'; if ( obj.isMesh ) return '🔷'; if ( obj.type === 'Group' ) return '📁'; return '📦'; } function createPropertyRow( label, value ) { const row = document.createElement( 'div' ); row.className = 'property-row'; row.style.display = 'flex'; row.style.justifyContent = 'space-between'; row.style.marginBottom = '2px'; const labelSpan = document.createElement( 'span' ); labelSpan.className = 'property-label'; labelSpan.textContent = `${label}`; labelSpan.style.marginRight = '10px'; labelSpan.style.whiteSpace = 'nowrap'; const valueSpan = document.createElement( 'span' ); valueSpan.className = 'property-value'; const displayValue = ( value === undefined || value === null ) ? '–' : ( typeof value === 'number' ? value.toLocaleString() : value ); valueSpan.textContent = displayValue; valueSpan.style.textAlign = 'right'; row.appendChild( labelSpan ); row.appendChild( valueSpan ); return row; } function createVectorRow( label, vector ) { const row = document.createElement( 'div' ); row.className = 'property-row'; row.style.marginBottom = '2px'; // Pad label to ensure consistent alignment const paddedLabel = label.padEnd( 16, ' ' ); // Pad to 16 characters const content = `${paddedLabel} ${vector.x.toFixed( 3 )}\t${vector.y.toFixed( 3 )}\t${vector.z.toFixed( 3 )}`; row.textContent = content; row.style.fontFamily = 'monospace'; row.style.whiteSpace = 'pre'; return row; } // --- State --- const state = { revision: null, scenes: new Map(), renderers: new Map(), objects: new Map(), selectedObject: null }; // Floating details panel let floatingPanel = null; let mousePosition = { x: 0, y: 0 }; // Create a connection to the background page const backgroundPageConnection = chrome.runtime.connect( { name: CONNECTION_NAME } ); // Initialize the connection with the inspected tab ID backgroundPageConnection.postMessage( { name: MESSAGE_INIT, tabId: chrome.devtools.inspectedWindow.tabId } ); // Request the initial state from the bridge script backgroundPageConnection.postMessage( { name: MESSAGE_REQUEST_STATE, tabId: chrome.devtools.inspectedWindow.tabId } ); // Function to scroll to canvas element function scrollToCanvas( rendererUuid ) { backgroundPageConnection.postMessage( { name: MESSAGE_SCROLL_TO_CANVAS, uuid: rendererUuid, tabId: chrome.devtools.inspectedWindow.tabId } ); } const intervalId = setInterval( () => { backgroundPageConnection.postMessage( { name: MESSAGE_REQUEST_STATE, tabId: chrome.devtools.inspectedWindow.tabId } ); }, STATE_POLLING_INTERVAL ); backgroundPageConnection.onDisconnect.addListener( () => { clearInterval( intervalId ); clearState(); } ); // Function to request object details from the bridge function requestObjectDetails( uuid ) { backgroundPageConnection.postMessage( { name: MESSAGE_REQUEST_OBJECT_DETAILS, uuid: uuid, tabId: chrome.devtools.inspectedWindow.tabId } ); } // Function to highlight object in 3D scene function requestObjectHighlight( uuid ) { backgroundPageConnection.postMessage( { name: MESSAGE_HIGHLIGHT_OBJECT, uuid: uuid, tabId: chrome.devtools.inspectedWindow.tabId } ); } // Function to remove highlight from 3D scene function requestObjectUnhighlight() { backgroundPageConnection.postMessage( { name: MESSAGE_UNHIGHLIGHT_OBJECT, tabId: chrome.devtools.inspectedWindow.tabId } ); } // Store renderer collapse states const rendererCollapsedState = new Map(); // Static DOM elements (created once in initUI) let renderersSection = null; let scenesSection = null; let sceneDirty = true; // Helper function to create properties column for renderer function createRendererPropertiesColumn( props ) { const propsCol = document.createElement( 'div' ); propsCol.className = 'properties-column'; const propsTitle = document.createElement( 'h4' ); propsTitle.textContent = 'Properties'; propsCol.appendChild( propsTitle ); propsCol.appendChild( createPropertyRow( 'Size', `${props.width}x${props.height}` ) ); propsCol.appendChild( createPropertyRow( 'Alpha', props.alpha ) ); propsCol.appendChild( createPropertyRow( 'Antialias', props.antialias ) ); propsCol.appendChild( createPropertyRow( 'Output Color Space', props.outputColorSpace ) ); propsCol.appendChild( createPropertyRow( 'Tone Mapping', props.toneMapping ) ); propsCol.appendChild( createPropertyRow( 'Tone Mapping Exposure', props.toneMappingExposure ) ); propsCol.appendChild( createPropertyRow( 'Shadows', props.shadows ? 'enabled' : 'disabled' ) ); propsCol.appendChild( createPropertyRow( 'Auto Clear', props.autoClear ) ); propsCol.appendChild( createPropertyRow( 'Auto Clear Color', props.autoClearColor ) ); propsCol.appendChild( createPropertyRow( 'Auto Clear Depth', props.autoClearDepth ) ); propsCol.appendChild( createPropertyRow( 'Auto Clear Stencil', props.autoClearStencil ) ); propsCol.appendChild( createPropertyRow( 'Local Clipping', props.localClipping ) ); propsCol.appendChild( createPropertyRow( 'Physically Correct Lights', props.physicallyCorrectLights ) ); return propsCol; } // Helper function to create stats column for renderer function createRendererStatsColumn( info ) { const statsCol = document.createElement( 'div' ); statsCol.className = 'stats-column'; // Render Stats const renderTitle = document.createElement( 'h4' ); renderTitle.textContent = 'Render Stats'; statsCol.appendChild( renderTitle ); statsCol.appendChild( createPropertyRow( 'Frame', info.render.frame ) ); statsCol.appendChild( createPropertyRow( 'Draw Calls', info.render.calls ) ); statsCol.appendChild( createPropertyRow( 'Triangles', info.render.triangles ) ); statsCol.appendChild( createPropertyRow( 'Points', info.render.points ) ); statsCol.appendChild( createPropertyRow( 'Lines', info.render.lines ) ); // Memory const memoryTitle = document.createElement( 'h4' ); memoryTitle.textContent = 'Memory'; memoryTitle.style.marginTop = '10px'; statsCol.appendChild( memoryTitle ); statsCol.appendChild( createPropertyRow( 'Geometries', info.memory.geometries ) ); statsCol.appendChild( createPropertyRow( 'Textures', info.memory.textures ) ); statsCol.appendChild( createPropertyRow( 'Shader Programs', info.memory.programs ) ); return statsCol; } // Helper function to process scene batch updates function processSceneBatch( sceneUuid, batchObjects ) { // 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 => { objData._sceneUuid = sceneUuid; state.objects.set( objData.uuid, objData ); if ( objData.isScene && objData.uuid === sceneUuid ) { state.scenes.set( objData.uuid, objData ); } } ); sceneDirty = true; } // Clear state when panel is reloaded function clearState() { state.revision = null; state.scenes.clear(); state.renderers.clear(); state.objects.clear(); sceneDirty = true; // Hide floating panel if ( floatingPanel ) { floatingPanel.classList.remove( 'visible' ); } } // Listen for messages from the background page backgroundPageConnection.onMessage.addListener( function ( message ) { if ( message.id === MESSAGE_ID ) { handleThreeEvent( message ); } } ); function handleThreeEvent( message ) { switch ( message.name ) { case EVENT_REGISTER: state.revision = message.detail.revision; break; case EVENT_RENDERER: const detail = message.detail; state.renderers.set( detail.uuid, detail ); state.objects.set( detail.uuid, detail ); updateRenderers(); break; case EVENT_OBJECT_DETAILS: state.selectedObject = message.detail; showFloatingDetails( message.detail ); break; case EVENT_SCENE: const { sceneUuid, objects: batchObjects } = message.detail; processSceneBatch( sceneUuid, batchObjects ); updateSceneTree(); break; case EVENT_COMMITTED: clearState(); updateRenderers(); updateSceneTree(); break; } } function renderRenderer( obj, container ) { // Create
element as the main container const detailsElement = document.createElement( 'details' ); detailsElement.className = 'renderer-container'; detailsElement.setAttribute( 'data-uuid', obj.uuid ); // Set initial state detailsElement.open = rendererCollapsedState.get( obj.uuid ) || false; // Add toggle listener to save state detailsElement.addEventListener( 'toggle', () => { rendererCollapsedState.set( obj.uuid, detailsElement.open ); } ); // Create the summary element (clickable header) - THIS IS THE FIRST CHILD const summaryElem = document.createElement( 'summary' ); // USE tag summaryElem.className = 'tree-item renderer-summary'; // Acts as summary // Update display name in the summary line const props = obj.properties; const details = [ `${props.width}x${props.height}` ]; if ( props.info ) { details.push( `${props.info.render.calls} draws` ); details.push( `${props.info.render.triangles.toLocaleString()} triangles` ); } const displayName = `${obj.type} ${details.join( ' ・ ' )}`; // Use toggle icon instead of paint icon const scrollButton = obj.canvasInDOM ? `` : `󠀠🫥`; summaryElem.innerHTML = ` ${displayName} ${obj.type} ${scrollButton}`; detailsElement.appendChild( summaryElem ); const propsContainer = document.createElement( 'div' ); propsContainer.className = 'properties-list'; // Adjust padding calculation if needed, ensure it's a number before adding const summaryPaddingLeft = parseFloat( summaryElem.style.paddingLeft ) || 0; propsContainer.style.paddingLeft = `${summaryPaddingLeft + 20}px`; // Indent further 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 gridContainer.appendChild( createRendererPropertiesColumn( props ) ); gridContainer.appendChild( createRendererStatsColumn( info ) ); propsContainer.appendChild( gridContainer ); } else { propsContainer.textContent = 'No properties available.'; } detailsElement.appendChild( propsContainer ); // Add click handler for scroll to canvas button const scrollBtn = detailsElement.querySelector( '.scroll-to-canvas-btn' ); if ( scrollBtn ) { scrollBtn.addEventListener( 'click', ( event ) => { event.preventDefault(); event.stopPropagation(); scrollToCanvas( obj.uuid ); } ); } container.appendChild( detailsElement ); // Append details to the main container } // Function to render an object and its children function renderObject( obj, container, level = 0, parentInvisible = false ) { 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 ); // Apply opacity for invisible objects or if parent is invisible if ( obj.visible === false || parentInvisible ) { elem.style.opacity = '0.5'; } 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 && object.name !== '__THREE_DEVTOOLS_HIGHLIGHT__' ) { 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; // Add mouseenter handler to request object details and highlight in 3D elem.addEventListener( 'mouseenter', () => { requestObjectDetails( obj.uuid ); // Only highlight if object and all parents are visible if ( obj.visible !== false && ! parentInvisible ) { requestObjectHighlight( obj.uuid ); } } ); // Add mouseleave handler to remove 3D highlight elem.addEventListener( 'mouseleave', () => { requestObjectUnhighlight(); } ); 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 && child.name !== '__THREE_DEVTOOLS_HIGHLIGHT__' ) .sort( ( a, b ) => { const getTypeOrder = ( obj ) => { if ( obj.isCamera ) return 1; if ( obj.isLight ) return 2; if ( obj.isGroup ) return 3; if ( obj.isMesh ) return 4; return 5; }; const aOrder = getTypeOrder( a ); const bOrder = getTypeOrder( b ); return aOrder - bOrder; } ); // Render each child children.forEach( child => { renderObject( child, childContainer, level + 1, parentInvisible || obj.visible === false ); } ); } } // Build the static DOM shell (called once) function initUI() { const container = document.getElementById( 'scene-tree' ); const header = document.createElement( 'div' ); header.className = 'header'; header.style.display = 'flex'; header.style.justifyContent = 'space-between'; const miscSpan = document.createElement( 'span' ); miscSpan.innerHTML = '+'; const manifest = chrome.runtime.getManifest(); const manifestVersionSpan = document.createElement( 'span' ); manifestVersionSpan.textContent = `${manifest.version}`; manifestVersionSpan.style.opacity = '0.5'; header.appendChild( miscSpan ); header.appendChild( manifestVersionSpan ); container.appendChild( header ); const sectionsContainer = document.createElement( 'div' ); sectionsContainer.className = 'sections-container'; container.appendChild( sectionsContainer ); renderersSection = document.createElement( 'div' ); renderersSection.className = 'section'; renderersSection.style.display = 'none'; sectionsContainer.appendChild( renderersSection ); scenesSection = document.createElement( 'div' ); scenesSection.className = 'section'; scenesSection.style.display = 'none'; sectionsContainer.appendChild( scenesSection ); } // Update only the renderers section function updateRenderers() { if ( state.renderers.size > 0 ) { renderersSection.style.display = ''; renderersSection.innerHTML = '

Renderers

'; state.renderers.forEach( renderer => { renderRenderer( renderer, renderersSection ); } ); } else { renderersSection.style.display = 'none'; } } // Rebuild the scene tree only when dirty function updateSceneTree() { if ( ! sceneDirty ) return; sceneDirty = false; if ( state.scenes.size > 0 ) { scenesSection.style.display = ''; scenesSection.innerHTML = '

Scenes

'; state.scenes.forEach( scene => { renderObject( scene, scenesSection ); } ); } else { scenesSection.style.display = 'none'; } } // Create floating details panel function createFloatingPanel() { if ( floatingPanel ) return floatingPanel; floatingPanel = document.createElement( 'div' ); floatingPanel.className = 'floating-details'; document.body.appendChild( floatingPanel ); return floatingPanel; } // Show floating details panel function showFloatingDetails( objectData ) { const panel = createFloatingPanel(); // Clear previous content panel.innerHTML = ''; if ( objectData.position ) { panel.appendChild( createVectorRow( 'Position', objectData.position ) ); } if ( objectData.rotation ) { panel.appendChild( createVectorRow( 'Rotation', objectData.rotation ) ); } if ( objectData.scale ) { panel.appendChild( createVectorRow( 'Scale', objectData.scale ) ); } // Position panel near mouse updateFloatingPanelPosition(); // Show panel panel.classList.add( 'visible' ); } // Update floating panel position function updateFloatingPanelPosition() { if ( ! floatingPanel || ! floatingPanel.classList.contains( 'visible' ) ) return; const offset = 15; // Offset from cursor let x = mousePosition.x + offset; let y = mousePosition.y + offset; // Prevent panel from going off-screen const panelRect = floatingPanel.getBoundingClientRect(); const maxX = window.innerWidth - panelRect.width - 10; const maxY = window.innerHeight - panelRect.height - 10; if ( x > maxX ) x = mousePosition.x - panelRect.width - offset; if ( y > maxY ) y = mousePosition.y - panelRect.height - offset; floatingPanel.style.left = `${Math.max( 10, x )}px`; floatingPanel.style.top = `${Math.max( 10, y )}px`; } // Track mouse position document.addEventListener( 'mousemove', ( event ) => { mousePosition.x = event.clientX; mousePosition.y = event.clientY; updateFloatingPanelPosition(); } ); // Hide panel when mouse leaves the tree area document.addEventListener( 'mouseover', ( event ) => { if ( floatingPanel && ! event.target.closest( '.tree-item' ) ) { floatingPanel.classList.remove( 'visible' ); } } ); // Initial UI setup initUI();