Animation.js 15 KB

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