//! Fixed render orbit pivot is independent of the orthographic image center. use crate::{ preview::{self, Mat4, PreviewCharacter}, project::CameraFraming, }; use serde::Serialize; #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum PivotSource { Manual, /// Vertical centre of the rest-pose bounds (Tim t1008u). BoundsCenter { min_y_milli: i32, max_y_milli: i32, }, Fallback { reason: String, }, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Resolved { pub framing: CameraFraming, pub source: PivotSource, } impl Resolved { pub fn description(&self) -> String { let y = self .framing .pivot_y_milli .unwrap_or(self.framing.center_y_milli) as f32 / 1000.; match &self.source { PivotSource::Manual => format!("Vaste pivot Y {y:.3} m"), PivotSource::BoundsCenter { .. } => { format!("Pivot in het midden van het model: Y {y:.3} m (rustpose)") } PivotSource::Fallback { reason } => { format!("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in") } } } } /// Rest-pose vertex positions (skinned with the rest joint transforms; a /// static body is already in its stored pose). pub fn rest_points(body: &PreviewCharacter) -> Vec<[f32; 3]> { if body.is_static() || body.joint_nodes.is_empty() { return body.positions.clone(); } let bones: Vec = (0..body.joint_nodes.len()) .map(|joint| { let world = preview::rest_joint_world(body, joint).unwrap_or_else(preview::identity); let inverse = body .inverse_binds .get(joint) .copied() .unwrap_or_else(preview::identity); preview::mul(world, inverse) }) .collect(); skin_points(body, &bones) } /// Vertical extent of the rest pose in millimetres: (min Y, max Y). pub fn rest_bounds_y(body: &PreviewCharacter) -> Result<(i32, i32), String> { let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY); for p in rest_points(body) { if p[1].is_finite() { lo = lo.min(p[1]); hi = hi.max(p[1]); } } if !lo.is_finite() || !hi.is_finite() { return Err("Model zonder geldige geometrie".into()); } let milli = |v: f32| (v * 1000.).round().clamp(-10000., 10000.) as i32; Ok((milli(lo), milli(hi))) } pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved { if framing.depth_range_milli.is_none() { framing.depth_range_milli = Some(auto_depth_range_milli(body)); } if framing.pivot_y_milli.is_some() { return Resolved { framing, source: PivotSource::Manual, }; } let source = match rest_bounds_y(body) { Ok((min_y_milli, max_y_milli)) => { framing.pivot_y_milli = Some(((i64::from(min_y_milli) + i64::from(max_y_milli)) / 2) as i32); PivotSource::BoundsCenter { min_y_milli, max_y_milli, } } Err(reason) => { framing.pivot_y_milli = Some(framing.center_y_milli); PivotSource::Fallback { reason } } }; Resolved { framing, source } } /// Translation around the pivot is separate from the image's vertical framing. /// Keeping the orthographic offset constant makes the orbit pivot pixel stable /// for every yaw/pitch without cropping the feet merely by raising the pivot. /// Orthographic camera distance from the orbit pivot (metres). pub const CAMERA_DISTANCE: f32 = 10.; /// Minimum half-depth of the per-sex depth interval (metres). pub const MIN_DEPTH_HALF: f32 = 0.5; /// How 16-bit depth samples map back to camera distance (t963u/t964u). pub const DEPTH_ENCODING: &str = "linear: distance = depth_near + (sample/65535)*(depth_far-depth_near); 16-bit gray+alpha; clamped to [near,far]"; /// Fixed, narrow depth interval around the orbit pivot derived only from the /// per-sex framing: `CAMERA_DISTANCE ∓ R`, R = max(height × 0.5 + |pivot − /// center|, 0.5 m) — the radius around the pivot that reaches the frame's /// farthest edge, so geometry inside the pitch-0 frame stays in range at /// pitch ±60 too. Shared by every clip, frame, view and equipment of that sex. pub fn depth_range(framing: CameraFraming) -> (f32, f32) { if let Some(total) = framing.depth_range_milli { let r = total as f32 / 2000.; return (CAMERA_DISTANCE - r, CAMERA_DISTANCE + r); } let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli); let offset = (i64::from(pivot) - i64::from(framing.center_y_milli)).unsigned_abs(); let r = ((u64::from(framing.height_milli) / 2 + offset) as f32 / 1000.).max(MIN_DEPTH_HALF); (CAMERA_DISTANCE - r, CAMERA_DISTANCE + r) } pub fn matrices(framing: CameraFraming, yaw: f32, pitch: f32, aspect: f32) -> (Mat4, Mat4) { let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.; let center = framing.center_y_milli as f32 / 1000.; let target = [0., pivot, 0.]; let d = preview::eye_offset(yaw, pitch, CAMERA_DISTANCE); let (near, far) = depth_range(framing); let eye = [d[0], pivot + d[1], d[2]]; let half = framing.height_milli as f32 / 2000.; let mut p = preview::identity(); p[0] = 1. / (half * aspect); p[5] = 1. / half; p[13] = (pivot - center) / half; p[10] = -2. / (far - near); p[14] = -(far + near) / (far - near); (preview::view_matrix(eye, target), p) } /// Labelled orientation convention (§6/§13), verified by /// `orientation_convention_is_labelled`: the glTF convention — the character /// faces +Z, +Y is up, its left hand side is +X (−X is right). Camera eye = `eye_offset(yaw, pitch)`. pub const ORIENTATION_VIEWS: [(f32, &str); 4] = [ (0., "voorkant"), (90., "linkerzijde"), (180., "achterkant"), (270., "rechterzijde"), ]; /// Fixed framing that contains every frame of every clip from every one of /// the 60 production views (one envelope per body, never per frame/layer). /// `margin` is a fraction of the envelope added on every side, `extra_m` an /// absolute allowance (e.g. for equipment). Pivot stays as resolved; only /// height and center Y are returned (milli). None when there is no geometry. pub fn animation_envelope( body: &PreviewCharacter, framing: CameraFraming, aspect: f32, fps: u32, margin: f32, extra_m: f32, ) -> Option<(u32, i32)> { let framing = resolve(body, framing).framing; let pivot = framing.pivot_y_milli.unwrap_or(framing.center_y_milli) as f32 / 1000.; let mut poses: Vec> = Vec::new(); if body.is_static() || body.clips.is_empty() { poses.push(body.positions.clone()); } else { let step = 1. / fps.max(1) as f32; for clip in &body.clips { let frames = ((clip.duration / step).ceil() as usize).max(1); for frame in 0..frames { let bones = preview::sample_skin_matrices(body, &clip.name, frame as f32 * step); poses.push(skin_points(body, &bones)); } } } let (mut lo, mut hi, mut side) = (f32::INFINITY, f32::NEG_INFINITY, 0f32); for pitch in preview::PAPERDOLL_PITCHES_DEG { for yaw in preview::paperdoll_yaws_deg() { let (view, _) = matrices(framing, yaw, pitch, aspect); for pose in &poses { for p in pose { let x = view[0] * p[0] + view[4] * p[1] + view[8] * p[2] + view[12]; let y = view[1] * p[0] + view[5] * p[1] + view[9] * p[2] + view[13]; if x.is_finite() && y.is_finite() { lo = lo.min(y); hi = hi.max(y); side = side.max(x.abs()); } } } } } if !lo.is_finite() || !hi.is_finite() { return None; } let height = (hi - lo).max(2. * side / aspect.max(1e-6)); let height = height * (1. + 2. * margin.max(0.)) + 2. * extra_m.max(0.); let center = pivot + (hi + lo) / 2.; Some(( (height * 1000.).ceil() as u32, (center * 1000.).round() as i32, )) } /// Per production pitch: (pitch, min, max view depth, vertex samples outside /// near/far) over every clip frame at `fps` and all twelve yaws. pub fn depth_extents( body: &PreviewCharacter, framing: CameraFraming, fps: u32, ) -> Vec<(f32, f32, f32, usize)> { let mut poses: Vec> = Vec::new(); if body.is_static() || body.clips.is_empty() { poses.push(body.positions.clone()); } else { let step = 1. / fps.max(1) as f32; for clip in &body.clips { let frames = ((clip.duration / step).ceil() as usize).max(1); for frame in 0..frames { let bones = preview::sample_skin_matrices(body, &clip.name, frame as f32 * step); poses.push(skin_points(body, &bones)); } } } let (near, far) = depth_range(framing); preview::PAPERDOLL_PITCHES_DEG .iter() .map(|&pitch| { let (mut lo, mut hi, mut out) = (f32::INFINITY, f32::NEG_INFINITY, 0); for yaw in preview::paperdoll_yaws_deg() { let (view, _) = matrices(framing, yaw, pitch, 1.); for p in poses.iter().flatten() { let z = -(view[2] * p[0] + view[6] * p[1] + view[10] * p[2] + view[14]); if z.is_finite() { lo = lo.min(z); hi = hi.max(z); out += usize::from(z < near || z > far); } } } (pitch, lo, hi, out) }) .collect() } fn skin_points(body: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> { body.positions .iter() .enumerate() .map(|(i, p)| { let w = body.weights.get(i).copied().unwrap_or([1., 0., 0., 0.]); let j = body.joints.get(i).copied().unwrap_or([0; 4]); let mut out = [0.; 3]; let mut sum = 0.; for k in 0..4 { if w[k] == 0. { continue; } let m = bones .get(j[k] as usize) .copied() .unwrap_or_else(preview::identity); for a in 0..3 { out[a] += w[k] * (m[a] * p[0] + m[4 + a] * p[1] + m[8 + a] * p[2] + m[12 + a]); } sum += w[k]; } if sum > 0. { out } else { *p } }) .collect() } /// How depth samples are stored in the depth PNGs (t980u/t981u/t983u/t987u). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum DepthEncoding { #[default] Gray, RgbBands, RgbPacked, } impl DepthEncoding { pub const ALL: [DepthEncoding; 3] = [Self::Gray, Self::RgbBands, Self::RgbPacked]; pub fn token(self) -> &'static str { match self { Self::Gray => "gray", Self::RgbBands => "rgb_bands", Self::RgbPacked => "rgb_packed", } } pub fn parse(token: &str) -> Option { Self::ALL.into_iter().find(|e| e.token() == token) } pub fn label(self) -> &'static str { match self { Self::Gray => "Grijs", Self::RgbBands => "RGB-banden", Self::RgbPacked => "RGB-gepakt (nearest, lossless)", } } } /// Depth PNG format: encoding × bit depth (8 or 16). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)] pub struct DepthFormat { pub bits: u8, pub encoding: DepthEncoding, } impl Default for DepthFormat { fn default() -> Self { Self { bits: 8, encoding: DepthEncoding::Gray, } } } impl DepthFormat { /// Format of every export before t983u (and of manifests without depth_bits). pub const GRAY16: DepthFormat = DepthFormat { bits: 16, encoding: DepthEncoding::Gray, }; /// Format recorded in a manifest: `depth_bits` + `depth_encoding` token, or /// the legacy schema (no `depth_bits`, long legacy text) = 16-bit gray. pub fn from_manifest(bits: Option, encoding: &str) -> Result { match bits { None if encoding == DEPTH_ENCODING => Ok(Self::GRAY16), None => Err("depth_encoding onbekend".into()), Some(b) => Self::new( u8::try_from(b).map_err(|_| "depth_bits moet 8 of 16 zijn")?, DepthEncoding::parse(encoding).ok_or("depth_encoding onbekend")?, ), } } /// Format recorded in a render.json document. pub fn from_doc(doc: &serde_json::Value) -> Result { Self::from_manifest( doc.get("depth_bits").and_then(serde_json::Value::as_u64), doc.get("depth_encoding") .and_then(serde_json::Value::as_str) .unwrap_or(""), ) } pub fn new(bits: u8, encoding: DepthEncoding) -> Result { if bits != 8 && bits != 16 { return Err("depth_bits moet 8 of 16 zijn".into()); } Ok(Self { bits, encoding }) } pub fn max(self) -> u64 { (1u64 << self.bits) - 1 } /// Channels per pixel (value channels + alpha). pub fn channels(self) -> usize { match self.encoding { DepthEncoding::Gray => 2, _ => 4, } } pub fn bytes_per_pixel(self) -> usize { self.channels() * usize::from(self.bits / 8) } pub fn color_type(self) -> png::ColorType { match self.encoding { DepthEncoding::Gray => png::ColorType::GrayscaleAlpha, _ => png::ColorType::Rgba, } } pub fn bit_depth(self) -> png::BitDepth { if self.bits == 8 { png::BitDepth::Eight } else { png::BitDepth::Sixteen } } /// Number of distinct depth levels minus one. pub fn levels(self) -> u64 { let m = self.max(); match self.encoding { DepthEncoding::Gray => m, DepthEncoding::RgbBands => 3 * m, DepthEncoding::RgbPacked => (1u64 << (3 * self.bits as u32)) - 1, } } /// Human-readable formula written to render.json. pub fn description(self) -> String { let fmt = format!("{}-bit", self.bits); let head = match self.encoding { DepthEncoding::Gray => format!("{fmt} gray+alpha; t=gray/{}", self.max()), DepthEncoding::RgbBands => format!( "{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)/{}", self.levels(), self.max(), self.levels() ), DepthEncoding::RgbPacked => format!( "{fmt} RGBA packed base {}; v=round(t*{}), R=v/B^2, G=(v/B)%B, B=v%B; nearest sampling, no mipmaps, lossless only", self.max() + 1, self.levels() ), }; head + "; distance=depth_near+t*(depth_far-depth_near), clamped" } /// Channel values (without alpha) for normalized depth `t` in 0..1. pub fn encode_value(self, t: f64) -> [u64; 3] { let t = if t.is_finite() { t.clamp(0., 1.) } else { 0. }; let m = self.max(); let v = (t * self.levels() as f64).round() as u64; match self.encoding { DepthEncoding::Gray => [v, 0, 0], DepthEncoding::RgbBands => [ v.min(m), v.saturating_sub(m).min(m), v.saturating_sub(2 * m).min(m), ], DepthEncoding::RgbPacked => { let b = m + 1; [v / (b * b), (v / b) % b, v % b] } } } /// Normalized depth from channel values. pub fn decode_value(self, c: [u64; 3]) -> f64 { let b = self.max() + 1; let v = match self.encoding { DepthEncoding::Gray => c[0], DepthEncoding::RgbBands => c[0] + c[1] + c[2], DepthEncoding::RgbPacked => c[0] * b * b + c[1] * b + c[2], }; v as f64 / self.levels() as f64 } /// Encode one pixel (t, alpha 0..1) as big-endian PNG sample bytes. pub fn encode_pixel(self, t: f64, alpha: f64, out: &mut Vec) { let c = self.encode_value(t); let a = (alpha.clamp(0., 1.) * self.max() as f64).round() as u64; let mut push = |x: u64| { if self.bits == 8 { out.push(x as u8); } else { out.extend_from_slice(&(x as u16).to_be_bytes()); } }; match self.encoding { DepthEncoding::Gray => { push(c[0]); push(a); } _ => { push(c[0]); push(c[1]); push(c[2]); push(a); } } } /// Decode one pixel of `bytes_per_pixel` bytes into canonical 16-bit /// (depth, alpha) samples as used by the compositor (gray16 scale). pub fn decode_pixel(self, px: &[u8]) -> Result<(u16, u16), String> { let read = |i: usize| -> u64 { if self.bits == 8 { u64::from(px[i]) } else { u64::from(u16::from_be_bytes([px[2 * i], px[2 * i + 1]])) } }; let (c, a) = match self.encoding { DepthEncoding::Gray => ([read(0), 0, 0], read(1)), _ => ([read(0), read(1), read(2)], read(3)), }; if self.encoding == DepthEncoding::RgbBands { let m = self.max(); // Bands fill in order: G only when R is full, B only when G is full. if (c[1] > 0 && c[0] != m) || (c[2] > 0 && c[1] != m) { return Err("depth rgb_bands: ongeldige bandvolgorde".into()); } } let t = self.decode_value(c); let scale = 65535. / self.max() as f64; Ok(( (t * 65535.).round() as u16, (a as f64 * scale).round() as u16, )) } } /// Default per-sex depth range (t985u): T-pose arm span plus one arm length /// on each side, in millimetres (total). Pose-independent: with identifiable /// left/right upper-arm and hand joints, arm = |upper arm → hand| × 1.2 (hand /// and fingers) and span = shoulder width + 2·arm, so A-/I-pose binds give the /// same result as a T-pose. Otherwise span = max(bind width, bind height) and /// arm = 0.4·span. pub fn auto_depth_range_milli(body: &PreviewCharacter) -> u32 { let (span, arm) = match arm_measure(body) { Some((shoulders, arm)) => (shoulders + 2. * arm, arm), None => { let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]); for p in &body.positions { for i in 0..2 { if p[i].is_finite() { lo[i] = lo[i].min(p[i]); hi[i] = hi[i].max(p[i]); } } } let span = (hi[0] - lo[0]).max(hi[1] - lo[1]); let span = if span.is_finite() && span > 0. { span } else { 1.0 }; (span, 0.4 * span) } }; (((span + 2. * arm) * 1000.).round() as u32).clamp(MIN_DEPTH_RANGE_MILLI, MAX_DEPTH_RANGE_MILLI) } pub const MIN_DEPTH_RANGE_MILLI: u32 = 100; pub const MAX_DEPTH_RANGE_MILLI: u32 = 19000; /// (shoulder width, arm length) from rest-pose joints: left/right upper arm /// and hand, identified by name. None when either side is missing. pub fn arm_measure(body: &PreviewCharacter) -> Option<(f32, f32)> { if body.is_static() { return None; } // (left?, kind) with kind 0 = upper arm, 1 = hand. let classify = |name: &str| -> Option<(bool, u8)> { let l = name.to_ascii_lowercase(); let kind = if l.contains("upperarm") || l.contains("upper_arm") || l.contains("arm_upper") { 0 } else if l.contains("hand") && !l.contains("handle") { 1 } else { return None; }; let tokens: Vec<&str> = l.split(|c: char| !c.is_ascii_alphanumeric()).collect(); let left = l.contains("left") || tokens.contains(&"l") || l.starts_with("l_"); let right = l.contains("right") || tokens.contains(&"r") || l.starts_with("r_"); match (left, right) { (true, false) => Some((true, kind)), (false, true) => Some((false, kind)), _ => None, } }; let mut found: [[Option<[f32; 3]>; 2]; 2] = [[None; 2]; 2]; for (j, name) in body.joint_names.iter().enumerate() { let Some((left, kind)) = classify(name) else { continue; }; let slot = &mut found[usize::from(left)][kind as usize]; if slot.is_some() { continue; // first match (closest to the root in joint order) } if let Some(w) = preview::rest_joint_world(body, j) { *slot = Some([w[12], w[13], w[14]]); } } let dist = |a: [f32; 3], b: [f32; 3]| { ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() }; let [[Some(ru), Some(rh)], [Some(lu), Some(lh)]] = found else { return None; }; let arm = (dist(lu, lh) + dist(ru, rh)) / 2. * 1.2; let shoulders = dist(lu, ru); (arm.is_finite() && arm > 0. && shoulders.is_finite()).then_some((shoulders, arm)) } #[cfg(test)] mod tests { #[test] fn orientation_convention_is_labelled() { // Labelled markers on a body facing +Z (glTF front), its left side on +X. let markers = [ ("voorkant", [0., 1., 0.3]), ("achterkant", [0., 1., -0.3]), ("linkerzijde", [0.3, 1., 0.]), ("rechterzijde", [-0.3, 1., 0.]), ]; let framing = crate::project::CameraFraming::default(); for (yaw, label) in ORIENTATION_VIEWS { let (view, _) = matrices(framing, yaw, 0., 1.); // View space looks down −Z: the largest z is nearest the camera. let z = |p: [f32; 3]| view[2] * p[0] + view[6] * p[1] + view[10] * p[2] + view[14]; let nearest = markers .iter() .max_by(|a, b| z(a.1).total_cmp(&z(b.1))) .unwrap() .0; assert_eq!(nearest, label, "yaw {yaw}"); } // Yaw 0 (front): the character's right (−X) is on the image's left. let (view, _) = matrices(framing, 0., 0., 1.); let x = |p: [f32; 3]| view[0] * p[0] + view[4] * p[1] + view[8] * p[2] + view[12]; assert!(x([-0.3, 1., 0.]) < 0. && x([0.3, 1., 0.]) > 0.); // Positive pitch looks down from above: the front marker sits lower. let (view, _) = matrices(framing, 0., 60., 1.); let y = |p: [f32; 3]| view[1] * p[0] + view[5] * p[1] + view[9] * p[2] + view[13]; assert!(y([0., 1., 0.3]) < y([0., 1., -0.3])); } #[test] fn animation_envelope_contains_every_frame_and_view() { let body = preview::load_gltf_file( &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap(); let start = crate::project::CameraFraming::default(); let (h, c) = animation_envelope(&body, start, 0.75, 8, 0., 0.).unwrap(); let mut framing = resolve(&body, start).framing; framing.height_milli = h; framing.center_y_milli = c; let (m2, c2) = animation_envelope(&body, framing, 0.75, 8, 0.05, 0.1).unwrap(); assert!(m2 as f32 >= h as f32 * 1.1 + 199.0); assert!((c2 - c).abs() <= 1); // Every skinned vertex of every frame lands inside NDC [-1, 1]. let mut worst = 0f32; for clip in &body.clips { for f in 0..((clip.duration * 8.).ceil() as usize).max(1) { let bones = preview::sample_skin_matrices(&body, &clip.name, f as f32 / 8.); let pts = skin_points(&body, &bones); for pitch in preview::PAPERDOLL_PITCHES_DEG { for yaw in preview::paperdoll_yaws_deg() { let (v, p) = matrices(framing, yaw, pitch, 0.75); let vp = preview::mul(p, v); for q in &pts { let nx = vp[0] * q[0] + vp[4] * q[1] + vp[8] * q[2] + vp[12]; let ny = vp[1] * q[0] + vp[5] * q[1] + vp[9] * q[2] + vp[13]; worst = worst.max(nx.abs()).max(ny.abs()); } } } } } assert!(worst <= 1.001 && worst > 0.95, "worst NDC {worst}"); } use super::*; use crate::preview::{self, PreviewCharacter, SceneNode}; fn node(name: &str, parent: Option, y: f32) -> SceneNode { SceneNode { name: name.into(), parent, translation: [0., y, 0.], rotation: [0., 0., 0., 1.], scale: [1., 1., 1.], } } fn rig(names: &[&str], parents: &[Option], ys: &[f32]) -> PreviewCharacter { assert_eq!(names.len(), parents.len()); assert_eq!(names.len(), ys.len()); let nodes = names .iter() .zip(parents) .zip(ys) .map(|((name, parent), y)| node(name, *parent, *y)) .collect(); PreviewCharacter { nodes, joint_nodes: (0..names.len()).collect(), joint_names: names.iter().map(|n| (*n).into()).collect(), inverse_binds: vec![preview::identity(); names.len()], positions: Vec::new(), normals: Vec::new(), source_normals_complete: true, joints: Vec::new(), weights: Vec::new(), indices: Vec::new(), base_color: [1., 1., 1., 1.], colors: Vec::new(), uvs: Vec::new(), surfaces: Vec::new(), clips: Vec::new(), rig: preview::BodyRig::Skinned, } } fn project(view: Mat4, projection: Mat4, world: [f32; 3]) -> [f32; 3] { let eye = [ view[0] * world[0] + view[4] * world[1] + view[8] * world[2] + view[12], view[1] * world[0] + view[5] * world[1] + view[9] * world[2] + view[13], view[2] * world[0] + view[6] * world[1] + view[10] * world[2] + view[14], ]; [ projection[0] * eye[0] + projection[12], projection[5] * eye[1] + projection[13], projection[10] * eye[2] + projection[14], ] } #[test] fn depth_range_is_narrow_fixed_and_maps_pivot_to_middle() { let f = CameraFraming::default(); let (near, far) = depth_range(f); assert!((near - 8.7).abs() < 1e-5 && (far - 11.3).abs() < 1e-5); let small = CameraFraming { height_milli: 400, ..f }; assert_eq!(depth_range(small), (9.5, 10.5)); let offset = CameraFraming { pivot_y_milli: Some(1460), ..f }; let (near, far) = depth_range(offset); assert!((near - 8.24).abs() < 1e-5 && (far - 11.76).abs() < 1e-5); // Pivot projects to NDC z = 0 (gray 32768) for every view. let f = CameraFraming { pivot_y_milli: Some(1460), ..f }; for (yaw, pitch) in [(0., 0.), (90., 30.), (210., -60.), (330., 60.)] { let (view, projection) = matrices(f, yaw, pitch, 1.); let p = project(view, projection, [0., 1.46, 0.]); assert!(p[2].abs() < 1e-4, "{p:?}"); // 0.5 m in front of the pivot uses a large part of the range. let d = preview::eye_offset(yaw, pitch, 0.5); let q = project(view, projection, [d[0], 1.46 + d[1], d[2]]); assert!((q[2] + 0.5 / 1.76).abs() < 1e-3, "{q:?}"); } } #[test] fn free_view_is_further_away_with_the_same_on_screen_size() { let fov = preview::free_fov_deg(); assert!((15.0..16.0).contains(&fov), "{fov}"); let d = 3.0; let old = (preview::FREE_BASE_FOV_DEG.to_radians() / 2.).tan() * d; let new = (fov.to_radians() / 2.).tan() * d * preview::FREE_DISTANCE_FACTOR; assert!((old - new).abs() < 1e-5); } #[test] fn resolve_manual_override_wins_and_fallback_uses_center() { let mut body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]); // Two vertices bound to Hips (rest Y = 1.0 + local): Y 0.2 and 2.0. body.positions = vec![[0., -0.8, 0.], [0., 1.0, 0.]]; body.joints = vec![[0; 4]; 2]; body.weights = vec![[1., 0., 0., 0.]; 2]; let auto = resolve( &body, CameraFraming { height_milli: 2600, center_y_milli: 1000, pivot_y_milli: None, depth_range_milli: None, }, ); assert_eq!( auto.source, PivotSource::BoundsCenter { min_y_milli: 200, max_y_milli: 2000 } ); assert_eq!(auto.framing.pivot_y_milli, Some(1100)); let manual = resolve( &body, CameraFraming { height_milli: 2600, center_y_milli: 1000, pivot_y_milli: Some(1800), depth_range_milli: None, }, ); assert_eq!(manual.source, PivotSource::Manual); assert_eq!(manual.framing.pivot_y_milli, Some(1800)); let missing = resolve( &rig(&["Hips"], &[None], &[1.0]), CameraFraming { height_milli: 2600, center_y_milli: 900, pivot_y_milli: None, depth_range_milli: None, }, ); assert!(matches!(missing.source, PivotSource::Fallback { .. })); assert_eq!(missing.framing.pivot_y_milli, Some(900)); } #[test] fn zero_pitch_keeps_image_center_when_pivot_differs() { let framing = CameraFraming { height_milli: 2600, center_y_milli: 1000, pivot_y_milli: Some(1600), depth_range_milli: None, }; for yaw in [0., 90., 180., 270.] { let (view, projection) = matrices(framing, yaw, 0., 1.); let center = project(view, projection, [0., 1., 0.]); assert!( center[0].abs() < 1e-4 && center[1].abs() < 1e-4, "yaw {yaw}: center NDC {center:?}" ); let feet = project(view, projection, [0., 0., 0.]); assert!( (feet[1] + 1000. / 1300.).abs() < 1e-4, "yaw {yaw}: feet NDC y {}", feet[1] ); } } #[test] fn all_60_angles_share_pivot_and_neck_projects_stably() { let framing = CameraFraming { height_milli: 3000, center_y_milli: 1100, pivot_y_milli: Some(1500), depth_range_milli: None, }; let mut pivot_ndcs = Vec::new(); for pitch in crate::preview::PAPERDOLL_PITCHES_DEG { for yaw in (0..12).map(|i| i as f32 * 30.) { let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.); let pivot = project(view, projection, [0., 1.5, 0.]); assert!( pivot[0].abs() < 1e-4, "pivot NDC x drifted at yaw {yaw} pitch {pitch}: {pivot:?}" ); pivot_ndcs.push(pivot[1]); let old = CameraFraming { pivot_y_milli: Some(framing.center_y_milli), ..framing }; let (old_view, old_projection) = matrices(old, yaw, pitch, 64. / 96.); assert_ne!( view, old_view, "eye must move with pivot at yaw {yaw} pitch {pitch}" ); let neck_new = project(view, projection, [0., 1.5, 0.]); let neck_old = project(old_view, old_projection, [0., 1.5, 0.]); if pitch.abs() > 1. { // Ortho Y-compensation cancels at pitch 0; pitched residual is δ/half*(1-cos θ). assert!( (neck_new[1] - neck_old[1]).abs() > 0.02, "pitched neck residual too small at pitch {pitch}: {} vs {}", neck_new[1], neck_old[1] ); assert!( (neck_new[2] - neck_old[2]).abs() > 1e-4 || (view[13] - old_view[13]).abs() > 1e-4, "view translation/depth must change with pivot" ); } else { assert!((neck_new[1] - neck_old[1]).abs() < 1e-4); } } } assert_eq!(pivot_ndcs.len(), 60); let first = pivot_ndcs[0]; assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4)); } #[test] fn male_fixture_pivot_is_rest_pose_vertical_centre() { let body = preview::load_gltf_file( &std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap(); let resolved = resolve(&body, CameraFraming::default()); let PivotSource::BoundsCenter { min_y_milli, max_y_milli, } = resolved.source else { panic!("expected bounds-centre pivot: {:?}", resolved.source); }; assert!(min_y_milli < 100 && max_y_milli > 1600); assert_eq!( resolved.framing.pivot_y_milli, Some((min_y_milli + max_y_milli) / 2) ); assert_eq!(resolved.framing.pivot_y_milli, Some(880)); assert_ne!( resolved.framing.pivot_y_milli.unwrap(), resolved.framing.center_y_milli ); } #[test] fn depth_codecs_follow_the_documented_formulas() { use super::{DepthEncoding as E, DepthFormat as F}; let f = |bits, e| F::new(bits, e).unwrap(); // gray assert_eq!(f(8, E::Gray).encode_value(1.), [255, 0, 0]); assert_eq!(f(16, E::Gray).encode_value(0.5), [32768, 0, 0]); // rgb_bands: 766 levels at 8 bit let b8 = f(8, E::RgbBands); assert_eq!(b8.levels() + 1, 766); assert_eq!(b8.encode_value(0.), [0, 0, 0]); assert_eq!(b8.encode_value(100. / 765.), [100, 0, 0]); assert_eq!(b8.encode_value(300. / 765.), [255, 45, 0]); assert_eq!(b8.encode_value(1.), [255, 255, 255]); assert_eq!(f(16, E::RgbBands).encode_value(1.), [65535, 65535, 65535]); // rgb_packed: positional base 256 / 65536 let p8 = f(8, E::RgbPacked); let v: u64 = 0x12_34_56; assert_eq!(p8.encode_value(v as f64 / 16_777_215.), [0x12, 0x34, 0x56]); let p16 = f(16, E::RgbPacked); assert_eq!(p16.levels(), (1u64 << 48) - 1); let v: u64 = 0x1234_5678_9abc; assert_eq!( p16.encode_value(v as f64 / p16.levels() as f64), [0x1234, 0x5678, 0x9abc] ); // Round trip and monotonic for every combination. for bits in [8, 16] { for e in E::ALL { let fmt = f(bits, e); let mut last = -1.; for k in 0..=1000 { let t = k as f64 / 1000.; let back = fmt.decode_value(fmt.encode_value(t)); assert!( (back - t).abs() <= 0.5 / fmt.levels() as f64 + 1e-12, "{fmt:?} {t}" ); assert!(back >= last); last = back; let mut px = Vec::new(); fmt.encode_pixel(t, 1., &mut px); assert_eq!(px.len(), fmt.bytes_per_pixel()); let (d, a) = fmt.decode_pixel(&px).unwrap(); assert!( (f64::from(d) / 65535. - t).abs() <= 1. / fmt.levels().min(65535) as f64 ); assert_eq!(a, 65535); } } } // Bands must fill in order. assert!(b8.decode_pixel(&[10, 5, 0, 255]).is_err()); assert_eq!(F::default(), f(8, E::Gray)); assert!(F::new(12, E::Gray).is_err()); } #[test] fn auto_depth_range_is_arm_span_plus_arm_margin_and_override_wins() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/bodies/test-body-male.glb"); let body = crate::preview::load_body_file(&path).unwrap(); let total = auto_depth_range_milli(&body) as f32 / 1000.; // Test body is bound with arms down; the measure is pose-independent. let (shoulders, arm) = arm_measure(&body).expect("arm joints"); let span = shoulders + 2. * arm; assert!( (total - (span + 2. * arm)).abs() < 0.002, "{total} {span} {arm}" ); assert!(span > 1.2 && span < 2.4, "T-pose span {span}"); let (lo, hi) = body .positions .iter() .fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[0]), b.max(p[0]))); assert!( span > hi - lo, "arms-down width is smaller than the T-pose span" ); let resolved = resolve(&body, CameraFraming::default()); let PivotSource::BoundsCenter { min_y_milli, max_y_milli, } = resolved.source else { panic!("expected bounds-centre pivot: {:?}", resolved.source); }; assert!(min_y_milli < 100 && max_y_milli > 1600); assert_eq!( resolved.framing.pivot_y_milli, Some((min_y_milli + max_y_milli) / 2) ); assert_eq!(resolved.framing.pivot_y_milli, Some(880)); assert_ne!( resolved.framing.pivot_y_milli.unwrap(), resolved.framing.center_y_milli ); } }