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