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 = ``;
const pauseIcon = ``;
const stopIcon = ``;
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 };