// Keyframed "god's-eye" camera paths (spec M9, integration phase). // // "Add keyframe" captures the current free-roam camera pose at the current t_global. Keyframes // render as octahedron gizmos with a smooth preview line. On playback (while the transport // plays) the viewer camera follows the path: position is interpolated with a Catmull-Rom curve // through the keyframe centers, orientation with quaternion slerp, both parameterized by // t_global between the first and last keyframe. Paths export/import as JSON. import * as THREE from "three"; import { state } from "./state.js"; import { scene3d } from "./scene3d.js"; const _qa = new THREE.Quaternion(); const _qb = new THREE.Quaternion(); const _pos = new THREE.Vector3(); export class CamPath { constructor() { this.keyframes = []; // [{ t_global, pos:[x,y,z], quat:[x,y,z,w], fov }] this.playing = false; this.group = null; // gizmos + preview line this._curve = null; this.onChange = null; // optional UI callback (count) } _ensureGroup() { if (!this.group) { this.group = new THREE.Group(); scene3d.addObject(this.group); } } /** Capture the current viewer-camera pose at the current master time. */ addKeyframe() { const cam = scene3d.camera; if (!cam) return; this.keyframes.push({ t_global: state.tGlobal, pos: [cam.position.x, cam.position.y, cam.position.z], quat: [cam.quaternion.x, cam.quaternion.y, cam.quaternion.z, cam.quaternion.w], fov: cam.fov, }); this.keyframes.sort((a, b) => a.t_global - b.t_global); this._rebuild(); } clear() { this.stop(); this.keyframes = []; this._rebuild(); } count() { return this.keyframes.length; } _rebuild() { this._ensureGroup(); for (const child of [...this.group.children]) { this.group.remove(child); child.geometry?.dispose?.(); child.material?.dispose?.(); } this._curve = null; for (const k of this.keyframes) { const giz = new THREE.Mesh( new THREE.OctahedronGeometry(0.16), new THREE.MeshBasicMaterial({ color: 0xffd23b, wireframe: true }) ); giz.position.set(k.pos[0], k.pos[1], k.pos[2]); this.group.add(giz); } if (this.keyframes.length >= 2) { this._curve = new THREE.CatmullRomCurve3( this.keyframes.map((k) => new THREE.Vector3(k.pos[0], k.pos[1], k.pos[2])) ); const pts = this._curve.getPoints(Math.max(20, this.keyframes.length * 12)); const line = new THREE.Line( new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0xffd23b, transparent: true, opacity: 0.6 }) ); this.group.add(line); } this.onChange?.(this.keyframes.length); } /** Begin following the path (requires >= 2 keyframes). Caller should also start the transport. */ play() { if (this.keyframes.length < 2) return false; this.playing = true; scene3d.enterPathMode(); return true; } stop() { if (!this.playing) return; this.playing = false; scene3d.exitPathMode(); } /** Per-frame: if playing, drive the viewer camera from the path at the current master time. */ update(tGlobal) { if (!this.playing) return; // If something else took the camera (snap-to-camera exits path mode), stop driving it. if (!scene3d.pathMode) { this.playing = false; this.onChange?.(this.keyframes.length); return; } if (!this._curve || this.keyframes.length < 2) return; const kf = this.keyframes; const n = kf.length; const t0 = kf[0].t_global; const t1 = kf[n - 1].t_global; // Locate the bracketing keyframes by time; clamp outside the path's time span. let i = 0; if (tGlobal <= t0) { i = 0; } else if (tGlobal >= t1) { i = n - 2; } else { while (i < n - 2 && kf[i + 1].t_global <= tGlobal) i++; } const a = kf[i]; const b = kf[i + 1]; const span = b.t_global - a.t_global; let f = span > 1e-6 ? (tGlobal - a.t_global) / span : 0; f = Math.max(0, Math.min(1, f)); // Position: Catmull-Rom, parameterized by (segment index + local fraction) / (n - 1). const u = Math.max(0, Math.min(1, (i + f) / (n - 1))); this._curve.getPoint(u, _pos); // Orientation: slerp the two bracketing keyframe quaternions. _qa.set(a.quat[0], a.quat[1], a.quat[2], a.quat[3]); _qb.set(b.quat[0], b.quat[1], b.quat[2], b.quat[3]); _qa.slerp(_qb, f); const fov = a.fov + (b.fov - a.fov) * f; scene3d.applyCameraPose(_pos, _qa, fov); } toJSON() { return JSON.stringify({ version: 1, keyframes: this.keyframes }, null, 2); } fromJSON(text) { const data = typeof text === "string" ? JSON.parse(text) : text; if (!data || !Array.isArray(data.keyframes)) throw new Error("invalid camera-path file"); this.keyframes = data.keyframes .filter((k) => Array.isArray(k.pos) && Array.isArray(k.quat)) .map((k) => ({ t_global: +k.t_global || 0, pos: k.pos.slice(0, 3).map(Number), quat: k.quat.slice(0, 4).map(Number), fov: +k.fov || 50, })) .sort((a, b) => a.t_global - b.t_global); this._rebuild(); } } export const camPath = new CamPath();