1use 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#[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#[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#[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}
126pub fn save_snapshot(world: &PyPhysicsWorld) -> String {
128 build_snapshot(world).to_json()
129}
130pub fn save_snapshot_pretty(world: &PyPhysicsWorld) -> String {
132 build_snapshot(world).to_pretty_json()
133}
134pub fn load_snapshot(json: &str) -> Result<SimulationSnapshot, Error> {
136 SimulationSnapshot::from_json(json)
137}
138pub 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}
165pub 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}
180pub fn config_to_json(config: &PySimConfig) -> String {
182 serde_json::to_string(config).unwrap_or_else(|_| "{}".to_string())
183}
184pub 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}
189pub 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}
203pub fn from_json(json: &str) -> Option<WorldState> {
205 serde_json::from_str(json).ok()
206}
207pub(super) const PICKLE_MAGIC: &[u8; 4] = b"OXPK";
209pub(super) const PICKLE_VERSION: u8 = 2;
211#[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#[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#[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#[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}
349pub 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}
354pub 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}