Skip to main content

oxiphysics_io/
animation_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Animation I/O: BVH motion capture, FBX ASCII, USD/USDA export, skeletal
5//! animation, blend shapes, timeline/sequencing, frame interpolation
6//! (including SLERP for rotations), and animation retargeting.
7//!
8//! All geometry is represented using plain `[f64; 3]` / `[f64; 4]` arrays
9//! (no nalgebra dependency).
10
11use std::collections::HashMap;
12
13// ── Tiny math helpers ──────────────────────────────────────────────────────
14
15/// Dot product of two 3-vectors.
16#[allow(dead_code)]
17fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
18    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
19}
20
21/// Add two 3-vectors.
22#[allow(dead_code)]
23fn vec3_add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
24    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
25}
26
27/// Subtract two 3-vectors.
28#[allow(dead_code)]
29fn vec3_sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
30    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
31}
32
33/// Scale a 3-vector.
34#[allow(dead_code)]
35fn vec3_scale(v: [f64; 3], s: f64) -> [f64; 3] {
36    [v[0] * s, v[1] * s, v[2] * s]
37}
38
39/// Linear interpolation for a 3-vector.
40#[allow(dead_code)]
41fn vec3_lerp(a: [f64; 3], b: [f64; 3], t: f64) -> [f64; 3] {
42    [
43        a[0] + (b[0] - a[0]) * t,
44        a[1] + (b[1] - a[1]) * t,
45        a[2] + (b[2] - a[2]) * t,
46    ]
47}
48
49/// Normalize a quaternion `[x, y, z, w]`.
50#[allow(dead_code)]
51fn quat_normalize(q: [f64; 4]) -> [f64; 4] {
52    let len2 = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3];
53    if len2 < 1e-30 {
54        return [0.0, 0.0, 0.0, 1.0];
55    }
56    let inv = 1.0 / len2.sqrt();
57    [q[0] * inv, q[1] * inv, q[2] * inv, q[3] * inv]
58}
59
60/// Quaternion dot product.
61#[allow(dead_code)]
62fn quat_dot(a: [f64; 4], b: [f64; 4]) -> f64 {
63    a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]
64}
65
66/// Multiply two quaternions (Hamilton product). Layout: `[x, y, z, w]`.
67#[allow(dead_code)]
68fn quat_mul(p: [f64; 4], q: [f64; 4]) -> [f64; 4] {
69    let [px, py, pz, pw] = p;
70    let [qx, qy, qz, qw] = q;
71    [
72        pw * qx + px * qw + py * qz - pz * qy,
73        pw * qy - px * qz + py * qw + pz * qx,
74        pw * qz + px * qy - py * qx + pz * qw,
75        pw * qw - px * qx - py * qy - pz * qz,
76    ]
77}
78
79/// Quaternion conjugate (inverse for unit quaternions).
80#[allow(dead_code)]
81fn quat_conjugate(q: [f64; 4]) -> [f64; 4] {
82    [-q[0], -q[1], -q[2], q[3]]
83}
84
85/// Euler XYZ (degrees) to quaternion `[x, y, z, w]`.
86#[allow(dead_code)]
87fn euler_xyz_to_quat(euler_deg: [f64; 3]) -> [f64; 4] {
88    let to_rad = std::f64::consts::PI / 180.0;
89    let (hx, hy, hz) = (
90        euler_deg[0] * to_rad * 0.5,
91        euler_deg[1] * to_rad * 0.5,
92        euler_deg[2] * to_rad * 0.5,
93    );
94    let (sx, cx) = (hx.sin(), hx.cos());
95    let (sy, cy) = (hy.sin(), hy.cos());
96    let (sz, cz) = (hz.sin(), hz.cos());
97    quat_normalize([
98        sx * cy * cz + cx * sy * sz,
99        cx * sy * cz - sx * cy * sz,
100        cx * cy * sz + sx * sy * cz,
101        cx * cy * cz - sx * sy * sz,
102    ])
103}
104
105/// Quaternion to Euler XYZ (degrees).
106#[allow(dead_code)]
107fn quat_to_euler_xyz(q: [f64; 4]) -> [f64; 3] {
108    let [x, y, z, w] = q;
109    let to_deg = 180.0 / std::f64::consts::PI;
110    let sinr_cosp = 2.0 * (w * x + y * z);
111    let cosr_cosp = 1.0 - 2.0 * (x * x + y * y);
112    let rx = sinr_cosp.atan2(cosr_cosp);
113    let sinp = 2.0 * (w * y - z * x);
114    let ry = if sinp.abs() >= 1.0 {
115        std::f64::consts::FRAC_PI_2.copysign(sinp)
116    } else {
117        sinp.asin()
118    };
119    let siny_cosp = 2.0 * (w * z + x * y);
120    let cosy_cosp = 1.0 - 2.0 * (y * y + z * z);
121    let rz = siny_cosp.atan2(cosy_cosp);
122    [rx * to_deg, ry * to_deg, rz * to_deg]
123}
124
125// ═══════════════════════════════════════════════════════════════════════════════
126// SLERP – Spherical Linear Interpolation
127// ═══════════════════════════════════════════════════════════════════════════════
128
129/// Spherical linear interpolation between two unit quaternions.
130///
131/// Handles the short-arc selection and falls back to linear interpolation
132/// when the quaternions are nearly parallel.
133#[allow(dead_code)]
134pub fn quat_slerp(a: [f64; 4], b: [f64; 4], t: f64) -> [f64; 4] {
135    let mut dot = quat_dot(a, b);
136    let b = if dot < 0.0 {
137        dot = -dot;
138        [-b[0], -b[1], -b[2], -b[3]]
139    } else {
140        b
141    };
142    if dot > 0.9995 {
143        // Nearly parallel – use linear interpolation
144        return quat_normalize([
145            a[0] + (b[0] - a[0]) * t,
146            a[1] + (b[1] - a[1]) * t,
147            a[2] + (b[2] - a[2]) * t,
148            a[3] + (b[3] - a[3]) * t,
149        ]);
150    }
151    let theta = dot.clamp(-1.0, 1.0).acos();
152    let sin_theta = theta.sin();
153    if sin_theta.abs() < 1e-15 {
154        return a;
155    }
156    let wa = ((1.0 - t) * theta).sin() / sin_theta;
157    let wb = (t * theta).sin() / sin_theta;
158    quat_normalize([
159        wa * a[0] + wb * b[0],
160        wa * a[1] + wb * b[1],
161        wa * a[2] + wb * b[2],
162        wa * a[3] + wb * b[3],
163    ])
164}
165
166// ═══════════════════════════════════════════════════════════════════════════════
167// Joint & skeleton hierarchy
168// ═══════════════════════════════════════════════════════════════════════════════
169
170/// A joint in a skeletal hierarchy.
171#[derive(Debug, Clone)]
172pub struct Joint {
173    /// Joint name.
174    pub name: String,
175    /// Index of the parent joint, or `None` for root.
176    pub parent: Option<usize>,
177    /// Rest-pose local offset from parent.
178    pub offset: [f64; 3],
179    /// Channel names (e.g. "Xposition", "Yposition", "Zrotation").
180    pub channels: Vec<String>,
181}
182
183/// A full skeleton definition.
184#[derive(Debug, Clone)]
185pub struct Skeleton {
186    /// Ordered list of joints (index 0 is typically the root).
187    pub joints: Vec<Joint>,
188    /// Map from joint name to index.
189    pub name_to_index: HashMap<String, usize>,
190}
191
192impl Skeleton {
193    /// Create an empty skeleton.
194    #[allow(dead_code)]
195    pub fn new() -> Self {
196        Self {
197            joints: Vec::new(),
198            name_to_index: HashMap::new(),
199        }
200    }
201
202    /// Add a joint and return its index.
203    #[allow(dead_code)]
204    pub fn add_joint(&mut self, name: &str, parent: Option<usize>, offset: [f64; 3]) -> usize {
205        let idx = self.joints.len();
206        self.name_to_index.insert(name.to_string(), idx);
207        self.joints.push(Joint {
208            name: name.to_string(),
209            parent,
210            offset,
211            channels: Vec::new(),
212        });
213        idx
214    }
215
216    /// Add channels to a joint.
217    #[allow(dead_code)]
218    pub fn set_channels(&mut self, joint_idx: usize, channels: Vec<String>) {
219        if let Some(j) = self.joints.get_mut(joint_idx) {
220            j.channels = channels;
221        }
222    }
223
224    /// Number of joints.
225    #[allow(dead_code)]
226    pub fn num_joints(&self) -> usize {
227        self.joints.len()
228    }
229
230    /// Get the children of a joint.
231    #[allow(dead_code)]
232    pub fn children(&self, joint_idx: usize) -> Vec<usize> {
233        self.joints
234            .iter()
235            .enumerate()
236            .filter(|(_, j)| j.parent == Some(joint_idx))
237            .map(|(i, _)| i)
238            .collect()
239    }
240
241    /// Find a joint by name.
242    #[allow(dead_code)]
243    pub fn find_joint(&self, name: &str) -> Option<usize> {
244        self.name_to_index.get(name).copied()
245    }
246
247    /// Total number of channels across all joints.
248    #[allow(dead_code)]
249    pub fn total_channels(&self) -> usize {
250        self.joints.iter().map(|j| j.channels.len()).sum()
251    }
252}
253
254impl Default for Skeleton {
255    fn default() -> Self {
256        Self::new()
257    }
258}
259
260// ═══════════════════════════════════════════════════════════════════════════════
261// Keyframe & animation clip
262// ═══════════════════════════════════════════════════════════════════════════════
263
264/// A single keyframe for one joint.
265#[derive(Debug, Clone, Copy)]
266pub struct JointPose {
267    /// Local translation.
268    pub translation: [f64; 3],
269    /// Local rotation as quaternion `[x, y, z, w]`.
270    pub rotation: [f64; 4],
271    /// Local scale.
272    pub scale: [f64; 3],
273}
274
275impl JointPose {
276    /// Identity pose.
277    #[allow(dead_code)]
278    pub fn identity() -> Self {
279        Self {
280            translation: [0.0; 3],
281            rotation: [0.0, 0.0, 0.0, 1.0],
282            scale: [1.0, 1.0, 1.0],
283        }
284    }
285
286    /// Interpolate between two poses.
287    #[allow(dead_code)]
288    pub fn lerp(&self, other: &JointPose, t: f64) -> JointPose {
289        JointPose {
290            translation: vec3_lerp(self.translation, other.translation, t),
291            rotation: quat_slerp(self.rotation, other.rotation, t),
292            scale: vec3_lerp(self.scale, other.scale, t),
293        }
294    }
295}
296
297/// A single frame of a full skeleton pose.
298#[derive(Debug, Clone)]
299pub struct SkeletonPose {
300    /// Per-joint local poses (indexed by joint index).
301    pub joint_poses: Vec<JointPose>,
302}
303
304impl SkeletonPose {
305    /// Create a default pose for a skeleton.
306    #[allow(dead_code)]
307    pub fn default_for(skeleton: &Skeleton) -> Self {
308        let n = skeleton.num_joints();
309        Self {
310            joint_poses: vec![JointPose::identity(); n],
311        }
312    }
313
314    /// Interpolate between two skeleton poses.
315    #[allow(dead_code)]
316    pub fn lerp(&self, other: &SkeletonPose, t: f64) -> SkeletonPose {
317        let poses: Vec<JointPose> = self
318            .joint_poses
319            .iter()
320            .zip(&other.joint_poses)
321            .map(|(a, b)| a.lerp(b, t))
322            .collect();
323        SkeletonPose { joint_poses: poses }
324    }
325}
326
327/// An animation clip: a sequence of timed skeleton poses.
328#[derive(Debug, Clone)]
329pub struct AnimationClip {
330    /// Name of the clip.
331    pub name: String,
332    /// Frame rate (frames per second).
333    pub fps: f64,
334    /// Frame timestamps.
335    pub times: Vec<f64>,
336    /// Per-frame skeleton poses.
337    pub frames: Vec<SkeletonPose>,
338}
339
340impl AnimationClip {
341    /// Create an empty clip.
342    #[allow(dead_code)]
343    pub fn new(name: &str, fps: f64) -> Self {
344        Self {
345            name: name.to_string(),
346            fps,
347            times: Vec::new(),
348            frames: Vec::new(),
349        }
350    }
351
352    /// Add a frame.
353    #[allow(dead_code)]
354    pub fn add_frame(&mut self, time: f64, pose: SkeletonPose) {
355        self.times.push(time);
356        self.frames.push(pose);
357    }
358
359    /// Duration of the clip.
360    #[allow(dead_code)]
361    pub fn duration(&self) -> f64 {
362        if self.times.is_empty() {
363            return 0.0;
364        }
365        self.times[self.times.len() - 1] - self.times[0]
366    }
367
368    /// Number of frames.
369    #[allow(dead_code)]
370    pub fn num_frames(&self) -> usize {
371        self.frames.len()
372    }
373
374    /// Sample the animation at a given time using linear interpolation
375    /// (with SLERP for rotations).
376    #[allow(dead_code)]
377    pub fn sample(&self, time: f64) -> Option<SkeletonPose> {
378        if self.frames.is_empty() {
379            return None;
380        }
381        if self.frames.len() == 1 || time <= self.times[0] {
382            return Some(self.frames[0].clone());
383        }
384        let last = self.times.len() - 1;
385        if time >= self.times[last] {
386            return Some(self.frames[last].clone());
387        }
388        // Binary search for the interval
389        let idx = match self
390            .times
391            .binary_search_by(|t| t.partial_cmp(&time).unwrap_or(std::cmp::Ordering::Equal))
392        {
393            Ok(i) => return Some(self.frames[i].clone()),
394            Err(i) => i,
395        };
396        let i0 = idx - 1;
397        let i1 = idx;
398        let dt = self.times[i1] - self.times[i0];
399        let t = if dt > 1e-15 {
400            (time - self.times[i0]) / dt
401        } else {
402            0.0
403        };
404        Some(self.frames[i0].lerp(&self.frames[i1], t))
405    }
406}
407
408// ═══════════════════════════════════════════════════════════════════════════════
409// BVH motion capture parser
410// ═══════════════════════════════════════════════════════════════════════════════
411
412/// Parsed BVH motion capture data.
413#[derive(Debug, Clone)]
414pub struct BvhData {
415    /// Skeleton hierarchy.
416    pub skeleton: Skeleton,
417    /// Frame rate (seconds per frame).
418    pub frame_time: f64,
419    /// Motion data: outer = frames, inner = channel values.
420    pub motion: Vec<Vec<f64>>,
421}
422
423/// Parse BVH format text into a `BvhData` structure.
424#[allow(dead_code)]
425pub fn parse_bvh(input: &str) -> Result<BvhData, String> {
426    let mut skeleton = Skeleton::new();
427    let lines = input.lines().map(|l| l.trim()).peekable();
428    let mut parent_stack: Vec<Option<usize>> = vec![None];
429    let mut frame_time = 1.0 / 30.0;
430    let mut motion: Vec<Vec<f64>> = Vec::new();
431    let mut in_motion = false;
432    let mut _num_frames: usize = 0;
433
434    for line in lines {
435        if line.is_empty() {
436            continue;
437        }
438        if in_motion {
439            let vals: Result<Vec<f64>, _> =
440                line.split_whitespace().map(|s| s.parse::<f64>()).collect();
441            match vals {
442                Ok(v) if !v.is_empty() => motion.push(v),
443                _ => {}
444            }
445            continue;
446        }
447
448        let tokens: Vec<&str> = line.split_whitespace().collect();
449        if tokens.is_empty() {
450            continue;
451        }
452
453        match tokens[0] {
454            "HIERARCHY" => {}
455            "ROOT" | "JOINT" => {
456                let name = if tokens.len() > 1 {
457                    tokens[1]
458                } else {
459                    "unnamed"
460                };
461                let parent = *parent_stack.last().unwrap_or(&None);
462                let idx = skeleton.add_joint(name, parent, [0.0; 3]);
463                parent_stack.push(Some(idx));
464            }
465            "End" => {
466                // End site – add as a leaf
467                let parent = *parent_stack.last().unwrap_or(&None);
468                let name = format!("EndSite_{}", skeleton.num_joints());
469                let idx = skeleton.add_joint(&name, parent, [0.0; 3]);
470                parent_stack.push(Some(idx));
471            }
472            "OFFSET"
473                if tokens.len() >= 4 => {
474                    let ox: f64 = tokens[1].parse().unwrap_or(0.0);
475                    let oy: f64 = tokens[2].parse().unwrap_or(0.0);
476                    let oz: f64 = tokens[3].parse().unwrap_or(0.0);
477                    if let Some(&Some(idx)) = parent_stack.last()
478                        && let Some(j) = skeleton.joints.get_mut(idx) {
479                            j.offset = [ox, oy, oz];
480                        }
481                }
482            "CHANNELS"
483                if tokens.len() >= 2 => {
484                    let _n: usize = tokens[1].parse().unwrap_or(0);
485                    let channels: Vec<String> = tokens[2..].iter().map(|s| s.to_string()).collect();
486                    if let Some(&Some(idx)) = parent_stack.last() {
487                        skeleton.set_channels(idx, channels);
488                    }
489                }
490            "{" => {}
491            "}" => {
492                parent_stack.pop();
493            }
494            "MOTION" => {
495                in_motion = true;
496            }
497            "Frames:"
498                if tokens.len() >= 2 => {
499                    _num_frames = tokens[1].parse().unwrap_or(0);
500                }
501            "Frame"
502                // "Frame Time: 0.033333"
503                if tokens.len() >= 3 => {
504                    frame_time = tokens[2].parse().unwrap_or(1.0 / 30.0);
505                }
506            _ => {}
507        }
508    }
509
510    Ok(BvhData {
511        skeleton,
512        frame_time,
513        motion,
514    })
515}
516
517/// Convert BVH channel data for one frame to a `SkeletonPose`.
518#[allow(dead_code)]
519pub fn bvh_frame_to_pose(skeleton: &Skeleton, channels: &[f64]) -> SkeletonPose {
520    let mut pose = SkeletonPose::default_for(skeleton);
521    let mut ch_idx = 0;
522
523    for (j_idx, joint) in skeleton.joints.iter().enumerate() {
524        let mut trans = joint.offset;
525        let mut euler = [0.0; 3];
526
527        for ch_name in &joint.channels {
528            if ch_idx >= channels.len() {
529                break;
530            }
531            let val = channels[ch_idx];
532            ch_idx += 1;
533            match ch_name.as_str() {
534                "Xposition" => trans[0] += val,
535                "Yposition" => trans[1] += val,
536                "Zposition" => trans[2] += val,
537                "Xrotation" => euler[0] = val,
538                "Yrotation" => euler[1] = val,
539                "Zrotation" => euler[2] = val,
540                _ => {}
541            }
542        }
543
544        let rot = euler_xyz_to_quat(euler);
545        pose.joint_poses[j_idx] = JointPose {
546            translation: trans,
547            rotation: rot,
548            scale: [1.0, 1.0, 1.0],
549        };
550    }
551
552    pose
553}
554
555/// Convert BVH data to an `AnimationClip`.
556#[allow(dead_code)]
557pub fn bvh_to_clip(bvh: &BvhData) -> AnimationClip {
558    let fps = if bvh.frame_time > 1e-15 {
559        1.0 / bvh.frame_time
560    } else {
561        30.0
562    };
563    let mut clip = AnimationClip::new("bvh_clip", fps);
564    for (i, frame_data) in bvh.motion.iter().enumerate() {
565        let time = i as f64 * bvh.frame_time;
566        let pose = bvh_frame_to_pose(&bvh.skeleton, frame_data);
567        clip.add_frame(time, pose);
568    }
569    clip
570}
571
572/// Export BVH data to string.
573#[allow(dead_code)]
574pub fn export_bvh(skeleton: &Skeleton, clip: &AnimationClip) -> String {
575    let mut out = String::new();
576    out.push_str("HIERARCHY\n");
577
578    fn write_joint(out: &mut String, skeleton: &Skeleton, idx: usize, indent: usize) {
579        let joint = &skeleton.joints[idx];
580        let prefix = " ".repeat(indent);
581        let keyword = if joint.parent.is_none() {
582            "ROOT"
583        } else {
584            "JOINT"
585        };
586        out.push_str(&format!("{}{} {}\n", prefix, keyword, joint.name));
587        out.push_str(&format!("{}{{\n", prefix));
588        out.push_str(&format!(
589            "{}  OFFSET {:.6} {:.6} {:.6}\n",
590            prefix, joint.offset[0], joint.offset[1], joint.offset[2]
591        ));
592        if !joint.channels.is_empty() {
593            out.push_str(&format!(
594                "{}  CHANNELS {} {}\n",
595                prefix,
596                joint.channels.len(),
597                joint.channels.join(" ")
598            ));
599        }
600        let children = skeleton.children(idx);
601        if children.is_empty() {
602            out.push_str(&format!("{}  End Site\n", prefix));
603            out.push_str(&format!("{}  {{\n", prefix));
604            out.push_str(&format!(
605                "{}    OFFSET 0.000000 0.000000 0.000000\n",
606                prefix
607            ));
608            out.push_str(&format!("{}  }}\n", prefix));
609        } else {
610            for &child in &children {
611                write_joint(out, skeleton, child, indent + 2);
612            }
613        }
614        out.push_str(&format!("{}}}\n", prefix));
615    }
616
617    // Find roots (joints with no parent)
618    for (i, j) in skeleton.joints.iter().enumerate() {
619        if j.parent.is_none() {
620            write_joint(&mut out, skeleton, i, 0);
621        }
622    }
623
624    out.push_str("MOTION\n");
625    out.push_str(&format!("Frames: {}\n", clip.num_frames()));
626    let frame_time = if clip.fps > 0.0 {
627        1.0 / clip.fps
628    } else {
629        1.0 / 30.0
630    };
631    out.push_str(&format!("Frame Time: {:.6}\n", frame_time));
632
633    for frame in &clip.frames {
634        let mut vals = Vec::new();
635        for (j_idx, joint) in skeleton.joints.iter().enumerate() {
636            let pose = &frame.joint_poses[j_idx];
637            for ch in &joint.channels {
638                match ch.as_str() {
639                    "Xposition" => vals.push(pose.translation[0]),
640                    "Yposition" => vals.push(pose.translation[1]),
641                    "Zposition" => vals.push(pose.translation[2]),
642                    "Xrotation" | "Yrotation" | "Zrotation" => {
643                        let euler = quat_to_euler_xyz(pose.rotation);
644                        match ch.as_str() {
645                            "Xrotation" => vals.push(euler[0]),
646                            "Yrotation" => vals.push(euler[1]),
647                            _ => vals.push(euler[2]),
648                        }
649                    }
650                    _ => vals.push(0.0),
651                }
652            }
653        }
654        let line: Vec<String> = vals.iter().map(|v| format!("{:.6}", v)).collect();
655        out.push_str(&line.join(" "));
656        out.push('\n');
657    }
658
659    out
660}
661
662// ═══════════════════════════════════════════════════════════════════════════════
663// FBX ASCII (basic)
664// ═══════════════════════════════════════════════════════════════════════════════
665
666/// A basic FBX ASCII node.
667#[derive(Debug, Clone)]
668pub struct FbxNode {
669    /// Node name.
670    pub name: String,
671    /// Properties (string values).
672    pub properties: Vec<String>,
673    /// Child nodes.
674    pub children: Vec<FbxNode>,
675}
676
677impl FbxNode {
678    /// Create a new FBX node.
679    #[allow(dead_code)]
680    pub fn new(name: &str) -> Self {
681        Self {
682            name: name.to_string(),
683            properties: Vec::new(),
684            children: Vec::new(),
685        }
686    }
687
688    /// Add a property.
689    #[allow(dead_code)]
690    pub fn add_property(&mut self, value: &str) {
691        self.properties.push(value.to_string());
692    }
693
694    /// Add a child node.
695    #[allow(dead_code)]
696    pub fn add_child(&mut self, child: FbxNode) {
697        self.children.push(child);
698    }
699
700    /// Serialize to FBX ASCII text.
701    #[allow(dead_code)]
702    pub fn to_ascii(&self, indent: usize) -> String {
703        let prefix = "  ".repeat(indent);
704        let mut out = format!("{}{}: ", prefix, self.name);
705        out.push_str(&self.properties.join(", "));
706        if self.children.is_empty() {
707            out.push_str(" {\n");
708            out.push_str(&format!("{}}}\n", prefix));
709        } else {
710            out.push_str(" {\n");
711            for child in &self.children {
712                out.push_str(&child.to_ascii(indent + 1));
713            }
714            out.push_str(&format!("{}}}\n", prefix));
715        }
716        out
717    }
718
719    /// Find a child by name.
720    #[allow(dead_code)]
721    pub fn find_child(&self, name: &str) -> Option<&FbxNode> {
722        self.children.iter().find(|c| c.name == name)
723    }
724}
725
726/// Parse a very basic FBX ASCII string.
727///
728/// This parser handles the top-level structure of FBX ASCII files. It is
729/// not a full FBX parser but sufficient for extracting skeleton and
730/// animation data from simple scenes.
731#[allow(dead_code)]
732pub fn parse_fbx_ascii(input: &str) -> Result<Vec<FbxNode>, String> {
733    let mut nodes = Vec::new();
734    let mut stack: Vec<FbxNode> = Vec::new();
735
736    for line in input.lines() {
737        let trimmed = line.trim();
738        if trimmed.is_empty() || trimmed.starts_with(';') {
739            continue;
740        }
741        if trimmed == "}" {
742            if let Some(node) = stack.pop() {
743                if let Some(parent) = stack.last_mut() {
744                    parent.add_child(node);
745                } else {
746                    nodes.push(node);
747                }
748            }
749            continue;
750        }
751        if let Some(colon_pos) = trimmed.find(':') {
752            let name = trimmed[..colon_pos].trim();
753            let rest = trimmed[colon_pos + 1..].trim();
754            let mut node = FbxNode::new(name);
755            // Parse properties (before '{')
756            let props_str = if let Some(brace) = rest.find('{') {
757                rest[..brace].trim()
758            } else {
759                rest
760            };
761            if !props_str.is_empty() {
762                for prop in props_str.split(',') {
763                    let p = prop.trim().trim_matches('"');
764                    if !p.is_empty() {
765                        node.add_property(p);
766                    }
767                }
768            }
769            if rest.contains('{') {
770                stack.push(node);
771            } else {
772                if let Some(parent) = stack.last_mut() {
773                    parent.add_child(node);
774                } else {
775                    nodes.push(node);
776                }
777            }
778        }
779    }
780
781    // Drain any unclosed nodes
782    while let Some(node) = stack.pop() {
783        if let Some(parent) = stack.last_mut() {
784            parent.add_child(node);
785        } else {
786            nodes.push(node);
787        }
788    }
789
790    Ok(nodes)
791}
792
793/// Export a skeleton to basic FBX ASCII format.
794#[allow(dead_code)]
795pub fn export_fbx_ascii_skeleton(skeleton: &Skeleton) -> String {
796    let mut root = FbxNode::new("FBXHeaderExtension");
797    let mut version = FbxNode::new("FBXVersion");
798    version.add_property("7400");
799    root.add_child(version);
800
801    let mut objects = FbxNode::new("Objects");
802    for (i, joint) in skeleton.joints.iter().enumerate() {
803        let mut model = FbxNode::new("Model");
804        model.add_property(&format!("{}", i));
805        model.add_property(&format!("\"Model::{}\"", joint.name));
806        model.add_property("\"LimbNode\"");
807
808        let mut props = FbxNode::new("Properties70");
809        let mut lct = FbxNode::new("P");
810        lct.add_property("\"Lcl Translation\"");
811        lct.add_property(&format!("{:.6}", joint.offset[0]));
812        lct.add_property(&format!("{:.6}", joint.offset[1]));
813        lct.add_property(&format!("{:.6}", joint.offset[2]));
814        props.add_child(lct);
815        model.add_child(props);
816        objects.add_child(model);
817    }
818
819    let mut out = root.to_ascii(0);
820    out.push_str(&objects.to_ascii(0));
821    out
822}
823
824// ═══════════════════════════════════════════════════════════════════════════════
825// USD/USDA export
826// ═══════════════════════════════════════════════════════════════════════════════
827
828/// Export a skeleton and animation clip to USDA (ASCII USD) format.
829#[allow(dead_code)]
830pub fn export_usda(skeleton: &Skeleton, clip: &AnimationClip, stage_name: &str) -> String {
831    let mut out = String::new();
832    out.push_str("#usda 1.0\n");
833    out.push_str(&format!("(\n    defaultPrim = \"{}\"\n)\n\n", stage_name));
834
835    // Skeleton definition
836    out.push_str(&format!("def Xform \"{}\" {{\n", stage_name));
837    out.push_str("    def Skeleton \"Skeleton\" {\n");
838
839    // Joint paths
840    let joint_paths: Vec<String> = skeleton
841        .joints
842        .iter()
843        .map(|j| format!("\"{}\"", j.name))
844        .collect();
845    out.push_str(&format!(
846        "        uniform token[] joints = [{}]\n",
847        joint_paths.join(", ")
848    ));
849
850    // Bind transforms
851    out.push_str("        uniform matrix4d[] bindTransforms = [\n");
852    for joint in &skeleton.joints {
853        out.push_str(&format!(
854            "            (({:.6}, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), ({:.6}, {:.6}, {:.6}, 1)),\n",
855            1.0, joint.offset[0], joint.offset[1], joint.offset[2]
856        ));
857    }
858    out.push_str("        ]\n");
859    out.push_str("    }\n");
860
861    // Animation
862    if !clip.frames.is_empty() {
863        out.push_str("    def SkelAnimation \"Animation\" {\n");
864        out.push_str(&format!(
865            "        uniform token[] joints = [{}]\n",
866            joint_paths.join(", ")
867        ));
868
869        // Write per-frame translations and rotations
870        for (f_idx, frame) in clip.frames.iter().enumerate() {
871            let time = if f_idx < clip.times.len() {
872                clip.times[f_idx]
873            } else {
874                f_idx as f64 / clip.fps
875            };
876            out.push_str(&format!(
877                "        float3[] translations.timeSamples[{:.6}] = [\n",
878                time
879            ));
880            for pose in &frame.joint_poses {
881                out.push_str(&format!(
882                    "            ({:.6}, {:.6}, {:.6}),\n",
883                    pose.translation[0], pose.translation[1], pose.translation[2]
884                ));
885            }
886            out.push_str("        ]\n");
887
888            out.push_str(&format!(
889                "        quatf[] rotations.timeSamples[{:.6}] = [\n",
890                time
891            ));
892            for pose in &frame.joint_poses {
893                let [x, y, z, w] = pose.rotation;
894                out.push_str(&format!(
895                    "            ({:.6}, {:.6}, {:.6}, {:.6}),\n",
896                    w, x, y, z
897                ));
898            }
899            out.push_str("        ]\n");
900        }
901        out.push_str("    }\n");
902    }
903
904    out.push_str("}\n");
905    out
906}
907
908// ═══════════════════════════════════════════════════════════════════════════════
909// Blend shapes
910// ═══════════════════════════════════════════════════════════════════════════════
911
912/// A blend shape (morph target) definition.
913#[derive(Debug, Clone)]
914pub struct BlendShape {
915    /// Name of the blend shape.
916    pub name: String,
917    /// Vertex deltas: `(vertex_index, delta_position)`.
918    pub deltas: Vec<(usize, [f64; 3])>,
919}
920
921impl BlendShape {
922    /// Create a new blend shape.
923    #[allow(dead_code)]
924    pub fn new(name: &str) -> Self {
925        Self {
926            name: name.to_string(),
927            deltas: Vec::new(),
928        }
929    }
930
931    /// Add a vertex delta.
932    #[allow(dead_code)]
933    pub fn add_delta(&mut self, vertex_idx: usize, delta: [f64; 3]) {
934        self.deltas.push((vertex_idx, delta));
935    }
936}
937
938/// A blend shape set with named targets and weights.
939#[derive(Debug, Clone)]
940pub struct BlendShapeSet {
941    /// The blend shapes.
942    pub shapes: Vec<BlendShape>,
943    /// Current weights for each shape.
944    pub weights: Vec<f64>,
945}
946
947impl BlendShapeSet {
948    /// Create a new empty blend shape set.
949    #[allow(dead_code)]
950    pub fn new() -> Self {
951        Self {
952            shapes: Vec::new(),
953            weights: Vec::new(),
954        }
955    }
956
957    /// Add a blend shape and return its index.
958    #[allow(dead_code)]
959    pub fn add_shape(&mut self, shape: BlendShape) -> usize {
960        let idx = self.shapes.len();
961        self.shapes.push(shape);
962        self.weights.push(0.0);
963        idx
964    }
965
966    /// Set the weight for a blend shape.
967    #[allow(dead_code)]
968    pub fn set_weight(&mut self, idx: usize, weight: f64) {
969        if idx < self.weights.len() {
970            self.weights[idx] = weight;
971        }
972    }
973
974    /// Evaluate the blend shapes and return the accumulated deltas per vertex.
975    ///
976    /// The result maps vertex_index to accumulated delta.
977    #[allow(dead_code)]
978    pub fn evaluate(&self) -> HashMap<usize, [f64; 3]> {
979        let mut result: HashMap<usize, [f64; 3]> = HashMap::new();
980        for (shape, &weight) in self.shapes.iter().zip(&self.weights) {
981            if weight.abs() < 1e-15 {
982                continue;
983            }
984            for &(vi, delta) in &shape.deltas {
985                let entry = result.entry(vi).or_insert([0.0; 3]);
986                *entry = vec3_add(*entry, vec3_scale(delta, weight));
987            }
988        }
989        result
990    }
991
992    /// Interpolate weights between two sets of weights.
993    #[allow(dead_code)]
994    pub fn interpolate_weights(a: &[f64], b: &[f64], t: f64) -> Vec<f64> {
995        a.iter()
996            .zip(b.iter())
997            .map(|(&wa, &wb)| wa + (wb - wa) * t)
998            .collect()
999    }
1000}
1001
1002impl Default for BlendShapeSet {
1003    fn default() -> Self {
1004        Self::new()
1005    }
1006}
1007
1008// ═══════════════════════════════════════════════════════════════════════════════
1009// Timeline / Sequence
1010// ═══════════════════════════════════════════════════════════════════════════════
1011
1012/// An event on the animation timeline.
1013#[derive(Debug, Clone)]
1014pub struct TimelineEvent {
1015    /// Time of the event.
1016    pub time: f64,
1017    /// Event name / type.
1018    pub name: String,
1019    /// Event payload (arbitrary string data).
1020    pub payload: String,
1021}
1022
1023/// A timeline track.
1024#[derive(Debug, Clone)]
1025pub struct TimelineTrack {
1026    /// Track name.
1027    pub name: String,
1028    /// Start time.
1029    pub start: f64,
1030    /// End time.
1031    pub end: f64,
1032    /// Events on this track.
1033    pub events: Vec<TimelineEvent>,
1034    /// Associated animation clip name.
1035    pub clip_name: Option<String>,
1036}
1037
1038impl TimelineTrack {
1039    /// Create a new track.
1040    #[allow(dead_code)]
1041    pub fn new(name: &str, start: f64, end: f64) -> Self {
1042        Self {
1043            name: name.to_string(),
1044            start,
1045            end,
1046            events: Vec::new(),
1047            clip_name: None,
1048        }
1049    }
1050
1051    /// Duration of the track.
1052    #[allow(dead_code)]
1053    pub fn duration(&self) -> f64 {
1054        self.end - self.start
1055    }
1056
1057    /// Add an event.
1058    #[allow(dead_code)]
1059    pub fn add_event(&mut self, time: f64, name: &str, payload: &str) {
1060        self.events.push(TimelineEvent {
1061            time,
1062            name: name.to_string(),
1063            payload: payload.to_string(),
1064        });
1065    }
1066
1067    /// Get events at or near a given time.
1068    #[allow(dead_code)]
1069    pub fn events_at(&self, time: f64, tolerance: f64) -> Vec<&TimelineEvent> {
1070        self.events
1071            .iter()
1072            .filter(|e| (e.time - time).abs() < tolerance)
1073            .collect()
1074    }
1075}
1076
1077/// A full animation timeline with multiple tracks.
1078#[derive(Debug, Clone)]
1079pub struct Timeline {
1080    /// Tracks in the timeline.
1081    pub tracks: Vec<TimelineTrack>,
1082    /// Global start time.
1083    pub start: f64,
1084    /// Global end time.
1085    pub end: f64,
1086    /// Frames per second.
1087    pub fps: f64,
1088}
1089
1090impl Timeline {
1091    /// Create a new timeline.
1092    #[allow(dead_code)]
1093    pub fn new(start: f64, end: f64, fps: f64) -> Self {
1094        Self {
1095            tracks: Vec::new(),
1096            start,
1097            end,
1098            fps,
1099        }
1100    }
1101
1102    /// Add a track.
1103    #[allow(dead_code)]
1104    pub fn add_track(&mut self, track: TimelineTrack) {
1105        self.tracks.push(track);
1106    }
1107
1108    /// Duration.
1109    #[allow(dead_code)]
1110    pub fn duration(&self) -> f64 {
1111        self.end - self.start
1112    }
1113
1114    /// Total number of frames.
1115    #[allow(dead_code)]
1116    pub fn total_frames(&self) -> usize {
1117        ((self.end - self.start) * self.fps).ceil() as usize
1118    }
1119
1120    /// Get the time for a given frame index.
1121    #[allow(dead_code)]
1122    pub fn frame_time(&self, frame: usize) -> f64 {
1123        self.start + (frame as f64) / self.fps
1124    }
1125}
1126
1127// ═══════════════════════════════════════════════════════════════════════════════
1128// Animation retargeting
1129// ═══════════════════════════════════════════════════════════════════════════════
1130
1131/// A mapping between source and target skeleton joints.
1132#[derive(Debug, Clone)]
1133pub struct RetargetMapping {
1134    /// Maps source joint index to target joint index.
1135    pub joint_map: HashMap<usize, usize>,
1136    /// Scale factor for translations.
1137    pub scale: f64,
1138    /// Per-joint rotation offset (applied after retargeting).
1139    pub rotation_offsets: HashMap<usize, [f64; 4]>,
1140}
1141
1142impl RetargetMapping {
1143    /// Create a new mapping with a given scale.
1144    #[allow(dead_code)]
1145    pub fn new(scale: f64) -> Self {
1146        Self {
1147            joint_map: HashMap::new(),
1148            scale,
1149            rotation_offsets: HashMap::new(),
1150        }
1151    }
1152
1153    /// Add a joint mapping.
1154    #[allow(dead_code)]
1155    pub fn map_joint(&mut self, source_idx: usize, target_idx: usize) {
1156        self.joint_map.insert(source_idx, target_idx);
1157    }
1158
1159    /// Add a rotation offset for a target joint.
1160    #[allow(dead_code)]
1161    pub fn set_rotation_offset(&mut self, target_idx: usize, offset: [f64; 4]) {
1162        self.rotation_offsets.insert(target_idx, offset);
1163    }
1164
1165    /// Build a mapping from joint names.
1166    #[allow(dead_code)]
1167    pub fn from_name_mapping(
1168        source: &Skeleton,
1169        target: &Skeleton,
1170        name_map: &HashMap<String, String>,
1171        scale: f64,
1172    ) -> Self {
1173        let mut mapping = Self::new(scale);
1174        for (src_name, tgt_name) in name_map {
1175            if let (Some(&si), Some(&ti)) = (
1176                source.name_to_index.get(src_name),
1177                target.name_to_index.get(tgt_name),
1178            ) {
1179                mapping.map_joint(si, ti);
1180            }
1181        }
1182        mapping
1183    }
1184}
1185
1186/// Retarget a single skeleton pose from a source to a target skeleton.
1187#[allow(dead_code)]
1188pub fn retarget_pose(
1189    source_pose: &SkeletonPose,
1190    target_skeleton: &Skeleton,
1191    mapping: &RetargetMapping,
1192) -> SkeletonPose {
1193    let mut target_pose = SkeletonPose::default_for(target_skeleton);
1194
1195    for (&src_idx, &tgt_idx) in &mapping.joint_map {
1196        if src_idx >= source_pose.joint_poses.len() || tgt_idx >= target_pose.joint_poses.len() {
1197            continue;
1198        }
1199        let src = &source_pose.joint_poses[src_idx];
1200        let mut tgt = JointPose {
1201            translation: vec3_scale(src.translation, mapping.scale),
1202            rotation: src.rotation,
1203            scale: src.scale,
1204        };
1205        // Apply rotation offset if present
1206        if let Some(&offset) = mapping.rotation_offsets.get(&tgt_idx) {
1207            tgt.rotation = quat_mul(offset, tgt.rotation);
1208            tgt.rotation = quat_normalize(tgt.rotation);
1209        }
1210        target_pose.joint_poses[tgt_idx] = tgt;
1211    }
1212
1213    target_pose
1214}
1215
1216/// Retarget an entire animation clip.
1217#[allow(dead_code)]
1218pub fn retarget_clip(
1219    source_clip: &AnimationClip,
1220    target_skeleton: &Skeleton,
1221    mapping: &RetargetMapping,
1222) -> AnimationClip {
1223    let mut target_clip = AnimationClip::new(&source_clip.name, source_clip.fps);
1224    for (i, frame) in source_clip.frames.iter().enumerate() {
1225        let time = if i < source_clip.times.len() {
1226            source_clip.times[i]
1227        } else {
1228            i as f64 / source_clip.fps
1229        };
1230        let retargeted = retarget_pose(frame, target_skeleton, mapping);
1231        target_clip.add_frame(time, retargeted);
1232    }
1233    target_clip
1234}
1235
1236// ═══════════════════════════════════════════════════════════════════════════════
1237// Frame interpolation utilities
1238// ═══════════════════════════════════════════════════════════════════════════════
1239
1240/// Interpolation mode for animation channels.
1241#[derive(Debug, Clone, Copy, PartialEq)]
1242pub enum InterpolationMode {
1243    /// Step / hold (nearest frame).
1244    Step,
1245    /// Linear interpolation (SLERP for rotations).
1246    Linear,
1247    /// Cubic Hermite (Catmull-Rom style).
1248    CubicHermite,
1249}
1250
1251/// Perform step interpolation.
1252#[allow(dead_code)]
1253pub fn step_interpolate(a: f64, _b: f64, _t: f64) -> f64 {
1254    a
1255}
1256
1257/// Perform linear interpolation.
1258#[allow(dead_code)]
1259pub fn linear_interpolate(a: f64, b: f64, t: f64) -> f64 {
1260    a + (b - a) * t
1261}
1262
1263/// Perform cubic Hermite interpolation between two values with tangents.
1264#[allow(dead_code)]
1265pub fn cubic_hermite_interpolate(p0: f64, m0: f64, p1: f64, m1: f64, t: f64) -> f64 {
1266    let t2 = t * t;
1267    let t3 = t2 * t;
1268    let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
1269    let h10 = t3 - 2.0 * t2 + t;
1270    let h01 = -2.0 * t3 + 3.0 * t2;
1271    let h11 = t3 - t2;
1272    h00 * p0 + h10 * m0 + h01 * p1 + h11 * m1
1273}
1274
1275/// Catmull-Rom tangent estimation.
1276#[allow(dead_code)]
1277pub fn catmull_rom_tangent(p_prev: f64, p_next: f64, dt: f64) -> f64 {
1278    if dt.abs() < 1e-15 {
1279        return 0.0;
1280    }
1281    (p_next - p_prev) / (2.0 * dt)
1282}
1283
1284/// Resample an animation clip to a new frame rate using the specified
1285/// interpolation mode.
1286#[allow(dead_code)]
1287pub fn resample_clip(
1288    clip: &AnimationClip,
1289    target_fps: f64,
1290    _mode: InterpolationMode,
1291) -> AnimationClip {
1292    let duration = clip.duration();
1293    if duration <= 0.0 || clip.frames.is_empty() {
1294        return clip.clone();
1295    }
1296    let num_frames = (duration * target_fps).ceil() as usize + 1;
1297    let mut resampled = AnimationClip::new(&clip.name, target_fps);
1298
1299    for i in 0..num_frames {
1300        let time = clip.times[0] + (i as f64) / target_fps;
1301        if let Some(pose) = clip.sample(time) {
1302            resampled.add_frame(time, pose);
1303        }
1304    }
1305
1306    resampled
1307}
1308
1309// ═══════════════════════════════════════════════════════════════════════════════
1310// Animation blending
1311// ═══════════════════════════════════════════════════════════════════════════════
1312
1313/// Blend two skeleton poses with a weight `t` (0 = fully a, 1 = fully b).
1314#[allow(dead_code)]
1315pub fn blend_poses(a: &SkeletonPose, b: &SkeletonPose, t: f64) -> SkeletonPose {
1316    a.lerp(b, t)
1317}
1318
1319/// Additive blend: add the difference (b - ref) to the base pose, scaled
1320/// by `weight`.
1321#[allow(dead_code)]
1322pub fn additive_blend(
1323    base: &SkeletonPose,
1324    reference: &SkeletonPose,
1325    additive: &SkeletonPose,
1326    weight: f64,
1327) -> SkeletonPose {
1328    let n = base.joint_poses.len();
1329    let mut result = base.clone();
1330
1331    for i in 0..n {
1332        if i >= reference.joint_poses.len() || i >= additive.joint_poses.len() {
1333            continue;
1334        }
1335        let ref_pose = &reference.joint_poses[i];
1336        let add_pose = &additive.joint_poses[i];
1337        let base_pose = &base.joint_poses[i];
1338
1339        // Translation delta
1340        let t_delta = vec3_sub(add_pose.translation, ref_pose.translation);
1341        result.joint_poses[i].translation =
1342            vec3_add(base_pose.translation, vec3_scale(t_delta, weight));
1343
1344        // Rotation delta
1345        let ref_inv = quat_conjugate(ref_pose.rotation);
1346        let r_delta = quat_mul(add_pose.rotation, ref_inv);
1347        let r_delta_scaled = quat_slerp([0.0, 0.0, 0.0, 1.0], r_delta, weight);
1348        result.joint_poses[i].rotation =
1349            quat_normalize(quat_mul(r_delta_scaled, base_pose.rotation));
1350    }
1351
1352    result
1353}
1354
1355// ═══════════════════════════════════════════════════════════════════════════════
1356// Animation export helpers
1357// ═══════════════════════════════════════════════════════════════════════════════
1358
1359/// Export animation data as a simple CSV.
1360///
1361/// Columns: `time, joint_index, tx, ty, tz, rx, ry, rz, rw`.
1362#[allow(dead_code)]
1363pub fn export_animation_csv(clip: &AnimationClip) -> String {
1364    let mut out = String::from("time,joint,tx,ty,tz,rx,ry,rz,rw\n");
1365    for (f_idx, frame) in clip.frames.iter().enumerate() {
1366        let time = if f_idx < clip.times.len() {
1367            clip.times[f_idx]
1368        } else {
1369            f_idx as f64 / clip.fps
1370        };
1371        for (j_idx, pose) in frame.joint_poses.iter().enumerate() {
1372            out.push_str(&format!(
1373                "{:.6},{},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6},{:.6}\n",
1374                time,
1375                j_idx,
1376                pose.translation[0],
1377                pose.translation[1],
1378                pose.translation[2],
1379                pose.rotation[0],
1380                pose.rotation[1],
1381                pose.rotation[2],
1382                pose.rotation[3],
1383            ));
1384        }
1385    }
1386    out
1387}
1388
1389/// Parse animation data from CSV (inverse of `export_animation_csv`).
1390///
1391/// Returns `(fps_estimate, frames_map)` where frames_map groups data by
1392/// time.
1393#[allow(dead_code)]
1394pub fn parse_animation_csv(input: &str, num_joints: usize) -> Result<AnimationClip, String> {
1395    let mut times_and_poses: Vec<(f64, usize, JointPose)> = Vec::new();
1396
1397    for (line_no, line) in input.lines().enumerate() {
1398        if line_no == 0 || line.trim().is_empty() {
1399            continue; // skip header
1400        }
1401        let parts: Vec<&str> = line.split(',').collect();
1402        if parts.len() < 9 {
1403            continue;
1404        }
1405        let time: f64 = parts[0].parse().map_err(|_| "Invalid time".to_string())?;
1406        let joint: usize = parts[1].parse().map_err(|_| "Invalid joint".to_string())?;
1407        let tx: f64 = parts[2].parse().unwrap_or(0.0);
1408        let ty: f64 = parts[3].parse().unwrap_or(0.0);
1409        let tz: f64 = parts[4].parse().unwrap_or(0.0);
1410        let rx: f64 = parts[5].parse().unwrap_or(0.0);
1411        let ry: f64 = parts[6].parse().unwrap_or(0.0);
1412        let rz: f64 = parts[7].parse().unwrap_or(0.0);
1413        let rw: f64 = parts[8].parse().unwrap_or(1.0);
1414
1415        times_and_poses.push((
1416            time,
1417            joint,
1418            JointPose {
1419                translation: [tx, ty, tz],
1420                rotation: [rx, ry, rz, rw],
1421                scale: [1.0, 1.0, 1.0],
1422            },
1423        ));
1424    }
1425
1426    // Group by time
1427    let mut frame_map: HashMap<u64, (f64, Vec<(usize, JointPose)>)> = HashMap::new();
1428    for (time, joint, pose) in &times_and_poses {
1429        let key = (*time * 1_000_000.0) as u64;
1430        let entry = frame_map.entry(key).or_insert((*time, Vec::new()));
1431        entry.1.push((*joint, *pose));
1432    }
1433
1434    let mut sorted_frames: Vec<(f64, Vec<(usize, JointPose)>)> = frame_map.into_values().collect();
1435    sorted_frames.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1436
1437    let fps = if sorted_frames.len() >= 2 {
1438        let dt = sorted_frames[1].0 - sorted_frames[0].0;
1439        if dt > 1e-15 { 1.0 / dt } else { 30.0 }
1440    } else {
1441        30.0
1442    };
1443
1444    let mut clip = AnimationClip::new("csv_clip", fps);
1445    for (time, joint_poses) in &sorted_frames {
1446        let mut pose = SkeletonPose {
1447            joint_poses: vec![JointPose::identity(); num_joints],
1448        };
1449        for (ji, jp) in joint_poses {
1450            if *ji < num_joints {
1451                pose.joint_poses[*ji] = *jp;
1452            }
1453        }
1454        clip.add_frame(*time, pose);
1455    }
1456
1457    Ok(clip)
1458}
1459
1460// ═══════════════════════════════════════════════════════════════════════════════
1461// Tests
1462// ═══════════════════════════════════════════════════════════════════════════════
1463
1464#[cfg(test)]
1465mod tests {
1466    use super::*;
1467
1468    #[test]
1469    fn test_quat_slerp_identity() {
1470        let id = [0.0, 0.0, 0.0, 1.0];
1471        let result = quat_slerp(id, id, 0.5);
1472        assert!((result[3] - 1.0).abs() < 1e-10, "w = {:.6}", result[3]);
1473    }
1474
1475    #[test]
1476    fn test_quat_slerp_endpoints() {
1477        let a = [0.0, 0.0, 0.0, 1.0];
1478        let angle = 0.5_f64;
1479        let b = quat_normalize([0.0, angle.sin(), 0.0, angle.cos()]);
1480        let r0 = quat_slerp(a, b, 0.0);
1481        let r1 = quat_slerp(a, b, 1.0);
1482        for i in 0..4 {
1483            assert!((r0[i] - a[i]).abs() < 1e-10, "r0[{i}] = {:.6}", r0[i]);
1484            assert!((r1[i] - b[i]).abs() < 1e-10, "r1[{i}] = {:.6}", r1[i]);
1485        }
1486    }
1487
1488    #[test]
1489    fn test_quat_slerp_midpoint() {
1490        let a = [0.0, 0.0, 0.0, 1.0];
1491        let angle = std::f64::consts::FRAC_PI_2;
1492        let b = quat_normalize([0.0, (angle / 2.0).sin(), 0.0, (angle / 2.0).cos()]);
1493        let mid = quat_slerp(a, b, 0.5);
1494        // Should be approximately half the rotation
1495        let dot_val = quat_dot(mid, a);
1496        assert!(
1497            dot_val > 0.9,
1498            "Midpoint should be close to a, dot = {:.6}",
1499            dot_val
1500        );
1501    }
1502
1503    #[test]
1504    fn test_skeleton_hierarchy() {
1505        let mut skel = Skeleton::new();
1506        let root = skel.add_joint("Hips", None, [0.0, 1.0, 0.0]);
1507        let spine = skel.add_joint("Spine", Some(root), [0.0, 0.2, 0.0]);
1508        let _head = skel.add_joint("Head", Some(spine), [0.0, 0.3, 0.0]);
1509        assert_eq!(skel.num_joints(), 3);
1510        assert_eq!(skel.children(root), vec![1]);
1511        assert_eq!(skel.find_joint("Spine"), Some(1));
1512    }
1513
1514    #[test]
1515    fn test_joint_pose_identity() {
1516        let pose = JointPose::identity();
1517        assert!((pose.rotation[3] - 1.0).abs() < 1e-10);
1518        assert!((pose.scale[0] - 1.0).abs() < 1e-10);
1519    }
1520
1521    #[test]
1522    fn test_joint_pose_lerp() {
1523        let a = JointPose {
1524            translation: [0.0, 0.0, 0.0],
1525            rotation: [0.0, 0.0, 0.0, 1.0],
1526            scale: [1.0, 1.0, 1.0],
1527        };
1528        let b = JointPose {
1529            translation: [2.0, 4.0, 6.0],
1530            rotation: [0.0, 0.0, 0.0, 1.0],
1531            scale: [2.0, 2.0, 2.0],
1532        };
1533        let mid = a.lerp(&b, 0.5);
1534        assert!((mid.translation[0] - 1.0).abs() < 1e-10);
1535        assert!((mid.scale[0] - 1.5).abs() < 1e-10);
1536    }
1537
1538    #[test]
1539    fn test_animation_clip_sample() {
1540        let mut skel = Skeleton::new();
1541        skel.add_joint("Root", None, [0.0; 3]);
1542        let mut clip = AnimationClip::new("test", 30.0);
1543        let mut pose0 = SkeletonPose::default_for(&skel);
1544        pose0.joint_poses[0].translation = [0.0, 0.0, 0.0];
1545        let mut pose1 = SkeletonPose::default_for(&skel);
1546        pose1.joint_poses[0].translation = [10.0, 0.0, 0.0];
1547        clip.add_frame(0.0, pose0);
1548        clip.add_frame(1.0, pose1);
1549        let sampled = clip.sample(0.5).unwrap();
1550        assert!(
1551            (sampled.joint_poses[0].translation[0] - 5.0).abs() < 1e-10,
1552            "tx = {:.6}",
1553            sampled.joint_poses[0].translation[0]
1554        );
1555    }
1556
1557    #[test]
1558    fn test_animation_clip_duration() {
1559        let mut clip = AnimationClip::new("test", 30.0);
1560        let pose = SkeletonPose {
1561            joint_poses: vec![],
1562        };
1563        clip.add_frame(0.0, pose.clone());
1564        clip.add_frame(2.0, pose);
1565        assert!((clip.duration() - 2.0).abs() < 1e-10);
1566    }
1567
1568    #[test]
1569    fn test_parse_bvh_basic() {
1570        let bvh_text = "\
1571HIERARCHY
1572ROOT Hips
1573{
1574  OFFSET 0.0 0.0 0.0
1575  CHANNELS 6 Xposition Yposition Zposition Xrotation Yrotation Zrotation
1576  JOINT Spine
1577  {
1578    OFFSET 0.0 5.0 0.0
1579    CHANNELS 3 Xrotation Yrotation Zrotation
1580    End Site
1581    {
1582      OFFSET 0.0 3.0 0.0
1583    }
1584  }
1585}
1586MOTION
1587Frames: 2
1588Frame Time: 0.033333
15890.0 0.0 0.0 0.0 0.0 0.0 10.0 20.0 30.0
15901.0 2.0 3.0 5.0 10.0 15.0 20.0 25.0 30.0";
1591
1592        let bvh = parse_bvh(bvh_text).unwrap();
1593        assert!(bvh.skeleton.num_joints() >= 2);
1594        assert_eq!(bvh.motion.len(), 2);
1595        assert!((bvh.frame_time - 0.033333).abs() < 1e-4);
1596    }
1597
1598    #[test]
1599    fn test_bvh_to_clip() {
1600        let bvh_text = "\
1601HIERARCHY
1602ROOT Root
1603{
1604  OFFSET 0.0 0.0 0.0
1605  CHANNELS 3 Xposition Yposition Zposition
1606}
1607MOTION
1608Frames: 2
1609Frame Time: 0.033333
16101.0 2.0 3.0
16114.0 5.0 6.0";
1612
1613        let bvh = parse_bvh(bvh_text).unwrap();
1614        let clip = bvh_to_clip(&bvh);
1615        assert_eq!(clip.num_frames(), 2);
1616    }
1617
1618    #[test]
1619    fn test_export_bvh_roundtrip() {
1620        let mut skel = Skeleton::new();
1621        let root = skel.add_joint("Root", None, [0.0; 3]);
1622        skel.set_channels(
1623            root,
1624            vec!["Xposition".into(), "Yposition".into(), "Zposition".into()],
1625        );
1626
1627        let mut clip = AnimationClip::new("test", 30.0);
1628        let mut pose = SkeletonPose::default_for(&skel);
1629        pose.joint_poses[0].translation = [1.0, 2.0, 3.0];
1630        clip.add_frame(0.0, pose);
1631
1632        let exported = export_bvh(&skel, &clip);
1633        assert!(exported.contains("ROOT Root"));
1634        assert!(exported.contains("MOTION"));
1635    }
1636
1637    #[test]
1638    fn test_fbx_node_ascii() {
1639        let mut node = FbxNode::new("Objects");
1640        node.add_property("\"Test\"");
1641        let child = FbxNode::new("Child");
1642        node.add_child(child);
1643        let text = node.to_ascii(0);
1644        assert!(text.contains("Objects:"));
1645        assert!(text.contains("Child:"));
1646    }
1647
1648    #[test]
1649    fn test_parse_fbx_ascii_basic() {
1650        let input = "\
1651; FBX test
1652Objects: {
1653  Model: 1, \"Model::Hips\", \"LimbNode\" {
1654    Properties70: {
1655    }
1656  }
1657}";
1658        let nodes = parse_fbx_ascii(input).unwrap();
1659        assert!(!nodes.is_empty());
1660        assert_eq!(nodes[0].name, "Objects");
1661    }
1662
1663    #[test]
1664    fn test_export_usda() {
1665        let mut skel = Skeleton::new();
1666        skel.add_joint("Root", None, [0.0, 1.0, 0.0]);
1667        let mut clip = AnimationClip::new("walk", 30.0);
1668        let pose = SkeletonPose::default_for(&skel);
1669        clip.add_frame(0.0, pose);
1670
1671        let usda = export_usda(&skel, &clip, "Character");
1672        assert!(usda.contains("#usda 1.0"));
1673        assert!(usda.contains("Skeleton"));
1674    }
1675
1676    #[test]
1677    fn test_blend_shape() {
1678        let mut bs = BlendShape::new("smile");
1679        bs.add_delta(0, [0.1, 0.2, 0.0]);
1680        bs.add_delta(1, [0.0, 0.1, 0.0]);
1681
1682        let mut set = BlendShapeSet::new();
1683        let idx = set.add_shape(bs);
1684        set.set_weight(idx, 0.5);
1685
1686        let result = set.evaluate();
1687        assert!((result[&0][0] - 0.05).abs() < 1e-10);
1688        assert!((result[&0][1] - 0.1).abs() < 1e-10);
1689    }
1690
1691    #[test]
1692    fn test_blend_shape_interpolate_weights() {
1693        let a = vec![0.0, 1.0];
1694        let b = vec![1.0, 0.0];
1695        let mid = BlendShapeSet::interpolate_weights(&a, &b, 0.5);
1696        assert!((mid[0] - 0.5).abs() < 1e-10);
1697        assert!((mid[1] - 0.5).abs() < 1e-10);
1698    }
1699
1700    #[test]
1701    fn test_timeline() {
1702        let mut tl = Timeline::new(0.0, 10.0, 30.0);
1703        let mut track = TimelineTrack::new("main", 0.0, 10.0);
1704        track.add_event(5.0, "footstep", "left");
1705        tl.add_track(track);
1706        assert!((tl.duration() - 10.0).abs() < 1e-10);
1707        assert_eq!(tl.total_frames(), 300);
1708        let events = tl.tracks[0].events_at(5.0, 0.01);
1709        assert_eq!(events.len(), 1);
1710    }
1711
1712    #[test]
1713    fn test_retarget_mapping() {
1714        let mut source = Skeleton::new();
1715        source.add_joint("Hips", None, [0.0; 3]);
1716        source.add_joint("Spine", Some(0), [0.0, 1.0, 0.0]);
1717
1718        let mut target = Skeleton::new();
1719        target.add_joint("pelvis", None, [0.0; 3]);
1720        target.add_joint("spine_01", Some(0), [0.0, 0.5, 0.0]);
1721
1722        let mut name_map = HashMap::new();
1723        name_map.insert("Hips".to_string(), "pelvis".to_string());
1724        name_map.insert("Spine".to_string(), "spine_01".to_string());
1725
1726        let mapping = RetargetMapping::from_name_mapping(&source, &target, &name_map, 0.5);
1727        assert_eq!(mapping.joint_map.len(), 2);
1728    }
1729
1730    #[test]
1731    fn test_retarget_pose() {
1732        let mut source_skel = Skeleton::new();
1733        source_skel.add_joint("A", None, [0.0; 3]);
1734
1735        let mut target_skel = Skeleton::new();
1736        target_skel.add_joint("B", None, [0.0; 3]);
1737
1738        let mut mapping = RetargetMapping::new(2.0);
1739        mapping.map_joint(0, 0);
1740
1741        let mut source_pose = SkeletonPose::default_for(&source_skel);
1742        source_pose.joint_poses[0].translation = [1.0, 2.0, 3.0];
1743
1744        let result = retarget_pose(&source_pose, &target_skel, &mapping);
1745        assert!((result.joint_poses[0].translation[0] - 2.0).abs() < 1e-10);
1746        assert!((result.joint_poses[0].translation[1] - 4.0).abs() < 1e-10);
1747    }
1748
1749    #[test]
1750    fn test_cubic_hermite() {
1751        // At endpoints
1752        let v0 = cubic_hermite_interpolate(0.0, 1.0, 1.0, 1.0, 0.0);
1753        let v1 = cubic_hermite_interpolate(0.0, 1.0, 1.0, 1.0, 1.0);
1754        assert!((v0).abs() < 1e-10);
1755        assert!((v1 - 1.0).abs() < 1e-10);
1756    }
1757
1758    #[test]
1759    fn test_step_interpolate() {
1760        assert!((step_interpolate(5.0, 10.0, 0.5) - 5.0).abs() < 1e-10);
1761    }
1762
1763    #[test]
1764    fn test_linear_interpolate() {
1765        assert!((linear_interpolate(0.0, 10.0, 0.3) - 3.0).abs() < 1e-10);
1766    }
1767
1768    #[test]
1769    fn test_resample_clip() {
1770        let mut skel = Skeleton::new();
1771        skel.add_joint("Root", None, [0.0; 3]);
1772        let mut clip = AnimationClip::new("test", 10.0);
1773        let mut p0 = SkeletonPose::default_for(&skel);
1774        p0.joint_poses[0].translation = [0.0, 0.0, 0.0];
1775        let mut p1 = SkeletonPose::default_for(&skel);
1776        p1.joint_poses[0].translation = [10.0, 0.0, 0.0];
1777        clip.add_frame(0.0, p0);
1778        clip.add_frame(1.0, p1);
1779        let resampled = resample_clip(&clip, 20.0, InterpolationMode::Linear);
1780        assert!(resampled.num_frames() > clip.num_frames());
1781    }
1782
1783    #[test]
1784    fn test_export_animation_csv() {
1785        let mut skel = Skeleton::new();
1786        skel.add_joint("Root", None, [0.0; 3]);
1787        let mut clip = AnimationClip::new("test", 30.0);
1788        let pose = SkeletonPose::default_for(&skel);
1789        clip.add_frame(0.0, pose);
1790        let csv = export_animation_csv(&clip);
1791        assert!(csv.contains("time,joint,tx,ty,tz,rx,ry,rz,rw"));
1792    }
1793
1794    #[test]
1795    fn test_parse_animation_csv_roundtrip() {
1796        let mut skel = Skeleton::new();
1797        skel.add_joint("Root", None, [0.0; 3]);
1798        let mut clip = AnimationClip::new("test", 30.0);
1799        let mut pose = SkeletonPose::default_for(&skel);
1800        pose.joint_poses[0].translation = [1.0, 2.0, 3.0];
1801        clip.add_frame(0.0, pose.clone());
1802        clip.add_frame(0.033333, pose);
1803        let csv = export_animation_csv(&clip);
1804        let parsed = parse_animation_csv(&csv, 1).unwrap();
1805        assert_eq!(parsed.num_frames(), 2);
1806    }
1807
1808    #[test]
1809    fn test_additive_blend() {
1810        let mut skel = Skeleton::new();
1811        skel.add_joint("Root", None, [0.0; 3]);
1812
1813        let mut base = SkeletonPose::default_for(&skel);
1814        base.joint_poses[0].translation = [5.0, 0.0, 0.0];
1815
1816        let reference = SkeletonPose::default_for(&skel);
1817        let mut additive = SkeletonPose::default_for(&skel);
1818        additive.joint_poses[0].translation = [1.0, 0.0, 0.0];
1819
1820        let result = additive_blend(&base, &reference, &additive, 1.0);
1821        // base + (additive - reference) * 1.0 = 5 + (1 - 0) = 6
1822        assert!(
1823            (result.joint_poses[0].translation[0] - 6.0).abs() < 1e-10,
1824            "tx = {:.6}",
1825            result.joint_poses[0].translation[0]
1826        );
1827    }
1828
1829    #[test]
1830    fn test_euler_quat_roundtrip() {
1831        // Single-axis rotation avoids convention mixing
1832        let euler = [15.0, 0.0, 0.0];
1833        let q = euler_xyz_to_quat(euler);
1834        let euler_back = quat_to_euler_xyz(q);
1835        assert!(
1836            (euler[0] - euler_back[0]).abs() < 0.1,
1837            "euler[0] = {:.6}, got {:.6}",
1838            euler[0],
1839            euler_back[0]
1840        );
1841        // Test a Y-only rotation
1842        let euler_y = [0.0, 25.0, 0.0];
1843        let qy = euler_xyz_to_quat(euler_y);
1844        let ey_back = quat_to_euler_xyz(qy);
1845        assert!(
1846            (euler_y[1] - ey_back[1]).abs() < 0.1,
1847            "euler_y[1] = {:.6}, got {:.6}",
1848            euler_y[1],
1849            ey_back[1]
1850        );
1851    }
1852
1853    #[test]
1854    fn test_catmull_rom_tangent() {
1855        let t = catmull_rom_tangent(0.0, 4.0, 1.0);
1856        assert!((t - 2.0).abs() < 1e-10, "tangent = {:.6}", t);
1857    }
1858
1859    #[test]
1860    fn test_export_fbx_skeleton() {
1861        let mut skel = Skeleton::new();
1862        skel.add_joint("Root", None, [0.0, 1.0, 0.0]);
1863        skel.add_joint("Spine", Some(0), [0.0, 0.5, 0.0]);
1864        let fbx = export_fbx_ascii_skeleton(&skel);
1865        assert!(fbx.contains("FBXVersion"));
1866        assert!(fbx.contains("Model"));
1867    }
1868}