Skip to main content

oxihuman_export/
usd_anim.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use std::path::Path;
7
8use anyhow::Context;
9use oxihuman_mesh::MeshBuffers;
10
11// ── Data types ────────────────────────────────────────────────────────────────
12
13/// Configuration for USD animation export.
14pub struct UsdAnimConfig {
15    pub start_time: f32,
16    pub end_time: f32,
17    pub fps: f32,
18    pub meters_per_unit: f32,
19}
20
21impl Default for UsdAnimConfig {
22    fn default() -> Self {
23        Self {
24            start_time: 0.0,
25            end_time: 1.0,
26            fps: 24.0,
27            meters_per_unit: 1.0,
28        }
29    }
30}
31
32/// A single time-sampled position frame.
33pub struct UsdTimeSample {
34    pub time: f32,
35    pub positions: Vec<[f32; 3]>,
36}
37
38// ── Formatting helpers ────────────────────────────────────────────────────────
39
40/// Format a position array as USD point list: `[(x, y, z), ...]`
41pub fn format_usda_point_array(positions: &[[f32; 3]]) -> String {
42    let inner: Vec<String> = positions
43        .iter()
44        .map(|p| format!("({:.6}, {:.6}, {:.6})", p[0], p[1], p[2]))
45        .collect();
46    format!("[{}]", inner.join(", "))
47}
48
49/// Build a `attr.timeSamples = { time: [...], ... }` block.
50pub fn build_usda_time_samples_block(attr_name: &str, samples: &[UsdTimeSample]) -> String {
51    if samples.is_empty() {
52        return format!("{}.timeSamples = {{}}", attr_name);
53    }
54
55    let mut lines: Vec<String> = Vec::new();
56    for sample in samples {
57        // USD time codes are typically integer or fractional frame numbers
58        let time_code = sample.time;
59        let pts = format_usda_point_array(&sample.positions);
60        lines.push(format!("            {:.4}: {},", time_code, pts));
61    }
62
63    format!(
64        "{}.timeSamples = {{\n{}\n        }}",
65        attr_name,
66        lines.join("\n")
67    )
68}
69
70// ── Main build function ───────────────────────────────────────────────────────
71
72/// Produce a .usda file string with `def Mesh` + animated `points.timeSamples`.
73pub fn build_usda_animated(
74    mesh: &MeshBuffers,
75    samples: &[UsdTimeSample],
76    cfg: &UsdAnimConfig,
77) -> String {
78    let start_frame = cfg.start_time * cfg.fps;
79    let end_frame = cfg.end_time * cfg.fps;
80
81    let face_count = mesh.indices.len() / 3;
82
83    // Build face vertex counts (all triangles = 3)
84    let fvc: Vec<String> = vec!["3".to_string(); face_count];
85    let fvi: Vec<String> = mesh.indices.iter().map(|i| i.to_string()).collect();
86
87    // Static normals from first-frame mesh
88    let normals_str = {
89        let inner: Vec<String> = mesh
90            .normals
91            .iter()
92            .map(|n| format!("({:.6}, {:.6}, {:.6})", n[0], n[1], n[2]))
93            .collect();
94        format!("[{}]", inner.join(", "))
95    };
96
97    // UV coordinates
98    let uv_str = {
99        let inner: Vec<String> = mesh
100            .uvs
101            .iter()
102            .map(|uv| format!("({:.6}, {:.6})", uv[0], uv[1]))
103            .collect();
104        format!("[{}]", inner.join(", "))
105    };
106
107    let time_samples_block = build_usda_time_samples_block("points", samples);
108
109    format!(
110        r#"#usda 1.0
111(
112    defaultPrim = "Root"
113    metersPerUnit = {meters_per_unit:.4}
114    startTimeCode = {start:.4}
115    endTimeCode = {end:.4}
116    timeCodesPerSecond = {fps:.4}
117    upAxis = "Y"
118)
119
120def Xform "Root"
121{{
122    def Mesh "Body"
123    {{
124        int[] faceVertexCounts = [{fvc}]
125        int[] faceVertexIndices = [{fvi}]
126        normal3f[] normals = {normals}
127        texCoord2f[] primvars:st = {uvs} (
128            interpolation = "vertex"
129        )
130        {time_samples}
131    }}
132}}
133"#,
134        meters_per_unit = cfg.meters_per_unit,
135        start = start_frame,
136        end = end_frame,
137        fps = cfg.fps,
138        fvc = fvc.join(", "),
139        fvi = fvi.join(", "),
140        normals = normals_str,
141        uvs = uv_str,
142        time_samples = time_samples_block,
143    )
144}
145
146/// Write animated USDA to a file path.
147pub fn export_usda_animated(
148    mesh: &MeshBuffers,
149    samples: &[UsdTimeSample],
150    cfg: &UsdAnimConfig,
151    path: &Path,
152) -> anyhow::Result<()> {
153    let content = build_usda_animated(mesh, samples, cfg);
154    std::fs::write(path, content.as_bytes())
155        .with_context(|| format!("Failed to write USD anim to {}", path.display()))?;
156    Ok(())
157}
158
159/// Generate a sequence of stub time samples (all identical positions) for testing.
160pub fn uniform_time_samples(
161    base_positions: &[[f32; 3]],
162    cfg: &UsdAnimConfig,
163) -> Vec<UsdTimeSample> {
164    let frame_count = ((cfg.end_time - cfg.start_time) * cfg.fps).ceil() as usize;
165    let frame_count = frame_count.max(1);
166
167    (0..frame_count)
168        .map(|i| {
169            let t = cfg.start_time + i as f32 / cfg.fps;
170            UsdTimeSample {
171                time: t,
172                positions: base_positions.to_vec(),
173            }
174        })
175        .collect()
176}
177
178/// Return a human-readable summary of the sample sequence.
179pub fn usd_anim_stats(samples: &[UsdTimeSample]) -> String {
180    if samples.is_empty() {
181        return "UsdAnim: 0 samples".to_string();
182    }
183    let frame_count = samples.len();
184    let vert_count = samples[0].positions.len();
185    let t_start = samples.first().map(|s| s.time).unwrap_or(0.0);
186    let t_end = samples.last().map(|s| s.time).unwrap_or(0.0);
187    format!(
188        "UsdAnim: frames={}, vertices_per_frame={}, time=[{:.3}..{:.3}]",
189        frame_count, vert_count, t_start, t_end
190    )
191}
192
193// ── Tests ─────────────────────────────────────────────────────────────────────
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn stub_mesh() -> MeshBuffers {
200        MeshBuffers {
201            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
202            normals: vec![[0.0, 0.0, 1.0]; 3],
203            tangents: vec![[1.0, 0.0, 0.0, 1.0]; 3],
204            uvs: vec![[0.0, 0.0]; 3],
205            indices: vec![0, 1, 2],
206            colors: None,
207            has_suit: false,
208        }
209    }
210
211    fn stub_cfg() -> UsdAnimConfig {
212        UsdAnimConfig {
213            start_time: 0.0,
214            end_time: 1.0,
215            fps: 24.0,
216            meters_per_unit: 1.0,
217        }
218    }
219
220    fn stub_samples(mesh: &MeshBuffers, n: usize) -> Vec<UsdTimeSample> {
221        (0..n)
222            .map(|i| UsdTimeSample {
223                time: i as f32 / 24.0,
224                positions: mesh.positions.clone(),
225            })
226            .collect()
227    }
228
229    #[test]
230    fn build_usda_animated_contains_time_samples() {
231        let mesh = stub_mesh();
232        let cfg = stub_cfg();
233        let samples = stub_samples(&mesh, 3);
234        let usda = build_usda_animated(&mesh, &samples, &cfg);
235        assert!(
236            usda.contains("timeSamples"),
237            "USDA must contain timeSamples"
238        );
239    }
240
241    #[test]
242    fn build_usda_animated_contains_mesh_prim() {
243        let mesh = stub_mesh();
244        let cfg = stub_cfg();
245        let usda = build_usda_animated(&mesh, &[], &cfg);
246        assert!(usda.contains("def Mesh"), "USDA must contain def Mesh");
247        assert!(usda.contains("Body"), "USDA must contain Body prim");
248    }
249
250    #[test]
251    fn build_usda_animated_contains_root_xform() {
252        let mesh = stub_mesh();
253        let cfg = stub_cfg();
254        let usda = build_usda_animated(&mesh, &[], &cfg);
255        assert!(usda.contains("def Xform \"Root\""));
256    }
257
258    #[test]
259    fn format_usda_point_array_format_check() {
260        let pts = &[[1.0f32, 2.0, 3.0], [4.0, 5.0, 6.0]];
261        let s = format_usda_point_array(pts);
262        assert!(s.starts_with('['));
263        assert!(s.ends_with(']'));
264        assert!(s.contains("(1.000000, 2.000000, 3.000000)"));
265        assert!(s.contains("(4.000000, 5.000000, 6.000000)"));
266    }
267
268    #[test]
269    fn format_usda_point_array_empty() {
270        let s = format_usda_point_array(&[]);
271        assert_eq!(s, "[]");
272    }
273
274    #[test]
275    fn build_usda_time_samples_block_has_both_times() {
276        let samples = vec![
277            UsdTimeSample {
278                time: 0.0,
279                positions: vec![[0.0, 0.0, 0.0]],
280            },
281            UsdTimeSample {
282                time: 1.0,
283                positions: vec![[1.0, 0.0, 0.0]],
284            },
285        ];
286        let block = build_usda_time_samples_block("points", &samples);
287        assert!(block.contains("0.0000"), "block must contain t=0");
288        assert!(block.contains("1.0000"), "block must contain t=1");
289        assert!(block.contains("timeSamples"));
290    }
291
292    #[test]
293    fn build_usda_time_samples_block_empty() {
294        let block = build_usda_time_samples_block("points", &[]);
295        assert!(block.contains("timeSamples"));
296        assert!(block.contains("{}"));
297    }
298
299    #[test]
300    fn uniform_time_samples_frame_count() {
301        let positions = vec![[0.0f32, 0.0, 0.0]];
302        let cfg = UsdAnimConfig {
303            start_time: 0.0,
304            end_time: 1.0,
305            fps: 24.0,
306            meters_per_unit: 1.0,
307        };
308        let samples = uniform_time_samples(&positions, &cfg);
309        // ceil((1.0 - 0.0) * 24.0) = 24
310        assert_eq!(samples.len(), 24);
311    }
312
313    #[test]
314    fn uniform_time_samples_fractional_ceil() {
315        let positions = vec![[0.0f32, 0.0, 0.0]];
316        let cfg = UsdAnimConfig {
317            start_time: 0.0,
318            end_time: 0.5,
319            fps: 24.0,
320            meters_per_unit: 1.0,
321        };
322        let samples = uniform_time_samples(&positions, &cfg);
323        // ceil(0.5 * 24) = ceil(12) = 12
324        assert_eq!(samples.len(), 12);
325    }
326
327    #[test]
328    fn uniform_time_samples_identical_positions() {
329        let positions = vec![[1.0f32, 2.0, 3.0], [4.0, 5.0, 6.0]];
330        let cfg = stub_cfg();
331        let samples = uniform_time_samples(&positions, &cfg);
332        for s in &samples {
333            assert_eq!(s.positions, positions);
334        }
335    }
336
337    #[test]
338    fn usd_anim_stats_non_empty() {
339        let mesh = stub_mesh();
340        let samples = stub_samples(&mesh, 5);
341        let s = usd_anim_stats(&samples);
342        assert!(!s.is_empty());
343        assert!(s.contains("5"));
344    }
345
346    #[test]
347    fn usd_anim_stats_empty_samples() {
348        let s = usd_anim_stats(&[]);
349        assert!(!s.is_empty());
350        assert!(s.contains('0'));
351    }
352
353    #[test]
354    fn usd_anim_stats_single_sample() {
355        let sample = UsdTimeSample {
356            time: 0.5,
357            positions: vec![[0.0f32, 0.0, 0.0]; 4],
358        };
359        let s = usd_anim_stats(&[sample]);
360        assert!(s.contains("frames=1"));
361        assert!(s.contains("vertices_per_frame=4"));
362    }
363
364    #[test]
365    fn export_usda_animated_writes_file() {
366        let mesh = stub_mesh();
367        let cfg = stub_cfg();
368        let samples = stub_samples(&mesh, 2);
369        let path = std::path::Path::new("/tmp/test_usd_anim_export.usda");
370        export_usda_animated(&mesh, &samples, &cfg, path).expect("should write file");
371        assert!(path.exists());
372        let content = std::fs::read_to_string(path).expect("should succeed");
373        assert!(content.contains("timeSamples"));
374    }
375}