Skip to main content

oxihuman_export/
usda_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! USDA (Universal Scene Description ASCII) text format writer.
5//!
6//! Produces valid `.usda` files with support for meshes, materials,
7//! skeletons, skin bindings, blend shapes, and xform transforms.
8
9use std::fmt::Write as FmtWrite;
10
11// ── Enums ────────────────────────────────────────────────────────────────────
12
13/// Subdivision scheme for a USD mesh.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum UsdSubdivScheme {
16    /// No subdivision.
17    None,
18    /// Catmull-Clark subdivision.
19    CatmullClark,
20    /// Loop subdivision.
21    Loop,
22    /// Bilinear subdivision.
23    Bilinear,
24}
25
26impl UsdSubdivScheme {
27    /// Return the USDA token string for this scheme.
28    fn as_token(self) -> &'static str {
29        match self {
30            Self::None => "none",
31            Self::CatmullClark => "catmullClark",
32            Self::Loop => "loop",
33            Self::Bilinear => "bilinear",
34        }
35    }
36}
37
38// ── Data structures ──────────────────────────────────────────────────────────
39
40/// A USD Mesh primitive with geometry data.
41pub struct UsdMesh {
42    /// Name of this mesh prim.
43    pub name: String,
44    /// Vertex positions (point3f).
45    pub positions: Vec<[f64; 3]>,
46    /// Per-vertex or per-face-vertex normals (normal3f).
47    pub normals: Vec<[f64; 3]>,
48    /// Texture coordinates (texCoord2f).
49    pub uvs: Vec<[f64; 2]>,
50    /// Number of vertices per face (e.g. `[3, 3, 4]`).
51    pub face_vertex_counts: Vec<i32>,
52    /// Indices into the points array.
53    pub face_vertex_indices: Vec<i32>,
54    /// Subdivision scheme.
55    pub subdivision_scheme: UsdSubdivScheme,
56}
57
58/// A USD material using UsdPreviewSurface.
59pub struct UsdMaterial {
60    /// Name of this material prim.
61    pub name: String,
62    /// Diffuse base colour (linear).
63    pub diffuse_color: [f64; 3],
64    /// Metallic factor (0..1).
65    pub metallic: f64,
66    /// Roughness factor (0..1).
67    pub roughness: f64,
68    /// Opacity (0..1).
69    pub opacity: f64,
70    /// Normal map scale.
71    pub normal_scale: f64,
72}
73
74/// A USD skeleton definition.
75pub struct UsdSkeleton {
76    /// Short joint names (e.g. `["Hips", "Spine"]`).
77    pub joint_names: Vec<String>,
78    /// Full joint paths (e.g. `["Hips", "Hips/Spine"]`).
79    pub joint_paths: Vec<String>,
80    /// Bind transforms as column-major 4x4 matrices (flattened to 16 elements).
81    pub bind_transforms: Vec<[f64; 16]>,
82    /// Rest transforms as column-major 4x4 matrices (flattened to 16 elements).
83    pub rest_transforms: Vec<[f64; 16]>,
84}
85
86/// Skin binding data that associates a mesh with a skeleton.
87pub struct UsdSkinBinding {
88    /// Per-vertex joint indices (variable number of influences per vertex).
89    pub joint_indices: Vec<Vec<i32>>,
90    /// Per-vertex joint weights (same shape as `joint_indices`).
91    pub joint_weights: Vec<Vec<f64>>,
92    /// Scene path to the skeleton prim.
93    pub skeleton_path: String,
94}
95
96/// A single blend shape (morph target).
97pub struct UsdBlendShape {
98    /// Name of the blend shape.
99    pub name: String,
100    /// Position offsets for affected vertices.
101    pub offsets: Vec<[f64; 3]>,
102    /// Indices of vertices that are affected.
103    pub point_indices: Vec<i32>,
104}
105
106// ── Writer ───────────────────────────────────────────────────────────────────
107
108/// USDA text format writer.
109///
110/// Builds a `.usda` file incrementally. Call methods in the order dictated
111/// by the USD hierarchy you wish to produce, then call [`finish`](Self::finish)
112/// to obtain the final string.
113pub struct UsdaWriter {
114    output: String,
115    indent_level: usize,
116}
117
118impl Default for UsdaWriter {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl UsdaWriter {
125    // ── Construction ─────────────────────────────────────────────────────
126
127    /// Create a new, empty writer.
128    pub fn new() -> Self {
129        Self {
130            output: String::with_capacity(4096),
131            indent_level: 0,
132        }
133    }
134
135    // ── Header ───────────────────────────────────────────────────────────
136
137    /// Write the USDA file header with layer metadata.
138    ///
139    /// `up_axis` should be `"Y"` or `"Z"`.
140    pub fn write_header(&mut self, up_axis: &str, meters_per_unit: f64) {
141        self.output.push_str("#usda 1.0\n(\n");
142        let _ = writeln!(self.output, "    upAxis = \"{}\"", up_axis);
143        let _ = writeln!(
144            self.output,
145            "    metersPerUnit = {:.6}",
146            meters_per_unit
147        );
148        self.output.push_str(")\n\n");
149    }
150
151    // ── Scope / Def helpers ──────────────────────────────────────────────
152
153    /// Open a `def <kind> "<name>" { ... }` block.
154    pub fn begin_def(&mut self, kind: &str, name: &str) {
155        self.write_indent();
156        let _ = writeln!(self.output, "def {} \"{}\" {{", kind, name);
157        self.indent_level += 1;
158    }
159
160    /// Close the most recent `def` block.
161    pub fn end_def(&mut self) {
162        if self.indent_level > 0 {
163            self.indent_level -= 1;
164        }
165        self.write_indent();
166        self.output.push_str("}\n");
167    }
168
169    // ── Mesh ─────────────────────────────────────────────────────────────
170
171    /// Write a complete Mesh prim.
172    pub fn write_mesh(&mut self, mesh: &UsdMesh) -> anyhow::Result<()> {
173        self.begin_def("Mesh", &mesh.name);
174
175        // subdivision scheme
176        self.write_indent();
177        let _ = writeln!(
178            self.output,
179            "uniform token subdivisionScheme = \"{}\"",
180            mesh.subdivision_scheme.as_token()
181        );
182
183        // points
184        self.write_indent();
185        self.output.push_str("point3f[] points = ");
186        self.write_f64x3_array(&mesh.positions);
187        self.output.push('\n');
188
189        // normals
190        if !mesh.normals.is_empty() {
191            self.write_indent();
192            self.output.push_str("normal3f[] normals = ");
193            self.write_f64x3_array(&mesh.normals);
194            self.output.push_str(" (\n");
195            self.write_indent();
196            self.output.push_str("    interpolation = \"faceVarying\"\n");
197            self.write_indent();
198            self.output.push_str(")\n");
199        }
200
201        // UVs
202        if !mesh.uvs.is_empty() {
203            self.write_indent();
204            self.output.push_str("texCoord2f[] primvars:st = ");
205            self.write_f64x2_array(&mesh.uvs);
206            self.output.push_str(" (\n");
207            self.write_indent();
208            self.output.push_str("    interpolation = \"faceVarying\"\n");
209            self.write_indent();
210            self.output.push_str(")\n");
211        }
212
213        // face vertex counts
214        self.write_indent();
215        self.output.push_str("int[] faceVertexCounts = ");
216        self.write_i32_array(&mesh.face_vertex_counts);
217        self.output.push('\n');
218
219        // face vertex indices
220        self.write_indent();
221        self.output.push_str("int[] faceVertexIndices = ");
222        self.write_i32_array(&mesh.face_vertex_indices);
223        self.output.push('\n');
224
225        self.end_def();
226        Ok(())
227    }
228
229    // ── Material ─────────────────────────────────────────────────────────
230
231    /// Write a Material prim with a UsdPreviewSurface shader.
232    pub fn write_material(&mut self, mat: &UsdMaterial) -> anyhow::Result<()> {
233        self.begin_def("Material", &mat.name);
234
235        // surface output
236        self.write_indent();
237        let _ = writeln!(
238            self.output,
239            "token outputs:surface.connect = </{}/PBRShader.outputs:surface>",
240            mat.name
241        );
242
243        // PBR shader
244        self.begin_def("Shader", "PBRShader");
245
246        self.write_indent();
247        self.output
248            .push_str("uniform token info:id = \"UsdPreviewSurface\"\n");
249
250        self.write_indent();
251        let _ = writeln!(
252            self.output,
253            "color3f inputs:diffuseColor = ({:.6}, {:.6}, {:.6})",
254            mat.diffuse_color[0], mat.diffuse_color[1], mat.diffuse_color[2]
255        );
256
257        self.write_indent();
258        let _ = writeln!(
259            self.output,
260            "float inputs:metallic = {:.6}",
261            mat.metallic
262        );
263
264        self.write_indent();
265        let _ = writeln!(
266            self.output,
267            "float inputs:roughness = {:.6}",
268            mat.roughness
269        );
270
271        self.write_indent();
272        let _ = writeln!(self.output, "float inputs:opacity = {:.6}", mat.opacity);
273
274        self.write_indent();
275        let _ = writeln!(
276            self.output,
277            "float inputs:normal = {:.6}",
278            mat.normal_scale
279        );
280
281        self.write_indent();
282        self.output
283            .push_str("token outputs:surface\n");
284
285        self.end_def(); // Shader
286
287        self.end_def(); // Material
288        Ok(())
289    }
290
291    // ── Skeleton ─────────────────────────────────────────────────────────
292
293    /// Write a Skeleton prim.
294    pub fn write_skeleton(&mut self, skel: &UsdSkeleton) -> anyhow::Result<()> {
295        self.begin_def("Skeleton", "Skeleton");
296
297        // joint names
298        self.write_indent();
299        self.output.push_str("uniform token[] joints = ");
300        self.write_string_array(&skel.joint_paths);
301        self.output.push('\n');
302
303        // joint names (display)
304        self.write_indent();
305        self.output.push_str("uniform token[] jointNames = ");
306        self.write_string_array(&skel.joint_names);
307        self.output.push('\n');
308
309        // bind transforms
310        self.write_indent();
311        self.output
312            .push_str("matrix4d[] bindTransforms = ");
313        self.write_matrix4d_array(&skel.bind_transforms);
314        self.output.push('\n');
315
316        // rest transforms
317        self.write_indent();
318        self.output
319            .push_str("matrix4d[] restTransforms = ");
320        self.write_matrix4d_array(&skel.rest_transforms);
321        self.output.push('\n');
322
323        self.end_def();
324        Ok(())
325    }
326
327    // ── Skin binding ─────────────────────────────────────────────────────
328
329    /// Write skin binding properties on an existing mesh prim.
330    ///
331    /// This writes a `SkelBindingAPI` block that should appear inside or
332    /// adjacent to the mesh prim identified by `mesh_path`.
333    pub fn write_skin_binding(
334        &mut self,
335        mesh_path: &str,
336        binding: &UsdSkinBinding,
337    ) -> anyhow::Result<()> {
338        self.begin_def("SkelBindingAPI", &format!("{}_SkelBinding", sanitise_name(mesh_path)));
339
340        // skeleton path
341        self.write_indent();
342        let _ = writeln!(
343            self.output,
344            "uniform token primvars:skel:skeleton = \"{}\"",
345            binding.skeleton_path
346        );
347
348        // Flatten joint indices and weights to a fixed element size.
349        // USD requires uniform element size, so we find the maximum number
350        // of influences and pad shorter entries with 0.
351        let element_size = binding
352            .joint_indices
353            .iter()
354            .map(|v| v.len())
355            .max()
356            .unwrap_or(0);
357
358        if element_size > 0 {
359            // joint indices (flattened)
360            self.write_indent();
361            self.output
362                .push_str("int[] primvars:skel:jointIndices = ");
363            self.write_flat_joint_indices(&binding.joint_indices, element_size);
364            self.output.push_str(" (\n");
365            self.write_indent();
366            let _ = writeln!(self.output, "    elementSize = {}", element_size);
367            self.write_indent();
368            self.output.push_str("    interpolation = \"vertex\"\n");
369            self.write_indent();
370            self.output.push_str(")\n");
371
372            // joint weights (flattened)
373            self.write_indent();
374            self.output
375                .push_str("float[] primvars:skel:jointWeights = ");
376            self.write_flat_joint_weights(&binding.joint_weights, element_size);
377            self.output.push_str(" (\n");
378            self.write_indent();
379            let _ = writeln!(self.output, "    elementSize = {}", element_size);
380            self.write_indent();
381            self.output.push_str("    interpolation = \"vertex\"\n");
382            self.write_indent();
383            self.output.push_str(")\n");
384        }
385
386        self.end_def();
387        Ok(())
388    }
389
390    // ── Blend shapes ─────────────────────────────────────────────────────
391
392    /// Write blend shape prims under the given mesh path scope.
393    pub fn write_blend_shapes(
394        &mut self,
395        mesh_path: &str,
396        shapes: &[UsdBlendShape],
397    ) -> anyhow::Result<()> {
398        if shapes.is_empty() {
399            return Ok(());
400        }
401
402        self.begin_def(
403            "Scope",
404            &format!("{}_BlendShapes", sanitise_name(mesh_path)),
405        );
406
407        // Collect blend shape names for the targets relationship.
408        let target_names: Vec<String> = shapes
409            .iter()
410            .map(|s| format!("<./{}>", s.name))
411            .collect();
412
413        self.write_indent();
414        let _ = write!(self.output, "uniform token[] blendShapes = [");
415        for (i, shape) in shapes.iter().enumerate() {
416            if i > 0 {
417                self.output.push_str(", ");
418            }
419            let _ = write!(self.output, "\"{}\"", shape.name);
420        }
421        self.output.push_str("]\n");
422
423        self.write_indent();
424        let _ = write!(self.output, "uniform rel blendShapeTargets = [");
425        for (i, target) in target_names.iter().enumerate() {
426            if i > 0 {
427                self.output.push_str(", ");
428            }
429            self.output.push_str(target);
430        }
431        self.output.push_str("]\n");
432
433        // Write each blend shape
434        for shape in shapes {
435            self.begin_def("BlendShape", &shape.name);
436
437            // offsets
438            self.write_indent();
439            self.output.push_str("vector3f[] offsets = ");
440            self.write_f64x3_array(&shape.offsets);
441            self.output.push('\n');
442
443            // point indices
444            self.write_indent();
445            self.output.push_str("int[] pointIndices = ");
446            self.write_i32_array(&shape.point_indices);
447            self.output.push('\n');
448
449            self.end_def();
450        }
451
452        self.end_def(); // Scope
453        Ok(())
454    }
455
456    // ── Xform ────────────────────────────────────────────────────────────
457
458    /// Write an Xform prim with a 4x4 transform matrix.
459    pub fn write_xform(&mut self, name: &str, matrix: &[f64; 16]) -> anyhow::Result<()> {
460        self.begin_def("Xform", name);
461
462        self.write_indent();
463        self.output
464            .push_str("matrix4d xformOp:transform = ");
465        self.write_matrix4d(matrix);
466        self.output.push('\n');
467
468        self.write_indent();
469        self.output
470            .push_str("uniform token[] xformOpOrder = [\"xformOp:transform\"]\n");
471
472        self.end_def();
473        Ok(())
474    }
475
476    // ── Finalise ─────────────────────────────────────────────────────────
477
478    /// Consume the writer and return the complete USDA string.
479    ///
480    /// Any unclosed `def` blocks are closed automatically.
481    pub fn finish(mut self) -> String {
482        // Close any remaining open scopes.
483        while self.indent_level > 0 {
484            self.end_def();
485        }
486        self.output
487    }
488
489    // ── Internal helpers ─────────────────────────────────────────────────
490
491    fn write_indent(&mut self) {
492        for _ in 0..self.indent_level {
493            self.output.push_str("    ");
494        }
495    }
496
497    fn write_f64x3_array(&mut self, data: &[[f64; 3]]) {
498        self.output.push('[');
499        for (i, v) in data.iter().enumerate() {
500            if i > 0 {
501                self.output.push_str(", ");
502            }
503            let _ = write!(
504                self.output,
505                "({:.6}, {:.6}, {:.6})",
506                v[0], v[1], v[2]
507            );
508        }
509        self.output.push(']');
510    }
511
512    fn write_f64x2_array(&mut self, data: &[[f64; 2]]) {
513        self.output.push('[');
514        for (i, v) in data.iter().enumerate() {
515            if i > 0 {
516                self.output.push_str(", ");
517            }
518            let _ = write!(self.output, "({:.6}, {:.6})", v[0], v[1]);
519        }
520        self.output.push(']');
521    }
522
523    fn write_i32_array(&mut self, data: &[i32]) {
524        self.output.push('[');
525        for (i, v) in data.iter().enumerate() {
526            if i > 0 {
527                self.output.push_str(", ");
528            }
529            let _ = write!(self.output, "{}", v);
530        }
531        self.output.push(']');
532    }
533
534    fn write_string_array(&mut self, data: &[String]) {
535        self.output.push('[');
536        for (i, s) in data.iter().enumerate() {
537            if i > 0 {
538                self.output.push_str(", ");
539            }
540            let _ = write!(self.output, "\"{}\"", s);
541        }
542        self.output.push(']');
543    }
544
545    /// Write a single 4x4 matrix in USDA row-major display format.
546    ///
547    /// USD stores matrices as `( (r00,r01,r02,r03), ... )`.
548    /// Input is a flat 16-element array in row-major order.
549    fn write_matrix4d(&mut self, m: &[f64; 16]) {
550        self.output.push_str("( ");
551        for row in 0..4 {
552            if row > 0 {
553                self.output.push_str(", ");
554            }
555            let base = row * 4;
556            let _ = write!(
557                self.output,
558                "({:.6}, {:.6}, {:.6}, {:.6})",
559                m[base],
560                m[base + 1],
561                m[base + 2],
562                m[base + 3]
563            );
564        }
565        self.output.push_str(" )");
566    }
567
568    fn write_matrix4d_array(&mut self, matrices: &[[f64; 16]]) {
569        self.output.push('[');
570        for (i, m) in matrices.iter().enumerate() {
571            if i > 0 {
572                self.output.push_str(", ");
573            }
574            self.write_matrix4d(m);
575        }
576        self.output.push(']');
577    }
578
579    /// Flatten variable-length joint indices into a fixed-width array padded with 0.
580    fn write_flat_joint_indices(&mut self, data: &[Vec<i32>], element_size: usize) {
581        self.output.push('[');
582        let mut first = true;
583        for indices in data {
584            for j in 0..element_size {
585                if !first {
586                    self.output.push_str(", ");
587                }
588                first = false;
589                let val = if j < indices.len() { indices[j] } else { 0 };
590                let _ = write!(self.output, "{}", val);
591            }
592        }
593        self.output.push(']');
594    }
595
596    /// Flatten variable-length joint weights into a fixed-width array padded with 0.0.
597    fn write_flat_joint_weights(&mut self, data: &[Vec<f64>], element_size: usize) {
598        self.output.push('[');
599        let mut first = true;
600        for weights in data {
601            for j in 0..element_size {
602                if !first {
603                    self.output.push_str(", ");
604                }
605                first = false;
606                let val = if j < weights.len() {
607                    weights[j]
608                } else {
609                    0.0
610                };
611                let _ = write!(self.output, "{:.6}", val);
612            }
613        }
614        self.output.push(']');
615    }
616}
617
618// ── Utility ──────────────────────────────────────────────────────────────────
619
620/// Sanitise a scene path into a valid prim name (replace `/` and spaces).
621fn sanitise_name(path: &str) -> String {
622    path.chars()
623        .map(|c| match c {
624            '/' | ' ' | '.' => '_',
625            _ => c,
626        })
627        .collect()
628}
629
630// ── Blend shape animation ────────────────────────────────────────────────────
631
632/// Time-sampled weight data for a single blend shape used in a `SkelAnimation` prim.
633///
634/// Each entry pairs a time code (in USD time units, typically frames) with the
635/// normalised blend weight (0.0 = no effect, 1.0 = full effect) for the named
636/// shape at that moment.  The `time_weight_pairs` slice does not need to be
637/// pre-sorted — [`UsdaWriter::write_blend_shape_animation`] will sort by time
638/// internally before emitting the `timeSamples` block.
639pub struct BlendShapeTimeSamples {
640    /// Name of this blend shape (e.g. `"smile"`, `"frown"`).
641    pub shape_name: String,
642    /// `(time_code, weight)` pairs — will be sorted ascending by time code.
643    pub time_weight_pairs: Vec<(f64, f32)>,
644}
645
646impl UsdaWriter {
647    /// Write a `SkelAnimation` prim with `blendShapeWeights.timeSamples`.
648    ///
649    /// The prim is named `"BodyAnim"` and contains:
650    ///
651    /// * `uniform token purpose = "default"`
652    /// * `uniform token[] blendShapes` — ordered by first appearance across all
653    ///   `BlendShapeTimeSamples` entries.
654    /// * `float[] blendShapeWeights.timeSamples` — every unique time code from
655    ///   all samples, sorted ascending; shapes absent at a given time emit `0.0`.
656    /// * `rel skelTargets = <{mesh_path}>` — where `mesh_path` is the USD scene
657    ///   path passed as the second argument (e.g. `"/Root/Body"`).
658    ///
659    /// # Errors
660    ///
661    /// Returns an error if formatting the output buffer fails (highly unlikely
662    /// in practice because [`String::write_fmt`] is infallible for in-memory
663    /// buffers, but the method signature is `anyhow::Result<()>` for
664    /// consistency with the rest of the writer API).
665    pub fn write_blend_shape_animation(
666        &mut self,
667        mesh_path: &str,
668        samples: &[BlendShapeTimeSamples],
669    ) -> anyhow::Result<()> {
670        // ── Step 1: collect unique shape names in first-appearance order ──────
671        let mut shape_names: Vec<&str> = Vec::new();
672        for s in samples {
673            let name = s.shape_name.as_str();
674            if !shape_names.contains(&name) {
675                shape_names.push(name);
676            }
677        }
678
679        // ── Step 2: collect all unique time codes, sorted ascending ───────────
680        let mut time_codes: Vec<f64> = Vec::new();
681        for s in samples {
682            for &(t, _) in &s.time_weight_pairs {
683                // Use integer-comparison tolerance: treat times equal when their
684                // bit representation agrees (exact f64 equality is fine here
685                // because time codes are typically integers cast to f64).
686                if !time_codes.contains(&t) {
687                    time_codes.push(t);
688                }
689            }
690        }
691        time_codes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
692
693        // ── Step 3: build a lookup: shape_name → time → weight ───────────────
694        // Using a Vec of Vec rather than HashMap to stay allocation-light and
695        // maintain insertion order without extra dependencies.
696        // shape_weights[shape_idx][time_idx] = weight (defaulting to 0.0)
697        let n_shapes = shape_names.len();
698        let n_times = time_codes.len();
699
700        // Allocate a flat matrix (n_shapes × n_times) initialised to 0.0.
701        let mut weight_matrix: Vec<f32> = vec![0.0_f32; n_shapes * n_times];
702
703        for s in samples {
704            let shape_idx = shape_names
705                .iter()
706                .position(|&n| n == s.shape_name.as_str())
707                .ok_or_else(|| anyhow::anyhow!("shape '{}' not in name list", s.shape_name))?;
708
709            for &(t, w) in &s.time_weight_pairs {
710                let time_idx = time_codes
711                    .iter()
712                    .position(|&tc| tc == t)
713                    .ok_or_else(|| anyhow::anyhow!("time code {} not in time list", t))?;
714                weight_matrix[shape_idx * n_times + time_idx] = w;
715            }
716        }
717
718        // ── Step 4: write the SkelAnimation prim ─────────────────────────────
719        self.begin_def("SkelAnimation", "BodyAnim");
720
721        // purpose
722        self.write_indent();
723        self.output
724            .push_str("uniform token purpose = \"default\"\n");
725
726        // blendShapes array
727        self.write_indent();
728        self.output.push_str("uniform token[] blendShapes = [");
729        for (i, name) in shape_names.iter().enumerate() {
730            if i > 0 {
731                self.output.push_str(", ");
732            }
733            let _ = write!(self.output, "\"{}\"", name);
734        }
735        self.output.push_str("]\n");
736
737        // blendShapeWeights.timeSamples
738        self.write_indent();
739        self.output
740            .push_str("float[] blendShapeWeights.timeSamples = {\n");
741
742        for (ti, &tc) in time_codes.iter().enumerate() {
743            self.write_indent();
744            // Format time code: drop the decimal point when it is a whole number.
745            let tc_str = if tc.fract() == 0.0 {
746                format!("{}", tc as i64)
747            } else {
748                format!("{}", tc)
749            };
750            let _ = write!(self.output, "    {}: [", tc_str);
751            for si in 0..n_shapes {
752                if si > 0 {
753                    self.output.push_str(", ");
754                }
755                let w = weight_matrix[si * n_times + ti];
756                // Emit as single decimal (e.g. "0.0", "1.0", "0.5").
757                let _ = write!(self.output, "{}", format_weight(w));
758            }
759            self.output.push_str("]\n");
760        }
761
762        self.write_indent();
763        self.output.push_str("}\n");
764
765        // skelTargets relationship
766        self.write_indent();
767        let _ = writeln!(self.output, "rel skelTargets = <{}>", mesh_path);
768
769        self.end_def(); // SkelAnimation
770        Ok(())
771    }
772}
773
774/// Format a blend weight value compactly: strip trailing zeros after the
775/// decimal point but always keep at least one decimal digit so the value
776/// is unambiguously a float in USDA syntax.
777fn format_weight(w: f32) -> String {
778    // Round to 6 decimal places to avoid floating-point noise.
779    let s = format!("{:.6}", w);
780    let s = s.trim_end_matches('0');
781    // Ensure at least one digit after the decimal point.
782    if s.ends_with('.') {
783        format!("{}0", s)
784    } else {
785        s.to_string()
786    }
787}
788
789// ── Tests ────────────────────────────────────────────────────────────────────
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794
795    fn identity_matrix() -> [f64; 16] {
796        [
797            1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
798        ]
799    }
800
801    fn sample_mesh() -> UsdMesh {
802        UsdMesh {
803            name: "Body".to_string(),
804            positions: vec![
805                [0.0, 0.0, 0.0],
806                [1.0, 0.0, 0.0],
807                [1.0, 1.0, 0.0],
808                [0.0, 1.0, 0.0],
809            ],
810            normals: vec![
811                [0.0, 0.0, 1.0],
812                [0.0, 0.0, 1.0],
813                [0.0, 0.0, 1.0],
814                [0.0, 0.0, 1.0],
815                [0.0, 0.0, 1.0],
816                [0.0, 0.0, 1.0],
817            ],
818            uvs: vec![
819                [0.0, 0.0],
820                [1.0, 0.0],
821                [1.0, 1.0],
822                [0.0, 0.0],
823                [1.0, 1.0],
824                [0.0, 1.0],
825            ],
826            face_vertex_counts: vec![3, 3],
827            face_vertex_indices: vec![0, 1, 2, 0, 2, 3],
828            subdivision_scheme: UsdSubdivScheme::None,
829        }
830    }
831
832    fn sample_material() -> UsdMaterial {
833        UsdMaterial {
834            name: "Skin".to_string(),
835            diffuse_color: [0.8, 0.6, 0.5],
836            metallic: 0.0,
837            roughness: 0.7,
838            opacity: 1.0,
839            normal_scale: 1.0,
840        }
841    }
842
843    fn sample_skeleton() -> UsdSkeleton {
844        UsdSkeleton {
845            joint_names: vec!["Hips".to_string(), "Spine".to_string()],
846            joint_paths: vec!["Hips".to_string(), "Hips/Spine".to_string()],
847            bind_transforms: vec![identity_matrix(), identity_matrix()],
848            rest_transforms: vec![identity_matrix(), identity_matrix()],
849        }
850    }
851
852    // ── Header tests ─────────────────────────────────────────────────────
853
854    #[test]
855    fn test_header_contains_magic() {
856        let mut w = UsdaWriter::new();
857        w.write_header("Y", 1.0);
858        let out = w.finish();
859        assert!(out.starts_with("#usda 1.0"), "must start with #usda 1.0");
860    }
861
862    #[test]
863    fn test_header_up_axis_y() {
864        let mut w = UsdaWriter::new();
865        w.write_header("Y", 1.0);
866        let out = w.finish();
867        assert!(out.contains("upAxis = \"Y\""));
868    }
869
870    #[test]
871    fn test_header_up_axis_z() {
872        let mut w = UsdaWriter::new();
873        w.write_header("Z", 0.01);
874        let out = w.finish();
875        assert!(out.contains("upAxis = \"Z\""));
876        assert!(out.contains("metersPerUnit = 0.010000"));
877    }
878
879    // ── Def block tests ──────────────────────────────────────────────────
880
881    #[test]
882    fn test_begin_end_def() {
883        let mut w = UsdaWriter::new();
884        w.begin_def("Xform", "Root");
885        w.end_def();
886        let out = w.finish();
887        assert!(out.contains("def Xform \"Root\" {"));
888        assert!(out.contains('}'));
889    }
890
891    #[test]
892    fn test_nested_def() {
893        let mut w = UsdaWriter::new();
894        w.begin_def("Xform", "Root");
895        w.begin_def("Xform", "Child");
896        w.end_def();
897        w.end_def();
898        let out = w.finish();
899        assert!(out.contains("def Xform \"Root\""));
900        assert!(out.contains("    def Xform \"Child\""));
901    }
902
903    #[test]
904    fn test_finish_closes_unclosed_defs() {
905        let mut w = UsdaWriter::new();
906        w.begin_def("Xform", "A");
907        w.begin_def("Xform", "B");
908        // deliberately not closing
909        let out = w.finish();
910        let close_count = out.matches('}').count();
911        assert!(close_count >= 2, "finish must auto-close open defs");
912    }
913
914    // ── Mesh tests ───────────────────────────────────────────────────────
915
916    #[test]
917    fn test_write_mesh_contains_points() {
918        let mut w = UsdaWriter::new();
919        w.write_header("Y", 1.0);
920        w.begin_def("Xform", "Root");
921        w.write_mesh(&sample_mesh()).expect("write_mesh");
922        w.end_def();
923        let out = w.finish();
924        assert!(out.contains("point3f[] points = "));
925        assert!(out.contains("(0.000000, 0.000000, 0.000000)"));
926        assert!(out.contains("(1.000000, 0.000000, 0.000000)"));
927    }
928
929    #[test]
930    fn test_write_mesh_contains_normals() {
931        let mut w = UsdaWriter::new();
932        w.write_mesh(&sample_mesh()).expect("write_mesh");
933        let out = w.finish();
934        assert!(out.contains("normal3f[] normals = "));
935        assert!(out.contains("interpolation = \"faceVarying\""));
936    }
937
938    #[test]
939    fn test_write_mesh_contains_uvs() {
940        let mut w = UsdaWriter::new();
941        w.write_mesh(&sample_mesh()).expect("write_mesh");
942        let out = w.finish();
943        assert!(out.contains("texCoord2f[] primvars:st = "));
944    }
945
946    #[test]
947    fn test_write_mesh_contains_face_data() {
948        let mut w = UsdaWriter::new();
949        w.write_mesh(&sample_mesh()).expect("write_mesh");
950        let out = w.finish();
951        assert!(out.contains("int[] faceVertexCounts = [3, 3]"));
952        assert!(out.contains("int[] faceVertexIndices = [0, 1, 2, 0, 2, 3]"));
953    }
954
955    #[test]
956    fn test_write_mesh_subdivision_scheme() {
957        let mut mesh = sample_mesh();
958        mesh.subdivision_scheme = UsdSubdivScheme::CatmullClark;
959        let mut w = UsdaWriter::new();
960        w.write_mesh(&mesh).expect("write_mesh");
961        let out = w.finish();
962        assert!(out.contains("subdivisionScheme = \"catmullClark\""));
963    }
964
965    #[test]
966    fn test_write_mesh_no_normals() {
967        let mut mesh = sample_mesh();
968        mesh.normals.clear();
969        let mut w = UsdaWriter::new();
970        w.write_mesh(&mesh).expect("write_mesh");
971        let out = w.finish();
972        assert!(
973            !out.contains("normal3f[]"),
974            "no normals section if empty"
975        );
976    }
977
978    #[test]
979    fn test_write_mesh_no_uvs() {
980        let mut mesh = sample_mesh();
981        mesh.uvs.clear();
982        let mut w = UsdaWriter::new();
983        w.write_mesh(&mesh).expect("write_mesh");
984        let out = w.finish();
985        assert!(
986            !out.contains("texCoord2f[]"),
987            "no UVs section if empty"
988        );
989    }
990
991    // ── Material tests ───────────────────────────────────────────────────
992
993    #[test]
994    fn test_write_material_basic() {
995        let mut w = UsdaWriter::new();
996        w.write_material(&sample_material()).expect("write_material");
997        let out = w.finish();
998        assert!(out.contains("def Material \"Skin\""));
999        assert!(out.contains("UsdPreviewSurface"));
1000        assert!(out.contains("diffuseColor"));
1001        assert!(out.contains("metallic"));
1002        assert!(out.contains("roughness"));
1003        assert!(out.contains("opacity"));
1004    }
1005
1006    #[test]
1007    fn test_write_material_diffuse_values() {
1008        let mut w = UsdaWriter::new();
1009        w.write_material(&sample_material()).expect("write_material");
1010        let out = w.finish();
1011        assert!(out.contains("0.800000"));
1012        assert!(out.contains("0.600000"));
1013        assert!(out.contains("0.500000"));
1014    }
1015
1016    #[test]
1017    fn test_write_material_surface_output() {
1018        let mut w = UsdaWriter::new();
1019        w.write_material(&sample_material()).expect("write_material");
1020        let out = w.finish();
1021        assert!(out.contains("outputs:surface"));
1022    }
1023
1024    // ── Skeleton tests ───────────────────────────────────────────────────
1025
1026    #[test]
1027    fn test_write_skeleton_basic() {
1028        let mut w = UsdaWriter::new();
1029        w.write_skeleton(&sample_skeleton()).expect("write_skeleton");
1030        let out = w.finish();
1031        assert!(out.contains("def Skeleton \"Skeleton\""));
1032        assert!(out.contains("uniform token[] joints"));
1033        assert!(out.contains("\"Hips\""));
1034        assert!(out.contains("\"Hips/Spine\""));
1035    }
1036
1037    #[test]
1038    fn test_write_skeleton_transforms() {
1039        let mut w = UsdaWriter::new();
1040        w.write_skeleton(&sample_skeleton()).expect("write_skeleton");
1041        let out = w.finish();
1042        assert!(out.contains("matrix4d[] bindTransforms"));
1043        assert!(out.contains("matrix4d[] restTransforms"));
1044        // Check identity matrix values
1045        assert!(out.contains("1.000000"));
1046    }
1047
1048    #[test]
1049    fn test_write_skeleton_joint_names() {
1050        let mut w = UsdaWriter::new();
1051        w.write_skeleton(&sample_skeleton()).expect("write_skeleton");
1052        let out = w.finish();
1053        assert!(out.contains("jointNames"));
1054        assert!(out.contains("\"Hips\""));
1055        assert!(out.contains("\"Spine\""));
1056    }
1057
1058    // ── Skin binding tests ───────────────────────────────────────────────
1059
1060    #[test]
1061    fn test_write_skin_binding_basic() {
1062        let binding = UsdSkinBinding {
1063            joint_indices: vec![vec![0, 1], vec![0], vec![1, 0]],
1064            joint_weights: vec![vec![0.7, 0.3], vec![1.0], vec![0.5, 0.5]],
1065            skeleton_path: "/Root/Skeleton".to_string(),
1066        };
1067        let mut w = UsdaWriter::new();
1068        w.write_skin_binding("/Root/Body", &binding)
1069            .expect("write_skin_binding");
1070        let out = w.finish();
1071        assert!(out.contains("primvars:skel:skeleton"));
1072        assert!(out.contains("/Root/Skeleton"));
1073    }
1074
1075    #[test]
1076    fn test_write_skin_binding_joint_indices_flattened() {
1077        let binding = UsdSkinBinding {
1078            joint_indices: vec![vec![0, 1], vec![0]],
1079            joint_weights: vec![vec![0.7, 0.3], vec![1.0]],
1080            skeleton_path: "/Root/Skeleton".to_string(),
1081        };
1082        let mut w = UsdaWriter::new();
1083        w.write_skin_binding("/Root/Body", &binding)
1084            .expect("write_skin_binding");
1085        let out = w.finish();
1086        // Second vertex has only one index, should be padded with 0
1087        assert!(out.contains("primvars:skel:jointIndices"));
1088        assert!(out.contains("elementSize = 2"));
1089    }
1090
1091    #[test]
1092    fn test_write_skin_binding_weights_flattened() {
1093        let binding = UsdSkinBinding {
1094            joint_indices: vec![vec![0, 1], vec![0]],
1095            joint_weights: vec![vec![0.7, 0.3], vec![1.0]],
1096            skeleton_path: "/Skel".to_string(),
1097        };
1098        let mut w = UsdaWriter::new();
1099        w.write_skin_binding("/Mesh", &binding)
1100            .expect("write_skin_binding");
1101        let out = w.finish();
1102        assert!(out.contains("primvars:skel:jointWeights"));
1103        assert!(out.contains("0.700000"));
1104        assert!(out.contains("0.300000"));
1105        assert!(out.contains("1.000000"));
1106    }
1107
1108    #[test]
1109    fn test_write_skin_binding_empty() {
1110        let binding = UsdSkinBinding {
1111            joint_indices: vec![],
1112            joint_weights: vec![],
1113            skeleton_path: "/Skel".to_string(),
1114        };
1115        let mut w = UsdaWriter::new();
1116        w.write_skin_binding("/Mesh", &binding)
1117            .expect("write_skin_binding");
1118        let out = w.finish();
1119        // No jointIndices or jointWeights if empty
1120        assert!(!out.contains("primvars:skel:jointIndices"));
1121    }
1122
1123    // ── Blend shape tests ────────────────────────────────────────────────
1124
1125    #[test]
1126    fn test_write_blend_shapes_basic() {
1127        let shapes = vec![
1128            UsdBlendShape {
1129                name: "Smile".to_string(),
1130                offsets: vec![[0.1, 0.2, 0.0], [0.05, 0.1, 0.0]],
1131                point_indices: vec![10, 11],
1132            },
1133            UsdBlendShape {
1134                name: "Frown".to_string(),
1135                offsets: vec![[-0.1, -0.2, 0.0]],
1136                point_indices: vec![10],
1137            },
1138        ];
1139        let mut w = UsdaWriter::new();
1140        w.write_blend_shapes("/Root/Body", &shapes)
1141            .expect("write_blend_shapes");
1142        let out = w.finish();
1143        assert!(out.contains("def BlendShape \"Smile\""));
1144        assert!(out.contains("def BlendShape \"Frown\""));
1145        assert!(out.contains("vector3f[] offsets"));
1146        assert!(out.contains("int[] pointIndices"));
1147    }
1148
1149    #[test]
1150    fn test_write_blend_shapes_names_array() {
1151        let shapes = vec![UsdBlendShape {
1152            name: "Open".to_string(),
1153            offsets: vec![[0.0, 0.1, 0.0]],
1154            point_indices: vec![5],
1155        }];
1156        let mut w = UsdaWriter::new();
1157        w.write_blend_shapes("/Mesh", &shapes)
1158            .expect("write_blend_shapes");
1159        let out = w.finish();
1160        assert!(out.contains("blendShapes = [\"Open\"]"));
1161    }
1162
1163    #[test]
1164    fn test_write_blend_shapes_targets_rel() {
1165        let shapes = vec![
1166            UsdBlendShape {
1167                name: "A".to_string(),
1168                offsets: vec![[1.0, 0.0, 0.0]],
1169                point_indices: vec![0],
1170            },
1171            UsdBlendShape {
1172                name: "B".to_string(),
1173                offsets: vec![[0.0, 1.0, 0.0]],
1174                point_indices: vec![1],
1175            },
1176        ];
1177        let mut w = UsdaWriter::new();
1178        w.write_blend_shapes("/M", &shapes)
1179            .expect("write_blend_shapes");
1180        let out = w.finish();
1181        assert!(out.contains("blendShapeTargets = [<./A>, <./B>]"));
1182    }
1183
1184    #[test]
1185    fn test_write_blend_shapes_empty() {
1186        let mut w = UsdaWriter::new();
1187        w.write_blend_shapes("/Mesh", &[])
1188            .expect("write_blend_shapes");
1189        let out = w.finish();
1190        assert!(
1191            !out.contains("BlendShape"),
1192            "empty shapes should produce no output"
1193        );
1194    }
1195
1196    // ── Xform tests ──────────────────────────────────────────────────────
1197
1198    #[test]
1199    fn test_write_xform_basic() {
1200        let mut w = UsdaWriter::new();
1201        w.write_xform("Root", &identity_matrix())
1202            .expect("write_xform");
1203        let out = w.finish();
1204        assert!(out.contains("def Xform \"Root\""));
1205        assert!(out.contains("matrix4d xformOp:transform"));
1206        assert!(out.contains("xformOpOrder"));
1207    }
1208
1209    #[test]
1210    fn test_write_xform_matrix_values() {
1211        let mat = [
1212            2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0, 2.0, 3.0, 1.0,
1213        ];
1214        let mut w = UsdaWriter::new();
1215        w.write_xform("Scaled", &mat).expect("write_xform");
1216        let out = w.finish();
1217        assert!(out.contains("2.000000"));
1218        assert!(out.contains("3.000000"));
1219    }
1220
1221    // ── Integration / round-trip tests ───────────────────────────────────
1222
1223    #[test]
1224    fn test_full_scene() {
1225        let mut w = UsdaWriter::new();
1226        w.write_header("Y", 1.0);
1227        w.begin_def("Xform", "Root");
1228        w.write_mesh(&sample_mesh()).expect("write_mesh");
1229        w.write_material(&sample_material()).expect("write_material");
1230        w.end_def();
1231        let out = w.finish();
1232
1233        assert!(out.starts_with("#usda 1.0"));
1234        assert!(out.contains("def Xform \"Root\""));
1235        assert!(out.contains("def Mesh \"Body\""));
1236        assert!(out.contains("def Material \"Skin\""));
1237    }
1238
1239    #[test]
1240    fn test_full_scene_with_skeleton_and_skin() {
1241        let mut w = UsdaWriter::new();
1242        w.write_header("Y", 1.0);
1243        w.begin_def("Xform", "Root");
1244        w.write_mesh(&sample_mesh()).expect("write_mesh");
1245        w.write_skeleton(&sample_skeleton()).expect("write_skeleton");
1246        let binding = UsdSkinBinding {
1247            joint_indices: vec![vec![0], vec![0, 1], vec![1], vec![0, 1]],
1248            joint_weights: vec![vec![1.0], vec![0.6, 0.4], vec![1.0], vec![0.5, 0.5]],
1249            skeleton_path: "/Root/Skeleton".to_string(),
1250        };
1251        w.write_skin_binding("/Root/Body", &binding)
1252            .expect("write_skin_binding");
1253        w.end_def();
1254        let out = w.finish();
1255
1256        assert!(out.contains("def Skeleton"));
1257        assert!(out.contains("primvars:skel:skeleton"));
1258        assert!(out.contains("primvars:skel:jointIndices"));
1259        assert!(out.contains("primvars:skel:jointWeights"));
1260    }
1261
1262    #[test]
1263    fn test_full_scene_with_blend_shapes() {
1264        let shapes = vec![
1265            UsdBlendShape {
1266                name: "Smile".to_string(),
1267                offsets: vec![[0.1, 0.2, 0.0]],
1268                point_indices: vec![0],
1269            },
1270            UsdBlendShape {
1271                name: "Blink".to_string(),
1272                offsets: vec![[0.0, -0.1, 0.0]],
1273                point_indices: vec![2],
1274            },
1275        ];
1276        let mut w = UsdaWriter::new();
1277        w.write_header("Y", 1.0);
1278        w.begin_def("Xform", "Root");
1279        w.write_mesh(&sample_mesh()).expect("write_mesh");
1280        w.write_blend_shapes("/Root/Body", &shapes)
1281            .expect("write_blend_shapes");
1282        w.end_def();
1283        let out = w.finish();
1284
1285        assert!(out.contains("def BlendShape \"Smile\""));
1286        assert!(out.contains("def BlendShape \"Blink\""));
1287    }
1288
1289    #[test]
1290    fn test_subdiv_scheme_tokens() {
1291        assert_eq!(UsdSubdivScheme::None.as_token(), "none");
1292        assert_eq!(UsdSubdivScheme::CatmullClark.as_token(), "catmullClark");
1293        assert_eq!(UsdSubdivScheme::Loop.as_token(), "loop");
1294        assert_eq!(UsdSubdivScheme::Bilinear.as_token(), "bilinear");
1295    }
1296
1297    #[test]
1298    fn test_sanitise_name() {
1299        assert_eq!(sanitise_name("/Root/Body"), "_Root_Body");
1300        assert_eq!(sanitise_name("hello world"), "hello_world");
1301        assert_eq!(sanitise_name("a.b.c"), "a_b_c");
1302        assert_eq!(sanitise_name("NoChange"), "NoChange");
1303    }
1304
1305    #[test]
1306    fn test_writer_default() {
1307        let w = UsdaWriter::default();
1308        let out = w.finish();
1309        assert!(out.is_empty(), "default writer should produce empty output");
1310    }
1311
1312    #[test]
1313    fn test_end_def_at_zero_indent() {
1314        let mut w = UsdaWriter::new();
1315        // Should not panic even if indent_level is already 0
1316        w.end_def();
1317        let out = w.finish();
1318        assert!(out.contains('}'));
1319    }
1320
1321    #[test]
1322    fn test_multiple_meshes() {
1323        let mut w = UsdaWriter::new();
1324        w.write_header("Y", 1.0);
1325        w.begin_def("Xform", "Root");
1326
1327        let mut mesh1 = sample_mesh();
1328        mesh1.name = "Head".to_string();
1329        w.write_mesh(&mesh1).expect("write_mesh head");
1330
1331        let mut mesh2 = sample_mesh();
1332        mesh2.name = "Hand".to_string();
1333        w.write_mesh(&mesh2).expect("write_mesh hand");
1334
1335        w.end_def();
1336        let out = w.finish();
1337
1338        assert!(out.contains("def Mesh \"Head\""));
1339        assert!(out.contains("def Mesh \"Hand\""));
1340    }
1341
1342    #[test]
1343    fn test_xform_with_translation() {
1344        let mut mat = identity_matrix();
1345        mat[12] = 5.0;
1346        mat[13] = 10.0;
1347        mat[14] = -3.0;
1348        let mut w = UsdaWriter::new();
1349        w.write_xform("Offset", &mat).expect("write_xform");
1350        let out = w.finish();
1351        assert!(out.contains("5.000000"));
1352        assert!(out.contains("10.000000"));
1353        assert!(out.contains("-3.000000"));
1354    }
1355
1356    #[test]
1357    fn test_write_to_file() {
1358        let mut w = UsdaWriter::new();
1359        w.write_header("Y", 1.0);
1360        w.begin_def("Xform", "Root");
1361        w.write_mesh(&sample_mesh()).expect("write_mesh");
1362        w.end_def();
1363        let out = w.finish();
1364
1365        let path = std::env::temp_dir().join("test_usda_export_writer.usda");
1366        std::fs::write(&path, &out).expect("write file");
1367
1368        let read_back = std::fs::read_to_string(&path).expect("read file");
1369        assert_eq!(out, read_back);
1370        assert!(read_back.starts_with("#usda 1.0"));
1371
1372        let _ = std::fs::remove_file(&path);
1373    }
1374
1375    // ── BlendShapeTimeSamples / write_blend_shape_animation tests ────────────
1376
1377    /// Test 1: Single shape, single time code → output contains `timeSamples`.
1378    #[test]
1379    fn test_blend_shape_animation_single_frame() {
1380        let samples = vec![BlendShapeTimeSamples {
1381            shape_name: "smile".to_string(),
1382            time_weight_pairs: vec![(0.0, 1.0)],
1383        }];
1384        let mut w = UsdaWriter::new();
1385        w.write_blend_shape_animation("/Root/Body", &samples)
1386            .expect("write_blend_shape_animation");
1387        let out = w.finish();
1388        assert!(
1389            out.contains("timeSamples"),
1390            "output must contain 'timeSamples'"
1391        );
1392        assert!(
1393            out.contains("\"smile\""),
1394            "output must include shape name 'smile'"
1395        );
1396        assert!(
1397            out.contains("0: ["),
1398            "output must include time code 0"
1399        );
1400    }
1401
1402    /// Test 2: Two shapes, multiple time codes → time codes are sorted ascending.
1403    #[test]
1404    fn test_blend_shape_animation_multi_frame_sorted() {
1405        let samples = vec![
1406            BlendShapeTimeSamples {
1407                shape_name: "smile".to_string(),
1408                // Intentionally un-sorted to verify sorting behaviour.
1409                time_weight_pairs: vec![(24.0, 1.0), (0.0, 0.0), (12.0, 0.5)],
1410            },
1411            BlendShapeTimeSamples {
1412                shape_name: "frown".to_string(),
1413                time_weight_pairs: vec![(0.0, 0.0), (12.0, 0.0), (24.0, 0.0)],
1414            },
1415        ];
1416        let mut w = UsdaWriter::new();
1417        w.write_blend_shape_animation("/Root/Body", &samples)
1418            .expect("write_blend_shape_animation");
1419        let out = w.finish();
1420
1421        // All three time codes must appear.
1422        assert!(out.contains("0: ["), "time 0 must be present");
1423        assert!(out.contains("12: ["), "time 12 must be present");
1424        assert!(out.contains("24: ["), "time 24 must be present");
1425
1426        // Both shape names must appear in the blendShapes array.
1427        assert!(out.contains("\"smile\""), "shape 'smile' must be in output");
1428        assert!(out.contains("\"frown\""), "shape 'frown' must be in output");
1429
1430        // Verify order: the position of "0:" must come before "12:" and "24:".
1431        let pos0 = out.find("0: [").expect("pos of time 0");
1432        let pos12 = out.find("12: [").expect("pos of time 12");
1433        let pos24 = out.find("24: [").expect("pos of time 24");
1434        assert!(pos0 < pos12, "time 0 must appear before time 12");
1435        assert!(pos12 < pos24, "time 12 must appear before time 24");
1436    }
1437
1438    /// Test 3: Output contains `uniform token purpose = "default"`.
1439    #[test]
1440    fn test_blend_shape_animation_contains_purpose_default() {
1441        let samples = vec![BlendShapeTimeSamples {
1442            shape_name: "blink".to_string(),
1443            time_weight_pairs: vec![(1.0, 0.5)],
1444        }];
1445        let mut w = UsdaWriter::new();
1446        w.write_blend_shape_animation("/Root/Face", &samples)
1447            .expect("write_blend_shape_animation");
1448        let out = w.finish();
1449        assert!(
1450            out.contains("uniform token purpose = \"default\""),
1451            "output must contain purpose = \"default\""
1452        );
1453        // Also verify the skelTargets relationship is wired to the mesh path.
1454        assert!(
1455            out.contains("rel skelTargets = </Root/Face>"),
1456            "output must contain rel skelTargets = </Root/Face>"
1457        );
1458    }
1459}