effectcraft/crates/engine/src/tracking.rs

632 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Motion tracking runtime: the analysis job (background thread with progress and cancel, or
//! blocking for the CLI / agents / wasm), writing track results into the tracker's Track Point
//! properties, and applying tracks (Transform, Stabilize, Corner Pin) to layers.
//!
//! The algorithms are in `effectcraft-track`; the property layout in
//! `effectcraft_project::tracking`.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_project::tracking::{LowConfidence, TrackChannel, TrackKind, TrackerOptions, TrackerSettings};
use effectcraft_project::{Comp, ItemId, Keyframe, Layer, LayerId, Project, PropGroup, Uid, Value};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost, FootageSource, LayerCache, Renderer};
use effectcraft_time::Tick;
use effectcraft_track as trk;
use serde::{Deserialize, Serialize};
use crate::{Event, Session};
/// Tracker panel ▸ Analyze buttons.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Direction {
#[default]
Forward,
Backward,
FrameForward,
FrameBackward,
}
impl Direction {
pub fn from_name(s: &str) -> Option<Direction> {
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
Some(match n.as_str() {
"forward" | "fwd" => Direction::Forward,
"backward" | "back" | "bwd" => Direction::Backward,
"frameforward" | "oneframeforward" | "1frameforward" => Direction::FrameForward,
"framebackward" | "oneframebackward" | "1framebackward" => Direction::FrameBackward,
_ => return None,
})
}
pub fn forward(self) -> bool {
matches!(self, Direction::Forward | Direction::FrameForward)
}
}
/// Track options for the algorithms.
pub fn track_options(o: &TrackerOptions) -> trk::TrackOptions {
trk::TrackOptions {
channel: match o.channel {
TrackChannel::Rgb => trk::Channel::Rgb,
TrackChannel::Luminance => trk::Channel::Luminance,
TrackChannel::Saturation => trk::Channel::Saturation,
},
blur: o.blur,
enhance: o.enhance,
subpixel: o.subpixel,
adapt_every_frame: o.adapt_every_frame,
threshold: o.threshold,
action: match o.action {
LowConfidence::Continue => trk::ConfidenceAction::Continue,
LowConfidence::Stop => trk::ConfidenceAction::Stop,
LowConfidence::Extrapolate => trk::ConfidenceAction::Extrapolate,
LowConfidence::Adapt => trk::ConfidenceAction::Adapt,
},
track_shape: o.track_shape,
}
}
/// A track point's regions and attach offset read from its properties at a layer time.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PointValues {
pub uid: Uid,
pub spec: trk::PointSpec,
pub attach_offset: [f64; 2],
}
pub fn point_values(tp: &PropGroup, lt: Tick) -> PointValues {
let v = |m: &str, d: [f64; 2]| tp.get(m).map(|p| p.value_at(lt).as_vec2()).unwrap_or(d);
PointValues {
uid: tp.uid,
spec: trk::PointSpec {
center: v("featureCenter", [0.0; 2]),
feature_size: v("featureSize", [32.0; 2]),
search_offset: v("searchOffset", [0.0; 2]),
search_size: v("searchSize", [64.0; 2]),
},
attach_offset: v("attachPointOffset", [0.0; 2]),
}
}
/// Attach points of a frame from the feature centres, given the start frame's centres and attach
/// points: two-point transforms rotate and scale the offsets with the track, Parallel Corner Pin
/// maps them through the 3-point affine, Perspective Corner Pin through the 4-point homography.
pub fn attach_points(kind: TrackKind, rot_scale: bool, ref_c: &[[f64; 2]], ref_a: &[[f64; 2]], cur: &[[f64; 2]]) -> Vec<[f64; 2]> {
let translate = || cur.iter().zip(ref_c.iter().zip(ref_a)).map(|(c, (r, a))| [c[0] + a[0] - r[0], c[1] + a[1] - r[1]]).collect::<Vec<_>>();
let through = |h: Option<trk::Homography>| match h {
Some(h) => ref_a.iter().map(|a| h.apply(*a)).collect(),
None => translate(),
};
match kind {
TrackKind::Perspective if cur.len() >= 4 => {
let h = trk::Homography::from_points([ref_c[0], ref_c[1], ref_c[2], ref_c[3]], [cur[0], cur[1], cur[2], cur[3]]);
through(h)
}
TrackKind::Affine if cur.len() >= 3 => through(trk::Homography::affine_from_points([ref_c[0], ref_c[1], ref_c[2]], [cur[0], cur[1], cur[2]])),
_ if rot_scale && cur.len() >= 2 => {
let Some(s) = trk::Homography::similarity_from_points([ref_c[0], ref_c[1]], [cur[0], cur[1]]) else { return translate() };
let m = s.0;
cur.iter()
.zip(ref_c.iter().zip(ref_a))
.map(|(c, (r, a))| {
let d = [a[0] - r[0], a[1] - r[1]];
[c[0] + m[0][0] * d[0] + m[0][1] * d[1], c[1] + m[1][0] * d[0] + m[1][1] * d[1]]
})
.collect()
}
_ => translate(),
}
}
/// Live analysis progress (serde for agents / the control channel).
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TrackProgress {
pub done: u64,
pub total: u64,
pub elapsed: f64,
/// Analysed frames per second.
pub fps: f64,
pub finished: bool,
pub cancelled: bool,
/// Stopped by "If Confidence is Below … ▸ Stop Tracking".
pub low_confidence_stop: bool,
pub error: Option<String>,
/// Comp time (seconds) of the last analysed frame.
pub time: f64,
}
/// One analysed frame.
#[derive(Clone, Debug)]
pub struct FrameResult {
pub comp_time: Tick,
pub layer_time: Tick,
/// Per point: (point group uid, result, attach point).
pub points: Vec<(Uid, trk::PointResult, [f64; 2])>,
}
#[derive(Default)]
pub struct TrackShared {
pub cancel: AtomicBool,
pub state: Mutex<TrackProgress>,
pub frames: Mutex<Vec<FrameResult>>,
}
pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
/// A running (or finished, not yet polled) analysis.
pub struct TrackJob {
pub shared: Arc<TrackShared>,
thread: Option<std::thread::JoinHandle<()>>,
pub comp: ItemId,
pub layer: LayerId,
pub tracker: Uid,
pub direction: Direction,
}
impl TrackJob {
pub fn is_finished(&self) -> bool {
lock(&self.shared.state).finished
}
pub fn progress(&self) -> TrackProgress {
lock(&self.shared.state).clone()
}
pub fn cancel(&self) {
self.shared.cancel.store(true, Ordering::Relaxed);
}
}
/// What the worker needs, detached from the session.
pub(crate) struct Work {
pub project: Arc<Project>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
pub cache: Arc<LayerCache>,
pub comp: ItemId,
pub layer: LayerId,
pub settings: TrackerSettings,
pub points: Vec<PointValues>,
/// Comp times to analyse; the first is the start frame.
pub times: Vec<Tick>,
}
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
/// Footage at its native size is used straight from the footage source (no copy).
pub(crate) fn source_frame(
r: &Renderer,
project: &Project,
cid: ItemId,
comp: &Comp,
layer: &Layer,
t: Tick,
expr: Option<&dyn ExprHost>,
) -> Option<(Arc<Image>, [f64; 2])> {
let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None };
if let effectcraft_project::LayerSource::Footage { item } = &layer.source
&& let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind)
&& f.has_video
&& let Some(img) = r.footage.frame(*item, f, ctx.source_time(layer))
&& img.width == f.width
&& img.height == f.height
{
return Some((img, [0.0; 2]));
}
let buf = r.layer_source(&ctx, layer)?;
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
// Footage decoded at another size: bring it to layer pixels.
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), buf.offset))
}
pub(crate) async fn run_work(w: Work, shared: &TrackShared) {
let t0 = web_time::Instant::now();
// A browser job worker's GPU (deferred readbacks): frames render in passes.
let accel = crate::offload::job_accel();
let accel = accel.as_deref();
let fail = |msg: &str| {
let mut s = lock(&shared.state);
s.error = Some(msg.to_string());
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 mut r = Renderer::new(&w.project, w.footage.as_ref(), crate::offload::analysis_opts(accel));
r.expr = w.expr.as_deref();
r.cache = Some(&w.cache);
let r = &r;
let wr = &w;
let frame_at =
|t: Tick| crate::offload::in_passes(accel, move |a| source_frame(&r.with_accel(a), &wr.project, wr.comp, comp, layer, t, wr.expr.as_deref()));
{
let mut s = lock(&shared.state);
s.total = w.times.len().saturating_sub(1) as u64;
}
let Some(first) = frame_at(w.times[0]).await else { return fail("the layer has no pixels to track") };
let specs: Vec<trk::PointSpec> = w.points.iter().map(|p| p.spec).collect();
let mut tracker = trk::Tracker::new(track_options(&w.settings.options), &specs, &trk::Frame { img: &first.0, offset: first.1 });
let ref_c: Vec<[f64; 2]> = specs.iter().map(|s| s.center).collect();
let ref_a: Vec<[f64; 2]> = w.points.iter().map(|p| [p.spec.center[0] + p.attach_offset[0], p.spec.center[1] + p.attach_offset[1]]).collect();
let rot_scale = w.settings.rotation || w.settings.scale;
// The start frame keeps its regions and gets keys too.
lock(&shared.frames).push(FrameResult {
comp_time: w.times[0],
layer_time: layer.layer_time(w.times[0]),
points: w
.points
.iter()
.zip(&ref_a)
.map(|(p, a)| (p.uid, trk::PointResult { center: p.spec.center, confidence: 100.0, rotation: 0.0, scale: 1.0, status: trk::Status::Tracked }, *a))
.collect(),
});
let mut next = if w.times.len() > 1 { frame_at(w.times[1]).await } else { None };
for k in 1..w.times.len() {
if shared.cancel.load(Ordering::Relaxed) {
lock(&shared.state).cancelled = true;
break;
}
let Some(cur) = next.take() else {
lock(&shared.state).error = Some(format!("no frame at {:.3} s", w.times[k].seconds()));
break;
};
// Track this frame while the next one renders.
let (res, nf) = crate::offload::join_fetch(
accel.is_some(),
|| tracker.step(&trk::Frame { img: &cur.0, offset: cur.1 }),
|| async { if k + 1 < w.times.len() { frame_at(w.times[k + 1]).await } else { None } },
)
.await;
next = nf;
if res.iter().any(|p| p.status == trk::Status::Stopped) {
lock(&shared.state).low_confidence_stop = true;
break;
}
let centers: Vec<[f64; 2]> = res.iter().map(|p| p.center).collect();
let attach = attach_points(w.settings.kind, rot_scale, &ref_c, &ref_a, &centers);
lock(&shared.frames).push(FrameResult {
comp_time: w.times[k],
layer_time: layer.layer_time(w.times[k]),
points: w.points.iter().zip(res).zip(attach).map(|((p, r), a)| (p.uid, r, a)).collect(),
});
let mut s = lock(&shared.state);
s.done = k as u64;
s.elapsed = t0.elapsed().as_secs_f64();
s.fps = if s.elapsed > 0.0 { k as f64 / s.elapsed } else { 0.0 };
s.time = w.times[k].seconds();
crate::offload::report(s.done, s.total);
}
let mut s = lock(&shared.state);
s.elapsed = t0.elapsed().as_secs_f64();
s.finished = true;
}
/// Set (or add) a key at layer time `t`.
pub(crate) fn key(g: &mut PropGroup, m: &str, t: Tick, v: Value) {
if let Some(p) = g.get_mut(m) {
effectcraft_keyframe::set_key(&mut p.keys, Keyframe::new(t, v));
}
}
/// Write analysed frames into the tracker's points.
fn write_frames(p: &mut Project, job: &TrackJob, frames: &[FrameResult]) {
let Some(comp) = p.comp_mut(job.comp) else { return };
let Some(layer) = comp.layer_mut(job.layer) else { return };
for f in frames {
for (uid, r, a) in &f.points {
let Some(tp) = layer.props.find_group_mut(*uid) else { continue };
key(tp, "featureCenter", f.layer_time, Value::Vec2(r.center));
key(tp, "confidence", f.layer_time, Value::Scalar(r.confidence));
key(tp, "attachPoint", f.layer_time, Value::Vec2(*a));
}
}
}
impl Session {
/// Whether a track analysis is running.
pub fn is_tracking(&self) -> bool {
self.track_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(crate::offload::JobKind::Track).is_some()
}
/// Live progress of the running (or just finished) analysis.
pub fn track_progress(&self) -> Option<TrackProgress> {
self.track_job.as_ref().map(|j| j.progress()).or_else(|| self.offloaded(crate::offload::JobKind::Track).map(|j| j.progress.track()))
}
/// Cancel the running analysis (results so far are kept).
pub fn stop_track(&mut self) -> bool {
if self.cancel_offloaded(crate::offload::JobKind::Track) {
return true;
}
match &self.track_job {
Some(j) if !j.is_finished() => {
j.cancel();
true
}
_ => false,
}
}
/// Start analysing. `wait`: block until done (wasm32 always does). One undo step covers the
/// whole analysis.
pub(crate) fn start_track(&mut self, work: Work, tracker: Uid, direction: Direction, wait: bool) -> Result<(), String> {
if self.is_tracking() {
return Err("a track analysis is already running".into());
}
self.poll_track();
if !wait && self.offloads() {
let Work { comp, layer, settings, points, times, .. } = work;
return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times });
}
let wait = wait || cfg!(target_arch = "wasm32");
let levels = self.prefs.general.undo_levels as usize;
self.history.record("Analyze Track", self.project.clone(), levels);
let shared = Arc::new(TrackShared::default());
let (comp, layer) = (work.comp, work.layer);
let thread = if wait {
crate::offload::run_or_queue({
let sh = shared.clone();
async move { run_work(work, &sh).await }
});
None
} else {
let sh = shared.clone();
Some(
std::thread::Builder::new()
.name("track-analyze".into())
.spawn(move || effectcraft_render::passes::block_on(run_work(work, &sh)))
.map_err(|e| e.to_string())?,
)
};
self.track_job = Some(TrackJob { shared, thread, comp, layer, tracker, direction });
self.poll_track();
Ok(())
}
/// Write new results into the project and move the CTI to the last analysed frame; drop the
/// job once it finished. Frontends call this every frame while tracking.
pub fn poll_track(&mut self) -> bool {
let Some(job) = &self.track_job else { return false };
let frames = std::mem::take(&mut *lock(&job.shared.frames));
let finished = job.is_finished();
let changed = !frames.is_empty();
if changed {
write_frames(Arc::make_mut(&mut self.project), job, &frames);
if self.state.active_comp == Some(job.comp)
&& let Some(last) = frames.last()
{
self.state.times.insert(job.comp, last.comp_time);
}
self.bump();
}
if finished && let Some(mut job) = self.track_job.take() {
if let Some(t) = job.thread.take() {
let _ = t.join();
}
let st = job.progress();
let msg = match (&st.error, st.cancelled, st.low_confidence_stop) {
(Some(e), _, _) => format!("Tracking stopped: {e}"),
(None, true, _) => format!("Tracking stopped after {} frame(s)", st.done),
(None, false, true) => format!("Tracking stopped: confidence below threshold after {} frame(s)", st.done),
_ => format!("Tracked {} frame(s) in {:.1} s", st.done, st.elapsed),
};
self.events.push(Event::Toast { message: msg, error: st.error.is_some() });
}
changed || finished
}
}
// ---------------------------------------------------------------- apply
/// Apply Dimensions.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Dims {
#[default]
XY,
X,
Y,
}
impl Dims {
pub fn from_name(s: &str) -> Option<Dims> {
match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"xy" | "xandy" | "both" => Some(Dims::XY),
"x" | "xonly" => Some(Dims::X),
"y" | "yonly" => Some(Dims::Y),
_ => None,
}
}
fn mix(self, old: [f64; 3], new: [f64; 2]) -> [f64; 3] {
match self {
Dims::XY => [new[0], new[1], old[2]],
Dims::X => [new[0], old[1], old[2]],
Dims::Y => [old[0], new[1], old[2]],
}
}
}
fn apply_m(m: &Mat3, p: [f64; 2]) -> [f64; 2] {
let q = m.apply(vec2(p[0], p[1]));
[q.x, q.y]
}
/// Parent space of a layer (comp space when unparented), as a 2D matrix from comp space.
fn comp_to_parent(ctx: &EvalCtx, layer: &Layer) -> Mat3 {
match layer.parent.and_then(|p| ctx.comp.layer(p)) {
Some(p) => ctx.layer_to_comp(p).0.inverse().unwrap_or(Mat3::IDENTITY),
None => Mat3::IDENTITY,
}
}
/// Per analysed time: (source layer time, attach points in source layer space).
pub fn attach_track(tracker: &PropGroup) -> Vec<(Tick, Vec<[f64; 2]>)> {
let pts: Vec<&PropGroup> = tracker.track_points().collect();
let Some(first) = pts.first().and_then(|g| g.get("attachPoint")) else { return vec![] };
first.keys.iter().map(|k| (k.time, pts.iter().map(|g| g.get("attachPoint").map(|p| p.value_at(k.time).as_vec2()).unwrap_or([0.0; 2])).collect())).collect()
}
/// Apply a tracker (Tracker panel ▸ Apply). Returns the number of keyframed frames.
pub fn apply(p: &mut Project, cid: ItemId, src: LayerId, tracker: Uid, dims: Dims) -> Result<usize, String> {
let comp = p.comp(cid).ok_or("no composition")?.clone();
let source = comp.layer(src).ok_or("no source layer")?.clone();
let (tg, settings) = source.tracker(tracker).ok_or("no such tracker")?;
let settings = settings.clone();
let track = attach_track(tg);
if track.is_empty() {
return Err("analyze the track first".into());
}
let target_id = match settings.kind {
TrackKind::Stabilize => src,
TrackKind::Raw => return Err("a Raw track has nothing to apply".into()),
_ => settings.target.ok_or("set a Motion Target first (Edit Target…)")?,
};
let target = comp.layer(target_id).ok_or("the motion target layer is gone")?.clone();
let ctx0 = EvalCtx::new(p, cid, &comp, Tick::ZERO);
let t_first = source.comp_time(track[0].0);
let rot_scale = settings.point_count() >= 2 && track[0].1.len() >= 2;
let (ang0, len0) = if rot_scale { effectcraft_track::angle_and_length(track[0].1[0], track[0].1[1]) } else { (0.0, 1.0) };
// (target layer time, values) to write.
let mut pos_keys = vec![];
let mut anchor_keys = vec![];
let mut rot_keys = vec![];
let mut scale_keys = vec![];
let mut corner_keys = vec![];
let ttr = target.transform().ok_or("the target has no transform")?;
let c0 = ctx0.at(t_first);
let rot0 = c0.f(&target, ttr, "rotation", 0.0);
let scale0 = c0.v3(&target, ttr, "scale", [100.0; 3]);
let mut prev_ang = 0.0;
// Stabilize: the feature stays where it was on the first tracked frame.
let stab_pos = {
let (m, _) = c0.layer_to_comp(&source);
apply_m(&comp_to_parent(&c0, &source), apply_m(&m, track[0].1[0]))
};
for (lt, pts) in &track {
let ct = source.comp_time(*lt);
let ctx = ctx0.at(ct);
let tt = target.layer_time(ct);
let (ang, len) = if rot_scale { effectcraft_track::angle_and_length(pts[0], pts[1]) } else { (ang0, len0) };
let dang = effectcraft_track::unwrap_degrees(prev_ang, ang - ang0);
prev_ang = dang;
let ratio = if len0 > 1e-9 { len / len0 } else { 1.0 };
match settings.kind {
TrackKind::Stabilize => {
if settings.position {
let old = ctx.v3(&target, ttr, "anchor", [0.0; 3]);
anchor_keys.push((tt, dims.mix(old, pts[0])));
let oldp = ctx.position(&target, ttr);
pos_keys.push((tt, dims.mix(oldp, stab_pos)));
}
if settings.rotation && rot_scale {
rot_keys.push((tt, rot0 - dang));
}
if settings.scale && rot_scale {
scale_keys.push((tt, [scale0[0] / ratio, scale0[1] / ratio, scale0[2]]));
}
}
TrackKind::Transform => {
let (m, _) = ctx.layer_to_comp(&source);
if settings.position {
let c = apply_m(&m, pts[0]);
let pp = apply_m(&comp_to_parent(&ctx, &target), c);
pos_keys.push((tt, dims.mix(ctx.position(&target, ttr), pp)));
}
if settings.rotation && rot_scale {
rot_keys.push((tt, rot0 + dang));
}
if settings.scale && rot_scale {
scale_keys.push((tt, [scale0[0] * ratio, scale0[1] * ratio, scale0[2]]));
}
}
TrackKind::Affine | TrackKind::Perspective => {
let (ms, _) = ctx.layer_to_comp(&source);
let (mt, _) = ctx.layer_to_comp(&target);
let inv = mt.inverse().ok_or("the target layer's transform is not invertible")?;
let n = pts.len();
if n < 3 {
return Err("corner pin tracks need 3 or 4 track points".into());
}
let lr =
if settings.kind == TrackKind::Affine || n < 4 { [pts[1][0] + pts[2][0] - pts[0][0], pts[1][1] + pts[2][1] - pts[0][1]] } else { pts[3] };
// Track Points 1–4: upper left, upper right, lower left, lower right.
let map = |q: [f64; 2]| apply_m(&inv, apply_m(&ms, q));
corner_keys.push((tt, [map(pts[0]), map(pts[1]), map(pts[2]), map(lr)]));
}
TrackKind::Raw => {}
}
}
let n = track.len();
let tsize = effectcraft_render::source_size(p, &target);
let mut ids_next = p.next_id;
let layer = p.comp_mut(cid).and_then(|c| c.layer_mut(target_id)).ok_or("no target layer")?;
if !corner_keys.is_empty() {
let spec = effectcraft_effects::find("ec.distort.cornerpin").ok_or("Corner Pin is not available")?;
let size = tsize;
let size = if size.0 == 0 { [comp.width as f64, comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
let fx = layer.props.sub_mut("effects").ok_or("the target layer can't have effects")?;
let same = fx.groups().filter(|g| g.match_id == spec.id).count();
let name = if same == 0 { spec.name.to_string() } else { format!("{} {}", spec.name, same + 1) };
let mut g = effectcraft_effects::instantiate(spec, &mut effectcraft_project::build::Ids(&mut ids_next), &name, size);
for (t, c) in &corner_keys {
for (m, v) in ["ul", "ur", "ll", "lr"].iter().zip(c) {
key(&mut g, m, *t, Value::Vec2(*v));
}
}
fx.children.push(g.into());
}
let tr = layer.transform_mut().ok_or("no transform")?;
for (t, v) in anchor_keys {
key(tr, "anchor", t, Value::Vec3(v));
}
let separated = tr.get("positionX").is_some();
for (t, v) in pos_keys {
if separated {
key(tr, "positionX", t, Value::Scalar(v[0]));
key(tr, "positionY", t, Value::Scalar(v[1]));
} else {
key(tr, "position", t, Value::Vec3(v));
}
}
for (t, v) in rot_keys {
key(tr, "rotation", t, Value::Scalar(v));
}
for (t, v) in scale_keys {
key(tr, "scale", t, Value::Vec3(v));
}
p.next_id = ids_next;
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attach_points_follow_the_track() {
let rc = [[0.0, 0.0], [10.0, 0.0]];
let ra = [[0.0, 5.0], [10.0, 5.0]];
// Rotated 90° and doubled.
let cur = [[100.0, 100.0], [100.0, 120.0]];
let a = attach_points(TrackKind::Transform, true, &rc, &ra, &cur);
assert!((a[0][0] - 90.0).abs() < 1e-9 && (a[0][1] - 100.0).abs() < 1e-9, "{a:?}");
let a = attach_points(TrackKind::Transform, false, &rc, &ra, &cur);
assert_eq!(a[0], [100.0, 105.0]);
let h = trk::Homography([[1.1, 0.1, 5.0], [0.05, 0.9, -3.0], [0.001, 0.0005, 1.0]]);
let rc4 = [[0.0, 0.0], [100.0, 0.0], [0.0, 100.0], [100.0, 100.0]];
let ra4 = [[10.0, 10.0], [90.0, 10.0], [10.0, 90.0], [90.0, 90.0]];
let cur4: Vec<[f64; 2]> = rc4.iter().map(|p| h.apply(*p)).collect();
let a = attach_points(TrackKind::Perspective, false, &rc4, &ra4, &cur4);
for (got, want) in a.iter().zip(ra4.iter().map(|p| h.apply(*p))) {
assert!((got[0] - want[0]).abs() < 1e-6 && (got[1] - want[1]).abs() < 1e-6);
}
}
}