Skip to main content

trx_rs/
trx_file.rs

1use bytemuck::{cast_slice, Pod};
2use std::collections::HashMap;
3use std::path::Path;
4
5use crate::dtype::{DType, TrxScalar};
6use crate::error::{Result, TrxError};
7use crate::header::Header;
8use crate::mmap_backing::{vec_to_bytes, MmapBacking};
9use crate::typed_view::TypedView2D;
10
11/// Named data array with column count and dtype metadata.
12#[derive(Debug)]
13pub struct DataArray {
14    backing: MmapBacking,
15    ncols: usize,
16    dtype: DType,
17}
18
19impl DataArray {
20    pub fn owned_bytes(backing: Vec<u8>, ncols: usize, dtype: DType) -> Self {
21        Self {
22            backing: MmapBacking::Owned(backing),
23            ncols,
24            dtype,
25        }
26    }
27
28    pub(crate) fn from_backing(backing: MmapBacking, ncols: usize, dtype: DType) -> Self {
29        Self {
30            backing,
31            ncols,
32            dtype,
33        }
34    }
35
36    pub fn clone_owned(&self) -> Self {
37        Self::owned_bytes(self.backing.as_bytes().to_vec(), self.ncols, self.dtype)
38    }
39
40    pub fn ncols(&self) -> usize {
41        self.ncols
42    }
43
44    pub fn dtype(&self) -> DType {
45        self.dtype
46    }
47
48    pub fn len_bytes(&self) -> usize {
49        self.backing.len()
50    }
51
52    pub fn nrows(&self) -> usize {
53        let row_bytes = self.ncols * self.dtype.size_of();
54        self.len_bytes().checked_div(row_bytes).unwrap_or(0)
55    }
56
57    pub fn as_bytes(&self) -> &[u8] {
58        self.backing.as_bytes()
59    }
60
61    pub(crate) fn as_bytes_mut(&mut self) -> Result<&mut [u8]> {
62        self.backing.as_bytes_mut()
63    }
64
65    pub fn cast_slice<T: Pod>(&self) -> &[T] {
66        self.backing.cast_slice()
67    }
68
69    pub(crate) fn cast_slice_mut<T: Pod>(&mut self) -> Result<&mut [T]> {
70        self.backing.cast_slice_mut()
71    }
72
73    pub fn typed_view<T: Pod>(&self) -> TypedView2D<'_, T> {
74        let data: &[T] = cast_slice(self.as_bytes());
75        TypedView2D::new(data, self.ncols)
76    }
77
78    /// Convert this array to a `Vec<u32>`, handling int64/uint64 source dtypes.
79    pub fn to_u32_vec(&self) -> Vec<u32> {
80        match self.dtype {
81            DType::UInt32 => self.cast_slice::<u32>().to_vec(),
82            DType::Int32 => self.cast_slice::<i32>().iter().map(|&x| x as u32).collect(),
83            DType::UInt64 => self.cast_slice::<u64>().iter().map(|&x| x as u32).collect(),
84            DType::Int64 => self.cast_slice::<i64>().iter().map(|&x| x as u32).collect(),
85            _ => self.cast_slice::<u32>().to_vec(),
86        }
87    }
88}
89
90pub type DataPerGroup = HashMap<String, HashMap<String, DataArray>>;
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct DataArrayInfo {
94    pub ncols: usize,
95    pub nrows: usize,
96    pub dtype: DType,
97}
98
99pub(crate) struct TrxParts {
100    pub header: Header,
101    pub positions_backing: MmapBacking,
102    pub offsets_backing: MmapBacking,
103    pub dps: HashMap<String, DataArray>,
104    pub dpv: HashMap<String, DataArray>,
105    pub groups: HashMap<String, DataArray>,
106    pub dpg: DataPerGroup,
107    pub tempdir: Option<tempfile::TempDir>,
108}
109
110/// Core TRX container, generic over the position scalar type `P`.
111///
112/// Owns all memory-mapped (or heap-allocated) backings. Typed views borrow
113/// from `self` — no explicit lifetime annotations needed.
114///
115/// # Field ordering
116/// `_tempdir` must be declared AFTER mmap fields so it is dropped last,
117/// keeping the temp directory alive while mmaps reference files within it.
118pub struct TrxFile<P: TrxScalar> {
119    header: Header,
120
121    /// Positions backing — `N × 3` elements of type `P`.
122    positions_backing: MmapBacking,
123
124    /// Offsets backing — `(nb_streamlines + 1)` u32 values.
125    /// TRX offsets are normalized to uint32 internally.
126    offsets_backing: MmapBacking,
127
128    /// Data per streamline: name → DataArray with `nb_streamlines` rows.
129    dps: HashMap<String, DataArray>,
130
131    /// Data per vertex: name → DataArray with `nb_vertices` rows.
132    dpv: HashMap<String, DataArray>,
133
134    /// Groups: name → DataArray of uint32 streamline indices.
135    groups: HashMap<String, DataArray>,
136
137    /// Data per group: group name -> field name -> DataArray.
138    dpg: DataPerGroup,
139
140    /// Temp directory handle (for zip-extracted files). Kept alive until drop.
141    _tempdir: Option<tempfile::TempDir>,
142
143    _phantom: std::marker::PhantomData<P>,
144}
145
146impl<P: TrxScalar> TrxFile<P> {
147    /// Create an empty TrxFile with the given header.
148    pub fn empty(header: Header) -> Self {
149        Self {
150            header,
151            positions_backing: MmapBacking::Owned(Vec::new()),
152            offsets_backing: MmapBacking::Owned(Vec::new()),
153            dps: HashMap::new(),
154            dpv: HashMap::new(),
155            groups: HashMap::new(),
156            dpg: HashMap::new(),
157            _tempdir: None,
158            _phantom: std::marker::PhantomData,
159        }
160    }
161
162    /// Construct from pre-built components (used by the loader).
163    pub(crate) fn from_parts(parts: TrxParts) -> Self {
164        Self {
165            header: parts.header,
166            positions_backing: parts.positions_backing,
167            offsets_backing: parts.offsets_backing,
168            dps: parts.dps,
169            dpv: parts.dpv,
170            groups: parts.groups,
171            dpg: parts.dpg,
172            _tempdir: parts.tempdir,
173            _phantom: std::marker::PhantomData,
174        }
175    }
176
177    pub fn header(&self) -> &Header {
178        &self.header
179    }
180
181    /// Return a new `TrxFile` with the header replaced. All data arrays and
182    /// positions are shared by reference-counted backing (owned copies).
183    pub fn with_updated_header(self, header: Header) -> Self {
184        Self { header, ..self }
185    }
186
187    // ── Positions ───────────────────────────────────────────────────
188
189    /// Positions as a flat slice of `[P; 3]` arrays.
190    pub fn positions(&self) -> &[[P; 3]] {
191        cast_slice(self.positions_backing.as_bytes())
192    }
193
194    /// Positions as a `TypedView2D` with 3 columns.
195    pub fn positions_2d(&self) -> TypedView2D<'_, P> {
196        let flat: &[P] = cast_slice(self.positions_backing.as_bytes());
197        TypedView2D::new(flat, 3)
198    }
199
200    /// Raw position bytes for direct GPU buffer upload.
201    pub fn positions_bytes(&self) -> &[u8] {
202        self.positions_backing.as_bytes()
203    }
204
205    /// Number of vertices (points) across all streamlines.
206    pub fn nb_vertices(&self) -> usize {
207        self.positions().len()
208    }
209
210    // ── Offsets ─────────────────────────────────────────────────────
211
212    /// Offsets as a slice of `u32`. Length is `nb_streamlines + 1`.
213    /// The i-th streamline spans `positions[offsets[i]..offsets[i+1]]`.
214    pub fn offsets(&self) -> &[u32] {
215        self.offsets_backing.cast_slice()
216    }
217
218    pub fn offsets_vec(&self) -> Vec<u32> {
219        self.offsets().to_vec()
220    }
221
222    /// Number of streamlines.
223    pub fn nb_streamlines(&self) -> usize {
224        let offsets = self.offsets();
225        if offsets.is_empty() {
226            0
227        } else {
228            offsets.len() - 1
229        }
230    }
231
232    // ── Streamline access ───────────────────────────────────────────
233
234    /// Get the i-th streamline as a slice of `[P; 3]` points.
235    pub fn streamline(&self, i: usize) -> &[[P; 3]] {
236        let offsets = self.offsets();
237        let start = offsets[i] as usize;
238        let end = offsets[i + 1] as usize;
239        &self.positions()[start..end]
240    }
241
242    /// Iterate over all streamlines.
243    pub fn streamlines(&self) -> StreamlineIter<'_, P> {
244        StreamlineIter {
245            positions: self.positions(),
246            offsets: self.offsets(),
247            index: 0,
248        }
249    }
250
251    /// Length (number of points) of each streamline.
252    pub fn streamline_lengths(&self) -> Vec<usize> {
253        let offsets = self.offsets();
254        offsets.windows(2).map(|w| (w[1] - w[0]) as usize).collect()
255    }
256
257    // ── DPS / DPV / Group access ────────────────────────────────────
258
259    /// Get a DPS (data-per-streamline) array cast to type `T`.
260    pub fn dps<T: Pod>(&self, name: &str) -> Result<TypedView2D<'_, T>> {
261        let arr = self.lookup_dps(name)?;
262        Ok(arr.typed_view())
263    }
264
265    /// Get a DPV (data-per-vertex) array cast to type `T`.
266    pub fn dpv<T: Pod>(&self, name: &str) -> Result<TypedView2D<'_, T>> {
267        let arr = self.lookup_dpv(name)?;
268        Ok(arr.typed_view())
269    }
270
271    /// Get group member indices (always u32).
272    pub fn group(&self, name: &str) -> Result<&[u32]> {
273        let arr = self.lookup_group(name)?;
274        Ok(arr.cast_slice())
275    }
276
277    /// Get a DPG (data-per-group) array cast to type `T`.
278    pub fn dpg<T: Pod>(&self, group: &str, name: &str) -> Result<TypedView2D<'_, T>> {
279        let arr = self.lookup_dpg(group, name)?;
280        Ok(arr.typed_view())
281    }
282
283    /// List DPS field names.
284    pub fn dps_names(&self) -> Vec<&str> {
285        self.dps.keys().map(|s| s.as_str()).collect()
286    }
287
288    /// List DPV field names.
289    pub fn dpv_names(&self) -> Vec<&str> {
290        self.dpv.keys().map(|s| s.as_str()).collect()
291    }
292
293    /// List group names.
294    pub fn group_names(&self) -> Vec<&str> {
295        self.groups.keys().map(|s| s.as_str()).collect()
296    }
297
298    /// List DPG group names.
299    pub fn dpg_group_names(&self) -> Vec<&str> {
300        self.dpg.keys().map(|s| s.as_str()).collect()
301    }
302
303    pub fn iter_dps(&self) -> impl Iterator<Item = (&str, DataArrayInfo)> + '_ {
304        self.dps
305            .iter()
306            .map(|(name, arr)| (name.as_str(), arr.info()))
307    }
308
309    pub fn iter_dpv(&self) -> impl Iterator<Item = (&str, DataArrayInfo)> + '_ {
310        self.dpv
311            .iter()
312            .map(|(name, arr)| (name.as_str(), arr.info()))
313    }
314
315    /// Iterate over groups, yielding `(name, &[u32])` pairs.
316    ///
317    /// # Safety note
318    /// This performs a raw byte reinterpretation via `cast_slice::<u32>()`.
319    /// If the on-disk group dtype is **not** `uint32` (e.g. `int64`), the
320    /// returned slice will contain mangled values. Prefer
321    /// [`group_entries_owned`] which handles dtype conversion correctly.
322    pub fn iter_groups(&self) -> impl Iterator<Item = (&str, &[u32])> + '_ {
323        self.groups
324            .iter()
325            .map(|(name, arr)| (name.as_str(), arr.cast_slice::<u32>()))
326    }
327
328    pub fn dpg_entries(&self, group: &str) -> Result<Vec<(String, DataArrayInfo)>> {
329        let entries = self
330            .dpg
331            .get(group)
332            .ok_or_else(|| TrxError::Argument(format!("no DPG group named '{group}'")))?;
333        Ok(entries
334            .iter()
335            .map(|(name, arr)| (name.clone(), arr.info()))
336            .collect())
337    }
338
339    pub fn dps_info(&self, name: &str) -> Result<DataArrayInfo> {
340        Ok(self.lookup_dps(name)?.info())
341    }
342
343    pub fn dps_array(&self, name: &str) -> Result<&DataArray> {
344        self.lookup_dps(name)
345    }
346
347    pub fn dpv_info(&self, name: &str) -> Result<DataArrayInfo> {
348        Ok(self.lookup_dpv(name)?.info())
349    }
350
351    pub fn dpv_array(&self, name: &str) -> Result<&DataArray> {
352        self.lookup_dpv(name)
353    }
354
355    pub fn group_info(&self, name: &str) -> Result<DataArrayInfo> {
356        Ok(self.lookup_group(name)?.info())
357    }
358
359    pub fn group_array(&self, name: &str) -> Result<&DataArray> {
360        self.lookup_group(name)
361    }
362
363    pub fn dpg_info(&self, group: &str, name: &str) -> Result<DataArrayInfo> {
364        Ok(self.lookup_dpg(group, name)?.info())
365    }
366
367    pub fn dpg_array(&self, group: &str, name: &str) -> Result<&DataArray> {
368        self.lookup_dpg(group, name)
369    }
370
371    pub fn scalar_dps_f32(&self, name: &str) -> Result<Vec<f32>> {
372        read_scalar_array_as_f32(self.lookup_dps(name)?, "DPS", name)
373    }
374
375    pub fn scalar_dpv_f32(&self, name: &str) -> Result<Vec<f32>> {
376        read_scalar_array_as_f32(self.lookup_dpv(name)?, "DPV", name)
377    }
378
379    pub fn group_entries_owned(&self) -> Vec<(String, Vec<u32>)> {
380        self.groups
381            .iter()
382            .map(|(name, arr)| (name.clone(), arr.to_u32_vec()))
383            .collect()
384    }
385
386    // ── Loading (convenience) ───────────────────────────────────────
387
388    /// Load a TRX file from a directory or `.trx` zip archive.
389    pub fn load(path: &Path) -> Result<Self> {
390        crate::io::load::<P>(path)
391    }
392
393    // ── Saving ──────────────────────────────────────────────────────
394
395    /// Save to a directory. The `offsets.*` array is written as `uint32` if
396    /// every offset fits in `u32`, otherwise as `uint64`.
397    pub fn save_to_directory(&self, path: &Path) -> Result<()> {
398        crate::io::directory::save_to_directory(self, path)
399    }
400
401    /// Save to a `.trx` zip archive. All entries are Stored (no compression):
402    /// DEFLATE rarely pays off on float-heavy tractography data. The
403    /// `offsets.*` array width is auto-picked (uint32 if it fits, else uint64).
404    pub fn save_to_zip(&self, path: &Path) -> Result<()> {
405        crate::io::zip::save_to_zip(self, path)
406    }
407
408    /// Save to a `.trx` zip archive, applying DEFLATE only to `groups/` entries.
409    /// Everything else is Stored.
410    pub fn save_to_zip_deflate_groups(&self, path: &Path) -> Result<()> {
411        crate::io::zip::save_to_zip_with(self, path, zip::CompressionMethod::Deflated)
412    }
413
414    /// Deprecated alias for [`save_to_zip`] — kept for backward compatibility.
415    pub fn save_to_zip_stored(&self, path: &Path) -> Result<()> {
416        crate::io::zip::save_to_zip(self, path)
417    }
418
419    /// Save — auto-detects format from extension (`.trx` = zip, otherwise directory).
420    pub fn save(&self, path: &Path) -> Result<()> {
421        if path.extension().and_then(|e| e.to_str()) == Some("trx") {
422            self.save_to_zip(path)
423        } else {
424            self.save_to_directory(path)
425        }
426    }
427
428    pub fn is_file_backed(&self) -> bool {
429        self._tempdir.is_some() && self.positions_backing.is_mapped()
430    }
431
432    pub(crate) fn dps_arrays(&self) -> &HashMap<String, DataArray> {
433        &self.dps
434    }
435
436    pub(crate) fn dpv_arrays(&self) -> &HashMap<String, DataArray> {
437        &self.dpv
438    }
439
440    pub(crate) fn group_arrays(&self) -> &HashMap<String, DataArray> {
441        &self.groups
442    }
443
444    pub(crate) fn dpg_arrays(&self) -> &DataPerGroup {
445        &self.dpg
446    }
447
448    pub(crate) fn dps_arrays_mut(&mut self) -> &mut HashMap<String, DataArray> {
449        &mut self.dps
450    }
451
452    pub(crate) fn dpv_arrays_mut(&mut self) -> &mut HashMap<String, DataArray> {
453        &mut self.dpv
454    }
455
456    pub(crate) fn group_arrays_mut(&mut self) -> &mut HashMap<String, DataArray> {
457        &mut self.groups
458    }
459
460    pub(crate) fn dpg_arrays_mut(&mut self) -> &mut DataPerGroup {
461        &mut self.dpg
462    }
463
464    pub(crate) fn clone_with_positions_dtype<Q>(&self) -> TrxFile<Q>
465    where
466        Q: TrxScalar + FromF32,
467    {
468        let positions: Vec<[Q; 3]> = self
469            .positions()
470            .iter()
471            .map(|point| {
472                [
473                    Q::from_f32(point[0].to_f32()),
474                    Q::from_f32(point[1].to_f32()),
475                    Q::from_f32(point[2].to_f32()),
476                ]
477            })
478            .collect();
479
480        TrxFile::from_parts(TrxParts {
481            header: self.header.clone(),
482            positions_backing: MmapBacking::Owned(vec_to_bytes(positions)),
483            offsets_backing: MmapBacking::Owned(vec_to_bytes(self.offsets_vec())),
484            dps: clone_data_map(&self.dps),
485            dpv: clone_data_map(&self.dpv),
486            groups: clone_data_map(&self.groups),
487            dpg: clone_dpg_map(&self.dpg),
488            tempdir: None,
489        })
490    }
491
492    fn lookup_dps(&self, name: &str) -> Result<&DataArray> {
493        self.dps
494            .get(name)
495            .ok_or_else(|| TrxError::Argument(format!("no DPS named '{name}'")))
496    }
497
498    fn lookup_dpv(&self, name: &str) -> Result<&DataArray> {
499        self.dpv
500            .get(name)
501            .ok_or_else(|| TrxError::Argument(format!("no DPV named '{name}'")))
502    }
503
504    fn lookup_group(&self, name: &str) -> Result<&DataArray> {
505        self.groups
506            .get(name)
507            .ok_or_else(|| TrxError::Argument(format!("no group named '{name}'")))
508    }
509
510    fn lookup_dpg(&self, group: &str, name: &str) -> Result<&DataArray> {
511        let group_map = self
512            .dpg
513            .get(group)
514            .ok_or_else(|| TrxError::Argument(format!("no DPG group named '{group}'")))?;
515        group_map
516            .get(name)
517            .ok_or_else(|| TrxError::Argument(format!("no DPG named '{name}' in group '{group}'")))
518    }
519}
520
521impl<P: TrxScalar> std::fmt::Debug for TrxFile<P> {
522    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
523        f.debug_struct("TrxFile")
524            .field("dtype", &P::DTYPE)
525            .field("nb_streamlines", &self.nb_streamlines())
526            .field("nb_vertices", &self.nb_vertices())
527            .field("dps", &self.dps_names())
528            .field("dpv", &self.dpv_names())
529            .field("groups", &self.group_names())
530            .field("dpg_group_names", &self.dpg_group_names())
531            .finish()
532    }
533}
534
535/// Iterator over streamlines, yielding `&[[P; 3]]` slices.
536pub struct StreamlineIter<'a, P: TrxScalar> {
537    positions: &'a [[P; 3]],
538    offsets: &'a [u32],
539    index: usize,
540}
541
542impl<'a, P: TrxScalar> Iterator for StreamlineIter<'a, P> {
543    type Item = &'a [[P; 3]];
544
545    fn next(&mut self) -> Option<Self::Item> {
546        if self.index + 1 >= self.offsets.len() {
547            return None;
548        }
549        let start = self.offsets[self.index] as usize;
550        let end = self.offsets[self.index + 1] as usize;
551        self.index += 1;
552        Some(&self.positions[start..end])
553    }
554
555    fn size_hint(&self) -> (usize, Option<usize>) {
556        let remaining = if self.offsets.is_empty() {
557            0
558        } else {
559            self.offsets.len() - 1 - self.index
560        };
561        (remaining, Some(remaining))
562    }
563}
564
565impl<'a, P: TrxScalar> ExactSizeIterator for StreamlineIter<'a, P> {}
566
567impl DataArray {
568    pub fn info(&self) -> DataArrayInfo {
569        DataArrayInfo {
570            ncols: self.ncols,
571            nrows: self.nrows(),
572            dtype: self.dtype,
573        }
574    }
575}
576
577fn read_scalar_array_as_f32(arr: &DataArray, kind: &str, name: &str) -> Result<Vec<f32>> {
578    if arr.ncols() != 1 {
579        return Err(TrxError::Argument(format!(
580            "{kind} '{name}' has {} columns; expected a scalar field",
581            arr.ncols()
582        )));
583    }
584
585    let values = match arr.dtype() {
586        DType::Float16 => arr
587            .cast_slice::<half::f16>()
588            .iter()
589            .map(|value| value.to_f32())
590            .collect(),
591        DType::Float32 => arr.cast_slice::<f32>().to_vec(),
592        DType::Float64 => arr
593            .cast_slice::<f64>()
594            .iter()
595            .map(|&value| value as f32)
596            .collect(),
597        DType::Int8 => arr
598            .cast_slice::<i8>()
599            .iter()
600            .map(|&value| value as f32)
601            .collect(),
602        DType::Int16 => arr
603            .cast_slice::<i16>()
604            .iter()
605            .map(|&value| value as f32)
606            .collect(),
607        DType::Int32 => arr
608            .cast_slice::<i32>()
609            .iter()
610            .map(|&value| value as f32)
611            .collect(),
612        DType::UInt8 => arr
613            .cast_slice::<u8>()
614            .iter()
615            .map(|&value| value as f32)
616            .collect(),
617        DType::UInt16 => arr
618            .cast_slice::<u16>()
619            .iter()
620            .map(|&value| value as f32)
621            .collect(),
622        DType::UInt32 => arr
623            .cast_slice::<u32>()
624            .iter()
625            .map(|&value| value as f32)
626            .collect(),
627        other => {
628            return Err(TrxError::DType(format!(
629                "{kind} '{name}' uses unsupported scalar dtype {other}"
630            )))
631        }
632    };
633
634    Ok(values)
635}
636
637fn clone_data_map(map: &HashMap<String, DataArray>) -> HashMap<String, DataArray> {
638    map.iter()
639        .map(|(name, arr)| (name.clone(), arr.clone_owned()))
640        .collect()
641}
642
643fn clone_dpg_map(map: &DataPerGroup) -> DataPerGroup {
644    map.iter()
645        .map(|(group, entries)| (group.clone(), clone_data_map(entries)))
646        .collect()
647}
648
649pub(crate) trait FromF32 {
650    fn from_f32(value: f32) -> Self;
651}
652
653impl FromF32 for half::f16 {
654    fn from_f32(value: f32) -> Self {
655        half::f16::from_f32(value)
656    }
657}
658
659impl FromF32 for f32 {
660    fn from_f32(value: f32) -> Self {
661        value
662    }
663}
664
665impl FromF32 for f64 {
666    fn from_f32(value: f32) -> Self {
667        value as f64
668    }
669}