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