use crate::check::{Check, CheckCtx};
use crate::config::{ClipExpectations, Config};
use crate::evaluation::{
Applicability, CheckOutput, CoverageGap, CoverageGapCode, EvaluationScope, EvaluationScopeCode,
};
use crate::finding::{Finding, Severity};
use crate::metrics::loop_continuity_metrics;
use super::exceeds_f32_cap;
pub const DEFAULT_MAX_POSITION_DELTA_M: f64 = 0.01;
pub const DEFAULT_MAX_ROTATION_DELTA_DEG: f64 = 1.0;
pub(crate) fn effective_caps(config: &Config, expectations: &ClipExpectations) -> (f64, f64) {
let settings = config.check_settings("loop-closure");
(
expectations.max_loop_position_delta_m.unwrap_or(
settings
.max_position_delta_m
.unwrap_or(DEFAULT_MAX_POSITION_DELTA_M),
),
expectations.max_loop_rotation_delta_deg.unwrap_or(
settings
.max_rotation_delta_deg
.unwrap_or(DEFAULT_MAX_ROTATION_DELTA_DEG),
),
)
}
pub struct LoopClosure;
impl Check for LoopClosure {
fn id(&self) -> &'static str {
"loop-closure"
}
fn applicability(&self, ctx: &CheckCtx) -> Applicability {
if ctx
.clip_expectations()
.iter()
.any(|expectations| expectations.looping == Some(true))
{
Applicability::Applicable
} else {
Applicability::NotApplicable
}
}
fn evaluate(&self, ctx: &CheckCtx) -> CheckOutput {
let mut findings = Vec::new();
let mut evaluated_scopes = Vec::new();
let mut gaps = Vec::new();
for (clip_index, clip) in ctx.doc.clips.iter().enumerate() {
let expectations = ctx.expectations(clip_index);
if expectations.looping != Some(true) {
continue;
}
let (max_position_delta_m, max_rotation_delta_deg) =
effective_caps(ctx.config, expectations);
let scope = EvaluationScope::new(EvaluationScopeCode::LOOP_CLOSURE).subject(&clip.name);
let Some(metrics) = ctx
.grid(clip_index)
.as_deref()
.and_then(loop_continuity_metrics)
else {
gaps.push(
CoverageGap::new(
CoverageGapCode::MEASUREMENT_UNAVAILABLE,
"clip has no usable per-bone loop-continuity sample",
)
.scope(scope),
);
continue;
};
evaluated_scopes.push(scope);
let max_position = metrics
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.position_delta_m.total_cmp(&b.position_delta_m));
if let Some((bone_index, metric)) = max_position
&& exceeds_f32_cap(metric.position_delta_m, max_position_delta_m)
{
let bone = &ctx.doc.skeleton.bones[bone_index].name;
findings.push(
Finding::new(
self.id(),
Severity::Error,
format!(
"loop does not close in position: bone '{bone}' is {:.4} m from its first-frame model-space position (cap {:.4} m)",
metric.position_delta_m, max_position_delta_m
),
)
.clip(&clip.name)
.bone(bone)
.time(clip.duration_s as f32)
.measured(metric.position_delta_m)
.expected(max_position_delta_m),
);
}
let max_rotation = metrics
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.rotation_delta_deg.total_cmp(&b.rotation_delta_deg));
if let Some((bone_index, metric)) = max_rotation
&& exceeds_f32_cap(metric.rotation_delta_deg, max_rotation_delta_deg)
{
let bone = &ctx.doc.skeleton.bones[bone_index].name;
findings.push(
Finding::new(
self.id(),
Severity::Error,
format!(
"loop does not close in rotation: bone '{bone}' is {:.3} degrees from its first-frame model-space rotation (cap {:.3} degrees)",
metric.rotation_delta_deg, max_rotation_delta_deg
),
)
.clip(&clip.name)
.bone(bone)
.time(clip.duration_s as f32)
.measured(metric.rotation_delta_deg)
.expected(max_rotation_delta_deg),
);
}
}
CheckOutput::from_coverage(findings, evaluated_scopes, gaps)
}
}