Skip to main content

readcon_core/
writer.rs

1use crate::types::{
2    ConFrame, SECTION_CHARGES, SECTION_DISPLACEMENTS, SECTION_ENERGIES, SECTION_FORCES,
3    SECTION_MAGMOMS, SECTION_SPINS, SECTION_SPREADS, SECTION_VELOCITIES, encode_fixed_bitmask,
4    meta,
5};
6use serde_json::json;
7use std::fs::File;
8use std::io::{self, BufWriter, Write};
9use std::path::Path;
10
11/// Failure from [`ConFrameWriter`]. I/O stays distinct from format errors
12/// so `InvalidUnits` and `InvalidCount` remain distinguishable (CON-error.1).
13#[derive(Debug)]
14pub enum WriteError {
15    Io(io::Error),
16    Format(crate::error::ParseError),
17}
18
19impl std::fmt::Display for WriteError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            WriteError::Io(e) => write!(f, "{e}"),
23            WriteError::Format(e) => write!(f, "{e}"),
24        }
25    }
26}
27
28impl std::error::Error for WriteError {
29    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
30        match self {
31            WriteError::Io(e) => Some(e),
32            WriteError::Format(e) => Some(e),
33        }
34    }
35}
36
37impl From<io::Error> for WriteError {
38    fn from(e: io::Error) -> Self {
39        WriteError::Io(e)
40    }
41}
42
43impl From<WriteError> for io::Error {
44    fn from(e: WriteError) -> Self {
45        match e {
46            WriteError::Io(e) => e,
47            WriteError::Format(err) => io::Error::new(io::ErrorKind::InvalidData, err.to_string()),
48        }
49    }
50}
51
52/// Numeric text representation for CON frame fields.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum FloatFormat {
55    /// A fixed number of digits after the decimal point.
56    DecimalPlaces(usize),
57    /// The shortest scientific representation preserving each finite f64.
58    /// Signed zero is preserved; decimal precision does not limit this mode.
59    RoundTrip,
60}
61
62impl Default for FloatFormat {
63    fn default() -> Self {
64        // CON-num.6 (D13): the default writer round-trips binary64.
65        Self::RoundTrip
66    }
67}
68
69/// A writer that can serialize and write `ConFrame` objects to any output stream.
70///
71/// This struct encapsulates a writer (like a file) and provides a high-level API
72/// for writing simulation frames in the `.con` format.
73///
74/// # Example
75/// ```no_run
76/// # use std::fs::File;
77/// # use readcon_core::types::ConFrame;
78/// # use readcon_core::writer::ConFrameWriter;
79/// # let frames: Vec<ConFrame> = Vec::new();
80/// let mut writer = ConFrameWriter::from_path("output.con").unwrap();
81/// writer.extend(frames.iter()).unwrap();
82/// ```
83pub struct ConFrameWriter<W: Write> {
84    writer: BufWriter<W>,
85    float_format: FloatFormat,
86    /// When true: sort metadata keys in JSON and emit sections in canonical
87    /// order (velocities, forces, energies). Opt-in. Float text follows
88    /// `float_format` (round-trip unless the caller asked for decimals).
89    canonical: bool,
90    /// Cache for the JSON metadata line: when consecutive frames share
91    /// the same (spec_version, sections-set, metadata) triple the
92    /// serialized JSON object is identical, so reusing the cached
93    /// string skips the per-frame `serde_json::Map::insert` rebuild
94    /// and re-serialisation. Hot for trajectory writes where every
95    /// frame has the same `units` / `potential` / `validate` keys.
96    metadata_cache: Option<MetadataCacheEntry>,
97    /// One frame of CON text. Filled then flushed with a single `write_all`
98    /// so the sink is not entered once per atom line.
99    scratch: Vec<u8>,
100    /// Reused shortest-round-trip buffer (ryu). One per writer, not per float.
101    ryu: ryu::Buffer,
102}
103
104#[derive(Debug)]
105struct MetadataCacheEntry {
106    /// Snapshot of the inputs that fully determine the serialized JSON
107    /// metadata line. Cheap to clone; cheaper than re-serialising the
108    /// whole map on every frame.
109    spec_version: u32,
110    has_velocities: bool,
111    has_forces: bool,
112    has_energies: bool,
113    has_charges: bool,
114    has_spins: bool,
115    has_magmoms: bool,
116    has_displacements: bool,
117    has_spreads: bool,
118    metadata: std::collections::BTreeMap<String, serde_json::Value>,
119    /// Cached serialised metadata line (without trailing newline).
120    serialized: String,
121}
122
123impl MetadataCacheEntry {
124    fn matches(
125        &self,
126        spec_version: u32,
127        has_velocities: bool,
128        has_forces: bool,
129        has_energies: bool,
130        has_charges: bool,
131        has_spins: bool,
132        has_magmoms: bool,
133        has_displacements: bool,
134        has_spreads: bool,
135        metadata: &std::collections::BTreeMap<String, serde_json::Value>,
136    ) -> bool {
137        self.spec_version == spec_version
138            && self.has_velocities == has_velocities
139            && self.has_forces == has_forces
140            && self.has_energies == has_energies
141            && self.has_charges == has_charges
142            && self.has_spins == has_spins
143            && self.has_magmoms == has_magmoms
144            && self.has_displacements == has_displacements
145            && self.has_spreads == has_spreads
146            && self.metadata.len() == metadata.len()
147            && self
148                .metadata
149                .iter()
150                .zip(metadata)
151                .all(|((ka, a), (kb, b))| ka == kb && metadata_value_eq(a, b))
152    }
153}
154
155// Cache equality preserves the numeric representation, including signed zero,
156// throughout nested JSON values.
157fn metadata_value_eq(a: &serde_json::Value, b: &serde_json::Value) -> bool {
158    use serde_json::Value;
159    match (a, b) {
160        (Value::Number(a), Value::Number(b)) if a.is_f64() && b.is_f64() => {
161            a.as_f64().map(f64::to_bits) == b.as_f64().map(f64::to_bits)
162        }
163        (Value::Array(a), Value::Array(b)) => {
164            a.len() == b.len() && a.iter().zip(b).all(|(a, b)| metadata_value_eq(a, b))
165        }
166        (Value::Object(a), Value::Object(b)) => {
167            a.len() == b.len()
168                && a.iter()
169                    .all(|(key, a)| b.get(key).is_some_and(|b| metadata_value_eq(a, b)))
170        }
171        _ => a == b,
172    }
173}
174
175/// CON-section.16. Minor 2 and later must list, in `section_arity`, every
176/// emitted section that minor 1 does not reserve.
177fn require_minor2_section_arity(
178    minor: u64,
179    sections: &[serde_json::Value],
180    metadata: &serde_json::Map<String, serde_json::Value>,
181) -> Result<(), WriteError> {
182    if minor < 2 {
183        return Ok(());
184    }
185    for name in sections.iter().filter_map(|value| value.as_str()) {
186        if crate::types::reserved_section_value_arity(name).is_some() {
187            continue;
188        }
189        let listed = metadata
190            .get(meta::SECTION_ARITY)
191            .and_then(|value| value.as_object())
192            .and_then(|obj| obj.get(name))
193            .and_then(|value| value.as_u64())
194            .is_some_and(|n| n >= 1);
195        if !listed {
196            return Err(WriteError::Format(
197                crate::error::ParseError::ValidationError(format!(
198                    "minor >= 2 must list {name} in section_arity"
199                )),
200            ));
201        }
202    }
203    Ok(())
204}
205
206// General implementation for any type that implements `Write`.
207impl<W: Write> ConFrameWriter<W> {
208    /// Creates a new `ConFrameWriter` that wraps a given writer.
209    ///
210    /// # Arguments
211    ///
212    /// * `writer` - Any type that implements `std::io::Write`, e.g., a `File`.
213    pub fn new(writer: W) -> Self {
214        Self::with_float_format(writer, FloatFormat::default())
215    }
216
217    /// Creates a new `ConFrameWriter` with a custom floating-point precision.
218    ///
219    /// # Arguments
220    ///
221    /// * `writer` - Any type that implements `std::io::Write`.
222    /// * `precision` - Number of decimal places for floating-point output.
223    pub fn with_precision(writer: W, precision: usize) -> Self {
224        Self::with_float_format(writer, FloatFormat::DecimalPlaces(precision))
225    }
226
227    /// Creates a writer with an explicit numeric text representation.
228    ///
229    /// `FloatFormat::RoundTrip` retains small forces and coordinates exactly.
230    /// Metadata floats use the JSON serializer independently of this setting.
231    pub fn with_float_format(writer: W, float_format: FloatFormat) -> Self {
232        Self {
233            writer: BufWriter::new(writer),
234            float_format,
235            canonical: false,
236            metadata_cache: None,
237            scratch: Vec::with_capacity(16 * 1024),
238            ryu: ryu::Buffer::new(),
239        }
240    }
241
242    /// Opt-in **canonical** serialization: BTree-ordered metadata keys in JSON,
243    /// fixed section order. Use for corpus
244    /// materialization and content-stable hashes; not required for on-disk fidelity
245    /// of spans preserved from `next_with_raw_span`.
246    pub fn canonical(mut self, on: bool) -> Self {
247        self.set_canonical(on);
248        self
249    }
250
251    /// Set or clear canonical mode on an existing writer (C ABI / FFI).
252    pub fn set_canonical(&mut self, on: bool) {
253        self.canonical = on;
254        if on {
255            self.metadata_cache = None;
256        }
257    }
258
259    /// Whether canonical serialization is enabled.
260    pub fn is_canonical(&self) -> bool {
261        self.canonical
262    }
263
264    fn refresh_metadata_cache(&mut self, frame: &ConFrame) -> Result<(), WriteError> {
265        let spec_version = frame.header.spec_version;
266        let has_vel = frame.has_velocities();
267        let has_frc = frame.has_forces();
268        let has_eng = frame.has_energies();
269        let has_chg = frame.has_charges();
270        let has_spn = frame.has_spins();
271        let has_mm = frame.has_magmoms();
272        let has_dsp = frame.has_displacements();
273        let has_spr = frame.has_spreads();
274
275        let cache_hit = !self.canonical
276            && self.metadata_cache.as_ref().is_some_and(|c| {
277                c.matches(
278                    spec_version,
279                    has_vel,
280                    has_frc,
281                    has_eng,
282                    has_chg,
283                    has_spn,
284                    has_mm,
285                    has_dsp,
286                    has_spr,
287                    &frame.header.metadata,
288                )
289            });
290        if cache_hit {
291            return Ok(());
292        }
293
294        let mut meta_obj = serde_json::Map::new();
295        meta_obj.insert(meta::CON_SPEC_VERSION.into(), json!(spec_version));
296        let mut sections = Vec::new();
297        if has_vel {
298            sections.push(json!(SECTION_VELOCITIES));
299        }
300        if has_frc {
301            sections.push(json!(SECTION_FORCES));
302        }
303        if has_eng {
304            sections.push(json!(SECTION_ENERGIES));
305        }
306        if has_chg {
307            sections.push(json!(SECTION_CHARGES));
308        }
309        if has_spn {
310            sections.push(json!(SECTION_SPINS));
311        }
312        if has_mm {
313            sections.push(json!(SECTION_MAGMOMS));
314        }
315        if has_dsp {
316            sections.push(json!(SECTION_DISPLACEMENTS));
317        }
318        if has_spr {
319            sections.push(json!(SECTION_SPREADS));
320        }
321        let validate = frame
322            .header
323            .metadata
324            .get(meta::VALIDATE)
325            .and_then(|value| value.as_bool())
326            .unwrap_or(false);
327        if !sections.is_empty() || validate {
328            meta_obj.insert(meta::SECTIONS.into(), json!(sections));
329        }
330        for (k, v) in &frame.header.metadata {
331            if k == meta::CON_SPEC_VERSION || k == meta::SECTIONS {
332                continue;
333            }
334            meta_obj.insert(k.clone(), v.clone());
335        }
336        if let Some(u) = meta_obj.get(meta::UNITS).cloned() {
337            // D11: an invalid unit fails the write. Do not substitute a default.
338            let c = crate::units::canonicalize_units_object(&u).map_err(|e| {
339                WriteError::Format(crate::error::ParseError::InvalidUnits(e.to_string()))
340            })?;
341            meta_obj.insert(meta::UNITS.into(), c);
342        }
343        if spec_version >= 3 {
344            match meta_obj.get(meta::UNITS) {
345                None => {
346                    return Err(WriteError::Format(crate::error::ParseError::InvalidUnits(
347                        "con_spec_version >= 3 requires units.length and units.energy".into(),
348                    )));
349                }
350                Some(u) => {
351                    crate::units::validate_v3_units_metadata(u).map_err(|e| {
352                        WriteError::Format(crate::error::ParseError::InvalidUnits(e.to_string()))
353                    })?;
354                }
355            }
356        }
357        let minor = frame
358            .header
359            .metadata
360            .get(meta::CON_SPEC_MINOR)
361            .and_then(|v| v.as_u64())
362            .unwrap_or(0);
363        if spec_version >= 3 && minor >= 1 && has_vel {
364            let time_ok = meta_obj
365                .get(meta::UNITS)
366                .and_then(|u| u.get("time"))
367                .and_then(|v| v.as_str())
368                .is_some_and(|s| !s.trim().is_empty());
369            if !time_ok {
370                return Err(WriteError::Format(crate::error::ParseError::InvalidUnits(
371                    "minor >= 1 requires units.time when a velocities section is present".into(),
372                )));
373            }
374        }
375        // A minor of 2 or later lists every section minor 1 does not reserve
376        // (CON-section.16). Reserved names need no entry.
377        require_minor2_section_arity(minor, &sections, &meta_obj)?;
378        let serialized = serde_json::Value::Object(meta_obj).to_string();
379        self.metadata_cache = Some(MetadataCacheEntry {
380            spec_version,
381            has_velocities: has_vel,
382            has_forces: has_frc,
383            has_energies: has_eng,
384            has_charges: has_chg,
385            has_spins: has_spn,
386            has_magmoms: has_mm,
387            has_displacements: has_dsp,
388            has_spreads: has_spr,
389            metadata: frame.header.metadata.clone(),
390            serialized,
391        });
392        Ok(())
393    }
394
395    /// Writes a single `ConFrame` to the output stream.
396    pub fn write_frame(&mut self, frame: &ConFrame) -> Result<(), WriteError> {
397        let zero_frame = frame.header.natm_types == 0
398            && frame.atom_data.is_empty()
399            && frame.header.natms_per_type.is_empty()
400            && frame.header.masses_per_type.is_empty();
401        if zero_frame && !frame.header.sections.is_empty() {
402            return Err(WriteError::Format(crate::error::ParseError::InvalidCount(
403                "a zero-atom frame cannot declare sections".into(),
404            )));
405        }
406        if !zero_frame
407            && (frame.header.natm_types == 0
408                || frame.atom_data.is_empty()
409                || frame.header.natms_per_type.iter().any(|n| *n == 0))
410        {
411            return Err(WriteError::Format(crate::error::ParseError::InvalidCount(
412                "each per-type count must be >= 1".into(),
413            )));
414        }
415        ensure_unique_ids(&frame.atom_data)?;
416        let prec = self.float_format;
417        let meta_line = if frame.header.spec_version < 2 {
418            // Spec 1 line 2 is free text. Do not re-emit JSON major 0 or 1 (D15).
419            frame.header.prebox_header.metadata_line().to_string()
420        } else {
421            self.refresh_metadata_cache(frame)?;
422            self.metadata_cache
423                .as_ref()
424                .expect("metadata_cache populated above")
425                .serialized
426                .clone()
427        };
428        let (boxl, angles) = match frame.header.lattice_vectors() {
429            Some(vecs) => crate::types::lengths_and_angles(vecs),
430            None => (frame.header.boxl, frame.header.angles),
431        };
432        self.scratch.clear();
433        {
434            let buf = &mut self.scratch;
435            let ryu = &mut self.ryu;
436
437            // --- Write the 9-line Header ---
438            let _ = writeln!(buf, "{}", frame.header.prebox_header.user);
439            let _ = writeln!(buf, "{meta_line}");
440            push_f64(buf, ryu, boxl[0], prec);
441            buf.push(b' ');
442            push_f64(buf, ryu, boxl[1], prec);
443            buf.push(b' ');
444            push_f64(buf, ryu, boxl[2], prec);
445            buf.push(b'\n');
446            push_f64(buf, ryu, angles[0], prec);
447            buf.push(b' ');
448            push_f64(buf, ryu, angles[1], prec);
449            buf.push(b' ');
450            push_f64(buf, ryu, angles[2], prec);
451            buf.push(b'\n');
452            let _ = writeln!(buf, "{}", frame.header.postbox_header[0]);
453            let _ = writeln!(buf, "{}", frame.header.postbox_header[1]);
454            let _ = writeln!(buf, "{}", frame.header.natm_types);
455
456            for (i, n) in frame.header.natms_per_type.iter().enumerate() {
457                if i > 0 {
458                    buf.push(b' ');
459                }
460                push_u64(buf, *n as u64);
461            }
462            buf.push(b'\n');
463
464            for (i, m) in frame.header.masses_per_type.iter().enumerate() {
465                if i > 0 {
466                    buf.push(b' ');
467                }
468                push_f64(buf, ryu, *m, prec);
469            }
470            buf.push(b'\n');
471
472            // --- Write the Atom Data ---
473            let mut atom_idx_offset = 0;
474            for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate() {
475                let symbol = &frame.atom_data[atom_idx_offset].symbol;
476                let _ = writeln!(buf, "{symbol}");
477                let _ = writeln!(buf, "Coordinates of Component {}", type_idx + 1);
478
479                for i in 0..num_atoms_in_type {
480                    let atom = &frame.atom_data[atom_idx_offset + i];
481                    push_xyz_line(
482                        buf,
483                        ryu,
484                        atom.x,
485                        atom.y,
486                        atom.z,
487                        prec,
488                        written_fixed_flag(atom.fixed, frame.header.spec_version),
489                        atom.atom_id,
490                    );
491                }
492                atom_idx_offset += num_atoms_in_type;
493            }
494
495            // --- Write optional velocity section ---
496            if frame.has_velocities() {
497                buf.push(b'\n');
498
499                let mut vel_idx_offset = 0;
500                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
501                {
502                    let symbol = &frame.atom_data[vel_idx_offset].symbol;
503                    let _ = writeln!(buf, "{symbol}");
504                    let _ = writeln!(buf, "Velocities of Component {}", type_idx + 1);
505
506                    for i in 0..num_atoms_in_type {
507                        let atom = &frame.atom_data[vel_idx_offset + i];
508                        let [vx, vy, vz] = atom.velocity().unwrap_or([0.0; 3]);
509                        push_xyz_line(
510                            buf,
511                            ryu,
512                            vx,
513                            vy,
514                            vz,
515                            prec,
516                            written_fixed_flag(atom.fixed, frame.header.spec_version),
517                            atom.atom_id,
518                        );
519                    }
520                    vel_idx_offset += num_atoms_in_type;
521                }
522            }
523
524            // --- Write optional force section ---
525            if frame.has_forces() {
526                buf.push(b'\n');
527
528                let mut force_idx_offset = 0;
529                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
530                {
531                    let symbol = &frame.atom_data[force_idx_offset].symbol;
532                    let _ = writeln!(buf, "{symbol}");
533                    let _ = writeln!(buf, "Forces of Component {}", type_idx + 1);
534
535                    for i in 0..num_atoms_in_type {
536                        let atom = &frame.atom_data[force_idx_offset + i];
537                        let [fx, fy, fz] = atom.force().unwrap_or([0.0; 3]);
538                        push_xyz_line(
539                            buf,
540                            ryu,
541                            fx,
542                            fy,
543                            fz,
544                            prec,
545                            written_fixed_flag(atom.fixed, frame.header.spec_version),
546                            atom.atom_id,
547                        );
548                    }
549                    force_idx_offset += num_atoms_in_type;
550                }
551            }
552
553            // --- Write optional energies section ---
554            if frame.has_energies() {
555                buf.push(b'\n');
556
557                let mut energy_idx_offset = 0;
558                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
559                {
560                    let symbol = &frame.atom_data[energy_idx_offset].symbol;
561                    let _ = writeln!(buf, "{symbol}");
562                    let _ = writeln!(buf, "Energies of Component {}", type_idx + 1);
563
564                    for i in 0..num_atoms_in_type {
565                        let atom = &frame.atom_data[energy_idx_offset + i];
566                        let e = atom.energy().unwrap_or(0.0);
567                        push_scalar_line(
568                            buf,
569                            ryu,
570                            e,
571                            prec,
572                            written_fixed_flag(atom.fixed, frame.header.spec_version),
573                            atom.atom_id,
574                        );
575                    }
576                    energy_idx_offset += num_atoms_in_type;
577                }
578            }
579
580            if frame.has_charges() {
581                buf.push(b'\n');
582                let mut off = 0;
583                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
584                {
585                    let symbol = &frame.atom_data[off].symbol;
586                    let _ = writeln!(buf, "{symbol}");
587                    let _ = writeln!(buf, "Charges of Component {}", type_idx + 1);
588                    for i in 0..num_atoms_in_type {
589                        let atom = &frame.atom_data[off + i];
590                        let q = atom.charge().unwrap_or(0.0);
591                        push_scalar_line(
592                            buf,
593                            ryu,
594                            q,
595                            prec,
596                            written_fixed_flag(atom.fixed, frame.header.spec_version),
597                            atom.atom_id,
598                        );
599                    }
600                    off += num_atoms_in_type;
601                }
602            }
603
604            if frame.has_spins() {
605                buf.push(b'\n');
606                let mut off = 0;
607                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
608                {
609                    let symbol = &frame.atom_data[off].symbol;
610                    let _ = writeln!(buf, "{symbol}");
611                    let _ = writeln!(buf, "Spins of Component {}", type_idx + 1);
612                    for i in 0..num_atoms_in_type {
613                        let atom = &frame.atom_data[off + i];
614                        let s = atom.spin().unwrap_or(0.0);
615                        push_scalar_line(
616                            buf,
617                            ryu,
618                            s,
619                            prec,
620                            written_fixed_flag(atom.fixed, frame.header.spec_version),
621                            atom.atom_id,
622                        );
623                    }
624                    off += num_atoms_in_type;
625                }
626            }
627
628            if frame.has_magmoms() {
629                buf.push(b'\n');
630                let mut off = 0;
631                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
632                {
633                    let symbol = &frame.atom_data[off].symbol;
634                    let _ = writeln!(buf, "{symbol}");
635                    let _ = writeln!(buf, "Magmoms of Component {}", type_idx + 1);
636                    for i in 0..num_atoms_in_type {
637                        let atom = &frame.atom_data[off + i];
638                        let [mx, my, mz] = atom.magmom().unwrap_or([0.0; 3]);
639                        push_xyz_line(
640                            buf,
641                            ryu,
642                            mx,
643                            my,
644                            mz,
645                            prec,
646                            written_fixed_flag(atom.fixed, frame.header.spec_version),
647                            atom.atom_id,
648                        );
649                    }
650                    off += num_atoms_in_type;
651                }
652            }
653
654            if frame.has_displacements() {
655                buf.push(b'\n');
656                let mut off = 0;
657                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
658                {
659                    let symbol = &frame.atom_data[off].symbol;
660                    let _ = writeln!(buf, "{symbol}");
661                    let _ = writeln!(buf, "Displacements of Component {}", type_idx + 1);
662                    for i in 0..num_atoms_in_type {
663                        let atom = &frame.atom_data[off + i];
664                        let [dx, dy, dz] = atom.displacement().unwrap_or([0.0; 3]);
665                        push_xyz_line(
666                            buf,
667                            ryu,
668                            dx,
669                            dy,
670                            dz,
671                            prec,
672                            written_fixed_flag(atom.fixed, frame.header.spec_version),
673                            atom.atom_id,
674                        );
675                    }
676                    off += num_atoms_in_type;
677                }
678            }
679
680            if frame.has_spreads() {
681                buf.push(b'\n');
682                let mut off = 0;
683                for (type_idx, &num_atoms_in_type) in frame.header.natms_per_type.iter().enumerate()
684                {
685                    let symbol = &frame.atom_data[off].symbol;
686                    let _ = writeln!(buf, "{symbol}");
687                    let _ = writeln!(buf, "Spreads of Component {}", type_idx + 1);
688                    for i in 0..num_atoms_in_type {
689                        let atom = &frame.atom_data[off + i];
690                        let [sx, sy, sz] = atom.spread().unwrap_or([0.0; 3]);
691                        if !crate::types::spread_row_ok([sx, sy, sz]) {
692                            let err = if sx.is_finite() && sy.is_finite() && sz.is_finite() {
693                                crate::types::invalid_spread(off + i)
694                            } else {
695                                crate::error::ParseError::NonFiniteValue(format!(
696                                    "spreads atom {}",
697                                    off + i
698                                ))
699                            };
700                            return Err(WriteError::Format(err));
701                        }
702                        push_xyz_line(
703                            buf,
704                            ryu,
705                            sx,
706                            sy,
707                            sz,
708                            prec,
709                            written_fixed_flag(atom.fixed, frame.header.spec_version),
710                            atom.atom_id,
711                        );
712                    }
713                    off += num_atoms_in_type;
714                }
715            }
716        }
717
718        self.writer.write_all(&self.scratch)?;
719        Ok(())
720    }
721
722    /// Flushes buffered data to the underlying writer and reports errors.
723    pub fn flush(&mut self) -> io::Result<()> {
724        self.writer.flush()
725    }
726
727    /// Writes all frames from an iterator to the output stream.
728    ///
729    /// This is the most convenient way to write a multi-frame file.
730    pub fn extend<'a>(
731        &mut self,
732        frames: impl Iterator<Item = &'a ConFrame>,
733    ) -> Result<(), WriteError> {
734        for frame in frames {
735            self.write_frame(frame)?;
736        }
737        Ok(())
738    }
739}
740
741fn ensure_unique_ids(atoms: &[crate::types::AtomDatum]) -> Result<(), WriteError> {
742    let n = atoms.len();
743    if n == 0 {
744        return Ok(());
745    }
746    // Trajectory frames usually number atoms 0..n-1. That needs no set.
747    if atoms
748        .iter()
749        .enumerate()
750        .all(|(i, atom)| atom.atom_id == i as u64)
751    {
752        return Ok(());
753    }
754    let mut seen_ids = std::collections::HashSet::with_capacity(n);
755    for atom in atoms {
756        if !seen_ids.insert(atom.atom_id) {
757            return Err(WriteError::Format(
758                crate::error::ParseError::ValidationError(format!(
759                    "atom id {} is repeated in the frame",
760                    atom.atom_id
761                )),
762            ));
763        }
764    }
765    Ok(())
766}
767
768fn written_fixed_flag(fixed: [bool; 3], spec_version: u32) -> u8 {
769    if spec_version < 2 {
770        if fixed.iter().any(|axis| *axis) { 1 } else { 0 }
771    } else {
772        encode_fixed_bitmask(fixed)
773    }
774}
775
776fn push_u64(buf: &mut Vec<u8>, n: u64) {
777    push_u128(buf, u128::from(n));
778}
779
780fn push_u128(buf: &mut Vec<u8>, mut n: u128) {
781    let mut tmp = [0u8; 40];
782    let mut i = 40;
783    if n == 0 {
784        buf.push(b'0');
785        return;
786    }
787    while n > 0 {
788        i -= 1;
789        tmp[i] = b'0' + (n % 10) as u8;
790        n /= 10;
791    }
792    buf.extend_from_slice(&tmp[i..]);
793}
794
795fn push_f64(buf: &mut Vec<u8>, ryu: &mut ryu::Buffer, value: f64, format: FloatFormat) {
796    match format {
797        FloatFormat::DecimalPlaces(precision) => push_f64_prec(buf, value, precision),
798        FloatFormat::RoundTrip => {
799            // Shortest correctly-rounded spelling. `format` covers the
800            // non-finite fallback; finite values take the reused buffer.
801            let text = if value.is_finite() {
802                ryu.format_finite(value)
803            } else {
804                ryu.format(value)
805            };
806            buf.extend_from_slice(text.as_bytes());
807        }
808    }
809}
810
811/// Fixed-point `f64` with `prec` digits after the decimal, matching `{:.prec$}`.
812/// Non-finite values and `prec > 17` fall back to `std::fmt`.
813fn push_f64_prec(buf: &mut Vec<u8>, v: f64, prec: usize) {
814    // The six-decimal path uses fixed-point arithmetic; other precisions
815    // use the standard formatter.
816    if !v.is_finite() || prec != 6 {
817        let _ = write!(buf, "{v:.prec$}");
818        return;
819    }
820    if v.is_sign_negative() {
821        buf.push(b'-');
822    }
823    let ax = v.abs();
824    if prec == 0 {
825        push_u128(buf, ax.round() as u128);
826        return;
827    }
828    let scale = 10u128.pow(prec as u32);
829    let n = (ax * scale as f64).round() as u128;
830    let int_part = n / scale;
831    let frac = n % scale;
832    push_u128(buf, int_part);
833    buf.push(b'.');
834    let mut tmp = [b'0'; 20];
835    let mut x = frac;
836    let mut i = prec;
837    while i > 0 {
838        i -= 1;
839        tmp[i] = b'0' + (x % 10) as u8;
840        x /= 10;
841    }
842    buf.extend_from_slice(&tmp[..prec]);
843}
844
845fn push_xyz_line(
846    buf: &mut Vec<u8>,
847    ryu: &mut ryu::Buffer,
848    x: f64,
849    y: f64,
850    z: f64,
851    prec: FloatFormat,
852    fixed: u8,
853    atom_id: u64,
854) {
855    push_f64(buf, ryu, x, prec);
856    buf.push(b' ');
857    push_f64(buf, ryu, y, prec);
858    buf.push(b' ');
859    push_f64(buf, ryu, z, prec);
860    buf.push(b' ');
861    push_u64(buf, u64::from(fixed));
862    buf.push(b' ');
863    push_u64(buf, atom_id);
864    buf.push(b'\n');
865}
866
867fn push_scalar_line(
868    buf: &mut Vec<u8>,
869    ryu: &mut ryu::Buffer,
870    v: f64,
871    prec: FloatFormat,
872    fixed: u8,
873    atom_id: u64,
874) {
875    push_f64(buf, ryu, v, prec);
876    buf.push(b' ');
877    push_u64(buf, u64::from(fixed));
878    buf.push(b' ');
879    push_u64(buf, atom_id);
880    buf.push(b'\n');
881}
882
883// Implementation block specifically for when the writer is a `File`.
884impl ConFrameWriter<File> {
885    /// Creates a new `ConFrameWriter` that writes to a file at the given path.
886    ///
887    /// This is a convenience function that creates the file and wraps it.
888    pub fn from_path<P: AsRef<Path>>(path: P) -> io::Result<Self> {
889        let file = File::create(path)?;
890        Ok(Self::new(file))
891    }
892
893    /// Creates a new `ConFrameWriter` that writes to a file with a custom precision.
894    pub fn from_path_with_precision<P: AsRef<Path>>(path: P, precision: usize) -> io::Result<Self> {
895        let file = File::create(path)?;
896        Ok(Self::with_precision(file, precision))
897    }
898}
899
900// Gzip-compressed writer constructors.
901impl ConFrameWriter<flate2::write::GzEncoder<File>> {
902    /// Creates a gzip-compressed writer for the given path.
903    pub fn from_path_gzip<P: AsRef<Path>>(path: P) -> io::Result<Self> {
904        let encoder = crate::compression::gzip_writer(path.as_ref())?;
905        Ok(Self::new(encoder))
906    }
907
908    /// Creates a gzip-compressed writer with custom precision.
909    pub fn from_path_gzip_with_precision<P: AsRef<Path>>(
910        path: P,
911        precision: usize,
912    ) -> io::Result<Self> {
913        let encoder = crate::compression::gzip_writer(path.as_ref())?;
914        Ok(Self::with_precision(encoder, precision))
915    }
916}
917
918// Zstd-compressed writer constructors. Available only with the `zstd`
919// Cargo feature.
920#[cfg(feature = "zstd")]
921impl ConFrameWriter<zstd::stream::write::AutoFinishEncoder<'static, File>> {
922    /// Creates a zstd-compressed writer for the given path.
923    pub fn from_path_zstd<P: AsRef<Path>>(path: P) -> io::Result<Self> {
924        let encoder = crate::compression::zstd_writer(path.as_ref())?;
925        Ok(Self::new(encoder))
926    }
927
928    /// Creates a zstd-compressed writer with custom precision.
929    pub fn from_path_zstd_with_precision<P: AsRef<Path>>(
930        path: P,
931        precision: usize,
932    ) -> io::Result<Self> {
933        let encoder = crate::compression::zstd_writer(path.as_ref())?;
934        Ok(Self::with_precision(encoder, precision))
935    }
936}
937
938#[cfg(test)]
939mod float_format_tests {
940    use super::push_f64_prec;
941
942    fn formatted(v: f64, prec: usize) -> String {
943        let mut buf = Vec::new();
944        push_f64_prec(&mut buf, v, prec);
945        String::from_utf8(buf).expect("utf8")
946    }
947
948    #[test]
949    fn matches_std_fixed_precision() {
950        let vals = [
951            0.0,
952            -0.0,
953            1.0,
954            -1.0,
955            0.9045,
956            6.975_299_999_999_995,
957            63.546,
958            1.008,
959            15.3456,
960            90.0,
961            218.0,
962            1e-6,
963            -1.23456789,
964            10.0,
965        ];
966        for prec in [0usize, 6] {
967            for v in vals {
968                let got = formatted(v, prec);
969                let exp = format!("{v:.prec$}");
970                assert_eq!(got, exp, "v={v:?} prec={prec}");
971            }
972        }
973    }
974}
975
976#[cfg(test)]
977mod section_arity_tests {
978    use super::require_minor2_section_arity;
979    use serde_json::json;
980
981    #[test]
982    fn minor2_lists_a_new_section_in_section_arity() {
983        let widgets = vec![json!("widgets")];
984        let err = require_minor2_section_arity(2, &widgets, &serde_json::Map::new()).unwrap_err();
985        let msg = err.to_string();
986        assert!(msg.contains("section_arity"), "{msg}");
987
988        let mut listed = serde_json::Map::new();
989        listed.insert("section_arity".into(), json!({"widgets": 3}));
990        require_minor2_section_arity(2, &widgets, &listed).unwrap();
991
992        let reserved = vec![json!("velocities"), json!("energies")];
993        require_minor2_section_arity(2, &reserved, &serde_json::Map::new()).unwrap();
994        require_minor2_section_arity(1, &widgets, &serde_json::Map::new()).unwrap();
995    }
996}