Animation.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. import { UIPanel, UIText, UIButton, UINumber } from './libs/ui.js';
  2. import { AnimationPathHelper } from 'three/addons/helpers/AnimationPathHelper.js';
  3. function Animation( editor ) {
  4. const signals = editor.signals;
  5. const strings = editor.strings;
  6. const mixer = editor.mixer;
  7. const container = new UIPanel();
  8. container.setId( 'animation' );
  9. container.dom.style.flexDirection = 'column';
  10. let panelHeight = 36;
  11. // Listen for resizer changes
  12. signals.animationPanelResized.add( function ( height ) {
  13. panelHeight = height;
  14. container.dom.style.height = height + 'px';
  15. signals.animationPanelChanged.dispatch( height );
  16. } );
  17. // Top bar - playback controls
  18. const controlsPanel = new UIPanel();
  19. controlsPanel.dom.style.padding = '6px 10px';
  20. controlsPanel.dom.style.borderBottom = '1px solid #ccc';
  21. controlsPanel.dom.style.display = 'flex';
  22. controlsPanel.dom.style.alignItems = 'center';
  23. controlsPanel.dom.style.justifyContent = 'center';
  24. controlsPanel.dom.style.gap = '6px';
  25. controlsPanel.dom.style.flexShrink = '0';
  26. container.add( controlsPanel );
  27. // SVG icons
  28. const playIcon = '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 1.5v9l7-4.5z" fill="currentColor"/></svg>';
  29. const pauseIcon = '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 1h3v10H2zM7 1h3v10H7z" fill="currentColor"/></svg>';
  30. const stopIcon = '<svg width="12" height="12" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" fill="currentColor"/></svg>';
  31. const playButton = new UIButton();
  32. playButton.dom.innerHTML = playIcon;
  33. playButton.dom.style.width = '24px';
  34. playButton.dom.style.height = '24px';
  35. playButton.dom.style.padding = '0';
  36. playButton.dom.style.borderRadius = '4px';
  37. playButton.dom.style.display = 'flex';
  38. playButton.dom.style.alignItems = 'center';
  39. playButton.dom.style.justifyContent = 'center';
  40. playButton.onClick( function () {
  41. if ( currentAction ) {
  42. if ( currentAction.paused ) {
  43. currentAction.paused = false;
  44. } else if ( ! currentAction.isRunning() ) {
  45. currentAction.reset();
  46. currentAction.play();
  47. }
  48. }
  49. } );
  50. controlsPanel.add( playButton );
  51. const pauseButton = new UIButton();
  52. pauseButton.dom.innerHTML = pauseIcon;
  53. pauseButton.dom.style.width = '24px';
  54. pauseButton.dom.style.height = '24px';
  55. pauseButton.dom.style.padding = '0';
  56. pauseButton.dom.style.borderRadius = '4px';
  57. pauseButton.dom.style.display = 'flex';
  58. pauseButton.dom.style.alignItems = 'center';
  59. pauseButton.dom.style.justifyContent = 'center';
  60. pauseButton.onClick( function () {
  61. if ( currentAction ) {
  62. currentAction.paused = true;
  63. }
  64. } );
  65. controlsPanel.add( pauseButton );
  66. const stopButton = new UIButton();
  67. stopButton.dom.innerHTML = stopIcon;
  68. stopButton.dom.style.width = '24px';
  69. stopButton.dom.style.height = '24px';
  70. stopButton.dom.style.padding = '0';
  71. stopButton.dom.style.borderRadius = '4px';
  72. stopButton.dom.style.display = 'flex';
  73. stopButton.dom.style.alignItems = 'center';
  74. stopButton.dom.style.justifyContent = 'center';
  75. stopButton.onClick( function () {
  76. if ( currentAction ) {
  77. currentAction.stop();
  78. }
  79. } );
  80. controlsPanel.add( stopButton );
  81. // Time display
  82. const timeDisplay = document.createElement( 'div' );
  83. timeDisplay.style.display = 'flex';
  84. timeDisplay.style.alignItems = 'center';
  85. timeDisplay.style.justifyContent = 'center';
  86. timeDisplay.style.gap = '4px';
  87. timeDisplay.style.height = '24px';
  88. timeDisplay.style.padding = '0 8px';
  89. timeDisplay.style.background = 'rgba(0,0,0,0.05)';
  90. timeDisplay.style.borderRadius = '4px';
  91. timeDisplay.style.fontFamily = 'monospace';
  92. timeDisplay.style.fontSize = '11px';
  93. controlsPanel.dom.appendChild( timeDisplay );
  94. const timeText = new UIText( '0.00' ).setWidth( '36px' );
  95. timeText.dom.style.textAlign = 'right';
  96. timeDisplay.appendChild( timeText.dom );
  97. const separator = new UIText( '/' );
  98. timeDisplay.appendChild( separator.dom );
  99. const durationText = new UIText( '0.00' ).setWidth( '36px' );
  100. timeDisplay.appendChild( durationText.dom );
  101. // Time Scale
  102. const mixerTimeScaleNumber = new UINumber( 1 ).setWidth( '60px' ).setRange( - 10, 10 );
  103. mixerTimeScaleNumber.onChange( function () {
  104. mixer.timeScale = mixerTimeScaleNumber.getValue();
  105. } );
  106. controlsPanel.add( new UIText( strings.getKey( 'sidebar/animations/timescale' ) ).setClass( 'Label' ) );
  107. controlsPanel.add( mixerTimeScaleNumber );
  108. // Timeline area with track rows
  109. const timelineArea = document.createElement( 'div' );
  110. timelineArea.style.flex = '1';
  111. timelineArea.style.display = 'flex';
  112. timelineArea.style.flexDirection = 'column';
  113. timelineArea.style.overflow = 'hidden';
  114. timelineArea.style.position = 'relative';
  115. container.dom.appendChild( timelineArea );
  116. // Scrollable track list
  117. const trackListContainer = document.createElement( 'div' );
  118. trackListContainer.style.flex = '1';
  119. trackListContainer.style.overflowY = 'auto';
  120. trackListContainer.style.overflowX = 'hidden';
  121. timelineArea.appendChild( trackListContainer );
  122. // Playhead (spans entire timeline area)
  123. const playhead = document.createElement( 'div' );
  124. playhead.style.position = 'absolute';
  125. playhead.style.top = '0';
  126. playhead.style.bottom = '0';
  127. playhead.style.width = '2px';
  128. playhead.style.background = '#f00';
  129. playhead.style.left = '150px'; // Start at timeline start (after labels)
  130. playhead.style.pointerEvents = 'none';
  131. playhead.style.zIndex = '10';
  132. timelineArea.appendChild( playhead );
  133. // Timeline scrubbing
  134. let isDragging = false;
  135. const labelWidth = 150;
  136. function updateTimeFromPosition( clientX ) {
  137. const rect = timelineArea.getBoundingClientRect();
  138. const timelineStart = labelWidth;
  139. const timelineWidth = rect.width - labelWidth;
  140. const x = Math.max( 0, Math.min( clientX - rect.left - timelineStart, timelineWidth ) );
  141. const percent = x / timelineWidth;
  142. if ( currentAction && currentClip ) {
  143. const time = percent * currentClip.duration;
  144. currentAction.play();
  145. currentAction.time = time;
  146. currentAction.paused = true;
  147. editor.mixer.update( 0 );
  148. }
  149. }
  150. timelineArea.addEventListener( 'mousedown', function ( event ) {
  151. const rect = timelineArea.getBoundingClientRect();
  152. if ( event.clientX - rect.left > labelWidth ) {
  153. event.preventDefault();
  154. isDragging = true;
  155. updateTimeFromPosition( event.clientX );
  156. }
  157. } );
  158. document.addEventListener( 'mousemove', function ( event ) {
  159. if ( isDragging ) {
  160. updateTimeFromPosition( event.clientX );
  161. }
  162. } );
  163. document.addEventListener( 'mouseup', function () {
  164. isDragging = false;
  165. } );
  166. // Track colors by type
  167. const trackColors = {
  168. position: '#4CAF50',
  169. quaternion: '#2196F3',
  170. rotation: '#2196F3',
  171. scale: '#FF9800',
  172. morphTargetInfluences: '#9C27B0',
  173. default: '#607D8B'
  174. };
  175. function getTrackColor( trackName ) {
  176. for ( const type in trackColors ) {
  177. if ( trackName.endsWith( '.' + type ) ) {
  178. return trackColors[ type ];
  179. }
  180. }
  181. return trackColors.default;
  182. }
  183. function getTrackType( trackName ) {
  184. const parts = trackName.split( '.' );
  185. return parts[ parts.length - 1 ];
  186. }
  187. // Hover path helper
  188. let hoverHelper = null;
  189. let currentAction = null;
  190. let currentClip = null;
  191. let currentRoot = null;
  192. // Get all clips from scene animations
  193. function getAnimationClips() {
  194. const scene = editor.scene;
  195. const clips = [];
  196. const seen = new Set();
  197. scene.traverse( function ( object ) {
  198. if ( object.animations && object.animations.length > 0 ) {
  199. for ( const clip of object.animations ) {
  200. if ( ! seen.has( clip.uuid ) ) {
  201. seen.add( clip.uuid );
  202. clips.push( { clip: clip, root: object } );
  203. }
  204. }
  205. }
  206. } );
  207. // Also check scene.animations directly
  208. for ( const clip of scene.animations ) {
  209. if ( ! seen.has( clip.uuid ) ) {
  210. seen.add( clip.uuid );
  211. clips.push( { clip: clip, root: scene } );
  212. }
  213. }
  214. return clips;
  215. }
  216. function getObjectName( trackName, root ) {
  217. // Extract UUID from track name (format: uuid.property)
  218. const dotIndex = trackName.lastIndexOf( '.' );
  219. if ( dotIndex === - 1 ) return trackName;
  220. const uuid = trackName.substring( 0, dotIndex );
  221. const object = root.getObjectByProperty( 'uuid', uuid );
  222. return object ? ( object.name || 'Object' ) : uuid.substring( 0, 8 );
  223. }
  224. function update() {
  225. trackListContainer.innerHTML = '';
  226. container.setDisplay( 'flex' );
  227. container.dom.style.height = panelHeight + 'px';
  228. signals.animationPanelChanged.dispatch( panelHeight );
  229. const clips = getAnimationClips();
  230. if ( clips.length === 0 ) {
  231. return;
  232. }
  233. for ( const { clip, root } of clips ) {
  234. // Clip header row
  235. const clipRow = document.createElement( 'div' );
  236. clipRow.style.display = 'flex';
  237. clipRow.style.alignItems = 'center';
  238. clipRow.style.height = '24px';
  239. clipRow.style.borderBottom = '1px solid #ccc';
  240. clipRow.style.cursor = 'pointer';
  241. clipRow.style.background = currentClip === clip ? 'rgba(0, 136, 255, 0.1)' : '';
  242. const clipLabel = document.createElement( 'div' );
  243. clipLabel.style.width = labelWidth + 'px';
  244. clipLabel.style.padding = '0 10px';
  245. clipLabel.style.fontSize = '11px';
  246. clipLabel.style.fontWeight = 'bold';
  247. clipLabel.style.overflow = 'hidden';
  248. clipLabel.style.textOverflow = 'ellipsis';
  249. clipLabel.style.whiteSpace = 'nowrap';
  250. clipLabel.style.flexShrink = '0';
  251. clipLabel.style.boxSizing = 'border-box';
  252. clipLabel.textContent = clip.name || 'Animation';
  253. clipRow.appendChild( clipLabel );
  254. const clipTimeline = document.createElement( 'div' );
  255. clipTimeline.style.flex = '1';
  256. clipTimeline.style.height = '100%';
  257. clipTimeline.style.background = 'rgba(0,0,0,0.03)';
  258. clipRow.appendChild( clipTimeline );
  259. clipRow.addEventListener( 'click', function () {
  260. if ( editor.selected !== root ) {
  261. signals.objectSelected.remove( selectDefaultClip );
  262. editor.select( root );
  263. signals.objectSelected.add( selectDefaultClip );
  264. }
  265. selectClip( clip, root );
  266. update(); // Refresh to update highlighting
  267. } );
  268. trackListContainer.appendChild( clipRow );
  269. // Only show tracks for selected clip
  270. if ( currentClip === clip ) {
  271. const duration = clip.duration;
  272. for ( const track of clip.tracks ) {
  273. const times = track.times;
  274. if ( times.length === 0 ) continue;
  275. const startTime = times[ 0 ];
  276. const endTime = times[ times.length - 1 ];
  277. const startPercent = ( startTime / duration ) * 100;
  278. const widthPercent = ( ( endTime - startTime ) / duration ) * 100;
  279. const trackRow = document.createElement( 'div' );
  280. trackRow.style.display = 'flex';
  281. trackRow.style.alignItems = 'center';
  282. trackRow.style.height = '20px';
  283. trackRow.style.borderBottom = '1px solid #eee';
  284. // Track label
  285. const trackLabel = document.createElement( 'div' );
  286. trackLabel.style.width = labelWidth + 'px';
  287. trackLabel.style.padding = '0 10px 0 20px';
  288. trackLabel.style.fontSize = '10px';
  289. trackLabel.style.overflow = 'hidden';
  290. trackLabel.style.textOverflow = 'ellipsis';
  291. trackLabel.style.whiteSpace = 'nowrap';
  292. trackLabel.style.flexShrink = '0';
  293. trackLabel.style.boxSizing = 'border-box';
  294. trackLabel.style.color = '#666';
  295. const objectName = getObjectName( track.name, root );
  296. const trackType = getTrackType( track.name );
  297. trackLabel.textContent = objectName + '.' + trackType;
  298. trackLabel.title = track.name;
  299. trackRow.appendChild( trackLabel );
  300. // Track timeline with block
  301. const trackTimeline = document.createElement( 'div' );
  302. trackTimeline.style.flex = '1';
  303. trackTimeline.style.height = '100%';
  304. trackTimeline.style.position = 'relative';
  305. trackTimeline.style.background = 'rgba(0,0,0,0.02)';
  306. const block = document.createElement( 'div' );
  307. block.style.position = 'absolute';
  308. block.style.left = startPercent + '%';
  309. block.style.width = Math.max( 0.5, widthPercent ) + '%';
  310. block.style.top = '3px';
  311. block.style.bottom = '3px';
  312. block.style.background = getTrackColor( track.name );
  313. block.style.borderRadius = '2px';
  314. block.style.opacity = '0.6';
  315. block.title = trackType + ': ' + startTime.toFixed( 2 ) + 's - ' + endTime.toFixed( 2 ) + 's';
  316. trackTimeline.appendChild( block );
  317. // Add keyframe markers
  318. for ( let i = 0; i < times.length; i ++ ) {
  319. const keyframePercent = ( times[ i ] / duration ) * 100;
  320. const keyframe = document.createElement( 'div' );
  321. keyframe.style.position = 'absolute';
  322. keyframe.style.left = keyframePercent + '%';
  323. keyframe.style.top = '50%';
  324. keyframe.style.width = '6px';
  325. keyframe.style.height = '6px';
  326. keyframe.style.marginLeft = '-3px';
  327. keyframe.style.marginTop = '-3px';
  328. keyframe.style.background = getTrackColor( track.name );
  329. keyframe.style.borderRadius = '1px';
  330. keyframe.style.transform = 'rotate(45deg)';
  331. keyframe.title = times[ i ].toFixed( 3 ) + 's';
  332. trackTimeline.appendChild( keyframe );
  333. }
  334. trackRow.appendChild( trackTimeline );
  335. // Hover on position tracks to show path helper
  336. if ( track.name.endsWith( '.position' ) && track.getValueSize() === 3 ) {
  337. const uuid = track.name.replace( '.position', '' );
  338. const object = root.getObjectByProperty( 'uuid', uuid );
  339. if ( object ) {
  340. trackRow.addEventListener( 'mouseenter', function () {
  341. showPath( clip, object );
  342. } );
  343. trackRow.addEventListener( 'mouseleave', function () {
  344. hidePath();
  345. } );
  346. }
  347. }
  348. trackListContainer.appendChild( trackRow );
  349. }
  350. }
  351. }
  352. }
  353. function selectClip( clip, root ) {
  354. // Stop current action
  355. if ( currentAction ) {
  356. currentAction.stop();
  357. }
  358. if ( currentClip === clip ) {
  359. // Unselect clip
  360. currentAction = null;
  361. currentClip = null;
  362. currentRoot = null;
  363. timeText.setValue( '0.00' );
  364. durationText.setValue( '0.00' );
  365. } else {
  366. // Select clip without playing
  367. currentClip = clip;
  368. currentRoot = root;
  369. currentAction = editor.mixer.clipAction( clip, root );
  370. // Update duration display
  371. durationText.setValue( clip.duration.toFixed( 2 ) );
  372. }
  373. }
  374. function showPath( clip, object ) {
  375. hidePath();
  376. hoverHelper = new AnimationPathHelper( currentRoot, clip, object );
  377. editor.sceneHelpers.add( hoverHelper );
  378. signals.sceneGraphChanged.dispatch();
  379. }
  380. function hidePath() {
  381. if ( hoverHelper ) {
  382. editor.sceneHelpers.remove( hoverHelper );
  383. hoverHelper.dispose();
  384. hoverHelper = null;
  385. signals.sceneGraphChanged.dispatch();
  386. }
  387. }
  388. function clear() {
  389. hidePath();
  390. trackListContainer.innerHTML = '';
  391. currentAction = null;
  392. currentClip = null;
  393. currentRoot = null;
  394. timeText.setValue( '0.00' );
  395. durationText.setValue( '0.00' );
  396. }
  397. // Update time display and playhead during playback
  398. function updateTime() {
  399. if ( currentAction && currentClip ) {
  400. const time = currentAction.time % currentClip.duration;
  401. timeText.setValue( time.toFixed( 2 ) );
  402. // Update playhead position
  403. const rect = timelineArea.getBoundingClientRect();
  404. const timelineWidth = rect.width - labelWidth;
  405. const playheadX = labelWidth + ( time / currentClip.duration ) * timelineWidth;
  406. playhead.style.left = playheadX + 'px';
  407. }
  408. requestAnimationFrame( updateTime );
  409. }
  410. function selectDefaultClip( object ) {
  411. if ( object !== null && object.animations && object.animations.length > 0 ) {
  412. selectClip( object.animations[ 0 ], object );
  413. update();
  414. }
  415. }
  416. updateTime();
  417. // Auto-select clip when an object with animations is selected
  418. signals.objectSelected.add( selectDefaultClip );
  419. // Update when scene changes
  420. signals.editorCleared.add( clear );
  421. signals.objectAdded.add( update );
  422. signals.objectRemoved.add( update );
  423. // Show panel on initial load
  424. container.setDisplay( 'flex' );
  425. container.dom.style.height = panelHeight + 'px';
  426. signals.animationPanelChanged.dispatch( panelHeight );
  427. return container;
  428. }
  429. export { Animation };
粤ICP备19079148号