| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581 |
- import { UIPanel, UIText, UIButton } from './libs/ui.js';
- import { AnimationPathHelper } from 'three/addons/helpers/AnimationPathHelper.js';
- function Animation( editor ) {
- const signals = editor.signals;
- const container = new UIPanel();
- container.setId( 'animation' );
- container.dom.style.flexDirection = 'column';
- let panelHeight = 36;
- // Listen for resizer changes
- signals.animationPanelResized.add( function ( height ) {
- panelHeight = height;
- container.dom.style.height = height + 'px';
- signals.animationPanelChanged.dispatch( height );
- } );
- // Top bar - playback controls
- const controlsPanel = new UIPanel();
- controlsPanel.dom.style.padding = '6px 10px';
- controlsPanel.dom.style.borderBottom = '1px solid #ccc';
- controlsPanel.dom.style.display = 'flex';
- controlsPanel.dom.style.alignItems = 'center';
- controlsPanel.dom.style.justifyContent = 'center';
- controlsPanel.dom.style.gap = '6px';
- controlsPanel.dom.style.flexShrink = '0';
- container.add( controlsPanel );
- // SVG icons
- const playIcon = `<svg width="12" height="12" viewBox="0 0 12 12"><path d="M3 1.5v9l7-4.5z" fill="currentColor"/></svg>`;
- const pauseIcon = `<svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 1h3v10H2zM7 1h3v10H7z" fill="currentColor"/></svg>`;
- const stopIcon = `<svg width="12" height="12" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" fill="currentColor"/></svg>`;
- const playButton = new UIButton();
- playButton.dom.innerHTML = playIcon;
- playButton.dom.style.width = '24px';
- playButton.dom.style.height = '24px';
- playButton.dom.style.padding = '0';
- playButton.dom.style.borderRadius = '4px';
- playButton.dom.style.display = 'flex';
- playButton.dom.style.alignItems = 'center';
- playButton.dom.style.justifyContent = 'center';
- playButton.onClick( function () {
- if ( currentAction ) {
- if ( currentAction.paused ) {
- currentAction.paused = false;
- } else if ( ! currentAction.isRunning() ) {
- currentAction.reset();
- currentAction.play();
- }
- }
- } );
- controlsPanel.add( playButton );
- const pauseButton = new UIButton();
- pauseButton.dom.innerHTML = pauseIcon;
- pauseButton.dom.style.width = '24px';
- pauseButton.dom.style.height = '24px';
- pauseButton.dom.style.padding = '0';
- pauseButton.dom.style.borderRadius = '4px';
- pauseButton.dom.style.display = 'flex';
- pauseButton.dom.style.alignItems = 'center';
- pauseButton.dom.style.justifyContent = 'center';
- pauseButton.onClick( function () {
- if ( currentAction ) {
- currentAction.paused = true;
- }
- } );
- controlsPanel.add( pauseButton );
- const stopButton = new UIButton();
- stopButton.dom.innerHTML = stopIcon;
- stopButton.dom.style.width = '24px';
- stopButton.dom.style.height = '24px';
- stopButton.dom.style.padding = '0';
- stopButton.dom.style.borderRadius = '4px';
- stopButton.dom.style.display = 'flex';
- stopButton.dom.style.alignItems = 'center';
- stopButton.dom.style.justifyContent = 'center';
- stopButton.onClick( function () {
- if ( currentAction ) {
- currentAction.stop();
- }
- } );
- controlsPanel.add( stopButton );
- // Time display
- const timeDisplay = document.createElement( 'div' );
- timeDisplay.style.display = 'flex';
- timeDisplay.style.alignItems = 'center';
- timeDisplay.style.justifyContent = 'center';
- timeDisplay.style.gap = '4px';
- timeDisplay.style.height = '24px';
- timeDisplay.style.padding = '0 8px';
- timeDisplay.style.background = 'rgba(0,0,0,0.05)';
- timeDisplay.style.borderRadius = '4px';
- timeDisplay.style.fontFamily = 'monospace';
- timeDisplay.style.fontSize = '11px';
- controlsPanel.dom.appendChild( timeDisplay );
- const timeText = new UIText( '0.00' ).setWidth( '36px' );
- timeText.dom.style.textAlign = 'right';
- timeDisplay.appendChild( timeText.dom );
- const separator = new UIText( '/' );
- timeDisplay.appendChild( separator.dom );
- const durationText = new UIText( '0.00' ).setWidth( '36px' );
- timeDisplay.appendChild( durationText.dom );
- // Timeline area with track rows
- const timelineArea = document.createElement( 'div' );
- timelineArea.style.flex = '1';
- timelineArea.style.display = 'flex';
- timelineArea.style.flexDirection = 'column';
- timelineArea.style.overflow = 'hidden';
- timelineArea.style.position = 'relative';
- container.dom.appendChild( timelineArea );
- // Scrollable track list
- const trackListContainer = document.createElement( 'div' );
- trackListContainer.style.flex = '1';
- trackListContainer.style.overflowY = 'auto';
- trackListContainer.style.overflowX = 'hidden';
- timelineArea.appendChild( trackListContainer );
- // Playhead (spans entire timeline area)
- const playhead = document.createElement( 'div' );
- playhead.style.position = 'absolute';
- playhead.style.top = '0';
- playhead.style.bottom = '0';
- playhead.style.width = '2px';
- playhead.style.background = '#f00';
- playhead.style.left = '150px'; // Start at timeline start (after labels)
- playhead.style.pointerEvents = 'none';
- playhead.style.zIndex = '10';
- timelineArea.appendChild( playhead );
- // Timeline scrubbing
- let isDragging = false;
- const labelWidth = 150;
- function updateTimeFromPosition( clientX ) {
- const rect = timelineArea.getBoundingClientRect();
- const timelineStart = labelWidth;
- const timelineWidth = rect.width - labelWidth;
- const x = Math.max( 0, Math.min( clientX - rect.left - timelineStart, timelineWidth ) );
- const percent = x / timelineWidth;
- if ( currentAction && currentClip ) {
- const time = percent * currentClip.duration;
- currentAction.play();
- currentAction.time = time;
- currentAction.paused = true;
- editor.mixer.update( 0 );
- }
- }
- timelineArea.addEventListener( 'mousedown', function ( event ) {
- const rect = timelineArea.getBoundingClientRect();
- if ( event.clientX - rect.left > labelWidth ) {
- event.preventDefault();
- isDragging = true;
- updateTimeFromPosition( event.clientX );
- }
- } );
- document.addEventListener( 'mousemove', function ( event ) {
- if ( isDragging ) {
- updateTimeFromPosition( event.clientX );
- }
- } );
- document.addEventListener( 'mouseup', function () {
- isDragging = false;
- } );
- // Track colors by type
- const trackColors = {
- position: '#4CAF50',
- quaternion: '#2196F3',
- rotation: '#2196F3',
- scale: '#FF9800',
- morphTargetInfluences: '#9C27B0',
- default: '#607D8B'
- };
- function getTrackColor( trackName ) {
- for ( const type in trackColors ) {
- if ( trackName.endsWith( '.' + type ) ) {
- return trackColors[ type ];
- }
- }
- return trackColors.default;
- }
- function getTrackType( trackName ) {
- const parts = trackName.split( '.' );
- return parts[ parts.length - 1 ];
- }
- // Hover path helper
- let hoverHelper = null;
- let currentAction = null;
- let currentClip = null;
- let currentRoot = null;
- // Get all clips from scene animations
- function getAnimationClips() {
- const scene = editor.scene;
- const clips = [];
- const seen = new Set();
- scene.traverse( function ( object ) {
- if ( object.animations && object.animations.length > 0 ) {
- for ( const clip of object.animations ) {
- if ( ! seen.has( clip.uuid ) ) {
- seen.add( clip.uuid );
- clips.push( { clip: clip, root: object } );
- }
- }
- }
- } );
- // Also check scene.animations directly
- for ( const clip of scene.animations ) {
- if ( ! seen.has( clip.uuid ) ) {
- seen.add( clip.uuid );
- clips.push( { clip: clip, root: scene } );
- }
- }
- return clips;
- }
- function getObjectName( trackName, root ) {
- // Extract UUID from track name (format: uuid.property)
- const dotIndex = trackName.lastIndexOf( '.' );
- if ( dotIndex === - 1 ) return trackName;
- const uuid = trackName.substring( 0, dotIndex );
- const object = root.getObjectByProperty( 'uuid', uuid );
- return object ? ( object.name || 'Object' ) : uuid.substring( 0, 8 );
- }
- function update() {
- trackListContainer.innerHTML = '';
- container.setDisplay( 'flex' );
- container.dom.style.height = panelHeight + 'px';
- signals.animationPanelChanged.dispatch( panelHeight );
- const clips = getAnimationClips();
- if ( clips.length === 0 ) {
- return;
- }
- for ( const { clip, root } of clips ) {
- // Clip header row
- const clipRow = document.createElement( 'div' );
- clipRow.style.display = 'flex';
- clipRow.style.alignItems = 'center';
- clipRow.style.height = '24px';
- clipRow.style.borderBottom = '1px solid #ccc';
- clipRow.style.cursor = 'pointer';
- clipRow.style.background = currentClip === clip ? 'rgba(0, 136, 255, 0.1)' : '';
- const clipLabel = document.createElement( 'div' );
- clipLabel.style.width = labelWidth + 'px';
- clipLabel.style.padding = '0 10px';
- clipLabel.style.fontSize = '11px';
- clipLabel.style.fontWeight = 'bold';
- clipLabel.style.overflow = 'hidden';
- clipLabel.style.textOverflow = 'ellipsis';
- clipLabel.style.whiteSpace = 'nowrap';
- clipLabel.style.flexShrink = '0';
- clipLabel.style.boxSizing = 'border-box';
- clipLabel.textContent = clip.name || 'Animation';
- clipRow.appendChild( clipLabel );
- const clipTimeline = document.createElement( 'div' );
- clipTimeline.style.flex = '1';
- clipTimeline.style.height = '100%';
- clipTimeline.style.background = 'rgba(0,0,0,0.03)';
- clipRow.appendChild( clipTimeline );
- clipRow.addEventListener( 'click', function () {
- editor.select( root );
- selectClip( clip, root );
- update(); // Refresh to update highlighting
- } );
- trackListContainer.appendChild( clipRow );
- // Only show tracks for selected clip
- if ( currentClip === clip ) {
- const duration = clip.duration;
- for ( const track of clip.tracks ) {
- const times = track.times;
- if ( times.length === 0 ) continue;
- const startTime = times[ 0 ];
- const endTime = times[ times.length - 1 ];
- const startPercent = ( startTime / duration ) * 100;
- const widthPercent = ( ( endTime - startTime ) / duration ) * 100;
- const trackRow = document.createElement( 'div' );
- trackRow.style.display = 'flex';
- trackRow.style.alignItems = 'center';
- trackRow.style.height = '20px';
- trackRow.style.borderBottom = '1px solid #eee';
- // Track label
- const trackLabel = document.createElement( 'div' );
- trackLabel.style.width = labelWidth + 'px';
- trackLabel.style.padding = '0 10px 0 20px';
- trackLabel.style.fontSize = '10px';
- trackLabel.style.overflow = 'hidden';
- trackLabel.style.textOverflow = 'ellipsis';
- trackLabel.style.whiteSpace = 'nowrap';
- trackLabel.style.flexShrink = '0';
- trackLabel.style.boxSizing = 'border-box';
- trackLabel.style.color = '#666';
- const objectName = getObjectName( track.name, root );
- const trackType = getTrackType( track.name );
- trackLabel.textContent = objectName + '.' + trackType;
- trackLabel.title = track.name;
- trackRow.appendChild( trackLabel );
- // Track timeline with block
- const trackTimeline = document.createElement( 'div' );
- trackTimeline.style.flex = '1';
- trackTimeline.style.height = '100%';
- trackTimeline.style.position = 'relative';
- trackTimeline.style.background = 'rgba(0,0,0,0.02)';
- const block = document.createElement( 'div' );
- block.style.position = 'absolute';
- block.style.left = startPercent + '%';
- block.style.width = Math.max( 0.5, widthPercent ) + '%';
- block.style.top = '3px';
- block.style.bottom = '3px';
- block.style.background = getTrackColor( track.name );
- block.style.borderRadius = '2px';
- block.style.opacity = '0.6';
- block.title = trackType + ': ' + startTime.toFixed( 2 ) + 's - ' + endTime.toFixed( 2 ) + 's';
- trackTimeline.appendChild( block );
- // Add keyframe markers
- for ( let i = 0; i < times.length; i ++ ) {
- const keyframePercent = ( times[ i ] / duration ) * 100;
- const keyframe = document.createElement( 'div' );
- keyframe.style.position = 'absolute';
- keyframe.style.left = keyframePercent + '%';
- keyframe.style.top = '50%';
- keyframe.style.width = '6px';
- keyframe.style.height = '6px';
- keyframe.style.marginLeft = '-3px';
- keyframe.style.marginTop = '-3px';
- keyframe.style.background = getTrackColor( track.name );
- keyframe.style.borderRadius = '1px';
- keyframe.style.transform = 'rotate(45deg)';
- keyframe.title = times[ i ].toFixed( 3 ) + 's';
- trackTimeline.appendChild( keyframe );
- }
- trackRow.appendChild( trackTimeline );
- // Hover on position tracks to show path helper
- if ( track.name.endsWith( '.position' ) && track.getValueSize() === 3 ) {
- const uuid = track.name.replace( '.position', '' );
- const object = root.getObjectByProperty( 'uuid', uuid );
- if ( object ) {
- trackRow.addEventListener( 'mouseenter', function () {
- showPath( clip, object );
- } );
- trackRow.addEventListener( 'mouseleave', function () {
- hidePath();
- } );
- }
- }
- trackListContainer.appendChild( trackRow );
- }
- }
- }
- }
- function selectClip( clip, root ) {
- // Stop current action
- if ( currentAction ) {
- currentAction.stop();
- }
- // Select clip without playing
- currentClip = clip;
- currentRoot = root;
- currentAction = editor.mixer.clipAction( clip, root );
- // Update duration display
- durationText.setValue( clip.duration.toFixed( 2 ) );
- }
- function showPath( clip, object ) {
- hidePath();
- hoverHelper = new AnimationPathHelper( currentRoot, clip, object );
- editor.sceneHelpers.add( hoverHelper );
- signals.sceneGraphChanged.dispatch();
- }
- function hidePath() {
- if ( hoverHelper ) {
- editor.sceneHelpers.remove( hoverHelper );
- hoverHelper.dispose();
- hoverHelper = null;
- signals.sceneGraphChanged.dispatch();
- }
- }
- function clear() {
- hidePath();
- trackListContainer.innerHTML = '';
- currentAction = null;
- currentClip = null;
- currentRoot = null;
- timeText.setValue( '0.00' );
- durationText.setValue( '0.00' );
- }
- // Update time display and playhead during playback
- function updateTime() {
- if ( currentAction && currentClip ) {
- const time = currentAction.time % currentClip.duration;
- timeText.setValue( time.toFixed( 2 ) );
- // Update playhead position
- const rect = timelineArea.getBoundingClientRect();
- const timelineWidth = rect.width - labelWidth;
- const playheadX = labelWidth + ( time / currentClip.duration ) * timelineWidth;
- playhead.style.left = playheadX + 'px';
- }
- requestAnimationFrame( updateTime );
- }
- updateTime();
- // Auto-select clip when an object with animations is selected
- signals.objectSelected.add( function ( object ) {
- if ( object !== null && object.animations && object.animations.length > 0 ) {
- selectClip( object.animations[ 0 ], object );
- update();
- }
- } );
- // Update when scene changes
- signals.editorCleared.add( clear );
- signals.objectAdded.add( update );
- signals.objectRemoved.add( update );
- // Show panel on initial load
- container.setDisplay( 'flex' );
- container.dom.style.height = panelHeight + 'px';
- signals.animationPanelChanged.dispatch( panelHeight );
- return container;
- }
- export { Animation };
|