| 1 | //! Fixed render orbit pivot is independent of the orthographic image center. |
| 2 | use crate::{ |
| 3 | preview::{self, Mat4, PreviewCharacter}, |
| 4 | project::CameraFraming, |
| 5 | }; |
| 6 | use serde::Serialize; |
| 7 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] |
| 8 | #[serde(tag = "kind", rename_all = "snake_case")] |
| 9 | pub enum PivotSource { |
| 10 | Manual, |
| 11 | Neck { |
| 12 | joint_index: usize, |
| 13 | joint_name: String, |
| 14 | }, |
| 15 | Fallback { |
| 16 | reason: String, |
| 17 | }, |
| 18 | } |
| 19 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 20 | pub struct Resolved { |
| 21 | pub framing: CameraFraming, |
| 22 | pub source: PivotSource, |
| 23 | } |
| 24 | impl Resolved { |
| 25 | pub fn description(&self) -> String { |
| 26 | let y = self |
| 27 | .framing |
| 28 | .pivot_y_milli |
| 29 | .unwrap_or(self.framing.center_y_milli) as f32 |
| 30 | / 1000.; |
| 31 | match &self.source { |
| 32 | PivotSource::Manual => format!("Vaste pivot Y {y:.3} m"), |
| 33 | PivotSource::Neck { joint_name, .. } => { |
| 34 | format!("Nekpivot {joint_name}: Y {y:.3} m (rustpose)") |
| 35 | } |
| 36 | PivotSource::Fallback { reason } => { |
| 37 | format!("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in") |
| 38 | } |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | fn neck_name(name: &str) -> bool { |
| 43 | let lower = name.to_ascii_lowercase(); |
| 44 | let is_neck = |token: &str| { |
| 45 | token |
| 46 | .strip_prefix("neck") |
| 47 | .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit())) |
| 48 | }; |
| 49 | lower |
| 50 | .split(|c: char| !c.is_ascii_alphanumeric()) |
| 51 | .any(is_neck) |
| 52 | || ["mixamorig", "bip001", "bip01", "ccbase"] |
| 53 | .iter() |
| 54 | .any(|prefix| lower.strip_prefix(prefix).is_some_and(is_neck)) |
| 55 | } |
| 56 | fn ancestor(body: &PreviewCharacter, parent: usize, child: usize) -> bool { |
| 57 | let mut current = Some(child); |
| 58 | for _ in 0..body.nodes.len() { |
| 59 | let Some(node) = current else { return false }; |
| 60 | if node == parent { |
| 61 | return true; |
| 62 | } |
| 63 | current = body.nodes.get(node).and_then(|n| n.parent); |
| 64 | } |
| 65 | false |
| 66 | } |
| 67 | /// Select the base of one neck chain, not a similarly named unrelated skeleton. |
| 68 | pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> { |
| 69 | let candidates = body |
| 70 | .joint_names |
| 71 | .iter() |
| 72 | .enumerate() |
| 73 | .filter(|(_, n)| neck_name(n)) |
| 74 | .map(|(j, _)| j) |
| 75 | .collect::<Vec<_>>(); |
| 76 | if candidates.is_empty() { |
| 77 | return Err("Geen nekbot gevonden".into()); |
| 78 | } |
| 79 | let roots = candidates |
| 80 | .iter() |
| 81 | .copied() |
| 82 | .filter(|j| { |
| 83 | candidates.iter().all(|other| { |
| 84 | match (body.joint_nodes.get(*j), body.joint_nodes.get(*other)) { |
| 85 | (Some(a), Some(b)) => ancestor(body, *a, *b), |
| 86 | _ => false, |
| 87 | } |
| 88 | }) |
| 89 | }) |
| 90 | .collect::<Vec<_>>(); |
| 91 | if roots.len() != 1 { |
| 92 | return Err("Meerdere losstaande/ongeldige nekketens".into()); |
| 93 | } |
| 94 | let joint = roots[0]; |
| 95 | let world = preview::rest_joint_world(body, joint).ok_or("Ongeldige rustpose van nekbot")?; |
| 96 | let height = (world[13] * 1000.).round(); |
| 97 | if !(-10000.0..=10000.0).contains(&height) { |
| 98 | return Err("Nekhoogte buiten -10…10 m".into()); |
| 99 | } |
| 100 | Ok((joint, height as i32)) |
| 101 | } |
| 102 | pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved { |
| 103 | if framing.pivot_y_milli.is_some() { |
| 104 | return Resolved { |
| 105 | framing, |
| 106 | source: PivotSource::Manual, |
| 107 | }; |
| 108 | } |
| 109 | let source = match neck_height(body) { |
| 110 | Ok((joint, y)) => { |
| 111 | framing.pivot_y_milli = Some(y); |
| 112 | PivotSource::Neck { |
| 113 | joint_index: joint, |
| 114 | joint_name: body.joint_names[joint].clone(), |
| 115 | } |
| 116 | } |
| 117 | Err(reason) => { |
| 118 | framing.pivot_y_milli = Some(framing.center_y_milli); |
| 119 | PivotSource::Fallback { reason } |
| 120 | } |
| 121 | }; |
| 122 | Resolved { framing, source } |
| 123 | } |
| 124 | /// Translation around the neck is separate from the image's vertical framing. |
| 125 | /// Keeping the orthographic offset constant makes the orbit pivot pixel stable |
| 126 | /// for every yaw/pitch without cropping the feet merely by raising the pivot. |
| 127 | pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) { |
| 128 | let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.; |
| 129 | let center = framing.center_y_milli as f32 / 1000.; |
| 130 | let target = [0., pivot, 0.]; |
| 131 | let d = preview::eye_offset(yaw, pitch, 10.); |
| 132 | let eye = [d[0], pivot + d[1], d[2]]; |
| 133 | let half = framing.height_milli as f32 / 2000.; |
| 134 | let mut p = preview::identity(); |
| 135 | p[0] = 1. / (half * aspect); |
| 136 | p[5] = 1. / half; |
| 137 | p[13] = (pivot - center) / half; |
| 138 | p[10] = -2. / (crate::production::FAR - crate::production::NEAR); |
| 139 | p[14] = -(crate::production::FAR + crate::production::NEAR) |
| 140 | / (crate::production::FAR - crate::production::NEAR); |
| 141 | (preview::view_matrix(eye, target), p) |
| 142 | } |
| 143 | |
| 144 | #[cfg(test)] |
| 145 | mod tests { |
| 146 | use super::*; |
| 147 | use crate::preview::{self, PreviewCharacter, SceneNode}; |
| 148 | |
| 149 | fn node(name: &str, parent: Option<usize>, y: f32) -> SceneNode { |
| 150 | SceneNode { |
| 151 | name: name.into(), |
| 152 | parent, |
| 153 | translation: [0., y, 0.], |
| 154 | rotation: [0., 0., 0., 1.], |
| 155 | scale: [1., 1., 1.], |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | fn rig(names: &[&str], parents: &[Option<usize>], ys: &[f32]) -> PreviewCharacter { |
| 160 | assert_eq!(names.len(), parents.len()); |
| 161 | assert_eq!(names.len(), ys.len()); |
| 162 | let nodes = names |
| 163 | .iter() |
| 164 | .zip(parents) |
| 165 | .zip(ys) |
| 166 | .map(|((name, parent), y)| node(name, *parent, *y)) |
| 167 | .collect(); |
| 168 | PreviewCharacter { |
| 169 | nodes, |
| 170 | joint_nodes: (0..names.len()).collect(), |
| 171 | joint_names: names.iter().map(|n| (*n).into()).collect(), |
| 172 | inverse_binds: vec![preview::identity(); names.len()], |
| 173 | positions: Vec::new(), |
| 174 | normals: Vec::new(), |
| 175 | source_normals_complete: true, |
| 176 | joints: Vec::new(), |
| 177 | weights: Vec::new(), |
| 178 | indices: Vec::new(), |
| 179 | base_color: [1., 1., 1., 1.], |
| 180 | colors: Vec::new(), |
| 181 | uvs: Vec::new(), |
| 182 | surfaces: Vec::new(), |
| 183 | clips: Vec::new(), |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] { |
| 188 | let eye = [ |
| 189 | view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12], |
| 190 | view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13], |
| 191 | view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14], |
| 192 | ]; |
| 193 | [ |
| 194 | projection[0] * eye[0] + projection[12], |
| 195 | projection[5] * eye[1] + projection[13], |
| 196 | projection[10] * eye[2] + projection[14], |
| 197 | ] |
| 198 | } |
| 199 | |
| 200 | #[test] |
| 201 | fn neck_name_variants_and_missing_ambiguous() { |
| 202 | let cases = [ |
| 203 | ("Neck", 1.5), |
| 204 | ("mixamorigNeck", 1.6), |
| 205 | ("Bip001 Neck", 1.7), |
| 206 | ("Bip01 Neck", 1.55), |
| 207 | ("CC_Base_Neck", 1.45), |
| 208 | ("neck2", 1.42), |
| 209 | ]; |
| 210 | for (name, y) in cases { |
| 211 | let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]); |
| 212 | let (joint, milli) = neck_height(&body).unwrap(); |
| 213 | assert_eq!(body.joint_names[joint], name); |
| 214 | assert_eq!(milli, (y * 1000.).round() as i32); |
| 215 | } |
| 216 | assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err()); |
| 217 | let ambiguous = rig( |
| 218 | &["Hips", "Neck", "Prop Neck"], |
| 219 | &[None, Some(0), None], |
| 220 | &[1., 0.5, 2.], |
| 221 | ); |
| 222 | assert!(neck_height(&ambiguous).is_err()); |
| 223 | let chain = rig( |
| 224 | &["Hips", "Neck", "Neck1"], |
| 225 | &[None, Some(0), Some(1)], |
| 226 | &[1., 0.5, 0.1], |
| 227 | ); |
| 228 | let (joint, _) = neck_height(&chain).unwrap(); |
| 229 | assert_eq!(chain.joint_names[joint], "Neck"); |
| 230 | } |
| 231 | |
| 232 | #[test] |
| 233 | fn resolve_manual_override_wins_and_fallback_uses_center() { |
| 234 | let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]); |
| 235 | let auto = resolve( |
| 236 | &body, |
| 237 | CameraFraming { |
| 238 | height_milli: 2600, |
| 239 | center_y_milli: 1000, |
| 240 | pivot_y_milli: None, |
| 241 | }, |
| 242 | ); |
| 243 | assert!(matches!(auto.source, PivotSource::Neck { .. })); |
| 244 | assert_eq!(auto.framing.pivot_y_milli, Some(1500)); |
| 245 | let manual = resolve( |
| 246 | &body, |
| 247 | CameraFraming { |
| 248 | height_milli: 2600, |
| 249 | center_y_milli: 1000, |
| 250 | pivot_y_milli: Some(1800), |
| 251 | }, |
| 252 | ); |
| 253 | assert_eq!(manual.source, PivotSource::Manual); |
| 254 | assert_eq!(manual.framing.pivot_y_milli, Some(1800)); |
| 255 | let missing = resolve( |
| 256 | &rig(&["Hips"], &[None], &[1.0]), |
| 257 | CameraFraming { |
| 258 | height_milli: 2600, |
| 259 | center_y_milli: 900, |
| 260 | pivot_y_milli: None, |
| 261 | }, |
| 262 | ); |
| 263 | assert!(matches!(missing.source, PivotSource::Fallback { .. })); |
| 264 | assert_eq!(missing.framing.pivot_y_milli, Some(900)); |
| 265 | } |
| 266 | |
| 267 | #[test] |
| 268 | fn zero_pitch_keeps_image_center_when_pivot_differs() { |
| 269 | let framing = CameraFraming { |
| 270 | height_milli: 2600, |
| 271 | center_y_milli: 1000, |
| 272 | pivot_y_milli: Some(1600), |
| 273 | }; |
| 274 | for yaw in [0., 90., 180., 270.] { |
| 275 | let (view, projection) = matrices(framing, yaw, 0., 1.); |
| 276 | let center = project(view, projection, [0., 1., 0.]); |
| 277 | assert!( |
| 278 | center[0].abs() < 1e-4 && center[1].abs() < 1e-4, |
| 279 | "yaw {yaw}: center NDC {center:?}" |
| 280 | ); |
| 281 | let feet = project(view, projection, [0., 0., 0.]); |
| 282 | assert!( |
| 283 | (feet[1] + 1000. / 1300.).abs() < 1e-4, |
| 284 | "yaw {yaw}: feet NDC y {}", |
| 285 | feet[1] |
| 286 | ); |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn all_36_angles_share_pivot_and_neck_projects_stably() { |
| 292 | let framing = CameraFraming { |
| 293 | height_milli: 3000, |
| 294 | center_y_milli: 1100, |
| 295 | pivot_y_milli: Some(1500), |
| 296 | }; |
| 297 | let mut pivot_ndcs = Vec::new(); |
| 298 | for pitch in [-30., 0., 30.] { |
| 299 | for yaw in (0..12).map(|i| i as f32 * 30.) { |
| 300 | let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.); |
| 301 | let pivot = project(view, projection, [0., 1.5, 0.]); |
| 302 | assert!( |
| 303 | pivot[0].abs() < 1e-4, |
| 304 | "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}" |
| 305 | ); |
| 306 | pivot_ndcs.push(pivot[1]); |
| 307 | let old = CameraFraming { |
| 308 | pivot_y_milli: Some(framing.center_y_milli), |
| 309 | ..framing |
| 310 | }; |
| 311 | let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.); |
| 312 | assert_ne!( |
| 313 | view, old_view, |
| 314 | "eye must move with pivot at yaw {yaw} pitch {pitch}" |
| 315 | ); |
| 316 | let neck_new = project(view, projection, [0., 1.5, 0.]); |
| 317 | let neck_old = project(old_view, old_projection, [0., 1.5, 0.]); |
| 318 | if pitch.abs() > 1. { |
| 319 | // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ). |
| 320 | assert!( |
| 321 | (neck_new[1] - neck_old[1]).abs() > 0.02, |
| 322 | "pitched neck residual too small at pitch {pitch}: {} vs {}", |
| 323 | neck_new[1], |
| 324 | neck_old[1] |
| 325 | ); |
| 326 | assert!( |
| 327 | (neck_new[2] - neck_old[2]).abs() > 1e-4 |
| 328 | || (view[13] - old_view[13]).abs() > 1e-4, |
| 329 | "view translation/depth must change with pivot" |
| 330 | ); |
| 331 | } else { |
| 332 | assert!((neck_new[1] - neck_old[1]).abs() < 1e-4); |
| 333 | } |
| 334 | } |
| 335 | } |
| 336 | assert_eq!(pivot_ndcs.len(), 36); |
| 337 | let first = pivot_ndcs[0]; |
| 338 | assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4)); |
| 339 | } |
| 340 | |
| 341 | #[test] |
| 342 | fn male_fixture_neck_is_detected() { |
| 343 | let body = preview::load_gltf_file( |
| 344 | &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 345 | .join("tests/fixtures/bodies/test-body-male.glb"), |
| 346 | ) |
| 347 | .unwrap(); |
| 348 | let resolved = resolve(&body, CameraFraming::default()); |
| 349 | assert!(matches!( |
| 350 | resolved.source, |
| 351 | PivotSource::Neck { |
| 352 | joint_name: ref name, |
| 353 | .. |
| 354 | } if name == "Neck" |
| 355 | )); |
| 356 | assert_eq!(resolved.framing.pivot_y_milli, Some(1460)); |
| 357 | assert_ne!( |
| 358 | resolved.framing.pivot_y_milli.unwrap(), |
| 359 | resolved.framing.center_y_milli |
| 360 | ); |
| 361 | } |
| 362 | } |