@zkmake/three-cameras 0.0.0 → 0.2.0

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.
@@ -0,0 +1,1324 @@
1
+ import { BufferGeometry, CameraHelper, Euler, Float32BufferAttribute, Group, Line, LineBasicMaterial, Matrix4, Points, PointsMaterial, Quaternion, Vector3, Vector4 } from "three";
2
+ //#region src/core/discover.ts
3
+ const isCubeCamera = (object) => object.type === "CubeCamera";
4
+ const isCamera = (object) => object.isCamera === true;
5
+ const kindOf = (camera) => {
6
+ if (isCubeCamera(camera)) return "cube";
7
+ if (camera.isArrayCamera) return "array";
8
+ if (camera.isPerspectiveCamera) return "perspective";
9
+ if (camera.isOrthographicCamera) return "orthographic";
10
+ return "other";
11
+ };
12
+ /** Every camera under `root`, in scene order, `root` included. */
13
+ const findCameras = (root) => {
14
+ const found = [];
15
+ const visit = (object) => {
16
+ if (isCubeCamera(object)) {
17
+ found.push(object);
18
+ return;
19
+ }
20
+ if (isCamera(object)) found.push(object);
21
+ for (const child of object.children) visit(child);
22
+ };
23
+ visit(root);
24
+ return found;
25
+ };
26
+ /** Where it sits: its ancestors' names (or types) from the root down, `scene › rig › cam`. */
27
+ const pathOf = (object) => {
28
+ const path = [];
29
+ for (let node = object.parent; node; node = node.parent) path.unshift(node.name || node.type);
30
+ return path;
31
+ };
32
+ //#endregion
33
+ //#region src/core/pose.ts
34
+ /**
35
+ * A camera's pose: where it is, which way it faces, and how it projects. Poses are what saved
36
+ * views hold now and what keyframes will: capture one, put one back, and blend between two.
37
+ *
38
+ * Position and rotation are the camera's own (local, relative to its parent), so a pose put back
39
+ * lands where it was taken even if the parent has moved since, the same way a keyframe would.
40
+ */
41
+ const isPerspective = (camera) => camera.isPerspectiveCamera === true;
42
+ const projects = (camera) => isPerspective(camera) || camera.isOrthographicCamera === true;
43
+ const capturePose = (camera) => {
44
+ const { position, quaternion } = camera;
45
+ const pose = {
46
+ position: [
47
+ position.x,
48
+ position.y,
49
+ position.z
50
+ ],
51
+ quaternion: [
52
+ quaternion.x,
53
+ quaternion.y,
54
+ quaternion.z,
55
+ quaternion.w
56
+ ],
57
+ near: 0,
58
+ far: 0,
59
+ zoom: 1
60
+ };
61
+ if (projects(camera)) {
62
+ pose.near = camera.near;
63
+ pose.far = camera.far;
64
+ pose.zoom = camera.zoom;
65
+ }
66
+ if (isPerspective(camera)) pose.fov = camera.fov;
67
+ return pose;
68
+ };
69
+ const applyPose = (camera, pose) => {
70
+ camera.position.fromArray(pose.position);
71
+ camera.quaternion.fromArray(pose.quaternion);
72
+ if (projects(camera)) {
73
+ camera.near = pose.near;
74
+ camera.far = pose.far;
75
+ camera.zoom = pose.zoom;
76
+ if (isPerspective(camera) && pose.fov !== void 0) camera.fov = pose.fov;
77
+ camera.updateProjectionMatrix();
78
+ }
79
+ };
80
+ const lerp = (a, b, t) => a + (b - a) * t;
81
+ const from = new Quaternion();
82
+ const to = new Quaternion();
83
+ const va = new Vector3();
84
+ const vb = new Vector3();
85
+ /** The pose `t` of the way from `a` to `b`: positions and lenses straight, rotation by slerp. */
86
+ const blendPoses = (a, b, t) => {
87
+ const position = va.fromArray(a.position).lerp(vb.fromArray(b.position), t);
88
+ const quaternion = from.fromArray(a.quaternion).slerp(to.fromArray(b.quaternion), t);
89
+ const pose = {
90
+ position: [
91
+ position.x,
92
+ position.y,
93
+ position.z
94
+ ],
95
+ quaternion: [
96
+ quaternion.x,
97
+ quaternion.y,
98
+ quaternion.z,
99
+ quaternion.w
100
+ ],
101
+ near: lerp(a.near, b.near, t),
102
+ far: lerp(a.far, b.far, t),
103
+ zoom: lerp(a.zoom, b.zoom, t)
104
+ };
105
+ if (a.fov !== void 0 && b.fov !== void 0) pose.fov = lerp(a.fov, b.fov, t);
106
+ return pose;
107
+ };
108
+ /** Slow in, slow out: a camera move that starts and settles gently. */
109
+ const easeInOut = (t) => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
110
+ const round = (value, digits = 4) => {
111
+ const text = value.toFixed(digits);
112
+ return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
113
+ };
114
+ /** Three.js to recreate the pose on a camera called `name`. */
115
+ const poseToCode = (pose, name = "camera") => {
116
+ const [x, y, z, w] = pose.quaternion;
117
+ const lines = [`${name}.position.set(${pose.position.map((value) => round(value)).join(", ")});`, `${name}.quaternion.set(${[
118
+ x,
119
+ y,
120
+ z,
121
+ w
122
+ ].map((value) => round(value, 5)).join(", ")});`];
123
+ if (pose.fov !== void 0) lines.push(`${name}.fov = ${round(pose.fov, 2)};`);
124
+ if (pose.zoom !== 1) lines.push(`${name}.zoom = ${round(pose.zoom, 3)};`);
125
+ lines.push(`${name}.near = ${round(pose.near)};`, `${name}.far = ${round(pose.far)};`);
126
+ lines.push(`${name}.updateProjectionMatrix();`);
127
+ return lines.join("\n");
128
+ };
129
+ //#endregion
130
+ //#region src/core/track.ts
131
+ /**
132
+ * A camera's track: keyframes in time, each a pose and the ease into the next. `sampleTrack` gives
133
+ * the pose at any time: the camera travels a smooth curve through the keyed positions
134
+ * (Catmull-Rom), turns by slerp, and its lens (fov, zoom, near, far) goes straight between keys,
135
+ * all paced by the outgoing key's ease. Before the first key it holds the first; after the last,
136
+ * the last.
137
+ *
138
+ * An ease is a preset or a custom cubic Bézier (CSS `cubic-bezier` terms: the curve from (0, 0) to
139
+ * (1, 1) through two handles). A handle may go above 1 or below 0, for overshoot and anticipation.
140
+ */
141
+ const EASES = [
142
+ "ease-in-out",
143
+ "linear",
144
+ "ease-in",
145
+ "ease-out",
146
+ "hold",
147
+ "custom"
148
+ ];
149
+ /** The presets as Bézier handles: where a custom curve starts when a preset is reshaped. */
150
+ const PRESET_BEZIERS = {
151
+ linear: [
152
+ .25,
153
+ .25,
154
+ .75,
155
+ .75
156
+ ],
157
+ "ease-in": [
158
+ .32,
159
+ 0,
160
+ .67,
161
+ 0
162
+ ],
163
+ "ease-out": [
164
+ .33,
165
+ 1,
166
+ .68,
167
+ 1
168
+ ],
169
+ "ease-in-out": [
170
+ .65,
171
+ 0,
172
+ .35,
173
+ 1
174
+ ]
175
+ };
176
+ const bezierAxis = (a, b, t) => {
177
+ const u = 1 - t;
178
+ return 3 * u * u * t * a + 3 * u * t * t * b + t * t * t;
179
+ };
180
+ const bezierSlope = (a, b, t) => {
181
+ const u = 1 - t;
182
+ return 3 * u * u * a + 6 * u * t * (b - a) + 3 * t * t * (1 - b);
183
+ };
184
+ /** A cubic Bézier ease: progress (y) at time share `x`, solving the curve's x for its parameter. */
185
+ const cubicBezier = ([x1, y1, x2, y2], x) => {
186
+ if (x <= 0 || x >= 1) return x <= 0 ? 0 : 1;
187
+ let t = x;
188
+ for (let i = 0; i < 8; i += 1) {
189
+ const error = bezierAxis(x1, x2, t) - x;
190
+ const slope = bezierSlope(x1, x2, t);
191
+ if (Math.abs(error) < 1e-7) return bezierAxis(y1, y2, t);
192
+ if (Math.abs(slope) < 1e-6) break;
193
+ t -= error / slope;
194
+ }
195
+ let low = 0;
196
+ let high = 1;
197
+ t = x;
198
+ for (let i = 0; i < 40; i += 1) {
199
+ const value = bezierAxis(x1, x2, t);
200
+ if (Math.abs(value - x) < 1e-7) break;
201
+ if (value < x) low = t;
202
+ else high = t;
203
+ t = (low + high) / 2;
204
+ }
205
+ return bezierAxis(y1, y2, t);
206
+ };
207
+ const easeFns = {
208
+ linear: (t) => t,
209
+ "ease-in": (t) => t * t * t,
210
+ "ease-out": (t) => 1 - Math.pow(1 - t, 3),
211
+ "ease-in-out": (t) => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
212
+ hold: () => 0
213
+ };
214
+ /** Progress through a segment at time share `t` (0–1). A custom curve may leave 0–1 in between. */
215
+ const applyEase = (ease, t, bezier) => {
216
+ const clamped = Math.min(1, Math.max(0, t));
217
+ if (ease === "custom") return cubicBezier(bezier ?? PRESET_BEZIERS.linear, clamped);
218
+ return easeFns[ease](clamped);
219
+ };
220
+ const sortKeys = (keys) => [...keys].sort((a, b) => a.time - b.time);
221
+ const p0 = new Vector3();
222
+ const p1 = new Vector3();
223
+ const p2 = new Vector3();
224
+ const p3 = new Vector3();
225
+ const out = new Vector3();
226
+ /** Uniform Catmull-Rom through p1 → p2, with p0 and p3 as the neighbours shaping the bend. */
227
+ const catmullRom = (t) => {
228
+ const t2 = t * t;
229
+ const t3 = t2 * t;
230
+ return out.set(.5 * (2 * p1.x + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2 + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3), .5 * (2 * p1.y + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2 + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3), .5 * (2 * p1.z + (-p0.z + p2.z) * t + (2 * p0.z - 5 * p1.z + 4 * p2.z - p3.z) * t2 + (-p0.z + 3 * p1.z - 3 * p2.z + p3.z) * t3));
231
+ };
232
+ /** The pose at `time` on sorted `keys`, or `null` with no keys. */
233
+ const sampleTrack = (keys, time) => {
234
+ const first = keys[0];
235
+ const last = keys.at(-1);
236
+ if (!first || !last) return null;
237
+ if (time <= first.time || keys.length === 1) return first.pose;
238
+ if (time >= last.time) return last.pose;
239
+ let index = 0;
240
+ while (index < keys.length - 2 && keys[index + 1].time <= time) index += 1;
241
+ const from = keys[index];
242
+ const to = keys[index + 1];
243
+ const span = to.time - from.time;
244
+ const t = applyEase(from.ease, span > 0 ? (time - from.time) / span : 1, from.bezier);
245
+ const pose = blendPoses(from.pose, to.pose, t);
246
+ if (from.ease !== "hold") {
247
+ const before = keys[index - 1];
248
+ const after = keys[index + 2];
249
+ p1.fromArray(from.pose.position);
250
+ p2.fromArray(to.pose.position);
251
+ if (before) p0.fromArray(before.pose.position);
252
+ else p0.copy(p1).multiplyScalar(2).sub(p2);
253
+ if (after) p3.fromArray(after.pose.position);
254
+ else p3.copy(p2).multiplyScalar(2).sub(p1);
255
+ const point = catmullRom(t);
256
+ pose.position = [
257
+ point.x,
258
+ point.y,
259
+ point.z
260
+ ];
261
+ }
262
+ return pose;
263
+ };
264
+ //#endregion
265
+ //#region src/core/lab.ts
266
+ /**
267
+ * `CameraLab`: the cameras a scene has and the ones drawing it. No UI; the panel drives it.
268
+ *
269
+ * Cameras come from two places: the scene graph, and the renderer. The camera an app renders with
270
+ * is often not in the scene at all (R3F's default camera isn't; most vanilla apps never add
271
+ * theirs), so the lab wraps `renderer.render` and notes every camera it's handed: that's how it
272
+ * knows which are live, and how often each draws.
273
+ *
274
+ * Entries are named by the `cameras` option, else the camera's name, else its kind, numbered when
275
+ * they repeat. A name holds for as long as the camera is listed, and saved views are kept by it.
276
+ *
277
+ * The same watch lets the lab act on frames: "look through" hands the renderer another camera in
278
+ * place of the app's main one (fitted to its aspect for that frame), a move to a saved view steps
279
+ * once per frame, and the timeline plays: each camera with keyframes is put where its track says,
280
+ * right before the frame draws, so it wins over an app that moves the camera itself.
281
+ *
282
+ * Once the timeline has been played or scrubbed, tracked cameras are held to their tracks, even
283
+ * paused. Editing one (`set`, a move to a saved view) lets it go, so the edit sticks until it's
284
+ * keyed or the timeline moves again; `stop()` lets them all go.
285
+ */
286
+ /** How long a camera's moves are kept, ms, and at most how many samples. */
287
+ const RECORD_MS = 8e3;
288
+ const RECORD_MAX = 900;
289
+ /** Moved this recently counts as moving, ms. */
290
+ const MOVING_MS = 1e3;
291
+ /** Less than this, in its own units, isn't a move. */
292
+ const STILL = 1e-4;
293
+ /** How often a recorded line redraws, ms. */
294
+ const MOTION_REDRAW_MS = 100;
295
+ /** Path points per second of track, and the most a path gets. */
296
+ const TRAIL_RATE = 30;
297
+ const TRAIL_MAX = 600;
298
+ /** Rendered this recently counts as live, ms. */
299
+ const LIVE_MS = 500;
300
+ /** A camera outside the scene stays listed this long after its last frame, ms. */
301
+ const KEEP_MS = 5e3;
302
+ /** A move to a saved view, ms. */
303
+ const MOVE_MS = 800;
304
+ /** A new timeline's length, s. */
305
+ const DURATION_S = 10;
306
+ /** Keys closer than this, s, are the same key. */
307
+ const SAME_TIME = .001;
308
+ const newId = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
309
+ const memoryTracks = () => {
310
+ let data = { tracks: {} };
311
+ return {
312
+ load: () => data,
313
+ save: (next) => {
314
+ data = next;
315
+ }
316
+ };
317
+ };
318
+ const memoryViews = () => {
319
+ let views = {};
320
+ return {
321
+ load: () => views,
322
+ save: (next) => {
323
+ views = next;
324
+ }
325
+ };
326
+ };
327
+ const KIND_LABELS = {
328
+ perspective: "perspective",
329
+ orthographic: "orthographic",
330
+ array: "array",
331
+ cube: "cube",
332
+ other: "camera"
333
+ };
334
+ const now = () => typeof performance === "undefined" ? Date.now() : performance.now();
335
+ const toDegrees = (radians) => radians * 180 / Math.PI;
336
+ const toRadians = (degrees) => degrees * Math.PI / 180;
337
+ const projectionOf = (camera, kind) => {
338
+ if (kind === "perspective" || kind === "array") {
339
+ const { near, far, zoom, fov, aspect } = camera;
340
+ return {
341
+ near,
342
+ far,
343
+ zoom,
344
+ fov,
345
+ aspect
346
+ };
347
+ }
348
+ if (kind === "orthographic") {
349
+ const { near, far, zoom, left, right, top, bottom } = camera;
350
+ return {
351
+ near,
352
+ far,
353
+ zoom,
354
+ left,
355
+ right,
356
+ top,
357
+ bottom
358
+ };
359
+ }
360
+ return null;
361
+ };
362
+ /** Ramer–Douglas–Peucker on positions: the samples where the path bends by more than `tolerance`. */
363
+ const simplify = (samples, tolerance, maxKeys) => {
364
+ const keep = /* @__PURE__ */ new Set([0, samples.length - 1]);
365
+ const a = new Vector3();
366
+ const b = new Vector3();
367
+ const p = new Vector3();
368
+ const distance = (index, from, to) => {
369
+ a.fromArray(samples[from].position);
370
+ b.fromArray(samples[to].position);
371
+ p.fromArray(samples[index].position);
372
+ const span = b.clone().sub(a);
373
+ const length = span.lengthSq();
374
+ if (length === 0) return p.distanceTo(a);
375
+ const t = Math.min(1, Math.max(0, p.clone().sub(a).dot(span) / length));
376
+ return p.distanceTo(a.clone().addScaledVector(span, t));
377
+ };
378
+ const split = (from, to) => {
379
+ let far = -1;
380
+ let most = tolerance;
381
+ for (let i = from + 1; i < to; i += 1) {
382
+ const d = distance(i, from, to);
383
+ if (d > most) {
384
+ most = d;
385
+ far = i;
386
+ }
387
+ }
388
+ if (far >= 0 && keep.size < maxKeys) {
389
+ keep.add(far);
390
+ split(from, far);
391
+ split(far, to);
392
+ }
393
+ };
394
+ split(0, samples.length - 1);
395
+ return [...keep].sort((x, y) => x - y).map((index) => samples[index]);
396
+ };
397
+ var CameraLab = class {
398
+ options;
399
+ records = /* @__PURE__ */ new Map();
400
+ byId = /* @__PURE__ */ new Map();
401
+ listeners = /* @__PURE__ */ new Set();
402
+ tweens = /* @__PURE__ */ new Map();
403
+ store;
404
+ saved;
405
+ restoreRender = null;
406
+ /** Look-through: the camera shown, and the app camera it stands in for. */
407
+ looking = null;
408
+ standIn = null;
409
+ /** The frame being drawn: what to put back after it. */
410
+ fitted = null;
411
+ viewport = new Vector4();
412
+ view = null;
413
+ trackStore;
414
+ tracks;
415
+ state;
416
+ /** When the playhead last advanced, ms; `null` until the next frame after a play. */
417
+ lastTick = null;
418
+ selectedId = null;
419
+ notifyQueued = false;
420
+ disposed = false;
421
+ constructor(options) {
422
+ this.options = options;
423
+ this.store = options.store ?? memoryViews();
424
+ this.saved = { ...this.store.load() };
425
+ this.trackStore = options.trackStore ?? memoryTracks();
426
+ const loaded = this.trackStore.load();
427
+ this.tracks = { ...loaded.tracks };
428
+ this.state = {
429
+ time: 0,
430
+ duration: loaded.duration && loaded.duration > 0 ? loaded.duration : DURATION_S,
431
+ playing: false,
432
+ loop: true,
433
+ engaged: false
434
+ };
435
+ this.watch(options.renderer);
436
+ this.refresh();
437
+ }
438
+ /** Whether the lab sees frames: look-through and animated moves need a renderer. */
439
+ get watching() {
440
+ return this.restoreRender !== null;
441
+ }
442
+ subscribe(listener) {
443
+ this.listeners.add(listener);
444
+ return () => {
445
+ this.listeners.delete(listener);
446
+ };
447
+ }
448
+ /** Every camera, in scene order; ones only the renderer knows about after. */
449
+ entries() {
450
+ const time = now();
451
+ return [...this.byId.values()].map((item) => this.entryOf(item, time));
452
+ }
453
+ entry(id) {
454
+ const item = this.byId.get(id);
455
+ return item ? this.entryOf(item, now()) : null;
456
+ }
457
+ /** Where it is and how it projects, read now. */
458
+ details(id) {
459
+ const item = this.byId.get(id);
460
+ if (!item) return null;
461
+ const { camera } = item;
462
+ const position = new Vector3();
463
+ const quaternion = new Quaternion();
464
+ camera.updateWorldMatrix(true, false);
465
+ camera.matrixWorld.decompose(position, quaternion, new Vector3());
466
+ const euler = new Euler().setFromQuaternion(quaternion);
467
+ const own = camera.rotation;
468
+ return {
469
+ position: [
470
+ position.x,
471
+ position.y,
472
+ position.z
473
+ ],
474
+ rotation: [
475
+ toDegrees(euler.x),
476
+ toDegrees(euler.y),
477
+ toDegrees(euler.z)
478
+ ],
479
+ local: {
480
+ position: [
481
+ camera.position.x,
482
+ camera.position.y,
483
+ camera.position.z
484
+ ],
485
+ rotation: [
486
+ toDegrees(own.x),
487
+ toDegrees(own.y),
488
+ toDegrees(own.z)
489
+ ]
490
+ },
491
+ projection: projectionOf(camera, item.kind)
492
+ };
493
+ }
494
+ /**
495
+ * Change a camera: its own position or rotation, or its lens. The projection updates, and a
496
+ * render-on-demand loop gets a frame. An app that sets the camera every frame wins on the next.
497
+ */
498
+ set(id, patch) {
499
+ const item = this.require(id);
500
+ const camera = item.camera;
501
+ if (patch.position) camera.position.set(...patch.position);
502
+ if (patch.rotation) {
503
+ const [x, y, z] = patch.rotation.map(toRadians);
504
+ camera.rotation.set(x, y, z);
505
+ }
506
+ if (this.projects(item)) {
507
+ const lens = camera;
508
+ if (patch.near !== void 0) lens.near = patch.near;
509
+ if (patch.far !== void 0) lens.far = patch.far;
510
+ if (patch.zoom !== void 0) lens.zoom = patch.zoom;
511
+ if (patch.fov !== void 0 && item.kind === "perspective") lens.fov = patch.fov;
512
+ lens.updateProjectionMatrix();
513
+ }
514
+ this.tweens.delete(item);
515
+ item.held = false;
516
+ this.options.invalidate?.();
517
+ }
518
+ /** The camera picked in a panel, which "add key" and the controls act on. */
519
+ get selected() {
520
+ return this.selectedId;
521
+ }
522
+ select(id) {
523
+ if (id !== this.selectedId && (id === null || this.byId.has(id))) {
524
+ this.selectedId = id;
525
+ this.notify();
526
+ }
527
+ }
528
+ /**
529
+ * Show the main view through `id`, or back through the app's own camera with `null`. The camera
530
+ * it stands in for is the app's main view: the one drawing the most of the screen (a
531
+ * picture-in-picture inset or a render target loses to it), most often on a tie.
532
+ */
533
+ lookThrough(id) {
534
+ if (id === null || this.looking?.id === id) {
535
+ this.looking = null;
536
+ this.standIn = null;
537
+ } else {
538
+ const item = this.require(id);
539
+ if (!this.projects(item)) throw new Error(`three-cameras: "${id}" can't be looked through (${item.kind})`);
540
+ const time = now();
541
+ const main = [...this.records.values()].filter((other) => other !== item && isCamera(other.camera)).map((other) => ({
542
+ other,
543
+ fps: other.frames.filter((at) => time - at <= 1e3).length
544
+ })).filter(({ fps }) => fps > 0).sort((a, b) => b.other.area - a.other.area || b.fps - a.fps)[0];
545
+ if (!main) throw new Error("three-cameras: no camera is drawing, so there's no view to look through");
546
+ this.looking = item;
547
+ this.standIn = main.other.camera;
548
+ }
549
+ this.options.invalidate?.();
550
+ this.notify();
551
+ }
552
+ /** Save where the camera is and how it projects, as a view to go back to. */
553
+ saveView(id, name) {
554
+ const item = this.require(id);
555
+ if (!this.projects(item)) throw new Error(`three-cameras: "${id}" has no lens to save (${item.kind})`);
556
+ const list = this.saved[id] ?? [];
557
+ const taken = new Set(list.map((view) => view.name));
558
+ let label = name ?? `view ${list.length + 1}`;
559
+ for (let n = 2; taken.has(label); n += 1) label = `${name ?? "view"} ${n}`;
560
+ const view = {
561
+ id: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
562
+ name: label,
563
+ pose: capturePose(item.camera)
564
+ };
565
+ this.saved = {
566
+ ...this.saved,
567
+ [id]: [...list, view]
568
+ };
569
+ this.store.save(this.saved);
570
+ this.notify();
571
+ return view;
572
+ }
573
+ views(id) {
574
+ return this.saved[id] ?? [];
575
+ }
576
+ deleteView(id, viewId) {
577
+ const list = this.saved[id] ?? [];
578
+ this.saved = {
579
+ ...this.saved,
580
+ [id]: list.filter((view) => view.id !== viewId)
581
+ };
582
+ this.store.save(this.saved);
583
+ this.notify();
584
+ }
585
+ /**
586
+ * Move the camera to a saved view: eased over `duration` ms, stepped once per frame (so it needs
587
+ * a renderer; without one, or with `duration: 0`, it lands at once).
588
+ */
589
+ goToView(id, viewId, { duration = MOVE_MS } = {}) {
590
+ const item = this.require(id);
591
+ const view = this.views(id).find((saved) => saved.id === viewId);
592
+ if (!view) throw new Error(`three-cameras: "${id}" has no saved view "${viewId}"`);
593
+ const camera = item.camera;
594
+ item.held = false;
595
+ if (duration <= 0 || !this.watching) {
596
+ this.tweens.delete(item);
597
+ applyPose(camera, view.pose);
598
+ } else this.tweens.set(item, {
599
+ from: capturePose(camera),
600
+ to: view.pose,
601
+ start: now(),
602
+ duration
603
+ });
604
+ this.options.invalidate?.();
605
+ }
606
+ timeline() {
607
+ return { ...this.state };
608
+ }
609
+ /** Play from the playhead (from the start if it's at the end). Needs a renderer to advance. */
610
+ play() {
611
+ if (this.state.time >= this.state.duration) this.state.time = 0;
612
+ this.state.playing = true;
613
+ this.lastTick = null;
614
+ this.engage();
615
+ }
616
+ pause() {
617
+ this.state.playing = false;
618
+ this.notify();
619
+ }
620
+ /** Stop and let every camera go back to the app (and to edits). The playhead stays. */
621
+ stop() {
622
+ this.state.playing = false;
623
+ this.state.engaged = false;
624
+ this.notify();
625
+ }
626
+ /** Move the playhead, and hold tracked cameras where their tracks are then. */
627
+ seek(time) {
628
+ this.state.time = Math.min(Math.max(0, time), this.state.duration);
629
+ this.lastTick = null;
630
+ this.engage();
631
+ }
632
+ setLoop(loop) {
633
+ this.state.loop = loop;
634
+ this.notify();
635
+ }
636
+ /** The timeline's length, s. Keys past it stay, and it grows to fit a key added later. */
637
+ setDuration(duration) {
638
+ if (duration > 0) {
639
+ this.state.duration = duration;
640
+ this.state.time = Math.min(this.state.time, duration);
641
+ this.saveTracks();
642
+ this.notify();
643
+ }
644
+ }
645
+ /** A camera's keyframes, in time order. */
646
+ keys(id) {
647
+ return this.tracks[id] ?? [];
648
+ }
649
+ /**
650
+ * Key the camera as it is now, at `time` (default: the playhead). A key already there is
651
+ * replaced, keeping its ease.
652
+ */
653
+ addKey(id, { time, ease } = {}) {
654
+ const item = this.require(id);
655
+ if (!this.projects(item)) throw new Error(`three-cameras: "${id}" has no lens to key (${item.kind})`);
656
+ const at = Math.max(0, time ?? this.state.time);
657
+ const list = this.keys(id);
658
+ const existing = list.find((key) => Math.abs(key.time - at) < SAME_TIME);
659
+ const key = {
660
+ id: existing?.id ?? newId(),
661
+ time: at,
662
+ pose: capturePose(item.camera),
663
+ ease: ease ?? existing?.ease ?? "ease-in-out"
664
+ };
665
+ this.setTrack(id, [...list.filter((other) => other !== existing), key]);
666
+ if (at > this.state.duration) this.state.duration = Math.ceil(at);
667
+ item.held = true;
668
+ this.state.engaged = true;
669
+ this.saveTracks();
670
+ this.notify();
671
+ return key;
672
+ }
673
+ /**
674
+ * Change a key: move it in time, change its ease or its curve (`bezier`, which makes the ease
675
+ * `custom`), set its pose outright (a graph editor's drag), or re-key it from the camera as it is.
676
+ */
677
+ updateKey(id, keyId, change) {
678
+ const item = this.require(id);
679
+ const key = this.keys(id).find((other) => other.id === keyId);
680
+ if (!key) throw new Error(`three-cameras: "${id}" has no key "${keyId}"`);
681
+ const ease = change.bezier ? "custom" : change.ease ?? key.ease;
682
+ const next = {
683
+ ...key,
684
+ time: change.time === void 0 ? key.time : Math.max(0, change.time),
685
+ ease,
686
+ pose: change.recapture ? capturePose(item.camera) : change.pose ?? key.pose
687
+ };
688
+ if (ease === "custom") next.bezier = change.bezier ?? key.bezier ?? (key.ease === "custom" || key.ease === "hold" ? PRESET_BEZIERS.linear : PRESET_BEZIERS[key.ease]);
689
+ else delete next.bezier;
690
+ this.setTrack(id, this.keys(id).map((other) => other === key ? next : other));
691
+ if (change.recapture) item.held = true;
692
+ this.saveTracks();
693
+ this.options.invalidate?.();
694
+ this.notify();
695
+ }
696
+ deleteKey(id, keyId) {
697
+ this.setTrack(id, this.keys(id).filter((key) => key.id !== keyId));
698
+ this.saveTracks();
699
+ this.notify();
700
+ }
701
+ setTrack(id, keys) {
702
+ const next = { ...this.tracks };
703
+ if (keys.length > 0) next[id] = sortKeys(keys);
704
+ else delete next[id];
705
+ this.tracks = next;
706
+ const item = this.byId.get(id);
707
+ if (item) this.syncTrail(item);
708
+ }
709
+ /**
710
+ * Show or hide a camera's motion path in the scene, or `auto` (the default): shown while it
711
+ * moves or has keys, for cameras in the scene. The path is its track as a line with a dot per
712
+ * key; a camera without keys shows where it's been lately instead.
713
+ */
714
+ setTrail(id, on) {
715
+ const item = this.require(id);
716
+ item.trailMode = on === "auto" ? "auto" : on ? "on" : "off";
717
+ this.syncTrail(item);
718
+ this.options.invalidate?.();
719
+ this.notify();
720
+ }
721
+ /** Where the camera has been lately, oldest first. */
722
+ motion(id) {
723
+ return [...this.require(id).motion];
724
+ }
725
+ /**
726
+ * Turn a camera's recorded moves into keys, replacing its track: a key where the path bends
727
+ * (within `tolerance`, in its own units), timed from the first sample, linear between. The
728
+ * lens is the camera's as it is now.
729
+ */
730
+ bakeMotion(id, { tolerance = .05, maxKeys = 40 } = {}) {
731
+ const item = this.require(id);
732
+ if (!this.projects(item)) throw new Error(`three-cameras: "${id}" has no lens to key (${item.kind})`);
733
+ const samples = item.motion;
734
+ if (samples.length < 2) throw new Error(`three-cameras: "${id}" hasn't moved lately, so there's nothing to bake`);
735
+ const picked = simplify(samples, tolerance, maxKeys);
736
+ const start = samples[0].time;
737
+ const lens = capturePose(item.camera);
738
+ const keys = picked.map((sample) => ({
739
+ id: newId(),
740
+ time: Math.round((sample.time - start) / 1e3 * 1e3) / 1e3,
741
+ pose: {
742
+ ...lens,
743
+ position: [...sample.position],
744
+ quaternion: [...sample.quaternion]
745
+ },
746
+ ease: "linear"
747
+ }));
748
+ this.setTrack(id, keys);
749
+ this.state.duration = Math.max(this.state.duration, Math.ceil(keys.at(-1).time));
750
+ this.saveTracks();
751
+ this.syncTrail(item);
752
+ this.notify();
753
+ return keys;
754
+ }
755
+ /** The main view as last drawn, or `null` before its first frame. */
756
+ viewTransform() {
757
+ return this.view ? {
758
+ world: this.view.world.clone(),
759
+ projection: this.view.projection.clone(),
760
+ viewport: this.view.viewport.clone()
761
+ } : null;
762
+ }
763
+ /** Every key dot drawn in the scene, where it is in the world. */
764
+ keyHandles() {
765
+ const handles = [];
766
+ for (const item of this.records.values()) {
767
+ const keys = this.tracks[item.id];
768
+ if (!item.trail || !keys) continue;
769
+ const parent = item.camera.parent;
770
+ parent?.updateWorldMatrix(true, false);
771
+ for (const key of keys) {
772
+ const position = new Vector3().fromArray(key.pose.position);
773
+ if (parent) position.applyMatrix4(parent.matrixWorld);
774
+ handles.push({
775
+ camera: item.id,
776
+ key: key.id,
777
+ position
778
+ });
779
+ }
780
+ }
781
+ return handles;
782
+ }
783
+ /** Move a key to a point in the world (a dragged dot): its position, in the camera's parent's space. */
784
+ moveKeyTo(id, keyId, world) {
785
+ const item = this.require(id);
786
+ const key = this.keys(id).find((other) => other.id === keyId);
787
+ if (!key) throw new Error(`three-cameras: "${id}" has no key "${keyId}"`);
788
+ const local = world.clone();
789
+ if (item.camera.parent) {
790
+ item.camera.parent.updateWorldMatrix(true, false);
791
+ item.camera.parent.worldToLocal(local);
792
+ }
793
+ this.updateKey(id, keyId, { pose: {
794
+ ...key.pose,
795
+ position: [
796
+ local.x,
797
+ local.y,
798
+ local.z
799
+ ]
800
+ } });
801
+ }
802
+ autoTrail(item, time) {
803
+ return this.projects(item) && this.inScene(item.camera) && ((this.tracks[item.id]?.length ?? 0) > 0 || time - item.lastMoved < MOVING_MS);
804
+ }
805
+ /** Make the path match its mode: create, draw or drop it. */
806
+ syncTrail(item, time = now()) {
807
+ const wanted = item.trailMode === "on" || item.trailMode === "auto" && this.autoTrail(item, time);
808
+ if (wanted && !item.trail) {
809
+ const trail = new Group();
810
+ const line = new Line(new BufferGeometry(), new LineBasicMaterial({
811
+ color: "#fbbf24",
812
+ transparent: true,
813
+ opacity: .9
814
+ }));
815
+ const markers = new Points(new BufferGeometry(), new PointsMaterial({
816
+ color: "#ffffff",
817
+ size: 8,
818
+ sizeAttenuation: false
819
+ }));
820
+ const recorded = new Line(new BufferGeometry(), new LineBasicMaterial({
821
+ color: "#7dd3fc",
822
+ transparent: true,
823
+ opacity: .55
824
+ }));
825
+ trail.name = `${item.id} (three-cameras path)`;
826
+ trail.userData.threeCameras = true;
827
+ trail.add(line, markers, recorded);
828
+ (item.camera.parent ?? this.options.scene).add(trail);
829
+ item.trail = trail;
830
+ this.drawTrail(item);
831
+ this.notifySoon();
832
+ } else if (!wanted && item.trail) {
833
+ this.dropTrail(item);
834
+ this.notifySoon();
835
+ } else if (item.trail) this.drawTrail(item);
836
+ }
837
+ drawTrail(item) {
838
+ const trail = item.trail;
839
+ if (!trail) return;
840
+ const [line, markers, recorded] = trail.children;
841
+ const keys = this.tracks[item.id] ?? [];
842
+ recorded.geometry.setAttribute("position", new Float32BufferAttribute(keys.length > 0 ? [] : item.motion.flatMap((sample) => sample.position), 3));
843
+ recorded.geometry.computeBoundingSphere();
844
+ const first = keys[0];
845
+ const last = keys.at(-1);
846
+ const path = [];
847
+ if (first && last) {
848
+ const span = last.time - first.time;
849
+ const count = Math.min(TRAIL_MAX, Math.max(1, Math.ceil(span * TRAIL_RATE)));
850
+ for (let i = 0; i <= count; i += 1) {
851
+ const pose = sampleTrack(keys, first.time + span * i / count);
852
+ if (pose) path.push(...pose.position);
853
+ }
854
+ }
855
+ line.geometry.setAttribute("position", new Float32BufferAttribute(path, 3));
856
+ line.geometry.computeBoundingSphere();
857
+ markers.geometry.setAttribute("position", new Float32BufferAttribute(keys.flatMap((key) => key.pose.position), 3));
858
+ markers.geometry.computeBoundingSphere();
859
+ this.options.invalidate?.();
860
+ }
861
+ dropTrail(item) {
862
+ if (!item.trail) return;
863
+ for (const child of item.trail.children) {
864
+ child.geometry.dispose();
865
+ child.material.dispose();
866
+ }
867
+ item.trail.removeFromParent();
868
+ item.trail = null;
869
+ }
870
+ saveTracks() {
871
+ this.trackStore.save({
872
+ duration: this.state.duration,
873
+ tracks: this.tracks
874
+ });
875
+ }
876
+ engage() {
877
+ this.state.engaged = true;
878
+ for (const item of this.records.values()) {
879
+ item.held = true;
880
+ this.tweens.delete(item);
881
+ }
882
+ this.applyTracks();
883
+ this.options.invalidate?.();
884
+ this.notify();
885
+ }
886
+ /** Every held camera with a track, to where its track is at the playhead. */
887
+ applyTracks() {
888
+ if (!this.state.engaged) return;
889
+ for (const item of this.records.values()) {
890
+ const keys = this.tracks[item.id];
891
+ if (item.held && keys && keys.length > 0) {
892
+ const pose = sampleTrack(keys, this.state.time);
893
+ if (pose) applyPose(item.camera, pose);
894
+ }
895
+ }
896
+ }
897
+ /** Move the playhead by the time since the last frame. */
898
+ advance(time) {
899
+ if (!this.state.playing) return;
900
+ const step = this.lastTick === null ? 0 : Math.min(.25, (time - this.lastTick) / 1e3);
901
+ this.lastTick = time;
902
+ this.state.time += step;
903
+ if (this.state.time >= this.state.duration) {
904
+ if (this.state.loop && this.state.duration > 0) this.state.time %= this.state.duration;
905
+ else {
906
+ this.state.time = this.state.duration;
907
+ this.state.playing = false;
908
+ this.notifySoon();
909
+ }
910
+ }
911
+ this.options.invalidate?.();
912
+ }
913
+ /**
914
+ * Look at the scene again: new cameras get entries, ones that left go (a camera outside the
915
+ * scene stays while it's still rendering, and a few seconds after).
916
+ */
917
+ refresh() {
918
+ if (this.disposed) return;
919
+ const time = now();
920
+ const listed = /* @__PURE__ */ new Set();
921
+ let changed = false;
922
+ for (const camera of findCameras(this.options.scene)) listed.add(camera);
923
+ for (const info of this.options.cameras ?? []) listed.add(info.camera);
924
+ for (const [camera, item] of this.records) {
925
+ const last = item.frames.at(-1);
926
+ if (last !== void 0 && time - last < KEEP_MS || camera === this.standIn) listed.add(camera);
927
+ }
928
+ for (const camera of listed) if (!this.records.has(camera)) {
929
+ this.add(camera);
930
+ changed = true;
931
+ }
932
+ for (const [camera, item] of this.records) if (!listed.has(camera)) {
933
+ this.remove(item);
934
+ changed = true;
935
+ }
936
+ if (this.looking && !this.records.has(this.looking.camera)) {
937
+ this.looking = null;
938
+ this.standIn = null;
939
+ }
940
+ if (changed) this.notify();
941
+ }
942
+ /** Show or hide a camera's frustum. The helper sits in the lab's scene and follows the camera. */
943
+ setHelper(id, on) {
944
+ const item = this.require(id);
945
+ if (on === (item.helper !== null)) return;
946
+ if (on) {
947
+ if (!this.canHelp(item)) throw new Error(`three-cameras: "${id}" has no frustum to draw (${item.kind})`);
948
+ const helper = new CameraHelper(item.camera);
949
+ helper.name = `${id} (three-cameras helper)`;
950
+ helper.userData.threeCameras = true;
951
+ this.options.scene.add(helper);
952
+ item.helper = helper;
953
+ } else this.dropHelper(item);
954
+ this.options.invalidate?.();
955
+ this.notify();
956
+ }
957
+ dispose() {
958
+ this.disposed = true;
959
+ for (const item of this.records.values()) {
960
+ this.dropHelper(item);
961
+ this.dropTrail(item);
962
+ }
963
+ this.looking = null;
964
+ this.standIn = null;
965
+ this.state.playing = false;
966
+ this.tweens.clear();
967
+ this.restoreRender?.();
968
+ this.restoreRender = null;
969
+ this.listeners.clear();
970
+ }
971
+ watch(renderer) {
972
+ const target = renderer;
973
+ if (!target || typeof target.render !== "function") return;
974
+ const render = target.render;
975
+ const own = Object.hasOwn(target, "render");
976
+ const before = (camera, host) => this.beforeFrame(camera, host);
977
+ const after = () => this.afterFrame();
978
+ const watched = function(scene, camera, ...rest) {
979
+ const shown = before(camera, this);
980
+ try {
981
+ return render.call(this, scene, shown, ...rest);
982
+ } finally {
983
+ after();
984
+ }
985
+ };
986
+ target.render = watched;
987
+ this.restoreRender = () => {
988
+ if (target.render !== watched) return;
989
+ if (own) target.render = render;
990
+ else delete target.render;
991
+ };
992
+ }
993
+ /** Before a frame: count it, step moves, and pick the camera it's drawn with. */
994
+ beforeFrame(camera, host) {
995
+ if (this.disposed || !camera) return camera;
996
+ const time = now();
997
+ this.count(camera, time);
998
+ this.measure(camera, host);
999
+ this.record(time);
1000
+ this.advance(time);
1001
+ this.applyTracks();
1002
+ this.step(time);
1003
+ let shown = camera;
1004
+ if (this.looking && camera === this.standIn) {
1005
+ shown = this.looking.camera;
1006
+ this.count(shown, time);
1007
+ const lens = shown;
1008
+ const view = camera;
1009
+ if (lens.isPerspectiveCamera && view.isPerspectiveCamera && lens.aspect !== view.aspect) {
1010
+ this.fitted = {
1011
+ camera: lens,
1012
+ aspect: lens.aspect
1013
+ };
1014
+ lens.aspect = view.aspect;
1015
+ lens.updateProjectionMatrix();
1016
+ }
1017
+ }
1018
+ for (const other of this.records.values()) if (other.helper) {
1019
+ other.helper.visible = other.camera !== shown;
1020
+ other.helper.update();
1021
+ }
1022
+ this.captureView(camera, shown);
1023
+ return shown;
1024
+ }
1025
+ /** The main view's matrices, when this frame is it: the camera drawing the most of the screen. */
1026
+ captureView(requested, shown) {
1027
+ const item = this.records.get(requested);
1028
+ if (!item) return;
1029
+ let main = true;
1030
+ for (const other of this.records.values()) if (other.area > item.area) {
1031
+ main = false;
1032
+ break;
1033
+ }
1034
+ if (!main || item.area === 0) return;
1035
+ shown.updateWorldMatrix(true, false);
1036
+ this.view ??= {
1037
+ world: new Matrix4(),
1038
+ projection: new Matrix4(),
1039
+ viewport: new Vector4()
1040
+ };
1041
+ this.view.world.copy(shown.matrixWorld);
1042
+ this.view.projection.copy(shown.projectionMatrix);
1043
+ this.view.viewport.copy(this.viewport);
1044
+ }
1045
+ /**
1046
+ * Note where cameras in the scene are, when they've moved: their recent motion, drawn for a
1047
+ * camera without keys and bakeable into keys. A camera its track is driving isn't recorded.
1048
+ */
1049
+ record(time) {
1050
+ const driving = this.state.engaged;
1051
+ for (const item of this.records.values()) {
1052
+ if (!this.projects(item) || !this.inScene(item.camera)) continue;
1053
+ const tracked = (this.tracks[item.id]?.length ?? 0) > 0;
1054
+ if (!(driving && item.held && tracked)) {
1055
+ const { position, quaternion } = item.camera;
1056
+ const last = item.motion.at(-1);
1057
+ if (!last || Math.abs(last.position[0] - position.x) > STILL || Math.abs(last.position[1] - position.y) > STILL || Math.abs(last.position[2] - position.z) > STILL || Math.abs(last.quaternion[3] - quaternion.w) > STILL) {
1058
+ if (last) item.lastMoved = time;
1059
+ item.motion.push({
1060
+ time,
1061
+ position: [
1062
+ position.x,
1063
+ position.y,
1064
+ position.z
1065
+ ],
1066
+ quaternion: [
1067
+ quaternion.x,
1068
+ quaternion.y,
1069
+ quaternion.z,
1070
+ quaternion.w
1071
+ ]
1072
+ });
1073
+ }
1074
+ while (item.motion.length > RECORD_MAX || item.motion.length > 0 && time - item.motion[0].time > RECORD_MS) item.motion.shift();
1075
+ }
1076
+ if (item.trailMode === "auto" || item.trail) {
1077
+ const was = item.trail !== null;
1078
+ if (item.trail && !tracked && time - item.motionDrawn >= MOTION_REDRAW_MS) {
1079
+ item.motionDrawn = time;
1080
+ this.drawTrail(item);
1081
+ }
1082
+ if (item.trailMode === "auto" && was !== this.autoTrail(item, time)) this.syncTrail(item, time);
1083
+ }
1084
+ }
1085
+ }
1086
+ afterFrame() {
1087
+ if (this.fitted) {
1088
+ this.fitted.camera.aspect = this.fitted.aspect;
1089
+ this.fitted.camera.updateProjectionMatrix();
1090
+ this.fitted = null;
1091
+ }
1092
+ }
1093
+ /** How much of the screen this frame covers, if the renderer says. */
1094
+ measure(camera, host) {
1095
+ const item = this.records.get(camera);
1096
+ if (!item || typeof host?.getCurrentViewport !== "function") return;
1097
+ const offscreen = typeof host.getRenderTarget === "function" && host.getRenderTarget() !== null;
1098
+ const viewport = host.getCurrentViewport(this.viewport);
1099
+ item.area = offscreen ? 0 : viewport.z * viewport.w;
1100
+ }
1101
+ count(camera, time) {
1102
+ const item = this.records.get(camera);
1103
+ if (item) {
1104
+ item.frames.push(time);
1105
+ while (item.frames.length > 0 && time - item.frames[0] > 1e3) item.frames.shift();
1106
+ } else if (this.findOwner(camera) === null) {
1107
+ this.add(camera).frames.push(time);
1108
+ this.notifySoon();
1109
+ }
1110
+ }
1111
+ /** Moves to saved views, one step per frame. */
1112
+ step(time) {
1113
+ if (this.tweens.size === 0) return;
1114
+ for (const [item, tween] of this.tweens) {
1115
+ const t = Math.min(1, (time - tween.start) / tween.duration);
1116
+ applyPose(item.camera, blendPoses(tween.from, tween.to, easeInOut(t)));
1117
+ if (t >= 1) this.tweens.delete(item);
1118
+ }
1119
+ this.options.invalidate?.();
1120
+ }
1121
+ /** A camera the renderer was handed that belongs to one listed, like a CubeCamera's faces. */
1122
+ findOwner(camera) {
1123
+ for (let node = camera.parent; node; node = node.parent) if (this.records.has(node)) return node;
1124
+ return null;
1125
+ }
1126
+ canHelp(item) {
1127
+ return this.projects(item);
1128
+ }
1129
+ projects(item) {
1130
+ return item.kind === "perspective" || item.kind === "orthographic";
1131
+ }
1132
+ add(camera) {
1133
+ const kind = kindOf(camera);
1134
+ const registered = this.options.cameras?.find((info) => info.camera === camera)?.name;
1135
+ const id = this.uniqueName(registered || camera.name || `${KIND_LABELS[kind]} camera`);
1136
+ const item = {
1137
+ id,
1138
+ camera,
1139
+ kind,
1140
+ helper: null,
1141
+ frames: [],
1142
+ area: 0,
1143
+ held: true,
1144
+ trail: null,
1145
+ trailMode: "auto",
1146
+ motion: [],
1147
+ lastMoved: Number.NEGATIVE_INFINITY,
1148
+ motionDrawn: 0
1149
+ };
1150
+ this.records.set(camera, item);
1151
+ this.byId.set(id, item);
1152
+ return item;
1153
+ }
1154
+ remove(item) {
1155
+ this.dropHelper(item);
1156
+ this.dropTrail(item);
1157
+ this.tweens.delete(item);
1158
+ if (this.selectedId === item.id) this.selectedId = null;
1159
+ this.records.delete(item.camera);
1160
+ this.byId.delete(item.id);
1161
+ }
1162
+ dropHelper(item) {
1163
+ if (item.helper) {
1164
+ item.helper.removeFromParent();
1165
+ item.helper.dispose();
1166
+ item.helper = null;
1167
+ }
1168
+ }
1169
+ uniqueName(base) {
1170
+ let name = base;
1171
+ for (let n = 2; this.byId.has(name); n += 1) name = `${base} ${n}`;
1172
+ return name;
1173
+ }
1174
+ inScene(camera) {
1175
+ let top = camera;
1176
+ while (top.parent) top = top.parent;
1177
+ return top === this.options.scene;
1178
+ }
1179
+ entryOf(item, time) {
1180
+ const recent = item.frames.filter((at) => time - at <= 1e3);
1181
+ const last = recent.at(-1);
1182
+ return {
1183
+ id: item.id,
1184
+ kind: item.kind,
1185
+ camera: item.camera,
1186
+ inScene: this.inScene(item.camera),
1187
+ live: last !== void 0 && time - last < LIVE_MS,
1188
+ fps: recent.length,
1189
+ helper: item.helper !== null,
1190
+ canHelp: this.canHelp(item),
1191
+ path: pathOf(item.camera),
1192
+ projects: this.projects(item),
1193
+ viewing: this.looking === item,
1194
+ standingIn: this.looking !== null && this.standIn === item.camera,
1195
+ views: this.saved[item.id]?.length ?? 0,
1196
+ keys: this.tracks[item.id]?.length ?? 0,
1197
+ trail: item.trail !== null,
1198
+ moving: time - item.lastMoved < MOVING_MS,
1199
+ recorded: item.motion.length > 1,
1200
+ selected: this.selectedId === item.id
1201
+ };
1202
+ }
1203
+ require(id) {
1204
+ const item = this.byId.get(id);
1205
+ if (!item) throw new Error(`three-cameras: no camera "${id}"`);
1206
+ return item;
1207
+ }
1208
+ notifySoon() {
1209
+ if (!this.notifyQueued) {
1210
+ this.notifyQueued = true;
1211
+ queueMicrotask(() => {
1212
+ this.notifyQueued = false;
1213
+ this.notify();
1214
+ });
1215
+ }
1216
+ }
1217
+ notify() {
1218
+ for (const listener of this.listeners) listener();
1219
+ }
1220
+ };
1221
+ //#endregion
1222
+ //#region src/core/channels.ts
1223
+ /**
1224
+ * A pose as separate channels, the way a graph editor shows it: position x, y, z; rotation x, y, z
1225
+ * (XYZ Euler, degrees); and the lens (fov, zoom, near, far). Read one, or write one back into a
1226
+ * pose. Rotation is stored as a quaternion, so a rotation channel goes through Euler angles on the
1227
+ * way in and out.
1228
+ */
1229
+ const CHANNELS = [
1230
+ "px",
1231
+ "py",
1232
+ "pz",
1233
+ "rx",
1234
+ "ry",
1235
+ "rz",
1236
+ "fov",
1237
+ "zoom",
1238
+ "near",
1239
+ "far"
1240
+ ];
1241
+ const CHANNEL_LABELS = {
1242
+ px: "position x",
1243
+ py: "position y",
1244
+ pz: "position z",
1245
+ rx: "rotation x",
1246
+ ry: "rotation y",
1247
+ rz: "rotation z",
1248
+ fov: "fov",
1249
+ zoom: "zoom",
1250
+ near: "near",
1251
+ far: "far"
1252
+ };
1253
+ const euler = new Euler();
1254
+ const quaternion = new Quaternion();
1255
+ const DEGREES = 180 / Math.PI;
1256
+ /** One channel's value in a pose; `undefined` for fov on an orthographic pose. */
1257
+ const readChannel = (pose, channel) => {
1258
+ switch (channel) {
1259
+ case "px": return pose.position[0];
1260
+ case "py": return pose.position[1];
1261
+ case "pz": return pose.position[2];
1262
+ case "rx":
1263
+ case "ry":
1264
+ case "rz":
1265
+ euler.setFromQuaternion(quaternion.fromArray(pose.quaternion));
1266
+ return (channel === "rx" ? euler.x : channel === "ry" ? euler.y : euler.z) * DEGREES;
1267
+ default: return pose[channel];
1268
+ }
1269
+ };
1270
+ /** A copy of `pose` with one channel set. */
1271
+ const writeChannel = (pose, channel, value) => {
1272
+ const next = {
1273
+ ...pose,
1274
+ position: [...pose.position],
1275
+ quaternion: [...pose.quaternion]
1276
+ };
1277
+ switch (channel) {
1278
+ case "px":
1279
+ case "py":
1280
+ case "pz":
1281
+ next.position[channel === "px" ? 0 : channel === "py" ? 1 : 2] = value;
1282
+ break;
1283
+ case "rx":
1284
+ case "ry":
1285
+ case "rz":
1286
+ euler.setFromQuaternion(quaternion.fromArray(pose.quaternion));
1287
+ euler[channel === "rx" ? "x" : channel === "ry" ? "y" : "z"] = value / DEGREES;
1288
+ quaternion.setFromEuler(euler);
1289
+ next.quaternion = [
1290
+ quaternion.x,
1291
+ quaternion.y,
1292
+ quaternion.z,
1293
+ quaternion.w
1294
+ ];
1295
+ break;
1296
+ default: next[channel] = value;
1297
+ }
1298
+ return next;
1299
+ };
1300
+ /**
1301
+ * A channel over time, `samples + 1` points from `start` to `end`, as the track plays it. Angles
1302
+ * are unwrapped (no jump at ±180°) so a turn reads as one curve.
1303
+ */
1304
+ const sampleChannel = (keys, channel, start, end, samples) => {
1305
+ const points = [];
1306
+ let previous;
1307
+ for (let i = 0; i <= samples; i += 1) {
1308
+ const time = start + (end - start) * i / samples;
1309
+ const pose = sampleTrack(keys, time);
1310
+ let value = pose ? readChannel(pose, channel) : void 0;
1311
+ if (value === void 0) continue;
1312
+ if (previous !== void 0 && channel.startsWith("r")) {
1313
+ while (value - previous > 180) value -= 360;
1314
+ while (value - previous < -180) value += 360;
1315
+ }
1316
+ previous = value;
1317
+ points.push([time, value]);
1318
+ }
1319
+ return points;
1320
+ };
1321
+ //#endregion
1322
+ export { pathOf as S, poseToCode as _, writeChannel as a, isCubeCamera as b, PRESET_BEZIERS as c, sampleTrack as d, sortKeys as f, easeInOut as g, capturePose as h, sampleChannel as i, applyEase as l, blendPoses as m, CHANNEL_LABELS as n, CameraLab as o, applyPose as p, readChannel as r, EASES as s, CHANNELS as t, cubicBezier as u, findCameras as v, kindOf as x, isCamera as y };
1323
+
1324
+ //# sourceMappingURL=channels-Dp6gFE5b.js.map