Skip to main content

oxihuman_export/
rig_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Skeleton/rig export for animation pipelines.
5//!
6//! Provides structs and functions for building, validating, and serializing
7//! hierarchical bone rigs to JSON and CSV formats.
8
9// ── Types ──────────────────────────────────────────────────────────────────
10
11/// A single bone in an exported rig hierarchy.
12#[allow(dead_code)]
13#[derive(Debug, Clone)]
14pub struct RigExportBone {
15    /// Unique numeric identifier for this bone.
16    pub id: u32,
17    /// Human-readable bone name.
18    pub name: String,
19    /// Parent bone ID, or `None` for root bones.
20    pub parent_id: Option<u32>,
21    /// World-space head position `[x, y, z]`.
22    pub head: [f32; 3],
23    /// World-space tail position `[x, y, z]`.
24    pub tail: [f32; 3],
25    /// Rotation quaternion `[x, y, z, w]`.
26    pub rotation: [f32; 4],
27    /// Bone length in world units.
28    pub length: f32,
29    /// Bind-pose transform (head + rotation snapshot).
30    pub bind_pose: ([f32; 3], [f32; 4]),
31}
32
33/// An assembled rig ready for export.
34#[allow(dead_code)]
35#[derive(Debug, Clone)]
36pub struct ExportRig {
37    /// Rig name (e.g. `"humanoid"`).
38    pub name: String,
39    /// All bones in the rig.
40    pub bones: Vec<RigExportBone>,
41}
42
43/// Configuration controlling how a rig is exported.
44#[allow(dead_code)]
45#[derive(Debug, Clone)]
46pub struct RigExportConfig {
47    /// Include the bind-pose in output.
48    pub include_bind_pose: bool,
49    /// Floating-point precision (decimal places).
50    pub precision: u32,
51    /// Export only bones whose names match this prefix (empty = all).
52    pub name_filter_prefix: String,
53}
54
55// ── Type aliases ───────────────────────────────────────────────────────────
56
57/// Result of a rig validation check.
58pub type RigValidationResult = Result<(), String>;
59
60// ── Config ─────────────────────────────────────────────────────────────────
61
62/// Return a sensible default [`RigExportConfig`].
63#[allow(dead_code)]
64pub fn default_rig_export_config() -> RigExportConfig {
65    RigExportConfig {
66        include_bind_pose: true,
67        precision: 6,
68        name_filter_prefix: String::new(),
69    }
70}
71
72// ── Rig construction ───────────────────────────────────────────────────────
73
74/// Create a new, empty [`ExportRig`] with the given name.
75#[allow(dead_code)]
76pub fn new_export_rig(name: &str) -> ExportRig {
77    ExportRig {
78        name: name.to_string(),
79        bones: Vec::new(),
80    }
81}
82
83/// Append a bone to the rig.
84#[allow(dead_code)]
85pub fn add_bone(rig: &mut ExportRig, bone: RigExportBone) {
86    rig.bones.push(bone);
87}
88
89/// Remove the bone with the given `id` from the rig.
90/// Returns `true` if a bone was removed.
91#[allow(dead_code)]
92pub fn remove_bone(rig: &mut ExportRig, id: u32) -> bool {
93    let before = rig.bones.len();
94    rig.bones.retain(|b| b.id != id);
95    rig.bones.len() < before
96}
97
98/// Return the total number of bones in the rig.
99#[allow(dead_code)]
100pub fn bone_count(rig: &ExportRig) -> usize {
101    rig.bones.len()
102}
103
104// ── Hierarchy queries ──────────────────────────────────────────────────────
105
106/// Return all root bones (bones with no parent).
107#[allow(dead_code)]
108pub fn rig_root_bones(rig: &ExportRig) -> Vec<&RigExportBone> {
109    rig.bones.iter().filter(|b| b.parent_id.is_none()).collect()
110}
111
112/// Find a bone by name (case-sensitive). Returns `None` if not found.
113#[allow(dead_code)]
114pub fn find_bone_by_name<'a>(rig: &'a ExportRig, name: &str) -> Option<&'a RigExportBone> {
115    rig.bones.iter().find(|b| b.name == name)
116}
117
118/// Return the ancestor chain of the bone with `start_id`, starting at
119/// `start_id` and walking up to the root.  Returns an empty `Vec` if
120/// `start_id` does not exist.
121#[allow(dead_code)]
122pub fn bone_chain(rig: &ExportRig, start_id: u32) -> Vec<&RigExportBone> {
123    let mut result = Vec::new();
124    let mut current_id = Some(start_id);
125    let mut visited = std::collections::HashSet::new();
126    while let Some(cid) = current_id {
127        if !visited.insert(cid) {
128            break; // cycle guard
129        }
130        if let Some(bone) = rig.bones.iter().find(|b| b.id == cid) {
131            result.push(bone);
132            current_id = bone.parent_id;
133        } else {
134            break;
135        }
136    }
137    result
138}
139
140/// Compute the maximum hierarchy depth of the rig (longest path from root
141/// to leaf).  Returns 0 for an empty rig.
142#[allow(dead_code)]
143pub fn rig_depth(rig: &ExportRig) -> usize {
144    fn depth_of(rig: &ExportRig, id: u32, visited: &mut std::collections::HashSet<u32>) -> usize {
145        if !visited.insert(id) {
146            return 0;
147        }
148        let children: Vec<u32> = rig
149            .bones
150            .iter()
151            .filter(|b| b.parent_id == Some(id))
152            .map(|b| b.id)
153            .collect();
154        if children.is_empty() {
155            return 1;
156        }
157        1 + children
158            .into_iter()
159            .map(|cid| depth_of(rig, cid, visited))
160            .max()
161            .unwrap_or(0)
162    }
163    rig_root_bones(rig)
164        .iter()
165        .map(|r| depth_of(rig, r.id, &mut std::collections::HashSet::new()))
166        .max()
167        .unwrap_or(0)
168}
169
170/// Validate the rig: checks there are no cycles and all parent IDs point to
171/// existing bones.  Returns `Ok(())` on success or an error string.
172#[allow(dead_code)]
173pub fn validate_rig(rig: &ExportRig) -> RigValidationResult {
174    // Check parent references
175    let ids: std::collections::HashSet<u32> = rig.bones.iter().map(|b| b.id).collect();
176    for bone in &rig.bones {
177        if let Some(pid) = bone.parent_id {
178            if !ids.contains(&pid) {
179                return Err(format!(
180                    "bone '{}' references non-existent parent id {}",
181                    bone.name, pid
182                ));
183            }
184        }
185    }
186    // Cycle check via DFS
187    for bone in &rig.bones {
188        let mut visited = std::collections::HashSet::new();
189        let mut cur = bone.parent_id;
190        while let Some(pid) = cur {
191            if !visited.insert(pid) {
192                return Err(format!("cycle detected involving bone id {pid}"));
193            }
194            cur = rig
195                .bones
196                .iter()
197                .find(|b| b.id == pid)
198                .and_then(|b| b.parent_id);
199        }
200    }
201    Ok(())
202}
203
204/// Return the sum of `length` across all bones.
205#[allow(dead_code)]
206pub fn total_bone_length(rig: &ExportRig) -> f32 {
207    rig.bones.iter().map(|b| b.length).sum()
208}
209
210/// Overwrite the bind-pose of the bone with `id`.
211/// Returns `true` if the bone was found.
212#[allow(dead_code)]
213pub fn set_bone_bind_pose(rig: &mut ExportRig, id: u32, pos: [f32; 3], rot: [f32; 4]) -> bool {
214    if let Some(bone) = rig.bones.iter_mut().find(|b| b.id == id) {
215        bone.bind_pose = (pos, rot);
216        true
217    } else {
218        false
219    }
220}
221
222// ── Serialization ──────────────────────────────────────────────────────────
223
224/// Serialize the rig to a compact JSON string.
225#[allow(dead_code)]
226pub fn rig_to_json(rig: &ExportRig) -> String {
227    let bone_strs: Vec<String> = rig
228        .bones
229        .iter()
230        .map(|b| {
231            let parent = match b.parent_id {
232                Some(p) => format!("{p}"),
233                None => "null".to_string(),
234            };
235            let (bp, br) = b.bind_pose;
236            format!(
237                r#"{{"id":{},"name":"{}","parent_id":{},"head":[{},{},{}],"tail":[{},{},{}],"rotation":[{},{},{},{}],"length":{},"bind_pose":{{"pos":[{},{},{}],"rot":[{},{},{},{}]}}}}"#,
238                b.id,
239                b.name,
240                parent,
241                b.head[0], b.head[1], b.head[2],
242                b.tail[0], b.tail[1], b.tail[2],
243                b.rotation[0], b.rotation[1], b.rotation[2], b.rotation[3],
244                b.length,
245                bp[0], bp[1], bp[2],
246                br[0], br[1], br[2], br[3],
247            )
248        })
249        .collect();
250    format!(
251        r#"{{"name":"{}","bones":[{}]}}"#,
252        rig.name,
253        bone_strs.join(",")
254    )
255}
256
257/// Serialize the rig to a CSV string (one row per bone).
258#[allow(dead_code)]
259pub fn rig_to_csv(rig: &ExportRig) -> String {
260    let mut out = String::from("id,name,parent_id,head_x,head_y,head_z,tail_x,tail_y,tail_z,rot_x,rot_y,rot_z,rot_w,length\n");
261    for b in &rig.bones {
262        let parent = match b.parent_id {
263            Some(p) => format!("{p}"),
264            None => "".to_string(),
265        };
266        out.push_str(&format!(
267            "{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
268            b.id,
269            b.name,
270            parent,
271            b.head[0],
272            b.head[1],
273            b.head[2],
274            b.tail[0],
275            b.tail[1],
276            b.tail[2],
277            b.rotation[0],
278            b.rotation[1],
279            b.rotation[2],
280            b.rotation[3],
281            b.length,
282        ));
283    }
284    out
285}
286
287// ── Tests ──────────────────────────────────────────────────────────────────
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn make_bone(id: u32, name: &str, parent: Option<u32>) -> RigExportBone {
294        RigExportBone {
295            id,
296            name: name.to_string(),
297            parent_id: parent,
298            head: [0.0, f32::from(id as u8), 0.0],
299            tail: [0.0, f32::from(id as u8) + 1.0, 0.0],
300            rotation: [0.0, 0.0, 0.0, 1.0],
301            length: 1.0,
302            bind_pose: ([0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]),
303        }
304    }
305
306    #[test]
307    fn test_default_rig_export_config() {
308        let cfg = default_rig_export_config();
309        assert!(cfg.include_bind_pose);
310        assert_eq!(cfg.precision, 6);
311        assert!(cfg.name_filter_prefix.is_empty());
312    }
313
314    #[test]
315    fn test_new_export_rig() {
316        let rig = new_export_rig("human");
317        assert_eq!(rig.name, "human");
318        assert!(rig.bones.is_empty());
319    }
320
321    #[test]
322    fn test_add_bone() {
323        let mut rig = new_export_rig("r");
324        add_bone(&mut rig, make_bone(0, "root", None));
325        assert_eq!(bone_count(&rig), 1);
326    }
327
328    #[test]
329    fn test_remove_bone_found() {
330        let mut rig = new_export_rig("r");
331        add_bone(&mut rig, make_bone(0, "root", None));
332        add_bone(&mut rig, make_bone(1, "spine", Some(0)));
333        let removed = remove_bone(&mut rig, 1);
334        assert!(removed);
335        assert_eq!(bone_count(&rig), 1);
336    }
337
338    #[test]
339    fn test_remove_bone_not_found() {
340        let mut rig = new_export_rig("r");
341        add_bone(&mut rig, make_bone(0, "root", None));
342        let removed = remove_bone(&mut rig, 99);
343        assert!(!removed);
344        assert_eq!(bone_count(&rig), 1);
345    }
346
347    #[test]
348    fn test_bone_count_empty() {
349        let rig = new_export_rig("r");
350        assert_eq!(bone_count(&rig), 0);
351    }
352
353    #[test]
354    fn test_rig_root_bones() {
355        let mut rig = new_export_rig("r");
356        add_bone(&mut rig, make_bone(0, "hip", None));
357        add_bone(&mut rig, make_bone(1, "spine", Some(0)));
358        add_bone(&mut rig, make_bone(2, "neck", None));
359        let roots = rig_root_bones(&rig);
360        assert_eq!(roots.len(), 2);
361    }
362
363    #[test]
364    fn test_find_bone_by_name_found() {
365        let mut rig = new_export_rig("r");
366        add_bone(&mut rig, make_bone(0, "hip", None));
367        add_bone(&mut rig, make_bone(1, "spine", Some(0)));
368        let bone = find_bone_by_name(&rig, "spine");
369        assert!(bone.is_some());
370        assert_eq!(bone.expect("should succeed").id, 1);
371    }
372
373    #[test]
374    fn test_find_bone_by_name_missing() {
375        let rig = new_export_rig("r");
376        assert!(find_bone_by_name(&rig, "missing").is_none());
377    }
378
379    #[test]
380    fn test_bone_chain_single_root() {
381        let mut rig = new_export_rig("r");
382        add_bone(&mut rig, make_bone(0, "hip", None));
383        add_bone(&mut rig, make_bone(1, "spine", Some(0)));
384        add_bone(&mut rig, make_bone(2, "chest", Some(1)));
385        let chain = bone_chain(&rig, 2);
386        assert_eq!(chain.len(), 3);
387        assert_eq!(chain[0].id, 2);
388        assert_eq!(chain[1].id, 1);
389        assert_eq!(chain[2].id, 0);
390    }
391
392    #[test]
393    fn test_bone_chain_nonexistent() {
394        let rig = new_export_rig("r");
395        let chain = bone_chain(&rig, 99);
396        assert!(chain.is_empty());
397    }
398
399    #[test]
400    fn test_rig_depth_empty() {
401        let rig = new_export_rig("r");
402        assert_eq!(rig_depth(&rig), 0);
403    }
404
405    #[test]
406    fn test_rig_depth_three_levels() {
407        let mut rig = new_export_rig("r");
408        add_bone(&mut rig, make_bone(0, "hip", None));
409        add_bone(&mut rig, make_bone(1, "spine", Some(0)));
410        add_bone(&mut rig, make_bone(2, "chest", Some(1)));
411        assert_eq!(rig_depth(&rig), 3);
412    }
413
414    #[test]
415    fn test_validate_rig_ok() {
416        let mut rig = new_export_rig("r");
417        add_bone(&mut rig, make_bone(0, "hip", None));
418        add_bone(&mut rig, make_bone(1, "spine", Some(0)));
419        assert!(validate_rig(&rig).is_ok());
420    }
421
422    #[test]
423    fn test_validate_rig_bad_parent() {
424        let mut rig = new_export_rig("r");
425        add_bone(&mut rig, make_bone(5, "orphan", Some(99)));
426        assert!(validate_rig(&rig).is_err());
427    }
428
429    #[test]
430    fn test_total_bone_length() {
431        let mut rig = new_export_rig("r");
432        let mut b0 = make_bone(0, "hip", None);
433        b0.length = 2.0;
434        let mut b1 = make_bone(1, "spine", Some(0));
435        b1.length = 3.0;
436        add_bone(&mut rig, b0);
437        add_bone(&mut rig, b1);
438        assert!((total_bone_length(&rig) - 5.0).abs() < 1e-5);
439    }
440
441    #[test]
442    fn test_set_bone_bind_pose_found() {
443        let mut rig = new_export_rig("r");
444        add_bone(&mut rig, make_bone(0, "hip", None));
445        let ok = set_bone_bind_pose(&mut rig, 0, [1.0, 2.0, 3.0], [0.0, 0.0, 0.0, 1.0]);
446        assert!(ok);
447        assert_eq!(rig.bones[0].bind_pose.0, [1.0, 2.0, 3.0]);
448    }
449
450    #[test]
451    fn test_set_bone_bind_pose_not_found() {
452        let mut rig = new_export_rig("r");
453        let ok = set_bone_bind_pose(&mut rig, 99, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0]);
454        assert!(!ok);
455    }
456
457    #[test]
458    fn test_rig_to_json_nonempty() {
459        let mut rig = new_export_rig("test_rig");
460        add_bone(&mut rig, make_bone(0, "hip", None));
461        let json = rig_to_json(&rig);
462        assert!(!json.is_empty());
463        assert!(json.contains("test_rig"));
464        assert!(json.contains("hip"));
465    }
466
467    #[test]
468    fn test_rig_to_csv_has_header() {
469        let mut rig = new_export_rig("r");
470        add_bone(&mut rig, make_bone(0, "hip", None));
471        let csv = rig_to_csv(&rig);
472        assert!(csv.starts_with("id,name"));
473        assert!(csv.contains("hip"));
474    }
475}