Skip to main content

spatialrust_interchange/
gltf.rs

1//! Minimal glTF 2.0 JSON export/import for triangle meshes (no external crate).
2
3use spatialrust_scene::TriangleMesh;
4
5use crate::{InterchangeError, InterchangeResult};
6
7/// Exports a triangle mesh to a minimal glTF 2.0 JSON document (embedded base64 positions/indices).
8pub fn export_triangle_mesh_gltf_json(mesh: &TriangleMesh) -> InterchangeResult<String> {
9    if mesh.positions.len() % 3 != 0 {
10        return Err(InterchangeError::InvalidConfiguration(
11            "mesh positions length must be a multiple of 3".into(),
12        ));
13    }
14    if mesh.indices.len() % 3 != 0 {
15        return Err(InterchangeError::InvalidConfiguration(
16            "mesh indices length must be a multiple of 3".into(),
17        ));
18    }
19    let pos_bytes = f32_slice_as_bytes(&mesh.positions);
20    let pos_b64 = base64_encode(&pos_bytes);
21    let idx_bytes: Vec<u8> = mesh.indices.iter().flat_map(|v| v.to_le_bytes()).collect();
22    let idx_b64 = base64_encode(&idx_bytes);
23    let vertex_count = mesh.vertex_count();
24    let index_count = mesh.indices.len();
25    // Hand-written minimal glTF JSON without serde.
26    Ok(format!(
27        r#"{{"asset":{{"version":"2.0","generator":"spatialrust-interchange"}},"buffers":[{{"byteLength":{pos_len},"uri":"data:application/octet-stream;base64,{pos_b64}"}},{{"byteLength":{idx_len},"uri":"data:application/octet-stream;base64,{idx_b64}"}}],"bufferViews":[{{"buffer":0,"byteOffset":0,"byteLength":{pos_len},"target":34962}},{{"buffer":1,"byteOffset":0,"byteLength":{idx_len},"target":34963}}],"accessors":[{{"bufferView":0,"componentType":5126,"count":{vertex_count},"type":"VEC3"}},{{"bufferView":1,"componentType":5125,"count":{index_count},"type":"SCALAR"}}],"meshes":[{{"primitives":[{{"attributes":{{"POSITION":0}},"indices":1}}}}],"nodes":[{{"mesh":0}}],"scenes":[{{"nodes":[0]}}],"scene":0}}"#,
28        pos_len = mesh.positions.len() * 4,
29        idx_len = idx_bytes.len(),
30        pos_b64 = pos_b64,
31        idx_b64 = idx_b64,
32        vertex_count = vertex_count,
33        index_count = index_count,
34    ))
35}
36
37/// Imports vertex/index counts from a SpatialRust-exported glTF JSON fragment.
38///
39/// Full binary decode is intentionally limited to validating SpatialRust-authored payloads
40/// that embed `accessors` counts.
41pub fn import_triangle_mesh_gltf_json(json: &str) -> InterchangeResult<(usize, usize)> {
42    let mesh = decode_triangle_mesh_gltf_json(json)?;
43    Ok((mesh.vertex_count(), mesh.indices.len()))
44}
45
46/// Decodes a SpatialRust-exported glTF JSON mesh with embedded base64 buffers.
47///
48/// The portable interchange boundary intentionally accepts only the minimal
49/// glTF shape emitted by [`export_triangle_mesh_gltf_json`]. It does not fetch
50/// external buffers, interpret arbitrary glTF scenes, or apply transforms.
51pub fn decode_triangle_mesh_gltf_json(json: &str) -> InterchangeResult<TriangleMesh> {
52    if !json.contains(r#""version":"2.0""#) {
53        return Err(InterchangeError::InvalidConfiguration(
54            "missing glTF 2.0 asset version".into(),
55        ));
56    }
57    let vertex_count = extract_accessor_count(json, "VEC3")?;
58    let index_count = extract_accessor_count(json, "SCALAR")?;
59    let buffers = extract_embedded_buffers(json)?;
60    if buffers.len() < 2 {
61        return Err(InterchangeError::InvalidConfiguration(
62            "glTF mesh requires embedded position and index buffers".into(),
63        ));
64    }
65    let position_bytes = base64_decode(buffers[0])?;
66    let index_bytes = base64_decode(buffers[1])?;
67    let expected_position_bytes = vertex_count
68        .checked_mul(3)
69        .and_then(|count| count.checked_mul(std::mem::size_of::<f32>()))
70        .ok_or_else(|| {
71            InterchangeError::InvalidConfiguration("position byte count overflow".into())
72        })?;
73    let expected_index_bytes =
74        index_count.checked_mul(std::mem::size_of::<u32>()).ok_or_else(|| {
75            InterchangeError::InvalidConfiguration("index byte count overflow".into())
76        })?;
77    if position_bytes.len() != expected_position_bytes {
78        return Err(InterchangeError::InvalidConfiguration(format!(
79            "position buffer has {} bytes; expected {}",
80            position_bytes.len(),
81            expected_position_bytes
82        )));
83    }
84    if index_bytes.len() != expected_index_bytes {
85        return Err(InterchangeError::InvalidConfiguration(format!(
86            "index buffer has {} bytes; expected {}",
87            index_bytes.len(),
88            expected_index_bytes
89        )));
90    }
91
92    let mut positions = Vec::with_capacity(vertex_count * 3);
93    for chunk in position_bytes.chunks_exact(4) {
94        positions.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]));
95    }
96    let mut indices = Vec::with_capacity(index_count);
97    for chunk in index_bytes.chunks_exact(4) {
98        let index = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
99        if usize::try_from(index).map_or(true, |index| index >= vertex_count) {
100            return Err(InterchangeError::InvalidConfiguration(format!(
101                "mesh index {} is outside {} vertices",
102                index, vertex_count
103            )));
104        }
105        indices.push(index);
106    }
107    Ok(TriangleMesh { positions, indices })
108}
109
110fn extract_accessor_count(json: &str, value_type: &str) -> InterchangeResult<usize> {
111    let marker = format!(r#""type":"{value_type}""#);
112    let idx = json
113        .find(&marker)
114        .ok_or_else(|| InterchangeError::InvalidConfiguration(format!("missing {marker}")))?;
115    let before = &json[..idx];
116    let key = "\"count\":";
117    let count_idx = before
118        .rfind(key)
119        .ok_or_else(|| InterchangeError::InvalidConfiguration("missing accessor count".into()))?;
120    let rest = &before[count_idx + key.len()..];
121    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
122    digits.parse().map_err(|_| {
123        InterchangeError::InvalidConfiguration("accessor count is not an integer".into())
124    })
125}
126
127fn extract_embedded_buffers(json: &str) -> InterchangeResult<Vec<&str>> {
128    let marker = r#""uri":"data:application/octet-stream;base64,"#;
129    let buffers: Vec<_> =
130        json.split(marker).skip(1).filter_map(|rest| rest.split('"').next()).collect();
131    if buffers.iter().any(|buffer| buffer.is_empty() && !json.contains(r#""byteLength":0"#)) {
132        return Err(InterchangeError::InvalidConfiguration(
133            "embedded glTF buffer URI is empty or malformed".into(),
134        ));
135    }
136    Ok(buffers)
137}
138
139fn base64_decode(value: &str) -> InterchangeResult<Vec<u8>> {
140    if value.len() % 4 != 0 {
141        return Err(InterchangeError::InvalidConfiguration(
142            "base64 buffer length must be a multiple of four".into(),
143        ));
144    }
145    let mut output = Vec::with_capacity(value.len() / 4 * 3);
146    for (chunk_index, chunk) in value.as_bytes().chunks_exact(4).enumerate() {
147        let a = base64_value(chunk[0]).ok_or_else(|| invalid_base64(chunk_index))?;
148        let b = base64_value(chunk[1]).ok_or_else(|| invalid_base64(chunk_index))?;
149        let c = if chunk[2] == b'=' {
150            0
151        } else {
152            base64_value(chunk[2]).ok_or_else(|| invalid_base64(chunk_index))?
153        };
154        let d = if chunk[3] == b'=' {
155            0
156        } else {
157            base64_value(chunk[3]).ok_or_else(|| invalid_base64(chunk_index))?
158        };
159        output.push((a << 2) | (b >> 4));
160        if chunk[2] != b'=' {
161            output.push((b << 4) | (c >> 2));
162        }
163        if chunk[3] != b'=' {
164            if chunk[2] == b'=' {
165                return Err(invalid_base64(chunk_index));
166            }
167            output.push((c << 6) | d);
168        }
169        if (chunk[2] == b'=' || chunk[3] == b'=') && chunk_index + 1 != value.len() / 4 {
170            return Err(invalid_base64(chunk_index));
171        }
172    }
173    Ok(output)
174}
175
176fn base64_value(byte: u8) -> Option<u8> {
177    match byte {
178        b'A'..=b'Z' => Some(byte - b'A'),
179        b'a'..=b'z' => Some(byte - b'a' + 26),
180        b'0'..=b'9' => Some(byte - b'0' + 52),
181        b'+' => Some(62),
182        b'/' => Some(63),
183        _ => None,
184    }
185}
186
187fn invalid_base64(chunk_index: usize) -> InterchangeError {
188    InterchangeError::InvalidConfiguration(format!("invalid base64 buffer at chunk {chunk_index}"))
189}
190
191fn f32_slice_as_bytes(values: &[f32]) -> Vec<u8> {
192    values.iter().flat_map(|v| v.to_le_bytes()).collect()
193}
194
195fn base64_encode(bytes: &[u8]) -> String {
196    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
197    let mut out = String::new();
198    for chunk in bytes.chunks(3) {
199        let b0 = chunk[0] as u32;
200        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
201        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
202        let triple = (b0 << 16) | (b1 << 8) | b2;
203        out.push(TABLE[((triple >> 18) & 63) as usize] as char);
204        out.push(TABLE[((triple >> 12) & 63) as usize] as char);
205        if chunk.len() > 1 {
206            out.push(TABLE[((triple >> 6) & 63) as usize] as char);
207        } else {
208            out.push('=');
209        }
210        if chunk.len() > 2 {
211            out.push(TABLE[(triple & 63) as usize] as char);
212        } else {
213            out.push('=');
214        }
215    }
216    out
217}
218
219#[cfg(test)]
220mod tests {
221    use super::{
222        decode_triangle_mesh_gltf_json, export_triangle_mesh_gltf_json,
223        import_triangle_mesh_gltf_json,
224    };
225    use spatialrust_scene::TriangleMesh;
226
227    #[test]
228    fn roundtrips_counts() {
229        let mesh = TriangleMesh {
230            positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
231            indices: vec![0, 1, 2],
232        };
233        let json = export_triangle_mesh_gltf_json(&mesh).unwrap();
234        let (vertices, indices) = import_triangle_mesh_gltf_json(&json).unwrap();
235        assert_eq!(vertices, 3);
236        assert_eq!(indices, 3);
237        assert_eq!(decode_triangle_mesh_gltf_json(&json).unwrap(), mesh);
238    }
239
240    #[test]
241    fn rejects_invalid_embedded_index() {
242        let mesh = TriangleMesh { positions: vec![0.0, 0.0, 0.0], indices: vec![] };
243        let json = export_triangle_mesh_gltf_json(&mesh).unwrap();
244        let invalid = json.replace("\"byteLength\":0", "\"byteLength\":4");
245        assert!(decode_triangle_mesh_gltf_json(&invalid).is_err());
246    }
247}