Skip to main content

energyml_export/
vtk.rs

1//! VTK, in the three flavours a caller is likely to be asked for.
2//!
3//! | name | file | what it is |
4//! |---|---|---|
5//! | `vtk` | `.vtk` | the **legacy** ASCII format, `DATASET POLYDATA` |
6//! | `vtp` | `.vtp` | the **XML** format, `PolyData` |
7//! | `vtu` | `.vtu` | the **XML** format, `UnstructuredGrid` |
8//!
9//! Three rather than one because they are not interchangeable in practice. The
10//! legacy format is read by everything, including old and in-house tools, and
11//! is the one to hand somebody who just needs it to open. The XML formats are
12//! what ParaView writes and what carries metadata properly. `PolyData` suits
13//! surfaces and lines; `UnstructuredGrid` is the general container, and the one
14//! to grow into for volumes.
15//!
16//! Each XML flavour takes a **file-format version** - VTK's own `version`
17//! attribute, not the VTK library's. `0.1` is what VTK 5-8 wrote, `1.0` is the
18//! long-standing default, `2.2` adds 64-bit headers. A reader older than the
19//! version it is handed refuses the file, so the default here is `1.0`: the one
20//! nothing refuses.
21//!
22//! # Metadata
23//!
24//! Every flavour carries whatever the [`Spec`] collected.
25//!
26//! - Legacy: the title line, then `FIELD FieldData` with one `string` array per
27//!   key. String arrays in legacy VTK are a real feature and an obscure one;
28//!   the comment lines above them are what a person actually reads.
29//! - XML: a `<FieldData>` block of `type="String"` arrays. VTK encodes a string
30//!   array as the **character codes** of each string followed by a `0`
31//!   terminator, which looks wrong and is not. An XML comment repeats the pairs
32//!   in plain text, because a format whose metadata only a library can read is
33//!   half a format.
34//!
35//! Neither encoding has been round-tripped through ParaView in this
36//! repository - there is none here to run - so they follow the documented
37//! format rather than an observed one. Worth checking against a real reader
38//! before relying on the machine-readable half.
39//!
40//! [`Spec`]: energyml_domain::metadata::Spec
41
42use std::io::Write;
43
44use energyml_geometry::{Attribute, AttributeScope, Placement, SceneObject, Topology};
45
46use crate::error::{Error, Result};
47use crate::registry::{Exporter, Options};
48
49/// Which VTK container to write.
50#[derive(Clone, Copy, PartialEq, Eq, Debug)]
51pub enum Flavour {
52    /// Legacy ASCII `.vtk`, `DATASET POLYDATA`.
53    Legacy,
54    /// XML `.vtp`, `PolyData`.
55    PolyData,
56    /// XML `.vtu`, `UnstructuredGrid`.
57    UnstructuredGrid,
58}
59
60/// Writes VTK.
61pub struct VtkExporter {
62    flavour: Flavour,
63    version: &'static str,
64}
65
66impl VtkExporter {
67    /// The legacy ASCII format.
68    pub fn legacy() -> Self {
69        Self {
70            flavour: Flavour::Legacy,
71            // Legacy files declare `# vtk DataFile Version x.y`. 3.0 is what
72            // every reader since VTK 4 accepts; 5.1 adds nothing this exporter
73            // uses and excludes older tools.
74            version: "3.0",
75        }
76    }
77
78    /// XML `PolyData`, at the default file-format version.
79    pub fn poly_data() -> Self {
80        Self {
81            flavour: Flavour::PolyData,
82            version: "1.0",
83        }
84    }
85
86    /// XML `UnstructuredGrid`, at the default file-format version.
87    pub fn unstructured_grid() -> Self {
88        Self {
89            flavour: Flavour::UnstructuredGrid,
90            version: "1.0",
91        }
92    }
93
94    /// Set the file-format version written into the header.
95    ///
96    /// `"0.1"`, `"1.0"` or `"2.2"` for the XML flavours; `"2.0"`, `"3.0"` or
97    /// `"5.1"` for the legacy one. Nothing else about the output changes - this
98    /// is what the file *claims to be*, and claiming a version a reader does
99    /// not know makes it refuse the file.
100    pub fn with_version(mut self, version: &'static str) -> Self {
101        self.version = version;
102        self
103    }
104
105    /// Which container this writes.
106    pub fn flavour(&self) -> Flavour {
107        self.flavour
108    }
109}
110
111impl Exporter for VtkExporter {
112    fn name(&self) -> &'static str {
113        match self.flavour {
114            Flavour::Legacy => "vtk",
115            Flavour::PolyData => "vtp",
116            Flavour::UnstructuredGrid => "vtu",
117        }
118    }
119
120    fn extension(&self) -> &'static str {
121        self.name()
122    }
123
124    fn mime(&self) -> &'static str {
125        match self.flavour {
126            Flavour::Legacy => "application/octet-stream",
127            _ => "application/xml",
128        }
129    }
130
131    fn describe(&self) -> &'static str {
132        match self.flavour {
133            Flavour::Legacy => "VTK legacy ASCII, DATASET POLYDATA",
134            Flavour::PolyData => "VTK XML PolyData (.vtp), surfaces and lines",
135            Flavour::UnstructuredGrid => "VTK XML UnstructuredGrid (.vtu), the general container",
136        }
137    }
138
139    fn placement(&self) -> Placement {
140        Placement::Metres
141    }
142
143    fn handles(&self, object: &SceneObject) -> bool {
144        !object.geometry.is_empty()
145    }
146
147    fn write(&self, object: &SceneObject, out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
148        if object.geometry.is_empty() {
149            return Err(Error::unsuitable(
150                "vtk",
151                format!("`{}`, which has no points", object.title),
152            ));
153        }
154        // Reshaped as the caller's rules ask, then stripped of the nodes that
155        // have no position - one funnel, one order, for every format. A `nan`
156        // coordinate lands a point at the origin in some VTK readers and stops
157        // the file loading in others; property values follow their elements.
158        // See `registry::prepare`. (Coordinates only: a `nan` in a *property*
159        // array is deliberate and stays - VTK reads it as the no-data it is.)
160        let object = crate::registry::prepare(object, options)?;
161        let object = object.as_ref();
162
163        let placement = options.placement.unwrap_or_else(|| self.placement());
164        let points = options.points(object, placement)?;
165        let cells = cells_of(object);
166
167        match self.flavour {
168            Flavour::Legacy => self.legacy_document(object, &points, &cells, options, out),
169            _ => self.xml_document(object, &points, &cells, options, out),
170        }
171    }
172
173    // One object per file, deliberately. VTK's legacy format holds one dataset,
174    // and the XML ones hold one piece per file unless you write a `.pvd`
175    // collection - which is a different file referencing these. Concatenating
176    // would produce something no reader accepts, so this refuses instead.
177    fn write_all(&self, objects: &[SceneObject], out: &mut dyn Write, options: &Options<'_>) -> Result<()> {
178        let writable: Vec<&SceneObject> = objects.iter().filter(|o| self.handles(o)).collect();
179        match writable.len() {
180            0 => Ok(()),
181            1 => self.write(writable[0], out, options),
182            n => Err(Error::unsuitable(
183                "vtk",
184                format!(
185                    "{n} objects in one file - a VTK file holds one dataset; \
186                     write to a directory to get one file each"
187                ),
188            )),
189        }
190    }
191}
192
193/// The cells of an object, as VTK counts them.
194struct Cells {
195    /// Triangles, as three point indices.
196    triangles: Vec<[u32; 3]>,
197    /// Rings of any length: `VTK_POLYGON`, for an outline and anything else a
198    /// fixed corner count cannot hold.
199    polygons: Vec<Vec<u32>>,
200    /// Quadrilaterals, as four point indices in ring order.
201    ///
202    /// A 2D grid's cells, and the faces of a hexahedral grid asked for as a
203    /// surface. `VTK_QUAD` rather than two `VTK_TRIANGLE`s, because that is
204    /// what the file describes and ParaView's cell count should say what the
205    /// grid's does.
206    quads: Vec<[u32; 4]>,
207    /// Polylines, as runs of consecutive point indices.
208    lines: Vec<Vec<u32>>,
209    /// Points that form nothing.
210    vertices: Vec<u32>,
211    /// Cells of eight corners, in VTK's order.
212    hexahedra: Vec<[u32; 8]>,
213    /// Cells of arbitrary faces: one entry per cell, one inner list per face,
214    /// each already wound so its normal points **out** of that cell.
215    ///
216    /// VTK calls this `VTK_POLYHEDRON` and encodes it unlike every other cell -
217    /// see [`write_unstructured_cells`], where it costs two extra arrays.
218    polyhedra: Vec<Vec<Vec<u32>>>,
219}
220
221impl Cells {
222    /// How many cells of every kind, in the order the writers emit them.
223    ///
224    /// One number used by three writers, so the `types` array and the
225    /// per-cell `faceoffsets` cannot drift apart from the connectivity.
226    fn count(&self) -> usize {
227        self.vertices.len()
228            + self.lines.len()
229            + self.triangles.len()
230            + self.quads.len()
231            + self.polygons.len()
232            + self.hexahedra.len()
233            + self.polyhedra.len()
234    }
235}
236
237/// The six faces of a hexahedron in VTK corner order, wound outward.
238///
239/// Needed only by `PolyData`, which has no volume cells: a grid written as
240/// `.vtp` becomes its faces, interior ones included. `.vtu` holds the cells
241/// themselves and is the right container for a grid.
242const HEXAHEDRON_FACES: [[usize; 4]; 6] = [
243    [0, 3, 2, 1],
244    [4, 5, 6, 7],
245    [0, 1, 5, 4],
246    [1, 2, 6, 5],
247    [2, 3, 7, 6],
248    [3, 0, 4, 7],
249];
250
251fn cells_of(object: &SceneObject) -> Cells {
252    let (_, triangles) = object.geometry.merged();
253    let mut lines = Vec::new();
254    let mut quads = Vec::new();
255    let mut polygons: Vec<Vec<u32>> = Vec::new();
256    let mut vertices = Vec::new();
257    let mut hexahedra = Vec::new();
258    let mut polyhedra: Vec<Vec<Vec<u32>>> = Vec::new();
259    let mut base = 0u32;
260    for patch in &object.geometry.patches {
261        match &patch.topology {
262            // A wireframe's edge is a two-point line; VTK's polyline cell
263            // takes any indices, consecutive or not.
264            Topology::Segments(pairs) => {
265                lines.extend(pairs.iter().map(|pair| vec![pair[0] + base, pair[1] + base]));
266            }
267            Topology::Polylines(runs) => {
268                let mut start = base;
269                for (rank, run) in runs.iter().enumerate() {
270                    if *run >= 2 {
271                        let mut line: Vec<_> = (0..*run).map(|i| start + i).collect();
272                        // A VTK polyline cell has no closing flag either: a
273                        // ring the file calls closed ends on its first point.
274                        if patch.closed.get(rank) == Some(&true) {
275                            line.push(start);
276                        }
277                        lines.push(line);
278                    }
279                    start += run;
280                }
281            }
282            // Already wound by `energyml_geometry::shape`, which measures the
283            // signed area rather than assuming a convention the file does not
284            // state - the reason a grid can be drawn as cells at all.
285            Topology::Quads(quadrilaterals) => {
286                quads.extend(quadrilaterals.iter().map(|quad| {
287                    let mut shifted = *quad;
288                    for corner in &mut shifted {
289                        *corner += base;
290                    }
291                    shifted
292                }));
293            }
294            Topology::Polygons(rings) => {
295                polygons.extend(
296                    rings
297                        .iter()
298                        .map(|ring| ring.iter().map(|node| node + base).collect()),
299                );
300            }
301            // A lattice still asked for as a lattice, and a point set: both are
302            // loose points here. `Shape::Surface` (the default for a grid) turns
303            // the first into `Quads` above.
304            Topology::Points | Topology::Grid { .. } => {
305                vertices.extend(base..base + patch.points.len() as u32);
306            }
307            Topology::Hexahedra { cells, .. } => {
308                hexahedra.extend(cells.iter().map(|cell| {
309                    let mut shifted = *cell;
310                    for corner in &mut shifted {
311                        *corner += base;
312                    }
313                    shifted
314                }));
315            }
316            // Already oriented outward by `faces_outward`, so nothing here has
317            // to know about `CellFaceIsRightHanded`.
318            Topology::Polyhedra(cells) => {
319                polyhedra.extend((0..cells.cell_count()).map(|cell| {
320                    cells
321                        .faces_outward(cell)
322                        .map(|face| face.iter().map(|node| node + base).collect())
323                        .collect()
324                }));
325            }
326            Topology::Triangles(_) => {}
327        }
328        base += patch.points.len() as u32;
329    }
330    Cells {
331        triangles,
332        quads,
333        polygons,
334        lines,
335        vertices,
336        hexahedra,
337        polyhedra,
338    }
339}
340
341impl VtkExporter {
342    fn legacy_document(
343        &self,
344        object: &SceneObject,
345        points: &[[f64; 3]],
346        cells: &Cells,
347        options: &Options<'_>,
348        out: &mut dyn Write,
349    ) -> Result<()> {
350        writeln!(out, "# vtk DataFile Version {}", self.version)?;
351        // The title line is capped at 256 characters by the format, so it holds
352        // the two things worth having and the rest goes to FieldData.
353        let title = format!("{} [{}]", one_line(&object.title), object.uuid);
354        writeln!(out, "{}", &title[..title.len().min(255)])?;
355        writeln!(out, "ASCII")?;
356        // A legacy file states one dataset kind up front, and `POLYDATA` has no
357        // volume cells at all. A grid therefore has to be written as an
358        // unstructured grid - the same points, a different envelope.
359        let volumetric = !cells.hexahedra.is_empty() || !cells.polyhedra.is_empty();
360        writeln!(
361            out,
362            "DATASET {}",
363            if volumetric {
364                "UNSTRUCTURED_GRID"
365            } else {
366                "POLYDATA"
367            }
368        )?;
369
370        writeln!(out, "POINTS {} double", points.len())?;
371        for point in points {
372            writeln!(out, "{} {} {}", point[0], point[1], point[2])?;
373        }
374        if volumetric {
375            // `CELLS n size` where size counts the leading count of each cell.
376            let count = cells.hexahedra.len()
377                + cells.polyhedra.len()
378                + cells.triangles.len()
379                + cells.quads.len()
380                + cells.polygons.len()
381                + cells.lines.len();
382            let size: usize = cells.hexahedra.len() * 9
383                + cells
384                    .polyhedra
385                    .iter()
386                    .map(|c| legacy_polyhedron_len(c) + 1)
387                    .sum::<usize>()
388                + cells.triangles.len() * 4
389                + cells.quads.len() * 5
390                + cells.polygons.iter().map(|p| p.len() + 1).sum::<usize>()
391                + cells.lines.iter().map(|l| l.len() + 1).sum::<usize>();
392            writeln!(out, "CELLS {count} {size}")?;
393            for cell in &cells.hexahedra {
394                let corners: Vec<String> = cell.iter().map(|c| c.to_string()).collect();
395                writeln!(out, "8 {}", corners.join(" "))?;
396            }
397            // A polyhedron's line is `n` then the face stream, where `n` is
398            // how many integers follow - not how many points, which is what
399            // every other cell type puts there.
400            for cell in &cells.polyhedra {
401                let mut stream = vec![cell.len().to_string()];
402                for face in cell {
403                    stream.push(face.len().to_string());
404                    stream.extend(face.iter().map(|node| node.to_string()));
405                }
406                writeln!(out, "{} {}", legacy_polyhedron_len(cell), stream.join(" "))?;
407            }
408            for triangle in &cells.triangles {
409                writeln!(out, "3 {} {} {}", triangle[0], triangle[1], triangle[2])?;
410            }
411            for quad in &cells.quads {
412                writeln!(out, "4 {} {} {} {}", quad[0], quad[1], quad[2], quad[3])?;
413            }
414            for ring in &cells.polygons {
415                let nodes: Vec<String> = ring.iter().map(|n| n.to_string()).collect();
416                writeln!(out, "{} {}", ring.len(), nodes.join(" "))?;
417            }
418            for line in &cells.lines {
419                let indices: Vec<String> = line.iter().map(|i| i.to_string()).collect();
420                writeln!(out, "{} {}", line.len(), indices.join(" "))?;
421            }
422            writeln!(out, "CELL_TYPES {count}")?;
423            for _ in &cells.hexahedra {
424                writeln!(out, "12")?; // VTK_HEXAHEDRON
425            }
426            for _ in &cells.polyhedra {
427                writeln!(out, "42")?; // VTK_POLYHEDRON
428            }
429            for _ in &cells.triangles {
430                writeln!(out, "5")?; // VTK_TRIANGLE
431            }
432            for _ in &cells.quads {
433                writeln!(out, "9")?; // VTK_QUAD
434            }
435            for _ in &cells.polygons {
436                writeln!(out, "7")?; // VTK_POLYGON
437            }
438            for _ in &cells.lines {
439                writeln!(out, "4")?; // VTK_POLY_LINE
440            }
441        }
442        if !volumetric
443            && (!cells.triangles.is_empty() || !cells.quads.is_empty() || !cells.polygons.is_empty())
444        {
445            let size: usize = cells.triangles.len() * 4
446                + cells.quads.len() * 5
447                + cells.polygons.iter().map(|p| p.len() + 1).sum::<usize>();
448            let count = cells.triangles.len() + cells.quads.len() + cells.polygons.len();
449            writeln!(out, "POLYGONS {count} {size}")?;
450            for triangle in &cells.triangles {
451                writeln!(out, "3 {} {} {}", triangle[0], triangle[1], triangle[2])?;
452            }
453            for quad in &cells.quads {
454                writeln!(out, "4 {} {} {} {}", quad[0], quad[1], quad[2], quad[3])?;
455            }
456            for ring in &cells.polygons {
457                let nodes: Vec<String> = ring.iter().map(|n| n.to_string()).collect();
458                writeln!(out, "{} {}", ring.len(), nodes.join(" "))?;
459            }
460        }
461        if !volumetric && !cells.lines.is_empty() {
462            let size: usize = cells.lines.iter().map(|l| l.len() + 1).sum();
463            writeln!(out, "LINES {} {size}", cells.lines.len())?;
464            for line in &cells.lines {
465                let indices: Vec<String> = line.iter().map(|i| i.to_string()).collect();
466                writeln!(out, "{} {}", line.len(), indices.join(" "))?;
467            }
468        }
469        if !cells.vertices.is_empty() {
470            writeln!(
471                out,
472                "VERTICES {} {}",
473                cells.vertices.len(),
474                cells.vertices.len() * 2
475            )?;
476            for vertex in &cells.vertices {
477                writeln!(out, "1 {vertex}")?;
478            }
479        }
480
481        let pairs = self.pairs(object, options);
482        if !pairs.is_empty() {
483            writeln!(out, "FIELD FieldData {}", pairs.len())?;
484            for (key, value) in &pairs {
485                // A legacy string array: name, 1 component, 1 tuple, type
486                // `string`, then the value on the next line.
487                writeln!(out, "{} 1 1 string", sanitise(key))?;
488                writeln!(out, "{}", one_line(value))?;
489            }
490        }
491
492        // The property values. `POINT_DATA`/`CELL_DATA` each open a section
493        // that everything after belongs to, so each is written once with all
494        // its arrays - writing the header twice makes VTK read the second set
495        // as replacing the first.
496        let cell_count = cells.count();
497        for (scope, count) in [
498            (AttributeScope::Points, points.len()),
499            (AttributeScope::Cells, cell_count),
500        ] {
501            let attributes: Vec<&Attribute> = object
502                .attributes
503                .iter()
504                .filter(|attribute| attribute.scope == scope)
505                .collect();
506            if attributes.is_empty() || count == 0 {
507                continue;
508            }
509            writeln!(
510                out,
511                "{} {count}",
512                match scope {
513                    AttributeScope::Points => "POINT_DATA",
514                    AttributeScope::Cells => "CELL_DATA",
515                }
516            )?;
517            for attribute in attributes {
518                // `SCALARS name double n` then `LOOKUP_TABLE`, which is what
519                // makes ParaView offer the array in its colour-by list. When
520                // the package states a colour map for the property, its ramp
521                // goes in beside the values under a name of its own; otherwise
522                // `default` leaves the choice to the viewer rather than
523                // inventing a ramp.
524                let table = if attribute.lut.is_empty() {
525                    "default".to_string()
526                } else {
527                    format!("{}_colors", sanitise(&attribute.name))
528                };
529                writeln!(
530                    out,
531                    "SCALARS {} double {}",
532                    sanitise(&attribute.name),
533                    attribute.components.max(1)
534                )?;
535                writeln!(out, "LOOKUP_TABLE {table}")?;
536                for chunk in attribute.values.chunks(attribute.components.max(1) as usize) {
537                    let numbers: Vec<String> = chunk.iter().map(number).collect();
538                    writeln!(out, "{}", numbers.join(" "))?;
539                }
540                if !attribute.lut.is_empty() {
541                    // The legacy format is the only VTK file that carries a
542                    // colour table at all: `.vtu` and `.vtp` have no element
543                    // for one, and ParaView keeps ramps in its own state.
544                    writeln!(out, "LOOKUP_TABLE {table} {}", attribute.lut.len())?;
545                    for [r, g, b, a] in &attribute.lut {
546                        writeln!(out, "{r} {g} {b} {a}")?;
547                    }
548                }
549            }
550        }
551        Ok(())
552    }
553
554    fn xml_document(
555        &self,
556        object: &SceneObject,
557        points: &[[f64; 3]],
558        cells: &Cells,
559        options: &Options<'_>,
560        out: &mut dyn Write,
561    ) -> Result<()> {
562        let container = match self.flavour {
563            Flavour::UnstructuredGrid => "UnstructuredGrid",
564            _ => "PolyData",
565        };
566        writeln!(out, "<?xml version=\"1.0\"?>")?;
567        writeln!(
568            out,
569            "<VTKFile type=\"{container}\" version=\"{}\" byte_order=\"LittleEndian\">",
570            self.version
571        )?;
572
573        // Plain text first: a person opening the file should not need a library
574        // to see what it is.
575        let pairs = self.pairs(object, options);
576        if !pairs.is_empty() {
577            writeln!(out, "  <!--")?;
578            for (key, value) in &pairs {
579                writeln!(out, "    {key}: {}", one_line(value).replace("--", "- -"))?;
580            }
581            writeln!(out, "  -->")?;
582        }
583
584        writeln!(out, "  <{container}>")?;
585        if !pairs.is_empty() {
586            writeln!(out, "    <FieldData>")?;
587            for (key, value) in &pairs {
588                let codes: Vec<String> = one_line(value)
589                    .chars()
590                    .map(|c| (c as u32).to_string())
591                    .chain(std::iter::once("0".to_string()))
592                    .collect();
593                writeln!(
594                    out,
595                    "      <DataArray type=\"String\" Name=\"{}\" NumberOfTuples=\"1\" format=\"ascii\">",
596                    escape(&sanitise(key))
597                )?;
598                writeln!(out, "        {}", codes.join(" "))?;
599                writeln!(out, "      </DataArray>")?;
600            }
601            writeln!(out, "    </FieldData>")?;
602        }
603
604        match self.flavour {
605            Flavour::UnstructuredGrid => {
606                let count = cells.count();
607                writeln!(
608                    out,
609                    "    <Piece NumberOfPoints=\"{}\" NumberOfCells=\"{count}\">",
610                    points.len()
611                )?;
612                write_points(points, out)?;
613                write_unstructured_cells(cells, out)?;
614            }
615            _ => {
616                writeln!(
617                    out,
618                    "    <Piece NumberOfPoints=\"{}\" NumberOfVerts=\"{}\" NumberOfLines=\"{}\" \
619                     NumberOfStrips=\"0\" NumberOfPolys=\"{}\">",
620                    points.len(),
621                    cells.vertices.len(),
622                    cells.lines.len(),
623                    // PolyData has no volume cells, so a grid contributes its
624                    // faces instead - six per hexahedron, and however many an
625                    // unstructured cell happens to have.
626                    cells.triangles.len()
627                        + cells.quads.len()
628                        + cells.polygons.len()
629                        + cells.hexahedra.len() * 6
630                        + cells.polyhedra.iter().map(|cell| cell.len()).sum::<usize>()
631                )?;
632                write_points(points, out)?;
633                write_poly_cells(cells, out)?;
634            }
635        }
636        write_attributes(object, cells, out)?;
637        writeln!(out, "    </Piece>")?;
638        writeln!(out, "  </{container}>")?;
639        writeln!(out, "</VTKFile>")?;
640        Ok(())
641    }
642
643    /// The metadata, with the identity always present.
644    ///
645    /// A spec may be empty or may not have been given; the uuid and the type
646    /// go in regardless, because a VTK file that cannot be traced back to the
647    /// object it came from is the thing this whole layer exists to avoid.
648    fn pairs(&self, object: &SceneObject, options: &Options<'_>) -> Vec<(String, String)> {
649        let mut pairs: Vec<(String, String)> = vec![
650            ("uuid".to_string(), object.uuid.clone()),
651            ("uri".to_string(), object.uri.clone()),
652            ("title".to_string(), object.title.clone()),
653            ("type".to_string(), object.qualified_type.clone()),
654        ];
655        if let Some(crs) = &object.crs {
656            let system = match crs.projected.epsg() {
657                Some(code) => format!("EPSG:{code}"),
658                None => format!("{:?}", crs.projected),
659            };
660            pairs.push(("crs".to_string(), format!("{} {system}", crs.uuid)));
661        }
662        // The equivalence, in the file's own metadata block. A VTK travels
663        // between tools that know nothing of RESQML; this is the only place the
664        // Z's meaning can still be read once it has.
665        if let Some(note) = options.time_equivalence_note(object) {
666            pairs.push(("vertical_axis".to_string(), note));
667        }
668        for (key, value) in object.metadata.pairs() {
669            // What the spec says wins: it is the caller's explicit choice.
670            if let Some(existing) = pairs.iter_mut().find(|(k, _)| k == key) {
671                existing.1 = value.to_string();
672            } else {
673                pairs.push((key.to_string(), value.to_string()));
674            }
675        }
676        pairs.retain(|(_, value)| !value.is_empty());
677        pairs
678    }
679}
680
681/// `<PointData>` and `<CellData>` blocks, one array each.
682///
683/// `Scalars="…"` on the block names the array a viewer colours by when it
684/// opens the file - without it ParaView shows the geometry in a flat colour
685/// and the values have to be found by hand.
686fn write_attributes(object: &SceneObject, cells: &Cells, out: &mut dyn Write) -> Result<()> {
687    for (scope, tag) in [
688        (AttributeScope::Points, "PointData"),
689        (AttributeScope::Cells, "CellData"),
690    ] {
691        let attributes: Vec<&Attribute> = object
692            .attributes
693            .iter()
694            .filter(|attribute| attribute.scope == scope)
695            .collect();
696        if attributes.is_empty() {
697            continue;
698        }
699        let _ = cells;
700        writeln!(
701            out,
702            "      <{tag} Scalars=\"{}\">",
703            escape(&sanitise(&attributes[0].name))
704        )?;
705        for attribute in attributes {
706            writeln!(
707                out,
708                "        <DataArray type=\"Float64\" Name=\"{}\" NumberOfComponents=\"{}\" \
709                 format=\"ascii\">",
710                escape(&sanitise(&attribute.name)),
711                attribute.components.max(1)
712            )?;
713            let numbers: Vec<String> = attribute.values.iter().map(number).collect();
714            writeln!(out, "          {}", numbers.join(" "))?;
715            writeln!(out, "        </DataArray>")?;
716        }
717        writeln!(out, "      </{tag}>")?;
718    }
719    Ok(())
720}
721
722/// A number VTK will read back as what was written.
723///
724/// `NaN` and the infinities have no lexical form in a VTK ASCII file; a
725/// property that carries them writes `nan`/`inf`, which VTK reads as such.
726fn number(value: &f64) -> String {
727    if value.is_nan() {
728        "nan".to_string()
729    } else if value.is_infinite() {
730        if *value > 0.0 {
731            "inf".to_string()
732        } else {
733            "-inf".to_string()
734        }
735    } else {
736        value.to_string()
737    }
738}
739
740fn write_points(points: &[[f64; 3]], out: &mut dyn Write) -> Result<()> {
741    writeln!(out, "      <Points>")?;
742    writeln!(
743        out,
744        "        <DataArray type=\"Float64\" NumberOfComponents=\"3\" format=\"ascii\">"
745    )?;
746    for point in points {
747        writeln!(out, "          {} {} {}", point[0], point[1], point[2])?;
748    }
749    writeln!(out, "        </DataArray>")?;
750    writeln!(out, "      </Points>")?;
751    Ok(())
752}
753
754/// How many integers a legacy `VTK_POLYHEDRON` face stream occupies.
755///
756/// `numFaces`, then per face its point count and its points. This is the number
757/// the legacy format wants as the cell's leading value - for every *other* cell
758/// type that leading value is the point count, which is why it is computed by a
759/// named function rather than inline.
760fn legacy_polyhedron_len(cell: &[Vec<u32>]) -> usize {
761    1 + cell.iter().map(|face| face.len() + 1).sum::<usize>()
762}
763
764fn write_poly_cells(cells: &Cells, out: &mut dyn Write) -> Result<()> {
765    if !cells.vertices.is_empty() {
766        let connectivity: Vec<String> = cells.vertices.iter().map(|v| v.to_string()).collect();
767        let offsets: Vec<String> = (1..=cells.vertices.len()).map(|i| i.to_string()).collect();
768        section("Verts", &connectivity, &offsets, out)?;
769    }
770    if !cells.lines.is_empty() {
771        let mut connectivity = Vec::new();
772        let mut offsets = Vec::new();
773        let mut at = 0usize;
774        for line in &cells.lines {
775            connectivity.extend(line.iter().map(|i| i.to_string()));
776            at += line.len();
777            offsets.push(at.to_string());
778        }
779        section("Lines", &connectivity, &offsets, out)?;
780    }
781    if !cells.triangles.is_empty()
782        || !cells.quads.is_empty()
783        || !cells.polygons.is_empty()
784        || !cells.hexahedra.is_empty()
785        || !cells.polyhedra.is_empty()
786    {
787        let mut connectivity = Vec::new();
788        let mut offsets = Vec::new();
789        let mut at = 0usize;
790        for triangle in &cells.triangles {
791            connectivity.extend(triangle.iter().map(|v| v.to_string()));
792            at += 3;
793            offsets.push(at.to_string());
794        }
795        for quad in &cells.quads {
796            connectivity.extend(quad.iter().map(|v| v.to_string()));
797            at += 4;
798            offsets.push(at.to_string());
799        }
800        for ring in &cells.polygons {
801            connectivity.extend(ring.iter().map(|v| v.to_string()));
802            at += ring.len();
803            offsets.push(at.to_string());
804        }
805        for cell in &cells.hexahedra {
806            for face in HEXAHEDRON_FACES {
807                connectivity.extend(face.iter().map(|corner| cell[*corner].to_string()));
808                at += 4;
809                offsets.push(at.to_string());
810            }
811        }
812        // `Polys` takes a polygon of any size, so an unstructured cell's faces
813        // go in whole rather than fan-triangulated. A quadrilateral stays a
814        // quadrilateral, which is what the file said.
815        for cell in &cells.polyhedra {
816            for face in cell {
817                connectivity.extend(face.iter().map(|node| node.to_string()));
818                at += face.len();
819                offsets.push(at.to_string());
820            }
821        }
822        section("Polys", &connectivity, &offsets, out)?;
823    }
824    Ok(())
825}
826
827fn write_unstructured_cells(cells: &Cells, out: &mut dyn Write) -> Result<()> {
828    let mut connectivity: Vec<String> = Vec::new();
829    let mut offsets: Vec<String> = Vec::new();
830    let mut types: Vec<String> = Vec::new();
831    let mut at = 0usize;
832
833    // VTK cell type numbers: 1 = vertex, 4 = polyline, 5 = triangle,
834    // 7 = polygon, 9 = quad, 12 = hexahedron.
835    for vertex in &cells.vertices {
836        connectivity.push(vertex.to_string());
837        at += 1;
838        offsets.push(at.to_string());
839        types.push("1".to_string());
840    }
841    for line in &cells.lines {
842        connectivity.extend(line.iter().map(|i| i.to_string()));
843        at += line.len();
844        offsets.push(at.to_string());
845        types.push("4".to_string());
846    }
847    for triangle in &cells.triangles {
848        connectivity.extend(triangle.iter().map(|v| v.to_string()));
849        at += 3;
850        offsets.push(at.to_string());
851        types.push("5".to_string());
852    }
853    for quad in &cells.quads {
854        connectivity.extend(quad.iter().map(|v| v.to_string()));
855        at += 4;
856        offsets.push(at.to_string());
857        types.push("9".to_string());
858    }
859    for ring in &cells.polygons {
860        connectivity.extend(ring.iter().map(|v| v.to_string()));
861        at += ring.len();
862        offsets.push(at.to_string());
863        types.push("7".to_string());
864    }
865    for cell in &cells.hexahedra {
866        connectivity.extend(cell.iter().map(|corner| corner.to_string()));
867        at += 8;
868        offsets.push(at.to_string());
869        types.push("12".to_string());
870    }
871
872    // `VTK_POLYHEDRON` is the one cell type whose connectivity does not
873    // describe it. The connectivity holds the cell's points, in no particular
874    // order and each **once**; the faces live in two further arrays that only
875    // exist when a polyhedron is present. `faceoffsets` carries one entry per
876    // cell of *every* kind, with −1 for the ones that are not polyhedra, so the
877    // vertices, lines, triangles and hexahedra written above have to be
878    // accounted for even though they contribute nothing to `faces`.
879    let mut faces: Vec<String> = Vec::new();
880    let mut face_offsets: Vec<String> = Vec::new();
881    for cell in &cells.polyhedra {
882        let mut points: Vec<u32> = cell.iter().flatten().copied().collect();
883        points.sort_unstable();
884        points.dedup();
885        connectivity.extend(points.iter().map(|node| node.to_string()));
886        at += points.len();
887        offsets.push(at.to_string());
888        types.push("42".to_string());
889
890        faces.push(cell.len().to_string());
891        for face in cell {
892            faces.push(face.len().to_string());
893            faces.extend(face.iter().map(|node| node.to_string()));
894        }
895        face_offsets.push(faces.len().to_string());
896    }
897
898    writeln!(out, "      <Cells>")?;
899    array("Int64", "connectivity", &connectivity, out)?;
900    array("Int64", "offsets", &offsets, out)?;
901    array("UInt8", "types", &types, out)?;
902    if !faces.is_empty() {
903        let others = cells.count() - cells.polyhedra.len();
904        let mut all: Vec<String> = vec!["-1".to_string(); others];
905        all.extend(face_offsets);
906        array("Int64", "faces", &faces, out)?;
907        array("Int64", "faceoffsets", &all, out)?;
908    }
909    writeln!(out, "      </Cells>")?;
910    Ok(())
911}
912
913fn section(name: &str, connectivity: &[String], offsets: &[String], out: &mut dyn Write) -> Result<()> {
914    writeln!(out, "      <{name}>")?;
915    array("Int64", "connectivity", connectivity, out)?;
916    array("Int64", "offsets", offsets, out)?;
917    writeln!(out, "      </{name}>")?;
918    Ok(())
919}
920
921fn array(kind: &str, name: &str, values: &[String], out: &mut dyn Write) -> Result<()> {
922    writeln!(
923        out,
924        "        <DataArray type=\"{kind}\" Name=\"{name}\" format=\"ascii\">"
925    )?;
926    writeln!(out, "          {}", values.join(" "))?;
927    writeln!(out, "        </DataArray>")?;
928    Ok(())
929}
930
931/// VTK array names cannot hold whitespace.
932fn sanitise(name: &str) -> String {
933    let cleaned: String = name
934        .chars()
935        .map(|c| if c.is_whitespace() { '_' } else { c })
936        .collect();
937    if cleaned.is_empty() {
938        "field".to_string()
939    } else {
940        cleaned
941    }
942}
943
944/// A value on one line: legacy VTK is line-oriented, and a newline inside a
945/// field would end the record early.
946fn one_line(value: &str) -> String {
947    value.replace(['\n', '\r'], " ")
948}
949
950fn escape(value: &str) -> String {
951    value
952        .replace('&', "&amp;")
953        .replace('<', "&lt;")
954        .replace('>', "&gt;")
955        .replace('"', "&quot;")
956}