Skip to main content

oxiphysics_python/serialization/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#[allow(unused_imports)]
6use super::functions_2::*;
7use crate::Error;
8use crate::types::{PyContactResult, PyVec3};
9use serde::{Deserialize, Serialize};
10
11#[allow(unused_imports)]
12use super::functions::*;
13use super::functions::{PICKLE_MAGIC, PICKLE_VERSION};
14
15use std::collections::HashMap;
16
17/// Serializable snapshot of the entire world state (legacy format).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WorldState {
20    /// Gravity vector.
21    pub gravity: PyVec3,
22    /// Simulation time.
23    pub time: f64,
24    /// Positions of all bodies.
25    pub positions: Vec<PyVec3>,
26    /// Number of bodies.
27    pub num_bodies: usize,
28}
29/// Configuration for incremental state export.
30#[derive(Debug, Clone)]
31pub struct IncrementalExportConfig {
32    /// Only export bodies whose speed exceeds this threshold (m/s).
33    pub min_speed_threshold: f64,
34    /// Maximum number of bodies per export batch.
35    pub max_batch_size: usize,
36    /// Whether to include sleeping bodies in the export.
37    pub include_sleeping: bool,
38}
39/// Serializable state of a single rigid body within a snapshot.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41pub struct SimBodyState {
42    /// Slot handle (u32 index in the world).
43    pub handle: u32,
44    /// Position `[x, y, z]` in world space.
45    pub position: [f64; 3],
46    /// Linear velocity `[vx, vy, vz]`.
47    pub velocity: [f64; 3],
48    /// Orientation quaternion `[x, y, z, w]`.
49    pub orientation: [f64; 4],
50    /// Angular velocity `[wx, wy, wz]`.
51    pub angular_velocity: [f64; 3],
52    /// Whether this body is currently sleeping.
53    pub is_sleeping: bool,
54    /// Whether this body is static.
55    pub is_static: bool,
56    /// Optional user tag.
57    pub tag: Option<String>,
58}
59impl SimBodyState {
60    /// Create a `SimBodyState` with default motion (at rest, identity orientation).
61    pub fn at_rest(handle: u32, position: [f64; 3]) -> Self {
62        Self {
63            handle,
64            position,
65            velocity: [0.0; 3],
66            orientation: [0.0, 0.0, 0.0, 1.0],
67            angular_velocity: [0.0; 3],
68            is_sleeping: false,
69            is_static: false,
70            tag: None,
71        }
72    }
73    /// Speed (magnitude of linear velocity).
74    pub fn speed(&self) -> f64 {
75        let v = &self.velocity;
76        (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
77    }
78    /// Angular speed (magnitude of angular velocity).
79    pub fn angular_speed(&self) -> f64 {
80        let w = &self.angular_velocity;
81        (w[0] * w[0] + w[1] * w[1] + w[2] * w[2]).sqrt()
82    }
83    /// Kinetic energy estimate using a unit-mass approximation.
84    pub fn kinetic_energy_proxy(&self) -> f64 {
85        let v = self.speed();
86        0.5 * v * v
87    }
88    /// Distance from origin.
89    pub fn distance_from_origin(&self) -> f64 {
90        let p = &self.position;
91        (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt()
92    }
93    /// Whether the body is effectively at rest (speed < threshold).
94    pub fn is_at_rest(&self, linear_threshold: f64, angular_threshold: f64) -> bool {
95        self.speed() < linear_threshold && self.angular_speed() < angular_threshold
96    }
97}
98/// A complete snapshot of simulation state at a particular moment.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SimulationSnapshot {
101    /// Snapshot format version (currently 1).
102    pub version: u32,
103    /// Simulation time when the snapshot was taken.
104    pub time: f64,
105    /// Gravity vector `[gx, gy, gz]` at snapshot time.
106    pub gravity: [f64; 3],
107    /// State of every live body at snapshot time.
108    pub bodies: Vec<SimBodyState>,
109    /// Contacts active at snapshot time (informational).
110    pub contacts: Vec<PyContactResult>,
111    /// Number of sleeping bodies at snapshot time.
112    pub sleeping_count: usize,
113    /// Optional human-readable description.
114    pub description: Option<String>,
115    /// Arbitrary key-value metadata.
116    pub metadata: std::collections::HashMap<String, String>,
117}
118impl SimulationSnapshot {
119    /// Current snapshot format version.
120    pub const FORMAT_VERSION: u32 = 1;
121    /// Create an empty snapshot at time zero.
122    pub fn empty() -> Self {
123        Self {
124            version: Self::FORMAT_VERSION,
125            time: 0.0,
126            gravity: [0.0, -9.81, 0.0],
127            bodies: Vec::new(),
128            contacts: Vec::new(),
129            sleeping_count: 0,
130            description: None,
131            metadata: std::collections::HashMap::new(),
132        }
133    }
134    /// Number of active bodies in this snapshot.
135    pub fn body_count(&self) -> usize {
136        self.bodies.len()
137    }
138    /// Number of sleeping bodies in this snapshot.
139    pub fn sleeping_count(&self) -> usize {
140        self.sleeping_count
141    }
142    /// Find a body by its handle.
143    pub fn find_body(&self, handle: u32) -> Option<&SimBodyState> {
144        self.bodies.iter().find(|b| b.handle == handle)
145    }
146    /// Find a body by its tag.
147    pub fn find_by_tag(&self, tag: &str) -> Option<&SimBodyState> {
148        self.bodies.iter().find(|b| b.tag.as_deref() == Some(tag))
149    }
150    /// Total kinetic-energy proxy across all non-sleeping bodies.
151    pub fn total_kinetic_energy_proxy(&self) -> f64 {
152        self.bodies
153            .iter()
154            .filter(|b| !b.is_sleeping)
155            .map(|b| b.kinetic_energy_proxy())
156            .sum()
157    }
158    /// Return the snapshot as a pretty-printed JSON string.
159    pub fn to_pretty_json(&self) -> String {
160        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
161    }
162    /// Serialize to compact JSON.
163    pub fn to_json(&self) -> String {
164        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
165    }
166    /// Deserialize from JSON.
167    pub fn from_json(json: &str) -> Result<Self, crate::Error> {
168        serde_json::from_str(json)
169            .map_err(|e| crate::Error::General(format!("snapshot deserialization failed: {e}")))
170    }
171    /// Add a metadata key-value pair.
172    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
173        self.metadata.insert(key.into(), value.into());
174        self
175    }
176    /// Set a human-readable description.
177    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
178        self.description = Some(desc.into());
179        self
180    }
181    /// Serialize to MessagePack bytes.
182    pub fn to_msgpack(&self) -> Vec<u8> {
183        let json = self.to_json();
184        let json_bytes = json.as_bytes();
185        let mut result = Vec::with_capacity(8 + json_bytes.len());
186        result.extend_from_slice(b"OXIP");
187        result.extend_from_slice(&(json_bytes.len() as u32).to_le_bytes());
188        result.extend_from_slice(json_bytes);
189        result
190    }
191    /// Deserialize from MessagePack bytes.
192    pub fn from_msgpack(data: &[u8]) -> Result<Self, Error> {
193        if data.len() < 8 {
194            return Err(Error::General("msgpack data too short".to_string()));
195        }
196        if &data[0..4] != b"OXIP" {
197            return Err(Error::General("invalid msgpack magic bytes".to_string()));
198        }
199        let len = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
200        if data.len() < 8 + len {
201            return Err(Error::General("msgpack data truncated".to_string()));
202        }
203        let json = std::str::from_utf8(&data[8..8 + len])
204            .map_err(|e| Error::General(format!("invalid UTF-8: {e}")))?;
205        Self::from_json(json)
206    }
207    /// Count of static bodies.
208    pub fn static_body_count(&self) -> usize {
209        self.bodies.iter().filter(|b| b.is_static).count()
210    }
211    /// Count of dynamic (non-static) bodies.
212    pub fn dynamic_body_count(&self) -> usize {
213        self.bodies.iter().filter(|b| !b.is_static).count()
214    }
215    /// All body handles.
216    pub fn handles(&self) -> Vec<u32> {
217        self.bodies.iter().map(|b| b.handle).collect()
218    }
219    /// Filter bodies by tag prefix.
220    pub fn find_by_tag_prefix(&self, prefix: &str) -> Vec<&SimBodyState> {
221        self.bodies
222            .iter()
223            .filter(|b| b.tag.as_deref().is_some_and(|t| t.starts_with(prefix)))
224            .collect()
225    }
226}
227impl SimulationSnapshot {
228    /// Compute the diff between `self` (snapshot A) and `other` (snapshot B).
229    ///
230    /// A body is considered "moved" if its position changed by more than
231    /// `position_threshold`.
232    pub fn diff(&self, other: &SimulationSnapshot, position_threshold: f64) -> SnapshotDiff {
233        let a_map: HashMap<u32, &SimBodyState> =
234            self.bodies.iter().map(|b| (b.handle, b)).collect();
235        let b_map: HashMap<u32, &SimBodyState> =
236            other.bodies.iter().map(|b| (b.handle, b)).collect();
237        let removed: Vec<u32> = a_map
238            .keys()
239            .filter(|h| !b_map.contains_key(h))
240            .copied()
241            .collect();
242        let added: Vec<u32> = b_map
243            .keys()
244            .filter(|h| !a_map.contains_key(h))
245            .copied()
246            .collect();
247        let mut moved = Vec::new();
248        let mut max_disp = 0.0_f64;
249        for (handle, a_body) in &a_map {
250            if let Some(b_body) = b_map.get(handle) {
251                let dx = b_body.position[0] - a_body.position[0];
252                let dy = b_body.position[1] - a_body.position[1];
253                let dz = b_body.position[2] - a_body.position[2];
254                let disp = (dx * dx + dy * dy + dz * dz).sqrt();
255                if disp > max_disp {
256                    max_disp = disp;
257                }
258                if disp > position_threshold {
259                    moved.push(*handle);
260                }
261            }
262        }
263        SnapshotDiff {
264            removed,
265            added,
266            moved,
267            max_displacement: max_disp,
268            time_delta: other.time - self.time,
269        }
270    }
271}
272/// Python-dict-compatible representation of a single body state.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct BodyDict {
275    /// Body handle.
276    pub handle: u32,
277    /// Position as list.
278    pub pos: [f64; 3],
279    /// Velocity as list.
280    pub vel: [f64; 3],
281    /// Orientation quaternion \[x, y, z, w\].
282    pub quat: [f64; 4],
283    /// Angular velocity.
284    pub omega: [f64; 3],
285    /// Whether sleeping.
286    pub sleeping: bool,
287    /// Whether static.
288    pub static_body: bool,
289    /// Optional tag.
290    pub tag: Option<String>,
291}
292impl BodyDict {
293    /// Convert from `SimBodyState`.
294    #[allow(dead_code)]
295    pub fn from_sim_body(b: &SimBodyState) -> Self {
296        Self {
297            handle: b.handle,
298            pos: b.position,
299            vel: b.velocity,
300            quat: b.orientation,
301            omega: b.angular_velocity,
302            sleeping: b.is_sleeping,
303            static_body: b.is_static,
304            tag: b.tag.clone(),
305        }
306    }
307    /// Convert back to `SimBodyState`.
308    #[allow(dead_code)]
309    pub fn to_sim_body(&self) -> SimBodyState {
310        SimBodyState {
311            handle: self.handle,
312            position: self.pos,
313            velocity: self.vel,
314            orientation: self.quat,
315            angular_velocity: self.omega,
316            is_sleeping: self.sleeping,
317            is_static: self.static_body,
318            tag: self.tag.clone(),
319        }
320    }
321}
322/// A Python-pickle-compatible binary envelope for a `SimulationSnapshot`.
323///
324/// The real `pickle` protocol uses a complex op-code stream. Here we implement
325/// a self-describing binary envelope that can be round-tripped without Python:
326///
327/// ```text
328/// [4]  magic  "OXPK"
329/// [1]  version  (currently 2)
330/// [4]  payload_len  (u32 LE)
331/// [payload_len]  JSON-encoded snapshot
332/// [1]  terminal  0x2e  ('.')
333/// ```
334#[derive(Debug, Clone)]
335pub struct PickleEnvelope {
336    /// The snapshot stored in this envelope.
337    pub snapshot: SimulationSnapshot,
338}
339impl PickleEnvelope {
340    /// Wrap a snapshot in a pickle envelope.
341    #[allow(dead_code)]
342    pub fn new(snapshot: SimulationSnapshot) -> Self {
343        Self { snapshot }
344    }
345    /// Serialize to bytes.
346    #[allow(dead_code)]
347    pub fn to_bytes(&self) -> Vec<u8> {
348        let json = self.snapshot.to_json();
349        let payload = json.as_bytes();
350        let mut buf = Vec::with_capacity(10 + payload.len());
351        buf.extend_from_slice(PICKLE_MAGIC);
352        buf.push(PICKLE_VERSION);
353        buf.extend_from_slice(&(payload.len() as u32).to_le_bytes());
354        buf.extend_from_slice(payload);
355        buf.push(b'.');
356        buf
357    }
358    /// Deserialize from bytes.
359    #[allow(dead_code)]
360    pub fn from_bytes(data: &[u8]) -> Result<Self, Error> {
361        if data.len() < 10 {
362            return Err(Error::General("pickle envelope too short".to_string()));
363        }
364        if &data[0..4] != PICKLE_MAGIC {
365            return Err(Error::General("invalid pickle magic bytes".to_string()));
366        }
367        let _version = data[4];
368        let payload_len = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
369        if data.len() < 9 + payload_len + 1 {
370            return Err(Error::General("pickle envelope truncated".to_string()));
371        }
372        let json = std::str::from_utf8(&data[9..9 + payload_len])
373            .map_err(|e| Error::General(format!("invalid UTF-8 in pickle: {e}")))?;
374        let snapshot = SimulationSnapshot::from_json(json)?;
375        Ok(Self { snapshot })
376    }
377    /// Serialize to a hex string (for embedding in Python source).
378    #[allow(dead_code)]
379    pub fn to_hex(&self) -> String {
380        self.to_bytes()
381            .iter()
382            .map(|b| format!("{b:02x}"))
383            .collect::<Vec<_>>()
384            .join("")
385    }
386}
387/// Result of JSON schema validation.
388#[derive(Debug, Clone)]
389pub struct SchemaValidationResult {
390    /// Whether the JSON conforms to the expected schema.
391    pub is_valid: bool,
392    /// List of schema violation descriptions.
393    pub errors: Vec<String>,
394}
395impl SchemaValidationResult {
396    /// Create a passing result.
397    #[allow(dead_code)]
398    pub fn ok() -> Self {
399        Self {
400            is_valid: true,
401            errors: Vec::new(),
402        }
403    }
404    /// Create a failing result with a single error.
405    #[allow(dead_code)]
406    pub fn err(msg: impl Into<String>) -> Self {
407        Self {
408            is_valid: false,
409            errors: vec![msg.into()],
410        }
411    }
412}
413/// A NumPy-compatible flat array descriptor for body positions.
414///
415/// Provides the data, shape, and dtype needed to reconstruct a NumPy array on
416/// the Python side via `np.frombuffer` or `np.array`.
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct NumpyPositionArray {
419    /// Flat f64 data, row-major: `[x0, y0, z0, x1, y1, z1, ...]`.
420    pub data: Vec<f64>,
421    /// Shape: `[n_bodies, 3]`.
422    pub shape: [usize; 2],
423    /// NumPy dtype string.
424    pub dtype: String,
425    /// Whether data is in C (row-major) order.
426    pub c_order: bool,
427}
428impl NumpyPositionArray {
429    /// Build a position array from a snapshot.
430    #[allow(dead_code)]
431    pub fn from_snapshot(snap: &SimulationSnapshot) -> Self {
432        let n = snap.bodies.len();
433        let mut data = Vec::with_capacity(n * 3);
434        for b in &snap.bodies {
435            data.extend_from_slice(&b.position);
436        }
437        Self {
438            data,
439            shape: [n, 3],
440            dtype: "float64".to_string(),
441            c_order: true,
442        }
443    }
444    /// Build a velocity array from a snapshot.
445    #[allow(dead_code)]
446    pub fn velocity_array(snap: &SimulationSnapshot) -> Self {
447        let n = snap.bodies.len();
448        let mut data = Vec::with_capacity(n * 3);
449        for b in &snap.bodies {
450            data.extend_from_slice(&b.velocity);
451        }
452        Self {
453            data,
454            shape: [n, 3],
455            dtype: "float64".to_string(),
456            c_order: true,
457        }
458    }
459    /// Get the position of body at row `i` as `[x, y, z]`.
460    #[allow(dead_code)]
461    pub fn get_row(&self, i: usize) -> Option<[f64; 3]> {
462        if i >= self.shape[0] {
463            return None;
464        }
465        let base = i * 3;
466        Some([self.data[base], self.data[base + 1], self.data[base + 2]])
467    }
468    /// Number of rows (bodies).
469    #[allow(dead_code)]
470    pub fn n_rows(&self) -> usize {
471        self.shape[0]
472    }
473    /// Total number of f64 elements.
474    #[allow(dead_code)]
475    pub fn size(&self) -> usize {
476        self.data.len()
477    }
478    /// Serialize to JSON.
479    #[allow(dead_code)]
480    pub fn to_json(&self) -> String {
481        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
482    }
483    /// Serialize data to raw bytes (f64 LE).
484    #[allow(dead_code)]
485    pub fn to_raw_bytes(&self) -> Vec<u8> {
486        let mut buf = Vec::with_capacity(self.data.len() * 8);
487        for &v in &self.data {
488            buf.extend_from_slice(&v.to_le_bytes());
489        }
490        buf
491    }
492}
493/// A serialized representation of a single body's state as a JSON-ready struct.
494#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
495pub struct BodyStateJson {
496    /// Body handle.
497    pub handle: u32,
498    /// World-space position `[x, y, z]`.
499    pub position: [f64; 3],
500    /// Linear velocity `[vx, vy, vz]`.
501    pub velocity: [f64; 3],
502    /// Orientation quaternion `[x, y, z, w]`.
503    pub orientation: [f64; 4],
504    /// Angular velocity `[wx, wy, wz]`.
505    pub angular_velocity: [f64; 3],
506    /// Whether body is sleeping.
507    pub is_sleeping: bool,
508    /// Whether body is static.
509    pub is_static: bool,
510    /// Optional user tag.
511    pub tag: Option<String>,
512    /// Schema version string.
513    pub schema_version: String,
514}
515impl BodyStateJson {
516    /// Create a `BodyStateJson` from a `SimBodyState`.
517    pub fn from_sim_body(body: &SimBodyState) -> Self {
518        Self {
519            handle: body.handle,
520            position: body.position,
521            velocity: body.velocity,
522            orientation: body.orientation,
523            angular_velocity: body.angular_velocity,
524            is_sleeping: body.is_sleeping,
525            is_static: body.is_static,
526            tag: body.tag.clone(),
527            schema_version: "1.0.0".to_string(),
528        }
529    }
530    /// Convert back to a `SimBodyState`.
531    pub fn to_sim_body(&self) -> SimBodyState {
532        SimBodyState {
533            handle: self.handle,
534            position: self.position,
535            velocity: self.velocity,
536            orientation: self.orientation,
537            angular_velocity: self.angular_velocity,
538            is_sleeping: self.is_sleeping,
539            is_static: self.is_static,
540            tag: self.tag.clone(),
541        }
542    }
543}
544/// A simulation checkpoint with metadata for save/restore workflows.
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct SimulationCheckpoint {
547    /// Checkpoint format version.
548    pub version: u32,
549    /// Human-readable checkpoint label.
550    pub label: String,
551    /// Wall-clock timestamp (Unix seconds, approximate).
552    pub timestamp: f64,
553    /// Simulation time at checkpoint.
554    pub sim_time: f64,
555    /// Number of steps taken.
556    pub step_count: u64,
557    /// Gravity vector at checkpoint.
558    pub gravity: [f64; 3],
559    /// All body states.
560    pub bodies: Vec<BodyStateJson>,
561    /// Arbitrary metadata key-value pairs.
562    pub metadata: std::collections::HashMap<String, String>,
563}
564impl SimulationCheckpoint {
565    /// Current checkpoint format version.
566    pub const FORMAT_VERSION: u32 = 1;
567    /// Create an empty checkpoint.
568    pub fn empty(label: impl Into<String>) -> Self {
569        Self {
570            version: Self::FORMAT_VERSION,
571            label: label.into(),
572            timestamp: 0.0,
573            sim_time: 0.0,
574            step_count: 0,
575            gravity: [0.0, -9.81, 0.0],
576            bodies: Vec::new(),
577            metadata: std::collections::HashMap::new(),
578        }
579    }
580    /// Build a checkpoint from a `SimulationSnapshot`.
581    pub fn from_snapshot(
582        snap: &SimulationSnapshot,
583        label: impl Into<String>,
584        step_count: u64,
585        timestamp: f64,
586    ) -> Self {
587        let bodies = snap
588            .bodies
589            .iter()
590            .map(BodyStateJson::from_sim_body)
591            .collect();
592        Self {
593            version: Self::FORMAT_VERSION,
594            label: label.into(),
595            timestamp,
596            sim_time: snap.time,
597            step_count,
598            gravity: snap.gravity,
599            bodies,
600            metadata: snap.metadata.clone(),
601        }
602    }
603    /// Serialize to JSON.
604    pub fn to_json(&self) -> String {
605        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
606    }
607    /// Deserialize from JSON.
608    pub fn from_json(json: &str) -> Result<Self, crate::Error> {
609        serde_json::from_str(json)
610            .map_err(|e| crate::Error::General(format!("checkpoint deserialization failed: {e}")))
611    }
612    /// Convert back to a `SimulationSnapshot`.
613    pub fn to_snapshot(&self) -> SimulationSnapshot {
614        let bodies = self.bodies.iter().map(|b| b.to_sim_body()).collect();
615        SimulationSnapshot {
616            version: SimulationSnapshot::FORMAT_VERSION,
617            time: self.sim_time,
618            gravity: self.gravity,
619            bodies,
620            contacts: Vec::new(),
621            sleeping_count: 0,
622            description: Some(format!("Restored from checkpoint '{}'", self.label)),
623            metadata: self.metadata.clone(),
624        }
625    }
626    /// Number of bodies in the checkpoint.
627    pub fn body_count(&self) -> usize {
628        self.bodies.len()
629    }
630}
631/// An incremental state update (delta) for efficient streaming.
632///
633/// Instead of sending the full snapshot every frame, only changed bodies
634/// are included in the delta.
635#[derive(Debug, Clone, Serialize, Deserialize)]
636pub struct IncrementalUpdate {
637    /// Sequence number for ordering.
638    pub sequence: u64,
639    /// Timestamp of this update.
640    pub time: f64,
641    /// Only bodies that changed since the last update.
642    pub changed_bodies: Vec<SimBodyState>,
643    /// Handles of bodies that were removed.
644    pub removed_handles: Vec<u32>,
645    /// Handles of bodies that were added.
646    pub added_handles: Vec<u32>,
647}
648impl IncrementalUpdate {
649    /// Create an empty incremental update.
650    pub fn empty(sequence: u64, time: f64) -> Self {
651        Self {
652            sequence,
653            time,
654            changed_bodies: Vec::new(),
655            removed_handles: Vec::new(),
656            added_handles: Vec::new(),
657        }
658    }
659    /// Serialize to JSON.
660    pub fn to_json(&self) -> String {
661        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
662    }
663    /// Deserialize from JSON.
664    pub fn from_json(json: &str) -> Result<Self, Error> {
665        serde_json::from_str(json)
666            .map_err(|e| Error::General(format!("incremental update deserialization: {e}")))
667    }
668    /// Whether this update is empty (no changes).
669    pub fn is_empty(&self) -> bool {
670        self.changed_bodies.is_empty()
671            && self.removed_handles.is_empty()
672            && self.added_handles.is_empty()
673    }
674    /// Number of changes in this update.
675    pub fn change_count(&self) -> usize {
676        self.changed_bodies.len() + self.removed_handles.len() + self.added_handles.len()
677    }
678}
679/// A single incremental export batch.
680#[derive(Debug, Clone, Serialize, Deserialize)]
681pub struct ExportBatch {
682    /// Batch index (0-based).
683    pub batch_index: usize,
684    /// Whether this is the final batch.
685    pub is_last: bool,
686    /// Total number of batches.
687    pub total_batches: usize,
688    /// Simulation time.
689    pub time: f64,
690    /// Body states in this batch.
691    pub bodies: Vec<SimBodyState>,
692}
693impl ExportBatch {
694    /// Serialize to JSON.
695    #[allow(dead_code)]
696    pub fn to_json(&self) -> String {
697        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
698    }
699    /// Deserialize from JSON.
700    #[allow(dead_code)]
701    pub fn from_json(json: &str) -> Result<Self, Error> {
702        serde_json::from_str(json)
703            .map_err(|e| Error::General(format!("ExportBatch deserialization: {e}")))
704    }
705}
706/// Schema version information.
707#[derive(Debug, Clone, Serialize, Deserialize)]
708pub struct SchemaVersion {
709    /// Major version (breaking changes).
710    pub major: u32,
711    /// Minor version (backward-compatible additions).
712    pub minor: u32,
713    /// Patch version.
714    pub patch: u32,
715}
716impl SchemaVersion {
717    /// Current schema version.
718    pub fn current() -> Self {
719        Self {
720            major: 1,
721            minor: 0,
722            patch: 0,
723        }
724    }
725    /// Check if this version is compatible with another.
726    pub fn is_compatible_with(&self, other: &SchemaVersion) -> bool {
727        self.major == other.major
728    }
729    /// Version string (e.g. "1.0.0").
730    pub fn to_string_version(&self) -> String {
731        format!("{}.{}.{}", self.major, self.minor, self.patch)
732    }
733}
734/// A Python-dict-compatible representation of a `SimulationSnapshot`.
735///
736/// The `to_dict_json` method returns a JSON string structured like a Python dict
737/// that the Python side can `eval()` or `json.loads()` directly.
738#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct SnapshotDict {
740    /// Snapshot version.
741    pub version: u32,
742    /// Simulation time.
743    pub time: f64,
744    /// Gravity as list.
745    pub gravity: [f64; 3],
746    /// Body states as list of dicts.
747    pub bodies: Vec<BodyDict>,
748    /// Number of contacts.
749    pub n_contacts: usize,
750    /// Optional description.
751    pub description: Option<String>,
752}
753impl SnapshotDict {
754    /// Build from a `SimulationSnapshot`.
755    #[allow(dead_code)]
756    pub fn from_snapshot(snap: &SimulationSnapshot) -> Self {
757        Self {
758            version: snap.version,
759            time: snap.time,
760            gravity: snap.gravity,
761            bodies: snap.bodies.iter().map(BodyDict::from_sim_body).collect(),
762            n_contacts: snap.contacts.len(),
763            description: snap.description.clone(),
764        }
765    }
766    /// Convert back to `SimulationSnapshot`.
767    #[allow(dead_code)]
768    pub fn to_snapshot(&self) -> SimulationSnapshot {
769        let bodies: Vec<SimBodyState> = self.bodies.iter().map(|b| b.to_sim_body()).collect();
770        let sleeping_count = bodies.iter().filter(|b| b.is_sleeping).count();
771        SimulationSnapshot {
772            version: self.version,
773            time: self.time,
774            gravity: self.gravity,
775            bodies,
776            contacts: Vec::new(),
777            sleeping_count,
778            description: self.description.clone(),
779            metadata: std::collections::HashMap::new(),
780        }
781    }
782    /// Serialize to JSON string (like Python's `json.dumps`).
783    #[allow(dead_code)]
784    pub fn to_dict_json(&self) -> String {
785        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
786    }
787    /// Deserialize from JSON string.
788    #[allow(dead_code)]
789    pub fn from_dict_json(json: &str) -> Result<Self, Error> {
790        serde_json::from_str(json)
791            .map_err(|e| Error::General(format!("SnapshotDict deserialization: {e}")))
792    }
793}
794/// Difference report between two snapshots.
795#[derive(Debug, Clone)]
796#[allow(dead_code)]
797pub struct SnapshotDiff {
798    /// Handles present in A but not in B (removed bodies).
799    pub removed: Vec<u32>,
800    /// Handles present in B but not in A (added bodies).
801    pub added: Vec<u32>,
802    /// Handles present in both with changed position (beyond threshold).
803    pub moved: Vec<u32>,
804    /// Maximum position displacement among all common bodies.
805    pub max_displacement: f64,
806    /// Time difference between snapshots.
807    pub time_delta: f64,
808}
809impl SnapshotDiff {
810    /// Returns `true` if there are no differences.
811    pub fn is_identical(&self) -> bool {
812        self.removed.is_empty() && self.added.is_empty() && self.moved.is_empty()
813    }
814    /// Total number of changed bodies.
815    pub fn change_count(&self) -> usize {
816        self.removed.len() + self.added.len() + self.moved.len()
817    }
818}
819/// Validation result for a deserialized snapshot.
820#[derive(Debug, Clone)]
821#[allow(dead_code)]
822pub struct ValidationResult {
823    /// Whether the snapshot is valid.
824    pub is_valid: bool,
825    /// List of validation warnings/errors.
826    pub issues: Vec<String>,
827}