| 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 | /// How depth samples are stored in the depth PNGs (t980u/t981u/t983u/t987u). |
| 174 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, serde::Deserialize)] |
| 175 | #[serde(rename_all = "snake_case")] |
| 176 | pub enum DepthEncoding { |
| 177 | #[default] |
| 178 | Gray, |
| 179 | RgbBands, |
| 180 | RgbPacked, |
| 181 | } |
| 182 | impl DepthEncoding { |
| 183 | pub const ALL: [DepthEncoding; 3] = [Self::Gray, Self::RgbBands, Self::RgbPacked]; |
| 184 | pub fn token(self) -> &'static str { |
| 185 | match self { |
| 186 | Self::Gray => "gray", |
| 187 | Self::RgbBands => "rgb_bands", |
| 188 | Self::RgbPacked => "rgb_packed", |
| 189 | } |
| 190 | } |
| 191 | pub fn parse(token: &str) -> Option<Self> { |
| 192 | Self::ALL.into_iter().find(|e| e.token() == token) |
| 193 | } |
| 194 | pub fn label(self) -> &'static str { |
| 195 | match self { |
| 196 | Self::Gray => "Grijs", |
| 197 | Self::RgbBands => "RGB-banden", |
| 198 | Self::RgbPacked => "RGB-gepakt (nearest, lossless)", |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | /// Depth PNG format: encoding × bit depth (8 or 16). |
| 204 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)] |
| 205 | pub struct DepthFormat { |
| 206 | pub bits: u8, |
| 207 | pub encoding: DepthEncoding, |
| 208 | } |
| 209 | impl Default for DepthFormat { |
| 210 | fn default() -> Self { |
| 211 | Self { |
| 212 | bits: 8, |
| 213 | encoding: DepthEncoding::Gray, |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | impl DepthFormat { |
| 218 | /// Format of every export before t983u (and of manifests without depth_bits). |
| 219 | pub const GRAY16: DepthFormat = DepthFormat { |
| 220 | bits: 16, |
| 221 | encoding: DepthEncoding::Gray, |
| 222 | }; |
| 223 | /// Format recorded in a manifest: `depth_bits` + `depth_encoding` token, or |
| 224 | /// the legacy schema (no `depth_bits`, long legacy text) = 16-bit gray. |
| 225 | pub fn from_manifest(bits: Option<u64>, encoding: &str) -> Result<Self, String> { |
| 226 | match bits { |
| 227 | None if encoding == DEPTH_ENCODING => Ok(Self::GRAY16), |
| 228 | None => Err("depth_encoding onbekend".into()), |
| 229 | Some(b) => Self::new( |
| 230 | u8::try_from(b).map_err(|_| "depth_bits moet 8 of 16 zijn")?, |
| 231 | DepthEncoding::parse(encoding).ok_or("depth_encoding onbekend")?, |
| 232 | ), |
| 233 | } |
| 234 | } |
| 235 | /// Format recorded in a render.json document. |
| 236 | pub fn from_doc(doc: &serde_json::Value) -> Result<Self, String> { |
| 237 | Self::from_manifest( |
| 238 | doc.get("depth_bits").and_then(serde_json::Value::as_u64), |
| 239 | doc.get("depth_encoding") |
| 240 | .and_then(serde_json::Value::as_str) |
| 241 | .unwrap_or(""), |
| 242 | ) |
| 243 | } |
| 244 | pub fn new(bits: u8, encoding: DepthEncoding) -> Result<Self, String> { |
| 245 | if bits != 8 && bits != 16 { |
| 246 | return Err("depth_bits moet 8 of 16 zijn".into()); |
| 247 | } |
| 248 | Ok(Self { bits, encoding }) |
| 249 | } |
| 250 | pub fn max(self) -> u64 { |
| 251 | (1u64 << self.bits) - 1 |
| 252 | } |
| 253 | /// Channels per pixel (value channels + alpha). |
| 254 | pub fn channels(self) -> usize { |
| 255 | match self.encoding { |
| 256 | DepthEncoding::Gray => 2, |
| 257 | _ => 4, |
| 258 | } |
| 259 | } |
| 260 | pub fn bytes_per_pixel(self) -> usize { |
| 261 | self.channels() * usize::from(self.bits / 8) |
| 262 | } |
| 263 | pub fn color_type(self) -> png::ColorType { |
| 264 | match self.encoding { |
| 265 | DepthEncoding::Gray => png::ColorType::GrayscaleAlpha, |
| 266 | _ => png::ColorType::Rgba, |
| 267 | } |
| 268 | } |
| 269 | pub fn bit_depth(self) -> png::BitDepth { |
| 270 | if self.bits == 8 { |
| 271 | png::BitDepth::Eight |
| 272 | } else { |
| 273 | png::BitDepth::Sixteen |
| 274 | } |
| 275 | } |
| 276 | /// Number of distinct depth levels minus one. |
| 277 | pub fn levels(self) -> u64 { |
| 278 | let m = self.max(); |
| 279 | match self.encoding { |
| 280 | DepthEncoding::Gray => m, |
| 281 | DepthEncoding::RgbBands => 3 * m, |
| 282 | DepthEncoding::RgbPacked => (1u64 << (3 * self.bits as u32)) - 1, |
| 283 | } |
| 284 | } |
| 285 | /// Human-readable formula written to render.json. |
| 286 | pub fn description(self) -> String { |
| 287 | let fmt = format!("{}-bit", self.bits); |
| 288 | let head = match self.encoding { |
| 289 | DepthEncoding::Gray => format!("{fmt} gray+alpha; t=gray/{}", self.max()), |
| 290 | DepthEncoding::RgbBands => format!( |
| 291 | "{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)/{}", |
| 292 | self.levels(), self.max(), self.levels() |
| 293 | ), |
| 294 | DepthEncoding::RgbPacked => format!( |
| 295 | "{fmt} RGBA packed base {}; v=round(t*{}), R=v/B^2, G=(v/B)%B, B=v%B; nearest sampling, no mipmaps, lossless only", |
| 296 | self.max() + 1, self.levels() |
| 297 | ), |
| 298 | }; |
| 299 | head + "; distance=depth_near+t*(depth_far-depth_near), clamped" |
| 300 | } |
| 301 | /// Channel values (without alpha) for normalized depth `t` in 0..1. |
| 302 | pub fn encode_value(self, t: f64) -> [u64; 3] { |
| 303 | let t = if t.is_finite() { t.clamp(0., 1.) } else { 0. }; |
| 304 | let m = self.max(); |
| 305 | let v = (t * self.levels() as f64).round() as u64; |
| 306 | match self.encoding { |
| 307 | DepthEncoding::Gray => [v, 0, 0], |
| 308 | DepthEncoding::RgbBands => [ |
| 309 | v.min(m), |
| 310 | v.saturating_sub(m).min(m), |
| 311 | v.saturating_sub(2 * m).min(m), |
| 312 | ], |
| 313 | DepthEncoding::RgbPacked => { |
| 314 | let b = m + 1; |
| 315 | [v / (b * b), (v / b) % b, v % b] |
| 316 | } |
| 317 | } |
| 318 | } |
| 319 | /// Normalized depth from channel values. |
| 320 | pub fn decode_value(self, c: [u64; 3]) -> f64 { |
| 321 | let b = self.max() + 1; |
| 322 | let v = match self.encoding { |
| 323 | DepthEncoding::Gray => c[0], |
| 324 | DepthEncoding::RgbBands => c[0] + c[1] + c[2], |
| 325 | DepthEncoding::RgbPacked => c[0] * b * b + c[1] * b + c[2], |
| 326 | }; |
| 327 | v as f64 / self.levels() as f64 |
| 328 | } |
| 329 | /// Encode one pixel (t, alpha 0..1) as big-endian PNG sample bytes. |
| 330 | pub fn encode_pixel(self, t: f64, alpha: f64, out: &mut Vec<u8>) { |
| 331 | let c = self.encode_value(t); |
| 332 | let a = (alpha.clamp(0., 1.) * self.max() as f64).round() as u64; |
| 333 | let mut push = |x: u64| { |
| 334 | if self.bits == 8 { |
| 335 | out.push(x as u8); |
| 336 | } else { |
| 337 | out.extend_from_slice(&(x as u16).to_be_bytes()); |
| 338 | } |
| 339 | }; |
| 340 | match self.encoding { |
| 341 | DepthEncoding::Gray => { |
| 342 | push(c[0]); |
| 343 | push(a); |
| 344 | } |
| 345 | _ => { |
| 346 | push(c[0]); |
| 347 | push(c[1]); |
| 348 | push(c[2]); |
| 349 | push(a); |
| 350 | } |
| 351 | } |
| 352 | } |
| 353 | /// Decode one pixel of `bytes_per_pixel` bytes into canonical 16-bit |
| 354 | /// (depth, alpha) samples as used by the compositor (gray16 scale). |
| 355 | pub fn decode_pixel(self, px: &[u8]) -> Result<(u16, u16), String> { |
| 356 | let read = |i: usize| -> u64 { |
| 357 | if self.bits == 8 { |
| 358 | u64::from(px[i]) |
| 359 | } else { |
| 360 | u64::from(u16::from_be_bytes([px[2 * i], px[2 * i + 1]])) |
| 361 | } |
| 362 | }; |
| 363 | let (c, a) = match self.encoding { |
| 364 | DepthEncoding::Gray => ([read(0), 0, 0], read(1)), |
| 365 | _ => ([read(0), read(1), read(2)], read(3)), |
| 366 | }; |
| 367 | if self.encoding == DepthEncoding::RgbBands { |
| 368 | let m = self.max(); |
| 369 | // Bands fill in order: G only when R is full, B only when G is full. |
| 370 | if (c[1] > 0 && c[0] != m) || (c[2] > 0 && c[1] != m) { |
| 371 | return Err("depth rgb_bands: ongeldige bandvolgorde".into()); |
| 372 | } |
| 373 | } |
| 374 | let t = self.decode_value(c); |
| 375 | let scale = 65535. / self.max() as f64; |
| 376 | Ok(( |
| 377 | (t * 65535.).round() as u16, |
| 378 | (a as f64 * scale).round() as u16, |
| 379 | )) |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | /// Default per-sex depth range (t985u): T-pose arm span plus one arm length |
| 384 | /// on each side, in millimetres (total). Pose-independent: with identifiable |
| 385 | /// left/right upper-arm and hand joints, arm = |upper arm → hand| × 1.2 (hand |
| 386 | /// and fingers) and span = shoulder width + 2·arm, so A-/I-pose binds give the |
| 387 | /// same result as a T-pose. Otherwise span = max(bind width, bind height) and |
| 388 | /// arm = 0.4·span. |
| 389 | pub fn auto_depth_range_milli(body: &PreviewCharacter) -> u32 { |
| 390 | let (span, arm) = match arm_measure(body) { |
| 391 | Some((shoulders, arm)) => (shoulders + 2. * arm, arm), |
| 392 | None => { |
| 393 | let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]); |
| 394 | for p in &body.positions { |
| 395 | for i in 0..2 { |
| 396 | if p[i].is_finite() { |
| 397 | lo[i] = lo[i].min(p[i]); |
| 398 | hi[i] = hi[i].max(p[i]); |
| 399 | } |
| 400 | } |
| 401 | } |
| 402 | let span = (hi[0] - lo[0]).max(hi[1] - lo[1]); |
| 403 | let span = if span.is_finite() && span > 0. { |
| 404 | span |
| 405 | } else { |
| 406 | 1.0 |
| 407 | }; |
| 408 | (span, 0.4 * span) |
| 409 | } |
| 410 | }; |
| 411 | (((span + 2. * arm) * 1000.).round() as u32).clamp(MIN_DEPTH_RANGE_MILLI, MAX_DEPTH_RANGE_MILLI) |
| 412 | } |
| 413 | pub const MIN_DEPTH_RANGE_MILLI: u32 = 100; |
| 414 | pub const MAX_DEPTH_RANGE_MILLI: u32 = 19000; |
| 415 | |
| 416 | /// (shoulder width, arm length) from rest-pose joints: left/right upper arm |
| 417 | /// and hand, identified by name. None when either side is missing. |
| 418 | pub fn arm_measure(body: &PreviewCharacter) -> Option<(f32, f32)> { |
| 419 | if body.is_static() { |
| 420 | return None; |
| 421 | } |
| 422 | // (left?, kind) with kind 0 = upper arm, 1 = hand. |
| 423 | let classify = |name: &str| -> Option<(bool, u8)> { |
| 424 | let l = name.to_ascii_lowercase(); |
| 425 | let kind = if l.contains("upperarm") || l.contains("upper_arm") || l.contains("arm_upper") { |
| 426 | 0 |
| 427 | } else if l.contains("hand") && !l.contains("handle") { |
| 428 | 1 |
| 429 | } else { |
| 430 | return None; |
| 431 | }; |
| 432 | let tokens: Vec<&str> = l.split(|c: char| !c.is_ascii_alphanumeric()).collect(); |
| 433 | let left = l.contains("left") || tokens.contains(&"l") || l.starts_with("l_"); |
| 434 | let right = l.contains("right") || tokens.contains(&"r") || l.starts_with("r_"); |
| 435 | match (left, right) { |
| 436 | (true, false) => Some((true, kind)), |
| 437 | (false, true) => Some((false, kind)), |
| 438 | _ => None, |
| 439 | } |
| 440 | }; |
| 441 | let mut found: [[Option<[f32; 3]>; 2]; 2] = [[None; 2]; 2]; |
| 442 | for (j, name) in body.joint_names.iter().enumerate() { |
| 443 | let Some((left, kind)) = classify(name) else { |
| 444 | continue; |
| 445 | }; |
| 446 | let slot = &mut found[usize::from(left)][kind as usize]; |
| 447 | if slot.is_some() { |
| 448 | continue; // first match (closest to the root in joint order) |
| 449 | } |
| 450 | if let Some(w) = preview::rest_joint_world(body, j) { |
| 451 | *slot = Some([w[12], w[13], w[14]]); |
| 452 | } |
| 453 | } |
| 454 | let dist = |a: [f32; 3], b: [f32; 3]| { |
| 455 | ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() |
| 456 | }; |
| 457 | let [[Some(ru), Some(rh)], [Some(lu), Some(lh)]] = found else { |
| 458 | return None; |
| 459 | }; |
| 460 | let arm = (dist(lu, lh) + dist(ru, rh)) / 2. * 1.2; |
| 461 | let shoulders = dist(lu, ru); |
| 462 | (arm.is_finite() && arm > 0. && shoulders.is_finite()).then_some((shoulders, arm)) |
| 463 | } |
| 464 | |
| 465 | #[cfg(test)] |
| 466 | mod tests { |
| 467 | use super::*; |
| 468 | use crate::preview::{self, PreviewCharacter, SceneNode}; |
| 469 | |
| 470 | fn node(name: &str, parent: Option<usize>, y: f32) -> SceneNode { |
| 471 | SceneNode { |
| 472 | name: name.into(), |
| 473 | parent, |
| 474 | translation: [0., y, 0.], |
| 475 | rotation: [0., 0., 0., 1.], |
| 476 | scale: [1., 1., 1.], |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | fn rig(names: &[&str], parents: &[Option<usize>], ys: &[f32]) -> PreviewCharacter { |
| 481 | assert_eq!(names.len(), parents.len()); |
| 482 | assert_eq!(names.len(), ys.len()); |
| 483 | let nodes = names |
| 484 | .iter() |
| 485 | .zip(parents) |
| 486 | .zip(ys) |
| 487 | .map(|((name, parent), y)| node(name, *parent, *y)) |
| 488 | .collect(); |
| 489 | PreviewCharacter { |
| 490 | nodes, |
| 491 | joint_nodes: (0..names.len()).collect(), |
| 492 | joint_names: names.iter().map(|n| (*n).into()).collect(), |
| 493 | inverse_binds: vec![preview::identity(); names.len()], |
| 494 | positions: Vec::new(), |
| 495 | normals: Vec::new(), |
| 496 | source_normals_complete: true, |
| 497 | joints: Vec::new(), |
| 498 | weights: Vec::new(), |
| 499 | indices: Vec::new(), |
| 500 | base_color: [1., 1., 1., 1.], |
| 501 | colors: Vec::new(), |
| 502 | uvs: Vec::new(), |
| 503 | surfaces: Vec::new(), |
| 504 | clips: Vec::new(), |
| 505 | rig: preview::BodyRig::Skinned, |
| 506 | } |
| 507 | } |
| 508 | |
| 509 | fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] { |
| 510 | let eye = [ |
| 511 | view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12], |
| 512 | view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13], |
| 513 | view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14], |
| 514 | ]; |
| 515 | [ |
| 516 | projection[0] * eye[0] + projection[12], |
| 517 | projection[5] * eye[1] + projection[13], |
| 518 | projection[10] * eye[2] + projection[14], |
| 519 | ] |
| 520 | } |
| 521 | |
| 522 | #[test] |
| 523 | fn depth_range_is_narrow_fixed_and_maps_pivot_to_middle() { |
| 524 | let f = CameraFraming::default(); |
| 525 | let (near, far) = depth_range(f); |
| 526 | assert!((near - 8.7).abs() < 1e-5 && (far - 11.3).abs() < 1e-5); |
| 527 | let small = CameraFraming { |
| 528 | height_milli: 400, |
| 529 | ..f |
| 530 | }; |
| 531 | assert_eq!(depth_range(small), (9.5, 10.5)); |
| 532 | let offset = CameraFraming { |
| 533 | pivot_y_milli: Some(1460), |
| 534 | ..f |
| 535 | }; |
| 536 | let (near, far) = depth_range(offset); |
| 537 | assert!((near - 8.24).abs() < 1e-5 && (far - 11.76).abs() < 1e-5); |
| 538 | // Pivot projects to NDC z = 0 (gray 32768) for every view. |
| 539 | let f = CameraFraming { |
| 540 | pivot_y_milli: Some(1460), |
| 541 | ..f |
| 542 | }; |
| 543 | for (yaw, pitch) in [(0., 0.), (90., 30.), (210., -60.), (330., 60.)] { |
| 544 | let (view, projection) = matrices(f, yaw, pitch, 1.); |
| 545 | let p = project(view, projection, [0., 1.46, 0.]); |
| 546 | assert!(p[2].abs() < 1e-4, "{p:?}"); |
| 547 | // 0.5 m in front of the pivot uses a large part of the range. |
| 548 | let d = preview::eye_offset(yaw, pitch, 0.5); |
| 549 | let q = project(view, projection, [d[0], 1.46 + d[1], d[2]]); |
| 550 | assert!((q[2] + 0.5 / 1.76).abs() < 1e-3, "{q:?}"); |
| 551 | } |
| 552 | } |
| 553 | |
| 554 | #[test] |
| 555 | fn neck_name_variants_and_missing_ambiguous() { |
| 556 | let cases = [ |
| 557 | ("Neck", 1.5), |
| 558 | ("mixamorigNeck", 1.6), |
| 559 | ("Bip001 Neck", 1.7), |
| 560 | ("Bip01 Neck", 1.55), |
| 561 | ("CC_Base_Neck", 1.45), |
| 562 | ("neck2", 1.42), |
| 563 | ]; |
| 564 | for (name, y) in cases { |
| 565 | let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]); |
| 566 | let (joint, milli) = neck_height(&body).unwrap(); |
| 567 | assert_eq!(body.joint_names[joint], name); |
| 568 | assert_eq!(milli, (y * 1000.).round() as i32); |
| 569 | } |
| 570 | assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err()); |
| 571 | let ambiguous = rig( |
| 572 | &["Hips", "Neck", "Prop Neck"], |
| 573 | &[None, Some(0), None], |
| 574 | &[1., 0.5, 2.], |
| 575 | ); |
| 576 | assert!(neck_height(&ambiguous).is_err()); |
| 577 | let chain = rig( |
| 578 | &["Hips", "Neck", "Neck1"], |
| 579 | &[None, Some(0), Some(1)], |
| 580 | &[1., 0.5, 0.1], |
| 581 | ); |
| 582 | let (joint, _) = neck_height(&chain).unwrap(); |
| 583 | assert_eq!(chain.joint_names[joint], "Neck"); |
| 584 | } |
| 585 | |
| 586 | #[test] |
| 587 | fn resolve_manual_override_wins_and_fallback_uses_center() { |
| 588 | let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]); |
| 589 | let auto = resolve( |
| 590 | &body, |
| 591 | CameraFraming { |
| 592 | height_milli: 2600, |
| 593 | center_y_milli: 1000, |
| 594 | pivot_y_milli: None, |
| 595 | depth_range_milli: None, |
| 596 | }, |
| 597 | ); |
| 598 | assert!(matches!(auto.source, PivotSource::Neck { .. })); |
| 599 | assert_eq!(auto.framing.pivot_y_milli, Some(1500)); |
| 600 | let manual = resolve( |
| 601 | &body, |
| 602 | CameraFraming { |
| 603 | height_milli: 2600, |
| 604 | center_y_milli: 1000, |
| 605 | pivot_y_milli: Some(1800), |
| 606 | depth_range_milli: None, |
| 607 | }, |
| 608 | ); |
| 609 | assert_eq!(manual.source, PivotSource::Manual); |
| 610 | assert_eq!(manual.framing.pivot_y_milli, Some(1800)); |
| 611 | let missing = resolve( |
| 612 | &rig(&["Hips"], &[None], &[1.0]), |
| 613 | CameraFraming { |
| 614 | height_milli: 2600, |
| 615 | center_y_milli: 900, |
| 616 | pivot_y_milli: None, |
| 617 | depth_range_milli: None, |
| 618 | }, |
| 619 | ); |
| 620 | assert!(matches!(missing.source, PivotSource::Fallback { .. })); |
| 621 | assert_eq!(missing.framing.pivot_y_milli, Some(900)); |
| 622 | } |
| 623 | |
| 624 | #[test] |
| 625 | fn zero_pitch_keeps_image_center_when_pivot_differs() { |
| 626 | let framing = CameraFraming { |
| 627 | height_milli: 2600, |
| 628 | center_y_milli: 1000, |
| 629 | pivot_y_milli: Some(1600), |
| 630 | depth_range_milli: None, |
| 631 | }; |
| 632 | for yaw in [0., 90., 180., 270.] { |
| 633 | let (view, projection) = matrices(framing, yaw, 0., 1.); |
| 634 | let center = project(view, projection, [0., 1., 0.]); |
| 635 | assert!( |
| 636 | center[0].abs() < 1e-4 && center[1].abs() < 1e-4, |
| 637 | "yaw {yaw}: center NDC {center:?}" |
| 638 | ); |
| 639 | let feet = project(view, projection, [0., 0., 0.]); |
| 640 | assert!( |
| 641 | (feet[1] + 1000. / 1300.).abs() < 1e-4, |
| 642 | "yaw {yaw}: feet NDC y {}", |
| 643 | feet[1] |
| 644 | ); |
| 645 | } |
| 646 | } |
| 647 | |
| 648 | #[test] |
| 649 | fn all_60_angles_share_pivot_and_neck_projects_stably() { |
| 650 | let framing = CameraFraming { |
| 651 | height_milli: 3000, |
| 652 | center_y_milli: 1100, |
| 653 | pivot_y_milli: Some(1500), |
| 654 | depth_range_milli: None, |
| 655 | }; |
| 656 | let mut pivot_ndcs = Vec::new(); |
| 657 | for pitch in crate::preview::PAPERDOLL_PITCHES_DEG { |
| 658 | for yaw in (0..12).map(|i| i as f32 * 30.) { |
| 659 | let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.); |
| 660 | let pivot = project(view, projection, [0., 1.5, 0.]); |
| 661 | assert!( |
| 662 | pivot[0].abs() < 1e-4, |
| 663 | "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}" |
| 664 | ); |
| 665 | pivot_ndcs.push(pivot[1]); |
| 666 | let old = CameraFraming { |
| 667 | pivot_y_milli: Some(framing.center_y_milli), |
| 668 | ..framing |
| 669 | }; |
| 670 | let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.); |
| 671 | assert_ne!( |
| 672 | view, old_view, |
| 673 | "eye must move with pivot at yaw {yaw} pitch {pitch}" |
| 674 | ); |
| 675 | let neck_new = project(view, projection, [0., 1.5, 0.]); |
| 676 | let neck_old = project(old_view, old_projection, [0., 1.5, 0.]); |
| 677 | if pitch.abs() > 1. { |
| 678 | // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ). |
| 679 | assert!( |
| 680 | (neck_new[1] - neck_old[1]).abs() > 0.02, |
| 681 | "pitched neck residual too small at pitch {pitch}: {} vs {}", |
| 682 | neck_new[1], |
| 683 | neck_old[1] |
| 684 | ); |
| 685 | assert!( |
| 686 | (neck_new[2] - neck_old[2]).abs() > 1e-4 |
| 687 | || (view[13] - old_view[13]).abs() > 1e-4, |
| 688 | "view translation/depth must change with pivot" |
| 689 | ); |
| 690 | } else { |
| 691 | assert!((neck_new[1] - neck_old[1]).abs() < 1e-4); |
| 692 | } |
| 693 | } |
| 694 | } |
| 695 | assert_eq!(pivot_ndcs.len(), 60); |
| 696 | let first = pivot_ndcs[0]; |
| 697 | assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4)); |
| 698 | } |
| 699 | |
| 700 | #[test] |
| 701 | fn male_fixture_neck_is_detected() { |
| 702 | let body = preview::load_gltf_file( |
| 703 | &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 704 | .join("tests/fixtures/bodies/test-body-male.glb"), |
| 705 | ) |
| 706 | .unwrap(); |
| 707 | let resolved = resolve(&body, CameraFraming::default()); |
| 708 | assert!(matches!( |
| 709 | resolved.source, |
| 710 | PivotSource::Neck { |
| 711 | joint_name: ref name, |
| 712 | .. |
| 713 | } if name == "Neck" |
| 714 | )); |
| 715 | assert_eq!(resolved.framing.pivot_y_milli, Some(1460)); |
| 716 | assert_ne!( |
| 717 | resolved.framing.pivot_y_milli.unwrap(), |
| 718 | resolved.framing.center_y_milli |
| 719 | ); |
| 720 | } |
| 721 | |
| 722 | #[test] |
| 723 | fn depth_codecs_follow_the_documented_formulas() { |
| 724 | use super::{DepthEncoding as E, DepthFormat as F}; |
| 725 | let f = |bits, e| F::new(bits, e).unwrap(); |
| 726 | // gray |
| 727 | assert_eq!(f(8, E::Gray).encode_value(1.), [255, 0, 0]); |
| 728 | assert_eq!(f(16, E::Gray).encode_value(0.5), [32768, 0, 0]); |
| 729 | // rgb_bands: 766 levels at 8 bit |
| 730 | let b8 = f(8, E::RgbBands); |
| 731 | assert_eq!(b8.levels() + 1, 766); |
| 732 | assert_eq!(b8.encode_value(0.), [0, 0, 0]); |
| 733 | assert_eq!(b8.encode_value(100. / 765.), [100, 0, 0]); |
| 734 | assert_eq!(b8.encode_value(300. / 765.), [255, 45, 0]); |
| 735 | assert_eq!(b8.encode_value(1.), [255, 255, 255]); |
| 736 | assert_eq!(f(16, E::RgbBands).encode_value(1.), [65535, 65535, 65535]); |
| 737 | // rgb_packed: positional base 256 / 65536 |
| 738 | let p8 = f(8, E::RgbPacked); |
| 739 | let v: u64 = 0x12_34_56; |
| 740 | assert_eq!(p8.encode_value(v as f64 / 16_777_215.), [0x12, 0x34, 0x56]); |
| 741 | let p16 = f(16, E::RgbPacked); |
| 742 | assert_eq!(p16.levels(), (1u64 << 48) - 1); |
| 743 | let v: u64 = 0x1234_5678_9abc; |
| 744 | assert_eq!( |
| 745 | p16.encode_value(v as f64 / p16.levels() as f64), |
| 746 | [0x1234, 0x5678, 0x9abc] |
| 747 | ); |
| 748 | // Round trip and monotonic for every combination. |
| 749 | for bits in [8, 16] { |
| 750 | for e in E::ALL { |
| 751 | let fmt = f(bits, e); |
| 752 | let mut last = -1.; |
| 753 | for k in 0..=1000 { |
| 754 | let t = k as f64 / 1000.; |
| 755 | let back = fmt.decode_value(fmt.encode_value(t)); |
| 756 | assert!( |
| 757 | (back - t).abs() <= 0.5 / fmt.levels() as f64 + 1e-12, |
| 758 | "{fmt:?} {t}" |
| 759 | ); |
| 760 | assert!(back >= last); |
| 761 | last = back; |
| 762 | let mut px = Vec::new(); |
| 763 | fmt.encode_pixel(t, 1., &mut px); |
| 764 | assert_eq!(px.len(), fmt.bytes_per_pixel()); |
| 765 | let (d, a) = fmt.decode_pixel(&px).unwrap(); |
| 766 | assert!( |
| 767 | (f64::from(d) / 65535. - t).abs() <= 1. / fmt.levels().min(65535) as f64 |
| 768 | ); |
| 769 | assert_eq!(a, 65535); |
| 770 | } |
| 771 | } |
| 772 | } |
| 773 | // Bands must fill in order. |
| 774 | assert!(b8.decode_pixel(&[10, 5, 0, 255]).is_err()); |
| 775 | assert_eq!(F::default(), f(8, E::Gray)); |
| 776 | assert!(F::new(12, E::Gray).is_err()); |
| 777 | } |
| 778 | |
| 779 | #[test] |
| 780 | fn auto_depth_range_is_arm_span_plus_arm_margin_and_override_wins() { |
| 781 | let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) |
| 782 | .join("tests/fixtures/bodies/test-body-male.glb"); |
| 783 | let body = crate::preview::load_body_file(&path).unwrap(); |
| 784 | let total = auto_depth_range_milli(&body) as f32 / 1000.; |
| 785 | // Test body is bound with arms down; the measure is pose-independent. |
| 786 | let (shoulders, arm) = arm_measure(&body).expect("arm joints"); |
| 787 | let span = shoulders + 2. * arm; |
| 788 | assert!( |
| 789 | (total - (span + 2. * arm)).abs() < 0.002, |
| 790 | "{total} {span} {arm}" |
| 791 | ); |
| 792 | assert!(span > 1.2 && span < 2.4, "T-pose span {span}"); |
| 793 | let (lo, hi) = body |
| 794 | .positions |
| 795 | .iter() |
| 796 | .fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[0]), b.max(p[0]))); |
| 797 | assert!( |
| 798 | span > hi - lo, |
| 799 | "arms-down width is smaller than the T-pose span" |
| 800 | ); |
| 801 | let resolved = resolve(&body, CameraFraming::default()); |
| 802 | assert!(matches!( |
| 803 | resolved.source, |
| 804 | PivotSource::Neck { |
| 805 | joint_name: ref name, |
| 806 | .. |
| 807 | } if name == "Neck" |
| 808 | )); |
| 809 | assert_eq!(resolved.framing.pivot_y_milli, Some(1460)); |
| 810 | assert_ne!( |
| 811 | resolved.framing.pivot_y_milli.unwrap(), |
| 812 | resolved.framing.center_y_milli |
| 813 | ); |
| 814 | } |
| 815 | } |