Skip to main content

oxihuman_morph/
mocap_bvh.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! BVH (Biovision Hierarchy) motion capture file parser and skeleton mapper.
5//!
6//! Supports parsing the HIERARCHY and MOTION sections of BVH files,
7//! skeleton traversal, frame interpolation, and mapping to OxiHuman joint names.
8
9use std::collections::HashMap;
10
11// ── Channel type ─────────────────────────────────────────────────────────────
12
13/// A single channel type in a BVH joint definition.
14#[allow(dead_code)]
15#[derive(Debug, Clone, PartialEq)]
16pub enum BvhChannel {
17    Xposition,
18    Yposition,
19    Zposition,
20    Xrotation,
21    Yrotation,
22    Zrotation,
23}
24
25impl BvhChannel {
26    fn from_str(s: &str) -> Result<Self, String> {
27        match s {
28            "Xposition" => Ok(BvhChannel::Xposition),
29            "Yposition" => Ok(BvhChannel::Yposition),
30            "Zposition" => Ok(BvhChannel::Zposition),
31            "Xrotation" => Ok(BvhChannel::Xrotation),
32            "Yrotation" => Ok(BvhChannel::Yrotation),
33            "Zrotation" => Ok(BvhChannel::Zrotation),
34            _ => Err(format!("Unknown BVH channel: '{s}'")),
35        }
36    }
37
38    fn as_str(&self) -> &'static str {
39        match self {
40            BvhChannel::Xposition => "Xposition",
41            BvhChannel::Yposition => "Yposition",
42            BvhChannel::Zposition => "Zposition",
43            BvhChannel::Xrotation => "Xrotation",
44            BvhChannel::Yrotation => "Yrotation",
45            BvhChannel::Zrotation => "Zrotation",
46        }
47    }
48
49    /// Returns true if this channel is a translation channel.
50    #[allow(dead_code)]
51    pub fn is_translation(&self) -> bool {
52        matches!(
53            self,
54            BvhChannel::Xposition | BvhChannel::Yposition | BvhChannel::Zposition
55        )
56    }
57
58    /// Returns true if this channel is a rotation channel.
59    #[allow(dead_code)]
60    pub fn is_rotation(&self) -> bool {
61        matches!(
62            self,
63            BvhChannel::Xrotation | BvhChannel::Yrotation | BvhChannel::Zrotation
64        )
65    }
66}
67
68// ── Joint ─────────────────────────────────────────────────────────────────────
69
70/// A single joint in the BVH hierarchy.
71#[allow(dead_code)]
72#[derive(Debug, Clone)]
73pub struct BvhJoint {
74    /// Joint name.
75    pub name: String,
76    /// Rest offset from parent joint (in BVH units).
77    pub offset: [f32; 3],
78    /// Ordered list of channels for this joint.
79    pub channels: Vec<BvhChannel>,
80    /// Indices of child joints into `BvhSkeleton.joints`.
81    pub children: Vec<usize>,
82    /// Index of parent joint, or `None` for the root.
83    pub parent: Option<usize>,
84}
85
86impl BvhJoint {
87    fn new(name: String, offset: [f32; 3], parent: Option<usize>) -> Self {
88        Self {
89            name,
90            offset,
91            channels: Vec::new(),
92            children: Vec::new(),
93            parent,
94        }
95    }
96}
97
98// ── Skeleton ──────────────────────────────────────────────────────────────────
99
100/// The full skeleton hierarchy read from a BVH file.
101#[allow(dead_code)]
102#[derive(Debug, Clone)]
103pub struct BvhSkeleton {
104    /// Flat array of all joints (including End Sites which have no channels).
105    pub joints: Vec<BvhJoint>,
106    /// Index of the root joint in `joints`.
107    pub root_index: usize,
108}
109
110impl BvhSkeleton {
111    /// Returns the total number of joints (including end-sites).
112    #[allow(dead_code)]
113    pub fn joint_count(&self) -> usize {
114        self.joints.len()
115    }
116
117    /// Finds a joint by name and returns its index, or `None`.
118    #[allow(dead_code)]
119    pub fn find_joint(&self, name: &str) -> Option<usize> {
120        self.joints.iter().position(|j| j.name == name)
121    }
122
123    /// Returns the total number of channels across all joints.
124    #[allow(dead_code)]
125    pub fn channel_count(&self) -> usize {
126        self.joints.iter().map(|j| j.channels.len()).sum()
127    }
128
129    /// Returns an ordered slice of joint names.
130    #[allow(dead_code)]
131    pub fn joint_names(&self) -> Vec<&str> {
132        self.joints.iter().map(|j| j.name.as_str()).collect()
133    }
134
135    /// Returns the parent index of `joint_idx`, or `None` for the root.
136    #[allow(dead_code)]
137    pub fn parent_of(&self, joint_idx: usize) -> Option<usize> {
138        self.joints.get(joint_idx)?.parent
139    }
140
141    /// Returns the children indices of `joint_idx`.
142    #[allow(dead_code)]
143    pub fn children_of(&self, joint_idx: usize) -> &[usize] {
144        self.joints
145            .get(joint_idx)
146            .map(|j| j.children.as_slice())
147            .unwrap_or(&[])
148    }
149
150    /// Compute the channel offset (into a frame's flat value array) for joint at `joint_idx`.
151    #[allow(dead_code)]
152    pub fn channel_offset_for(&self, joint_idx: usize) -> usize {
153        self.joints[..joint_idx]
154            .iter()
155            .map(|j| j.channels.len())
156            .sum()
157    }
158}
159
160// ── Frame ─────────────────────────────────────────────────────────────────────
161
162/// One frame of motion data: flat channel values in joint-declaration order.
163#[allow(dead_code)]
164#[derive(Debug, Clone)]
165pub struct BvhFrame {
166    /// All channel values in the same order as joint/channel declarations.
167    pub values: Vec<f32>,
168}
169
170impl BvhFrame {
171    /// Returns the channel slice for the given joint starting at `joint_channel_offset`.
172    #[allow(dead_code)]
173    pub fn get_channels(&self, joint: &BvhJoint, joint_channel_offset: usize) -> &[f32] {
174        let len = joint.channels.len();
175        let end = (joint_channel_offset + len).min(self.values.len());
176        &self.values[joint_channel_offset..end]
177    }
178}
179
180// ── BvhFile ───────────────────────────────────────────────────────────────────
181
182/// A fully parsed BVH file.
183#[allow(dead_code)]
184#[derive(Debug, Clone)]
185pub struct BvhFile {
186    /// Parsed skeleton hierarchy.
187    pub skeleton: BvhSkeleton,
188    /// All motion frames.
189    pub frames: Vec<BvhFrame>,
190    /// Duration of a single frame in seconds.
191    pub frame_time: f32,
192}
193
194impl BvhFile {
195    /// Number of frames in the file.
196    #[allow(dead_code)]
197    pub fn frame_count(&self) -> usize {
198        self.frames.len()
199    }
200
201    /// Total duration in seconds.
202    #[allow(dead_code)]
203    pub fn duration_seconds(&self) -> f32 {
204        self.frames.len() as f32 * self.frame_time
205    }
206
207    /// Frames per second.
208    #[allow(dead_code)]
209    pub fn fps(&self) -> f32 {
210        if self.frame_time > 0.0 {
211            1.0 / self.frame_time
212        } else {
213            0.0
214        }
215    }
216
217    /// Returns the root translation [x, y, z] at frame `frame`.
218    ///
219    /// The root joint must have Xposition, Yposition, Zposition as its first three channels
220    /// (the BVH standard for a 6-DOF root).  If the root has no translation channels the
221    /// function returns `[0.0, 0.0, 0.0]`.
222    #[allow(dead_code)]
223    pub fn root_translation(&self, frame: usize) -> [f32; 3] {
224        let root = &self.skeleton.joints[self.skeleton.root_index];
225        let frame_data = &self.frames[frame];
226        let mut tx = 0.0_f32;
227        let mut ty = 0.0_f32;
228        let mut tz = 0.0_f32;
229        for (offset, ch) in root.channels.iter().enumerate() {
230            let v = frame_data.values.get(offset).copied().unwrap_or(0.0);
231            match ch {
232                BvhChannel::Xposition => tx = v,
233                BvhChannel::Yposition => ty = v,
234                BvhChannel::Zposition => tz = v,
235                _ => {}
236            }
237        }
238        [tx, ty, tz]
239    }
240
241    /// Returns the Euler rotation (degrees) [x, y, z] for joint `joint_idx` at `frame`.
242    ///
243    /// Only the rotation channels of the joint are inspected; translation channels are skipped.
244    #[allow(dead_code)]
245    pub fn joint_rotation(&self, frame: usize, joint_idx: usize) -> [f32; 3] {
246        let joint = &self.skeleton.joints[joint_idx];
247        let ch_offset = self.skeleton.channel_offset_for(joint_idx);
248        let frame_data = &self.frames[frame];
249        let mut rx = 0.0_f32;
250        let mut ry = 0.0_f32;
251        let mut rz = 0.0_f32;
252        for (i, ch) in joint.channels.iter().enumerate() {
253            let v = frame_data.values.get(ch_offset + i).copied().unwrap_or(0.0);
254            match ch {
255                BvhChannel::Xrotation => rx = v,
256                BvhChannel::Yrotation => ry = v,
257                BvhChannel::Zrotation => rz = v,
258                _ => {}
259            }
260        }
261        [rx, ry, rz]
262    }
263
264    /// Linearly interpolate between frames `frame_a` and `frame_b` by factor `t` ∈ [0, 1].
265    #[allow(dead_code)]
266    pub fn interpolate_frame(&self, frame_a: usize, frame_b: usize, t: f32) -> BvhFrame {
267        let a = &self.frames[frame_a].values;
268        let b = &self.frames[frame_b].values;
269        let len = a.len().min(b.len());
270        let values = (0..len).map(|i| a[i] + (b[i] - a[i]) * t).collect();
271        BvhFrame { values }
272    }
273
274    /// Sample motion at `time_seconds` using linear interpolation between neighbouring frames.
275    ///
276    /// Times before the first frame return the first frame; times beyond the last frame return
277    /// the last frame.
278    #[allow(dead_code)]
279    pub fn sample_at(&self, time_seconds: f32) -> BvhFrame {
280        if self.frames.is_empty() {
281            return BvhFrame { values: vec![] };
282        }
283        if self.frame_time <= 0.0 || self.frames.len() == 1 {
284            return self.frames[0].clone();
285        }
286        let total = self.frames.len() - 1;
287        let frame_f = (time_seconds / self.frame_time).max(0.0);
288        let frame_a = (frame_f.floor() as usize).min(total);
289        let frame_b = (frame_a + 1).min(total);
290        let t = frame_f.fract();
291        self.interpolate_frame(frame_a, frame_b, t)
292    }
293}
294
295// ── Parser ────────────────────────────────────────────────────────────────────
296
297/// Parse a BVH file from its string content.
298///
299/// Returns a [`BvhFile`] on success or an error string on failure.
300#[allow(dead_code)]
301pub fn parse_bvh(content: &str) -> Result<BvhFile, String> {
302    let mut lines = content.lines().peekable();
303
304    // ── HIERARCHY section ──────────────────────────────────────────────────
305    // Skip blank lines / whitespace until "HIERARCHY"
306    loop {
307        match lines.peek() {
308            None => return Err("Unexpected end of file before HIERARCHY".into()),
309            Some(l) => {
310                if l.trim() == "HIERARCHY" {
311                    lines.next();
312                    break;
313                }
314                lines.next();
315            }
316        }
317    }
318
319    // State for hierarchical joint parsing
320    let mut joints: Vec<BvhJoint> = Vec::new();
321    // Stack of joint indices: top = current parent
322    let mut parent_stack: Vec<usize> = Vec::new();
323    let mut root_index: Option<usize> = None;
324    // Depth of brace nesting inside End Site blocks we want to skip
325    let mut end_site_depth: i32 = 0;
326    let mut in_end_site = false;
327
328    loop {
329        let raw = match lines.next() {
330            None => return Err("Unexpected end of file inside HIERARCHY".into()),
331            Some(l) => l,
332        };
333        let line = raw.trim();
334
335        if line == "MOTION" {
336            break;
337        }
338        if line.is_empty() {
339            continue;
340        }
341
342        // End Site handling — we only read its OFFSET and skip the rest
343        if line.starts_with("End Site") {
344            in_end_site = true;
345            end_site_depth = 0;
346            continue;
347        }
348        if in_end_site {
349            if line == "{" {
350                end_site_depth += 1;
351            } else if line == "}" {
352                end_site_depth -= 1;
353                if end_site_depth == 0 {
354                    in_end_site = false;
355                    // Closing brace of End Site — also pop parent (End Site closes the joint block)
356                    // Actually no: in BVH the End Site brace pair is *inside* the joint's braces.
357                    // The joint's own closing brace will be handled below.
358                }
359            }
360            // Skip all lines inside End Site (including OFFSET)
361            continue;
362        }
363
364        if line == "{" {
365            // Push the most recently added joint onto the parent stack
366            if let Some(&last) = joints.last().map(|_| joints.len() - 1).as_ref() {
367                parent_stack.push(last);
368            }
369        } else if line == "}" {
370            parent_stack.pop();
371        } else if let Some(rest) = line.strip_prefix("ROOT ") {
372            let name = rest.trim().to_string();
373            let idx = joints.len();
374            joints.push(BvhJoint::new(name, [0.0; 3], None));
375            root_index = Some(idx);
376        } else if let Some(rest) = line.strip_prefix("JOINT ") {
377            let name = rest.trim().to_string();
378            let idx = joints.len();
379            let parent = parent_stack.last().copied();
380            joints.push(BvhJoint::new(name, [0.0; 3], parent));
381            if let Some(p) = parent {
382                joints[p].children.push(idx);
383            }
384        } else if let Some(rest) = line.strip_prefix("OFFSET ") {
385            let nums: Vec<f32> = rest
386                .split_whitespace()
387                .filter_map(|s| s.parse().ok())
388                .collect();
389            if nums.len() < 3 {
390                return Err(format!("OFFSET line has fewer than 3 values: '{line}'"));
391            }
392            if let Some(&idx) = parent_stack.last() {
393                joints[idx].offset = [nums[0], nums[1], nums[2]];
394            }
395        } else if let Some(rest) = line.strip_prefix("CHANNELS ") {
396            let mut parts = rest.split_whitespace();
397            let count: usize = parts
398                .next()
399                .and_then(|s| s.parse().ok())
400                .ok_or_else(|| format!("Invalid CHANNELS count in: '{line}'"))?;
401            let mut channels = Vec::with_capacity(count);
402            for _ in 0..count {
403                let ch_str = parts
404                    .next()
405                    .ok_or_else(|| format!("Not enough channel names in: '{line}'"))?;
406                channels.push(BvhChannel::from_str(ch_str)?);
407            }
408            if let Some(&idx) = parent_stack.last() {
409                joints[idx].channels = channels;
410            }
411        }
412    }
413
414    let root_index = root_index.ok_or("No ROOT joint found in HIERARCHY")?;
415
416    // ── MOTION section ────────────────────────────────────────────────────
417    // Expect "Frames: N"
418    let frames_line =
419        next_non_empty(&mut lines).ok_or("Missing 'Frames:' line in MOTION section")?;
420    let frames_line = frames_line.trim();
421    let frame_count: usize = frames_line
422        .strip_prefix("Frames:")
423        .or_else(|| frames_line.strip_prefix("Frames: "))
424        .and_then(|s| s.trim().parse().ok())
425        .ok_or_else(|| format!("Cannot parse frame count from: '{frames_line}'"))?;
426
427    // Expect "Frame Time: T"
428    let ftime_line =
429        next_non_empty(&mut lines).ok_or("Missing 'Frame Time:' line in MOTION section")?;
430    let ftime_line = ftime_line.trim();
431    let frame_time: f32 = ftime_line
432        .strip_prefix("Frame Time:")
433        .or_else(|| ftime_line.strip_prefix("Frame Time: "))
434        .and_then(|s| s.trim().parse().ok())
435        .ok_or_else(|| format!("Cannot parse frame time from: '{ftime_line}'"))?;
436
437    // Compute expected number of values per frame
438    let channel_count: usize = joints.iter().map(|j| j.channels.len()).sum();
439
440    // Parse frame data
441    let mut frames: Vec<BvhFrame> = Vec::with_capacity(frame_count);
442    let mut remaining = frame_count;
443
444    // Collect remaining tokens (frames may span multiple lines)
445    let mut token_buf: Vec<f32> = Vec::new();
446
447    for raw in lines {
448        let line = raw.trim();
449        if line.is_empty() {
450            continue;
451        }
452        for tok in line.split_whitespace() {
453            match tok.parse::<f32>() {
454                Ok(v) => token_buf.push(v),
455                Err(_) => {
456                    return Err(format!("Non-numeric token '{tok}' in MOTION data"));
457                }
458            }
459            if channel_count > 0 && token_buf.len() == channel_count {
460                frames.push(BvhFrame {
461                    values: token_buf.clone(),
462                });
463                token_buf.clear();
464                remaining = remaining.saturating_sub(1);
465            }
466        }
467        if remaining == 0 {
468            break;
469        }
470    }
471
472    // If channel_count == 0 treat any leftover tokens as single empty frames
473    if channel_count == 0 {
474        for _ in 0..frame_count {
475            frames.push(BvhFrame { values: vec![] });
476        }
477    }
478
479    let skeleton = BvhSkeleton { joints, root_index };
480
481    Ok(BvhFile {
482        skeleton,
483        frames,
484        frame_time,
485    })
486}
487
488/// Helper: advance an iterator skipping empty/blank lines, returning the next non-empty one.
489fn next_non_empty<'a, I: Iterator<Item = &'a str>>(iter: &mut I) -> Option<&'a str> {
490    iter.find(|l| !l.trim().is_empty())
491}
492
493// ── Writer ────────────────────────────────────────────────────────────────────
494
495/// Serialize a [`BvhFile`] back to BVH text (for round-trip testing).
496#[allow(dead_code)]
497pub fn write_bvh(file: &BvhFile) -> String {
498    let mut out = String::new();
499    out.push_str("HIERARCHY\n");
500
501    // Recursive joint writer
502    write_joint_recursive(&file.skeleton, file.skeleton.root_index, 0, &mut out);
503
504    out.push_str("MOTION\n");
505    out.push_str(&format!("Frames: {}\n", file.frames.len()));
506    out.push_str(&format!("Frame Time: {:.6}\n", file.frame_time));
507
508    for frame in &file.frames {
509        let line: Vec<String> = frame.values.iter().map(|v| format!("{v:.6}")).collect();
510        out.push_str(&line.join(" "));
511        out.push('\n');
512    }
513
514    out
515}
516
517fn write_joint_recursive(skel: &BvhSkeleton, idx: usize, depth: usize, out: &mut String) {
518    let indent = "  ".repeat(depth);
519    let joint = &skel.joints[idx];
520
521    if depth == 0 {
522        out.push_str(&format!("{indent}ROOT {}\n", joint.name));
523    } else {
524        out.push_str(&format!("{indent}JOINT {}\n", joint.name));
525    }
526    out.push_str(&format!("{indent}{{\n"));
527    out.push_str(&format!(
528        "{indent}  OFFSET {:.2} {:.2} {:.2}\n",
529        joint.offset[0], joint.offset[1], joint.offset[2]
530    ));
531    if !joint.channels.is_empty() {
532        let ch_names: Vec<&str> = joint.channels.iter().map(|c| c.as_str()).collect();
533        out.push_str(&format!(
534            "{indent}  CHANNELS {} {}\n",
535            joint.channels.len(),
536            ch_names.join(" ")
537        ));
538    }
539    if joint.children.is_empty() {
540        // End Site
541        out.push_str(&format!("{indent}  End Site\n"));
542        out.push_str(&format!("{indent}  {{\n"));
543        out.push_str(&format!("{indent}    OFFSET 0.00 0.00 0.00\n"));
544        out.push_str(&format!("{indent}  }}\n"));
545    } else {
546        for &child_idx in &joint.children {
547            write_joint_recursive(skel, child_idx, depth + 1, out);
548        }
549    }
550    out.push_str(&format!("{indent}}}\n"));
551}
552
553// ── Joint name mapping ────────────────────────────────────────────────────────
554
555/// Map common BVH joint names to OxiHuman skeleton joint names.
556///
557/// Returns a `HashMap<bvh_name, oxihuman_name>`.  Unmapped joints are not
558/// included in the result.
559#[allow(dead_code)]
560pub fn map_bvh_to_oxihuman(bvh: &BvhFile) -> HashMap<String, String> {
561    // Canonical mapping table (BVH name → OxiHuman name)
562    let table: &[(&str, &str)] = &[
563        // ── Spine / torso ──────────────────────────────────────────────────
564        ("Hips", "pelvis"),
565        ("Spine", "spine_01"),
566        ("Spine1", "spine_02"),
567        ("Spine2", "spine_03"),
568        ("Neck", "neck_01"),
569        ("Neck1", "neck_02"),
570        ("Head", "head"),
571        // ── Left arm ───────────────────────────────────────────────────────
572        ("LeftShoulder", "clavicle_l"),
573        ("LeftArm", "upperarm_l"),
574        ("LeftForeArm", "lowerarm_l"),
575        ("LeftHand", "hand_l"),
576        // ── Right arm ──────────────────────────────────────────────────────
577        ("RightShoulder", "clavicle_r"),
578        ("RightArm", "upperarm_r"),
579        ("RightForeArm", "lowerarm_r"),
580        ("RightHand", "hand_r"),
581        // ── Left leg ───────────────────────────────────────────────────────
582        ("LeftUpLeg", "thigh_l"),
583        ("LeftLeg", "calf_l"),
584        ("LeftFoot", "foot_l"),
585        ("LeftToeBase", "ball_l"),
586        // ── Right leg ──────────────────────────────────────────────────────
587        ("RightUpLeg", "thigh_r"),
588        ("RightLeg", "calf_r"),
589        ("RightFoot", "foot_r"),
590        ("RightToeBase", "ball_r"),
591        // ── Left fingers ───────────────────────────────────────────────────
592        ("LeftHandThumb1", "thumb_01_l"),
593        ("LeftHandThumb2", "thumb_02_l"),
594        ("LeftHandThumb3", "thumb_03_l"),
595        ("LeftHandIndex1", "index_01_l"),
596        ("LeftHandIndex2", "index_02_l"),
597        ("LeftHandIndex3", "index_03_l"),
598        ("LeftHandMiddle1", "middle_01_l"),
599        ("LeftHandMiddle2", "middle_02_l"),
600        ("LeftHandMiddle3", "middle_03_l"),
601        ("LeftHandRing1", "ring_01_l"),
602        ("LeftHandRing2", "ring_02_l"),
603        ("LeftHandRing3", "ring_03_l"),
604        ("LeftHandPinky1", "pinky_01_l"),
605        ("LeftHandPinky2", "pinky_02_l"),
606        ("LeftHandPinky3", "pinky_03_l"),
607        // ── Right fingers ──────────────────────────────────────────────────
608        ("RightHandThumb1", "thumb_01_r"),
609        ("RightHandThumb2", "thumb_02_r"),
610        ("RightHandThumb3", "thumb_03_r"),
611        ("RightHandIndex1", "index_01_r"),
612        ("RightHandIndex2", "index_02_r"),
613        ("RightHandIndex3", "index_03_r"),
614        ("RightHandMiddle1", "middle_01_r"),
615        ("RightHandMiddle2", "middle_02_r"),
616        ("RightHandMiddle3", "middle_03_r"),
617        ("RightHandRing1", "ring_01_r"),
618        ("RightHandRing2", "ring_02_r"),
619        ("RightHandRing3", "ring_03_r"),
620        ("RightHandPinky1", "pinky_01_r"),
621        ("RightHandPinky2", "pinky_02_r"),
622        ("RightHandPinky3", "pinky_03_r"),
623    ];
624
625    let lookup: HashMap<&str, &str> = table.iter().copied().collect();
626    let mut result = HashMap::new();
627
628    for joint in &bvh.skeleton.joints {
629        if let Some(&oxi_name) = lookup.get(joint.name.as_str()) {
630            result.insert(joint.name.clone(), oxi_name.to_string());
631        }
632    }
633
634    result
635}
636
637// ── Retargeting ───────────────────────────────────────────────────────────────
638
639/// Scale the translation channels in a frame by `scale`.
640///
641/// `translation_channels` is the number of leading channels in the frame
642/// that are translation channels (typically 3 for a 6-DOF root).
643/// Rotation channels are left unchanged.
644#[allow(dead_code)]
645pub fn retarget_scale(frame: &BvhFrame, scale: f32, translation_channels: usize) -> BvhFrame {
646    let values = frame
647        .values
648        .iter()
649        .enumerate()
650        .map(|(i, &v)| {
651            if i < translation_channels {
652                v * scale
653            } else {
654                v
655            }
656        })
657        .collect();
658    BvhFrame { values }
659}
660
661// ── Tests ─────────────────────────────────────────────────────────────────────
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    /// Minimal two-joint, two-frame BVH string used by many tests.
668    fn minimal_bvh() -> &'static str {
669        "HIERARCHY
670ROOT Hips
671{
672  OFFSET 0.00 0.00 0.00
673  CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation
674  JOINT Spine
675  {
676    OFFSET 0.00 5.21 0.00
677    CHANNELS 3 Zrotation Xrotation Yrotation
678    End Site
679    {
680      OFFSET 0.00 5.00 0.00
681    }
682  }
683}
684MOTION
685Frames: 2
686Frame Time: 0.033333
6870.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
6881.0 2.0 3.0 1.0 2.0 3.0 4.0 5.0 6.0
689"
690    }
691
692    // ── Test 1: successful parse ──────────────────────────────────────────
693    #[test]
694    fn test_parse_minimal_bvh() {
695        let bvh = parse_bvh(minimal_bvh()).expect("parse failed");
696        assert_eq!(bvh.skeleton.joint_count(), 2);
697        assert_eq!(bvh.frame_count(), 2);
698    }
699
700    // ── Test 2: joint names ───────────────────────────────────────────────
701    #[test]
702    fn test_joint_names() {
703        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
704        let names = bvh.skeleton.joint_names();
705        assert!(names.contains(&"Hips"));
706        assert!(names.contains(&"Spine"));
707    }
708
709    // ── Test 3: channel count ─────────────────────────────────────────────
710    #[test]
711    fn test_channel_count() {
712        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
713        // Hips: 6  +  Spine: 3  = 9
714        assert_eq!(bvh.skeleton.channel_count(), 9);
715    }
716
717    // ── Test 4: frame time and fps ────────────────────────────────────────
718    #[test]
719    fn test_fps() {
720        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
721        let fps = bvh.fps();
722        assert!((fps - 30.0).abs() < 0.5, "fps ≈ 30, got {fps}");
723    }
724
725    // ── Test 5: duration ──────────────────────────────────────────────────
726    #[test]
727    fn test_duration() {
728        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
729        let dur = bvh.duration_seconds();
730        assert!(dur > 0.0);
731        assert!((dur - 2.0 * 0.033333_f32).abs() < 1e-4);
732    }
733
734    // ── Test 6: root translation ──────────────────────────────────────────
735    #[test]
736    fn test_root_translation_frame0() {
737        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
738        let t = bvh.root_translation(0);
739        assert_eq!(t, [0.0, 0.0, 0.0]);
740    }
741
742    #[test]
743    fn test_root_translation_frame1() {
744        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
745        let t = bvh.root_translation(1);
746        assert_eq!(t, [1.0, 2.0, 3.0]);
747    }
748
749    // ── Test 7: joint rotation ────────────────────────────────────────────
750    #[test]
751    fn test_joint_rotation_spine_frame1() {
752        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
753        let spine_idx = bvh.skeleton.find_joint("Spine").expect("Spine not found");
754        let rot = bvh.joint_rotation(1, spine_idx);
755        // Frame 1 Spine channels: Zrotation=4, Xrotation=5, Yrotation=6
756        assert_eq!(rot[0], 5.0); // Xrotation
757        assert_eq!(rot[2], 4.0); // Zrotation  — note: returned as [rx, ry, rz]
758    }
759
760    // ── Test 8: interpolate_frame ─────────────────────────────────────────
761    #[test]
762    fn test_interpolate_frame_midpoint() {
763        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
764        let mid = bvh.interpolate_frame(0, 1, 0.5);
765        assert!((mid.values[0] - 0.5).abs() < 1e-5);
766        assert!((mid.values[1] - 1.0).abs() < 1e-5);
767    }
768
769    // ── Test 9: sample_at ────────────────────────────────────────────────
770    #[test]
771    fn test_sample_at_beginning() {
772        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
773        let f = bvh.sample_at(0.0);
774        assert_eq!(f.values, bvh.frames[0].values);
775    }
776
777    // ── Test 10: write_bvh round-trip ────────────────────────────────────
778    #[test]
779    fn test_write_bvh_round_trip() {
780        let original = parse_bvh(minimal_bvh()).expect("should succeed");
781        let text = write_bvh(&original);
782        let reparsed = parse_bvh(&text).expect("re-parse failed");
783        assert_eq!(
784            reparsed.skeleton.joint_count(),
785            original.skeleton.joint_count()
786        );
787        assert_eq!(reparsed.frame_count(), original.frame_count());
788    }
789
790    // ── Test 11: retarget_scale ───────────────────────────────────────────
791    #[test]
792    fn test_retarget_scale() {
793        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
794        let frame1 = bvh.frames[1].clone();
795        let scaled = retarget_scale(&frame1, 2.0, 3);
796        assert!((scaled.values[0] - 2.0).abs() < 1e-5); // 1.0 * 2
797        assert!((scaled.values[1] - 4.0).abs() < 1e-5); // 2.0 * 2
798        assert!((scaled.values[3] - 1.0).abs() < 1e-5); // rotation unchanged
799    }
800
801    // ── Test 12: map_bvh_to_oxihuman ─────────────────────────────────────
802    #[test]
803    fn test_map_bvh_to_oxihuman() {
804        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
805        let map = map_bvh_to_oxihuman(&bvh);
806        assert_eq!(map.get("Hips").map(|s| s.as_str()), Some("pelvis"));
807        assert_eq!(map.get("Spine").map(|s| s.as_str()), Some("spine_01"));
808    }
809
810    // ── Test 13: find_joint ───────────────────────────────────────────────
811    #[test]
812    fn test_find_joint() {
813        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
814        assert!(bvh.skeleton.find_joint("Hips").is_some());
815        assert!(bvh.skeleton.find_joint("DoesNotExist").is_none());
816    }
817
818    // ── Test 14: parent/children ──────────────────────────────────────────
819    #[test]
820    fn test_parent_children() {
821        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
822        let hips_idx = bvh.skeleton.find_joint("Hips").expect("should succeed");
823        let spine_idx = bvh.skeleton.find_joint("Spine").expect("should succeed");
824        assert_eq!(bvh.skeleton.parent_of(hips_idx), None);
825        assert_eq!(bvh.skeleton.parent_of(spine_idx), Some(hips_idx));
826        assert!(bvh.skeleton.children_of(hips_idx).contains(&spine_idx));
827    }
828
829    // ── Test 15: BvhChannel helpers ───────────────────────────────────────
830    #[test]
831    fn test_channel_helpers() {
832        assert!(BvhChannel::Xposition.is_translation());
833        assert!(!BvhChannel::Xposition.is_rotation());
834        assert!(BvhChannel::Yrotation.is_rotation());
835        assert!(!BvhChannel::Yrotation.is_translation());
836    }
837
838    // ── Test 16: write output to /tmp/ ────────────────────────────────────
839    #[test]
840    fn test_write_to_tmp() {
841        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
842        let text = write_bvh(&bvh);
843        std::fs::write("/tmp/test_mocap_bvh_output.bvh", &text)
844            .expect("failed to write /tmp/test_mocap_bvh_output.bvh");
845        assert!(text.contains("HIERARCHY"));
846        assert!(text.contains("MOTION"));
847    }
848
849    // ── Test 17: sample_at beyond last frame ──────────────────────────────
850    #[test]
851    fn test_sample_at_beyond_end() {
852        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
853        // 9999 seconds is way beyond the last frame — should return last frame values
854        let f = bvh.sample_at(9999.0);
855        assert_eq!(f.values.len(), bvh.frames[0].values.len());
856    }
857
858    // ── Test 18: get_channels helper ─────────────────────────────────────
859    #[test]
860    fn test_get_channels() {
861        let bvh = parse_bvh(minimal_bvh()).expect("should succeed");
862        let hips_idx = bvh.skeleton.find_joint("Hips").expect("should succeed");
863        let hips = &bvh.skeleton.joints[hips_idx];
864        let ch_offset = bvh.skeleton.channel_offset_for(hips_idx);
865        let frame0 = &bvh.frames[0];
866        let ch = frame0.get_channels(hips, ch_offset);
867        assert_eq!(ch.len(), 6);
868    }
869}