Skip to main content

konjure_sdk/
runtime.rs

1use crate::*;
2use alloc::{collections::BTreeMap, format, string::String, vec::Vec};
3use serde::{Deserialize, Serialize};
4#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
5#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
6#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
7#[serde(deny_unknown_fields)]
8/// One deterministic scene-node result from [`Runtime::sample`].
9pub struct Draw {
10    /// Source entity identifier.
11    pub entity: EntityId,
12    /// Identifier of the owned built-in mesh, or `None` for groups.
13    pub mesh_entity: Option<EntityId>,
14    /// Evaluated local transform after enabled animations.
15    pub transform: Transform,
16    /// Column-major local-to-world affine matrix.
17    pub matrix: [f64; 16],
18    /// Evaluated runtime colour.
19    pub color: Color,
20}
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25/// Owned headless render state at an explicit scene time.
26pub struct Frame {
27    /// Sample time in seconds.
28    pub time_seconds: f64,
29    /// Draw records sorted by entity identifier, including groups.
30    pub draws: Vec<Draw>,
31    /// Copy of currently retained runtime events in oldest-to-newest order.
32    pub events: Vec<RuntimeEvent>,
33}
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
36#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38#[serde(rename_all = "snake_case")]
39/// A runtime decision retained for sampling and recording.
40pub enum RuntimeEvent {
41    /// A local state-changing action was applied.
42    ActionApplied {
43        /// Entity receiving the action.
44        entity: EntityId,
45        /// Declared action name.
46        action: ActionName,
47    },
48    /// An external effect was proposed but deliberately not executed.
49    EffectProposed {
50        /// Entity that proposed the effect.
51        entity: EntityId,
52        /// Adapter-interpreted effect identifier.
53        effect: String,
54    },
55}
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60#[serde(tag = "kind", rename_all = "snake_case")]
61/// A request to dispatch a declared entity action.
62pub enum Event {
63    /// Invokes one action declared on an entity.
64    InvokeAction {
65        /// Target entity identifier.
66        entity: EntityId,
67        /// Name of the declared action.
68        action: ActionName,
69    },
70}
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
73#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75#[serde(rename_all = "snake_case")]
76/// Outcome class for a dispatched event.
77pub enum ReceiptStatus {
78    /// The runtime changed its local state.
79    Applied,
80    /// The runtime emitted a proposal without any external side effect.
81    Proposed,
82}
83#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
84#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
85#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87/// Result of one accepted [`Runtime::dispatch`] call.
88pub struct Receipt {
89    /// Whether the decision changed local state or only proposed an effect.
90    pub status: ReceiptStatus,
91    /// Retained event corresponding to the dispatch.
92    pub event: RuntimeEvent,
93}
94fn local_matrix(t: Transform) -> [f64; 16] {
95    let q = t.rotation;
96    let (x2, y2, z2) = (q.x + q.x, q.y + q.y, q.z + q.z);
97    let (xx, xy, xz) = (q.x * x2, q.x * y2, q.x * z2);
98    let (yy, yz, zz) = (q.y * y2, q.y * z2, q.z * z2);
99    let (wx, wy, wz) = (q.w * x2, q.w * y2, q.w * z2);
100    [
101        (1. - yy - zz) * t.scale.x,
102        (xy + wz) * t.scale.x,
103        (xz - wy) * t.scale.x,
104        0.,
105        (xy - wz) * t.scale.y,
106        (1. - xx - zz) * t.scale.y,
107        (yz + wx) * t.scale.y,
108        0.,
109        (xz + wy) * t.scale.z,
110        (yz - wx) * t.scale.z,
111        (1. - xx - yy) * t.scale.z,
112        0.,
113        t.translation.x,
114        t.translation.y,
115        t.translation.z,
116        1.,
117    ]
118}
119fn mul_matrix(a: [f64; 16], b: [f64; 16]) -> Result<[f64; 16], Diagnostic> {
120    let mut out = [0.; 16];
121    for column in 0..4 {
122        for row in 0..4 {
123            for k in 0..4 {
124                let term = a[k * 4 + row] * b[column * 4 + k];
125                out[column * 4 + row] += term;
126                if !out[column * 4 + row].is_finite()
127                    || out[column * 4 + row].abs() > MAX_COORDINATE_M
128                {
129                    return Err(Diagnostic::plain(
130                        "world transform exceeds renderable bounds",
131                    ));
132                }
133            }
134        }
135    }
136    Ok(out)
137}
138fn world_matrix(
139    scene: &SceneDocument,
140    local: &BTreeMap<EntityId, Transform>,
141    id: &EntityId,
142) -> Result<[f64; 16], Diagnostic> {
143    let mut chain = Vec::new();
144    let mut cursor = id.clone();
145    loop {
146        let entity = scene
147            .entities
148            .iter()
149            .find(|e| e.id == cursor)
150            .ok_or_else(|| Diagnostic::plain("entity disappeared while sampling"))?;
151        chain.push(cursor.clone());
152        if let Some(parent) = &entity.parent {
153            cursor = parent.clone()
154        } else {
155            break;
156        }
157    }
158    let mut matrix = [
159        1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.,
160    ];
161    for entity in chain.iter().rev() {
162        matrix = mul_matrix(
163            matrix,
164            local_matrix(
165                *local
166                    .get(entity)
167                    .expect("topology validation preserves transforms"),
168            ),
169        )?
170    }
171    // Every adapter eventually renders f32 matrices. Reject collapsed bases here
172    // so native, Unity and WASM cannot silently disagree at that conversion.
173    let columns = [
174        [matrix[0], matrix[1], matrix[2]],
175        [matrix[4], matrix[5], matrix[6]],
176        [matrix[8], matrix[9], matrix[10]],
177    ];
178    let lengths = columns.map(|v| libm::sqrt(v.iter().map(|n| n * n).sum()));
179    if lengths
180        .iter()
181        .any(|length| !(1e-6..=MAX_COORDINATE_M).contains(length))
182    {
183        return Err(Diagnostic::plain(
184            "world transform scale exceeds renderable bounds",
185        ));
186    }
187    let [a, b, c] = columns;
188    let determinant = a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0])
189        + a[2] * (b[0] * c[1] - b[1] * c[0]);
190    if determinant.abs() / lengths.iter().product::<f64>() < 1e-6 {
191        return Err(Diagnostic::plain(
192            "world transform basis is too close to singular for rendering",
193        ));
194    }
195    Ok(matrix)
196}
197/// Headless, deterministic evaluator for one owned validated scene.
198///
199/// The runtime owns the document, generated meshes, local action state, and
200/// event buffer. It does not render, access clocks, perform I/O, or execute
201/// proposed external effects.
202pub struct Runtime {
203    scene: SceneDocument,
204    meshes: BTreeMap<EntityId, Mesh>,
205    motion_enabled: BTreeMap<EntityId, bool>,
206    colors: BTreeMap<EntityId, Color>,
207    events: Vec<RuntimeEvent>,
208}
209impl Runtime {
210    /// Validates and takes ownership of a scene, then tessellates built-in geometry.
211    ///
212    /// Built-in mesh buffers are allocated once and retained for the runtime's
213    /// lifetime. `Geometry::Mesh` intentionally fails because asset resolution
214    /// belongs to a platform adapter.
215    ///
216    /// # Errors
217    ///
218    /// Returns validation or tessellation diagnostics, including unresolved mesh assets.
219    pub fn new(scene: SceneDocument) -> Result<Self, Diagnostic> {
220        validate(&scene)?;
221        let mut meshes = BTreeMap::new();
222        let mut motion_enabled = BTreeMap::new();
223        let mut colors = BTreeMap::new();
224        for e in &scene.entities {
225            match &e.geometry {
226                Geometry::Group => {}
227                Geometry::Mesh { asset } => {
228                    return Err(Diagnostic::plain(format!(
229                        "mesh asset `{asset}` is unresolved; provide it through a platform adapter"
230                    )));
231                }
232                geometry => {
233                    meshes.insert(e.id.clone(), tessellate(geometry)?);
234                }
235            }
236            motion_enabled.insert(e.id.clone(), true);
237            colors.insert(e.id.clone(), e.color);
238        }
239        Ok(Self {
240            scene,
241            meshes,
242            motion_enabled,
243            colors,
244            events: Vec::new(),
245        })
246    }
247    /// Borrows the immutable authored scene document owned by this runtime.
248    pub fn scene(&self) -> &SceneDocument {
249        &self.scene
250    }
251    /// Borrows the deterministic built-in meshes keyed by source entity identifier.
252    pub fn meshes(&self) -> &BTreeMap<EntityId, Mesh> {
253        &self.meshes
254    }
255    /// Evaluates one frame at an explicit time without changing runtime state.
256    ///
257    /// It allocates owned frame output and temporary transform state, composes
258    /// parent transforms, and sorts draw records by entity ID. Repeated samples
259    /// with the same state and time are deterministic.
260    ///
261    /// # Errors
262    ///
263    /// Returns a diagnostic for a non-finite or out-of-range time, or for a
264    /// composed transform that cannot be represented safely by render adapters.
265    pub fn sample(&self, time_seconds: f64) -> Result<Frame, Diagnostic> {
266        if !time_seconds.is_finite() || !(0.0..=MAX_SAMPLE_SECONDS).contains(&time_seconds) {
267            return Err(Diagnostic::plain(
268                "sample time must be finite and non-negative",
269            ));
270        }
271        let mut local = BTreeMap::new();
272        for e in &self.scene.entities {
273            let mut t = e.transform;
274            if self.motion_enabled.get(&e.id).copied().unwrap_or(true) {
275                for a in &e.animations {
276                    match a {
277                        Animation::Orbit { radius_m, period_s } => {
278                            let x = 2. * core::f64::consts::PI * time_seconds / period_s;
279                            t.translation.x += radius_m * libm::cos(x);
280                            t.translation.z += radius_m * libm::sin(x)
281                        }
282                        Animation::Bob {
283                            amplitude_m,
284                            period_s,
285                        } => {
286                            t.translation.y += amplitude_m
287                                * libm::sin(2. * core::f64::consts::PI * time_seconds / period_s)
288                        }
289                        Animation::Spin { degrees_per_second } => {
290                            let r = degrees_per_second * core::f64::consts::PI / 180.
291                                * time_seconds
292                                / 2.;
293                            let spin = Quaternion {
294                                x: 0.,
295                                y: libm::sin(r),
296                                z: 0.,
297                                w: libm::cos(r),
298                            };
299                            t.rotation = Quaternion {
300                                x: spin.w * t.rotation.x
301                                    + spin.x * t.rotation.w
302                                    + spin.y * t.rotation.z
303                                    - spin.z * t.rotation.y,
304                                y: spin.w * t.rotation.y - spin.x * t.rotation.z
305                                    + spin.y * t.rotation.w
306                                    + spin.z * t.rotation.x,
307                                z: spin.w * t.rotation.z + spin.x * t.rotation.y
308                                    - spin.y * t.rotation.x
309                                    + spin.z * t.rotation.w,
310                                w: spin.w * t.rotation.w
311                                    - spin.x * t.rotation.x
312                                    - spin.y * t.rotation.y
313                                    - spin.z * t.rotation.z,
314                            };
315                        }
316                    }
317                }
318            }
319            local.insert(e.id.clone(), t);
320        }
321        let mut draws = Vec::with_capacity(self.scene.entities.len());
322        for e in &self.scene.entities {
323            let t = *local
324                .get(&e.id)
325                .expect("every entity has a local transform");
326            let matrix = world_matrix(&self.scene, &local, &e.id)?;
327            draws.push(Draw {
328                entity: e.id.clone(),
329                mesh_entity: self.meshes.contains_key(&e.id).then(|| e.id.clone()),
330                transform: t,
331                matrix,
332                color: *self.colors.get(&e.id).unwrap_or(&e.color),
333            });
334        }
335        draws.sort_by(|a, b| a.entity.cmp(&b.entity));
336        Ok(Frame {
337            time_seconds,
338            draws,
339            events: self.events.clone(),
340        })
341    }
342    /// Dispatches a declared action and retains its resulting runtime event.
343    ///
344    /// Applied actions toggle animation evaluation or replace a runtime colour.
345    /// Proposed effects are recorded but never executed. When the bounded event
346    /// buffer is full, the oldest event is discarded. Invalid events leave the
347    /// runtime unchanged.
348    ///
349    /// # Errors
350    ///
351    /// Returns a diagnostic when the entity is unknown or does not declare the action.
352    pub fn dispatch(&mut self, event: Event) -> Result<Receipt, Diagnostic> {
353        match event {
354            Event::InvokeAction { entity, action } => {
355                let entity_def = self
356                    .scene
357                    .entities
358                    .iter()
359                    .find(|e| e.id == entity)
360                    .ok_or_else(|| Diagnostic::plain("event references an unknown entity"))?;
361                let action_def = entity_def
362                    .actions
363                    .get(&action)
364                    .ok_or_else(|| Diagnostic::plain("action is not declared on this entity"))?;
365                let runtime_event = match action_def {
366                    Action::ToggleMotion => {
367                        let current = self.motion_enabled.get(&entity).copied().unwrap_or(true);
368                        self.motion_enabled.insert(entity.clone(), !current);
369                        RuntimeEvent::ActionApplied {
370                            entity: entity.clone(),
371                            action: action.clone(),
372                        }
373                    }
374                    Action::ChangeColor { color } => {
375                        self.colors.insert(entity.clone(), *color);
376                        RuntimeEvent::ActionApplied {
377                            entity: entity.clone(),
378                            action: action.clone(),
379                        }
380                    }
381                    Action::ProposeEffect { effect } => RuntimeEvent::EffectProposed {
382                        entity: entity.clone(),
383                        effect: effect.clone(),
384                    },
385                };
386                let status = if matches!(runtime_event, RuntimeEvent::EffectProposed { .. }) {
387                    ReceiptStatus::Proposed
388                } else {
389                    ReceiptStatus::Applied
390                };
391                if self.events.len() == MAX_RUNTIME_EVENTS {
392                    self.events.remove(0);
393                }
394                self.events.push(runtime_event.clone());
395                Ok(Receipt {
396                    status,
397                    event: runtime_event,
398                })
399            }
400        }
401    }
402}