//! 3D Camera Tracker runtime: the analysis job (`camera.analyze`, `track.camera`), keeping //! analyses valid, and turning a solve into composition layers (`camera.createFromSolve`). //! //! The job renders the layer's input to the effect (`Renderer::layer_input`) for every frame //! between the layer's In and Out points on a background thread and tracks features through //! them (step 1 of 2, "Analyzing in background"), then solves the camera (step 2, "Solving //! camera"). When only the solve settings changed (Shot Type, Angle of View, Solve Method, //! deleted points) the stored tracks are re-solved without re-tracking. Results are written into //! the effect's hidden parameters as one undo step; edits that change the layer's frames clear //! them and queue the effect again ([`invalidate`], run after every edit), like After Effects. //! //! Composition space: the solve's canonical frame maps to comp world by //! [`CameraSolve::world`] with the zoom of the first solved frame (focal length × the layer's //! scale in the comp), so the "3D Tracker Camera" reproduces the footage's view and created //! layers sit on the selected points. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use effectcraft_effects::camera_tracker::{self as ct, AVERAGE_ERROR, METHOD_USED, SOLVE, SOLVE_KEY, TRACKS, TRACKS_KEY}; use effectcraft_keyframe::{Keyframe, Value}; use effectcraft_project::{AutoOrient, GroupKind, ItemId, ItemKind, Layer, LayerId, Project, PropGroup, Uid}; use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_time::Tick; use effectcraft_track::camtrack::linalg::{self, Similarity, V3}; use effectcraft_track::camtrack::{AnalyzeOpts, CameraSolve, CameraTracks, SolveSettings, Target, TrackAnalyzer}; use serde::Serialize; use crate::offload::JobKind; use crate::tracking::lock; use crate::warp::{analysis_times, signature}; use crate::{Event, Session}; /// Live analysis progress (serde for agents / the control channel). #[derive(Clone, Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] pub struct CameraProgress { /// 1 = tracking features, 2 = solving the camera. pub step: u8, pub done: u64, pub total: u64, pub elapsed: f64, pub fps: f64, pub finished: bool, pub cancelled: bool, pub error: Option, /// Re-solving stored tracks (no tracking step). pub solve_only: bool, } impl CameraProgress { /// The viewer banner text, as After Effects words it. pub fn banner(&self) -> String { match self.step { 2 => "Solving camera".into(), _ => { let pc = (100 * self.done).checked_div(self.total).unwrap_or(0); format!("Analyzing in background (step 1 of 2): {pc}%") } } } } struct CamResult { /// New tracks (None for a re-solve). tracks: Option, solve: Result, } #[derive(Default)] pub struct CameraShared { pub cancel: AtomicBool, pub state: Mutex, result: Mutex>, } /// A running (or finished, not yet polled) camera analysis. pub struct CameraJob { pub shared: Arc, thread: Option>, pub comp: ItemId, pub layer: LayerId, pub effect: Uid, /// The layer signature the tracks are made from, and the solve settings key. key: String, solve_key: String, } impl CameraJob { pub fn is_finished(&self) -> bool { lock(&self.shared.state).finished } pub fn progress(&self) -> CameraProgress { lock(&self.shared.state).clone() } } struct CamWork { project: Arc, footage: Arc, expr: Option>, cache: Arc, comp: ItemId, layer: LayerId, effect_index: usize, size: [f64; 2], detailed: bool, times: Vec, /// Stored tracks to re-solve (skips step 1). tracks: Option>, settings: SolveSettings, } pub(crate) fn is_tracker(g: &PropGroup) -> bool { matches!(&g.kind, GroupKind::Effect { effect } if effect == ct::ID) } fn str_of(g: &PropGroup, m: &str) -> String { match g.prop(m).map(|p| &p.value) { Some(Value::Str(s)) => s.clone(), _ => String::new(), } } /// What a tracker's tracks are made from: the layer's frames and Detailed Analysis. fn tracks_key(project: &Project, cid: ItemId, l: &Layer, g: &PropGroup) -> String { let detailed = effectcraft_effects::warp_stab::param(&static_params(g), "advanced/detailedAnalysis").is_some_and(Value::as_bool); format!("{}{}", signature(project, cid, l), if detailed { "+detailed" } else { "" }) } /// Parameters of an instance from static values (the tracker's settings are not animated). pub fn static_params(g: &PropGroup) -> effectcraft_effects::Params { effectcraft_effects::flatten_params(g, &mut |p| p.value.clone()) } fn clear(g: &mut PropGroup, all: bool) { let mut ids = vec![SOLVE, SOLVE_KEY, METHOD_USED]; if all { ids.extend([TRACKS, TRACKS_KEY]); } for m in ids { if let Some(p) = g.prop_mut(m) { p.value = Value::Str(String::new()); p.keys.clear(); } } if let Some(p) = g.prop_mut(AVERAGE_ERROR) { p.value = Value::Scalar(0.0); } } /// Clear analyses that no longer match, after an edit: tracks made from other frames (and their /// solve), and solves made with other settings. Returns the trackers that need (re-)analysis: /// cleared ones and newly applied, unanalysed ones. pub(crate) fn invalidate(before: &Project, after: &mut Project) -> Vec<(ItemId, LayerId, Uid)> { let mut todo = vec![]; let mut clears = vec![]; for (cid, it) in &after.items { let ItemKind::Comp(c) = &it.kind else { continue }; for l in &c.layers { let Some(fx) = l.effects() else { continue }; if !fx.groups().any(is_tracker) { continue; } let old_layer = before.comp(*cid).and_then(|bc| bc.layer(l.id)); for g in fx.groups().filter(|g| is_tracker(g)) { let tracked = !str_of(g, TRACKS).is_empty(); if tracked && str_of(g, TRACKS_KEY) != tracks_key(after, *cid, l, g) { clears.push((*cid, l.id, g.uid, true)); todo.push((*cid, l.id, g.uid)); } else if tracked && str_of(g, SOLVE_KEY) != ct::solve_key(&ct::settings(&static_params(g))) { clears.push((*cid, l.id, g.uid, false)); todo.push((*cid, l.id, g.uid)); } else if !tracked && old_layer.and_then(|ol| ol.props.find_group(g.uid)).is_none() { todo.push((*cid, l.id, g.uid)); } } } } for (cid, lid, uid, all) in clears { if let Some(g) = after.comp_mut(cid).and_then(|c| c.layer_mut(lid)).and_then(|l| l.props.find_group_mut(uid)) { clear(g, all); } } todo } fn input_frame(r: &Renderer, ctx: &EvalCtx, layer: &Layer, effect_index: usize) -> Option<(Arc, [f64; 2])> { let buf = r.layer_input(ctx, layer, effect_index)?; if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 { let w = (buf.img.width as f64 / buf.scale).round().max(1.0) as u32; let h = (buf.img.height as f64 / buf.scale).round().max(1.0) as u32; let off = [buf.offset[0] / buf.scale, buf.offset[1] / buf.scale]; return Some((Arc::new(effectcraft_raster::resample(&buf.img, w, h)), off)); } Some((Arc::new(buf.img.clone()), buf.offset)) } fn run_work(w: CamWork, shared: &CameraShared) { let t0 = web_time::Instant::now(); let fail = |msg: &str| { let mut s = lock(&shared.state); s.error = Some(msg.to_string()); s.finished = true; }; let cancelled = || { let mut s = lock(&shared.state); s.cancelled = true; s.finished = true; }; let Some(comp) = w.project.comp(w.comp) else { return fail("composition missing") }; let Some(layer) = comp.layer(w.layer) else { return fail("layer missing") }; let lt0 = layer.layer_time(w.times[0]).seconds(); let fd = if w.times.len() > 1 { layer.layer_time(w.times[1]).seconds() - lt0 } else { comp.frame_duration().seconds() }; let (tracks, fresh) = match w.tracks.clone() { Some(t) => (t, false), None => { let mut r = Renderer::new(&w.project, w.footage.as_ref(), RenderOpts { scale: 1.0, ..Default::default() }); r.expr = w.expr.as_deref(); r.cache = Some(&w.cache); let r = &r; let ctx0 = EvalCtx::new(&w.project, w.comp, comp, Tick::ZERO); let ctx0 = EvalCtx { expr: w.expr.as_deref(), ..ctx0 }; let frame_at = |t: Tick| input_frame(r, &ctx0.at(t), layer, w.effect_index); { let mut s = lock(&shared.state); s.step = 1; s.total = w.times.len() as u64; } let mut an = TrackAnalyzer::new(w.size, AnalyzeOpts { detailed: w.detailed }); let mut next = frame_at(w.times[0]); for k in 0..w.times.len() { if shared.cancel.load(Ordering::Relaxed) { return cancelled(); } let Some(cur) = next.take() else { return fail(&format!("no frame at {:.3} s", w.times[k].seconds())) }; let ((), nf) = rayon::join( || an.push(&effectcraft_track::Frame { img: &cur.0, offset: cur.1 }), || if k + 1 < w.times.len() { frame_at(w.times[k + 1]) } else { None }, ); next = nf; let mut s = lock(&shared.state); s.done = k as u64 + 1; s.elapsed = t0.elapsed().as_secs_f64(); s.fps = if s.elapsed > 0.0 { s.done as f64 / s.elapsed } else { 0.0 }; crate::offload::report(s.done, s.total); } (Arc::new(an.finish(lt0, fd)), true) } }; lock(&shared.state).step = 2; let solve = effectcraft_track::camtrack::solve(&tracks, &w.settings, Some(&shared.cancel)).map_err(|e| e.0); if shared.cancel.load(Ordering::Relaxed) { return cancelled(); } *lock(&shared.result) = Some(CamResult { tracks: fresh.then(|| (*tracks).clone()), solve }); let mut s = lock(&shared.state); s.elapsed = t0.elapsed().as_secs_f64(); s.finished = true; } impl Session { pub fn is_camera_analyzing(&self) -> bool { self.camera_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::Camera).is_some() } /// Progress of the running (or just finished) analysis. pub fn camera_progress(&self) -> Option { self.camera_job.as_ref().map(|j| j.progress()).or_else(|| { self.offloaded(JobKind::Camera).map(|j| { let p = &j.progress; CameraProgress { step: 1, done: p.done, total: p.total, elapsed: p.elapsed, fps: crate::offload::rate(p.done, p.elapsed), ..Default::default() } }) }) } /// The tracker being analysed: (comp, layer, effect uid). pub fn camera_target(&self) -> Option<(ItemId, LayerId, Uid)> { self.camera_job.as_ref().map(|j| (j.comp, j.layer, j.effect)).or_else(|| self.offloaded(JobKind::Camera).map(|j| (j.comp, j.layer, j.uid))) } /// Cancel the running analysis (nothing is written). pub fn stop_camera(&mut self) -> bool { if self.cancel_offloaded(JobKind::Camera) { return true; } match &self.camera_job { Some(j) if !j.is_finished() => { j.shared.cancel.store(true, Ordering::Relaxed); true } _ => false, } } /// Start analysing 3D Camera Tracker `effect` on `layer` (re-solving the stored tracks when /// they are still valid). `wait`: block until done (wasm32 always does). Returns the number /// of frames. pub fn start_camera(&mut self, comp: ItemId, layer: LayerId, effect: Uid, wait: bool) -> Result { if self.is_camera_analyzing() { return Err("a 3D Camera Tracker analysis is already running".into()); } self.poll_camera_job(); self.camera_pending.retain(|x| *x != (comp, layer, effect)); let c = self.project.comp(comp).ok_or("no composition")?; let l = c.layer(layer).ok_or("no layer")?; let fx = l.effects().ok_or("the layer has no effects")?; let index = fx.groups().position(|g| g.uid == effect).ok_or("no such effect")?; let g = fx.groups().nth(index).filter(|g| is_tracker(g)).ok_or("not a 3D Camera Tracker")?; if l.switches.adjustment { return Err("3D Camera Tracker needs footage, not an adjustment layer".into()); } let times = analysis_times(c, l); if times.len() < 2 { return Err("the layer must be at least two frames long".into()); } let (w, h) = effectcraft_render::source_size(&self.project, l); let size = if w == 0 { [c.width as f64, c.height as f64] } else { [w as f64, h as f64] }; let params = static_params(g); let key = tracks_key(&self.project, comp, l, g); let settings = ct::settings(¶ms); let solve_key = ct::solve_key(&settings); let tracks = ct::tracks(¶ms).filter(|_| str_of(g, TRACKS_KEY) == key); let n = times.len(); let work = CamWork { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), cache: self.layer_cache.clone(), comp, layer, effect_index: index, size, detailed: params.values.iter().any(|(k, v)| k.ends_with("detailedAnalysis") && v.as_bool()), times, tracks: tracks.clone(), settings, }; if !wait && self.offloads() { drop(work); self.offload_analysis(crate::offload::WorkerJob::Camera { comp, layer, effect })?; return Ok(n); } let wait = wait || cfg!(target_arch = "wasm32"); let shared = Arc::new(CameraShared::default()); { let mut st = lock(&shared.state); st.solve_only = tracks.is_some(); st.step = if tracks.is_some() { 2 } else { 1 }; st.total = n as u64; } let thread = if wait { run_work(work, &shared); None } else { let sh = shared.clone(); Some(std::thread::Builder::new().name("camera-track".into()).spawn(move || run_work(work, &sh)).map_err(|e| e.to_string())?) }; self.camera_job = Some(CameraJob { shared, thread, comp, layer, effect, key, solve_key }); self.events.push(Event::ProjectChanged { revision: self.revision }); self.poll_camera_job(); Ok(n) } /// Write a finished analysis into its effect (one undo step) and drop the job. fn poll_camera_job(&mut self) -> bool { let Some(job) = &self.camera_job else { return false }; if !job.is_finished() { return false; } let mut job = self.camera_job.take().expect("job"); if let Some(t) = job.thread.take() { let _ = t.join(); } let st = job.progress(); let result = lock(&job.shared.result).take(); match (result, &st.error) { (Some(res), None) if !st.cancelled => { let (cid, lid, uid) = (job.comp, job.layer, job.effect); let (key, skey) = (job.key.clone(), job.solve_key.clone()); let tracks_json = res.tracks.as_ref().map(CameraTracks::to_json); let (solve_json, method, avg, msg, failed) = match &res.solve { Ok(s) => ( s.to_json(), s.method_used.label().to_string(), s.average_error, format!( "3D Camera Tracker: solved {} frame(s), {} points, average error {:.2} px ({:.1} s)", s.frames.len(), s.points.len(), s.average_error, st.elapsed ), false, ), Err(e) => (String::new(), String::new(), 0.0, format!("3D Camera Tracker: {e}"), true), }; let r = self.edit(if st.solve_only { "3D Camera Tracker Solve" } else { "3D Camera Tracker Analysis" }, None, |p, _| { let g = p .comp_mut(cid) .and_then(|c| c.layer_mut(lid)) .and_then(|l| l.props.find_group_mut(uid)) .ok_or_else(|| crate::EngineError::Other("the 3D Camera Tracker was removed during analysis".into()))?; let mut set = |m: &str, v: Value| { if let Some(pr) = g.prop_mut(m) { pr.value = v; } }; if let Some(t) = &tracks_json { set(TRACKS, Value::Str(t.clone())); set(TRACKS_KEY, Value::Str(key.clone())); } set(SOLVE, Value::Str(solve_json.clone())); set(SOLVE_KEY, Value::Str(skey.clone())); set(METHOD_USED, Value::Str(method.clone())); set(AVERAGE_ERROR, Value::Scalar(avg)); Ok(()) }); let (msg, failed) = match r { Ok(()) => (msg, failed), Err(e) => (format!("3D Camera Tracker: {e}"), true), }; self.events.push(Event::Toast { message: msg, error: failed }); } _ => { let msg = match &st.error { Some(e) => format!("3D Camera Tracker analysis failed: {e}"), None => "3D Camera Tracker analysis cancelled".into(), }; self.events.push(Event::Toast { message: msg, error: st.error.is_some() }); self.bump(); } } true } /// Frontends call this every frame: finishes analyses and (with `auto`) starts queued /// (re-)analyses in the background, like After Effects. pub fn poll_camera(&mut self, auto: bool) -> bool { let changed = self.poll_camera_job(); if auto && self.camera_job.is_none() && self.offloaded(JobKind::Camera).is_none() { while let Some((c, l, e)) = self.camera_pending.first().copied() { self.camera_pending.remove(0); if self.start_camera(c, l, e, false).is_ok() { return true; } } } changed } } // ---------------------------------------------------------------- solve → composition /// A tracker's solve placed in its composition. pub struct Placed { pub solve: Arc, /// Canonical → comp world. pub world: Similarity, /// Comp pixels per layer pixel. pub scale: f64, /// Comp times of the analysed frames. pub times: Vec, pub comp_size: [f64; 2], } impl Placed { /// Comp-world position of a canonical point. pub fn to_world(&self, x: V3) -> V3 { self.world.apply(x) } /// Canonical position of a comp-world point. pub fn from_world(&self, x: V3) -> V3 { let r = &self.world; linalg::scale(linalg::mtv(&r.r, linalg::sub(x, r.t)), 1.0 / r.s) } /// The 3D Tracker Camera on frame `k`: (position, orientation degrees, zoom). pub fn camera(&self, k: usize) -> (V3, V3, f64) { let c = &self.solve.frames[k]; let m = linalg::mm(&self.world.r, &linalg::mt(&c.r())); (self.to_world(c.center), linalg::euler_xyz(&m), c.focal * self.scale) } /// Analysed frame at comp time `t`. pub fn frame_at(&self, t: Tick) -> usize { self.times.iter().enumerate().min_by_key(|(_, x)| (x.0 - t.0).abs()).map(|(i, _)| i).unwrap_or(0).min(self.solve.frames.len().saturating_sub(1)) } } /// Comp pixels per layer pixel of `layer` (its 2D transform at its In point). fn layer_scale(project: &Project, cid: ItemId, layer: &Layer) -> f64 { let Some(comp) = project.comp(cid) else { return 1.0 }; let ctx = EvalCtx::new(project, cid, comp, layer.in_point.max(Tick::ZERO)); let m = ctx.world_matrix(layer).0; let s = m[0][0].hypot(m[1][0]); if s.is_finite() && s > 1e-9 { s } else { 1.0 } } /// The solve of tracker `uid` on `lid`, placed in the comp. pub fn placed(project: &Project, cid: ItemId, lid: LayerId, uid: Uid) -> Option { let comp = project.comp(cid)?; let layer = comp.layer(lid)?; let g = layer.props.find_group(uid).filter(|g| is_tracker(g))?; let solve = ct::solve(&static_params(g))?; let scale = layer_scale(project, cid, layer); let k0 = solve.first_solved()?; let comp_size = [comp.width as f64, comp.height as f64]; let world = solve.world(comp_size, solve.frames[k0].focal * scale); let mut times = analysis_times(comp, layer); times.truncate(solve.frames.len()); Some(Placed { solve, world, scale, times, comp_size }) } /// What to create from a target (`camera.createFromSolve`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CreateKind { Text, Solid, Null, ShadowCatcher, /// Only the camera. Camera, } impl CreateKind { pub fn from_name(s: &str) -> Option { match s.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { "text" => Some(CreateKind::Text), "solid" => Some(CreateKind::Solid), "null" => Some(CreateKind::Null), "shadowcatcher" | "shadow" => Some(CreateKind::ShadowCatcher), "camera" => Some(CreateKind::Camera), _ => None, } } } /// Rotation (layer → world) of a layer lying on a plane with unit `normal` (towards the camera), /// its x axis along `right` projected on the plane: the layer's front (−z) faces the camera. pub fn plane_orientation(normal: V3, right: V3) -> V3 { let z = linalg::scale(normal, -1.0); let mut x = linalg::sub(right, linalg::scale(z, linalg::dot(right, z))); if linalg::norm(x) < 1e-9 { x = if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }; x = linalg::sub(x, linalg::scale(z, linalg::dot(x, z))); } let x = linalg::normalize(x); let y = linalg::cross(z, x); let m = linalg::from_cols(x, y, z); norm_angles(linalg::euler_xyz(&m)) } fn norm_angles(a: V3) -> V3 { a.map(|v| { let r = v.rem_euclid(360.0); if (r - 360.0).abs() < 1e-9 { 0.0 } else { r } }) } /// Key `path` on `l` at every `(time, value)`. pub(crate) fn key_all(l: &mut Layer, path: &str, keys: &[(Tick, Value)]) { if let Some(pr) = l.props.prop_mut(path) { pr.keys = keys.iter().map(|(t, v)| Keyframe::new(*t, v.clone())).collect(); if let Some((_, v)) = keys.first() { pr.value = v.clone(); } } } /// Write the solved camera path into camera layer `l` (one-node, keyed on every analysed frame; /// Zoom keyed for Variable Zoom). pub(crate) fn write_camera(l: &mut Layer, pl: &Placed) { l.auto_orient = AutoOrient::Off; let n = pl.times.len().min(pl.solve.frames.len()); let mut pos = Vec::with_capacity(n); let mut ori = Vec::with_capacity(n); let mut zoom = Vec::with_capacity(n); let mut prev: Option = None; for k in 0..n { let (p, o, z) = pl.camera(k); // Unwrap the angles so per-frame keys interpolate the short way. let o = match prev { None => norm_angles(o), Some(q) => [0, 1, 2].map(|i| q[i] + (o[i] - q[i] + 180.0).rem_euclid(360.0) - 180.0), }; prev = Some(o); let t = l.layer_time(pl.times[k]); pos.push((t, Value::Vec3(p))); ori.push((t, Value::Vec3(o))); zoom.push((t, Value::Scalar(z))); } key_all(l, "transform/position", &pos); key_all(l, "transform/orientation", &ori); let variable = pl.solve.frames.iter().any(|f| (f.focal - pl.solve.frames[0].focal).abs() > 1e-6 * f.focal); if variable { key_all(l, "cameraOptions/zoom", &zoom); } else if let Some((_, z)) = zoom.first() && let Some(pr) = l.props.prop_mut("cameraOptions/zoom") { pr.keys.clear(); pr.value = z.clone(); } if let Some((_, z)) = zoom.first() && let Some(pr) = l.props.prop_mut("cameraOptions/focusDistance") { pr.value = z.clone(); } // A one-node camera has no Point of Interest. if let Some(tr) = l.props.sub_mut("transform") { tr.children.retain(|c| !matches!(c, effectcraft_project::Node::Prop(p) if p.match_id == "poi")); } for rot in ["transform/rotationX", "transform/rotationY", "transform/rotation"] { if let Some(pr) = l.props.prop_mut(rot) { pr.keys.clear(); pr.value = Value::Scalar(0.0); } } } /// The target for `ids` on frame `k`: when fewer than three points are given, the nearest /// solved neighbours (in 3D) complete the plane. pub fn target_for(solve: &CameraSolve, ids: &[u32], k: usize) -> Option { let mut use_ids: Vec = ids.iter().copied().filter(|i| solve.point(*i).is_some()).collect(); if use_ids.is_empty() { return None; } if use_ids.len() < 3 { let c = solve.point(use_ids[0])?.pos; let mut others: Vec<(f64, u32)> = solve.visible(k).filter(|p| !use_ids.contains(&p.id)).map(|p| (linalg::norm(linalg::sub(p.pos, c)), p.id)).collect(); others.sort_by(|a, b| a.0.total_cmp(&b.0)); for (_, id) in others.into_iter().take(6usize.saturating_sub(use_ids.len())) { use_ids.push(id); } let mut t = solve.target(&use_ids, k)?; // The target sits on the chosen point(s). let pts: Vec = ids.iter().filter_map(|i| solve.point(*i)).map(|p| p.pos).collect(); t.center = linalg::scale(pts.iter().fold([0.0; 3], |a, b| linalg::add(a, *b)), 1.0 / pts.len() as f64); return Some(t); } solve.target(&use_ids, k) } #[cfg(test)] mod tests { use super::*; #[test] fn plane_orientation_faces_the_normal() { let n = linalg::normalize([0.2, -0.9, -0.3]); let o = plane_orientation(n, [1.0, 0.0, 0.0]); let m = linalg::from_euler_xyz(o); // Layer −z (its front) is the normal. let front = linalg::mv(&m, [0.0, 0.0, -1.0]); assert!(linalg::norm(linalg::sub(front, n)) < 1e-9); } }