Skip to main content

spatialrust_interchange/
usd.rs

1//! OpenUSD adapter contracts and USDA ASCII mesh interchange (no libusd).
2//!
3//! Native OpenUSD/Hydra bindings remain install-time optional. Enabling
4//! `openusd` provides stage adapters plus a portable `.usda` ASCII codec for
5//! triangle meshes that other USD tools can open.
6
7use spatialrust_scene::TriangleMesh;
8
9use crate::{InterchangeError, InterchangeResult};
10
11/// Hierarchical USD prim path.
12#[derive(Clone, Debug, PartialEq, Eq, Hash)]
13pub struct UsdPrimPath(pub String);
14
15impl UsdPrimPath {
16    /// Creates a validated absolute prim path.
17    pub fn try_new(path: impl Into<String>) -> InterchangeResult<Self> {
18        let path = path.into();
19        if !path.starts_with('/') || path.len() < 2 {
20            return Err(InterchangeError::InvalidConfiguration(
21                "USD prim path must be absolute like /World/Mesh".into(),
22            ));
23        }
24        Ok(Self(path))
25    }
26
27    /// Returns the leaf prim name (`/World/Mesh` → `Mesh`).
28    #[must_use]
29    pub fn leaf_name(&self) -> &str {
30        self.0.rsplit('/').next().unwrap_or(self.0.as_str())
31    }
32}
33
34/// Host-side USD stage description used before optional bindings land.
35#[derive(Clone, Debug, Default, PartialEq)]
36pub struct UsdStageDescription {
37    /// Root layer identifier.
38    pub root_layer: String,
39    /// Declared mesh prim paths.
40    pub mesh_prims: Vec<UsdPrimPath>,
41}
42
43/// Adapter interface for composing / exporting OpenUSD stages.
44pub trait UsdStageAdapter {
45    /// Declares a mesh prim for a triangle mesh.
46    fn declare_mesh(&mut self, path: UsdPrimPath, mesh: &TriangleMesh) -> InterchangeResult<()>;
47
48    /// Returns the stage description.
49    fn description(&self) -> &UsdStageDescription;
50}
51
52/// In-memory USD stage adapter (no OpenUSD native dependency).
53#[derive(Clone, Debug, Default)]
54pub struct MemoryUsdStageAdapter {
55    description: UsdStageDescription,
56    meshes: Vec<(UsdPrimPath, TriangleMesh)>,
57}
58
59impl MemoryUsdStageAdapter {
60    /// Creates an adapter with a root layer id.
61    #[must_use]
62    pub fn new(root_layer: impl Into<String>) -> Self {
63        Self {
64            description: UsdStageDescription {
65                root_layer: root_layer.into(),
66                mesh_prims: Vec::new(),
67            },
68            meshes: Vec::new(),
69        }
70    }
71
72    /// Returns declared meshes.
73    #[must_use]
74    pub fn meshes(&self) -> &[(UsdPrimPath, TriangleMesh)] {
75        &self.meshes
76    }
77
78    /// Exports the stage as USDA ASCII text.
79    pub fn export_usda(&self) -> InterchangeResult<String> {
80        export_stage_usda(self)
81    }
82}
83
84impl UsdStageAdapter for MemoryUsdStageAdapter {
85    fn declare_mesh(&mut self, path: UsdPrimPath, mesh: &TriangleMesh) -> InterchangeResult<()> {
86        if mesh.is_empty() {
87            return Err(InterchangeError::InvalidConfiguration(
88                "cannot declare an empty mesh prim".into(),
89            ));
90        }
91        if mesh.positions.len() % 3 != 0 || mesh.indices.len() % 3 != 0 {
92            return Err(InterchangeError::InvalidConfiguration(
93                "mesh positions/indices must be multiples of 3".into(),
94            ));
95        }
96        self.description.mesh_prims.push(path.clone());
97        self.meshes.push((path, mesh.clone()));
98        Ok(())
99    }
100
101    fn description(&self) -> &UsdStageDescription {
102        &self.description
103    }
104}
105
106/// Exports all meshes from a memory stage as a single USDA ASCII document.
107pub fn export_stage_usda(stage: &MemoryUsdStageAdapter) -> InterchangeResult<String> {
108    if stage.meshes.is_empty() {
109        return Err(InterchangeError::InvalidConfiguration(
110            "stage has no mesh prims to export".into(),
111        ));
112    }
113    let mut out =
114        String::from("#usda 1.0\n(\n    defaultPrim = \"World\"\n)\n\ndef Xform \"World\"\n{\n");
115    for (path, mesh) in &stage.meshes {
116        out.push_str(&format!("    def Mesh \"{}\"\n    {{\n", path.leaf_name()));
117        out.push_str("        point3f[] points = [");
118        for (i, chunk) in mesh.positions.chunks_exact(3).enumerate() {
119            if i > 0 {
120                out.push_str(", ");
121            }
122            out.push_str(&format!("({}, {}, {})", chunk[0], chunk[1], chunk[2]));
123        }
124        out.push_str("]\n");
125        let tri_count = mesh.indices.len() / 3;
126        out.push_str("        int[] faceVertexCounts = [");
127        for i in 0..tri_count {
128            if i > 0 {
129                out.push_str(", ");
130            }
131            out.push('3');
132        }
133        out.push_str("]\n");
134        out.push_str("        int[] faceVertexIndices = [");
135        for (i, idx) in mesh.indices.iter().enumerate() {
136            if i > 0 {
137                out.push_str(", ");
138            }
139            out.push_str(&idx.to_string());
140        }
141        out.push_str("]\n");
142        out.push_str(&format!("        custom string spatialrust:primPath = \"{}\"\n", path.0));
143        out.push_str("    }\n");
144    }
145    out.push_str("}\n");
146    Ok(out)
147}
148
149/// Imports the first SpatialRust-authored Mesh prim from USDA ASCII.
150pub fn import_mesh_from_usda(usda: &str) -> InterchangeResult<(UsdPrimPath, TriangleMesh)> {
151    if !usda.contains("#usda") {
152        return Err(InterchangeError::InvalidConfiguration("missing USDA header".into()));
153    }
154    let path = extract_quoted_after(usda, "spatialrust:primPath = ").or_else(|_| {
155        let leaf = extract_mesh_leaf(usda)?;
156        UsdPrimPath::try_new(format!("/World/{leaf}"))
157    })?;
158    let points_blob = extract_bracket_list(usda, "point3f[] points = ")?;
159    let indices_blob = extract_bracket_list(usda, "int[] faceVertexIndices = ")?;
160    let mut positions = Vec::new();
161    for tok in points_blob.split(['(', ')', ',', ' ']).filter(|t| !t.is_empty()) {
162        let v: f32 = tok.parse().map_err(|_| {
163            InterchangeError::InvalidConfiguration(format!("bad point component `{tok}`"))
164        })?;
165        positions.push(v);
166    }
167    if positions.len() % 3 != 0 {
168        return Err(InterchangeError::InvalidConfiguration(
169            "points length must be a multiple of 3".into(),
170        ));
171    }
172    let mut indices = Vec::new();
173    for tok in indices_blob.split([',', ' ']).filter(|t| !t.is_empty()) {
174        let v: u32 = tok.parse().map_err(|_| {
175            InterchangeError::InvalidConfiguration(format!("bad face index `{tok}`"))
176        })?;
177        indices.push(v);
178    }
179    if indices.len() % 3 != 0 {
180        return Err(InterchangeError::InvalidConfiguration(
181            "faceVertexIndices length must be a multiple of 3".into(),
182        ));
183    }
184    Ok((path, TriangleMesh { positions, indices }))
185}
186
187fn extract_mesh_leaf(usda: &str) -> InterchangeResult<String> {
188    let key = "def Mesh \"";
189    let start = usda
190        .find(key)
191        .ok_or_else(|| InterchangeError::InvalidConfiguration("missing Mesh prim".into()))?
192        + key.len();
193    let end = usda[start..]
194        .find('"')
195        .ok_or_else(|| InterchangeError::InvalidConfiguration("unterminated Mesh name".into()))?
196        + start;
197    Ok(usda[start..end].to_string())
198}
199
200fn extract_quoted_after(usda: &str, marker: &str) -> InterchangeResult<UsdPrimPath> {
201    let start = usda
202        .find(marker)
203        .ok_or_else(|| InterchangeError::InvalidConfiguration(format!("missing {marker}")))?
204        + marker.len();
205    let rest = usda[start..].trim_start();
206    if !rest.starts_with('"') {
207        return Err(InterchangeError::InvalidConfiguration("expected quoted string".into()));
208    }
209    let end = rest[1..]
210        .find('"')
211        .ok_or_else(|| InterchangeError::InvalidConfiguration("unterminated string".into()))?
212        + 1;
213    UsdPrimPath::try_new(rest[1..end].to_string())
214}
215
216fn extract_bracket_list(usda: &str, marker: &str) -> InterchangeResult<String> {
217    let start = usda
218        .find(marker)
219        .ok_or_else(|| InterchangeError::InvalidConfiguration(format!("missing {marker}")))?
220        + marker.len();
221    let rest = &usda[start..];
222    let open = rest
223        .find('[')
224        .ok_or_else(|| InterchangeError::InvalidConfiguration("missing '['".into()))?;
225    let close = rest[open..]
226        .find(']')
227        .ok_or_else(|| InterchangeError::InvalidConfiguration("missing ']'".into()))?
228        + open;
229    Ok(rest[open + 1..close].to_string())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::{
235        export_stage_usda, import_mesh_from_usda, MemoryUsdStageAdapter, UsdPrimPath,
236        UsdStageAdapter,
237    };
238    use spatialrust_scene::TriangleMesh;
239
240    #[test]
241    fn usda_roundtrip_preserves_mesh() {
242        let mut stage = MemoryUsdStageAdapter::new("scene.usda");
243        let mesh = TriangleMesh {
244            positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
245            indices: vec![0, 1, 2],
246        };
247        stage.declare_mesh(UsdPrimPath::try_new("/World/Mesh").unwrap(), &mesh).unwrap();
248        let usda = export_stage_usda(&stage).unwrap();
249        assert!(usda.starts_with("#usda 1.0"));
250        let (path, imported) = import_mesh_from_usda(&usda).unwrap();
251        assert_eq!(path.0, "/World/Mesh");
252        assert_eq!(imported.positions, mesh.positions);
253        assert_eq!(imported.indices, mesh.indices);
254    }
255}