Skip to main content

oxiphysics_io/
binary_format.rs

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