BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / camera.rs

camera.rs

camera.rs 37.2 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/// 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)`.
176pub 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.
188pub 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
240fn 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")]
274pub enum DepthEncoding {
275 #[default]
276 Gray,
277 RgbBands,
278 RgbPacked,
279}
280impl 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)]
303pub struct DepthFormat {
304 pub bits: u8,
305 pub encoding: DepthEncoding,
306}
307impl Default for DepthFormat {
308 fn default() -> Self {
309 Self {
310 bits: 8,
311 encoding: DepthEncoding::Gray,
312 }
313 }
314}
315impl 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.
487pub 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}
511pub const MIN_DEPTH_RANGE_MILLI: u32 = 100;
512pub 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.
516pub 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)]
564mod 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}