Skip to main content

oxiphysics_io/
binary_format.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! HDF5-inspired binary format for simulation checkpoints and time series data.
6//!
7//! The `OxiFile` format stores hierarchical groups of typed datasets with
8//! attributes, serialized to a compact binary representation.
9#![allow(missing_docs)]
10#![allow(dead_code)]
11
12use std::fs;
13use std::io::Write;
14
15// ---------------------------------------------------------------------------
16// DataType
17// ---------------------------------------------------------------------------
18
19/// Supported element types for datasets.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DataType {
22    Float32,
23    Float64,
24    Int32,
25    Int64,
26    UInt8,
27    UInt32,
28}
29
30impl DataType {
31    /// Returns the byte size of a single element of this type.
32    pub fn size_bytes(&self) -> usize {
33        match self {
34            DataType::Float32 => 4,
35            DataType::Float64 => 8,
36            DataType::Int32 => 4,
37            DataType::Int64 => 8,
38            DataType::UInt8 => 1,
39            DataType::UInt32 => 4,
40        }
41    }
42
43    fn tag(&self) -> u8 {
44        match self {
45            DataType::Float32 => 0,
46            DataType::Float64 => 1,
47            DataType::Int32 => 2,
48            DataType::Int64 => 3,
49            DataType::UInt8 => 4,
50            DataType::UInt32 => 5,
51        }
52    }
53
54    fn from_tag(tag: u8) -> Result<Self, String> {
55        match tag {
56            0 => Ok(DataType::Float32),
57            1 => Ok(DataType::Float64),
58            2 => Ok(DataType::Int32),
59            3 => Ok(DataType::Int64),
60            4 => Ok(DataType::UInt8),
61            5 => Ok(DataType::UInt32),
62            _ => Err(format!("Unknown DataType tag: {}", tag)),
63        }
64    }
65}
66
67// ---------------------------------------------------------------------------
68// DatasetShape
69// ---------------------------------------------------------------------------
70
71/// Describes the shape (dimensions) of a dataset.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct DatasetShape {
74    pub dims: Vec<usize>,
75}
76
77impl DatasetShape {
78    /// Creates a new shape from a list of dimensions.
79    pub fn new(dims: Vec<usize>) -> Self {
80        Self { dims }
81    }
82
83    /// Total number of elements (product of all dimensions).
84    pub fn total_elements(&self) -> usize {
85        if self.dims.is_empty() {
86            return 1; // scalar
87        }
88        self.dims.iter().product()
89    }
90
91    /// Returns `true` if the shape represents a scalar (no dimensions).
92    pub fn is_scalar(&self) -> bool {
93        self.dims.is_empty()
94    }
95
96    /// Number of dimensions.
97    pub fn rank(&self) -> usize {
98        self.dims.len()
99    }
100}
101
102// ---------------------------------------------------------------------------
103// Attribute / AttributeValue
104// ---------------------------------------------------------------------------
105
106/// A named metadata attribute attached to a dataset or group.
107#[derive(Debug, Clone, PartialEq)]
108pub struct Attribute {
109    pub name: String,
110    pub value: AttributeValue,
111}
112
113impl Attribute {
114    /// Creates a new attribute with the given name and value.
115    pub fn new(name: impl Into<String>, value: AttributeValue) -> Self {
116        Self {
117            name: name.into(),
118            value,
119        }
120    }
121}
122
123/// Possible values for an attribute.
124#[derive(Debug, Clone, PartialEq)]
125pub enum AttributeValue {
126    Int(i64),
127    Float(f64),
128    Text(String),
129    IntArray(Vec<i64>),
130    FloatArray(Vec<f64>),
131}
132
133impl AttributeValue {
134    fn tag(&self) -> u8 {
135        match self {
136            AttributeValue::Int(_) => 0,
137            AttributeValue::Float(_) => 1,
138            AttributeValue::Text(_) => 2,
139            AttributeValue::IntArray(_) => 3,
140            AttributeValue::FloatArray(_) => 4,
141        }
142    }
143}
144
145// ---------------------------------------------------------------------------
146// Dataset
147// ---------------------------------------------------------------------------
148
149/// A named, typed, shaped array of raw bytes with optional attributes.
150#[derive(Debug, Clone)]
151pub struct Dataset {
152    pub name: String,
153    pub dtype: DataType,
154    pub shape: DatasetShape,
155    /// Raw little-endian bytes of the dataset contents.
156    pub data: Vec<u8>,
157    pub attributes: Vec<Attribute>,
158}
159
160impl Dataset {
161    /// Constructs a dataset from a slice of `f64` values.
162    pub fn from_f64_slice(name: &str, data: &[f64], shape: DatasetShape) -> Self {
163        let mut bytes = Vec::with_capacity(data.len() * 8);
164        for &v in data {
165            bytes.extend_from_slice(&v.to_le_bytes());
166        }
167        Self {
168            name: name.to_string(),
169            dtype: DataType::Float64,
170            shape,
171            data: bytes,
172            attributes: Vec::new(),
173        }
174    }
175
176    /// Constructs a dataset from a slice of `f32` values.
177    pub fn from_f32_slice(name: &str, data: &[f32], shape: DatasetShape) -> Self {
178        let mut bytes = Vec::with_capacity(data.len() * 4);
179        for &v in data {
180            bytes.extend_from_slice(&v.to_le_bytes());
181        }
182        Self {
183            name: name.to_string(),
184            dtype: DataType::Float32,
185            shape,
186            data: bytes,
187            attributes: Vec::new(),
188        }
189    }
190
191    /// Constructs a dataset from a slice of `i32` values.
192    pub fn from_i32_slice(name: &str, data: &[i32], shape: DatasetShape) -> Self {
193        let mut bytes = Vec::with_capacity(data.len() * 4);
194        for &v in data {
195            bytes.extend_from_slice(&v.to_le_bytes());
196        }
197        Self {
198            name: name.to_string(),
199            dtype: DataType::Int32,
200            shape,
201            data: bytes,
202            attributes: Vec::new(),
203        }
204    }
205
206    /// Decodes the raw bytes back into a `Vec`f64`.
207    pub fn to_f64_vec(&self) -> Result<Vec<f64>, String> {
208        if self.dtype != DataType::Float64 {
209            return Err(format!("Expected Float64, got {:?}", self.dtype));
210        }
211        let n = self.shape.total_elements();
212        if self.data.len() != n * 8 {
213            return Err(format!(
214                "Data length mismatch: {} bytes for {} f64 elements",
215                self.data.len(),
216                n
217            ));
218        }
219        Ok((0..n)
220            .map(|i| {
221                f64::from_le_bytes(
222                    self.data[i * 8..i * 8 + 8]
223                        .try_into()
224                        .expect("slice length must match"),
225                )
226            })
227            .collect())
228    }
229
230    /// Decodes the raw bytes back into a `Vec`f32`.
231    pub fn to_f32_vec(&self) -> Result<Vec<f32>, String> {
232        if self.dtype != DataType::Float32 {
233            return Err(format!("Expected Float32, got {:?}", self.dtype));
234        }
235        let n = self.shape.total_elements();
236        if self.data.len() != n * 4 {
237            return Err(format!(
238                "Data length mismatch: {} bytes for {} f32 elements",
239                self.data.len(),
240                n
241            ));
242        }
243        Ok((0..n)
244            .map(|i| {
245                f32::from_le_bytes(
246                    self.data[i * 4..i * 4 + 4]
247                        .try_into()
248                        .expect("slice length must match"),
249                )
250            })
251            .collect())
252    }
253
254    /// Decodes the raw bytes back into a `Vec`i32`.
255    pub fn to_i32_vec(&self) -> Result<Vec<i32>, String> {
256        if self.dtype != DataType::Int32 {
257            return Err(format!("Expected Int32, got {:?}", self.dtype));
258        }
259        let n = self.shape.total_elements();
260        if self.data.len() != n * 4 {
261            return Err(format!(
262                "Data length mismatch: {} bytes for {} i32 elements",
263                self.data.len(),
264                n
265            ));
266        }
267        Ok((0..n)
268            .map(|i| {
269                i32::from_le_bytes(
270                    self.data[i * 4..i * 4 + 4]
271                        .try_into()
272                        .expect("slice length must match"),
273                )
274            })
275            .collect())
276    }
277
278    /// Appends an attribute to this dataset.
279    pub fn add_attribute(&mut self, attr: Attribute) {
280        self.attributes.push(attr);
281    }
282
283    /// Looks up an attribute by name.
284    pub fn get_attribute(&self, name: &str) -> Option<&Attribute> {
285        self.attributes.iter().find(|a| a.name == name)
286    }
287}
288
289// ---------------------------------------------------------------------------
290// Group
291// ---------------------------------------------------------------------------
292
293/// A named container that holds datasets, subgroups, and attributes.
294#[derive(Debug, Clone)]
295pub struct Group {
296    pub name: String,
297    pub datasets: Vec<Dataset>,
298    pub subgroups: Vec<Group>,
299    pub attributes: Vec<Attribute>,
300}
301
302impl Group {
303    /// Creates an empty group with the given name.
304    pub fn new(name: &str) -> Self {
305        Self {
306            name: name.to_string(),
307            datasets: Vec::new(),
308            subgroups: Vec::new(),
309            attributes: Vec::new(),
310        }
311    }
312
313    /// Adds a dataset to this group.
314    pub fn add_dataset(&mut self, ds: Dataset) {
315        self.datasets.push(ds);
316    }
317
318    /// Adds a subgroup to this group.
319    pub fn add_subgroup(&mut self, g: Group) {
320        self.subgroups.push(g);
321    }
322
323    /// Looks up a dataset by name.
324    pub fn get_dataset(&self, name: &str) -> Option<&Dataset> {
325        self.datasets.iter().find(|d| d.name == name)
326    }
327
328    /// Looks up a direct subgroup by name.
329    pub fn get_subgroup(&self, name: &str) -> Option<&Group> {
330        self.subgroups.iter().find(|g| g.name == name)
331    }
332
333    /// Appends an attribute to this group.
334    pub fn add_attribute(&mut self, attr: Attribute) {
335        self.attributes.push(attr);
336    }
337
338    /// Looks up an attribute by name.
339    pub fn get_attribute(&self, name: &str) -> Option<&Attribute> {
340        self.attributes.iter().find(|a| a.name == name)
341    }
342}
343
344// ---------------------------------------------------------------------------
345// OxiFile
346// ---------------------------------------------------------------------------
347
348const MAGIC: &[u8; 8] = b"OXIPHY01";
349
350/// Root container for the OxiPhy binary file format.
351///
352/// Binary layout:
353/// - 8 bytes magic: `OXIPHY01`
354/// - 4 bytes version: `u32` little-endian
355/// - Recursively serialized root group
356#[derive(Debug, Clone)]
357pub struct OxiFile {
358    pub version: u32,
359    pub root: Group,
360}
361
362impl OxiFile {
363    /// Creates a new empty `OxiFile` at version 1.
364    pub fn new() -> Self {
365        Self {
366            version: 1,
367            root: Group::new("/"),
368        }
369    }
370
371    /// Serializes the entire file to a byte vector.
372    pub fn write_to_bytes(&self) -> Vec<u8> {
373        let mut buf = Vec::new();
374        buf.extend_from_slice(MAGIC);
375        write_u32(&mut buf, self.version);
376        serialize_group(&mut buf, &self.root);
377        buf
378    }
379
380    /// Deserializes an `OxiFile` from a byte slice.
381    pub fn read_from_bytes(data: &[u8]) -> Result<Self, String> {
382        if data.len() < 12 {
383            return Err("Data too short to be a valid OxiFile".to_string());
384        }
385        if &data[0..8] != MAGIC {
386            return Err("Invalid magic bytes: not an OxiFile".to_string());
387        }
388        let mut pos = 8usize;
389        let version = read_u32(data, &mut pos)?;
390        let root = deserialize_group(data, &mut pos)?;
391        Ok(Self { version, root })
392    }
393
394    /// Writes the file to disk at the given path.
395    pub fn save(&self, path: &str) -> Result<(), String> {
396        let bytes = self.write_to_bytes();
397        let mut f =
398            fs::File::create(path).map_err(|e| format!("Cannot create file '{}': {}", path, e))?;
399        f.write_all(&bytes)
400            .map_err(|e| format!("Write error: {}", e))?;
401        Ok(())
402    }
403
404    /// Loads an `OxiFile` from disk.
405    pub fn load(path: &str) -> Result<Self, String> {
406        let bytes = fs::read(path).map_err(|e| format!("Cannot read file '{}': {}", path, e))?;
407        Self::read_from_bytes(&bytes)
408    }
409}
410
411impl Default for OxiFile {
412    fn default() -> Self {
413        Self::new()
414    }
415}
416
417// ---------------------------------------------------------------------------
418// Serialization helpers (public)
419// ---------------------------------------------------------------------------
420
421/// Appends a `u32` in little-endian format.
422pub fn write_u32(buf: &mut Vec<u8>, v: u32) {
423    buf.extend_from_slice(&v.to_le_bytes());
424}
425
426/// Appends a `u64` in little-endian format.
427pub fn write_u64(buf: &mut Vec<u8>, v: u64) {
428    buf.extend_from_slice(&v.to_le_bytes());
429}
430
431/// Appends a length-prefixed UTF-8 string (u32 length + bytes).
432pub fn write_string(buf: &mut Vec<u8>, s: &str) {
433    let bytes = s.as_bytes();
434    write_u32(buf, bytes.len() as u32);
435    buf.extend_from_slice(bytes);
436}
437
438/// Reads a `u32` in little-endian format, advancing `pos`.
439pub fn read_u32(data: &[u8], pos: &mut usize) -> Result<u32, String> {
440    if *pos + 4 > data.len() {
441        return Err(format!("read_u32: unexpected end of data at pos {}", *pos));
442    }
443    let v = u32::from_le_bytes(
444        data[*pos..*pos + 4]
445            .try_into()
446            .expect("slice length must match"),
447    );
448    *pos += 4;
449    Ok(v)
450}
451
452/// Reads a `u64` in little-endian format, advancing `pos`.
453pub fn read_u64(data: &[u8], pos: &mut usize) -> Result<u64, String> {
454    if *pos + 8 > data.len() {
455        return Err(format!("read_u64: unexpected end of data at pos {}", *pos));
456    }
457    let v = u64::from_le_bytes(
458        data[*pos..*pos + 8]
459            .try_into()
460            .expect("slice length must match"),
461    );
462    *pos += 8;
463    Ok(v)
464}
465
466/// Reads a length-prefixed UTF-8 string, advancing `pos`.
467pub fn read_string(data: &[u8], pos: &mut usize) -> Result<String, String> {
468    let len = read_u32(data, pos)? as usize;
469    if *pos + len > data.len() {
470        return Err(format!(
471            "read_string: string body out of bounds at pos {}",
472            *pos
473        ));
474    }
475    let s = std::str::from_utf8(&data[*pos..*pos + len])
476        .map_err(|e| format!("Invalid UTF-8: {}", e))?
477        .to_string();
478    *pos += len;
479    Ok(s)
480}
481
482// ---------------------------------------------------------------------------
483// Internal serialization helpers
484// ---------------------------------------------------------------------------
485
486fn write_i64(buf: &mut Vec<u8>, v: i64) {
487    buf.extend_from_slice(&v.to_le_bytes());
488}
489
490fn read_i64(data: &[u8], pos: &mut usize) -> Result<i64, String> {
491    if *pos + 8 > data.len() {
492        return Err(format!("read_i64: unexpected end of data at pos {}", *pos));
493    }
494    let v = i64::from_le_bytes(
495        data[*pos..*pos + 8]
496            .try_into()
497            .expect("slice length must match"),
498    );
499    *pos += 8;
500    Ok(v)
501}
502
503fn write_f64(buf: &mut Vec<u8>, v: f64) {
504    buf.extend_from_slice(&v.to_le_bytes());
505}
506
507fn read_f64(data: &[u8], pos: &mut usize) -> Result<f64, String> {
508    if *pos + 8 > data.len() {
509        return Err(format!("read_f64: unexpected end of data at pos {}", *pos));
510    }
511    let v = f64::from_le_bytes(
512        data[*pos..*pos + 8]
513            .try_into()
514            .expect("slice length must match"),
515    );
516    *pos += 8;
517    Ok(v)
518}
519
520// ---------------------------------------------------------------------------
521// Attribute serialization
522// ---------------------------------------------------------------------------
523
524fn serialize_attribute(buf: &mut Vec<u8>, attr: &Attribute) {
525    write_string(buf, &attr.name);
526    buf.push(attr.value.tag());
527    match &attr.value {
528        AttributeValue::Int(v) => {
529            write_i64(buf, *v);
530        }
531        AttributeValue::Float(v) => {
532            write_f64(buf, *v);
533        }
534        AttributeValue::Text(s) => {
535            write_string(buf, s);
536        }
537        AttributeValue::IntArray(arr) => {
538            write_u64(buf, arr.len() as u64);
539            for &v in arr {
540                write_i64(buf, v);
541            }
542        }
543        AttributeValue::FloatArray(arr) => {
544            write_u64(buf, arr.len() as u64);
545            for &v in arr {
546                write_f64(buf, v);
547            }
548        }
549    }
550}
551
552fn deserialize_attribute(data: &[u8], pos: &mut usize) -> Result<Attribute, String> {
553    let name = read_string(data, pos)?;
554    if *pos >= data.len() {
555        return Err("deserialize_attribute: missing type tag".to_string());
556    }
557    let tag = data[*pos];
558    *pos += 1;
559    let value = match tag {
560        0 => AttributeValue::Int(read_i64(data, pos)?),
561        1 => AttributeValue::Float(read_f64(data, pos)?),
562        2 => AttributeValue::Text(read_string(data, pos)?),
563        3 => {
564            let n = read_u64(data, pos)? as usize;
565            let mut arr = Vec::with_capacity(n);
566            for _ in 0..n {
567                arr.push(read_i64(data, pos)?);
568            }
569            AttributeValue::IntArray(arr)
570        }
571        4 => {
572            let n = read_u64(data, pos)? as usize;
573            let mut arr = Vec::with_capacity(n);
574            for _ in 0..n {
575                arr.push(read_f64(data, pos)?);
576            }
577            AttributeValue::FloatArray(arr)
578        }
579        _ => return Err(format!("Unknown AttributeValue tag: {}", tag)),
580    };
581    Ok(Attribute { name, value })
582}
583
584// ---------------------------------------------------------------------------
585// Dataset serialization
586// ---------------------------------------------------------------------------
587
588fn serialize_dataset(buf: &mut Vec<u8>, ds: &Dataset) {
589    write_string(buf, &ds.name);
590    buf.push(ds.dtype.tag());
591    // shape
592    write_u32(buf, ds.shape.dims.len() as u32);
593    for &d in &ds.shape.dims {
594        write_u64(buf, d as u64);
595    }
596    // raw data
597    write_u64(buf, ds.data.len() as u64);
598    buf.extend_from_slice(&ds.data);
599    // attributes
600    write_u32(buf, ds.attributes.len() as u32);
601    for attr in &ds.attributes {
602        serialize_attribute(buf, attr);
603    }
604}
605
606fn deserialize_dataset(data: &[u8], pos: &mut usize) -> Result<Dataset, String> {
607    let name = read_string(data, pos)?;
608    if *pos >= data.len() {
609        return Err("deserialize_dataset: missing dtype tag".to_string());
610    }
611    let dtype = DataType::from_tag(data[*pos])?;
612    *pos += 1;
613    let ndims = read_u32(data, pos)? as usize;
614    let mut dims = Vec::with_capacity(ndims);
615    for _ in 0..ndims {
616        dims.push(read_u64(data, pos)? as usize);
617    }
618    let shape = DatasetShape { dims };
619    let data_len = read_u64(data, pos)? as usize;
620    if *pos + data_len > data.len() {
621        return Err(format!(
622            "deserialize_dataset: data body out of bounds at pos {}",
623            *pos
624        ));
625    }
626    let raw = data[*pos..*pos + data_len].to_vec();
627    *pos += data_len;
628    let n_attrs = read_u32(data, pos)? as usize;
629    let mut attributes = Vec::with_capacity(n_attrs);
630    for _ in 0..n_attrs {
631        attributes.push(deserialize_attribute(data, pos)?);
632    }
633    Ok(Dataset {
634        name,
635        dtype,
636        shape,
637        data: raw,
638        attributes,
639    })
640}
641
642// ---------------------------------------------------------------------------
643// Group serialization
644// ---------------------------------------------------------------------------
645
646fn serialize_group(buf: &mut Vec<u8>, group: &Group) {
647    write_string(buf, &group.name);
648    // attributes
649    write_u32(buf, group.attributes.len() as u32);
650    for attr in &group.attributes {
651        serialize_attribute(buf, attr);
652    }
653    // datasets
654    write_u32(buf, group.datasets.len() as u32);
655    for ds in &group.datasets {
656        serialize_dataset(buf, ds);
657    }
658    // subgroups (recursive)
659    write_u32(buf, group.subgroups.len() as u32);
660    for sg in &group.subgroups {
661        serialize_group(buf, sg);
662    }
663}
664
665fn deserialize_group(data: &[u8], pos: &mut usize) -> Result<Group, String> {
666    let name = read_string(data, pos)?;
667    let n_attrs = read_u32(data, pos)? as usize;
668    let mut attributes = Vec::with_capacity(n_attrs);
669    for _ in 0..n_attrs {
670        attributes.push(deserialize_attribute(data, pos)?);
671    }
672    let n_datasets = read_u32(data, pos)? as usize;
673    let mut datasets = Vec::with_capacity(n_datasets);
674    for _ in 0..n_datasets {
675        datasets.push(deserialize_dataset(data, pos)?);
676    }
677    let n_subgroups = read_u32(data, pos)? as usize;
678    let mut subgroups = Vec::with_capacity(n_subgroups);
679    for _ in 0..n_subgroups {
680        subgroups.push(deserialize_group(data, pos)?);
681    }
682    Ok(Group {
683        name,
684        datasets,
685        subgroups,
686        attributes,
687    })
688}
689
690// ---------------------------------------------------------------------------
691// SimulationCheckpoint
692// ---------------------------------------------------------------------------
693
694/// High-level helper for writing physics simulation checkpoint data.
695pub struct SimulationCheckpoint;
696
697impl SimulationCheckpoint {
698    /// Creates a fresh `OxiFile` ready for checkpoint data.
699    #[allow(clippy::new_ret_no_self)]
700    pub fn new() -> OxiFile {
701        OxiFile::new()
702    }
703
704    /// Flattens a slice of 3-vectors and stores them as a `(N, 3)` dataset.
705    pub fn add_positions(file: &mut OxiFile, group: &str, positions: &[[f64; 3]]) {
706        let flat: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
707        let shape = DatasetShape::new(vec![positions.len(), 3]);
708        let ds = Dataset::from_f64_slice("positions", &flat, shape);
709        Self::get_or_create_group(&mut file.root, group).add_dataset(ds);
710    }
711
712    /// Flattens a slice of 3-vectors and stores them as a `(N, 3)` dataset.
713    pub fn add_velocities(file: &mut OxiFile, group: &str, velocities: &[[f64; 3]]) {
714        let flat: Vec<f64> = velocities.iter().flat_map(|v| v.iter().copied()).collect();
715        let shape = DatasetShape::new(vec![velocities.len(), 3]);
716        let ds = Dataset::from_f64_slice("velocities", &flat, shape);
717        Self::get_or_create_group(&mut file.root, group).add_dataset(ds);
718    }
719
720    /// Stores a 1-D scalar field as a `(N,)` dataset.
721    pub fn add_scalar_field(file: &mut OxiFile, group: &str, name: &str, values: &[f64]) {
722        let shape = DatasetShape::new(vec![values.len()]);
723        let ds = Dataset::from_f64_slice(name, values, shape);
724        Self::get_or_create_group(&mut file.root, group).add_dataset(ds);
725    }
726
727    /// Stores timestep metadata as attributes on the root group.
728    pub fn add_timestep_metadata(file: &mut OxiFile, step: u64, time: f64, dt: f64) {
729        file.root
730            .add_attribute(Attribute::new("step", AttributeValue::Int(step as i64)));
731        file.root
732            .add_attribute(Attribute::new("time", AttributeValue::Float(time)));
733        file.root
734            .add_attribute(Attribute::new("dt", AttributeValue::Float(dt)));
735    }
736
737    // Returns a mutable reference to the named direct subgroup, creating it if absent.
738    fn get_or_create_group<'a>(root: &'a mut Group, name: &str) -> &'a mut Group {
739        if let Some(idx) = root.subgroups.iter().position(|g| g.name == name) {
740            return &mut root.subgroups[idx];
741        }
742        root.subgroups.push(Group::new(name));
743        root.subgroups
744            .last_mut()
745            .expect("collection should not be empty")
746    }
747}
748
749impl Default for SimulationCheckpoint {
750    fn default() -> Self {
751        Self
752    }
753}
754
755// ---------------------------------------------------------------------------
756// Endianness handling
757// ---------------------------------------------------------------------------
758
759/// Which byte order a binary stream uses.
760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
761pub enum Endianness {
762    /// Least-significant byte first (x86/arm default).
763    Little,
764    /// Most-significant byte first (network order, big-endian).
765    Big,
766}
767
768impl Endianness {
769    /// Detect the native byte order at runtime.
770    pub fn native() -> Self {
771        if cfg!(target_endian = "little") {
772            Endianness::Little
773        } else {
774            Endianness::Big
775        }
776    }
777
778    /// Convert a `u32` to bytes in this byte order.
779    pub fn u32_to_bytes(self, v: u32) -> [u8; 4] {
780        match self {
781            Endianness::Little => v.to_le_bytes(),
782            Endianness::Big => v.to_be_bytes(),
783        }
784    }
785
786    /// Convert a `u32` from bytes in this byte order.
787    pub fn u32_from_bytes(self, b: [u8; 4]) -> u32 {
788        match self {
789            Endianness::Little => u32::from_le_bytes(b),
790            Endianness::Big => u32::from_be_bytes(b),
791        }
792    }
793
794    /// Convert a `f64` to bytes in this byte order.
795    pub fn f64_to_bytes(self, v: f64) -> [u8; 8] {
796        match self {
797            Endianness::Little => v.to_le_bytes(),
798            Endianness::Big => v.to_be_bytes(),
799        }
800    }
801
802    /// Convert a `f64` from bytes in this byte order.
803    pub fn f64_from_bytes(self, b: [u8; 8]) -> f64 {
804        match self {
805            Endianness::Little => f64::from_le_bytes(b),
806            Endianness::Big => f64::from_be_bytes(b),
807        }
808    }
809
810    /// Swap bytes of a `u32` from this endianness to native order.
811    pub fn u32_to_native(self, v: u32) -> u32 {
812        match self {
813            Endianness::Little => u32::from_le_bytes(v.to_ne_bytes()),
814            Endianness::Big => u32::from_be_bytes(v.to_ne_bytes()),
815        }
816    }
817}
818
819// ---------------------------------------------------------------------------
820// Binary mesh format (simple triangle mesh)
821// ---------------------------------------------------------------------------
822
823/// A minimal binary triangle mesh representation.
824///
825/// Format:
826/// - `u32` number of vertices
827/// - `u32` number of triangles
828/// - `3 * n_verts * f64` flat XYZ vertex positions
829/// - `3 * n_tris * u32` triangle vertex indices
830#[derive(Debug, Clone)]
831pub struct BinaryMesh {
832    /// Vertex positions stored as flat XYZ triples.
833    pub vertices: Vec<[f64; 3]>,
834    /// Triangle indices (0-based).
835    pub triangles: Vec<[u32; 3]>,
836}
837
838impl BinaryMesh {
839    /// Create an empty mesh.
840    pub fn new() -> Self {
841        Self {
842            vertices: Vec::new(),
843            triangles: Vec::new(),
844        }
845    }
846
847    /// Serialize to bytes (little-endian).
848    pub fn to_bytes(&self) -> Vec<u8> {
849        let mut buf = Vec::new();
850        write_u32(&mut buf, self.vertices.len() as u32);
851        write_u32(&mut buf, self.triangles.len() as u32);
852        for v in &self.vertices {
853            for k in 0..3 {
854                buf.extend_from_slice(&v[k].to_le_bytes());
855            }
856        }
857        for t in &self.triangles {
858            for k in 0..3 {
859                write_u32(&mut buf, t[k]);
860            }
861        }
862        buf
863    }
864
865    /// Deserialize from bytes (little-endian).
866    pub fn from_bytes(data: &[u8]) -> Result<Self, String> {
867        let mut pos = 0usize;
868        let n_verts = read_u32(data, &mut pos)? as usize;
869        let n_tris = read_u32(data, &mut pos)? as usize;
870
871        let mut vertices = Vec::with_capacity(n_verts);
872        for _ in 0..n_verts {
873            let mut xyz = [0.0_f64; 3];
874            for k in 0..3 {
875                if pos + 8 > data.len() {
876                    return Err("BinaryMesh: vertex data truncated".to_string());
877                }
878                xyz[k] = f64::from_le_bytes(
879                    data[pos..pos + 8]
880                        .try_into()
881                        .expect("slice length must match"),
882                );
883                pos += 8;
884            }
885            vertices.push(xyz);
886        }
887
888        let mut triangles = Vec::with_capacity(n_tris);
889        for _ in 0..n_tris {
890            let i0 = read_u32(data, &mut pos)?;
891            let i1 = read_u32(data, &mut pos)?;
892            let i2 = read_u32(data, &mut pos)?;
893            triangles.push([i0, i1, i2]);
894        }
895
896        Ok(Self {
897            vertices,
898            triangles,
899        })
900    }
901
902    /// Number of vertices.
903    pub fn n_vertices(&self) -> usize {
904        self.vertices.len()
905    }
906
907    /// Number of triangles.
908    pub fn n_triangles(&self) -> usize {
909        self.triangles.len()
910    }
911}
912
913impl Default for BinaryMesh {
914    fn default() -> Self {
915        Self::new()
916    }
917}
918
919// ---------------------------------------------------------------------------
920// Binary particle data
921// ---------------------------------------------------------------------------
922
923/// Compact binary storage for particle data (positions + optional scalars).
924///
925/// Header: magic `b"OXIPART"` + u32 particle count + u32 field count.
926/// Data: `n_particles * (3 + n_fields)` f64 values in row-major order.
927#[derive(Debug, Clone)]
928pub struct BinaryParticleData {
929    /// Particle positions.
930    pub positions: Vec<[f64; 3]>,
931    /// Optional scalar fields (one Vec per field, each of length `n`).
932    pub scalar_fields: Vec<Vec<f64>>,
933    /// Field names corresponding to `scalar_fields`.
934    pub field_names: Vec<String>,
935}
936
937const PARTICLE_MAGIC: &[u8; 7] = b"OXIPART";
938
939impl BinaryParticleData {
940    /// Create a new empty particle data container.
941    pub fn new() -> Self {
942        Self {
943            positions: Vec::new(),
944            scalar_fields: Vec::new(),
945            field_names: Vec::new(),
946        }
947    }
948
949    /// Add a scalar field by name.  Must have one value per particle.
950    pub fn add_field(&mut self, name: &str, values: Vec<f64>) {
951        assert_eq!(
952            values.len(),
953            self.positions.len(),
954            "Field '{}' length {} != particle count {}",
955            name,
956            values.len(),
957            self.positions.len()
958        );
959        self.field_names.push(name.to_string());
960        self.scalar_fields.push(values);
961    }
962
963    /// Number of particles.
964    pub fn n_particles(&self) -> usize {
965        self.positions.len()
966    }
967
968    /// Number of scalar fields.
969    pub fn n_fields(&self) -> usize {
970        self.scalar_fields.len()
971    }
972
973    /// Serialize to bytes.
974    pub fn to_bytes(&self) -> Vec<u8> {
975        let n = self.positions.len();
976        let nf = self.scalar_fields.len();
977        let mut buf = Vec::new();
978        buf.extend_from_slice(PARTICLE_MAGIC);
979        write_u32(&mut buf, n as u32);
980        write_u32(&mut buf, nf as u32);
981        // Field names
982        for name in &self.field_names {
983            write_string(&mut buf, name);
984        }
985        // Positions
986        for p in &self.positions {
987            for k in 0..3 {
988                buf.extend_from_slice(&p[k].to_le_bytes());
989            }
990        }
991        // Scalar fields
992        for field in &self.scalar_fields {
993            for &v in field {
994                buf.extend_from_slice(&v.to_le_bytes());
995            }
996        }
997        buf
998    }
999
1000    /// Deserialize from bytes.
1001    pub fn from_bytes(data: &[u8]) -> Result<Self, String> {
1002        if data.len() < 7 {
1003            return Err("BinaryParticleData: too short".to_string());
1004        }
1005        if &data[..7] != PARTICLE_MAGIC {
1006            return Err("BinaryParticleData: bad magic".to_string());
1007        }
1008        let mut pos = 7usize;
1009        let n = read_u32(data, &mut pos)? as usize;
1010        let nf = read_u32(data, &mut pos)? as usize;
1011
1012        let mut field_names = Vec::with_capacity(nf);
1013        for _ in 0..nf {
1014            field_names.push(read_string(data, &mut pos)?);
1015        }
1016
1017        let mut positions = Vec::with_capacity(n);
1018        for _ in 0..n {
1019            let mut xyz = [0.0_f64; 3];
1020            for k in 0..3 {
1021                if pos + 8 > data.len() {
1022                    return Err("BinaryParticleData: positions truncated".to_string());
1023                }
1024                xyz[k] = f64::from_le_bytes(
1025                    data[pos..pos + 8]
1026                        .try_into()
1027                        .expect("slice length must match"),
1028                );
1029                pos += 8;
1030            }
1031            positions.push(xyz);
1032        }
1033
1034        let mut scalar_fields = Vec::with_capacity(nf);
1035        for _ in 0..nf {
1036            let mut field = Vec::with_capacity(n);
1037            for _ in 0..n {
1038                if pos + 8 > data.len() {
1039                    return Err("BinaryParticleData: scalar field truncated".to_string());
1040                }
1041                let v = f64::from_le_bytes(
1042                    data[pos..pos + 8]
1043                        .try_into()
1044                        .expect("slice length must match"),
1045                );
1046                pos += 8;
1047                field.push(v);
1048            }
1049            scalar_fields.push(field);
1050        }
1051
1052        Ok(Self {
1053            positions,
1054            scalar_fields,
1055            field_names,
1056        })
1057    }
1058}
1059
1060impl Default for BinaryParticleData {
1061    fn default() -> Self {
1062        Self::new()
1063    }
1064}
1065
1066// ---------------------------------------------------------------------------
1067// Compressed binary output (run-length encoding for f64 streams)
1068// ---------------------------------------------------------------------------
1069
1070/// Very simple run-length compression for f64 arrays.
1071///
1072/// Consecutive equal values are stored as `(value, count)` pairs.
1073/// This is mainly useful for fields with large constant regions.
1074///
1075/// Format: `u32 n_runs || \[f64 value, u32 count\] × n_runs`
1076#[allow(dead_code)]
1077pub fn rle_compress_f64(values: &[f64]) -> Vec<u8> {
1078    if values.is_empty() {
1079        let mut buf = Vec::new();
1080        write_u32(&mut buf, 0);
1081        return buf;
1082    }
1083
1084    let mut runs: Vec<(f64, u32)> = Vec::new();
1085    let mut cur = values[0];
1086    let mut cnt = 1u32;
1087    for &v in &values[1..] {
1088        if v.to_bits() == cur.to_bits() {
1089            cnt += 1;
1090        } else {
1091            runs.push((cur, cnt));
1092            cur = v;
1093            cnt = 1;
1094        }
1095    }
1096    runs.push((cur, cnt));
1097
1098    let mut buf = Vec::new();
1099    write_u32(&mut buf, runs.len() as u32);
1100    for (val, count) in runs {
1101        buf.extend_from_slice(&val.to_le_bytes());
1102        write_u32(&mut buf, count);
1103    }
1104    buf
1105}
1106
1107/// Decompress a run-length encoded f64 array.
1108#[allow(dead_code)]
1109pub fn rle_decompress_f64(data: &[u8]) -> Result<Vec<f64>, String> {
1110    let mut pos = 0usize;
1111    let n_runs = read_u32(data, &mut pos)? as usize;
1112    let mut result = Vec::new();
1113    for _ in 0..n_runs {
1114        if pos + 12 > data.len() {
1115            return Err("rle_decompress_f64: truncated".to_string());
1116        }
1117        let val = f64::from_le_bytes(
1118            data[pos..pos + 8]
1119                .try_into()
1120                .expect("slice length must match"),
1121        );
1122        pos += 8;
1123        let count = read_u32(data, &mut pos)? as usize;
1124        for _ in 0..count {
1125            result.push(val);
1126        }
1127    }
1128    Ok(result)
1129}
1130
1131// ---------------------------------------------------------------------------
1132// Binary format versioning
1133// ---------------------------------------------------------------------------
1134
1135/// Supported OxiFile format versions.
1136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1137pub enum FormatVersion {
1138    /// Version 1: initial release.
1139    V1 = 1,
1140    /// Version 2: added compression flags.
1141    V2 = 2,
1142}
1143
1144impl FormatVersion {
1145    /// Parse from a `u32` tag.
1146    pub fn from_u32(v: u32) -> Result<Self, String> {
1147        match v {
1148            1 => Ok(FormatVersion::V1),
1149            2 => Ok(FormatVersion::V2),
1150            _ => Err(format!("Unknown format version: {v}")),
1151        }
1152    }
1153
1154    /// Convert to a `u32` tag.
1155    pub fn to_u32(self) -> u32 {
1156        self as u32
1157    }
1158
1159    /// Whether this version supports compression metadata.
1160    pub fn supports_compression(self) -> bool {
1161        matches!(self, FormatVersion::V2)
1162    }
1163}
1164
1165// ---------------------------------------------------------------------------
1166// Extended OxiFile helpers
1167// ---------------------------------------------------------------------------
1168
1169impl OxiFile {
1170    /// Create an OxiFile at a specific format version.
1171    pub fn with_version(version: FormatVersion) -> Self {
1172        Self {
1173            version: version.to_u32(),
1174            root: Group::new("/"),
1175        }
1176    }
1177
1178    /// Return the parsed `FormatVersion` if recognised, else an error.
1179    pub fn format_version(&self) -> Result<FormatVersion, String> {
1180        FormatVersion::from_u32(self.version)
1181    }
1182
1183    /// Store a `BinaryMesh` under the given group name.
1184    pub fn add_binary_mesh(&mut self, group: &str, mesh: &BinaryMesh) {
1185        let bytes = mesh.to_bytes();
1186        let shape = DatasetShape::new(vec![bytes.len()]);
1187        let mut ds = Dataset {
1188            name: "mesh_binary".to_string(),
1189            dtype: DataType::UInt8,
1190            shape,
1191            data: bytes,
1192            attributes: Vec::new(),
1193        };
1194        ds.add_attribute(Attribute::new(
1195            "n_vertices",
1196            AttributeValue::Int(mesh.n_vertices() as i64),
1197        ));
1198        ds.add_attribute(Attribute::new(
1199            "n_triangles",
1200            AttributeValue::Int(mesh.n_triangles() as i64),
1201        ));
1202        SimulationCheckpoint::get_or_create_group(&mut self.root, group).add_dataset(ds);
1203    }
1204
1205    /// Retrieve and decode a `BinaryMesh` from the given group.
1206    pub fn get_binary_mesh(&self, group: &str) -> Result<BinaryMesh, String> {
1207        let grp = self
1208            .root
1209            .get_subgroup(group)
1210            .ok_or_else(|| format!("Group '{}' not found", group))?;
1211        let ds = grp
1212            .get_dataset("mesh_binary")
1213            .ok_or_else(|| "Dataset 'mesh_binary' not found".to_string())?;
1214        BinaryMesh::from_bytes(&ds.data)
1215    }
1216}
1217
1218// ---------------------------------------------------------------------------
1219// Tests
1220// ---------------------------------------------------------------------------
1221
1222#[cfg(test)]
1223mod tests {
1224    use super::*;
1225
1226    #[test]
1227    fn test_dataset_f64_round_trip() {
1228        let original = vec![1.0_f64, 2.5, -3.125, 0.0, 1e10];
1229        let shape = DatasetShape::new(vec![original.len()]);
1230        let ds = Dataset::from_f64_slice("test", &original, shape);
1231        let recovered = ds.to_f64_vec().expect("to_f64_vec failed");
1232        assert_eq!(original, recovered);
1233    }
1234
1235    #[test]
1236    fn test_dataset_f32_round_trip() {
1237        let original = vec![1.0_f32, 2.5, -3.125, 0.0];
1238        let shape = DatasetShape::new(vec![original.len()]);
1239        let ds = Dataset::from_f32_slice("f32ds", &original, shape);
1240        let recovered = ds.to_f32_vec().expect("to_f32_vec failed");
1241        assert_eq!(original, recovered);
1242    }
1243
1244    #[test]
1245    fn test_dataset_i32_round_trip() {
1246        let original = vec![0_i32, -1, 42, i32::MAX, i32::MIN];
1247        let shape = DatasetShape::new(vec![original.len()]);
1248        let ds = Dataset::from_i32_slice("i32ds", &original, shape);
1249        let recovered = ds.to_i32_vec().expect("to_i32_vec failed");
1250        assert_eq!(original, recovered);
1251    }
1252
1253    #[test]
1254    fn test_group_add_get_dataset() {
1255        let mut g = Group::new("particles");
1256        let ds = Dataset::from_f64_slice("energy", &[1.0, 2.0, 3.0], DatasetShape::new(vec![3]));
1257        g.add_dataset(ds);
1258        let found = g.get_dataset("energy").expect("dataset not found");
1259        assert_eq!(found.name, "energy");
1260        assert!(g.get_dataset("missing").is_none());
1261    }
1262
1263    #[test]
1264    fn test_oxifile_round_trip() {
1265        let mut file = OxiFile::new();
1266        let ds = Dataset::from_f64_slice("x", &[1.0, 2.0, 3.0], DatasetShape::new(vec![3]));
1267        file.root.add_dataset(ds);
1268
1269        let bytes = file.write_to_bytes();
1270        let loaded = OxiFile::read_from_bytes(&bytes).expect("round-trip failed");
1271        assert_eq!(loaded.version, 1);
1272        let ds2 = loaded
1273            .root
1274            .get_dataset("x")
1275            .expect("dataset missing after round-trip");
1276        let vals = ds2.to_f64_vec().unwrap();
1277        assert_eq!(vals, vec![1.0, 2.0, 3.0]);
1278    }
1279
1280    #[test]
1281    fn test_read_from_bytes_invalid_magic() {
1282        let bad: Vec<u8> = b"BADMAGIC\x01\x00\x00\x00".to_vec();
1283        let result = OxiFile::read_from_bytes(&bad);
1284        assert!(result.is_err());
1285        assert!(result.unwrap_err().contains("Invalid magic bytes"));
1286    }
1287
1288    #[test]
1289    fn test_dataset_shape_total_elements() {
1290        let s = DatasetShape::new(vec![3, 4, 5]);
1291        assert_eq!(s.total_elements(), 60);
1292        assert_eq!(s.rank(), 3);
1293        assert!(!s.is_scalar());
1294
1295        let scalar = DatasetShape::new(vec![]);
1296        assert_eq!(scalar.total_elements(), 1);
1297        assert!(scalar.is_scalar());
1298        assert_eq!(scalar.rank(), 0);
1299    }
1300
1301    #[test]
1302    fn test_attribute_get_set() {
1303        let mut ds = Dataset::from_f64_slice("d", &[1.0], DatasetShape::new(vec![1]));
1304        ds.add_attribute(Attribute::new(
1305            "units",
1306            AttributeValue::Text("meters".to_string()),
1307        ));
1308        ds.add_attribute(Attribute::new("count", AttributeValue::Int(42)));
1309
1310        let attr = ds.get_attribute("units").expect("units not found");
1311        assert_eq!(attr.value, AttributeValue::Text("meters".to_string()));
1312        assert!(ds.get_attribute("nope").is_none());
1313    }
1314
1315    #[test]
1316    fn test_simulation_checkpoint_positions() {
1317        let mut file = SimulationCheckpoint::new();
1318        let positions = [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
1319        SimulationCheckpoint::add_positions(&mut file, "frame0", &positions);
1320
1321        let grp = file.root.get_subgroup("frame0").expect("group missing");
1322        let ds = grp.get_dataset("positions").expect("positions missing");
1323        let vals = ds.to_f64_vec().expect("to_f64_vec failed");
1324
1325        let expected: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1326        assert_eq!(vals, expected);
1327        assert_eq!(ds.shape.dims, vec![3, 3]);
1328    }
1329
1330    #[test]
1331    fn test_simulation_checkpoint_round_trip() {
1332        let mut file = SimulationCheckpoint::new();
1333        let positions = [[0.1, 0.2, 0.3], [-1.0, 2.0, -3.0]];
1334        SimulationCheckpoint::add_positions(&mut file, "step1", &positions);
1335        SimulationCheckpoint::add_timestep_metadata(&mut file, 1, 0.01, 0.001);
1336
1337        let bytes = file.write_to_bytes();
1338        let loaded = OxiFile::read_from_bytes(&bytes).expect("round-trip failed");
1339
1340        let step_attr = loaded.root.get_attribute("step").expect("step missing");
1341        assert_eq!(step_attr.value, AttributeValue::Int(1));
1342
1343        let grp = loaded.root.get_subgroup("step1").expect("subgroup missing");
1344        let ds = grp.get_dataset("positions").expect("dataset missing");
1345        let vals = ds.to_f64_vec().unwrap();
1346        let expected: Vec<f64> = positions.iter().flat_map(|p| p.iter().copied()).collect();
1347        assert_eq!(vals, expected);
1348    }
1349
1350    // -----------------------------------------------------------------------
1351    // Endianness tests
1352    // -----------------------------------------------------------------------
1353
1354    #[test]
1355    fn test_endianness_u32_round_trip() {
1356        let v: u32 = 0xDEAD_BEEF;
1357        for end in [Endianness::Little, Endianness::Big] {
1358            let bytes = end.u32_to_bytes(v);
1359            let back = end.u32_from_bytes(bytes);
1360            assert_eq!(back, v, "Endianness {:?} u32 round-trip failed", end);
1361        }
1362    }
1363
1364    #[test]
1365    fn test_endianness_f64_round_trip() {
1366        let v = std::f64::consts::PI;
1367        for end in [Endianness::Little, Endianness::Big] {
1368            let bytes = end.f64_to_bytes(v);
1369            let back = end.f64_from_bytes(bytes);
1370            assert!(
1371                (back - v).abs() < 1e-15,
1372                "Endianness {:?} f64 round-trip failed",
1373                end
1374            );
1375        }
1376    }
1377
1378    #[test]
1379    fn test_endianness_native() {
1380        let native = Endianness::native();
1381        assert!(native == Endianness::Little || native == Endianness::Big);
1382    }
1383
1384    // -----------------------------------------------------------------------
1385    // BinaryMesh tests
1386    // -----------------------------------------------------------------------
1387
1388    #[test]
1389    fn test_binary_mesh_round_trip() {
1390        let mut mesh = BinaryMesh::new();
1391        mesh.vertices = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
1392        mesh.triangles = vec![[0, 1, 2]];
1393        let bytes = mesh.to_bytes();
1394        let mesh2 = BinaryMesh::from_bytes(&bytes).expect("round-trip failed");
1395        assert_eq!(mesh2.n_vertices(), 3);
1396        assert_eq!(mesh2.n_triangles(), 1);
1397        assert!((mesh2.vertices[1][0] - 1.0).abs() < 1e-15);
1398        assert_eq!(mesh2.triangles[0], [0, 1, 2]);
1399    }
1400
1401    #[test]
1402    fn test_binary_mesh_empty() {
1403        let mesh = BinaryMesh::new();
1404        let bytes = mesh.to_bytes();
1405        let mesh2 = BinaryMesh::from_bytes(&bytes).expect("empty mesh round-trip failed");
1406        assert_eq!(mesh2.n_vertices(), 0);
1407        assert_eq!(mesh2.n_triangles(), 0);
1408    }
1409
1410    // -----------------------------------------------------------------------
1411    // BinaryParticleData tests
1412    // -----------------------------------------------------------------------
1413
1414    #[test]
1415    fn test_binary_particle_data_round_trip() {
1416        let mut pd = BinaryParticleData::new();
1417        pd.positions = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
1418        pd.add_field("density", vec![1000.0, 1200.0]);
1419        pd.add_field("pressure", vec![101325.0, 202650.0]);
1420
1421        let bytes = pd.to_bytes();
1422        let pd2 = BinaryParticleData::from_bytes(&bytes).expect("round-trip failed");
1423        assert_eq!(pd2.n_particles(), 2);
1424        assert_eq!(pd2.n_fields(), 2);
1425        assert_eq!(pd2.field_names[0], "density");
1426        assert!((pd2.scalar_fields[0][1] - 1200.0).abs() < 1e-12);
1427        assert!((pd2.positions[1][2] - 6.0).abs() < 1e-15);
1428    }
1429
1430    #[test]
1431    fn test_binary_particle_data_bad_magic() {
1432        let bad: Vec<u8> = b"BADMAGIC".to_vec();
1433        assert!(BinaryParticleData::from_bytes(&bad).is_err());
1434    }
1435
1436    // -----------------------------------------------------------------------
1437    // RLE compression tests
1438    // -----------------------------------------------------------------------
1439
1440    #[test]
1441    fn test_rle_compress_decompress_round_trip() {
1442        let original = vec![1.0, 1.0, 1.0, 2.5, 2.5, 3.0, 1.0];
1443        let compressed = rle_compress_f64(&original);
1444        let decompressed = rle_decompress_f64(&compressed).expect("decompression failed");
1445        assert_eq!(original.len(), decompressed.len());
1446        for (a, b) in original.iter().zip(decompressed.iter()) {
1447            assert!((a - b).abs() < 1e-15);
1448        }
1449    }
1450
1451    #[test]
1452    fn test_rle_compress_empty() {
1453        let compressed = rle_compress_f64(&[]);
1454        let decompressed = rle_decompress_f64(&compressed).expect("empty decompression failed");
1455        assert!(decompressed.is_empty());
1456    }
1457
1458    #[test]
1459    fn test_rle_compresses_constant_field() {
1460        let original = vec![3.125; 1000];
1461        let compressed = rle_compress_f64(&original);
1462        // Should be much smaller than the raw 8000 bytes.
1463        assert!(
1464            compressed.len() < 100,
1465            "RLE should compress constant field significantly"
1466        );
1467        let decompressed = rle_decompress_f64(&compressed).unwrap();
1468        assert_eq!(decompressed.len(), 1000);
1469    }
1470
1471    // -----------------------------------------------------------------------
1472    // FormatVersion tests
1473    // -----------------------------------------------------------------------
1474
1475    #[test]
1476    fn test_format_version_round_trip() {
1477        assert_eq!(FormatVersion::from_u32(1).unwrap(), FormatVersion::V1);
1478        assert_eq!(FormatVersion::from_u32(2).unwrap(), FormatVersion::V2);
1479        assert!(FormatVersion::from_u32(99).is_err());
1480    }
1481
1482    #[test]
1483    fn test_format_version_supports_compression() {
1484        assert!(!FormatVersion::V1.supports_compression());
1485        assert!(FormatVersion::V2.supports_compression());
1486    }
1487
1488    // -----------------------------------------------------------------------
1489    // OxiFile + BinaryMesh integration test
1490    // -----------------------------------------------------------------------
1491
1492    #[test]
1493    fn test_oxifile_binary_mesh_store_retrieve() {
1494        let mut file = OxiFile::new();
1495        let mut mesh = BinaryMesh::new();
1496        mesh.vertices = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
1497        mesh.triangles = vec![[0, 1, 2]];
1498        file.add_binary_mesh("geometry", &mesh);
1499
1500        let mesh2 = file
1501            .get_binary_mesh("geometry")
1502            .expect("retrieve mesh failed");
1503        assert_eq!(mesh2.n_vertices(), 3);
1504        assert_eq!(mesh2.n_triangles(), 1);
1505    }
1506
1507    #[test]
1508    fn test_oxifile_with_version() {
1509        let file = OxiFile::with_version(FormatVersion::V2);
1510        assert_eq!(file.version, 2);
1511        assert_eq!(file.format_version().unwrap(), FormatVersion::V2);
1512    }
1513
1514    #[test]
1515    fn test_datatype_size_bytes() {
1516        assert_eq!(DataType::Float32.size_bytes(), 4);
1517        assert_eq!(DataType::Float64.size_bytes(), 8);
1518        assert_eq!(DataType::Int32.size_bytes(), 4);
1519        assert_eq!(DataType::Int64.size_bytes(), 8);
1520        assert_eq!(DataType::UInt8.size_bytes(), 1);
1521        assert_eq!(DataType::UInt32.size_bytes(), 4);
1522    }
1523
1524    #[test]
1525    fn test_dataset_wrong_type_returns_err() {
1526        let ds = Dataset::from_f64_slice("d", &[1.0, 2.0], DatasetShape::new(vec![2]));
1527        assert!(ds.to_f32_vec().is_err());
1528        assert!(ds.to_i32_vec().is_err());
1529    }
1530}