| 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.depth_range_milli.is_none() { |
| 109 | framing.depth_range_milli = Some(auto_depth_range_milli(body)); |
| 110 | } |
| 111 | if framing.pivot_y_milli.is_some() { |
| 112 | return Resolved { |
| 113 | framing, |
| 114 | source: PivotSource::Manual, |
| 115 | }; |
| 116 | } |
| 117 | let source = match neck_height(body) { |
| 118 | Ok((joint, y)) => { |
| 119 | framing.pivot_y_milli = Some(y); |
| 120 | PivotSource::Neck { |
| 121 | joint_index: joint, |
| 122 | joint_name: body.joint_names[joint].clone(), |
| 123 | } |
| 124 | } |
| 125 | Err(reason) => { |
| 126 | framing.pivot_y_milli = Some(framing.center_y_milli); |
| 127 | PivotSource::Fallback { reason } |
| 128 | } |
| 129 | }; |
| 130 | Resolved { framing, source } |
| 131 | } |
| 132 | /// Translation around the neck is separate from the image's vertical framing. |
| 133 | /// Keeping the orthographic offset constant makes the orbit pivot pixel stable |
| 134 | /// for every yaw/pitch without cropping the feet merely by raising the pivot. |
| 135 | /// Orthographic camera distance from the orbit pivot (metres). |
| 136 | pub const CAMERA_DISTANCE: f32 = 10.; |
| 137 | /// Minimum half-depth of the per-sex depth interval (metres). |
| 138 | pub const MIN_DEPTH_HALF: f32 = 0.5; |
| 139 | /// How 16-bit depth samples map back to camera distance (t963u/t964u). |
| 140 | pub const DEPTH_ENCODING: &str = "linear: distance = depth_near + (sample/65535)*(depth_far-depth_near); 16-bit gray+alpha; clamped to [near,far]"; |
| 141 | /// Fixed, narrow depth interval around the orbit pivot derived only from the |
| 142 | /// per-sex framing: `CAMERA_DISTANCE ∓ R`, R = max(height × 0.5 + |pivot − |
| 143 | /// center|, 0.5 m) — the radius around the pivot that reaches the frame's |
| 144 | /// farthest edge, so geometry inside the pitch-0 frame stays in range at |
| 145 | /// pitch ±60 too. Shared by every clip, frame, view and equipment of that sex. |
| 146 | pub fn depth_range(framing: CameraFraming) -> (f32, f32) { |
| 147 | if let Some(total) = framing.depth_range_milli { |
| 148 | let r = total as f32 / 2000.; |
| 149 | return (CAMERA_DISTANCE - r, CAMERA_DISTANCE + r); |
| 150 | } |
| 151 | let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli); |
| 152 | let offset = (i64::from(pivot) - i64::from(framing.center_y_milli)).unsigned_abs(); |
| 153 | let r = ((u64::from(framing.height_milli) / 2 + offset) as f32 / 1000.).max(MIN_DEPTH_HALF); |
| 154 | (CAMERA_DISTANCE - r, CAMERA_DISTANCE + r) |
| 155 | } |
| 156 | pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) { |
| 157 | let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.; |
| 158 | let center = framing.center_y_milli as f32 / 1000.; |
| 159 | let target = [0., pivot, 0.]; |
| 160 | let d = preview::eye_offset(yaw, pitch, CAMERA_DISTANCE); |
| 161 | let (near, far) = depth_range(framing); |
| 162 | let eye = [d[0], pivot + d[1], d[2]]; |
| 163 | let half = framing.height_milli as f32 / 2000.; |
| 164 | let mut p = preview::identity(); |
| 165 | p[0] = 1. / (half * aspect); |
| 166 | p[5] = 1. / half; |
| 167 | p[13] = (pivot - center) / half; |
| 168 | p[10] = -2. / (far - near); |
| 169 | p[14] = -(far + near) / (far - near); |
| 170 | (preview::view_matrix(eye, target), p) |
| 171 | } |
| 172 | |
| 173 | /// Labelled orientation convention (§6/§13), verified by |
| 174 | /// `orientation_convention_is_labelled`: the character faces −Z, +Y is up, |
| 175 | /// its right hand side is +X. Camera eye = `eye_offset(yaw, pitch)`. |
| 176 | pub const ORIENTATION_VIEWS: [(f32, &str); 4] = [ |
| 177 | (0., "voorkant"), |
| 178 | (90., "linkerzijde"), |
| 179 | (180., "achterkant"), |
| 180 | (270., "rechterzijde"), |
| 181 | ]; |
| 182 | |
| 183 | /// Fixed framing that contains every frame of every clip from every one of |
| 184 | /// the 60 production views (one envelope per body, never per frame/layer). |
| 185 | /// `margin` is a fraction of the envelope added on every side, `extra_m` an |
| 186 | /// absolute allowance (e.g. for equipment). Pivot stays as resolved; only |
| 187 | /// height and center Y are returned (milli). None when there is no geometry. |
| 188 | pub fn animation_envelope( |
| 189 | body: &PreviewCharacter, |
| 190 | framing: CameraFraming, |
| 191 | aspect: f32, |
| 192 | fps: u32, |
| 193 | margin: f32, |
| 194 | extra_m: f32, |
| 195 | ) -> Option<(u32, i32)> { |
| 196 | let framing = resolve(body, framing).framing; |
| 197 | let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.; |
| 198 | let mut poses: Vec<Vec<[f32; 3]>> = Vec::new(); |
| 199 | if body.is_static() || body.clips.is_empty() { |
| 200 | poses.push(body.positions.clone()); |
| 201 | } else { |
| 202 | let step = 1. / fps.max(1) as f32; |
| 203 | for clip in &body.clips { |
| 204 | let frames = ((clip.duration / step).ceil() as usize).max(1); |
| 205 | for frame in 0..frames { |
| 206 | let bones = preview::sample_skin_matrices(body, &clip.name, frame as f32 * step); |
| 207 | poses.push(skin_points(body, &bones)); |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | let (mut lo, mut hi, mut side) = (f32::INFINITY, f32::NEG_INFINITY, 0f32); |
| 212 | for pitch in preview::PAPERDOLL_PITCHES_DEG { |
| 213 | for yaw in preview::paperdoll_yaws_deg() { |
| 214 | let (view, _) = matrices(framing, yaw, pitch, aspect); |
| 215 | for pose in &poses { |
| 216 | for p in pose { |
| 217 | let x = view[0] * p[0] + view[4] * p[1] + view[8] * p[2] + view[12]; |
| 218 | let y = view[1] * p[0] + view[5] * p[1] + view[9] * p[2] + view[13]; |
| 219 | if x.is_finite() && y.is_finite() { |
| 220 | lo = lo.min(y); |
| 221 | hi = hi.max(y); |
| 222 | side = side.max(x.abs()); |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | if !lo.is_finite() || !hi.is_finite() { |
| 229 | return None; |
| 230 | } |
| 231 | let height = (hi - lo).max(2. * side / aspect.max(1e-6)); |
| 232 | let height = height * (1. + 2. * margin.max(0.)) + 2. * extra_m.max(0.); |
| 233 | let center = pivot + (hi + lo) / 2.; |
| 234 | Some(( |
| 235 | (height * 1000.).ceil() as u32, |
| 236 | (center * 1000.).round() as i32, |
| 237 | )) |
| 238 | } |
| 239 | |
| 240 | fn skin_points(body: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> { |
| 241 | body.positions |
| 242 | .iter() |
| 243 | .enumerate() |
| 244 | .map(|(i, p)| { |
| 245 | let w = body.weights.get(i).copied().unwrap_or([1., 0., 0., 0.]); |
| 246 | let j = body.joints.get(i).copied().unwrap_or([0; 4]); |
| 247 | let mut out = [0.; 3]; |
| 248 | let mut sum = 0.; |
| 249 | for k in 0..4 { |
| 250 | if w[k] == 0. { |
| 251 | continue; |
| 252 | } |
| 253 | let m = bones |
| 254 | .get(j[k] as usize) |
| 255 | .copied() |
| 256 | .unwrap_or_else(preview::identity); |
| 257 | for a in 0..3 { |
| 258 | out[a] += w[k] * (m[a] * p[0] + m[4 + a] * p[1] + m[8 + a] * p[2] + m[12 + a]); |
| 259 | } |
| 260 | sum += w[k]; |
| 261 | } |
| 262 | if sum > 0. { |
| 263 | out |
| 264 | } else { |
| 265 | *p |
| 266 | } |
| 267 | }) |
| 268 | .collect() |
| 269 | } |
| 270 | |
| 271 | /// How depth samples are stored in the depth PNGs (t980u/t981u/t983u/t987u). |
| 272 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, serde::Deserialize)] |
| 273 | #[serde(rename_all = "snake_case")] |
| 274 | pub enum DepthEncoding { |
| 275 | #[default] |
| 276 | Gray, |
| 277 | RgbBands, |
| 278 | RgbPacked, |
| 279 | } |
| 280 | impl DepthEncoding { |
| 281 | pub const ALL: [DepthEncoding; 3] = [Self::Gray, Self::RgbBands, Self::RgbPacked]; |
| 282 | pub fn token(self) -> &'static str { |
| 283 | match self { |
| 284 | Self::Gray => "gray", |
| 285 | Self::RgbBands => "rgb_bands", |
| 286 | Self::RgbPacked => "rgb_packed", |
| 287 | } |
| 288 | } |
| 289 | pub fn parse(token: &str) -> Option<Self> { |
| 290 | Self::ALL.into_iter().find(|e| e.token() == token) |
| 291 | } |
| 292 | pub fn label(self) -> &'static str { |
| 293 | match self { |
| 294 | Self::Gray => "Grijs", |
| 295 | Self::RgbBands => "RGB-banden", |
| 296 | Self::RgbPacked => "RGB-gepakt (nearest, lossless)", |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | /// Depth PNG format: encoding × bit depth (8 or 16). |
| 302 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)] |
| 303 | pub struct DepthFormat { |
| 304 | pub bits: u8, |
| 305 | pub encoding: DepthEncoding, |
| 306 | } |
| 307 | impl Default for DepthFormat { |
| 308 | fn default() -> Self { |
| 309 | Self { |
| 310 | bits: 8, |
| 311 | encoding: DepthEncoding::Gray, |
| 312 | } |
| 313 | } |
| 314 | } |
| 315 | impl DepthFormat { |
| 316 | /// Format of every export before t983u (and of manifests without depth_bits). |
| 317 | pub const GRAY16: DepthFormat = DepthFormat { |
| 318 | bits: 16, |
| 319 | encoding: DepthEncoding::Gray, |
| 320 | }; |
| 321 | /// Format recorded in a manifest: `depth_bits` + `depth_encoding` token, or |
| 322 | /// the legacy schema (no `depth_bits`, long legacy text) = 16-bit gray. |
| 323 | pub fn from_manifest(bits: Option<u64>, encoding: &str) -> Result<Self, String> { |
| 324 | match bits { |
| 325 | None if encoding == DEPTH_ENCODING => Ok(Self::GRAY16), |
| 326 | None => Err("depth_encoding onbekend".into()), |
| 327 | Some(b) => Self::new( |
| 328 | u8::try_from(b).map_err(|_| "depth_bits moet 8 of 16 zijn")?, |
| 329 | DepthEncoding::parse(encoding).ok_or("depth_encoding onbekend")?, |
| 330 | ), |
| 331 | } |
| 332 | } |
| 333 | /// Format recorded in a render.json document. |
| 334 | pub fn from_doc(doc: &serde_json::Value) -> Result<Self, String> { |
| 335 | Self::from_manifest( |
| 336 | doc.get("depth_bits").and_then(serde_json::Value::as_u64), |
| 337 | doc.get("depth_encoding") |
| 338 | .and_then(serde_json::Value::as_str) |
| 339 | .unwrap_or(""), |
| 340 | ) |
| 341 | } |
| 342 | pub fn new(bits: u8, encoding: DepthEncoding) -> Result<Self, String> { |
| 343 | if bits != 8 && bits != 16 { |
| 344 | return Err("depth_bits moet 8 of 16 zijn".into()); |
| 345 | } |
| 346 | Ok(Self { bits, encoding }) |
| 347 | } |
| 348 | pub fn max(self) -> u64 { |
| 349 | (1u64 << self.bits) - 1 |
| 350 | } |
| 351 | /// Channels per pixel (value channels + alpha). |
| 352 | pub fn channels(self) -> usize { |
| 353 | match self.encoding { |
| 354 | DepthEncoding::Gray => 2, |
| 355 | _ => 4, |
| 356 | } |
| 357 | } |
| 358 | pub fn bytes_per_pixel(self) -> usize { |
| 359 | self.channels() * usize::from(self.bits / 8) |
| 360 | } |
| 361 | pub fn color_type(self) -> png::ColorType { |
| 362 | match self.encoding { |
| 363 | DepthEncoding::Gray => png::ColorType::GrayscaleAlpha, |
| 364 | _ => png::ColorType::Rgba, |
| 365 | } |
| 366 | } |
| 367 | pub fn bit_depth(self) -> png::BitDepth { |
| 368 | if self.bits == 8 { |
| 369 | png::BitDepth::Eight |
| 370 | } else { |
| 371 | png::BitDepth::Sixteen |
| 372 | } |
| 373 | } |
| 374 | /// Number of distinct depth levels minus one. |
| 375 | pub fn levels(self) -> u64 { |
| 376 | let m = self.max(); |
| 377 | match self.encoding { |
| 378 | DepthEncoding::Gray => m, |
| 379 | DepthEncoding::RgbBands => 3 * m, |
| 380 | DepthEncoding::RgbPacked => (1u64 << (3 * self.bits as u32)) - 1, |
| 381 | } |
| 382 | } |
| 383 | /// Human-readable formula written to render.json. |
| 384 | pub fn description(self) -> String { |
| 385 | let fmt = format!("{}-bit", self.bits); |
| 386 | let head = match self.encoding { |
| 387 | DepthEncoding::Gray => format!("{fmt} gray+alpha; t=gray/{}", self.max()), |
| 388 | DepthEncoding::RgbBands => format!( |
| 389 | "{fmt} RGBA bands; v=round(t*{}), R=min(v,M), G=clamp(v-M,0,M), B=clamp(v-2M,0,M), M={}; t=(R+G+B)/{}", |
| 390 | self.levels(), self.max(), self.levels() |
| 391 | ), |
| 392 | DepthEncoding::RgbPacked => format!( |
| 393 | "{fmt} RGBA packed base {}; v=round(t*{}), R=v/B^2, G=(v/B)%B, B=v%B; nearest sampling, no mipmaps, lossless only", |
| 394 | self.max() + 1, self.levels() |
| 395 | ), |
| 396 | }; |
| 397 | head + "; distance=depth_near+t*(depth_far-depth_near), clamped" |
| 398 | } |
| 399 | /// Channel values (without alpha) for normalized depth `t` in 0..1. |
| 400 | pub fn encode_value(self, t: f64) -> [u64; 3] { |
| 401 | let t = if t.is_finite() { t.clamp(0., 1.) } else { 0. }; |
| 402 | let m = self.max(); |
| 403 | let v = (t * self.levels() as f64).round() as u64; |
| 404 | match self.encoding { |
| 405 | DepthEncoding::Gray => [v, 0, 0], |
| 406 | DepthEncoding::RgbBands => [ |
| 407 | v.min(m), |
| 408 | v.saturating_sub(m).min(m), |
| 409 | v.saturating_sub(2 * m).min(m), |
| 410 | ], |
| 411 | DepthEncoding::RgbPacked => { |
| 412 | let b = m + 1; |
| 413 | [v / (b * b), (v / b) % b, v % b] |
| 414 | } |
| 415 | } |
| 416 | } |
| 417 | /// Normalized depth from channel values. |
| 418 | pub fn decode_value(self, c: [u64; 3]) -> f64 { |
| 419 | let b = self.max() + 1; |
| 420 | let v = match self.encoding { |
| 421 | DepthEncoding::Gray => c[0], |
| 422 | DepthEncoding::RgbBands => c[0] + c[1] + c[2], |
| 423 | DepthEncoding::RgbPacked => c[0] * b * b + c[1] * b + c[2], |
| 424 | }; |
| 425 | v as f64 / self.levels() as f64 |
| 426 | } |
| 427 | /// Encode one pixel (t, alpha 0..1) as big-endian PNG sample bytes. |
| 428 | pub fn encode_pixel(self, t: f64, alpha: f64, out: &mut Vec<u8>) { |
| 429 | let c = self.encode_value(t); |
| 430 | let a = (alpha.clamp(0., 1.) * self.max() as f64).round() as u64; |
| 431 | let mut push = |x: u64| { |
| 432 | if self.bits == 8 { |
| 433 | out.push(x as u8); |
| 434 | } else { |
| 435 | out.extend_from_slice(&(x as u16).to_be_bytes()); |
| 436 | } |
| 437 | }; |
| 438 | match self.encoding { |
| 439 | DepthEncoding::Gray => { |
| 440 | push(c[0]); |
| 441 | push(a); |
| 442 | } |
| 443 | _ => { |
| 444 | push(c[0]); |
| 445 | push(c[1]); |
| 446 | push(c[2]); |
| 447 | push(a); |
| 448 | } |
| 449 | } |
| 450 | } |
| 451 | /// Decode one pixel of `bytes_per_pixel` bytes into canonical 16-bit |
| 452 | /// (depth, alpha) samples as used by the compositor (gray16 scale). |
| 453 | pub fn decode_pixel(self, px: &[u8]) -> Result<(u16, u16), String> { |
| 454 | let read = |i: usize| -> u64 { |
| 455 | if self.bits == 8 { |
| 456 | u64::from(px[i]) |
| 457 | } else { |
| 458 | u64::from(u16::from_be_bytes([px[2 * i], px[2 * i + 1]])) |
| 459 | } |
| 460 | }; |
| 461 | let (c, a) = match self.encoding { |
| 462 | DepthEncoding::Gray => ([read(0), 0, 0], read(1)), |
| 463 | _ => ([read(0), read(1), read(2)], read(3)), |
| 464 | }; |
| 465 | if self.encoding == DepthEncoding::RgbBands { |
| 466 | let m = self.max(); |
| 467 | // Bands fill in order: G only when R is full, B only when G is full. |
| 468 | if (c[1] > 0 && c[0] != m) || (c[2] > 0 && c[1] != m) { |
| 469 | return Err("depth rgb_bands: ongeldige bandvolgorde".into()); |
| 470 | } |
| 471 | } |
| 472 | let t = self.decode_value(c); |
| 473 | let scale = 65535. / self.max() as f64; |
| 474 | Ok(( |
| 475 | (t * 65535.).round() as u16, |
| 476 | (a as f64 * scale).round() as u16, |
| 477 | )) |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | /// Default per-sex depth range (t985u): T-pose arm span plus one arm length |
| 482 | /// on each side, in millimetres (total). Pose-independent: with identifiable |
| 483 | /// left/right upper-arm and hand joints, arm = |upper arm → hand| × 1.2 (hand |
| 484 | /// and fingers) and span = shoulder width + 2·arm, so A-/I-pose binds give the |
| 485 | /// same result as a T-pose. Otherwise span = max(bind width, bind height) and |
| 486 | /// arm = 0.4·span. |
| 487 | pub fn auto_depth_range_milli(body: &PreviewCharacter) -> u32 { |
| 488 | let (span, arm) = match arm_measure(body) { |
| 489 | Some((shoulders, arm)) => (shoulders + 2. * arm, arm), |
| 490 | None => { |
| 491 | let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]); |
| 492 | for p in &body.positions { |
| 493 | for i in 0..2 { |
| 494 | if p[i].is_finite() { |
| 495 | lo[i] = lo[i].min(p[i]); |
| 496 | hi[i] = hi[i].max(p[i]); |
| 497 | } |
| 498 | } |
| 499 | } |
| 500 | let span = (hi[0] - lo[0]).max(hi[1] - lo[1]); |
| 501 | let span = if span.is_finite() && span > 0. { |
| 502 | span |
| 503 | } else { |
| 504 | 1.0 |
| 505 | }; |
| 506 | (span, 0.4 * span) |
| 507 | } |
| 508 | }; |
| 509 | (((span + 2. * arm) * 1000.).round() as u32).clamp(MIN_DEPTH_RANGE_MILLI, MAX_DEPTH_RANGE_MILLI) |
| 510 | } |
| 511 | pub const MIN_DEPTH_RANGE_MILLI: u32 = 100; |
| 512 | pub const MAX_DEPTH_RANGE_MILLI: u32 = 19000; |
| 513 | |
| 514 | /// (shoulder width, arm length) from rest-pose joints: left/right upper arm |
| 515 | /// and hand, identified by name. None when either side is missing. |
| 516 | pub fn arm_measure(body: &PreviewCharacter) -> Option<(f32, f32)> { |
| 517 | if body.is_static() { |
| 518 | return None; |
| 519 | } |
| 520 | // (left?, kind) with kind 0 = upper arm, 1 = hand. |
| 521 | let classify = |name: &str| -> Option<(bool, u8)> { |
| 522 | let l = name.to_ascii_lowercase(); |
| 523 | let kind = if l.contains("upperarm") || l.contains("upper_arm") || l.contains("arm_upper") { |
| 524 | 0 |
| 525 | } else if l.contains("hand") && !l.contains("handle") { |
| 526 | 1 |
| 527 | } else { |
| 528 | return None; |
| 529 | }; |
| 530 | let tokens: Vec<&str> = l.split(|c: char| !c.is_ascii_alphanumeric()).collect(); |
| 531 | let left = l.contains("left") || tokens.contains(&"l") || l.starts_with("l_"); |
| 532 | let right = l.contains("right") || tokens.contains(&"r") || l.starts_with("r_"); |
| 533 | match (left, right) { |
| 534 | (true, false) => Some((true, kind)), |
| 535 | (false, true) => Some((false, kind)), |
| 536 | _ => None, |
| 537 | } |
| 538 | }; |
| 539 | let mut found: [[Option<[f32; 3]>; 2]; 2] = [[None; 2]; 2]; |
| 540 | for (j, name) in body.joint_names.iter().enumerate() { |
| 541 | let Some((left, kind)) = classify(name) else { |
| 542 | continue; |
| 543 | }; |
| 544 | let slot = &mut found[usize::from(left)][kind as usize]; |
| 545 | if slot.is_some() { |
| 546 | continue; // first match (closest to the root in joint order) |
| 547 | } |
| 548 | if let Some(w) = preview::rest_joint_world(body, j) { |
| 549 | *slot = Some([w[12], w[13], w[14]]); |
| 550 | } |
| 551 | } |
| 552 | let dist = |a: [f32; 3], b: [f32; 3]| { |
| 553 | ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() |
| 554 | }; |
| 555 | let [[Some(ru), Some(rh)], [Some(lu), Some(lh)]] = found else { |
| 556 | return None; |
| 557 | }; |
| 558 | let arm = (dist(lu, lh) + dist(ru, rh)) / 2. * 1.2; |
| 559 | let shoulders = dist(lu, ru); |
| 560 | (arm.is_finite() && arm > 0. && shoulders.is_finite()).then_some((shoulders, arm)) |
| 561 | } |
| 562 | |
| 563 | #[cfg(test)] |
| 564 | mod tests { |
| 565 | #[test] |
| 566 | fn orientation_convention_is_labelled() { |
| 567 | // Labelled markers on a body facing −Z with its right side on +X. |
| 568 | let markers = [ |
| 569 | ("voorkant", [0., 1., -0.3]), |
| 570 | ("achterkant", [0., 1., 0.3]), |
| 571 | ("linkerzijde", [-0.3, 1., 0.]), |
| 572 | ("rechterzijde", [0.3, 1., 0.]), |
| 573 | ]; |
| 574 | let framing = crate::project::CameraFraming::default(); |
| 575 | for (yaw, label) in ORIENTATION_VIEWS { |
| 576 | let (view, _) = matrices(framing, yaw, 0., 1.); |
| 577 | // View space looks down −Z: the largest z is nearest the camera. |
| 578 | let z = |p: [f32; 3]| view[2] * p[0] + view[6] * p[1] + view[10] * p[2] + view[14]; |
| 579 | let nearest = markers |
| 580 | .iter() |
| 581 | .max_by(|a, b| z(a.1).total_cmp(&z(b.1))) |
| 582 | .unwrap() |
| 583 | .0; |
| 584 | assert_eq!(nearest, label, "yaw {yaw}"); |
| 585 | } |
| 586 | // Yaw 0 (front): the character's right (+X) is on the image's left. |
| 587 | let (view, _) = matrices(framing, 0., 0., 1.); |
| 588 | let x = |p: [f32; 3]| view[0] * p[0] + view[4] * p[1] + view[8] * p[2] + view[12]; |
| 589 | assert!(x([0.3, 1., 0.]) < 0. && x([-0.3, 1., 0.]) > 0.); |
| 590 | // Positive pitch looks down from above: the front marker sits lower. |
| 591 | let (view, _) = matrices(framing, 0., 60., 1.); |
| 592 | let y = |p: [f32; 3]| view[1] * p[0] + view[5] * p[1] + view[9] * p[2] + view[13]; |
| 593 | assert!(y([0., 1., -0.3]) < y([0., 1., 0.3])); |
| 594 | } |
| 595 | |
| 596 | #[test] |
| 597 | fn animation_envelope_contains_every_frame_and_view() { |
| 598 | let body = preview::load_gltf_file( |
| 599 | &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 600 | .join("tests/fixtures/bodies/test-body-male.glb"), |
| 601 | ) |
| 602 | .unwrap(); |
| 603 | let start = crate::project::CameraFraming::default(); |
| 604 | let (h, c) = animation_envelope(&body, start, 0.75, 8, 0., 0.).unwrap(); |
| 605 | let mut framing = resolve(&body, start).framing; |
| 606 | framing.height_milli = h; |
| 607 | framing.center_y_milli = c; |
| 608 | let (m2, c2) = animation_envelope(&body, framing, 0.75, 8, 0.05, 0.1).unwrap(); |
| 609 | assert!(m2 as f32 >= h as f32 * 1.1 + 199.0); |
| 610 | assert!((c2 - c).abs() <= 1); |
| 611 | // Every skinned vertex of every frame lands inside NDC [-1, 1]. |
| 612 | let mut worst = 0f32; |
| 613 | for clip in &body.clips { |
| 614 | for f in 0..((clip.duration * 8.).ceil() as usize).max(1) { |
| 615 | let bones = preview::sample_skin_matrices(&body, &clip.name, f as f32 / 8.); |
| 616 | let pts = skin_points(&body, &bones); |
| 617 | for pitch in preview::PAPERDOLL_PITCHES_DEG { |
| 618 | for yaw in preview::paperdoll_yaws_deg() { |
| 619 | let (v, p) = matrices(framing, yaw, pitch, 0.75); |
| 620 | let vp = preview::mul(p, v); |
| 621 | for q in &pts { |
| 622 | let nx = vp[0] * q[0] + vp[4] * q[1] + vp[8] * q[2] + vp[12]; |
| 623 | let ny = vp[1] * q[0] + vp[5] * q[1] + vp[9] * q[2] + vp[13]; |
| 624 | worst = worst.max(nx.abs()).max(ny.abs()); |
| 625 | } |
| 626 | } |
| 627 | } |
| 628 | } |
| 629 | } |
| 630 | assert!(worst <= 1.001 && worst > 0.95, "worst NDC {worst}"); |
| 631 | } |
| 632 | |
| 633 | use super::*; |
| 634 | use crate::preview::{self, PreviewCharacter, SceneNode}; |
| 635 | |
| 636 | fn node(name: &str, parent: Option<usize>, y: f32) -> SceneNode { |
| 637 | SceneNode { |
| 638 | name: name.into(), |
| 639 | parent, |
| 640 | translation: [0., y, 0.], |
| 641 | rotation: [0., 0., 0., 1.], |
| 642 | scale: [1., 1., 1.], |
| 643 | } |
| 644 | } |
| 645 | |
| 646 | fn rig(names: &[&str], parents: &[Option<usize>], ys: &[f32]) -> PreviewCharacter { |
| 647 | assert_eq!(names.len(), parents.len()); |
| 648 | assert_eq!(names.len(), ys.len()); |
| 649 | let nodes = names |
| 650 | .iter() |
| 651 | .zip(parents) |
| 652 | .zip(ys) |
| 653 | .map(|((name, parent), y)| node(name, *parent, *y)) |
| 654 | .collect(); |
| 655 | PreviewCharacter { |
| 656 | nodes, |
| 657 | joint_nodes: (0..names.len()).collect(), |
| 658 | joint_names: names.iter().map(|n| (*n).into()).collect(), |
| 659 | inverse_binds: vec![preview::identity(); names.len()], |
| 660 | positions: Vec::new(), |
| 661 | normals: Vec::new(), |
| 662 | source_normals_complete: true, |
| 663 | joints: Vec::new(), |
| 664 | weights: Vec::new(), |
| 665 | indices: Vec::new(), |
| 666 | base_color: [1., 1., 1., 1.], |
| 667 | colors: Vec::new(), |
| 668 | uvs: Vec::new(), |
| 669 | surfaces: Vec::new(), |
| 670 | clips: Vec::new(), |
| 671 | rig: preview::BodyRig::Skinned, |
| 672 | } |
| 673 | } |
| 674 | |
| 675 | fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] { |
| 676 | let eye = [ |
| 677 | view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12], |
| 678 | view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13], |
| 679 | view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14], |
| 680 | ]; |
| 681 | [ |
| 682 | projection[0] * eye[0] + projection[12], |
| 683 | projection[5] * eye[1] + projection[13], |
| 684 | projection[10] * eye[2] + projection[14], |
| 685 | ] |
| 686 | } |
| 687 | |
| 688 | #[test] |
| 689 | fn depth_range_is_narrow_fixed_and_maps_pivot_to_middle() { |
| 690 | let f = CameraFraming::default(); |
| 691 | let (near, far) = depth_range(f); |
| 692 | assert!((near - 8.7).abs() < 1e-5 && (far - 11.3).abs() < 1e-5); |
| 693 | let small = CameraFraming { |
| 694 | height_milli: 400, |
| 695 | ..f |
| 696 | }; |
| 697 | assert_eq!(depth_range(small), (9.5, 10.5)); |
| 698 | let offset = CameraFraming { |
| 699 | pivot_y_milli: Some(1460), |
| 700 | ..f |
| 701 | }; |
| 702 | let (near, far) = depth_range(offset); |
| 703 | assert!((near - 8.24).abs() < 1e-5 && (far - 11.76).abs() < 1e-5); |
| 704 | // Pivot projects to NDC z = 0 (gray 32768) for every view. |
| 705 | let f = CameraFraming { |
| 706 | pivot_y_milli: Some(1460), |
| 707 | ..f |
| 708 | }; |
| 709 | for (yaw, pitch) in [(0., 0.), (90., 30.), (210., -60.), (330., 60.)] { |
| 710 | let (view, projection) = matrices(f, yaw, pitch, 1.); |
| 711 | let p = project(view, projection, [0., 1.46, 0.]); |
| 712 | assert!(p[2].abs() < 1e-4, "{p:?}"); |
| 713 | // 0.5 m in front of the pivot uses a large part of the range. |
| 714 | let d = preview::eye_offset(yaw, pitch, 0.5); |
| 715 | let q = project(view, projection, [d[0], 1.46 + d[1], d[2]]); |
| 716 | assert!((q[2] + 0.5 / 1.76).abs() < 1e-3, "{q:?}"); |
| 717 | } |
| 718 | } |
| 719 | |
| 720 | #[test] |
| 721 | fn neck_name_variants_and_missing_ambiguous() { |
| 722 | let cases = [ |
| 723 | ("Neck", 1.5), |
| 724 | ("mixamorigNeck", 1.6), |
| 725 | ("Bip001 Neck", 1.7), |
| 726 | ("Bip01 Neck", 1.55), |
| 727 | ("CC_Base_Neck", 1.45), |
| 728 | ("neck2", 1.42), |
| 729 | ]; |
| 730 | for (name, y) in cases { |
| 731 | let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]); |
| 732 | let (joint, milli) = neck_height(&body).unwrap(); |
| 733 | assert_eq!(body.joint_names[joint], name); |
| 734 | assert_eq!(milli, (y * 1000.).round() as i32); |
| 735 | } |
| 736 | assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err()); |
| 737 | let ambiguous = rig( |
| 738 | &["Hips", "Neck", "Prop Neck"], |
| 739 | &[None, Some(0), None], |
| 740 | &[1., 0.5, 2.], |
| 741 | ); |
| 742 | assert!(neck_height(&ambiguous).is_err()); |
| 743 | let chain = rig( |
| 744 | &["Hips", "Neck", "Neck1"], |
| 745 | &[None, Some(0), Some(1)], |
| 746 | &[1., 0.5, 0.1], |
| 747 | ); |
| 748 | let (joint, _) = neck_height(&chain).unwrap(); |
| 749 | assert_eq!(chain.joint_names[joint], "Neck"); |
| 750 | } |
| 751 | |
| 752 | #[test] |
| 753 | fn resolve_manual_override_wins_and_fallback_uses_center() { |
| 754 | let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]); |
| 755 | let auto = resolve( |
| 756 | &body, |
| 757 | CameraFraming { |
| 758 | height_milli: 2600, |
| 759 | center_y_milli: 1000, |
| 760 | pivot_y_milli: None, |
| 761 | depth_range_milli: None, |
| 762 | }, |
| 763 | ); |
| 764 | assert!(matches!(auto.source, PivotSource::Neck { .. })); |
| 765 | assert_eq!(auto.framing.pivot_y_milli, Some(1500)); |
| 766 | let manual = resolve( |
| 767 | &body, |
| 768 | CameraFraming { |
| 769 | height_milli: 2600, |
| 770 | center_y_milli: 1000, |
| 771 | pivot_y_milli: Some(1800), |
| 772 | depth_range_milli: None, |
| 773 | }, |
| 774 | ); |
| 775 | assert_eq!(manual.source, PivotSource::Manual); |
| 776 | assert_eq!(manual.framing.pivot_y_milli, Some(1800)); |
| 777 | let missing = resolve( |
| 778 | &rig(&["Hips"], &[None], &[1.0]), |
| 779 | CameraFraming { |
| 780 | height_milli: 2600, |
| 781 | center_y_milli: 900, |
| 782 | pivot_y_milli: None, |
| 783 | depth_range_milli: None, |
| 784 | }, |
| 785 | ); |
| 786 | assert!(matches!(missing.source, PivotSource::Fallback { .. })); |
| 787 | assert_eq!(missing.framing.pivot_y_milli, Some(900)); |
| 788 | } |
| 789 | |
| 790 | #[test] |
| 791 | fn zero_pitch_keeps_image_center_when_pivot_differs() { |
| 792 | let framing = CameraFraming { |
| 793 | height_milli: 2600, |
| 794 | center_y_milli: 1000, |
| 795 | pivot_y_milli: Some(1600), |
| 796 | depth_range_milli: None, |
| 797 | }; |
| 798 | for yaw in [0., 90., 180., 270.] { |
| 799 | let (view, projection) = matrices(framing, yaw, 0., 1.); |
| 800 | let center = project(view, projection, [0., 1., 0.]); |
| 801 | assert!( |
| 802 | center[0].abs() < 1e-4 && center[1].abs() < 1e-4, |
| 803 | "yaw {yaw}: center NDC {center:?}" |
| 804 | ); |
| 805 | let feet = project(view, projection, [0., 0., 0.]); |
| 806 | assert!( |
| 807 | (feet[1] + 1000. / 1300.).abs() < 1e-4, |
| 808 | "yaw {yaw}: feet NDC y {}", |
| 809 | feet[1] |
| 810 | ); |
| 811 | } |
| 812 | } |
| 813 | |
| 814 | #[test] |
| 815 | fn all_60_angles_share_pivot_and_neck_projects_stably() { |
| 816 | let framing = CameraFraming { |
| 817 | height_milli: 3000, |
| 818 | center_y_milli: 1100, |
| 819 | pivot_y_milli: Some(1500), |
| 820 | depth_range_milli: None, |
| 821 | }; |
| 822 | let mut pivot_ndcs = Vec::new(); |
| 823 | for pitch in crate::preview::PAPERDOLL_PITCHES_DEG { |
| 824 | for yaw in (0..12).map(|i| i as f32 * 30.) { |
| 825 | let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.); |
| 826 | let pivot = project(view, projection, [0., 1.5, 0.]); |
| 827 | assert!( |
| 828 | pivot[0].abs() < 1e-4, |
| 829 | "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}" |
| 830 | ); |
| 831 | pivot_ndcs.push(pivot[1]); |
| 832 | let old = CameraFraming { |
| 833 | pivot_y_milli: Some(framing.center_y_milli), |
| 834 | ..framing |
| 835 | }; |
| 836 | let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.); |
| 837 | assert_ne!( |
| 838 | view, old_view, |
| 839 | "eye must move with pivot at yaw {yaw} pitch {pitch}" |
| 840 | ); |
| 841 | let neck_new = project(view, projection, [0., 1.5, 0.]); |
| 842 | let neck_old = project(old_view, old_projection, [0., 1.5, 0.]); |
| 843 | if pitch.abs() > 1. { |
| 844 | // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ). |
| 845 | assert!( |
| 846 | (neck_new[1] - neck_old[1]).abs() > 0.02, |
| 847 | "pitched neck residual too small at pitch {pitch}: {} vs {}", |
| 848 | neck_new[1], |
| 849 | neck_old[1] |
| 850 | ); |
| 851 | assert!( |
| 852 | (neck_new[2] - neck_old[2]).abs() > 1e-4 |
| 853 | || (view[13] - old_view[13]).abs() > 1e-4, |
| 854 | "view translation/depth must change with pivot" |
| 855 | ); |
| 856 | } else { |
| 857 | assert!((neck_new[1] - neck_old[1]).abs() < 1e-4); |
| 858 | } |
| 859 | } |
| 860 | } |
| 861 | assert_eq!(pivot_ndcs.len(), 60); |
| 862 | let first = pivot_ndcs[0]; |
| 863 | assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4)); |
| 864 | } |
| 865 | |
| 866 | #[test] |
| 867 | fn male_fixture_neck_is_detected() { |
| 868 | let body = preview::load_gltf_file( |
| 869 | &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 870 | .join("tests/fixtures/bodies/test-body-male.glb"), |
| 871 | ) |
| 872 | .unwrap(); |
| 873 | let resolved = resolve(&body, CameraFraming::default()); |
| 874 | assert!(matches!( |
| 875 | resolved.source, |
| 876 | PivotSource::Neck { |
| 877 | joint_name: ref name, |
| 878 | .. |
| 879 | } if name == "Neck" |
| 880 | )); |
| 881 | assert_eq!(resolved.framing.pivot_y_milli, Some(1460)); |
| 882 | assert_ne!( |
| 883 | resolved.framing.pivot_y_milli.unwrap(), |
| 884 | resolved.framing.center_y_milli |
| 885 | ); |
| 886 | } |
| 887 | |
| 888 | #[test] |
| 889 | fn depth_codecs_follow_the_documented_formulas() { |
| 890 | use super::{DepthEncoding as E, DepthFormat as F}; |
| 891 | let f = |bits, e| F::new(bits, e).unwrap(); |
| 892 | // gray |
| 893 | assert_eq!(f(8, E::Gray).encode_value(1.), [255, 0, 0]); |
| 894 | assert_eq!(f(16, E::Gray).encode_value(0.5), [32768, 0, 0]); |
| 895 | // rgb_bands: 766 levels at 8 bit |
| 896 | let b8 = f(8, E::RgbBands); |
| 897 | assert_eq!(b8.levels() + 1, 766); |
| 898 | assert_eq!(b8.encode_value(0.), [0, 0, 0]); |
| 899 | assert_eq!(b8.encode_value(100. / 765.), [100, 0, 0]); |
| 900 | assert_eq!(b8.encode_value(300. / 765.), [255, 45, 0]); |
| 901 | assert_eq!(b8.encode_value(1.), [255, 255, 255]); |
| 902 | assert_eq!(f(16, E::RgbBands).encode_value(1.), [65535, 65535, 65535]); |
| 903 | // rgb_packed: positional base 256 / 65536 |
| 904 | let p8 = f(8, E::RgbPacked); |
| 905 | let v: u64 = 0x12_34_56; |
| 906 | assert_eq!(p8.encode_value(v as f64 / 16_777_215.), [0x12, 0x34, 0x56]); |
| 907 | let p16 = f(16, E::RgbPacked); |
| 908 | assert_eq!(p16.levels(), (1u64 << 48) - 1); |
| 909 | let v: u64 = 0x1234_5678_9abc; |
| 910 | assert_eq!( |
| 911 | p16.encode_value(v as f64 / p16.levels() as f64), |
| 912 | [0x1234, 0x5678, 0x9abc] |
| 913 | ); |
| 914 | // Round trip and monotonic for every combination. |
| 915 | for bits in [8, 16] { |
| 916 | for e in E::ALL { |
| 917 | let fmt = f(bits, e); |
| 918 | let mut last = -1.; |
| 919 | for k in 0..=1000 { |
| 920 | let t = k as f64 / 1000.; |
| 921 | let back = fmt.decode_value(fmt.encode_value(t)); |
| 922 | assert!( |
| 923 | (back - t).abs() <= 0.5 / fmt.levels() as f64 + 1e-12, |
| 924 | "{fmt:?} {t}" |
| 925 | ); |
| 926 | assert!(back >= last); |
| 927 | last = back; |
| 928 | let mut px = Vec::new(); |
| 929 | fmt.encode_pixel(t, 1., &mut px); |
| 930 | assert_eq!(px.len(), fmt.bytes_per_pixel()); |
| 931 | let (d, a) = fmt.decode_pixel(&px).unwrap(); |
| 932 | assert!( |
| 933 | (f64::from(d) / 65535. - t).abs() <= 1. / fmt.levels().min(65535) as f64 |
| 934 | ); |
| 935 | assert_eq!(a, 65535); |
| 936 | } |
| 937 | } |
| 938 | } |
| 939 | // Bands must fill in order. |
| 940 | assert!(b8.decode_pixel(&[10, 5, 0, 255]).is_err()); |
| 941 | assert_eq!(F::default(), f(8, E::Gray)); |
| 942 | assert!(F::new(12, E::Gray).is_err()); |
| 943 | } |
| 944 | |
| 945 | #[test] |
| 946 | fn auto_depth_range_is_arm_span_plus_arm_margin_and_override_wins() { |
| 947 | let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 948 | .join("tests/fixtures/bodies/test-body-male.glb"); |
| 949 | let body = crate::preview::load_body_file(&path).unwrap(); |
| 950 | let total = auto_depth_range_milli(&body) as f32 / 1000.; |
| 951 | // Test body is bound with arms down; the measure is pose-independent. |
| 952 | let (shoulders, arm) = arm_measure(&body).expect("arm joints"); |
| 953 | let span = shoulders + 2. * arm; |
| 954 | assert!( |
| 955 | (total - (span + 2. * arm)).abs() < 0.002, |
| 956 | "{total} {span} {arm}" |
| 957 | ); |
| 958 | assert!(span > 1.2 && span < 2.4, "T-pose span {span}"); |
| 959 | let (lo, hi) = body |
| 960 | .positions |
| 961 | .iter() |
| 962 | .fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[0]), b.max(p[0]))); |
| 963 | assert!( |
| 964 | span > hi - lo, |
| 965 | "arms-down width is smaller than the T-pose span" |
| 966 | ); |
| 967 | let resolved = resolve(&body, CameraFraming::default()); |
| 968 | assert!(matches!( |
| 969 | resolved.source, |
| 970 | PivotSource::Neck { |
| 971 | joint_name: ref name, |
| 972 | .. |
| 973 | } if name == "Neck" |
| 974 | )); |
| 975 | assert_eq!(resolved.framing.pivot_y_milli, Some(1460)); |
| 976 | assert_ne!( |
| 977 | resolved.framing.pivot_y_milli.unwrap(), |
| 978 | resolved.framing.center_y_milli |
| 979 | ); |
| 980 | } |
| 981 | } |