1use std::path::Path;
2
3use crate::error::{Result, TrxError};
4use crate::header::Header;
5use crate::tractogram::Tractogram;
6
7#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
9pub enum VtkCoordinateMode {
10 HeaderOrWarn,
12 #[default]
14 AssumeRas,
15 AssumeLps,
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum VtkCoordinateSpace {
22 Ras,
24 Lps,
26}
27
28pub fn read_vtk(
29 path: &Path,
30 header_override: Option<Header>,
31 coordinate_mode: VtkCoordinateMode,
32) -> Result<Tractogram> {
33 let bytes = std::fs::read(path)?;
34 let (positions, offsets) = parse_vtk_bytes(&bytes, coordinate_mode)?;
35
36 let header = header_override.unwrap_or(Header {
37 voxel_to_rasmm: Header::identity_affine(),
38 dimensions: [1, 1, 1],
39 nb_streamlines: 0,
40 nb_vertices: 0,
41 extra: Default::default(),
42 });
43
44 Ok(Tractogram::from_positions_and_offsets(
45 positions, offsets, header,
46 ))
47}
48
49use std::io::Write;
50
51pub fn write_vtk(path: &Path, tractogram: &Tractogram) -> Result<()> {
52 let file = std::fs::File::create(path)?;
53 let mut file = std::io::BufWriter::new(file);
54
55 let mut header = String::new();
56 header.push_str("# vtk DataFile Version 4.2\n");
57 header.push_str("trx-rs tractogram SPACE=RAS\n");
58 header.push_str("BINARY\n");
59 header.push_str("DATASET POLYDATA\n");
60 header.push_str(&format!("POINTS {} float\n", tractogram.nb_vertices()));
61 file.write_all(header.as_bytes())?;
62
63 for point in tractogram.positions() {
64 file.write_all(&point[0].to_be_bytes())?;
65 file.write_all(&point[1].to_be_bytes())?;
66 file.write_all(&point[2].to_be_bytes())?;
67 }
68
69 let total_line_entries: usize = tractogram
70 .streamlines()
71 .map(|streamline| streamline.len() + 1)
72 .sum();
73
74 let lines_header = format!(
75 "\nLINES {} {}\n",
76 tractogram.nb_streamlines(),
77 total_line_entries
78 );
79 file.write_all(lines_header.as_bytes())?;
80
81 let mut vertex_index = 0u32;
82 for streamline in tractogram.streamlines() {
83 let len = streamline.len() as u32;
84 file.write_all(&len.to_be_bytes())?;
85 for _ in 0..len {
86 file.write_all(&vertex_index.to_be_bytes())?;
87 vertex_index += 1;
88 }
89 }
90
91 file.flush()?;
92
93 Ok(())
94}
95
96pub fn inspect_vtk_declared_space(path: &Path) -> Result<Option<VtkCoordinateSpace>> {
97 let bytes = std::fs::read(path)?;
98 inspect_vtk_declared_space_bytes(&bytes)
99}
100
101pub fn vtk_import_warnings(path: &Path, mode: VtkCoordinateMode) -> Result<Vec<String>> {
102 let declared = inspect_vtk_declared_space(path)?;
103 Ok(match (mode, declared) {
104 (VtkCoordinateMode::HeaderOrWarn, None) => vec![
105 "VTK file does not declare `SPACE=RAS` or `SPACE=LPS`; trx-rs assumed LPS and converted to RAS.".to_string(),
106 "If the tractogram looks mirrored, re-import it and force VTK coordinates to RAS.".to_string(),
107 ],
108 _ => Vec::new(),
109 })
110}
111
112fn parse_vtk_bytes(
113 bytes: &[u8],
114 coordinate_mode: VtkCoordinateMode,
115) -> Result<(Vec<[f32; 3]>, Vec<u32>)> {
116 let mut cursor = 0usize;
117 let version = read_line(bytes, &mut cursor)?;
118 if !version.starts_with("# vtk DataFile Version") {
119 return Err(TrxError::Format("not a legacy VTK file".into()));
120 }
121
122 let _comment = read_line(bytes, &mut cursor)?;
123 let format = read_line(bytes, &mut cursor)?;
124 let dataset = read_line(bytes, &mut cursor)?;
125 if dataset.trim() != "DATASET POLYDATA" {
126 return Err(TrxError::Format(
127 "only VTK POLYDATA streamline files are supported".into(),
128 ));
129 }
130
131 let points_header = read_line(bytes, &mut cursor)?;
132 let mut header_parts = points_header.split_whitespace();
133 if header_parts.next() != Some("POINTS") {
134 return Err(TrxError::Format(
135 "VTK file is missing POINTS section".into(),
136 ));
137 }
138 let point_count = header_parts
139 .next()
140 .ok_or_else(|| TrxError::Format("missing VTK point count".into()))?
141 .parse::<usize>()
142 .map_err(|_| TrxError::Format("invalid VTK point count".into()))?;
143 let point_type = header_parts
144 .next()
145 .ok_or_else(|| TrxError::Format("missing VTK points datatype".into()))?;
146 let vtk_header_text = header_text(bytes, cursor)?;
147
148 let is_binary = format.trim().eq_ignore_ascii_case("BINARY");
149 let (mut positions, offsets) = if is_binary {
150 parse_binary_points(bytes, &mut cursor, point_count, point_type)?
151 } else if format.trim().eq_ignore_ascii_case("ASCII") {
152 parse_ascii_points_and_lines(bytes, cursor, point_count)?
153 } else {
154 return Err(TrxError::Format(format!(
155 "unsupported VTK encoding '{}'",
156 format.trim()
157 )));
158 };
159
160 let coordinate_space = resolve_vtk_coordinate_space(vtk_header_text, coordinate_mode)?;
161 for point in &mut positions {
162 *point = vtk_world_to_ras(*point, coordinate_space);
163 }
164
165 Ok((positions, offsets))
166}
167
168fn parse_ascii_points_and_lines(
169 bytes: &[u8],
170 cursor: usize,
171 point_count: usize,
172) -> Result<(Vec<[f32; 3]>, Vec<u32>)> {
173 let text = std::str::from_utf8(&bytes[cursor..])
174 .map_err(|_| TrxError::Format("ASCII VTK body is not valid UTF-8".into()))?;
175 let mut tokens = text.split_whitespace();
176
177 let mut points = Vec::with_capacity(point_count);
178 for _ in 0..point_count {
179 let x = next_token(&mut tokens, "point x")?
180 .parse::<f32>()
181 .map_err(|_| TrxError::Format("invalid VTK point coordinate".into()))?;
182 let y = next_token(&mut tokens, "point y")?
183 .parse::<f32>()
184 .map_err(|_| TrxError::Format("invalid VTK point coordinate".into()))?;
185 let z = next_token(&mut tokens, "point z")?
186 .parse::<f32>()
187 .map_err(|_| TrxError::Format("invalid VTK point coordinate".into()))?;
188 points.push([x, y, z]);
189 }
190
191 if next_token(&mut tokens, "LINES keyword")? != "LINES" {
192 return Err(TrxError::Format("VTK file is missing LINES section".into()));
193 }
194 let line_count = next_token(&mut tokens, "line count")?
195 .parse::<usize>()
196 .map_err(|_| TrxError::Format("invalid VTK line count".into()))?;
197 let _line_size = next_token(&mut tokens, "line size")?
198 .parse::<usize>()
199 .map_err(|_| TrxError::Format("invalid VTK line size".into()))?;
200
201 build_streamlines_from_tokens(points, line_count, &mut tokens)
202}
203
204fn parse_binary_points(
205 bytes: &[u8],
206 cursor: &mut usize,
207 point_count: usize,
208 point_type: &str,
209) -> Result<(Vec<[f32; 3]>, Vec<u32>)> {
210 let element_size = match point_type {
211 "float" => 4,
212 "double" => 8,
213 other => {
214 return Err(TrxError::Format(format!(
215 "unsupported binary VTK point datatype '{other}'"
216 )))
217 }
218 };
219 let points_bytes = point_count
220 .checked_mul(3)
221 .and_then(|count| count.checked_mul(element_size))
222 .ok_or_else(|| TrxError::Format("VTK points section is too large".into()))?;
223 let end = cursor
224 .checked_add(points_bytes)
225 .ok_or_else(|| TrxError::Format("VTK points section overflow".into()))?;
226 let data = bytes
227 .get(*cursor..end)
228 .ok_or_else(|| TrxError::Format("VTK points section is truncated".into()))?;
229
230 let mut points = Vec::with_capacity(point_count);
231 match point_type {
232 "float" => {
233 for chunk in data.as_chunks::<12>().0 {
234 points.push([
235 f32::from_be_bytes(chunk[0..4].try_into().unwrap()),
236 f32::from_be_bytes(chunk[4..8].try_into().unwrap()),
237 f32::from_be_bytes(chunk[8..12].try_into().unwrap()),
238 ]);
239 }
240 }
241 "double" => {
242 for chunk in data.as_chunks::<24>().0 {
243 points.push([
244 f64::from_be_bytes(chunk[0..8].try_into().unwrap()) as f32,
245 f64::from_be_bytes(chunk[8..16].try_into().unwrap()) as f32,
246 f64::from_be_bytes(chunk[16..24].try_into().unwrap()) as f32,
247 ]);
248 }
249 }
250 _ => unreachable!(),
251 }
252 *cursor = end;
253 skip_newlines(bytes, cursor);
254
255 let lines_header = read_line(bytes, cursor)?;
256 let mut parts = lines_header.split_whitespace();
257 if parts.next() != Some("LINES") {
258 return Err(TrxError::Format("VTK file is missing LINES section".into()));
259 }
260 let line_count = parts
261 .next()
262 .ok_or_else(|| TrxError::Format("missing VTK line count".into()))?
263 .parse::<usize>()
264 .map_err(|_| TrxError::Format("invalid VTK line count".into()))?;
265 let total_size = parts
266 .next()
267 .ok_or_else(|| TrxError::Format("missing VTK total line size".into()))?
268 .parse::<usize>()
269 .map_err(|_| TrxError::Format("invalid VTK total line size".into()))?;
270
271 let line_bytes = total_size
272 .checked_mul(4)
273 .ok_or_else(|| TrxError::Format("VTK line section is too large".into()))?;
274 let end = cursor
275 .checked_add(line_bytes)
276 .ok_or_else(|| TrxError::Format("VTK line section overflow".into()))?;
277 let data = bytes
278 .get(*cursor..end)
279 .ok_or_else(|| TrxError::Format("VTK line section is truncated".into()))?;
280
281 let mut ints = Vec::with_capacity(total_size);
282 for chunk in data.as_chunks::<4>().0 {
283 ints.push(i32::from_be_bytes(*chunk));
284 }
285
286 build_streamlines_from_ints(points, line_count, ints)
287}
288
289fn build_streamlines_from_ints(
290 points: Vec<[f32; 3]>,
291 line_count: usize,
292 ints: Vec<i32>,
293) -> Result<(Vec<[f32; 3]>, Vec<u32>)> {
294 let mut cursor = 0usize;
295 let mut positions = Vec::with_capacity(ints.len().saturating_sub(line_count));
296 let mut offsets = Vec::with_capacity(line_count + 1);
297 offsets.push(0);
298
299 for _ in 0..line_count {
300 let length = *ints
301 .get(cursor)
302 .ok_or_else(|| TrxError::Format("VTK LINES section ended unexpectedly".into()))?;
303 cursor += 1;
304 let length = usize::try_from(length)
305 .map_err(|_| TrxError::Format("VTK streamline length cannot be negative".into()))?;
306 for _ in 0..length {
307 let point_index = *ints.get(cursor).ok_or_else(|| {
308 TrxError::Format("VTK LINES point index section ended unexpectedly".into())
309 })?;
310 cursor += 1;
311 let point_index = usize::try_from(point_index)
312 .map_err(|_| TrxError::Format("VTK point index cannot be negative".into()))?;
313 let point = *points.get(point_index).ok_or_else(|| {
314 TrxError::Format(format!("VTK point index {point_index} is out of bounds"))
315 })?;
316 positions.push(point);
317 }
318 offsets.push(u32::try_from(positions.len()).map_err(|_| {
319 TrxError::Argument("tractogram has more than u32::MAX vertices".into())
320 })?);
321 }
322 Ok((positions, offsets))
323}
324
325fn build_streamlines_from_tokens<'a>(
326 points: Vec<[f32; 3]>,
327 line_count: usize,
328 tokens: &mut impl Iterator<Item = &'a str>,
329) -> Result<(Vec<[f32; 3]>, Vec<u32>)> {
330 let mut positions = Vec::new();
331 let mut offsets = Vec::with_capacity(line_count + 1);
332 offsets.push(0);
333
334 for _ in 0..line_count {
335 let length = next_token(tokens, "streamline length")?
336 .parse::<usize>()
337 .map_err(|_| TrxError::Format("invalid VTK streamline length".into()))?;
338 for _ in 0..length {
339 let point_index = next_token(tokens, "streamline point index")?
340 .parse::<usize>()
341 .map_err(|_| TrxError::Format("invalid VTK point index".into()))?;
342 let point = *points.get(point_index).ok_or_else(|| {
343 TrxError::Format(format!("VTK point index {point_index} is out of bounds"))
344 })?;
345 positions.push(point);
346 }
347 offsets.push(u32::try_from(positions.len()).map_err(|_| {
348 TrxError::Argument("tractogram has more than u32::MAX vertices".into())
349 })?);
350 }
351 Ok((positions, offsets))
352}
353
354fn read_line<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<&'a str> {
355 let start = *cursor;
356 while *cursor < bytes.len() && bytes[*cursor] != b'\n' {
357 *cursor += 1;
358 }
359 let end = *cursor;
360 if *cursor < bytes.len() && bytes[*cursor] == b'\n' {
361 *cursor += 1;
362 }
363 std::str::from_utf8(&bytes[start..end])
364 .map(|line| line.trim_end_matches('\r'))
365 .map_err(|_| TrxError::Format("VTK header is not valid UTF-8".into()))
366}
367
368fn skip_newlines(bytes: &[u8], cursor: &mut usize) {
369 while *cursor < bytes.len() && matches!(bytes[*cursor], b'\n' | b'\r') {
370 *cursor += 1;
371 }
372}
373
374fn next_token<'a>(tokens: &mut impl Iterator<Item = &'a str>, label: &str) -> Result<&'a str> {
375 tokens
376 .next()
377 .ok_or_else(|| TrxError::Format(format!("missing VTK token for {label}")))
378}
379
380fn inspect_vtk_declared_space_bytes(bytes: &[u8]) -> Result<Option<VtkCoordinateSpace>> {
381 let mut cursor = 0usize;
382 let version = read_line(bytes, &mut cursor)?;
383 if !version.starts_with("# vtk DataFile Version") {
384 return Err(TrxError::Format("not a legacy VTK file".into()));
385 }
386 let _comment = read_line(bytes, &mut cursor)?;
387 let _format = read_line(bytes, &mut cursor)?;
388 let _dataset = read_line(bytes, &mut cursor)?;
389 let _points = read_line(bytes, &mut cursor)?;
390 Ok(parse_vtk_declared_space(header_text(bytes, cursor)?))
391}
392
393fn parse_vtk_declared_space(comment: &str) -> Option<VtkCoordinateSpace> {
394 let upper = comment.to_ascii_uppercase();
395 if upper.contains("SPACE=RAS") {
396 Some(VtkCoordinateSpace::Ras)
397 } else if upper.contains("SPACE=LPS") {
398 Some(VtkCoordinateSpace::Lps)
399 } else {
400 None
401 }
402}
403
404fn resolve_vtk_coordinate_space(
405 header_text: &str,
406 mode: VtkCoordinateMode,
407) -> Result<VtkCoordinateSpace> {
408 Ok(match mode {
409 VtkCoordinateMode::AssumeRas => VtkCoordinateSpace::Ras,
410 VtkCoordinateMode::AssumeLps => VtkCoordinateSpace::Lps,
411 VtkCoordinateMode::HeaderOrWarn => {
412 parse_vtk_declared_space(header_text).unwrap_or(VtkCoordinateSpace::Lps)
413 }
414 })
415}
416
417fn header_text(bytes: &[u8], end: usize) -> Result<&str> {
418 std::str::from_utf8(&bytes[..end])
419 .map_err(|_| TrxError::Format("VTK header is not valid UTF-8".into()))
420}
421
422fn vtk_world_to_ras(point: [f32; 3], coordinate_space: VtkCoordinateSpace) -> [f32; 3] {
423 match coordinate_space {
424 VtkCoordinateSpace::Ras => point,
425 VtkCoordinateSpace::Lps => [-point[0], -point[1], point[2]],
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::{
432 parse_vtk_declared_space, resolve_vtk_coordinate_space, vtk_world_to_ras,
433 VtkCoordinateMode, VtkCoordinateSpace,
434 };
435
436 #[test]
437 fn vtk_header_space_parser_detects_ras_and_lps() {
438 assert_eq!(
439 parse_vtk_declared_space("created in slicer SPACE=RAS"),
440 Some(VtkCoordinateSpace::Ras)
441 );
442 assert_eq!(
443 parse_vtk_declared_space("created in slicer SPACE=LPS"),
444 Some(VtkCoordinateSpace::Lps)
445 );
446 assert_eq!(parse_vtk_declared_space("vtk output"), None);
447 }
448
449 #[test]
450 fn header_or_warn_defaults_to_lps_when_header_is_absent() {
451 assert_eq!(
452 resolve_vtk_coordinate_space("vtk output", VtkCoordinateMode::HeaderOrWarn).unwrap(),
453 VtkCoordinateSpace::Lps
454 );
455 }
456
457 #[test]
458 fn forced_ras_leaves_coordinates_unchanged() {
459 assert_eq!(
460 vtk_world_to_ras([1.0, 2.0, 3.0], VtkCoordinateSpace::Ras),
461 [1.0, 2.0, 3.0]
462 );
463 }
464}