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