An AnimationMixer owns the playback for one root object. mixer.clipAction(clip) returns the clip's AnimationAction, which carries the state: play(), stop(), paused, time, timeScale, weight, and setLoop(THREE.LoopRepeat | LoopOnce | LoopPingPong, repetitions). Nothing moves until you advance the mixer with mixer.update(delta) in the render loop. The same pull-out clip, now on the covered shelf:
<script src="three-map.js"></script>
<script type="module">
import * as THREE from 'three';
import { createStage, createShelf, loadCovers } from './bookshelf.js';
const { renderer, scene, camera } = createStage({ width: 640, height: 190 });
scene.add(createShelf(await loadCovers()));
const book = scene.getObjectByName('Salt and Saffron');
const { x, y, z } = book.position; // keys start where the book stands
const clip = new THREE.AnimationClip('pull-out', -1, [
new THREE.VectorKeyframeTrack('.position', [0, 1.2], [x, y, z, x, y + 0.12, z + 0.35]),
new THREE.NumberKeyframeTrack('.rotation[x]', [0, 1.2], [0, -0.3]),
]);
const mixer = new THREE.AnimationMixer(book);
const action = mixer.clipAction(clip).setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true; // hold the last frame, do not reset
action.play();
mixer.addEventListener('finished', () => { window.__done = true; });
const timer = new THREE.Timer();
renderer.setAnimationLoop(() => {
timer.update();
mixer.update(timer.getDelta());
renderer.render(scene, camera);
});
</script>
The mixer fires finished when a LoopOnce action ends and loop at each repetition, for chaining clips. Without clampWhenFinished the book would jump back onto the shelf. Call timer.update() without the loop's timestamp here: that timestamp can be older than the timer's creation, and the negative first delta ran this clip backward to its start in testing.
<!doctype html>
<style>
body { margin: 0; background: #f5f1e8; font: 13px system-ui, sans-serif; color: #263238; }
canvas { display: block; max-width: 100%; }
.ui { padding: 4px 8px; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="ui"><button id="play">Pull out</button> <button id="back">Put back</button>
timeScale <input type="range" id="speed" min="0.2" max="3" step="0.1" value="1"> <span id="state"></span></div>
<script type="module">
import * as THREE from 'three';
const BOOKS = [['The Quiet Harbor', '#1f5f8b'], ['Patterns of the Deep Web', '#5b3f99'], ['Salt and Saffron', '#e09a10'],
['Small Steps to Big Summits', '#3f7d3a'], ["The Clockmaker's Paradox", '#b5452f'], ['Gardens in Glass', '#2a9d8f']];
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(600, 260);
document.body.prepend(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#fbf8f2');
const camera = new THREE.PerspectiveCamera(35, 600 / 260, 0.1, 50);
camera.position.set(0.8, 0.5, 3.1);
camera.lookAt(0, -0.1, 0);
scene.add(new THREE.HemisphereLight('#ffffff', '#8d6e63', 1.2));
const sun = new THREE.DirectionalLight('#ffffff', 2.5);
sun.position.set(2, 3, 4);
scene.add(sun);
const plank = new THREE.Mesh(new THREE.BoxGeometry(3.2, 0.06, 0.7), new THREE.MeshStandardMaterial({ color: '#8d6e63' }));
plank.position.y = -0.52;
scene.add(plank);
// Books stand spine-out: 0.14 m thick, 0.62 m deep
BOOKS.forEach(([title, color], i) => {
const book = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.9 + 0.05 * (i % 3), 0.62), new THREE.MeshStandardMaterial({ color }));
book.name = title;
book.position.set((i - 2.5) * 0.18, -0.49 + book.geometry.parameters.height / 2, 0);
scene.add(book);
});
const book = scene.getObjectByName('Salt and Saffron');
const { x, y, z } = book.position; // keys start where the book stands
const clip = new THREE.AnimationClip('pull-out', -1, [
new THREE.VectorKeyframeTrack('.position', [0, 1.2], [x, y, z, x, y + 0.12, z + 0.45]),
new THREE.NumberKeyframeTrack('.rotation[x]', [0, 1.2], [0, -0.3]),
]);
const mixer = new THREE.AnimationMixer(book);
const action = mixer.clipAction(clip).setLoop(THREE.LoopOnce, 1);
action.clampWhenFinished = true; // hold the last frame instead of resetting
const state = document.getElementById('state');
mixer.addEventListener('finished', () => { state.textContent = `finished at time ${action.time.toFixed(2)} s`; });
function run(direction) {
action.paused = false;
action.timeScale = direction * +document.getElementById('speed').value;
if (!action.isRunning()) action.play();
if (direction < 0 && action.time === 0) action.time = clip.duration;
state.textContent = direction > 0 ? 'pulling out…' : 'putting back…';
}
document.getElementById('play').onclick = () => { action.reset(); run(1); };
document.getElementById('back').onclick = () => run(-1);
action.play();
const timer = new THREE.Timer();
renderer.setAnimationLoop(() => {
timer.update();
mixer.update(timer.getDelta()); // nothing moves until the mixer advances
renderer.render(scene, camera);
});
</script>