BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / camera.rs

camera.rs

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