NodeEditor.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826
  1. import * as THREE from 'three';
  2. import * as Nodes from 'three/nodes';
  3. import { Canvas, CircleMenu, ButtonInput, StringInput, ContextMenu, Tips, Search, Loader, Node, TreeViewNode, TreeViewInput, Element } from 'flow';
  4. import { FileEditor } from './editors/FileEditor.js';
  5. import { exportJSON } from './NodeEditorUtils.js';
  6. import { init, ClassLib, getNodeEditorClass, getNodeList } from './NodeEditorLib.js';
  7. import { SplitscreenManager } from './SplitscreenManager.js';
  8. init();
  9. Element.icons.unlink = 'ti ti-unlink';
  10. export class NodeEditor extends THREE.EventDispatcher {
  11. constructor( scene = null, renderer = null, composer = null ) {
  12. super();
  13. const domElement = document.createElement( 'flow' );
  14. const canvas = new Canvas();
  15. domElement.append( canvas.dom );
  16. this.scene = scene;
  17. this.renderer = renderer;
  18. const { global } = Nodes;
  19. global.set( 'THREE', THREE );
  20. global.set( 'TSL', Nodes );
  21. global.set( 'scene', scene );
  22. global.set( 'renderer', renderer );
  23. global.set( 'composer', composer );
  24. this.nodeClasses = [];
  25. this.canvas = canvas;
  26. this.domElement = domElement;
  27. this._preview = false;
  28. this._splitscreen = false;
  29. this.search = null;
  30. this.menu = null;
  31. this.previewMenu = null;
  32. this.nodesContext = null;
  33. this.examplesContext = null;
  34. this._initSplitview();
  35. this._initUpload();
  36. this._initTips();
  37. this._initMenu();
  38. this._initSearch();
  39. this._initNodesContext();
  40. this._initExamplesContext();
  41. this._initShortcuts();
  42. this._initParams();
  43. }
  44. setSize( width, height ) {
  45. this.canvas.setSize( width, height );
  46. return this;
  47. }
  48. centralizeNode( node ) {
  49. const canvas = this.canvas;
  50. const nodeRect = node.dom.getBoundingClientRect();
  51. node.setPosition(
  52. ( ( canvas.width / 2 ) - canvas.scrollLeft ) - nodeRect.width,
  53. ( ( canvas.height / 2 ) - canvas.scrollTop ) - nodeRect.height
  54. );
  55. return this;
  56. }
  57. add( node ) {
  58. const onRemove = () => {
  59. node.removeEventListener( 'remove', onRemove );
  60. node.setEditor( null );
  61. };
  62. node.setEditor( this );
  63. node.addEventListener( 'remove', onRemove );
  64. this.canvas.add( node );
  65. this.dispatchEvent( { type: 'add', node } );
  66. return this;
  67. }
  68. get nodes() {
  69. return this.canvas.nodes;
  70. }
  71. set preview( value ) {
  72. if ( this._preview === value ) return;
  73. if ( value ) {
  74. this._wasSplitscreen = this.splitscreen;
  75. this.splitscreen = false;
  76. this.menu.dom.remove();
  77. this.canvas.dom.remove();
  78. this.search.dom.remove();
  79. this.domElement.append( this.previewMenu.dom );
  80. } else {
  81. this.canvas.focusSelected = false;
  82. this.domElement.append( this.menu.dom );
  83. this.domElement.append( this.canvas.dom );
  84. this.domElement.append( this.search.dom );
  85. this.previewMenu.dom.remove();
  86. if (this._wasSplitscreen == true) {
  87. this.splitscreen = true;
  88. }
  89. }
  90. this._preview = value;
  91. }
  92. get preview() {
  93. return this._preview;
  94. }
  95. set splitscreen( value ) {
  96. if ( this._splitscreen === value ) return;
  97. this.splitview.setSplitview( value );
  98. this._splitscreen = value;
  99. }
  100. get splitscreen() {
  101. return this._splitscreen;
  102. }
  103. newProject() {
  104. const canvas = this.canvas;
  105. canvas.clear();
  106. canvas.scrollLeft = 0;
  107. canvas.scrollTop = 0;
  108. canvas.zoom = 1;
  109. this.dispatchEvent( { type: 'new' } );
  110. }
  111. async loadURL( url ) {
  112. const loader = new Loader( Loader.OBJECTS );
  113. const json = await loader.load( url, ClassLib );
  114. this.loadJSON( json );
  115. }
  116. loadJSON( json ) {
  117. const canvas = this.canvas;
  118. canvas.clear();
  119. canvas.deserialize( json );
  120. for ( const node of canvas.nodes ) {
  121. this.add( node );
  122. }
  123. this.dispatchEvent( { type: 'load' } );
  124. }
  125. _initSplitview() {
  126. this.splitview = new SplitscreenManager( this );
  127. }
  128. _initUpload() {
  129. const canvas = this.canvas;
  130. canvas.onDrop( () => {
  131. for ( const item of canvas.droppedItems ) {
  132. const { relativeClientX, relativeClientY } = canvas;
  133. const file = item.getAsFile();
  134. const reader = new FileReader();
  135. reader.onload = () => {
  136. const fileEditor = new FileEditor( reader.result, file.name );
  137. fileEditor.setPosition(
  138. relativeClientX - ( fileEditor.getWidth() / 2 ),
  139. relativeClientY - 20
  140. );
  141. this.add( fileEditor );
  142. };
  143. reader.readAsArrayBuffer( file );
  144. }
  145. } );
  146. }
  147. _initTips() {
  148. this.tips = new Tips();
  149. this.domElement.append( this.tips.dom );
  150. }
  151. _initMenu() {
  152. const menu = new CircleMenu();
  153. const previewMenu = new CircleMenu();
  154. menu.setAlign( 'top left' );
  155. previewMenu.setAlign( 'top left' );
  156. const previewButton = new ButtonInput().setIcon( 'ti ti-brand-threejs' ).setToolTip( 'Preview' );
  157. const splitscreenButton = new ButtonInput().setIcon( 'ti ti-layout-sidebar-right-expand' ).setToolTip( 'Splitscreen' );
  158. const menuButton = new ButtonInput().setIcon( 'ti ti-apps' ).setToolTip( 'Add' );
  159. const examplesButton = new ButtonInput().setIcon( 'ti ti-file-symlink' ).setToolTip( 'Examples' );
  160. const newButton = new ButtonInput().setIcon( 'ti ti-file' ).setToolTip( 'New' );
  161. const openButton = new ButtonInput().setIcon( 'ti ti-upload' ).setToolTip( 'Open' );
  162. const saveButton = new ButtonInput().setIcon( 'ti ti-download' ).setToolTip( 'Save' );
  163. const editorButton = new ButtonInput().setIcon( 'ti ti-subtask' ).setToolTip( 'Editor' );
  164. previewButton.onClick( () => this.preview = true );
  165. editorButton.onClick( () => this.preview = false );
  166. splitscreenButton.onClick( () => {
  167. this.splitscreen = !this.splitscreen;
  168. splitscreenButton.setIcon(this.splitscreen ? 'ti ti-layout-sidebar-right-collapse' : 'ti ti-layout-sidebar-right-expand');
  169. });
  170. menuButton.onClick( () => this.nodesContext.open() );
  171. examplesButton.onClick( () => this.examplesContext.open() );
  172. newButton.onClick( () => {
  173. if ( confirm( 'Are you sure?' ) === true ) {
  174. this.newProject();
  175. }
  176. } );
  177. openButton.onClick( () => {
  178. const input = document.createElement( 'input' );
  179. input.type = 'file';
  180. input.onchange = e => {
  181. const file = e.target.files[ 0 ];
  182. const reader = new FileReader();
  183. reader.readAsText( file, 'UTF-8' );
  184. reader.onload = readerEvent => {
  185. const loader = new Loader( Loader.OBJECTS );
  186. const json = loader.parse( JSON.parse( readerEvent.target.result ), ClassLib );
  187. this.loadJSON( json );
  188. };
  189. };
  190. input.click();
  191. } );
  192. saveButton.onClick( () => {
  193. exportJSON( this.canvas.toJSON(), 'node_editor' );
  194. } );
  195. menu.add( previewButton )
  196. .add( splitscreenButton )
  197. .add( newButton )
  198. .add( examplesButton )
  199. .add( openButton )
  200. .add( saveButton )
  201. .add( menuButton );
  202. previewMenu.add( editorButton );
  203. this.domElement.appendChild(menu.dom);
  204. this.menu = menu;
  205. this.previewMenu = previewMenu;
  206. }
  207. _initExamplesContext() {
  208. const context = new ContextMenu();
  209. //**************//
  210. // MAIN
  211. //**************//
  212. const onClickExample = async ( button ) => {
  213. this.examplesContext.hide();
  214. const filename = button.getExtra();
  215. this.loadURL( `./examples/${filename}.json` );
  216. };
  217. const addExamples = ( category, names ) => {
  218. const subContext = new ContextMenu();
  219. for ( const name of names ) {
  220. const filename = name.replaceAll( ' ', '-' ).toLowerCase();
  221. subContext.add( new ButtonInput( name )
  222. .setIcon( 'ti ti-file-symlink' )
  223. .onClick( onClickExample )
  224. .setExtra( category.toLowerCase() + '/' + filename )
  225. );
  226. }
  227. context.add( new ButtonInput( category ), subContext );
  228. return subContext;
  229. };
  230. //**************//
  231. // EXAMPLES
  232. //**************//
  233. addExamples( 'Universal', [
  234. 'Teapot',
  235. 'Matcap',
  236. 'Fresnel'
  237. ] );
  238. if ( this.renderer.isWebGLRenderer ) {
  239. addExamples( 'WebGL', [
  240. 'Car'
  241. ] );
  242. context.add( new ButtonInput( 'WebGPU Version' ).onClick( () => {
  243. if ( confirm( 'Are you sure?' ) === true ) {
  244. window.location.search = '?backend=webgpu';
  245. }
  246. } ) );
  247. } else if ( this.renderer.isWebGPURenderer ) {
  248. addExamples( 'WebGPU', [
  249. 'Particle'
  250. ] );
  251. context.add( new ButtonInput( 'WebGL Version' ).onClick( () => {
  252. if ( confirm( 'Are you sure?' ) === true ) {
  253. window.location.search = '';
  254. }
  255. } ) );
  256. }
  257. this.examplesContext = context;
  258. }
  259. _initShortcuts() {
  260. document.addEventListener( 'keydown', ( e ) => {
  261. if ( e.target === document.body ) {
  262. const key = e.key;
  263. if ( key === 'Tab' ) {
  264. this.search.inputDOM.focus();
  265. e.preventDefault();
  266. e.stopImmediatePropagation();
  267. } else if ( key === ' ' ) {
  268. this.preview = ! this.preview;
  269. } else if ( key === 'Delete' ) {
  270. if ( this.canvas.selected ) this.canvas.selected.dispose();
  271. } else if ( key === 'Escape' ) {
  272. this.canvas.select( null );
  273. }
  274. }
  275. } );
  276. }
  277. _initParams() {
  278. const urlParams = new URLSearchParams( window.location.search );
  279. const example = urlParams.get( 'example' ) || 'universal/teapot';
  280. this.loadURL( `./examples/${example}.json` );
  281. }
  282. addClass( nodeData ) {
  283. this.removeClass( nodeData );
  284. this.nodeClasses.push( nodeData );
  285. ClassLib[ nodeData.name ] = nodeData.nodeClass;
  286. return this;
  287. }
  288. removeClass( nodeData ) {
  289. const index = this.nodeClasses.indexOf( nodeData );
  290. if ( index !== - 1 ) {
  291. this.nodeClasses.splice( index, 1 );
  292. delete ClassLib[ nodeData.name ];
  293. }
  294. return this;
  295. }
  296. _initSearch() {
  297. const traverseNodeEditors = ( item ) => {
  298. if ( item.children ) {
  299. for ( const subItem of item.children ) {
  300. traverseNodeEditors( subItem );
  301. }
  302. } else {
  303. const button = new ButtonInput( item.name );
  304. button.setIcon( `ti ti-${item.icon}` );
  305. button.addEventListener( 'complete', async () => {
  306. const nodeClass = await getNodeEditorClass( item );
  307. const node = new nodeClass();
  308. this.add( node );
  309. this.centralizeNode( node );
  310. this.canvas.select( node );
  311. } );
  312. search.add( button );
  313. if ( item.tags !== undefined ) {
  314. search.setTag( button, item.tags );
  315. }
  316. }
  317. };
  318. const search = new Search();
  319. search.forceAutoComplete = true;
  320. search.onFilter( async () => {
  321. search.clear();
  322. const nodeList = await getNodeList();
  323. for ( const item of nodeList.nodes ) {
  324. traverseNodeEditors( item );
  325. }
  326. for ( const item of this.nodeClasses ) {
  327. traverseNodeEditors( item );
  328. }
  329. } );
  330. search.onSubmit( () => {
  331. if ( search.currentFiltered !== null ) {
  332. search.currentFiltered.button.dispatchEvent( new Event( 'complete' ) );
  333. }
  334. } );
  335. this.search = search;
  336. this.domElement.append( search.dom );
  337. }
  338. async _initNodesContext() {
  339. const context = new ContextMenu( this.canvas.canvas ).setWidth( 300 );
  340. let isContext = false;
  341. const contextPosition = {};
  342. const add = ( node ) => {
  343. context.hide();
  344. this.add( node );
  345. if ( isContext ) {
  346. node.setPosition(
  347. Math.round( contextPosition.x ),
  348. Math.round( contextPosition.y )
  349. );
  350. } else {
  351. this.centralizeNode( node );
  352. }
  353. this.canvas.select( node );
  354. isContext = false;
  355. };
  356. context.onContext( () => {
  357. isContext = true;
  358. const { relativeClientX, relativeClientY } = this.canvas;
  359. contextPosition.x = Math.round( relativeClientX );
  360. contextPosition.y = Math.round( relativeClientY );
  361. } );
  362. context.addEventListener( 'show', () => {
  363. reset();
  364. focus();
  365. } );
  366. //**************//
  367. // INPUTS
  368. //**************//
  369. const nodeButtons = [];
  370. let nodeButtonsVisible = [];
  371. let nodeButtonsIndex = - 1;
  372. const focus = () => requestAnimationFrame( () => search.inputDOM.focus() );
  373. const reset = () => {
  374. search.setValue( '', false );
  375. for ( const button of nodeButtons ) {
  376. button.setOpened( false ).setVisible( true ).setSelected( false );
  377. }
  378. };
  379. const node = new Node();
  380. context.add( node );
  381. const search = new StringInput().setPlaceHolder( 'Search...' ).setIcon( 'ti ti-list-search' );
  382. search.inputDOM.addEventListener( 'keydown', e => {
  383. const key = e.key;
  384. if ( key === 'ArrowDown' ) {
  385. const previous = nodeButtonsVisible[ nodeButtonsIndex ];
  386. if ( previous ) previous.setSelected( false );
  387. const current = nodeButtonsVisible[ nodeButtonsIndex = ( nodeButtonsIndex + 1 ) % nodeButtonsVisible.length ];
  388. if ( current ) current.setSelected( true );
  389. e.preventDefault();
  390. e.stopImmediatePropagation();
  391. } else if ( key === 'ArrowUp' ) {
  392. const previous = nodeButtonsVisible[ nodeButtonsIndex ];
  393. if ( previous ) previous.setSelected( false );
  394. const current = nodeButtonsVisible[ nodeButtonsIndex > 0 ? -- nodeButtonsIndex : ( nodeButtonsIndex = nodeButtonsVisible.length - 1 ) ];
  395. if ( current ) current.setSelected( true );
  396. e.preventDefault();
  397. e.stopImmediatePropagation();
  398. } else if ( key === 'Enter' ) {
  399. if ( nodeButtonsVisible[ nodeButtonsIndex ] !== undefined ) {
  400. nodeButtonsVisible[ nodeButtonsIndex ].dom.click();
  401. } else {
  402. context.hide();
  403. }
  404. e.preventDefault();
  405. e.stopImmediatePropagation();
  406. } else if ( key === 'Escape' ) {
  407. context.hide();
  408. }
  409. } );
  410. search.onChange( () => {
  411. const value = search.getValue().toLowerCase();
  412. if ( value.length === 0 ) return reset();
  413. nodeButtonsVisible = [];
  414. nodeButtonsIndex = 0;
  415. for ( const button of nodeButtons ) {
  416. const buttonLabel = button.getLabel().toLowerCase();
  417. button.setVisible( false ).setSelected( false );
  418. const visible = buttonLabel.indexOf( value ) !== - 1;
  419. if ( visible && button.children.length === 0 ) {
  420. nodeButtonsVisible.push( button );
  421. }
  422. }
  423. for ( const button of nodeButtonsVisible ) {
  424. let parent = button;
  425. while ( parent !== null ) {
  426. parent.setOpened( true ).setVisible( true );
  427. parent = parent.parent;
  428. }
  429. }
  430. if ( nodeButtonsVisible[ nodeButtonsIndex ] !== undefined ) {
  431. nodeButtonsVisible[ nodeButtonsIndex ].setSelected( true );
  432. }
  433. } );
  434. const treeView = new TreeViewInput();
  435. node.add( new Element().setHeight( 30 ).add( search ) );
  436. node.add( new Element().setHeight( 200 ).add( treeView ) );
  437. const addNodeEditorElement = ( nodeData ) => {
  438. const button = new TreeViewNode( nodeData.name );
  439. button.setIcon( `ti ti-${nodeData.icon}` );
  440. if ( nodeData.children === undefined ) {
  441. button.isNodeClass = true;
  442. button.onClick( async () => {
  443. const nodeClass = await getNodeEditorClass( nodeData );
  444. add( new nodeClass() );
  445. } );
  446. }
  447. if ( nodeData.tip ) {
  448. //button.setToolTip( item.tip );
  449. }
  450. nodeButtons.push( button );
  451. if ( nodeData.children ) {
  452. for ( const subItem of nodeData.children ) {
  453. const subButton = addNodeEditorElement( subItem );
  454. button.add( subButton );
  455. }
  456. }
  457. return button;
  458. };
  459. //
  460. const nodeList = await getNodeList();
  461. for ( const node of nodeList.nodes ) {
  462. const button = addNodeEditorElement( node );
  463. treeView.add( button );
  464. }
  465. this.nodesContext = context;
  466. }
  467. }