Skip to main content

oxiphysics_python/serialization/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5use crate::Error;
6use crate::types::{PySimConfig, PyVec3};
7use crate::world_api::PyPhysicsWorld;
8
9use super::types::{
10    BodyStateJson, ExportBatch, IncrementalExportConfig, IncrementalUpdate, SchemaValidationResult,
11    SimBodyState, SimulationSnapshot, ValidationResult, WorldState,
12};
13
14/// Compute an incremental update between two snapshots.
15#[allow(dead_code)]
16pub fn compute_incremental_update(
17    old: &SimulationSnapshot,
18    new: &SimulationSnapshot,
19    sequence: u64,
20) -> IncrementalUpdate {
21    let old_handles: std::collections::HashSet<u32> = old.bodies.iter().map(|b| b.handle).collect();
22    let new_handles: std::collections::HashSet<u32> = new.bodies.iter().map(|b| b.handle).collect();
23    let removed: Vec<u32> = old_handles.difference(&new_handles).copied().collect();
24    let added: Vec<u32> = new_handles.difference(&old_handles).copied().collect();
25    let mut changed = Vec::new();
26    for new_body in &new.bodies {
27        if let Some(old_body) = old.find_body(new_body.handle) {
28            let pos_changed =
29                (0..3).any(|k| (new_body.position[k] - old_body.position[k]).abs() > 1e-12);
30            let vel_changed =
31                (0..3).any(|k| (new_body.velocity[k] - old_body.velocity[k]).abs() > 1e-12);
32            if pos_changed || vel_changed {
33                changed.push(new_body.clone());
34            }
35        } else {
36            changed.push(new_body.clone());
37        }
38    }
39    IncrementalUpdate {
40        sequence,
41        time: new.time,
42        changed_bodies: changed,
43        removed_handles: removed,
44        added_handles: added,
45    }
46}
47/// Apply an incremental update to a snapshot.
48#[allow(dead_code)]
49pub fn apply_incremental_update(snap: &mut SimulationSnapshot, update: &IncrementalUpdate) {
50    snap.bodies
51        .retain(|b| !update.removed_handles.contains(&b.handle));
52    for changed in &update.changed_bodies {
53        if let Some(existing) = snap.bodies.iter_mut().find(|b| b.handle == changed.handle) {
54            *existing = changed.clone();
55        } else {
56            snap.bodies.push(changed.clone());
57        }
58    }
59    snap.time = update.time;
60}
61/// Validate a deserialized snapshot for consistency.
62#[allow(dead_code)]
63pub fn validate_snapshot(snap: &SimulationSnapshot) -> ValidationResult {
64    let mut issues = Vec::new();
65    if snap.version != SimulationSnapshot::FORMAT_VERSION {
66        issues.push(format!(
67            "version mismatch: expected {}, got {}",
68            SimulationSnapshot::FORMAT_VERSION,
69            snap.version
70        ));
71    }
72    let mut handles = std::collections::HashSet::new();
73    for body in &snap.bodies {
74        if !handles.insert(body.handle) {
75            issues.push(format!("duplicate handle: {}", body.handle));
76        }
77    }
78    for body in &snap.bodies {
79        for k in 0..3 {
80            if !body.position[k].is_finite() {
81                issues.push(format!("body {}: non-finite position[{k}]", body.handle));
82            }
83            if !body.velocity[k].is_finite() {
84                issues.push(format!("body {}: non-finite velocity[{k}]", body.handle));
85            }
86            if !body.angular_velocity[k].is_finite() {
87                issues.push(format!(
88                    "body {}: non-finite angular_velocity[{k}]",
89                    body.handle
90                ));
91            }
92        }
93        for k in 0..4 {
94            if !body.orientation[k].is_finite() {
95                issues.push(format!("body {}: non-finite orientation[{k}]", body.handle));
96            }
97        }
98        let qlen = body.orientation.iter().map(|x| x * x).sum::<f64>().sqrt();
99        if (qlen - 1.0).abs() > 0.01 {
100            issues.push(format!(
101                "body {}: quaternion not normalized (len={})",
102                body.handle, qlen
103            ));
104        }
105    }
106    let actual_sleeping = snap.bodies.iter().filter(|b| b.is_sleeping).count();
107    if actual_sleeping != snap.sleeping_count {
108        issues.push(format!(
109            "sleeping_count mismatch: field says {}, actual is {}",
110            snap.sleeping_count, actual_sleeping
111        ));
112    }
113    if !snap.time.is_finite() {
114        issues.push("non-finite simulation time".to_string());
115    }
116    for k in 0..3 {
117        if !snap.gravity[k].is_finite() {
118            issues.push(format!("non-finite gravity[{k}]"));
119        }
120    }
121    ValidationResult {
122        is_valid: issues.is_empty(),
123        issues,
124    }
125}
126/// Serialize the current state of `world` into a compact JSON string.
127pub fn save_snapshot(world: &PyPhysicsWorld) -> String {
128    build_snapshot(world).to_json()
129}
130/// Serialize the current state of `world` into a pretty-printed JSON string.
131pub fn save_snapshot_pretty(world: &PyPhysicsWorld) -> String {
132    build_snapshot(world).to_pretty_json()
133}
134/// Deserialize a `SimulationSnapshot` from a JSON string.
135pub fn load_snapshot(json: &str) -> Result<SimulationSnapshot, Error> {
136    SimulationSnapshot::from_json(json)
137}
138/// Build a `SimulationSnapshot` from `world` without serializing it.
139pub fn build_snapshot(world: &PyPhysicsWorld) -> SimulationSnapshot {
140    let handles = world.active_handles();
141    let bodies: Vec<SimBodyState> = handles
142        .iter()
143        .map(|&h| SimBodyState {
144            handle: h,
145            position: world.get_position(h).unwrap_or([0.0; 3]),
146            velocity: world.get_velocity(h).unwrap_or([0.0; 3]),
147            orientation: world.get_orientation(h).unwrap_or([0.0, 0.0, 0.0, 1.0]),
148            angular_velocity: world.get_angular_velocity(h).unwrap_or([0.0; 3]),
149            is_sleeping: world.is_sleeping(h),
150            is_static: false,
151            tag: world.get_tag(h),
152        })
153        .collect();
154    SimulationSnapshot {
155        version: SimulationSnapshot::FORMAT_VERSION,
156        time: world.time(),
157        gravity: world.gravity(),
158        bodies,
159        contacts: world.get_contacts(),
160        sleeping_count: world.sleeping_count(),
161        description: None,
162        metadata: std::collections::HashMap::new(),
163    }
164}
165/// Restore world state from a snapshot (positions and velocities only).
166pub fn apply_snapshot(world: &mut PyPhysicsWorld, snap: &SimulationSnapshot) -> usize {
167    let mut updated = 0;
168    for body_state in &snap.bodies {
169        let h = body_state.handle;
170        if world.get_position(h).is_some() {
171            world.set_position(h, body_state.position);
172            world.set_velocity(h, body_state.velocity);
173            world.set_orientation(h, body_state.orientation);
174            world.set_angular_velocity(h, body_state.angular_velocity);
175            updated += 1;
176        }
177    }
178    updated
179}
180/// Serialize a `PySimConfig` to JSON.
181pub fn config_to_json(config: &PySimConfig) -> String {
182    serde_json::to_string(config).unwrap_or_else(|_| "{}".to_string())
183}
184/// Deserialize a `PySimConfig` from JSON.
185pub fn config_from_json(json: &str) -> Result<PySimConfig, Error> {
186    serde_json::from_str(json)
187        .map_err(|e| Error::General(format!("config deserialization failed: {e}")))
188}
189/// Serialize a `PyPhysicsWorld` to a JSON string (legacy).
190pub fn to_json(world: &PyPhysicsWorld) -> String {
191    let state = WorldState {
192        gravity: PyVec3::from_array(world.gravity()),
193        time: world.time(),
194        positions: world
195            .all_positions()
196            .into_iter()
197            .map(PyVec3::from_array)
198            .collect(),
199        num_bodies: world.body_count(),
200    };
201    serde_json::to_string(&state).unwrap_or_else(|_| "{}".to_string())
202}
203/// Deserialize a `WorldState` from a JSON string (legacy).
204pub fn from_json(json: &str) -> Option<WorldState> {
205    serde_json::from_str(json).ok()
206}
207/// Magic bytes that identify the OxiPhysics pickle envelope.
208pub(super) const PICKLE_MAGIC: &[u8; 4] = b"OXPK";
209/// Protocol version stored in the pickle envelope.
210pub(super) const PICKLE_VERSION: u8 = 2;
211/// Compute pairwise distances between all body pairs from a snapshot.
212/// Returns a flat upper-triangular distance array.
213#[allow(dead_code)]
214pub fn compute_pairwise_distances(snap: &SimulationSnapshot) -> Vec<f64> {
215    let n = snap.bodies.len();
216    let mut dists = Vec::new();
217    for i in 0..n {
218        for j in (i + 1)..n {
219            let pi = &snap.bodies[i].position;
220            let pj = &snap.bodies[j].position;
221            let dx = pi[0] - pj[0];
222            let dy = pi[1] - pj[1];
223            let dz = pi[2] - pj[2];
224            dists.push((dx * dx + dy * dy + dz * dz).sqrt());
225        }
226    }
227    dists
228}
229/// Validate that a JSON string is a well-formed `SimulationSnapshot`.
230///
231/// Checks:
232/// - Parses as valid JSON object
233/// - Contains required top-level keys
234/// - `bodies` is an array
235/// - All body entries have `handle`, `position`, `velocity` fields
236/// - `time` and `gravity` are finite numbers / arrays
237#[allow(dead_code)]
238pub fn validate_snapshot_json(json: &str) -> SchemaValidationResult {
239    let value: serde_json::Value = match serde_json::from_str(json) {
240        Ok(v) => v,
241        Err(e) => return SchemaValidationResult::err(format!("JSON parse error: {e}")),
242    };
243    let obj = match value.as_object() {
244        Some(o) => o,
245        None => return SchemaValidationResult::err("root is not an object"),
246    };
247    let mut errors = Vec::new();
248    for key in &["version", "time", "gravity", "bodies"] {
249        if !obj.contains_key(*key) {
250            errors.push(format!("missing required key: {key}"));
251        }
252    }
253    if let Some(t) = obj.get("time").and_then(|v| v.as_f64())
254        && !t.is_finite()
255    {
256        errors.push("time is not finite".to_string());
257    }
258    if let Some(g) = obj.get("gravity").and_then(|v| v.as_array()) {
259        if g.len() != 3 {
260            errors.push(format!("gravity must have 3 components, got {}", g.len()));
261        }
262        for (i, gi) in g.iter().enumerate() {
263            if let Some(f) = gi.as_f64() {
264                if !f.is_finite() {
265                    errors.push(format!("gravity[{i}] is not finite"));
266                }
267            } else {
268                errors.push(format!("gravity[{i}] is not a number"));
269            }
270        }
271    } else if obj.contains_key("gravity") {
272        errors.push("gravity is not an array".to_string());
273    }
274    if let Some(bodies) = obj.get("bodies").and_then(|v| v.as_array()) {
275        for (idx, body) in bodies.iter().enumerate() {
276            let body_obj = match body.as_object() {
277                Some(o) => o,
278                None => {
279                    errors.push(format!("bodies[{idx}] is not an object"));
280                    continue;
281                }
282            };
283            for field in &["handle", "position", "velocity"] {
284                if !body_obj.contains_key(*field) {
285                    errors.push(format!("bodies[{idx}] missing field: {field}"));
286                }
287            }
288            if let Some(pos) = body_obj.get("position").and_then(|v| v.as_array())
289                && pos.len() != 3
290            {
291                errors.push(format!("bodies[{idx}].position must have 3 components"));
292            }
293        }
294    } else if obj.contains_key("bodies") {
295        errors.push("bodies is not an array".to_string());
296    }
297    SchemaValidationResult {
298        is_valid: errors.is_empty(),
299        errors,
300    }
301}
302/// Stream-export a snapshot as a series of `ExportBatch` objects.
303#[allow(dead_code)]
304pub fn export_snapshot_incremental(
305    snap: &SimulationSnapshot,
306    config: &IncrementalExportConfig,
307) -> Vec<ExportBatch> {
308    let filtered: Vec<&SimBodyState> = snap
309        .bodies
310        .iter()
311        .filter(|b| {
312            if !config.include_sleeping && b.is_sleeping {
313                return false;
314            }
315            b.speed() >= config.min_speed_threshold
316        })
317        .collect();
318    let chunk_size = config.max_batch_size.max(1);
319    let total_batches = (filtered.len() + chunk_size - 1).max(1) / chunk_size;
320    filtered
321        .chunks(chunk_size)
322        .enumerate()
323        .map(|(i, chunk)| ExportBatch {
324            batch_index: i,
325            is_last: i + 1 >= total_batches,
326            total_batches,
327            time: snap.time,
328            bodies: chunk.iter().map(|b| (*b).clone()).collect(),
329        })
330        .collect()
331}
332/// Reconstruct a snapshot by merging export batches (in order).
333#[allow(dead_code)]
334pub fn merge_export_batches(batches: &[ExportBatch]) -> SimulationSnapshot {
335    let time = batches.first().map(|b| b.time).unwrap_or(0.0);
336    let bodies: Vec<SimBodyState> = batches.iter().flat_map(|b| b.bodies.clone()).collect();
337    let sleeping_count = bodies.iter().filter(|b| b.is_sleeping).count();
338    SimulationSnapshot {
339        version: SimulationSnapshot::FORMAT_VERSION,
340        time,
341        gravity: [0.0, -9.81, 0.0],
342        bodies,
343        contacts: Vec::new(),
344        sleeping_count,
345        description: None,
346        metadata: std::collections::HashMap::new(),
347    }
348}
349/// Serialize a `SimBodyState` to a JSON string via `BodyStateJson`.
350pub fn serialize_body_state_json(body: &SimBodyState) -> String {
351    let bsj = BodyStateJson::from_sim_body(body);
352    serde_json::to_string(&bsj).unwrap_or_else(|_| "{}".to_string())
353}
354/// Deserialize a `SimBodyState` from a JSON string produced by
355/// `serialize_body_state_json`.
356pub fn deserialize_body_state_json(json: &str) -> Result<SimBodyState, crate::Error> {
357    let bsj: BodyStateJson = serde_json::from_str(json)
358        .map_err(|e| crate::Error::General(format!("body state deserialization failed: {e}")))?;
359    Ok(bsj.to_sim_body())
360}
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::serialization::BodyDict;
365    use crate::serialization::NumpyPositionArray;
366    use crate::serialization::PickleEnvelope;
367    use crate::serialization::SchemaVersion;
368    use crate::serialization::SnapshotDict;
369    use crate::types::{PyRigidBodyConfig, PyRigidBodyDesc, PySimConfig, PyVec3};
370    fn make_world_with_bodies() -> PyPhysicsWorld {
371        let mut world = PyPhysicsWorld::new(PySimConfig::earth_gravity());
372        let cfg = PyRigidBodyConfig::dynamic(1.0, [1.0, 2.0, 3.0]).with_tag("body_0");
373        world.add_rigid_body(cfg);
374        let cfg2 = PyRigidBodyConfig::static_body([0.0, 0.0, 0.0]);
375        world.add_rigid_body(cfg2);
376        world
377    }
378    #[test]
379    fn test_legacy_serialization_roundtrip() {
380        let mut world = PyPhysicsWorld::new(PySimConfig::earth_gravity());
381        let desc = PyRigidBodyDesc {
382            mass: 1.0,
383            position: PyVec3::new(1.0, 2.0, 3.0),
384            is_static: false,
385        };
386        world.add_body_legacy(&desc);
387        let json = to_json(&world);
388        let state = from_json(&json).expect("should deserialize");
389        assert_eq!(state.num_bodies, 1);
390        assert!((state.positions[0].x - 1.0).abs() < 1e-10);
391        assert!((state.gravity.y + 9.81).abs() < 1e-10);
392    }
393    #[test]
394    fn test_save_load_snapshot_roundtrip() {
395        let world = make_world_with_bodies();
396        let json = save_snapshot(&world);
397        let snap = load_snapshot(&json).expect("deserialize snapshot");
398        assert_eq!(snap.bodies.len(), 2);
399        assert_eq!(snap.version, SimulationSnapshot::FORMAT_VERSION);
400    }
401    #[test]
402    fn test_snapshot_gravity_preserved() {
403        let world = PyPhysicsWorld::new(PySimConfig::moon_gravity());
404        let json = save_snapshot(&world);
405        let snap = load_snapshot(&json).expect("deserialize");
406        assert!((snap.gravity[1] + 1.62).abs() < 1e-10);
407    }
408    #[test]
409    fn test_snapshot_time_preserved() {
410        let mut world = PyPhysicsWorld::new(PySimConfig::earth_gravity());
411        world.step(0.1);
412        world.step(0.1);
413        let json = save_snapshot(&world);
414        let snap = load_snapshot(&json).expect("deserialize");
415        assert!((snap.time - 0.2).abs() < 1e-10);
416    }
417    #[test]
418    fn test_snapshot_body_position_preserved() {
419        let mut world = PyPhysicsWorld::new(PySimConfig::earth_gravity());
420        let cfg = PyRigidBodyConfig::dynamic(1.0, [5.0, 10.0, -3.0]);
421        world.add_rigid_body(cfg);
422        let snap = build_snapshot(&world);
423        assert_eq!(snap.bodies.len(), 1);
424        assert!((snap.bodies[0].position[0] - 5.0).abs() < 1e-10);
425        assert!((snap.bodies[0].position[1] - 10.0).abs() < 1e-10);
426    }
427    #[test]
428    fn test_snapshot_find_by_tag() {
429        let world = make_world_with_bodies();
430        let snap = build_snapshot(&world);
431        let found = snap.find_by_tag("body_0");
432        assert!(found.is_some());
433        assert!((found.unwrap().position[0] - 1.0).abs() < 1e-10);
434    }
435    #[test]
436    fn test_snapshot_find_by_handle() {
437        let mut world = PyPhysicsWorld::new(PySimConfig::earth_gravity());
438        let h = world.add_rigid_body(PyRigidBodyConfig::dynamic(1.0, [7.0, 0.0, 0.0]));
439        let snap = build_snapshot(&world);
440        let found = snap.find_body(h);
441        assert!(found.is_some());
442        assert!((found.unwrap().position[0] - 7.0).abs() < 1e-10);
443    }
444    #[test]
445    fn test_snapshot_total_kinetic_energy_proxy() {
446        let mut world = PyPhysicsWorld::new(PySimConfig::zero_gravity());
447        let h = world.add_rigid_body(PyRigidBodyConfig::dynamic(1.0, [0.0; 3]));
448        world.set_velocity(h, [3.0, 4.0, 0.0]);
449        let snap = build_snapshot(&world);
450        let ke = snap.total_kinetic_energy_proxy();
451        assert!((ke - 12.5).abs() < 1e-10);
452    }
453    #[test]
454    fn test_sim_body_state_speed() {
455        let mut s = SimBodyState::at_rest(0, [0.0; 3]);
456        s.velocity = [3.0, 4.0, 0.0];
457        assert!((s.speed() - 5.0).abs() < 1e-10);
458    }
459    #[test]
460    fn test_apply_snapshot_updates_body() {
461        let mut world = PyPhysicsWorld::new(PySimConfig::earth_gravity());
462        let h = world.add_rigid_body(PyRigidBodyConfig::dynamic(1.0, [0.0; 3]));
463        let mut snap = build_snapshot(&world);
464        snap.bodies[0].position = [99.0, 0.0, 0.0];
465        let updated = apply_snapshot(&mut world, &snap);
466        assert_eq!(updated, 1);
467        let pos = world.get_position(h).unwrap();
468        assert!((pos[0] - 99.0).abs() < 1e-10);
469    }
470    #[test]
471    fn test_config_json_roundtrip() {
472        let cfg = PySimConfig::earth_gravity();
473        let json = config_to_json(&cfg);
474        let restored = config_from_json(&json).expect("restore config");
475        assert!((restored.gravity[1] + 9.81).abs() < 1e-10);
476        assert_eq!(restored.solver_iterations, cfg.solver_iterations);
477    }
478    #[test]
479    fn test_snapshot_metadata() {
480        let snap = SimulationSnapshot::empty()
481            .with_metadata("author", "test")
482            .with_description("unit test snapshot");
483        let json = snap.to_json();
484        let back = SimulationSnapshot::from_json(&json).expect("deserialize");
485        assert_eq!(
486            back.metadata.get("author").map(String::as_str),
487            Some("test")
488        );
489        assert_eq!(back.description.as_deref(), Some("unit test snapshot"));
490    }
491    #[test]
492    fn test_snapshot_pretty_json_is_valid() {
493        let world = make_world_with_bodies();
494        let pretty = save_snapshot_pretty(&world);
495        let parsed: serde_json::Value =
496            serde_json::from_str(&pretty).expect("pretty JSON must be valid");
497        assert!(parsed.is_object());
498    }
499    #[test]
500    fn test_load_snapshot_invalid_json() {
501        let result = load_snapshot("this is not json");
502        assert!(result.is_err());
503    }
504    #[test]
505    fn test_snapshot_after_step() {
506        let mut world = PyPhysicsWorld::new(PySimConfig::earth_gravity());
507        world.add_rigid_body(PyRigidBodyConfig::dynamic(1.0, [0.0, 100.0, 0.0]));
508        world.step(1.0);
509        let snap = build_snapshot(&world);
510        assert!(snap.bodies[0].position[1] < 100.0);
511        assert!(snap.bodies[0].velocity[1] < 0.0);
512    }
513    #[test]
514    fn test_sim_body_state_serde_roundtrip() {
515        let mut s = SimBodyState::at_rest(42, [1.0, 2.0, 3.0]);
516        s.velocity = [0.1, -0.2, 0.3];
517        s.tag = Some("player".to_string());
518        let json = serde_json::to_string(&s).expect("serialize");
519        let back: SimBodyState = serde_json::from_str(&json).expect("deserialize");
520        assert_eq!(back, s);
521    }
522    #[test]
523    fn test_msgpack_roundtrip() {
524        let snap = SimulationSnapshot::empty()
525            .with_metadata("key", "value")
526            .with_description("msgpack test");
527        let bytes = snap.to_msgpack();
528        let restored = SimulationSnapshot::from_msgpack(&bytes).expect("msgpack roundtrip");
529        assert_eq!(restored.description.as_deref(), Some("msgpack test"));
530        assert_eq!(
531            restored.metadata.get("key").map(String::as_str),
532            Some("value")
533        );
534    }
535    #[test]
536    fn test_msgpack_invalid_magic() {
537        let bad_data = b"BAAD\x00\x00\x00\x00";
538        let result = SimulationSnapshot::from_msgpack(bad_data);
539        assert!(result.is_err());
540    }
541    #[test]
542    fn test_msgpack_truncated() {
543        let result = SimulationSnapshot::from_msgpack(b"OXI");
544        assert!(result.is_err());
545    }
546    #[test]
547    fn test_schema_version_current() {
548        let v = SchemaVersion::current();
549        assert_eq!(v.major, 1);
550        assert_eq!(v.to_string_version(), "1.0.0");
551    }
552    #[test]
553    fn test_schema_version_compatibility() {
554        let v1 = SchemaVersion {
555            major: 1,
556            minor: 0,
557            patch: 0,
558        };
559        let v2 = SchemaVersion {
560            major: 1,
561            minor: 1,
562            patch: 0,
563        };
564        let v3 = SchemaVersion {
565            major: 2,
566            minor: 0,
567            patch: 0,
568        };
569        assert!(v1.is_compatible_with(&v2));
570        assert!(!v1.is_compatible_with(&v3));
571    }
572    #[test]
573    fn test_incremental_update_empty() {
574        let update = IncrementalUpdate::empty(0, 0.0);
575        assert!(update.is_empty());
576        assert_eq!(update.change_count(), 0);
577    }
578    #[test]
579    fn test_incremental_update_json_roundtrip() {
580        let mut update = IncrementalUpdate::empty(1, 0.5);
581        update
582            .changed_bodies
583            .push(SimBodyState::at_rest(0, [1.0, 0.0, 0.0]));
584        update.removed_handles.push(5);
585        let json = update.to_json();
586        let restored = IncrementalUpdate::from_json(&json).expect("roundtrip");
587        assert_eq!(restored.sequence, 1);
588        assert_eq!(restored.changed_bodies.len(), 1);
589        assert_eq!(restored.removed_handles.len(), 1);
590    }
591    #[test]
592    fn test_compute_incremental_update() {
593        let mut old = SimulationSnapshot::empty();
594        old.bodies.push(SimBodyState::at_rest(0, [0.0, 0.0, 0.0]));
595        old.bodies.push(SimBodyState::at_rest(1, [1.0, 0.0, 0.0]));
596        let mut new = SimulationSnapshot::empty();
597        new.time = 1.0;
598        new.bodies.push(SimBodyState::at_rest(0, [0.0, 0.0, 0.0]));
599        let mut moved_body = SimBodyState::at_rest(1, [2.0, 0.0, 0.0]);
600        moved_body.velocity = [1.0, 0.0, 0.0];
601        new.bodies.push(moved_body);
602        new.bodies.push(SimBodyState::at_rest(2, [3.0, 0.0, 0.0]));
603        let update = compute_incremental_update(&old, &new, 1);
604        assert!(!update.is_empty());
605        assert!(!update.changed_bodies.is_empty());
606        assert!(update.added_handles.contains(&2));
607    }
608    #[test]
609    fn test_apply_incremental_update() {
610        let mut snap = SimulationSnapshot::empty();
611        snap.bodies.push(SimBodyState::at_rest(0, [0.0, 0.0, 0.0]));
612        snap.bodies.push(SimBodyState::at_rest(1, [1.0, 0.0, 0.0]));
613        let mut update = IncrementalUpdate::empty(1, 1.0);
614        update
615            .changed_bodies
616            .push(SimBodyState::at_rest(0, [5.0, 0.0, 0.0]));
617        update.removed_handles.push(1);
618        apply_incremental_update(&mut snap, &update);
619        assert_eq!(snap.bodies.len(), 1);
620        assert!((snap.bodies[0].position[0] - 5.0).abs() < 1e-10);
621        assert!((snap.time - 1.0).abs() < 1e-10);
622    }
623    #[test]
624    fn test_validate_valid_snapshot() {
625        let snap = SimulationSnapshot::empty();
626        let result = validate_snapshot(&snap);
627        assert!(
628            result.is_valid,
629            "empty snapshot should be valid: {:?}",
630            result.issues
631        );
632    }
633    #[test]
634    fn test_validate_duplicate_handles() {
635        let mut snap = SimulationSnapshot::empty();
636        snap.bodies.push(SimBodyState::at_rest(0, [0.0; 3]));
637        snap.bodies.push(SimBodyState::at_rest(0, [1.0, 0.0, 0.0]));
638        let result = validate_snapshot(&snap);
639        assert!(!result.is_valid);
640        assert!(result.issues.iter().any(|s| s.contains("duplicate")));
641    }
642    #[test]
643    fn test_validate_nan_position() {
644        let mut snap = SimulationSnapshot::empty();
645        let mut body = SimBodyState::at_rest(0, [0.0; 3]);
646        body.position[0] = f64::NAN;
647        snap.bodies.push(body);
648        let result = validate_snapshot(&snap);
649        assert!(!result.is_valid);
650        assert!(result.issues.iter().any(|s| s.contains("non-finite")));
651    }
652    #[test]
653    fn test_validate_quaternion_normalization() {
654        let mut snap = SimulationSnapshot::empty();
655        let mut body = SimBodyState::at_rest(0, [0.0; 3]);
656        body.orientation = [0.0, 0.0, 0.0, 0.0];
657        snap.bodies.push(body);
658        let result = validate_snapshot(&snap);
659        assert!(!result.is_valid);
660    }
661    #[test]
662    fn test_distance_from_origin() {
663        let s = SimBodyState::at_rest(0, [3.0, 4.0, 0.0]);
664        assert!((s.distance_from_origin() - 5.0).abs() < 1e-10);
665    }
666    #[test]
667    fn test_is_at_rest() {
668        let mut s = SimBodyState::at_rest(0, [0.0; 3]);
669        assert!(s.is_at_rest(0.1, 0.1));
670        s.velocity = [1.0, 0.0, 0.0];
671        assert!(!s.is_at_rest(0.1, 0.1));
672    }
673    #[test]
674    fn test_snapshot_static_dynamic_counts() {
675        let mut snap = SimulationSnapshot::empty();
676        let mut b1 = SimBodyState::at_rest(0, [0.0; 3]);
677        b1.is_static = true;
678        snap.bodies.push(b1);
679        snap.bodies.push(SimBodyState::at_rest(1, [1.0, 0.0, 0.0]));
680        assert_eq!(snap.static_body_count(), 1);
681        assert_eq!(snap.dynamic_body_count(), 1);
682    }
683    #[test]
684    fn test_snapshot_handles() {
685        let mut snap = SimulationSnapshot::empty();
686        snap.bodies.push(SimBodyState::at_rest(5, [0.0; 3]));
687        snap.bodies.push(SimBodyState::at_rest(10, [0.0; 3]));
688        let handles = snap.handles();
689        assert_eq!(handles, vec![5, 10]);
690    }
691    #[test]
692    fn test_find_by_tag_prefix() {
693        let mut snap = SimulationSnapshot::empty();
694        let mut b1 = SimBodyState::at_rest(0, [0.0; 3]);
695        b1.tag = Some("player_1".to_string());
696        let mut b2 = SimBodyState::at_rest(1, [0.0; 3]);
697        b2.tag = Some("player_2".to_string());
698        let mut b3 = SimBodyState::at_rest(2, [0.0; 3]);
699        b3.tag = Some("enemy_1".to_string());
700        snap.bodies.push(b1);
701        snap.bodies.push(b2);
702        snap.bodies.push(b3);
703        let players = snap.find_by_tag_prefix("player");
704        assert_eq!(players.len(), 2);
705    }
706    #[test]
707    fn test_pickle_envelope_roundtrip() {
708        let snap = SimulationSnapshot::empty()
709            .with_metadata("source", "test")
710            .with_description("pickle test");
711        let env = PickleEnvelope::new(snap.clone());
712        let bytes = env.to_bytes();
713        let restored = PickleEnvelope::from_bytes(&bytes).expect("pickle roundtrip");
714        assert_eq!(
715            restored.snapshot.description.as_deref(),
716            Some("pickle test")
717        );
718        assert_eq!(
719            restored.snapshot.metadata.get("source").map(String::as_str),
720            Some("test")
721        );
722    }
723    #[test]
724    fn test_pickle_envelope_invalid_magic() {
725        let bad = b"BAAD\x02\x00\x00\x00\x00{}";
726        assert!(PickleEnvelope::from_bytes(bad).is_err());
727    }
728    #[test]
729    fn test_pickle_envelope_truncated() {
730        let result = PickleEnvelope::from_bytes(b"OX");
731        assert!(result.is_err());
732    }
733    #[test]
734    fn test_pickle_envelope_to_hex_non_empty() {
735        let snap = SimulationSnapshot::empty();
736        let env = PickleEnvelope::new(snap);
737        let hex = env.to_hex();
738        assert!(!hex.is_empty());
739        assert!(
740            hex.starts_with("4f58504b"),
741            "expected OXPK prefix, got: {hex}"
742        );
743    }
744    #[test]
745    fn test_pickle_with_bodies() {
746        let mut snap = SimulationSnapshot::empty();
747        snap.bodies.push(SimBodyState::at_rest(0, [1.0, 2.0, 3.0]));
748        snap.bodies.push(SimBodyState::at_rest(1, [4.0, 5.0, 6.0]));
749        let env = PickleEnvelope::new(snap);
750        let bytes = env.to_bytes();
751        let restored = PickleEnvelope::from_bytes(&bytes).unwrap();
752        assert_eq!(restored.snapshot.bodies.len(), 2);
753        assert!((restored.snapshot.bodies[1].position[2] - 6.0).abs() < 1e-10);
754    }
755    #[test]
756    fn test_numpy_position_array_from_snapshot() {
757        let mut snap = SimulationSnapshot::empty();
758        snap.bodies.push(SimBodyState::at_rest(0, [1.0, 2.0, 3.0]));
759        snap.bodies.push(SimBodyState::at_rest(1, [4.0, 5.0, 6.0]));
760        let arr = NumpyPositionArray::from_snapshot(&snap);
761        assert_eq!(arr.shape, [2, 3]);
762        assert_eq!(arr.size(), 6);
763        assert_eq!(arr.n_rows(), 2);
764        assert!(arr.c_order);
765        assert_eq!(arr.dtype, "float64");
766        let row0 = arr.get_row(0).unwrap();
767        assert!((row0[0] - 1.0).abs() < 1e-10);
768        assert!((row0[2] - 3.0).abs() < 1e-10);
769        let row1 = arr.get_row(1).unwrap();
770        assert!((row1[0] - 4.0).abs() < 1e-10);
771    }
772    #[test]
773    fn test_numpy_velocity_array() {
774        let mut snap = SimulationSnapshot::empty();
775        let mut b = SimBodyState::at_rest(0, [0.0; 3]);
776        b.velocity = [1.0, 2.0, 3.0];
777        snap.bodies.push(b);
778        let arr = NumpyPositionArray::velocity_array(&snap);
779        assert_eq!(arr.n_rows(), 1);
780        let row = arr.get_row(0).unwrap();
781        assert!((row[0] - 1.0).abs() < 1e-10);
782        assert!((row[1] - 2.0).abs() < 1e-10);
783    }
784    #[test]
785    fn test_numpy_position_array_get_row_out_of_range() {
786        let snap = SimulationSnapshot::empty();
787        let arr = NumpyPositionArray::from_snapshot(&snap);
788        assert!(arr.get_row(0).is_none());
789    }
790    #[test]
791    fn test_numpy_position_array_to_raw_bytes() {
792        let mut snap = SimulationSnapshot::empty();
793        snap.bodies.push(SimBodyState::at_rest(0, [1.0, 2.0, 3.0]));
794        let arr = NumpyPositionArray::from_snapshot(&snap);
795        let bytes = arr.to_raw_bytes();
796        assert_eq!(bytes.len(), 24);
797        let first = f64::from_le_bytes(bytes[0..8].try_into().unwrap());
798        assert!((first - 1.0).abs() < 1e-10);
799    }
800    #[test]
801    fn test_numpy_position_array_json_roundtrip() {
802        let mut snap = SimulationSnapshot::empty();
803        snap.bodies.push(SimBodyState::at_rest(0, [5.0, 6.0, 7.0]));
804        let arr = NumpyPositionArray::from_snapshot(&snap);
805        let json = arr.to_json();
806        let back: NumpyPositionArray = serde_json::from_str(&json).unwrap();
807        assert_eq!(back.shape, [1, 3]);
808        assert!((back.data[0] - 5.0).abs() < 1e-10);
809    }
810    #[test]
811    fn test_compute_pairwise_distances() {
812        let mut snap = SimulationSnapshot::empty();
813        snap.bodies.push(SimBodyState::at_rest(0, [0.0, 0.0, 0.0]));
814        snap.bodies.push(SimBodyState::at_rest(1, [3.0, 4.0, 0.0]));
815        snap.bodies.push(SimBodyState::at_rest(2, [0.0, 0.0, 1.0]));
816        let dists = compute_pairwise_distances(&snap);
817        assert_eq!(dists.len(), 3);
818        assert!(
819            (dists[0] - 5.0).abs() < 1e-10,
820            "expected 5.0, got {}",
821            dists[0]
822        );
823        assert!(
824            (dists[1] - 1.0).abs() < 1e-10,
825            "expected 1.0, got {}",
826            dists[1]
827        );
828    }
829    #[test]
830    fn test_body_dict_from_and_to_sim_body() {
831        let mut b = SimBodyState::at_rest(7, [1.0, 2.0, 3.0]);
832        b.velocity = [0.1, 0.2, 0.3];
833        b.tag = Some("test".to_string());
834        b.is_sleeping = true;
835        let bd = BodyDict::from_sim_body(&b);
836        assert_eq!(bd.handle, 7);
837        assert!((bd.pos[0] - 1.0).abs() < 1e-10);
838        assert!(bd.sleeping);
839        assert_eq!(bd.tag.as_deref(), Some("test"));
840        let back = bd.to_sim_body();
841        assert_eq!(back, b);
842    }
843    #[test]
844    fn test_snapshot_dict_roundtrip() {
845        let mut snap = SimulationSnapshot::empty();
846        snap.time = 5.0;
847        snap.gravity = [0.0, -9.81, 0.0];
848        snap.bodies.push(SimBodyState::at_rest(0, [1.0, 2.0, 3.0]));
849        let dict = SnapshotDict::from_snapshot(&snap);
850        assert_eq!(dict.time, 5.0);
851        assert_eq!(dict.bodies.len(), 1);
852        let json = dict.to_dict_json();
853        let back = SnapshotDict::from_dict_json(&json).unwrap();
854        assert_eq!(back.bodies.len(), 1);
855        assert!((back.time - 5.0).abs() < 1e-10);
856        let back_snap = back.to_snapshot();
857        assert_eq!(back_snap.bodies.len(), 1);
858        assert!((back_snap.bodies[0].position[0] - 1.0).abs() < 1e-10);
859    }
860    #[test]
861    fn test_snapshot_dict_n_contacts() {
862        let snap = SimulationSnapshot::empty();
863        let dict = SnapshotDict::from_snapshot(&snap);
864        assert_eq!(dict.n_contacts, 0);
865    }
866    #[test]
867    fn test_snapshot_dict_invalid_json() {
868        let result = SnapshotDict::from_dict_json("not json");
869        assert!(result.is_err());
870    }
871    #[test]
872    fn test_validate_snapshot_json_valid() {
873        let snap = SimulationSnapshot::empty();
874        let json = snap.to_json();
875        let result = validate_snapshot_json(&json);
876        assert!(
877            result.is_valid,
878            "empty snapshot JSON should be valid: {:?}",
879            result.errors
880        );
881    }
882    #[test]
883    fn test_validate_snapshot_json_invalid_json() {
884        let result = validate_snapshot_json("this is not json");
885        assert!(!result.is_valid);
886        assert!(result.errors.iter().any(|e| e.contains("JSON parse")));
887    }
888    #[test]
889    fn test_validate_snapshot_json_missing_key() {
890        let json = r#"{"version":1,"time":0.0}"#;
891        let result = validate_snapshot_json(json);
892        assert!(!result.is_valid);
893        assert!(result.errors.iter().any(|e| e.contains("gravity")));
894    }
895    #[test]
896    fn test_validate_snapshot_json_invalid_bodies() {
897        let json = r#"{"version":1,"time":0.0,"gravity":[0,0,0],"bodies":"not_an_array"}"#;
898        let result = validate_snapshot_json(json);
899        assert!(!result.is_valid);
900        assert!(
901            result
902                .errors
903                .iter()
904                .any(|e| e.contains("bodies is not an array"))
905        );
906    }
907    #[test]
908    fn test_validate_snapshot_json_body_missing_field() {
909        let json = r#"{"version":1,"time":0.0,"gravity":[0,0,0],"bodies":[{"handle":0}]}"#;
910        let result = validate_snapshot_json(json);
911        assert!(!result.is_valid);
912        assert!(result.errors.iter().any(|e| e.contains("position")));
913    }
914    #[test]
915    fn test_validate_schema_validation_result_ok() {
916        let r = SchemaValidationResult::ok();
917        assert!(r.is_valid);
918        assert!(r.errors.is_empty());
919    }
920    #[test]
921    fn test_validate_schema_validation_result_err() {
922        let r = SchemaValidationResult::err("something wrong");
923        assert!(!r.is_valid);
924        assert_eq!(r.errors.len(), 1);
925    }
926    #[test]
927    fn test_export_snapshot_incremental_basic() {
928        let mut snap = SimulationSnapshot::empty();
929        for i in 0..10 {
930            snap.bodies
931                .push(SimBodyState::at_rest(i, [i as f64, 0.0, 0.0]));
932        }
933        let config = IncrementalExportConfig {
934            max_batch_size: 3,
935            ..Default::default()
936        };
937        let batches = export_snapshot_incremental(&snap, &config);
938        assert!(!batches.is_empty());
939        assert_eq!(batches.len(), 4);
940        assert!(batches.last().unwrap().is_last);
941        assert!(!batches[0].is_last);
942        let total_bodies: usize = batches.iter().map(|b| b.bodies.len()).sum();
943        assert_eq!(total_bodies, 10);
944    }
945    #[test]
946    fn test_export_snapshot_incremental_single_batch() {
947        let mut snap = SimulationSnapshot::empty();
948        snap.bodies.push(SimBodyState::at_rest(0, [0.0; 3]));
949        let config = IncrementalExportConfig::default();
950        let batches = export_snapshot_incremental(&snap, &config);
951        assert_eq!(batches.len(), 1);
952        assert!(batches[0].is_last);
953        assert_eq!(batches[0].batch_index, 0);
954    }
955    #[test]
956    fn test_export_snapshot_incremental_speed_filter() {
957        let mut snap = SimulationSnapshot::empty();
958        snap.bodies.push(SimBodyState::at_rest(0, [0.0; 3]));
959        let mut moving = SimBodyState::at_rest(1, [0.0; 3]);
960        moving.velocity = [10.0, 0.0, 0.0];
961        snap.bodies.push(moving);
962        let config = IncrementalExportConfig {
963            min_speed_threshold: 5.0,
964            ..Default::default()
965        };
966        let batches = export_snapshot_incremental(&snap, &config);
967        let total_bodies: usize = batches.iter().map(|b| b.bodies.len()).sum();
968        assert_eq!(total_bodies, 1, "only the moving body should be exported");
969    }
970    #[test]
971    fn test_export_snapshot_incremental_exclude_sleeping() {
972        let mut snap = SimulationSnapshot::empty();
973        let mut sleeping = SimBodyState::at_rest(0, [0.0; 3]);
974        sleeping.is_sleeping = true;
975        snap.bodies.push(sleeping);
976        snap.bodies.push(SimBodyState::at_rest(1, [1.0, 0.0, 0.0]));
977        let config = IncrementalExportConfig {
978            include_sleeping: false,
979            ..Default::default()
980        };
981        let batches = export_snapshot_incremental(&snap, &config);
982        let total_bodies: usize = batches.iter().map(|b| b.bodies.len()).sum();
983        assert_eq!(total_bodies, 1);
984    }
985    #[test]
986    fn test_merge_export_batches() {
987        let mut snap = SimulationSnapshot::empty();
988        snap.time = 3.0;
989        for i in 0..6 {
990            snap.bodies
991                .push(SimBodyState::at_rest(i, [i as f64, 0.0, 0.0]));
992        }
993        let config = IncrementalExportConfig {
994            max_batch_size: 2,
995            ..Default::default()
996        };
997        let batches = export_snapshot_incremental(&snap, &config);
998        let merged = merge_export_batches(&batches);
999        assert_eq!(merged.bodies.len(), 6);
1000        assert!((merged.time - 3.0).abs() < 1e-10);
1001    }
1002    #[test]
1003    fn test_export_batch_json_roundtrip() {
1004        let batch = ExportBatch {
1005            batch_index: 0,
1006            is_last: true,
1007            total_batches: 1,
1008            time: 1.0,
1009            bodies: vec![SimBodyState::at_rest(0, [1.0, 2.0, 3.0])],
1010        };
1011        let json = batch.to_json();
1012        let back = ExportBatch::from_json(&json).unwrap();
1013        assert_eq!(back.batch_index, 0);
1014        assert!(back.is_last);
1015        assert_eq!(back.bodies.len(), 1);
1016    }
1017    #[test]
1018    fn test_merge_empty_batches() {
1019        let merged = merge_export_batches(&[]);
1020        assert!(merged.bodies.is_empty());
1021        assert!((merged.time - 0.0).abs() < 1e-10);
1022    }
1023}