BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / camera.rs

camera.rs

camera.rs 30.4 KiB Download Raw
1//! Fixed render orbit pivot is independent of the orthographic image center.
2use crate::{
3 preview::{self, Mat4, PreviewCharacter},
4 project::CameraFraming,
5};
6use serde::Serialize;
7#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
8#[serde(tag = "kind", rename_all = "snake_case")]
9pub 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)]
20pub struct Resolved {
21 pub framing: CameraFraming,
22 pub source: PivotSource,
23}
24impl 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}
42fn 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}
56fn 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.
68pub 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}
107pub 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).
136pub const CAMERA_DISTANCE: f32 = 10.;
137/// Minimum half-depth of the per-sex depth interval (metres).
138pub const MIN_DEPTH_HALF: f32 = 0.5;
139/// How 16-bit depth samples map back to camera distance (t963u/t964u).
140pub 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.
146pub 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}
156pub 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")]
176pub enum DepthEncoding {
177 #[default]
178 Gray,
179 RgbBands,
180 RgbPacked,
181}
182impl 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)]
205pub struct DepthFormat {
206 pub bits: u8,
207 pub encoding: DepthEncoding,
208}
209impl Default for DepthFormat {
210 fn default() -> Self {
211 Self {
212 bits: 8,
213 encoding: DepthEncoding::Gray,
214 }
215 }
216}
217impl 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.
389pub 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}
413pub const MIN_DEPTH_RANGE_MILLI: u32 = 100;
414pub 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.
418pub 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)]
466mod 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}