@zanim/web 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/zanim_web_core.wasm +0 -0
- package/package.json +76 -0
- package/src/compositing.js +136 -0
- package/src/core.js +652 -0
- package/src/evaluator.js +73 -0
- package/src/ir.d.ts +26 -0
- package/src/ir.js +250 -0
- package/src/media.js +289 -0
- package/src/player.js +99 -0
- package/src/scene.js +633 -0
- package/src/svg.js +96 -0
- package/src/three.js +129 -0
- package/src/typst.js +39 -0
- package/src/zanim.d.ts +223 -0
- package/src/zanim.js +97 -0
- package/vite.d.ts +4 -0
- package/vite.js +82 -0
package/src/scene.js
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Camera2D,
|
|
3
|
+
CanvasRenderer,
|
|
4
|
+
CachedBatch2D,
|
|
5
|
+
DEFAULT_WASM_URL,
|
|
6
|
+
Easing,
|
|
7
|
+
Frame,
|
|
8
|
+
Group,
|
|
9
|
+
LOCAL,
|
|
10
|
+
PARENT,
|
|
11
|
+
WORLD,
|
|
12
|
+
Polyline,
|
|
13
|
+
PolylineInterpolation,
|
|
14
|
+
PrimitiveInterpolation,
|
|
15
|
+
ScalarValue,
|
|
16
|
+
Transform2D,
|
|
17
|
+
Vec2,
|
|
18
|
+
ZObject,
|
|
19
|
+
ZanimWasm,
|
|
20
|
+
appendOrdered,
|
|
21
|
+
assignState,
|
|
22
|
+
cloneState,
|
|
23
|
+
lerpColorValue,
|
|
24
|
+
lerpNumber,
|
|
25
|
+
lerpStyleState,
|
|
26
|
+
snapshotStyle,
|
|
27
|
+
} from './core.js';
|
|
28
|
+
import {
|
|
29
|
+
evaluateBatch,
|
|
30
|
+
evaluateObjectState,
|
|
31
|
+
evaluateValue,
|
|
32
|
+
parentWorldAt,
|
|
33
|
+
worldTransformAt,
|
|
34
|
+
} from './evaluator.js';
|
|
35
|
+
import { destroyScene, pauseScene, playScene, renderScene, seekScene } from './player.js';
|
|
36
|
+
|
|
37
|
+
class HeadlessRenderer {
|
|
38
|
+
constructor({ width = 1280, height = 720, unitSize = 90 } = {}) {
|
|
39
|
+
this.canvas = { width, height };
|
|
40
|
+
this.baseUnitSize = unitSize;
|
|
41
|
+
this.unitSize = unitSize;
|
|
42
|
+
this.dpr = 1;
|
|
43
|
+
}
|
|
44
|
+
resize() {}
|
|
45
|
+
clear() { throw new Error('headless Scene cannot render; compile it with @zanim/web/ir'); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function spansOverlap(a0, a1, b0, b1) {
|
|
49
|
+
if (a0 === a1 || b0 === b1) return false;
|
|
50
|
+
return a0 < b1 - 1e-12 && b0 < a1 - 1e-12;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function spansTouch(a0, a1, b0, b1) {
|
|
54
|
+
if (a0 === a1 && b0 === b1) return Math.abs(a0 - b0) <= 1e-12;
|
|
55
|
+
if (a0 === a1) return b0 - 1e-12 <= a0 && a0 < b1 - 1e-12;
|
|
56
|
+
if (b0 === b1) return a0 - 1e-12 <= b0 && b0 < a1 - 1e-12;
|
|
57
|
+
return a0 < b1 - 1e-12 && b0 < a1 - 1e-12;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class Scene {
|
|
61
|
+
constructor(renderer, { fps = 60 } = {}) {
|
|
62
|
+
this.renderer = renderer;
|
|
63
|
+
this.objects = [];
|
|
64
|
+
this.fps = fps;
|
|
65
|
+
this.cursor = 0;
|
|
66
|
+
this.duration = 0;
|
|
67
|
+
this.clips = [];
|
|
68
|
+
this.valueClips = [];
|
|
69
|
+
this.values = [];
|
|
70
|
+
this.interpolations = [];
|
|
71
|
+
this.initial = new Map();
|
|
72
|
+
this.authored = new Map();
|
|
73
|
+
this._trackedObjects = new Map();
|
|
74
|
+
this._clipsByObject = new Map();
|
|
75
|
+
this._valueClipsByValue = new Map();
|
|
76
|
+
this._valueAuthored = new Map();
|
|
77
|
+
this._batchInitial = new Map();
|
|
78
|
+
this._batchAuthored = new Map();
|
|
79
|
+
this._batchClipsByObject = new Map();
|
|
80
|
+
this._mediaClipsByObject = new Map();
|
|
81
|
+
this._worldSpaceSpans = new Map();
|
|
82
|
+
this._parallelBase = null;
|
|
83
|
+
this._parallelEnd = null;
|
|
84
|
+
this._parallelDuration = null;
|
|
85
|
+
this._renderList = [];
|
|
86
|
+
this._renderListDirty = true;
|
|
87
|
+
this.playing = false;
|
|
88
|
+
this._raf = null;
|
|
89
|
+
this._start = 0;
|
|
90
|
+
this.time = 0;
|
|
91
|
+
this.stats = { renderMs: 0, seekMs: 0, frames: 0 };
|
|
92
|
+
this._resizeObserver = null;
|
|
93
|
+
this.renderer.resize?.();
|
|
94
|
+
this.camera = new Camera2D(this);
|
|
95
|
+
this._track(this.camera, 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
static headless({ width = 1280, height = 720, unitSize = 90, fps = 60 } = {}) {
|
|
99
|
+
return new Scene(new HeadlessRenderer({ width, height, unitSize }), { fps });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static async create(canvas, { wasmURL = DEFAULT_WASM_URL, wasm = null, renderer = {}, fps = 60, observeResize = true } = {}) {
|
|
103
|
+
const target = typeof canvas === 'string' ? document.querySelector(canvas) : canvas;
|
|
104
|
+
if (!target || typeof target.getContext !== 'function') throw new TypeError('Scene.create requires a canvas element or selector');
|
|
105
|
+
const engine = wasm ?? await ZanimWasm.load(wasmURL);
|
|
106
|
+
const scene = new Scene(new CanvasRenderer(target, engine, renderer), { fps });
|
|
107
|
+
if (observeResize && typeof ResizeObserver !== 'undefined') {
|
|
108
|
+
scene._resizeObserver = new ResizeObserver(() => scene.render());
|
|
109
|
+
scene._resizeObserver.observe(target);
|
|
110
|
+
}
|
|
111
|
+
scene.render();
|
|
112
|
+
return scene;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_track(object, birth = this.cursor) {
|
|
116
|
+
if (!this._trackedObjects.has(object.id)) {
|
|
117
|
+
this._trackedObjects.set(object.id, object);
|
|
118
|
+
object._scene = this;
|
|
119
|
+
object.birth = birth;
|
|
120
|
+
const initial = cloneState(object);
|
|
121
|
+
this.initial.set(object.id, initial);
|
|
122
|
+
this.authored.set(object.id, { ...initial });
|
|
123
|
+
if (object instanceof CachedBatch2D) {
|
|
124
|
+
const batch = object.items.map(item => [...item]);
|
|
125
|
+
this._batchInitial.set(object.id, batch);
|
|
126
|
+
this._batchAuthored.set(object.id, batch.map(item => [...item]));
|
|
127
|
+
}
|
|
128
|
+
if (object instanceof Group) {
|
|
129
|
+
for (const child of object.children) {
|
|
130
|
+
child._parent = object;
|
|
131
|
+
this._track(child, birth);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return object;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
add(...objects) {
|
|
139
|
+
this._requireLifetimeBoundary();
|
|
140
|
+
for (const object of objects) {
|
|
141
|
+
if (!this.objects.includes(object)) {
|
|
142
|
+
this.objects.push(object);
|
|
143
|
+
this._renderListDirty = true;
|
|
144
|
+
}
|
|
145
|
+
this._track(object, this.cursor);
|
|
146
|
+
}
|
|
147
|
+
return objects.length === 1 ? objects[0] : objects;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
addLater(...objects) { return this.add(...objects); }
|
|
151
|
+
remove(...objects) {
|
|
152
|
+
this._requireLifetimeBoundary();
|
|
153
|
+
for (const object of objects) {
|
|
154
|
+
if (!this._trackedObjects.has(object.id)) throw new Error('object is not in this scene');
|
|
155
|
+
if (object === this.camera) throw new TypeError('Camera2D cannot be removed from Scene');
|
|
156
|
+
if (Number.isFinite(object.death)) throw new Error('object has already been removed from this scene');
|
|
157
|
+
if (this.cursor < object.birth) throw new Error('object cannot be removed before it is added');
|
|
158
|
+
object.death = this.cursor;
|
|
159
|
+
}
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
invalidateOrder() { this._renderListDirty = true; return this; }
|
|
163
|
+
|
|
164
|
+
addValue(...values) {
|
|
165
|
+
for (const value of values) {
|
|
166
|
+
if (!this.values.includes(value)) this.values.push(value);
|
|
167
|
+
if (!this._valueAuthored.has(value.id)) this._valueAuthored.set(value.id, value.initial);
|
|
168
|
+
}
|
|
169
|
+
return values.length === 1 ? values[0] : values;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
authoredState(object) {
|
|
173
|
+
const state = this.authored.get(object.id);
|
|
174
|
+
if (!state) throw new Error('object must be added before reading authored state');
|
|
175
|
+
return { ...state };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
authoredValue(value) {
|
|
179
|
+
if (!this._valueAuthored.has(value.id)) throw new Error('value must be added before reading authored state');
|
|
180
|
+
return this._valueAuthored.get(value.id);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
authoredCenter(object) {
|
|
184
|
+
return this._withAuthoredObjects([object], () => object.center);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_withAuthoredObjects(objects, callback) {
|
|
188
|
+
const saved = [];
|
|
189
|
+
const expanded = [];
|
|
190
|
+
const visit = object => {
|
|
191
|
+
if (expanded.includes(object)) return;
|
|
192
|
+
expanded.push(object);
|
|
193
|
+
if (object instanceof Group) for (const child of object.children) visit(child);
|
|
194
|
+
};
|
|
195
|
+
for (const object of objects) visit(object);
|
|
196
|
+
for (const object of expanded) {
|
|
197
|
+
if (!this.authored.has(object.id)) continue;
|
|
198
|
+
const batch = object instanceof CachedBatch2D ? object.items.map(item => [...item]) : null;
|
|
199
|
+
saved.push([object, cloneState(object), batch]);
|
|
200
|
+
assignState(object, this.authoredState(object));
|
|
201
|
+
if (batch && this._batchAuthored.has(object.id)) {
|
|
202
|
+
object.items = this._batchAuthored.get(object.id).map(item => [...item]);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
try { return callback(); }
|
|
206
|
+
finally {
|
|
207
|
+
for (let i = saved.length - 1; i >= 0; i--) {
|
|
208
|
+
const [object, state, batch] = saved[i];
|
|
209
|
+
assignState(object, state);
|
|
210
|
+
if (batch) object.items = batch;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
_scheduleBase() { return this._parallelBase == null ? this.cursor : this._parallelBase; }
|
|
216
|
+
|
|
217
|
+
_resolveDuration(duration) {
|
|
218
|
+
const value = duration ?? this._parallelDuration ?? 1;
|
|
219
|
+
if (!(value >= 0)) throw new RangeError('duration must be >= 0');
|
|
220
|
+
return Number(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_span(duration, at = 0) {
|
|
224
|
+
const resolved = this._resolveDuration(duration);
|
|
225
|
+
const start = this._scheduleBase() + Number(at);
|
|
226
|
+
return { start, end: start + resolved, duration: resolved };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_advanceAfterSchedule(end) {
|
|
230
|
+
if (this._parallelBase == null) this.cursor = end;
|
|
231
|
+
else this._parallelEnd = Math.max(this._parallelEnd, end);
|
|
232
|
+
this.duration = Math.max(this.duration, end);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
animateValue(value, { to, duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
236
|
+
if (!this.values.includes(value)) this.addValue(value);
|
|
237
|
+
const span = this._span(duration, at);
|
|
238
|
+
const existing = this._valueClipsByValue.get(value.id) ?? [];
|
|
239
|
+
if (existing.some(clip => spansOverlap(span.start, span.end, clip.start, clip.end))) throw new Error(`overlapping value channel for value ${value.id}`);
|
|
240
|
+
if (existing.length && span.start < existing.at(-1).start - 1e-12) throw new Error('clips on the same channel must be authored in chronological order');
|
|
241
|
+
const before = this.valueAt(value, span.start);
|
|
242
|
+
const clip = { value, start: span.start, end: span.end, before, after: Number(to), easing };
|
|
243
|
+
this.valueClips.push(clip);
|
|
244
|
+
appendOrdered(this._valueClipsByValue, value.id, clip);
|
|
245
|
+
this._advanceAfterSchedule(clip.end);
|
|
246
|
+
this._valueAuthored.set(value.id, Number(to));
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
valueAt(value, time) { return evaluateValue(this, value, time); }
|
|
251
|
+
|
|
252
|
+
wait(seconds = 1) {
|
|
253
|
+
if (this._parallelBase != null) throw new Error('wait() is not allowed inside parallel()');
|
|
254
|
+
this.cursor += seconds;
|
|
255
|
+
this.duration = Math.max(this.duration, this.cursor);
|
|
256
|
+
return this;
|
|
257
|
+
}
|
|
258
|
+
at(seconds) {
|
|
259
|
+
if (this._parallelBase != null) throw new Error('at() is not allowed inside parallel()');
|
|
260
|
+
this.cursor = Number(seconds);
|
|
261
|
+
this.duration = Math.max(this.duration, this.cursor);
|
|
262
|
+
return this;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_scheduleClip(object, clip) {
|
|
266
|
+
this.clips.push(clip);
|
|
267
|
+
appendOrdered(this._clipsByObject, object.id, clip);
|
|
268
|
+
this._advanceAfterSchedule(clip.end);
|
|
269
|
+
return clip;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
_requireLifetimeBoundary() {
|
|
273
|
+
if (this._parallelBase != null) throw new Error('add() and remove() are not allowed inside parallel()');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
_effectiveLifetime(object) {
|
|
277
|
+
let birth = object.birth;
|
|
278
|
+
let death = object.death;
|
|
279
|
+
for (const parent of this._ancestors(object)) {
|
|
280
|
+
birth = Math.max(birth, parent.birth);
|
|
281
|
+
death = Math.min(death, parent.death);
|
|
282
|
+
}
|
|
283
|
+
return { birth, death };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_requireAliveForSpan(object, span) {
|
|
287
|
+
if (!this._trackedObjects.has(object.id)) throw new Error('object must be added before animation');
|
|
288
|
+
const { birth, death } = this._effectiveLifetime(object);
|
|
289
|
+
if (span.start < birth - 1e-12) throw new Error(`animation starts at ${span.start}, before object lifetime begins at ${birth}`);
|
|
290
|
+
if (Number.isFinite(death) && (span.end > death + 1e-12 || span.start >= death - 1e-12)) throw new Error(`animation lies outside object lifetime ending at ${death}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_clipChannels(clip) {
|
|
294
|
+
if (clip.kind === 'transformFunction') return ['transform'];
|
|
295
|
+
if (clip.kind !== 'state') return [];
|
|
296
|
+
return Object.keys(clip.changes).map(key => key === 'reveal' ? 'trim' : key);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
_assertObjectChannelsAvailable(object, channels, span) {
|
|
300
|
+
const wanted = new Set(channels);
|
|
301
|
+
let latestStart = -Infinity;
|
|
302
|
+
for (const clip of this._clipsByObject.get(object.id) ?? []) {
|
|
303
|
+
if (!this._clipChannels(clip).some(channel => wanted.has(channel))) continue;
|
|
304
|
+
latestStart = Math.max(latestStart, clip.start);
|
|
305
|
+
if (spansOverlap(span.start, span.end, clip.start, clip.end)) throw new Error(`overlapping animation channel for object ${object.id}`);
|
|
306
|
+
}
|
|
307
|
+
if (span.start < latestStart - 1e-12) throw new Error(`clips on the same channel must be authored in chronological order`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
_transformClips(object) {
|
|
311
|
+
return (this._clipsByObject.get(object.id) ?? []).filter(
|
|
312
|
+
clip => clip.kind === 'transformFunction' || clip.changes?.transform,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
_ancestors(object) {
|
|
317
|
+
const out = [];
|
|
318
|
+
let parent = object._parent;
|
|
319
|
+
while (parent) { out.push(parent); parent = parent._parent; }
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
_isAncestor(ancestor, object) {
|
|
324
|
+
let parent = object._parent;
|
|
325
|
+
while (parent) {
|
|
326
|
+
if (parent === ancestor) return true;
|
|
327
|
+
parent = parent._parent;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
_assertWorldParentStatic(object, start, end) {
|
|
333
|
+
for (const parent of this._ancestors(object)) {
|
|
334
|
+
for (const clip of this._transformClips(parent)) {
|
|
335
|
+
if (spansTouch(start, end, clip.start, clip.end)) {
|
|
336
|
+
throw new Error('WORLD transform on a nested object requires all ancestors to remain transform-static over the same span; use LOCAL/PARENT for articulated motion');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_assertNoDescendantWorldDependency(object, start, end) {
|
|
343
|
+
for (const [id, spans] of this._worldSpaceSpans) {
|
|
344
|
+
const child = this._trackedObjects.get(id);
|
|
345
|
+
if (!child || !this._isAncestor(object, child)) continue;
|
|
346
|
+
if (spans.some(([s0, s1]) => spansTouch(start, end, s0, s1))) {
|
|
347
|
+
throw new Error('ancestor transform overlaps a nested WORLD transform; use LOCAL/PARENT for articulated motion');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
_recordWorldSpan(object, start, end) {
|
|
353
|
+
let spans = this._worldSpaceSpans.get(object.id);
|
|
354
|
+
if (!spans) this._worldSpaceSpans.set(object.id, spans = []);
|
|
355
|
+
spans.push([start, end]);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
animate(object, { transform = undefined, opacity = undefined, reveal = undefined, style = undefined, duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
359
|
+
const span = this._span(duration, at);
|
|
360
|
+
this._requireAliveForSpan(object, span);
|
|
361
|
+
if (transform !== undefined) this._assertNoDescendantWorldDependency(object, span.start, span.end);
|
|
362
|
+
const before = this.stateAt(object, span.start);
|
|
363
|
+
const changes = {};
|
|
364
|
+
if (transform !== undefined) changes.transform = { before: before.transform, after: transform };
|
|
365
|
+
if (opacity !== undefined) changes.opacity = { before: before.opacity, after: Number(opacity) };
|
|
366
|
+
if (reveal !== undefined) changes.reveal = { before: before.reveal, after: Number(reveal) };
|
|
367
|
+
if (style !== undefined) changes.style = { before: before.style, after: style };
|
|
368
|
+
this._assertObjectChannelsAvailable(object, Object.keys(changes).map(key => key === 'reveal' ? 'trim' : key), span);
|
|
369
|
+
const clip = { kind: 'state', object, start: span.start, end: span.end, easing, changes };
|
|
370
|
+
this._scheduleClip(object, clip);
|
|
371
|
+
const authored = { ...(this.authored.get(object.id) ?? before) };
|
|
372
|
+
for (const [key, value] of Object.entries(changes)) authored[key] = value.after;
|
|
373
|
+
this.authored.set(object.id, authored);
|
|
374
|
+
return object;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
transformFunction(object, provider, { duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
378
|
+
const span = this._span(duration, at);
|
|
379
|
+
this._requireAliveForSpan(object, span);
|
|
380
|
+
this._assertNoDescendantWorldDependency(object, span.start, span.end);
|
|
381
|
+
this._assertObjectChannelsAvailable(object, ['transform'], span);
|
|
382
|
+
const before = this.stateAt(object, span.start);
|
|
383
|
+
const target = provider(1);
|
|
384
|
+
if (!(target instanceof Transform2D)) throw new TypeError('transformFunction provider must return Transform2D');
|
|
385
|
+
const clip = { kind: 'transformFunction', object, start: span.start, end: span.end, easing, provider, before: before.transform, after: target };
|
|
386
|
+
this._scheduleClip(object, clip);
|
|
387
|
+
this.authored.set(object.id, { ...this.authoredState(object), transform: target });
|
|
388
|
+
return object;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
fadeIn(object, { duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
392
|
+
const before = this.stateAt(object, this._span(duration, at).start);
|
|
393
|
+
if (Math.abs(before.opacity) > 1e-12) throw new Error(`fadeIn() requires opacity 0, got ${before.opacity}`);
|
|
394
|
+
return this.animate(object, { opacity: 1, duration, easing, at });
|
|
395
|
+
}
|
|
396
|
+
fadeOut(object, { duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) { return this.animate(object, { opacity: 0, duration, easing, at }); }
|
|
397
|
+
style(object, { to, duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) { if (!snapshotStyle(object)) throw new TypeError('style() requires a styled 2D object'); return this.animate(object, { style: to, duration, easing, at }); }
|
|
398
|
+
trim(object, { to, duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) { if (!('reveal' in object)) throw new TypeError('trim() requires a path-trimmable object'); if (!(to >= 0 && to <= 1)) throw new RangeError('trim target must be in [0,1]'); return this.animate(object, { reveal: to, duration, easing, at }); }
|
|
399
|
+
create(object, { duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) { const before = this.stateAt(object, this._span(duration, at).start); if (before.reveal == null) throw new TypeError('create() currently supports path objects'); if (Math.abs(before.reveal) > 1e-12) throw new Error(`create() requires trim 0, got ${before.reveal}`); return this.trim(object, { to: 1, duration, easing, at }); }
|
|
400
|
+
|
|
401
|
+
batchAt(object, time) { return evaluateBatch(this, object, time); }
|
|
402
|
+
|
|
403
|
+
media(object, { duration = null, sourceStart = 0, speed = 1, loop = false, sourceDuration = object?.duration ?? null, at = 0 } = {}) {
|
|
404
|
+
if (!object || !object._mediaKind) throw new TypeError('media() requires a Web media object');
|
|
405
|
+
const rate = Number(speed);
|
|
406
|
+
if (!(rate > 0) || !Number.isFinite(rate)) throw new RangeError('media speed must be positive');
|
|
407
|
+
const startAt = Number(sourceStart);
|
|
408
|
+
if (startAt < 0 || !Number.isFinite(startAt)) throw new RangeError('media sourceStart must be >= 0');
|
|
409
|
+
let resolvedDuration = duration;
|
|
410
|
+
const sourceLength = sourceDuration == null ? null : Number(sourceDuration);
|
|
411
|
+
if (resolvedDuration == null) {
|
|
412
|
+
if (!(sourceLength >= 0) || !Number.isFinite(sourceLength)) throw new Error('media playback needs duration until source metadata is available');
|
|
413
|
+
resolvedDuration = Math.max(0, sourceLength - startAt) / rate;
|
|
414
|
+
}
|
|
415
|
+
const span = this._span(resolvedDuration, at);
|
|
416
|
+
this._requireAliveForSpan(object, span);
|
|
417
|
+
const existing = this._mediaClipsByObject.get(object.id) ?? [];
|
|
418
|
+
if (existing.some(clip => spansOverlap(span.start, span.end, clip.start, clip.end))) throw new Error(`overlapping media channel for object ${object.id}`);
|
|
419
|
+
if (existing.length && span.start < existing.at(-1).start - 1e-12) throw new Error('clips on the same channel must be authored in chronological order');
|
|
420
|
+
const clip = { kind:'media', object, start:span.start, end:span.end, sourceStart:startAt, speed:rate, loop:!!loop, sourceDuration:sourceLength };
|
|
421
|
+
appendOrdered(this._mediaClipsByObject, object.id, clip);
|
|
422
|
+
this.clips.push(clip);
|
|
423
|
+
this._advanceAfterSchedule(span.end);
|
|
424
|
+
return object;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
mediaPlaybackAt(object, time) {
|
|
428
|
+
for (const clip of this._mediaClipsByObject.get(object.id) ?? []) if (time >= clip.start && time < clip.end) return clip;
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
mediaTimeAt(object, time) {
|
|
433
|
+
const clips = this._mediaClipsByObject.get(object.id) ?? [];
|
|
434
|
+
if (!clips.length) return 0;
|
|
435
|
+
const clip = this.mediaPlaybackAt(object, time);
|
|
436
|
+
if (!clip) return null;
|
|
437
|
+
const sourceDuration = clip.sourceDuration ?? object.duration;
|
|
438
|
+
if (!(sourceDuration >= 0)) return clip.sourceStart;
|
|
439
|
+
const elapsed = Math.max(0, Number(time) - clip.start) * clip.speed;
|
|
440
|
+
if (clip.loop) {
|
|
441
|
+
const length = sourceDuration - clip.sourceStart;
|
|
442
|
+
return length > 0 ? clip.sourceStart + (elapsed % length) : clip.sourceStart;
|
|
443
|
+
}
|
|
444
|
+
return Math.min(sourceDuration, clip.sourceStart + elapsed);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
batch(object, { to, duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
448
|
+
if (!(object instanceof CachedBatch2D)) throw new TypeError('batch() requires a batch object');
|
|
449
|
+
const target = to instanceof CachedBatch2D ? to.items : to;
|
|
450
|
+
if (!Array.isArray(target)) throw new TypeError('batch target must be an item array or batch object');
|
|
451
|
+
const span = this._span(duration, at);
|
|
452
|
+
this._requireAliveForSpan(object, span);
|
|
453
|
+
const existing = this._batchClipsByObject.get(object.id) ?? [];
|
|
454
|
+
if (existing.some(clip => spansOverlap(span.start, span.end, clip.start, clip.end))) throw new Error(`overlapping batch channel for object ${object.id}`);
|
|
455
|
+
if (existing.length && span.start < existing.at(-1).start - 1e-12) throw new Error('clips on the same channel must be authored in chronological order');
|
|
456
|
+
const before = this.batchAt(object, span.start);
|
|
457
|
+
if (before.length !== target.length) throw new RangeError('batch interpolation requires matching item counts');
|
|
458
|
+
const clip = { kind: 'batch', object, start: span.start, end: span.end, before, after: target.map(item => [...item]), easing };
|
|
459
|
+
appendOrdered(this._batchClipsByObject, object.id, clip);
|
|
460
|
+
this.clips.push(clip);
|
|
461
|
+
this._advanceAfterSchedule(clip.end);
|
|
462
|
+
this._batchAuthored.set(object.id, clip.after.map(item => [...item]));
|
|
463
|
+
return object;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
_parentWorldAt(object, time) { return parentWorldAt(this, object, time); }
|
|
467
|
+
|
|
468
|
+
worldTransformAt(object, time = this.cursor) { return worldTransformAt(this, object, time); }
|
|
469
|
+
|
|
470
|
+
move(object, by, { frame = WORLD, duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
471
|
+
const span = this._span(duration, at);
|
|
472
|
+
const v = Vec2.from(by), current = this.authoredState(object).transform, delta = Transform2D.translation(v.x, v.y);
|
|
473
|
+
let target;
|
|
474
|
+
if (frame === LOCAL) target = current.mul(delta);
|
|
475
|
+
else if (frame === PARENT) target = delta.mul(current);
|
|
476
|
+
else if (frame === WORLD) {
|
|
477
|
+
const parent = this._parentWorldAt(object, span.start);
|
|
478
|
+
if (object._parent) this._assertWorldParentStatic(object, span.start, span.end);
|
|
479
|
+
this._assertNoDescendantWorldDependency(object, span.start, span.end);
|
|
480
|
+
target = parent.inverse().mul(delta).mul(parent).mul(current);
|
|
481
|
+
if (object._parent) this._recordWorldSpan(object, span.start, span.end);
|
|
482
|
+
} else throw new Error(`unknown frame ${frame}`);
|
|
483
|
+
return this.animate(object, { transform: target, duration, easing, at });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
rotate(object, by, { frame = PARENT, about = null, duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
487
|
+
const span = this._span(duration, at);
|
|
488
|
+
const current = this.authoredState(object).transform;
|
|
489
|
+
const angle = Number(by);
|
|
490
|
+
if (!Number.isFinite(angle)) throw new RangeError('rotation angle must be finite');
|
|
491
|
+
let provider;
|
|
492
|
+
if (about) {
|
|
493
|
+
const q = Vec2.from(about), parent = this._parentWorldAt(object, span.start);
|
|
494
|
+
if (object._parent) this._assertWorldParentStatic(object, span.start, span.end);
|
|
495
|
+
this._assertNoDescendantWorldDependency(object, span.start, span.end);
|
|
496
|
+
const inverse = parent.inverse();
|
|
497
|
+
provider = alpha => inverse.mul(Transform2D.translation(q.x, q.y))
|
|
498
|
+
.mul(Transform2D.rotation(angle * alpha)).mul(Transform2D.translation(-q.x, -q.y))
|
|
499
|
+
.mul(parent).mul(current);
|
|
500
|
+
if (object._parent) this._recordWorldSpan(object, span.start, span.end);
|
|
501
|
+
} else if (frame === LOCAL) provider = alpha => current.mul(Transform2D.rotation(angle * alpha));
|
|
502
|
+
else if (frame === PARENT) provider = alpha => Transform2D.rotation(angle * alpha).mul(current);
|
|
503
|
+
else if (frame === WORLD) {
|
|
504
|
+
const parent = this._parentWorldAt(object, span.start);
|
|
505
|
+
if (object._parent) this._assertWorldParentStatic(object, span.start, span.end);
|
|
506
|
+
this._assertNoDescendantWorldDependency(object, span.start, span.end);
|
|
507
|
+
const inverse = parent.inverse();
|
|
508
|
+
provider = alpha => inverse.mul(Transform2D.rotation(angle * alpha)).mul(parent).mul(current);
|
|
509
|
+
if (object._parent) this._recordWorldSpan(object, span.start, span.end);
|
|
510
|
+
} else throw new Error(`unknown frame ${frame}`);
|
|
511
|
+
// Angle interpolation preserves length, pivot arcs and full revolutions.
|
|
512
|
+
return this.transformFunction(object, provider, { duration, easing, at });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
scale(object, by, { frame = PARENT, about = null, duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
516
|
+
const span = this._span(duration, at);
|
|
517
|
+
const S = Transform2D.scaling(by), current = this.authoredState(object).transform;
|
|
518
|
+
let target;
|
|
519
|
+
if (about) {
|
|
520
|
+
const q = Vec2.from(about), parent = this._parentWorldAt(object, span.start);
|
|
521
|
+
if (object._parent) this._assertWorldParentStatic(object, span.start, span.end);
|
|
522
|
+
this._assertNoDescendantWorldDependency(object, span.start, span.end);
|
|
523
|
+
const op = Transform2D.translation(q.x, q.y).mul(S).mul(Transform2D.translation(-q.x, -q.y));
|
|
524
|
+
target = parent.inverse().mul(op).mul(parent).mul(current);
|
|
525
|
+
if (object._parent) this._recordWorldSpan(object, span.start, span.end);
|
|
526
|
+
} else if (frame === LOCAL) target = current.mul(S);
|
|
527
|
+
else if (frame === PARENT) target = S.mul(current);
|
|
528
|
+
else if (frame === WORLD) {
|
|
529
|
+
const parent = this._parentWorldAt(object, span.start);
|
|
530
|
+
if (object._parent) this._assertWorldParentStatic(object, span.start, span.end);
|
|
531
|
+
this._assertNoDescendantWorldDependency(object, span.start, span.end);
|
|
532
|
+
target = parent.inverse().mul(S).mul(parent).mul(current);
|
|
533
|
+
if (object._parent) this._recordWorldSpan(object, span.start, span.end);
|
|
534
|
+
} else throw new Error(`unknown frame ${frame}`);
|
|
535
|
+
return this.animate(object, { transform: target, duration, easing, at });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
affine(object, { position = [0, 0], rotation = 0, scale = 1, shear = [0, 0], duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
539
|
+
return this.animate(object, { transform: Transform2D.affine({ position, rotation, scale, shear }), duration, easing, at });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
interpolate(source, target, { duration = null, easing = Easing.SMOOTHSTEP, at = 0 } = {}) {
|
|
543
|
+
const span = this._span(duration, at);
|
|
544
|
+
const sourceState = this.authored.has(source.id) ? this.authoredState(source) : cloneState(source);
|
|
545
|
+
const targetState = this.authored.has(target.id) ? this.authoredState(target) : cloneState(target);
|
|
546
|
+
const transient = this._withAuthoredObjects([source, target], () =>
|
|
547
|
+
(source instanceof Polyline && target instanceof Polyline && !source.closed && !target.closed)
|
|
548
|
+
? new PolylineInterpolation(source, target, span.start, span.end, easing)
|
|
549
|
+
: new PrimitiveInterpolation(source, target, span.start, span.end, easing));
|
|
550
|
+
transient._transientInterpolation = true;
|
|
551
|
+
this.objects.push(transient);
|
|
552
|
+
this._track(transient, span.start);
|
|
553
|
+
transient.birth = span.start;
|
|
554
|
+
transient.death = span.end;
|
|
555
|
+
this.interpolations.push({ source, target, sourceState, targetState, start: span.start, end: span.end, easing, transient });
|
|
556
|
+
this._renderListDirty = true;
|
|
557
|
+
this._advanceAfterSchedule(span.end);
|
|
558
|
+
return transient;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
replace(source, target, { duration = 1, easing = Easing.SMOOTHSTEP } = {}) {
|
|
562
|
+
if (!this.objects.includes(source)) throw new Error('replace() source must be a top-level scene object');
|
|
563
|
+
if (this._trackedObjects.has(target.id)) throw new Error('replace() target must not already be in the scene');
|
|
564
|
+
const start = this.cursor, end = start + duration;
|
|
565
|
+
this.interpolate(source, target, { duration, easing, at: 0 });
|
|
566
|
+
source.death = start;
|
|
567
|
+
this.objects.push(target);
|
|
568
|
+
this._track(target, end);
|
|
569
|
+
target.birth = end;
|
|
570
|
+
this._renderListDirty = true;
|
|
571
|
+
this.cursor = end;
|
|
572
|
+
this.duration = Math.max(this.duration, end);
|
|
573
|
+
return target;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
parallel(durationOrCallback, maybeCallback) {
|
|
577
|
+
if (this._parallelBase != null) throw new Error('nested parallel() blocks are not supported');
|
|
578
|
+
const shared = typeof durationOrCallback === 'function' ? null : Number(durationOrCallback);
|
|
579
|
+
const callback = typeof durationOrCallback === 'function' ? durationOrCallback : maybeCallback;
|
|
580
|
+
if (typeof callback !== 'function') throw new TypeError('parallel requires a callback');
|
|
581
|
+
if (shared != null && shared < 0) throw new RangeError('parallel duration must be >= 0');
|
|
582
|
+
this._parallelBase = this.cursor;
|
|
583
|
+
this._parallelEnd = this.cursor;
|
|
584
|
+
this._parallelDuration = shared;
|
|
585
|
+
const withShared = opts => shared == null || opts?.duration != null ? (opts ?? {}) : { ...(opts ?? {}), duration: shared };
|
|
586
|
+
const api = {
|
|
587
|
+
animate: (obj, opts = {}) => this.animate(obj, withShared(opts)),
|
|
588
|
+
animateValue: (value, opts = {}) => this.animateValue(value, withShared(opts)),
|
|
589
|
+
transformFunction: (obj, provider, opts = {}) => this.transformFunction(obj, provider, withShared(opts)),
|
|
590
|
+
fadeIn: (obj, opts = {}) => this.fadeIn(obj, withShared(opts)),
|
|
591
|
+
fadeOut: (obj, opts = {}) => this.fadeOut(obj, withShared(opts)),
|
|
592
|
+
create: (obj, opts = {}) => this.create(obj, withShared(opts)),
|
|
593
|
+
style: (obj, opts = {}) => this.style(obj, withShared(opts)),
|
|
594
|
+
batch: (obj, opts = {}) => this.batch(obj, withShared(opts)),
|
|
595
|
+
media: (obj, opts = {}) => this.media(obj, withShared(opts)),
|
|
596
|
+
move: (obj, by, opts = {}) => this.move(obj, by, withShared(opts)),
|
|
597
|
+
rotate: (obj, by, opts = {}) => this.rotate(obj, by, withShared(opts)),
|
|
598
|
+
scale: (obj, by, opts = {}) => this.scale(obj, by, withShared(opts)),
|
|
599
|
+
affine: (obj, opts = {}) => this.affine(obj, withShared(opts)),
|
|
600
|
+
interpolate: (source, target, opts = {}) => this.interpolate(source, target, withShared(opts)),
|
|
601
|
+
};
|
|
602
|
+
try { callback(api); }
|
|
603
|
+
finally {
|
|
604
|
+
this.cursor = Math.max(this.cursor, this._parallelEnd);
|
|
605
|
+
this._parallelBase = null;
|
|
606
|
+
this._parallelEnd = null;
|
|
607
|
+
this._parallelDuration = null;
|
|
608
|
+
}
|
|
609
|
+
return this;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
stateAt(object, time) { return evaluateObjectState(this, object, time); }
|
|
613
|
+
|
|
614
|
+
get frame() { return Frame.fromRenderer(this.renderer); }
|
|
615
|
+
|
|
616
|
+
layout(...args) {
|
|
617
|
+
let options = args.at(-1);
|
|
618
|
+
if (!options || typeof options !== 'object' || !('to' in options)) throw new TypeError('Scene.layout requires {to, duration?, easing?, at?}');
|
|
619
|
+
args = args.slice(0, -1);
|
|
620
|
+
const objects = args.length === 1 && args[0] instanceof Group ? args[0].children : args;
|
|
621
|
+
const targets = this._withAuthoredObjects(objects, () => options.to.targets(...objects));
|
|
622
|
+
this.parallel(options.duration ?? 1, api => objects.forEach((object, i) => api.animate(object, { transform: targets[i], easing: options.easing ?? Easing.SMOOTHSTEP, at: options.at ?? 0 })));
|
|
623
|
+
return objects;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
seek(time) { return seekScene(this, time); }
|
|
627
|
+
render() { return renderScene(this); }
|
|
628
|
+
play(options = {}) { return playScene(this, options); }
|
|
629
|
+
pause() { return pauseScene(this); }
|
|
630
|
+
destroy() { return destroyScene(this); }
|
|
631
|
+
setMatrix(matrix) { for (const object of this.objects) { object.transform = Transform2D.fromMat2(matrix); const state = cloneState(object); this.initial.set(object.id, state); this.authored.set(object.id, { ...state }); } this.render(); }
|
|
632
|
+
animateTo(target, duration = 1000) { const seconds = duration / 1000, start = this.time || 0, targets = this.objects.map(object => [object, Transform2D.fromMat2(target)]); this.at(start); this.parallel(seconds, api => { for (const [object, transform] of targets) api.animate(object, { transform }); }); this.play({ from: start }); }
|
|
633
|
+
}
|