Просмотр исходного кода

Editor: Add Animation panel. (#32869)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
mrdoob 2 недель назад
Родитель
Сommit
3ebd629b96

+ 65 - 2
editor/css/main.css

@@ -343,7 +343,7 @@ hr {
 	top: 36px;
 	left: 0;
 	right: 350px;
-	bottom: 0;
+	bottom: 36px;
 }
 
 	#viewport .Text {
@@ -351,6 +351,48 @@ hr {
 		pointer-events: none;
 	}
 
+#animation {
+	position: absolute;
+	left: 0;
+	right: 350px;
+	bottom: 0;
+	height: 36px;
+	background: #eee;
+	border-top: 1px solid #ccc;
+	display: none;
+	flex-direction: row;
+}
+
+	#animation .Panel {
+		color: #888;
+	}
+
+	#animation input[type="range"] {
+		accent-color: #08f;
+	}
+
+#animation-resizer {
+	position: absolute;
+	left: 0;
+	right: 350px;
+	bottom: 36px;
+	height: 5px;
+	transform: translateY(2.5px);
+	cursor: row-resize;
+	z-index: 2;
+}
+
+	#animation-resizer:hover {
+		background-color: #08f8;
+		transition-property: background-color;
+		transition-delay: 0.1s;
+		transition-duration: 0.2s;
+	}
+
+	#animation-resizer:active {
+		background-color: #08f;
+	}
+
 #script {
 	position: absolute;
 	top: 36px;
@@ -536,7 +578,7 @@ hr {
 	position: absolute;
 	left: calc(50% - 175px);
 	transform: translateX(-50%);
-	bottom: 20px;
+	bottom: 56px;
 	height: 32px;
 	background: #eee;
 	text-align: center;
@@ -621,6 +663,10 @@ hr {
 		display: none;
 	}
 
+	#animation-resizer {
+		display: none;
+	}
+
 	#menubar .menu .options {
 		max-height: calc(100% - 80px);
 	}
@@ -663,6 +709,10 @@ hr {
 		bottom: 330px;
 	}
 
+	#animation {
+		display: none !important;
+	}
+
 }
 
 /* DARK MODE */
@@ -740,6 +790,19 @@ hr {
 			border: solid 1px #5A5A5A;
 		}
 
+	#animation {
+		background-color: #111;
+		border-top: 1px solid #222;
+	}
+
+		#animation .Panel {
+			border-bottom: 1px solid #222;
+		}
+
+		#animation .timeline-container {
+			background: rgba(255, 255, 255, 0.05);
+		}
+
 	#tabs {
 		background-color: #1b1b1b;
 		border-top: 1px solid #222;

+ 31 - 0
editor/index.html

@@ -69,6 +69,8 @@
 			import { Sidebar } from './js/Sidebar.js';
 			import { Menubar } from './js/Menubar.js';
 			import { Resizer } from './js/Resizer.js';
+			import { AnimationResizer } from './js/AnimationResizer.js';
+			import { Animation } from './js/Animation.js';
 
 			window.URL = window.URL || window.webkitURL;
 			window.BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder;
@@ -101,6 +103,35 @@
 			const resizer = new Resizer( editor );
 			document.body.appendChild( resizer.dom );
 
+			const animation = new Animation( editor );
+			document.body.appendChild( animation.dom );
+
+			const animationResizer = new AnimationResizer( editor );
+			document.body.appendChild( animationResizer.dom );
+
+			editor.signals.animationPanelChanged.add( function ( height ) {
+
+				const visible = height !== false;
+
+				viewport.dom.classList.toggle( 'with-animation', visible );
+				toolbar.dom.classList.toggle( 'with-animation', visible );
+
+				if ( visible ) {
+
+					viewport.dom.style.bottom = height + 'px';
+					toolbar.dom.style.bottom = ( height + 20 ) + 'px';
+
+				} else {
+
+					viewport.dom.style.bottom = '';
+					toolbar.dom.style.bottom = '';
+
+				}
+
+				editor.signals.windowResize.dispatch();
+
+			} );
+
 			//
 
 			editor.storage.init( function () {

+ 581 - 0
editor/js/Animation.js

@@ -0,0 +1,581 @@
+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 };

+ 73 - 0
editor/js/AnimationResizer.js

@@ -0,0 +1,73 @@
+import { UIElement } from './libs/ui.js';
+
+function AnimationResizer( editor ) {
+
+	const signals = editor.signals;
+
+	const dom = document.createElement( 'div' );
+	dom.id = 'animation-resizer';
+
+	let panelHeight = 36;
+	let startY = 0;
+	let startHeight = 0;
+
+	function onPointerDown( event ) {
+
+		if ( event.isPrimary === false ) return;
+
+		startY = event.clientY;
+		startHeight = panelHeight;
+
+		dom.ownerDocument.addEventListener( 'pointermove', onPointerMove );
+		dom.ownerDocument.addEventListener( 'pointerup', onPointerUp );
+
+	}
+
+	function onPointerUp( event ) {
+
+		if ( event.isPrimary === false ) return;
+
+		dom.ownerDocument.removeEventListener( 'pointermove', onPointerMove );
+		dom.ownerDocument.removeEventListener( 'pointerup', onPointerUp );
+
+	}
+
+	function onPointerMove( event ) {
+
+		if ( event.isPrimary === false ) return;
+
+		const deltaY = startY - event.clientY;
+		const newHeight = startHeight + deltaY;
+		const maxHeight = window.innerHeight / 2;
+
+		// Clamp between 36px (top bar only) and half the window height
+		panelHeight = Math.max( 36, Math.min( maxHeight, newHeight ) );
+
+		signals.animationPanelResized.dispatch( panelHeight );
+
+	}
+
+	dom.addEventListener( 'pointerdown', onPointerDown );
+
+	// Show/hide based on animation panel visibility
+	signals.animationPanelChanged.add( function ( height ) {
+
+		if ( height === false ) {
+
+			dom.style.display = 'none';
+
+		} else {
+
+			dom.style.display = 'block';
+			dom.style.bottom = height + 'px';
+			panelHeight = height;
+
+		}
+
+	} );
+
+	return new UIElement( dom );
+
+}
+
+export { AnimationResizer };

+ 3 - 1
editor/js/Editor.js

@@ -93,8 +93,10 @@ function Editor() {
 
 		pathTracerUpdated: new Signal(),
 
-		morphTargetsUpdated: new Signal()
+		animationPanelChanged: new Signal(),
+		animationPanelResized: new Signal(),
 
+		morphTargetsUpdated: new Signal()
 
 	};
 

+ 7 - 0
editor/js/Resizer.js

@@ -44,6 +44,13 @@ function Resizer( editor ) {
 		document.getElementById( 'player' ).style.right = x + 'px';
 		document.getElementById( 'script' ).style.right = x + 'px';
 		document.getElementById( 'viewport' ).style.right = x + 'px';
+		document.getElementById( 'animation' ).style.right = x + 'px';
+		document.getElementById( 'animation-resizer' ).style.right = x + 'px';
+
+		// Center toolbar in viewport area
+		const toolbar = document.getElementById( 'toolbar' );
+		const viewportWidth = offsetWidth - x;
+		toolbar.style.left = ( viewportWidth / 2 ) + 'px';
 
 		signals.windowResize.dispatch();
 

+ 0 - 102
editor/js/Sidebar.Object.Animation.js

@@ -1,102 +0,0 @@
-import { UIBreak, UIButton, UIDiv, UIText, UINumber, UIRow } from './libs/ui.js';
-
-function SidebarObjectAnimation( editor ) {
-
-	const strings = editor.strings;
-	const signals = editor.signals;
-	const mixer = editor.mixer;
-
-	function getButtonText( action ) {
-
-		return action.isRunning()
-			? strings.getKey( 'sidebar/animations/stop' )
-			: strings.getKey( 'sidebar/animations/play' );
-
-	}
-
-	function Animation( animation, object ) {
-
-		const action = mixer.clipAction( animation, object );
-
-		const container = new UIRow();
-
-		const name = new UIText( animation.name ).setWidth( '200px' );
-		container.add( name );
-
-		const button = new UIButton( getButtonText( action ) );
-		button.onClick( function () {
-
-			action.isRunning() ? action.stop() : action.play();
-			button.setTextContent( getButtonText( action ) );
-
-		} );
-
-		container.add( button );
-
-		return container;
-
-	}
-
-	signals.objectSelected.add( function ( object ) {
-
-		if ( object !== null && object.animations.length > 0 ) {
-
-			animationsList.clear();
-
-			const animations = object.animations;
-
-			for ( const animation of animations ) {
-
-				animationsList.add( new Animation( animation, object ) );
-
-			}
-
-			container.setDisplay( '' );
-
-		} else {
-
-			container.setDisplay( 'none' );
-
-		}
-
-	} );
-
-	signals.objectRemoved.add( function ( object ) {
-
-		if ( object !== null && object.animations.length > 0 ) {
-
-			mixer.uncacheRoot( object );
-
-		}
-
-	} );
-
-	const container = new UIDiv();
-	container.setMarginTop( '20px' );
-	container.setDisplay( 'none' );
-
-	container.add( new UIText( strings.getKey( 'sidebar/animations' ) ).setTextTransform( 'uppercase' ) );
-	container.add( new UIBreak() );
-	container.add( new UIBreak() );
-
-	const animationsList = new UIDiv();
-	container.add( animationsList );
-
-	const mixerTimeScaleRow = new UIRow();
-	const mixerTimeScaleNumber = new UINumber( 1 ).setWidth( '60px' ).setRange( - 10, 10 );
-	mixerTimeScaleNumber.onChange( function () {
-
-		mixer.timeScale = mixerTimeScaleNumber.getValue();
-
-	} );
-
-	mixerTimeScaleRow.add( new UIText( strings.getKey( 'sidebar/animations/timescale' ) ).setClass( 'Label' ) );
-	mixerTimeScaleRow.add( mixerTimeScaleNumber );
-
-	container.add( mixerTimeScaleRow );
-
-	return container;
-
-}
-
-export { SidebarObjectAnimation };

+ 0 - 6
editor/js/Sidebar.Object.js

@@ -11,8 +11,6 @@ import { SetScaleCommand } from './commands/SetScaleCommand.js';
 import { SetColorCommand } from './commands/SetColorCommand.js';
 import { SetShadowValueCommand } from './commands/SetShadowValueCommand.js';
 
-import { SidebarObjectAnimation } from './Sidebar.Object.Animation.js';
-
 function SidebarObject( editor ) {
 
 	const strings = editor.strings;
@@ -427,10 +425,6 @@ function SidebarObject( editor ) {
 	} );
 	container.add( exportJson );
 
-	// Animations
-
-	container.add( new SidebarObjectAnimation( editor ) );
-
 	//
 
 	function update() {

+ 302 - 0
examples/jsm/helpers/AnimationPathHelper.js

@@ -0,0 +1,302 @@
+import {
+	BufferGeometry,
+	Float32BufferAttribute,
+	Line,
+	LineBasicMaterial,
+	Object3D,
+	Points,
+	PointsMaterial
+} from 'three';
+
+/**
+ * Visualizes the motion path of an animated object based on position keyframes
+ * from an AnimationClip.
+ *
+ * ```js
+ * const clip = model.animations[ 0 ];
+ * const helper = new AnimationPathHelper( model, clip, object );
+ * scene.add( helper );
+ * ```
+ *
+ * @augments Object3D
+ * @three_import import { AnimationPathHelper } from 'three/addons/helpers/AnimationPathHelper.js';
+ */
+class AnimationPathHelper extends Object3D {
+
+	/**
+	 * Constructs a new animation path helper.
+	 *
+	 * @param {Object3D} root - The root object containing the animation clips.
+	 * @param {AnimationClip} clip - The animation clip containing position keyframes.
+	 * @param {Object3D} object - The specific object to show the path for.
+	 * @param {Object} [options={}] - Configuration options.
+	 * @param {number|Color|string} [options.color=0x00ff00] - The path line color.
+	 * @param {number|Color|string} [options.markerColor=0xff0000] - The keyframe marker color.
+	 * @param {number} [options.divisions=100] - Number of samples for smooth path interpolation.
+	 * @param {boolean} [options.showMarkers=true] - Whether to show markers at keyframe positions.
+	 * @param {number} [options.markerSize=5] - Size of keyframe markers in pixels.
+	 */
+	constructor( root, clip, object, options = {} ) {
+
+		super();
+
+		const {
+			color = 0x00ff00,
+			markerColor = 0xff0000,
+			divisions = 100,
+			showMarkers = true,
+			markerSize = 5
+		} = options;
+
+		/**
+		 * This flag can be used for type testing.
+		 *
+		 * @type {boolean}
+		 * @readonly
+		 * @default true
+		 */
+		this.isAnimationPathHelper = true;
+
+		this.type = 'AnimationPathHelper';
+
+		/**
+		 * The root object containing the animation clips.
+		 *
+		 * @type {Object3D}
+		 */
+		this.root = root;
+
+		/**
+		 * The animation clip containing position keyframes.
+		 *
+		 * @type {AnimationClip}
+		 */
+		this.clip = clip;
+
+		/**
+		 * The object whose path is being visualized.
+		 *
+		 * @type {Object3D}
+		 */
+		this.object = object;
+
+		/**
+		 * Number of samples for smooth path interpolation.
+		 *
+		 * @type {number}
+		 * @default 100
+		 */
+		this.divisions = divisions;
+
+		/**
+		 * The position track for the object.
+		 *
+		 * @type {KeyframeTrack|null}
+		 * @private
+		 */
+		this._track = this._findTrackForObject( object );
+
+		if ( this._track === null ) {
+
+			console.warn( 'AnimationPathHelper: No position track found for object', object.name );
+			return;
+
+		}
+
+		// Create line for path
+		const lineGeometry = new BufferGeometry();
+		const lineMaterial = new LineBasicMaterial( {
+			color: color,
+			toneMapped: false
+		} );
+
+		/**
+		 * The line representing the animation path.
+		 *
+		 * @type {Line}
+		 */
+		this.line = new Line( lineGeometry, lineMaterial );
+		this.add( this.line );
+
+		// Create points for keyframe markers
+		if ( showMarkers ) {
+
+			const pointsGeometry = new BufferGeometry();
+			const pointsMaterial = new PointsMaterial( {
+				color: markerColor,
+				size: markerSize,
+				sizeAttenuation: false,
+				toneMapped: false
+			} );
+
+			/**
+			 * Points marking keyframe positions.
+			 *
+			 * @type {Points|null}
+			 */
+			this.points = new Points( pointsGeometry, pointsMaterial );
+			this.add( this.points );
+
+		} else {
+
+			this.points = null;
+
+		}
+
+		// Sync matrix with object's parent
+		this.matrixAutoUpdate = false;
+
+		this._updateGeometry();
+
+	}
+
+	/**
+	 * Finds the position track for the given object.
+	 *
+	 * @private
+	 * @param {Object3D} object - The object to find the track for.
+	 * @returns {KeyframeTrack|null} The position track, or null if not found.
+	 */
+	_findTrackForObject( object ) {
+
+		const targetName = object.uuid + '.position';
+
+		for ( const track of this.clip.tracks ) {
+
+			if ( track.name === targetName && track.getValueSize() === 3 ) {
+
+				return track;
+
+			}
+
+		}
+
+		return null;
+
+	}
+
+	/**
+	 * Samples the track at regular intervals.
+	 *
+	 * @private
+	 * @returns {Float32Array} Array of sampled positions.
+	 */
+	_sampleTrack() {
+
+		const track = this._track;
+		const interpolant = track.createInterpolant();
+		const duration = this.clip.duration;
+		const positions = [];
+
+		for ( let i = 0; i <= this.divisions; i ++ ) {
+
+			const t = ( i / this.divisions ) * duration;
+			const result = interpolant.evaluate( t );
+			positions.push( result[ 0 ], result[ 1 ], result[ 2 ] );
+
+		}
+
+		return new Float32Array( positions );
+
+	}
+
+	/**
+	 * Updates the geometry with sampled path data.
+	 *
+	 * @private
+	 */
+	_updateGeometry() {
+
+		if ( this._track === null ) return;
+
+		// Update line geometry
+		const sampledPositions = this._sampleTrack();
+		this.line.geometry.setAttribute( 'position', new Float32BufferAttribute( sampledPositions, 3 ) );
+		this.line.geometry.computeBoundingSphere();
+
+		// Update keyframe markers
+		if ( this.points !== null ) {
+
+			this.points.geometry.setAttribute( 'position', new Float32BufferAttribute( new Float32Array( this._track.values ), 3 ) );
+			this.points.geometry.computeBoundingSphere();
+
+		}
+
+	}
+
+	/**
+	 * Updates the helper's transform to match the object's parent.
+	 *
+	 * @param {boolean} force - Force matrix update.
+	 */
+	updateMatrixWorld( force ) {
+
+		// Position the helper at the object's parent so the path appears in correct local space
+		if ( this.object && this.object.parent ) {
+
+			this.object.parent.updateWorldMatrix( true, false );
+			this.matrix.copy( this.object.parent.matrixWorld );
+
+		} else {
+
+			this.matrix.identity();
+
+		}
+
+		this.matrixWorld.copy( this.matrix );
+
+		// Update children
+		for ( let i = 0; i < this.children.length; i ++ ) {
+
+			this.children[ i ].updateMatrixWorld( force );
+
+		}
+
+	}
+
+	/**
+	 * Sets the path line color.
+	 *
+	 * @param {number|Color|string} color - The new color.
+	 */
+	setColor( color ) {
+
+		if ( this.line ) this.line.material.color.set( color );
+
+	}
+
+	/**
+	 * Sets the keyframe marker color.
+	 *
+	 * @param {number|Color|string} color - The new color.
+	 */
+	setMarkerColor( color ) {
+
+		if ( this.points ) this.points.material.color.set( color );
+
+	}
+
+	/**
+	 * Frees the GPU-related resources allocated by this instance.
+	 */
+	dispose() {
+
+		if ( this.line ) {
+
+			this.line.geometry.dispose();
+			this.line.material.dispose();
+
+		}
+
+		if ( this.points ) {
+
+			this.points.geometry.dispose();
+			this.points.material.dispose();
+
+		}
+
+	}
+
+}
+
+export { AnimationPathHelper };

粤ICP备19079148号