import { Raycaster, Vector2, BoxHelper, error, warn } from 'three/webgpu'; import { Extension } from 'three/addons/inspector/Extension.js'; import { TSLGraphLoader } from './TSLGraphLoader.js'; const HOST_SOURCE = 'tsl-graph-host'; const EDITOR_SOURCE = 'tsl-graph-editor'; const _resposeByCommand = { 'tsl:command:get-code': 'tsl:response:get-code', 'tsl:command:set-root-material': 'tsl:response:set-root-material', 'tsl:command:get-graph': 'tsl:response:get-graph', 'tsl:command:load': 'tsl:response:load', 'tsl:command:clear-graph': 'tsl:response:clear-graph' }; const _refMaterials = new WeakMap(); class TSLGraphEditor extends Extension { constructor( options = {} ) { super( 'TSL Graph', options ); const editorUrl = new URL( 'https://www.tsl-graph.xyz/editor/standalone' ); editorUrl.searchParams.set( 'graphs', 'material' ); editorUrl.searchParams.set( 'targetOrigin', '*' ); // UI Setup this.content.style.display = 'flex'; this.content.style.flexDirection = 'column'; this.content.style.position = 'relative'; const headerDiv = document.createElement( 'div' ); headerDiv.style.padding = '4px'; headerDiv.style.backgroundColor = 'var(--profiler-header-bg, #2a2a33aa)'; headerDiv.style.borderBottom = '1px solid var(--profiler-border, #4a4a5a)'; headerDiv.style.display = 'flex'; headerDiv.style.justifyContent = 'center'; headerDiv.style.gap = '4px'; headerDiv.style.position = 'relative'; const importBtn = document.createElement( 'button' ); importBtn.innerHTML = ''; importBtn.className = 'panel-action-btn'; importBtn.title = 'Import'; importBtn.style.padding = '5px 8px'; importBtn.onclick = () => this._importData(); const exportBtn = document.createElement( 'button' ); exportBtn.innerHTML = ''; exportBtn.className = 'panel-action-btn'; exportBtn.title = 'Export'; exportBtn.style.padding = '5px 8px'; exportBtn.onclick = () => this._exportData(); const manageBtn = document.createElement( 'button' ); manageBtn.innerHTML = ''; manageBtn.className = 'panel-action-btn'; manageBtn.title = 'Saved Materials'; manageBtn.style.padding = '5px 8px'; manageBtn.onclick = () => this._showManagerModal(); const autoIdBtn = document.createElement( 'button' ); autoIdBtn.innerHTML = ''; autoIdBtn.className = 'panel-action-btn'; autoIdBtn.title = 'Auto-Generate Graph ID'; autoIdBtn.style.padding = '5px 8px'; autoIdBtn.style.position = 'absolute'; autoIdBtn.style.right = '4px'; autoIdBtn.style.top = '4px'; this.autoGraphId = false; autoIdBtn.onclick = () => { this.autoGraphId = ! this.autoGraphId; if ( this.autoGraphId ) { autoIdBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.1)'; autoIdBtn.style.color = '#fff'; } else { autoIdBtn.style.backgroundColor = ''; autoIdBtn.style.color = ''; } }; headerDiv.appendChild( importBtn ); headerDiv.appendChild( exportBtn ); headerDiv.appendChild( manageBtn ); headerDiv.appendChild( autoIdBtn ); this.content.appendChild( headerDiv ); this.iframe = document.createElement( 'iframe' ); this.iframe.style.width = '100%'; this.iframe.style.height = '100%'; this.iframe.style.border = 'none'; this.iframe.src = editorUrl.toString(); this.editorOrigin = new URL( this.iframe.src ).origin; this.content.appendChild( this.iframe ); this.material = null; this.uniforms = null; this.isReady = false; this._codeData = null; this._codeSaveTimeout = null; this._pending = new Map(); this._resolveReady = null; this._editorReady = new Promise( ( resolve ) => { this._resolveReady = resolve; } ); window.addEventListener( 'message', this.onMessage.bind( this ) ); } get hasGraphs() { return TSLGraphLoader.hasGraphs; } _initPicker( inspector ) { const renderer = inspector.getRenderer(); let boundingBox = null; const raycaster = new Raycaster(); const pointer = new Vector2(); const removeBoundingBox = () => { if ( boundingBox ) { boundingBox.removeFromParent(); boundingBox.dispose(); boundingBox = null; } }; this.addEventListener( 'change', ( { material } ) => { if ( material === null ) { removeBoundingBox(); } } ); this.addEventListener( 'remove', ( { graphId } ) => { const frame = inspector.getFrame(); const scene = frame && frame.renders.length > 0 ? frame.renders[ 0 ].scene : null; if ( scene ) { scene.traverse( ( object ) => { if ( object.material && object.material.userData && object.material.userData.graphId === graphId ) { this.restoreMaterial( object.material ); } } ); } } ); const pointerDownPosition = new Vector2(); renderer.domElement.addEventListener( 'pointerdown', ( e ) => { pointerDownPosition.set( e.clientX, e.clientY ); } ); renderer.domElement.addEventListener( 'pointerup', ( e ) => { const frame = inspector.getFrame(); for ( const render of frame.renders ) { const scene = render.scene; if ( scene.isScene !== true ) continue; const camera = render.camera; if ( pointerDownPosition.distanceTo( pointer.set( e.clientX, e.clientY ) ) > 2 ) return; const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ( ( e.clientX - rect.left ) / rect.width ) * 2 - 1; pointer.y = - ( ( e.clientY - rect.top ) / rect.height ) * 2 + 1; raycaster.setFromCamera( pointer, camera ); const intersects = raycaster.intersectObjects( scene.children, true ); let graphMaterial = null; if ( intersects.length > 0 ) { for ( const intersect of intersects ) { const object = intersect.object; const material = object.material; if ( material && material.isNodeMaterial ) { removeBoundingBox(); boundingBox = new BoxHelper( object, 0xffff00 ); scene.add( boundingBox ); graphMaterial = material; } if ( object.isMesh || object.isSprite ) { break; } } } this.setMaterial( graphMaterial ); } } ); } apply( scene ) { const loader = new TSLGraphLoader(); const applier = loader.parse( TSLGraphLoader.getCodes() ); applier.apply( scene ); return this; } restoreMaterial( material ) { material.copy( new material.constructor() ); material.needsUpdate = true; } init( inspector ) { this._initPicker( inspector ); } async setMaterial( material ) { if ( this.material === material ) return; await this._setMaterial( material ); this.dispatchEvent( { type: 'change', material } ); } async loadGraph( graphData ) { await this.command( 'load', { graphData } ); } async command( type, payload ) { type = 'tsl:command:' + type; await this._editorReady; const requestId = this._makeRequestId(); const expectedType = _resposeByCommand[ type ]; return new Promise( ( resolve, reject ) => { const timer = window.setTimeout( () => { if ( ! this._pending.has( requestId ) ) return; this._pending.delete( requestId ); reject( new Error( `Timeout for ${type}` ) ); }, 5000 ); this._pending.set( requestId, { expectedType, resolve, reject, timer } ); const message = { source: HOST_SOURCE, type, requestId }; if ( payload !== undefined ) message.payload = payload; this._post( message ); } ); } async getCode() { return this.command( 'get-code' ); } async getTSLFunction() { const graphLoader = new TSLGraphLoader(); const applier = graphLoader.parse( await this.getCode() ); return applier.tslGraphFns[ 'tslGraph' ]; } async getGraph() { return ( await this.command( 'get-graph' ) ).graphData; } async onResponse( /*type, payload*/ ) { } async onEvent( type, payload ) { if ( type === 'ready' ) { if ( ! this.isReady ) { this.isReady = true; this._resolveReady(); } } else if ( type === 'graph-changed' ) { if ( this.material === null ) return; await this._updateMaterial(); const graphData = await this.getGraph(); const graphId = this.material.userData.graphId; TSLGraphLoader.setGraph( graphId, graphData ); } else if ( type === 'uniforms-changed' ) { this._updateUniforms( payload.uniforms ); } } async onMessage( event ) { if ( event.origin !== this.editorOrigin ) return; if ( ! this._isEditorMessage( event.data ) ) return; const msg = event.data; if ( msg.requestId && msg.type.startsWith( 'tsl:response:' ) ) { const waiter = this._pending.get( msg.requestId ); if ( ! waiter ) return; if ( msg.type !== waiter.expectedType ) return; this._pending.delete( msg.requestId ); window.clearTimeout( waiter.timer ); if ( msg.error ) waiter.reject( new Error( msg.error ) ); else waiter.resolve( msg.payload ); this.onResponse( msg.type.substring( 'tsl:response:'.length ), msg.payload ); } else if ( msg.type.startsWith( 'tsl:event:' ) ) { this.onEvent( msg.type.substring( 'tsl:event:'.length ), msg.payload ); } } async _setMaterial( material ) { if ( ! material ) { this.material = null; this.materialDefault = null; this.uniforms = null; await this.command( 'clear-graph' ); return; } if ( material.isNodeMaterial !== true ) { error( 'TSLGraphEditor: "Material" needs be a "NodeMaterial".' ); return; } if ( material.userData.graphId === undefined ) { if ( this.autoGraphId ) { material.userData.graphId = material.name || 'id:' + material.id; } else { warn( 'TSLGraphEditor: "NodeMaterial" has no graphId. Set a "graphId" for the material in "material.userData.graphId".' ); return; } } let materialDefault = _refMaterials.get( material ); if ( materialDefault === undefined ) { //materialDefault = material.clone(); materialDefault = new material.constructor(); materialDefault.userData = material.userData; _refMaterials.set( material, materialDefault ); } this.material = material; this.materialDefault = materialDefault; this.uniforms = null; const graphData = TSLGraphLoader.getGraph( this.material.userData.graphId ); if ( graphData ) { await this.loadGraph( graphData ); } else { await this.command( 'clear-graph' ); await this.command( 'set-root-material', { materialType: this._getGraphType( this.material ) } ); } } _getGraphType( material ) { if ( material.isMeshPhysicalNodeMaterial ) return 'material/physical'; if ( material.isMeshStandardNodeMaterial ) return 'material/standard'; if ( material.isMeshPhongNodeMaterial ) return 'material/phong'; if ( material.isMeshBasicNodeMaterial ) return 'material/basic'; if ( material.isSpriteNodeMaterial ) return 'material/sprite'; return 'material/node'; } _showManagerModal() { const overlay = document.createElement( 'div' ); overlay.style.position = 'absolute'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; overlay.style.zIndex = '100'; overlay.style.display = 'flex'; overlay.style.justifyContent = 'center'; overlay.style.alignItems = 'center'; overlay.onclick = ( e ) => { if ( e.target === overlay ) { this.content.removeChild( overlay ); } }; const modal = document.createElement( 'div' ); modal.style.width = '80%'; modal.style.maxWidth = '500px'; modal.style.height = '400px'; modal.style.backgroundColor = 'var(--profiler-bg, #1e1e24f5)'; modal.style.border = '1px solid var(--profiler-border, #4a4a5a)'; modal.style.borderRadius = '8px'; modal.style.display = 'flex'; modal.style.flexDirection = 'column'; const header = document.createElement( 'div' ); header.style.padding = '15px'; header.style.borderBottom = '1px solid var(--profiler-border, #4a4a5a)'; header.style.display = 'flex'; header.style.justifyContent = 'space-between'; header.style.alignItems = 'center'; header.style.gap = '15px'; const filterInput = document.createElement( 'input' ); filterInput.type = 'text'; filterInput.className = 'console-filter-input'; filterInput.placeholder = 'Filter...'; filterInput.style.flex = '1'; const closeBtn = document.createElement( 'button' ); closeBtn.innerHTML = '✕'; closeBtn.style.background = 'transparent'; closeBtn.style.border = 'none'; closeBtn.style.color = 'var(--text-secondary, #9a9aab)'; closeBtn.style.cursor = 'pointer'; closeBtn.style.fontSize = '16px'; closeBtn.onmouseover = () => closeBtn.style.color = 'var(--text-primary, #e0e0e0)'; closeBtn.onmouseout = () => closeBtn.style.color = 'var(--text-secondary, #9a9aab)'; closeBtn.onclick = () => this.content.removeChild( overlay ); header.appendChild( filterInput ); header.appendChild( closeBtn ); const codes = this.getCodes(); const materialIds = Object.keys( codes.materials || {} ); if ( materialIds.length === 0 ) { const listContainer = document.createElement( 'div' ); listContainer.style.padding = '10px'; listContainer.style.flex = '1'; const emptyMsg = document.createElement( 'div' ); emptyMsg.textContent = 'No saved materials found.'; emptyMsg.style.color = 'var(--text-secondary, #9a9aab)'; emptyMsg.style.padding = '10px'; emptyMsg.style.textAlign = 'center'; emptyMsg.style.fontFamily = 'var(--font-family, sans-serif)'; emptyMsg.style.fontSize = '12px'; listContainer.appendChild( emptyMsg ); modal.appendChild( header ); modal.appendChild( listContainer ); } else { const listHeaderContainer = document.createElement( 'div' ); listHeaderContainer.style.display = 'grid'; listHeaderContainer.style.gridTemplateColumns = '1fr 80px'; listHeaderContainer.style.gap = '10px'; listHeaderContainer.style.padding = '10px 15px 8px 15px'; listHeaderContainer.style.borderBottom = '1px solid var(--profiler-border, #4a4a5a)'; listHeaderContainer.style.backgroundColor = 'var(--profiler-bg, #1e1e24f5)'; listHeaderContainer.style.fontFamily = 'var(--font-family, sans-serif)'; listHeaderContainer.style.fontSize = '11px'; listHeaderContainer.style.fontWeight = 'bold'; listHeaderContainer.style.textTransform = 'uppercase'; listHeaderContainer.style.letterSpacing = '0.5px'; listHeaderContainer.style.color = 'var(--text-secondary, #9a9aab)'; listHeaderContainer.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'; listHeaderContainer.style.zIndex = '1'; const col1 = document.createElement( 'div' ); col1.textContent = 'Material Name / ID'; const col2 = document.createElement( 'div' ); col2.textContent = 'Action'; col2.style.textAlign = 'right'; listHeaderContainer.appendChild( col1 ); listHeaderContainer.appendChild( col2 ); const scrollWrapper = document.createElement( 'div' ); scrollWrapper.style.flex = '1'; scrollWrapper.style.overflowY = 'auto'; scrollWrapper.style.padding = '0'; const rows = []; for ( const id of materialIds ) { const itemRow = document.createElement( 'div' ); itemRow.style.display = 'grid'; itemRow.style.gridTemplateColumns = '1fr 80px'; itemRow.style.gap = '10px'; itemRow.style.alignItems = 'center'; itemRow.style.padding = '8px 15px'; itemRow.style.borderBottom = '1px solid rgba(74, 74, 90, 0.4)'; itemRow.onmouseover = () => itemRow.style.backgroundColor = 'rgba(255, 255, 255, 0.04)'; itemRow.onmouseout = () => itemRow.style.backgroundColor = 'transparent'; const nameSpan = document.createElement( 'span' ); const materialData = codes.materials[ id ]; const materialName = materialData.name || id; nameSpan.textContent = materialName; nameSpan.style.fontFamily = 'var(--font-mono, monospace)'; nameSpan.style.fontSize = '12px'; nameSpan.style.color = 'var(--text-primary, #e0e0e0)'; nameSpan.style.userSelect = 'all'; nameSpan.style.overflow = 'hidden'; nameSpan.style.textOverflow = 'ellipsis'; nameSpan.style.whiteSpace = 'nowrap'; const actionContainer = document.createElement( 'div' ); actionContainer.style.textAlign = 'right'; const removeBtn = document.createElement( 'button' ); removeBtn.textContent = 'Remove'; removeBtn.style.background = 'rgba(244, 67, 54, 0.1)'; removeBtn.style.border = '1px solid var(--color-red, #f44336)'; removeBtn.style.color = 'var(--color-red, #f44336)'; removeBtn.style.borderRadius = '4px'; removeBtn.style.padding = '4px 8px'; removeBtn.style.cursor = 'pointer'; removeBtn.style.fontSize = '11px'; removeBtn.onmouseover = () => removeBtn.style.background = 'rgba(244, 67, 54, 0.2)'; removeBtn.onmouseout = () => removeBtn.style.background = 'rgba(244, 67, 54, 0.1)'; actionContainer.appendChild( removeBtn ); itemRow.appendChild( nameSpan ); itemRow.appendChild( actionContainer ); scrollWrapper.appendChild( itemRow ); rows.push( { element: itemRow, text: materialName.toLowerCase() } ); removeBtn.onclick = async () => { delete codes.materials[ id ]; TSLGraphLoader.setCodes( codes ); TSLGraphLoader.deleteGraph( id ); scrollWrapper.removeChild( itemRow ); const index = rows.findIndex( r => r.element === itemRow ); if ( index > - 1 ) rows.splice( index, 1 ); if ( rows.length === 0 ) { modal.removeChild( listHeaderContainer ); modal.removeChild( scrollWrapper ); const listContainer = document.createElement( 'div' ); listContainer.style.padding = '10px'; listContainer.style.flex = '1'; const emptyMsg = document.createElement( 'div' ); emptyMsg.textContent = 'No saved materials found.'; emptyMsg.style.color = 'var(--text-secondary, #9a9aab)'; emptyMsg.style.padding = '10px'; emptyMsg.style.textAlign = 'center'; emptyMsg.style.fontFamily = 'var(--font-family, sans-serif)'; emptyMsg.style.fontSize = '12px'; listContainer.appendChild( emptyMsg ); modal.appendChild( listContainer ); } _refMaterials.delete( this.material ); if ( this.material && this.material.userData.graphId === id ) { this.restoreMaterial( this.material ); await this.setMaterial( null ); } this.dispatchEvent( { type: 'remove', graphId: id } ); }; } filterInput.addEventListener( 'input', ( e ) => { const term = e.target.value.toLowerCase(); for ( const row of rows ) { row.element.style.display = row.text.includes( term ) ? 'grid' : 'none'; } } ); modal.appendChild( header ); modal.appendChild( listHeaderContainer ); modal.appendChild( scrollWrapper ); } overlay.appendChild( modal ); this.content.appendChild( overlay ); } _exportData() { const codes = this.getCodes(); const materialIds = Object.keys( codes.materials || {} ); const exportPayload = { codes: codes, graphs: {} }; for ( const id of materialIds ) { const graphData = TSLGraphLoader.getGraph( id ); if ( graphData ) { exportPayload.graphs[ id ] = graphData; } } const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent( JSON.stringify( exportPayload, null, '\t' ) ); const downloadAnchorNode = document.createElement( 'a' ); downloadAnchorNode.setAttribute( 'href', dataStr ); downloadAnchorNode.setAttribute( 'download', 'tsl-graphs.json' ); document.body.appendChild( downloadAnchorNode ); downloadAnchorNode.click(); downloadAnchorNode.remove(); } _importData() { const fileInput = document.createElement( 'input' ); fileInput.type = 'file'; fileInput.accept = '.json'; fileInput.onchange = e => { const file = e.target.files[ 0 ]; if ( ! file ) return; const reader = new FileReader(); reader.onload = async ( event ) => { try { const importedData = TSLGraphLoader.setGraphs( JSON.parse( event.target.result ) ); this._codeData = importedData.codes; // Reload visual state if we have a material open if ( this.material ) { // refresh material await this._setMaterial( this.material ); } } catch ( err ) { error( 'TSLGraphEditor: Failed to parse or load imported JSON.', err ); } }; reader.readAsText( file ); }; fileInput.click(); } getCodes() { if ( this._codeData === null ) { this._codeData = TSLGraphLoader.getCodes(); } return this._codeData; } _saveCode() { const graphId = this.material.userData.graphId; clearTimeout( this._codeSaveTimeout ); this._codeSaveTimeout = setTimeout( async () => { if ( this.material === null || graphId !== this.material.userData.graphId ) return; const codes = this.getCodes(); const codeData = await this.getCode(); codes.materials[ graphId ] = codeData.material; TSLGraphLoader.setCodes( codes ); }, 1000 ); } _restoreMaterial() { this.material.copy( this.materialDefault ); } async _updateMaterial() { this._restoreMaterial(); const applyNodes = await this.getTSLFunction(); const { uniforms } = applyNodes( this.material ); this.uniforms = uniforms; this.material.needsUpdate = true; this._saveCode(); } _updateUniforms( uniforms ) { if ( this.uniforms === null ) return; for ( const uniform of uniforms ) { const uniformNode = this.uniforms[ uniform.name ]; const uniformType = uniform.uniformType; const value = uniform.value; if ( uniformType.startsWith( 'vec' ) ) { uniformNode.value.fromArray( value ); } else if ( uniformType.startsWith( 'color' ) ) { uniformNode.value.setHex( parseInt( value.slice( 1 ), 16 ) ); } else { uniformNode.value = value; } } this._saveCode(); } _isEditorMessage( value ) { if ( ! value || typeof value !== 'object' ) return false; return value.source === EDITOR_SOURCE && typeof value.type === 'string'; } _makeRequestId() { return `${Date.now()}-${Math.random().toString( 36 ).slice( 2, 10 )}`; } _post( message ) { if ( this.iframe.contentWindow ) { this.iframe.contentWindow.postMessage( message, this.editorOrigin ); } } } export default TSLGraphEditor;