Skip to main content

konjure_lang/
interchange.rs

1//! Scene-bundle import as ordinary editable Konjure source.
2//!
3//! Geometry exchange captures evaluated state. It cannot reconstruct procedural
4//! authoring, simulation callbacks, modifiers, textures, or host resources.
5use konjure_sdk::{
6    Diagnostic, Geometry, Vec3,
7    interchange::{MAX_BUNDLE_BYTES, SceneBundle},
8};
9use rapier3d::glamx::{DQuat, EulerRot};
10use serde::Serialize;
11use std::{collections::BTreeMap, fmt::Write};
12
13/// Maximum generated project modules, including `main`.
14pub const MAX_IMPORTED_MODULES: usize = 64;
15/// Kreate's total source-text admission limit for an imported project.
16pub const MAX_IMPORTED_SOURCE_BYTES: usize = 1024 * 1024;
17/// Largest encoded data fragment passed to `Mesh.decode_json`.
18pub const MAX_MESH_JSON_CHUNK_BYTES: usize = 16 * 1024;
19
20fn problem(message: impl Into<String>) -> Diagnostic {
21    Diagnostic {
22        message: message.into(),
23        span: None,
24    }
25}
26
27/// Parses and validates a bounded scene-bundle file.
28///
29/// # Errors
30/// Returns a diagnostic before a caller changes its project if parsing or admission fails.
31pub fn parse_bundle(input: &str) -> Result<SceneBundle, Diagnostic> {
32    if input.len() > MAX_BUNDLE_BYTES {
33        return Err(problem("scene bundle exceeds 8 MiB"));
34    }
35    let bundle: SceneBundle = serde_json::from_str(input).map_err(|e| problem(e.to_string()))?;
36    bundle.validate()?;
37    Ok(bundle)
38}
39
40fn vector(value: Vec3) -> String {
41    format!("Vec3 {{ x: {}, y: {}, z: {} }}", value.x, value.y, value.z)
42}
43
44/// Converts a validated mesh snapshot into editable source with local hierarchy.
45/// Mesh buffers use the language's `Tensor[f32]` contract. Transform coordinates
46/// retain `f64`; imported mesh coordinates round once to `f32`.
47///
48/// # Errors
49/// Rejects invalid bundles, executable scene actions/animations, and generated
50/// source beyond the compiler's 1 MiB limit. Nothing executes during conversion.
51pub fn to_source(bundle: &SceneBundle) -> Result<String, Diagnostic> {
52    bundle.validate()?;
53    let mut output = String::from(
54        "// Imported scene. Geometry is baked; transforms and materials remain editable.\n",
55    );
56    let ids: BTreeMap<_, _> = bundle
57        .document
58        .entities
59        .iter()
60        .enumerate()
61        .map(|(i, e)| (&e.id, i))
62        .collect();
63    for (i, entity) in bundle.document.entities.iter().enumerate() {
64        if !entity.animations.is_empty() || !entity.actions.is_empty() {
65            return Err(problem(
66                "exchange requires an evaluated scene without actions or animations",
67            ));
68        }
69        let q = entity.transform.rotation;
70        let (x, y, z) = DQuat::from_xyzw(q.x, q.y, q.z, q.w).to_euler(EulerRot::XYZ);
71        let transform = format!(
72            "Transform {{ position: {}, rotation: {}, scale: {} }}",
73            vector(entity.transform.translation),
74            vector(Vec3 { x, y, z }),
75            vector(entity.transform.scale)
76        );
77        writeln!(
78            output,
79            "\n// {}\nlet object_{i} = spawn({transform});",
80            entity.id.0
81        )
82        .map_err(|e| problem(e.to_string()))?;
83        if !matches!(entity.geometry, Geometry::Group) {
84            if let Geometry::Line { from_m, to_m } = entity.geometry {
85                writeln!(
86                    output,
87                    "add(object_{i}, Line {{ from: {}, to: {} }});",
88                    vector(from_m),
89                    vector(to_m)
90                )
91                .map_err(|e| problem(e.to_string()))?;
92            } else {
93                let mesh = &bundle.meshes[&entity.id];
94                write!(output, "add(object_{i}, Mesh {{ positions: [")
95                    .map_err(|e| problem(e.to_string()))?;
96                for (index, p) in mesh.positions.iter().enumerate() {
97                    if index > 0 {
98                        output.push(',');
99                    }
100                    write!(output, "[{},{},{}]", p.x as f32, p.y as f32, p.z as f32)
101                        .map_err(|e| problem(e.to_string()))?;
102                }
103                output.push_str("]:f32, normals: [");
104                for (index, p) in mesh.normals.iter().enumerate() {
105                    if index > 0 {
106                        output.push(',');
107                    }
108                    write!(output, "[{},{},{}]", p.x as f32, p.y as f32, p.z as f32)
109                        .map_err(|e| problem(e.to_string()))?;
110                }
111                output.push_str("]:f32, indices: [");
112                for (index, triangle) in mesh.indices.as_chunks::<3>().0.iter().enumerate() {
113                    if index > 0 {
114                        output.push(',');
115                    }
116                    write!(output, "[{},{},{}]", triangle[0], triangle[1], triangle[2])
117                        .map_err(|e| problem(e.to_string()))?;
118                }
119                output.push_str("]:u32 });\n");
120            }
121        }
122        let c = entity.color;
123        writeln!(output,"add(object_{i}, Material {{ color: \"#{:02x}{:02x}{:02x}{:02x}\", roughness: {}, emissive: {} }});",c.r,c.g,c.b,c.a,entity.material.roughness,entity.material.emissive).map_err(|e|problem(e.to_string()))?;
124        if output.len() > 1024 * 1024 {
125            return Err(problem(
126                "imported scene exceeds the 1 MiB source limit; simplify meshes in Blender",
127            ));
128        }
129    }
130    for (i, entity) in bundle.document.entities.iter().enumerate() {
131        if let Some(parent) = &entity.parent {
132            writeln!(
133                output,
134                "add(object_{i}, Parent {{ entity: object_{} }});",
135                ids[parent]
136            )
137            .map_err(|e| problem(e.to_string()))?;
138        }
139    }
140    Ok(output)
141}
142
143#[derive(Serialize)]
144struct MeshF32Json {
145    positions: Vec<PointF32>,
146    normals: Vec<PointF32>,
147    indices: Vec<u32>,
148}
149
150#[derive(Serialize)]
151struct PointF32 {
152    x: f32,
153    y: f32,
154    z: f32,
155}
156
157fn mesh_decode_call(output: &mut String, mesh: &konjure_sdk::Mesh) -> Result<(), Diagnostic> {
158    // Mesh values in the language are explicitly f32 tensors. Rounding before
159    // serialization both preserves that contract and avoids retaining irrelevant
160    // host f64 digits in generated project source.
161    let mesh = MeshF32Json {
162        positions: mesh
163            .positions
164            .iter()
165            .map(|point| PointF32 {
166                x: point.x as f32,
167                y: point.y as f32,
168                z: point.z as f32,
169            })
170            .collect(),
171        normals: mesh
172            .normals
173            .iter()
174            .map(|normal| PointF32 {
175                x: normal.x as f32,
176                y: normal.y as f32,
177                z: normal.z as f32,
178            })
179            .collect(),
180        indices: mesh.indices.clone(),
181    };
182    let encoded = serde_json::to_string(&mesh).map_err(|error| problem(error.to_string()))?;
183    output.push_str("Mesh {}.decode_json([");
184    for (index, chunk) in encoded
185        .as_bytes()
186        .chunks(MAX_MESH_JSON_CHUNK_BYTES)
187        .enumerate()
188    {
189        if index > 0 {
190            output.push(',');
191        }
192        let chunk = std::str::from_utf8(chunk).map_err(|error| problem(error.to_string()))?;
193        // JSON string literals are valid Konjure text literals. Serializing each
194        // chunk separately also handles escaping if the mesh representation grows
195        // non-numeric fields in a future compatible format.
196        output.push_str(&serde_json::to_string(chunk).map_err(|error| problem(error.to_string()))?);
197    }
198    output.push_str("])");
199    Ok(())
200}
201
202/// Converts a scene bundle into a bounded editable source project.
203///
204/// `main` retains transforms, materials, and [`Parent`](crate::spatial) links;
205/// each embedded triangle mesh lives in a private `geometry_N` module that
206/// exports `create() -> Res[Mesh, DataError]`. This keeps individual source
207/// files readable and avoids relaxing compiler limits for large imported assets.
208///
209/// # Errors
210///
211/// Rejects more than 64 generated modules, over one MiB of aggregate source,
212/// or any generated project that cannot initialize and render in the native
213/// language runtime.
214pub fn to_sources(bundle: &SceneBundle) -> Result<BTreeMap<String, String>, Diagnostic> {
215    bundle.validate()?;
216    let mesh_count = bundle
217        .document
218        .entities
219        .iter()
220        .filter(|entity| !matches!(entity.geometry, Geometry::Group | Geometry::Line { .. }))
221        .count();
222    if mesh_count + 1 > MAX_IMPORTED_MODULES {
223        return Err(problem(
224            "imported scene requires more than 64 source modules; simplify or combine meshes in Blender",
225        ));
226    }
227    let ids: BTreeMap<_, _> = bundle
228        .document
229        .entities
230        .iter()
231        .enumerate()
232        .map(|(index, entity)| (&entity.id, index))
233        .collect();
234    let mut sources = BTreeMap::new();
235    let mut main = String::from(
236        "// Imported scene.\n// Geometry is baked in geometry_N modules; transforms and materials remain editable.\n",
237    );
238    let mut next_geometry = 0usize;
239    for (index, entity) in bundle.document.entities.iter().enumerate() {
240        if !entity.animations.is_empty() || !entity.actions.is_empty() {
241            return Err(problem(
242                "exchange requires an evaluated scene without actions or animations",
243            ));
244        }
245        let q = entity.transform.rotation;
246        let (x, y, z) = DQuat::from_xyzw(q.x, q.y, q.z, q.w).to_euler(EulerRot::XYZ);
247        writeln!(
248            main,
249            "\n// {}\nlet object_{index} = spawn(\n  Transform {{\n    position: {},\n    rotation: {},\n    scale: {}\n  }}\n);",
250            entity.id.0,
251            vector(entity.transform.translation),
252            vector(Vec3 { x, y, z }),
253            vector(entity.transform.scale),
254        )
255        .map_err(|e| problem(e.to_string()))?;
256        match entity.geometry {
257            Geometry::Group => {}
258            Geometry::Line { from_m, to_m } => writeln!(
259                main,
260                "add(object_{index}, Line {{ from: {}, to: {} }});",
261                vector(from_m),
262                vector(to_m)
263            )
264            .map_err(|e| problem(e.to_string()))?,
265            _ => {
266                let module = format!("geometry_{next_geometry}");
267                next_geometry += 1;
268                let mesh = bundle
269                    .meshes
270                    .get(&entity.id)
271                    .ok_or_else(|| problem("validated bundle is missing geometry"))?;
272                let mut source = format!(
273                    "// Baked immutable mesh data for {}. Edit transforms/material in main.\nfunc create() -> Res[Mesh, DataError] {{ ret ",
274                    entity.id.0
275                );
276                mesh_decode_call(&mut source, mesh)?;
277                source.push_str("; }\nexport create;\n");
278                sources.insert(module.clone(), source);
279                let call = format!("{module}.create()?");
280                writeln!(main, "import {module};\nadd(object_{index}, {call});")
281                    .map_err(|e| problem(e.to_string()))?;
282            }
283        }
284        let color = entity.color;
285        writeln!(
286            main,
287            "add(object_{index}, Material {{\n  color: \"#{:02x}{:02x}{:02x}{:02x}\",\n  roughness: {},\n  emissive: {}\n}});",
288            color.r,
289            color.g,
290            color.b,
291            color.a,
292            entity.material.roughness,
293            entity.material.emissive
294        )
295        .map_err(|e| problem(e.to_string()))?;
296    }
297    for (index, entity) in bundle.document.entities.iter().enumerate() {
298        if let Some(parent) = &entity.parent {
299            writeln!(
300                main,
301                "add(object_{index}, Parent {{ entity: object_{} }});",
302                ids[parent]
303            )
304            .map_err(|e| problem(e.to_string()))?;
305        }
306    }
307    let bytes = main.len() + sources.values().map(String::len).sum::<usize>();
308    if bytes > MAX_IMPORTED_SOURCE_BYTES {
309        return Err(problem(
310            "imported source exceeds the 1 MiB project limit; simplify meshes in Blender",
311        ));
312    }
313    sources.insert("main".into(), main.clone());
314    let mut imports = sources.clone();
315    imports.remove("main");
316    crate::spatial::SpatialRuntime::new(&main, &imports)
317        .and_then(|runtime| runtime.render().map_err(|error| vec![error]))
318        .map_err(|errors| {
319            problem(
320                errors
321                    .into_iter()
322                    .map(|error| error.to_string())
323                    .collect::<Vec<_>>()
324                    .join("\n"),
325            )
326        })?;
327    Ok(sources)
328}
329
330/// Captures the currently accepted language state as a validated geometry bundle.
331///
332/// # Errors
333/// Returns the runtime's spatial diagnostic or an exchange validation error.
334pub fn from_runtime(runtime: &crate::spatial::SpatialRuntime) -> Result<SceneBundle, Diagnostic> {
335    let render = runtime.render().map_err(|e| problem(e.to_string()))?;
336    SceneBundle::new(render.document, render.meshes)
337}