//! Fixed render orbit pivot is independent of the orthographic image center. use crate::{ preview::{self, Mat4, PreviewCharacter}, project::CameraFraming, }; use serde::Serialize; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum PivotSource { Manual, Neck { joint_index: usize, joint_name: String, }, Fallback { reason: String, }, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Resolved { pub framing: CameraFraming, pub source: PivotSource, } impl Resolved { pub fn description(&self) -> String { let y = self .framing .pivot_y_milli .unwrap_or(self.framing.center_y_milli) as f32 / 1000.; match &self.source { PivotSource::Manual => format!("Vaste pivot Y {y:.3} m"), PivotSource::Neck { joint_name, .. } => { format!("Nekpivot {joint_name}: Y {y:.3} m (rustpose)") } PivotSource::Fallback { reason } => { format!("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in") } } } } fn neck_name(name: &str) -> bool { let lower = name.to_ascii_lowercase(); let is_neck = |token: &str| { token .strip_prefix("neck") .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit())) }; lower .split(|c: char| !c.is_ascii_alphanumeric()) .any(is_neck) || ["mixamorig", "bip001", "bip01", "ccbase"] .iter() .any(|prefix| lower.strip_prefix(prefix).is_some_and(is_neck)) } fn ancestor(body: &PreviewCharacter, parent: usize, child: usize) -> bool { let mut current = Some(child); for _ in 0..body.nodes.len() { let Some(node) = current else { return false }; if node == parent { return true; } current = body.nodes.get(node).and_then(|n| n.parent); } false } /// Select the base of one neck chain, not a similarly named unrelated skeleton. pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> { let candidates = body .joint_names .iter() .enumerate() .filter(|(_, n)| neck_name(n)) .map(|(j, _)| j) .collect::>(); if candidates.is_empty() { return Err("Geen nekbot gevonden".into()); } let roots = candidates .iter() .copied() .filter(|j| { candidates.iter().all(|other| { match (body.joint_nodes.get(*j), body.joint_nodes.get(*other)) { (Some(a), Some(b)) => ancestor(body, *a, *b), _ => false, } }) }) .collect::>(); if roots.len() != 1 { return Err("Meerdere losstaande/ongeldige nekketens".into()); } let joint = roots[0]; let world = preview::rest_joint_world(body, joint).ok_or("Ongeldige rustpose van nekbot")?; let height = (world[13] * 1000.).round(); if !(-10000.0..=10000.0).contains(&height) { return Err("Nekhoogte buiten -10…10 m".into()); } Ok((joint, height as i32)) } pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved { if framing.pivot_y_milli.is_some() { return Resolved { framing, source: PivotSource::Manual, }; } let source = match neck_height(body) { Ok((joint, y)) => { framing.pivot_y_milli = Some(y); PivotSource::Neck { joint_index: joint, joint_name: body.joint_names[joint].clone(), } } Err(reason) => { framing.pivot_y_milli = Some(framing.center_y_milli); PivotSource::Fallback { reason } } }; Resolved { framing, source } } /// Translation around the neck is separate from the image's vertical framing. /// Keeping the orthographic offset constant makes the orbit pivot pixel stable /// for every yaw/pitch without cropping the feet merely by raising the pivot. pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) { let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.; let center = framing.center_y_milli as f32 / 1000.; let target = [0., pivot, 0.]; let d = preview::eye_offset(yaw, pitch, 10.); let eye = [d[0], pivot + d[1], d[2]]; let half = framing.height_milli as f32 / 2000.; let mut p = preview::identity(); p[0] = 1. / (half * aspect); p[5] = 1. / half; p[13] = (pivot - center) / half; p[10] = -2. / (crate::production::FAR - crate::production::NEAR); p[14] = -(crate::production::FAR + crate::production::NEAR) / (crate::production::FAR - crate::production::NEAR); (preview::view_matrix(eye, target), p) } #[cfg(test)] mod tests { use super::*; use crate::preview::{self, PreviewCharacter, SceneNode}; fn node(name: &str, parent: Option, y: f32) -> SceneNode { SceneNode { name: name.into(), parent, translation: [0., y, 0.], rotation: [0., 0., 0., 1.], scale: [1., 1., 1.], } } fn rig(names: &[&str], parents: &[Option], ys: &[f32]) -> PreviewCharacter { assert_eq!(names.len(), parents.len()); assert_eq!(names.len(), ys.len()); let nodes = names .iter() .zip(parents) .zip(ys) .map(|((name, parent), y)| node(name, *parent, *y)) .collect(); PreviewCharacter { nodes, joint_nodes: (0..names.len()).collect(), joint_names: names.iter().map(|n| (*n).into()).collect(), inverse_binds: vec![preview::identity(); names.len()], positions: Vec::new(), normals: Vec::new(), source_normals_complete: true, joints: Vec::new(), weights: Vec::new(), indices: Vec::new(), base_color: [1., 1., 1., 1.], colors: Vec::new(), uvs: Vec::new(), surfaces: Vec::new(), clips: Vec::new(), } } fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] { let eye = [ view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12], view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13], view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14], ]; [ projection[0] * eye[0] + projection[12], projection[5] * eye[1] + projection[13], projection[10] * eye[2] + projection[14], ] } #[test] fn neck_name_variants_and_missing_ambiguous() { let cases = [ ("Neck", 1.5), ("mixamorigNeck", 1.6), ("Bip001 Neck", 1.7), ("Bip01 Neck", 1.55), ("CC_Base_Neck", 1.45), ("neck2", 1.42), ]; for (name, y) in cases { let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]); let (joint, milli) = neck_height(&body).unwrap(); assert_eq!(body.joint_names[joint], name); assert_eq!(milli, (y * 1000.).round() as i32); } assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err()); let ambiguous = rig( &["Hips", "Neck", "Prop Neck"], &[None, Some(0), None], &[1., 0.5, 2.], ); assert!(neck_height(&ambiguous).is_err()); let chain = rig( &["Hips", "Neck", "Neck1"], &[None, Some(0), Some(1)], &[1., 0.5, 0.1], ); let (joint, _) = neck_height(&chain).unwrap(); assert_eq!(chain.joint_names[joint], "Neck"); } #[test] fn resolve_manual_override_wins_and_fallback_uses_center() { let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]); let auto = resolve( &body, CameraFraming { height_milli: 2600, center_y_milli: 1000, pivot_y_milli: None, }, ); assert!(matches!(auto.source, PivotSource::Neck { .. })); assert_eq!(auto.framing.pivot_y_milli, Some(1500)); let manual = resolve( &body, CameraFraming { height_milli: 2600, center_y_milli: 1000, pivot_y_milli: Some(1800), }, ); assert_eq!(manual.source, PivotSource::Manual); assert_eq!(manual.framing.pivot_y_milli, Some(1800)); let missing = resolve( &rig(&["Hips"], &[None], &[1.0]), CameraFraming { height_milli: 2600, center_y_milli: 900, pivot_y_milli: None, }, ); assert!(matches!(missing.source, PivotSource::Fallback { .. })); assert_eq!(missing.framing.pivot_y_milli, Some(900)); } #[test] fn zero_pitch_keeps_image_center_when_pivot_differs() { let framing = CameraFraming { height_milli: 2600, center_y_milli: 1000, pivot_y_milli: Some(1600), }; for yaw in [0., 90., 180., 270.] { let (view, projection) = matrices(framing, yaw, 0., 1.); let center = project(view, projection, [0., 1., 0.]); assert!( center[0].abs() < 1e-4 && center[1].abs() < 1e-4, "yaw {yaw}: center NDC {center:?}" ); let feet = project(view, projection, [0., 0., 0.]); assert!( (feet[1] + 1000. / 1300.).abs() < 1e-4, "yaw {yaw}: feet NDC y {}", feet[1] ); } } #[test] fn all_36_angles_share_pivot_and_neck_projects_stably() { let framing = CameraFraming { height_milli: 3000, center_y_milli: 1100, pivot_y_milli: Some(1500), }; let mut pivot_ndcs = Vec::new(); for pitch in [-30., 0., 30.] { for yaw in (0..12).map(|i| i as f32 * 30.) { let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.); let pivot = project(view, projection, [0., 1.5, 0.]); assert!( pivot[0].abs() < 1e-4, "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}" ); pivot_ndcs.push(pivot[1]); let old = CameraFraming { pivot_y_milli: Some(framing.center_y_milli), ..framing }; let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.); assert_ne!( view, old_view, "eye must move with pivot at yaw {yaw} pitch {pitch}" ); let neck_new = project(view, projection, [0., 1.5, 0.]); let neck_old = project(old_view, old_projection, [0., 1.5, 0.]); if pitch.abs() > 1. { // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ). assert!( (neck_new[1] - neck_old[1]).abs() > 0.02, "pitched neck residual too small at pitch {pitch}: {} vs {}", neck_new[1], neck_old[1] ); assert!( (neck_new[2] - neck_old[2]).abs() > 1e-4 || (view[13] - old_view[13]).abs() > 1e-4, "view translation/depth must change with pivot" ); } else { assert!((neck_new[1] - neck_old[1]).abs() < 1e-4); } } } assert_eq!(pivot_ndcs.len(), 36); let first = pivot_ndcs[0]; assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4)); } #[test] fn male_fixture_neck_is_detected() { let body = preview::load_gltf_file( &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap(); let resolved = resolve(&body, CameraFraming::default()); assert!(matches!( resolved.source, PivotSource::Neck { joint_name: ref name, .. } if name == "Neck" )); assert_eq!(resolved.framing.pivot_y_milli, Some(1460)); assert_ne!( resolved.framing.pivot_y_milli.unwrap(), resolved.framing.center_y_milli ); } }