festifun/frontend/src/camPath.js
m3ultra e5d6b2c47f Integration (M8 + M9): annotation→3D, camera paths, README, v0.1.0
M8: POST /api/annotations resolves a bbox to a 3D point (ray → two-view
triangulation / nearest-point / centroid-depth) via lane B's geometry
primitives; correction panel + bbox-drawing UI; resolved anchors render live
in overlays and the 3D scene. CR-2 (additive): annotations.event_id +
PATCH /api/events/{id} + db.update_event.

M9: keyframed god's-eye camera paths (Catmull-Rom position + slerp
orientation, export/import), gizmos, path-mode camera hooks.

Verified: 101 backend tests pass (two-view corner recovery err 0.00000);
frontend build clean; live in-browser M8 triangulation (gap 0.21) + M9 path
(camera on keyframe at t) + M4 sync ~16ms. Cleanup: worktrees removed, debris
stash dropped, pristine demo DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:59:51 +10:00

167 lines
5.2 KiB
JavaScript

// 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();