Animation.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  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. editor.select( root );
  261. selectClip( clip, root );
  262. update(); // Refresh to update highlighting
  263. } );
  264. trackListContainer.appendChild( clipRow );
  265. // Only show tracks for selected clip
  266. if ( currentClip === clip ) {
  267. const duration = clip.duration;
  268. for ( const track of clip.tracks ) {
  269. const times = track.times;
  270. if ( times.length === 0 ) continue;
  271. const startTime = times[ 0 ];
  272. const endTime = times[ times.length - 1 ];
  273. const startPercent = ( startTime / duration ) * 100;
  274. const widthPercent = ( ( endTime - startTime ) / duration ) * 100;
  275. const trackRow = document.createElement( 'div' );
  276. trackRow.style.display = 'flex';
  277. trackRow.style.alignItems = 'center';
  278. trackRow.style.height = '20px';
  279. trackRow.style.borderBottom = '1px solid #eee';
  280. // Track label
  281. const trackLabel = document.createElement( 'div' );
  282. trackLabel.style.width = labelWidth + 'px';
  283. trackLabel.style.padding = '0 10px 0 20px';
  284. trackLabel.style.fontSize = '10px';
  285. trackLabel.style.overflow = 'hidden';
  286. trackLabel.style.textOverflow = 'ellipsis';
  287. trackLabel.style.whiteSpace = 'nowrap';
  288. trackLabel.style.flexShrink = '0';
  289. trackLabel.style.boxSizing = 'border-box';
  290. trackLabel.style.color = '#666';
  291. const objectName = getObjectName( track.name, root );
  292. const trackType = getTrackType( track.name );
  293. trackLabel.textContent = objectName + '.' + trackType;
  294. trackLabel.title = track.name;
  295. trackRow.appendChild( trackLabel );
  296. // Track timeline with block
  297. const trackTimeline = document.createElement( 'div' );
  298. trackTimeline.style.flex = '1';
  299. trackTimeline.style.height = '100%';
  300. trackTimeline.style.position = 'relative';
  301. trackTimeline.style.background = 'rgba(0,0,0,0.02)';
  302. const block = document.createElement( 'div' );
  303. block.style.position = 'absolute';
  304. block.style.left = startPercent + '%';
  305. block.style.width = Math.max( 0.5, widthPercent ) + '%';
  306. block.style.top = '3px';
  307. block.style.bottom = '3px';
  308. block.style.background = getTrackColor( track.name );
  309. block.style.borderRadius = '2px';
  310. block.style.opacity = '0.6';
  311. block.title = trackType + ': ' + startTime.toFixed( 2 ) + 's - ' + endTime.toFixed( 2 ) + 's';
  312. trackTimeline.appendChild( block );
  313. // Add keyframe markers
  314. for ( let i = 0; i < times.length; i ++ ) {
  315. const keyframePercent = ( times[ i ] / duration ) * 100;
  316. const keyframe = document.createElement( 'div' );
  317. keyframe.style.position = 'absolute';
  318. keyframe.style.left = keyframePercent + '%';
  319. keyframe.style.top = '50%';
  320. keyframe.style.width = '6px';
  321. keyframe.style.height = '6px';
  322. keyframe.style.marginLeft = '-3px';
  323. keyframe.style.marginTop = '-3px';
  324. keyframe.style.background = getTrackColor( track.name );
  325. keyframe.style.borderRadius = '1px';
  326. keyframe.style.transform = 'rotate(45deg)';
  327. keyframe.title = times[ i ].toFixed( 3 ) + 's';
  328. trackTimeline.appendChild( keyframe );
  329. }
  330. trackRow.appendChild( trackTimeline );
  331. // Hover on position tracks to show path helper
  332. if ( track.name.endsWith( '.position' ) && track.getValueSize() === 3 ) {
  333. const uuid = track.name.replace( '.position', '' );
  334. const object = root.getObjectByProperty( 'uuid', uuid );
  335. if ( object ) {
  336. trackRow.addEventListener( 'mouseenter', function () {
  337. showPath( clip, object );
  338. } );
  339. trackRow.addEventListener( 'mouseleave', function () {
  340. hidePath();
  341. } );
  342. }
  343. }
  344. trackListContainer.appendChild( trackRow );
  345. }
  346. }
  347. }
  348. }
  349. function selectClip( clip, root ) {
  350. // Stop current action
  351. if ( currentAction ) {
  352. currentAction.stop();
  353. }
  354. if ( currentClip === clip ) {
  355. // Unselect clip
  356. currentAction = null;
  357. currentClip = null;
  358. currentRoot = null;
  359. timeText.setValue( '0.00' );
  360. durationText.setValue( '0.00' );
  361. } else {
  362. // Select clip without playing
  363. currentClip = clip;
  364. currentRoot = root;
  365. currentAction = editor.mixer.clipAction( clip, root );
  366. // Update duration display
  367. durationText.setValue( clip.duration.toFixed( 2 ) );
  368. }
  369. }
  370. function showPath( clip, object ) {
  371. hidePath();
  372. hoverHelper = new AnimationPathHelper( currentRoot, clip, object );
  373. editor.sceneHelpers.add( hoverHelper );
  374. signals.sceneGraphChanged.dispatch();
  375. }
  376. function hidePath() {
  377. if ( hoverHelper ) {
  378. editor.sceneHelpers.remove( hoverHelper );
  379. hoverHelper.dispose();
  380. hoverHelper = null;
  381. signals.sceneGraphChanged.dispatch();
  382. }
  383. }
  384. function clear() {
  385. hidePath();
  386. trackListContainer.innerHTML = '';
  387. currentAction = null;
  388. currentClip = null;
  389. currentRoot = null;
  390. timeText.setValue( '0.00' );
  391. durationText.setValue( '0.00' );
  392. }
  393. // Update time display and playhead during playback
  394. function updateTime() {
  395. if ( currentAction && currentClip ) {
  396. const time = currentAction.time % currentClip.duration;
  397. timeText.setValue( time.toFixed( 2 ) );
  398. // Update playhead position
  399. const rect = timelineArea.getBoundingClientRect();
  400. const timelineWidth = rect.width - labelWidth;
  401. const playheadX = labelWidth + ( time / currentClip.duration ) * timelineWidth;
  402. playhead.style.left = playheadX + 'px';
  403. }
  404. requestAnimationFrame( updateTime );
  405. }
  406. updateTime();
  407. // Auto-select clip when an object with animations is selected
  408. signals.objectSelected.add( function ( object ) {
  409. if ( object !== null && object.animations && object.animations.length > 0 ) {
  410. selectClip( object.animations[ 0 ], object );
  411. update();
  412. }
  413. } );
  414. // Update when scene changes
  415. signals.editorCleared.add( clear );
  416. signals.objectAdded.add( update );
  417. signals.objectRemoved.add( update );
  418. // Show panel on initial load
  419. container.setDisplay( 'flex' );
  420. container.dom.style.height = panelHeight + 'px';
  421. signals.animationPanelChanged.dispatch( panelHeight );
  422. return container;
  423. }
  424. export { Animation };
粤ICP备19079148号