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>
167 lines
5.2 KiB
JavaScript
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();
|