Skip to main content

energyml_geometry/
shape.rs

1//! Asking a representation for a *form* rather than taking the one it has.
2//!
3//! # Why this exists
4//!
5//! A representation carries one topology, and an exporter used to be stuck with
6//! it: a 2D grid came out as loose points because that is what
7//! [`Topology::Grid`] literally is, even though its cells are perfectly well
8//! defined and a horizon drawn as 300 000 dots is not what anyone wanted.
9//!
10//! But "always draw a grid's cells" would only move the problem. Someone
11//! exporting to GeoJSON for QGIS wants polygons; someone feeding a point-cloud
12//! pipeline wants the nodes; someone checking a triangulated surface's node
13//! spacing wants its points and not its faces. **The right form depends on what
14//! the file is for, which is knowledge the caller has and this crate does not.**
15//!
16//! So the caller asks, in the vocabulary of what is on screen rather than of
17//! what is in the schema:
18//!
19//! | [`Shape`] | What comes out |
20//! |---|---|
21//! | [`Shape::Auto`] | the richest form this topology supports - the default |
22//! | [`Shape::Points`] | the nodes, and nothing joining them |
23//! | [`Shape::Lines`] | polylines |
24//! | [`Shape::Surface`] | triangles and quadrilaterals |
25//! | [`Shape::Volume`] | cells with an inside |
26//! | [`Shape::Outline`] | **one** polygon: the convex hull of the whole object |
27//! | [`Shape::Centroid`] | **one** point: the mean of the nodes |
28//!
29//! # What is derived, and what is refused
30//!
31//! Every conversion here is **arithmetic on indices the file already states**.
32//! Nothing invents a connection: a grid's cell corners come from the indexing
33//! rule RESQML writes down (`index = fastest + fastestCount × slowest`), a
34//! hexahedron's faces come from the corner order the topology already
35//! guarantees, and edges come from cells that already exist.
36//!
37//! What is **refused, by name**, is everything that would need a decision the
38//! file does not contain:
39//!
40//! - **Polylines → Surface.** A line is not a boundary unless it closes, and
41//!   RESQML states closure in a separate boolean array this build does not
42//!   read. Filling a set of lines would draw a surface nobody described.
43//! - **Anything → Volume that has no inside.** A triangulated surface is not a
44//!   solid, and pretending otherwise is not a rendering choice, it is a
45//!   different object.
46//! - **Points → anything.** Loose points carry no connection to recover.
47//!
48//! Three conversions that *are* available, and all three are **derived
49//! boundaries** rather than inventions:
50//!
51//! - **Cells → Lines** gives the wireframe: the edges of the cells, each
52//!   stored once, as [`Topology::Segments`] - the indexed-segment topology
53//!   this module's earlier refusal said would make it work with no change
54//!   here. The edges are the *cells'* edges, never the triangulation's: a
55//!   quad contributes four, not the diagonal its drawing splits it by, because
56//!   the diagonal is an artefact of rendering and not a line the file
57//!   describes. A volume goes through its boundary first, so its wireframe is
58//!   the outside's edges - the same rule as its surface. Decided in favour of
59//!   a wireframe over a named refusal: the precedent is the skin below, and an edge of a
60//!   cell is data-derived exactly the way a boundary face is.
61//!
62//! - **Hexahedra → Surface** and **Polyhedra → Surface** give the grid's
63//!   **outside**: the faces exactly one cell claims, wound outward. Emitting
64//!   every face instead draws each interior one twice, one of the two facing
65//!   the camera, and a hundred-thousand-cell grid becomes an opaque block
66//!   z-fighting with itself at ten times the cost of the picture wanted.
67//!   Polyhedra come back as [`Topology::Polygons`] because a face is not
68//!   always four corners; [`Shape::Volume`] still keeps them whole, and VTK
69//!   writes those natively.
70//!
71//! A refusal names both ends - *"a PolylineSet cannot be exported as a
72//! surface"* - because the caller who asked for it wrote a rule, and a rule
73//! that silently does nothing is worse than one that fails.
74
75use std::collections::HashMap;
76
77use crate::error::{Error, Result};
78use crate::geometry::{Geometry, Patch, Topology};
79use crate::points::Point;
80
81/// The form a caller wants a representation drawn in.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum Shape {
84    /// The richest form this topology supports.
85    ///
86    /// A grid becomes its cells, a polyline set stays lines, a hexahedral grid
87    /// stays a volume. What every export did before there was anything to ask.
88    #[default]
89    Auto,
90    /// The nodes alone.
91    Points,
92    /// Polylines.
93    Lines,
94    /// Triangles and quadrilaterals.
95    Surface,
96    /// Cells with an inside.
97    Volume,
98    /// One polygon: the convex hull of every node, whatever the topology was.
99    ///
100    /// A **summary**, not a rendering - the first of the two shapes here whose
101    /// output is not the object. It answers "where is this", which is what a
102    /// discovery index wants and what OSDU's `SpatialArea` asks for: *"a polygon
103    /// boundary that reflects the locale of the content"*. (OSDU).
104    ///
105    /// Convex and not concave, deliberately: an alpha-shape follows a horizon's
106    /// bays and holes far better, and it needs a parameter whose wrong value
107    /// silently cuts the outline into pieces. A hull has no parameter and no
108    /// wrong answer - it is simply the smallest convex thing containing the
109    /// data.
110    Outline,
111    /// One point: the mean of the nodes.
112    ///
113    /// The other summary, and OSDU's `SpatialPoint`: *"a centroid point that
114    /// reflects the locale of the content"*.
115    ///
116    /// The mean of the **nodes**, which is not the centroid of the area - a
117    /// densely sampled corner pulls it. Saying so rather than implying a
118    /// precision this does not have; for "where is this on a map", at the scale
119    /// these are used, the difference is invisible.
120    Centroid,
121}
122
123impl Shape {
124    /// The name a caller writes on a command line.
125    pub fn name(&self) -> &'static str {
126        match self {
127            Shape::Auto => "auto",
128            Shape::Points => "points",
129            Shape::Lines => "lines",
130            Shape::Surface => "surface",
131            Shape::Volume => "volume",
132            Shape::Outline => "outline",
133            Shape::Centroid => "centroid",
134        }
135    }
136
137    /// Every shape there is, for a caller listing them.
138    pub fn all() -> [Shape; 7] {
139        [
140            Shape::Auto,
141            Shape::Points,
142            Shape::Lines,
143            Shape::Surface,
144            Shape::Volume,
145            Shape::Outline,
146            Shape::Centroid,
147        ]
148    }
149
150    /// Whether this shape summarises the object instead of drawing it.
151    ///
152    /// The two are different in a way a caller has to know about: a summary
153    /// **replaces the points**, so nothing that hangs on nodes or cells - a
154    /// property, a colour ramp - comes through it. Four points that say where a
155    /// horizon is cannot carry a value per node of the horizon.
156    pub fn is_summary(&self) -> bool {
157        matches!(self, Shape::Outline | Shape::Centroid)
158    }
159
160    /// Parse a name, case-insensitively.
161    ///
162    /// The plural and singular both work - someone writing `--as Grid=polygon`
163    /// means the same thing as `surface`, and refusing them over an `s` teaches
164    /// nothing.
165    pub fn parse(text: &str) -> Option<Shape> {
166        match text.trim().to_ascii_lowercase().as_str() {
167            "auto" | "default" | "natural" => Some(Shape::Auto),
168            "point" | "points" | "pointcloud" | "multipoint" => Some(Shape::Points),
169            "line" | "lines" | "polyline" | "polylines" | "multilinestring" => Some(Shape::Lines),
170            "surface" | "surfaces" | "polygon" | "polygons" | "multipolygon" | "face" | "faces" => {
171                Some(Shape::Surface)
172            }
173            "volume" | "volumes" | "cell" | "cells" | "solid" => Some(Shape::Volume),
174            "outline" | "hull" | "footprint" | "extent" | "area" => Some(Shape::Outline),
175            "centroid" | "centre" | "center" | "middle" => Some(Shape::Centroid),
176            _ => None,
177        }
178    }
179}
180
181impl std::fmt::Display for Shape {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.write_str(self.name())
184    }
185}
186
187impl Topology {
188    /// The richest form this topology supports.
189    pub fn natural_shape(&self) -> Shape {
190        match self {
191            Topology::Points => Shape::Points,
192            Topology::Polylines(_) | Topology::Segments(_) => Shape::Lines,
193            Topology::Triangles(_) | Topology::Quads(_) | Topology::Polygons(_) => Shape::Surface,
194            Topology::Grid { .. } => Shape::Surface,
195            Topology::Hexahedra { .. } | Topology::Polyhedra(_) => Shape::Volume,
196        }
197    }
198
199    /// Whether this topology already **is** the shape asked for.
200    ///
201    /// The one place that question is answered, so the fast path and the
202    /// conversion cannot disagree. [`Topology::Grid`] is the exception worth
203    /// naming: its natural shape is a surface, but it is not one *yet* - its
204    /// cells have to be derived from the lattice indexing first.
205    pub fn is_already(&self, shape: Shape) -> bool {
206        // A summary is never what an object already is: it replaces the points.
207        if shape.is_summary() || matches!(self, Topology::Grid { .. }) {
208            return false;
209        }
210        matches!(shape, Shape::Auto) || shape == self.natural_shape()
211    }
212
213    /// A word for this topology, for an error message a person has to act on.
214    pub fn describe(&self) -> &'static str {
215        match self {
216            Topology::Points => "a point set",
217            Topology::Triangles(_) => "a triangulated surface",
218            Topology::Quads(_) => "a quadrilateral surface",
219            Topology::Polygons(_) => "a polygonal surface",
220            Topology::Polylines(_) => "a set of polylines",
221            Topology::Segments(_) => "a wireframe of segments",
222            Topology::Grid { .. } => "a 2D grid",
223            Topology::Hexahedra { .. } => "a hexahedral grid",
224            Topology::Polyhedra(_) => "an unstructured grid",
225        }
226    }
227}
228
229impl Geometry {
230    /// Whether every patch can be drawn in this shape.
231    ///
232    /// Asked *before* an export starts, so a caller can report or skip rather
233    /// than find a half-written file - the same reason
234    /// `Exporter::handles` exists.
235    pub fn can_be(&self, shape: Shape) -> bool {
236        self.patches
237            .iter()
238            .all(|patch| reshape(&patch.topology, &patch.points, shape).is_ok())
239    }
240
241    /// This geometry in the shape asked for, or a refusal naming both ends.
242    ///
243    /// The points are untouched - every conversion here is arithmetic on
244    /// indices - so a node's position, and anything indexed on nodes, survives
245    /// unchanged. What changes is only what joins them.
246    pub fn as_shape(&self, shape: Shape) -> Result<Geometry> {
247        // A summary is about the **object**, not a patch of it: the hull of a
248        // surface cut into eleven patches is one hull, not eleven. So this one
249        // branch looks across all of them and returns a single patch, which is
250        // also why it is the only case where the points change.
251        if shape.is_summary() {
252            return self.summarise(shape);
253        }
254
255        let mut patches = Vec::with_capacity(self.patches.len());
256        for patch in &self.patches {
257            patches.push(Patch {
258                index: patch.index,
259                node_offset: patch.node_offset,
260                points: patch.points.clone(),
261                topology: reshape(&patch.topology, &patch.points, shape)?,
262                closed: Vec::new(),
263            });
264        }
265        Ok(Geometry {
266            kind: self.kind,
267            patches,
268        })
269    }
270
271    /// The cell edges, **indexed on the points [`Geometry::drawable`] gives**.
272    ///
273    /// # The bug this exists for
274    ///
275    /// Reported as *IJK grids with edges shooting off to the origin*,
276    /// and the reading was never the problem: a viewer drew the faces from
277    /// `drawable()` and the wireframe from `as_shape(Lines).drawable()`, and
278    /// **those two do not share a point list**.
279    ///
280    /// They diverge for exactly one reason, and it is a rule this crate applies
281    /// on purpose: a node of a *volume* that no cell names is not a location -
282    /// `CellGeometryIsDefined` marks cells undefined while their coordinates
283    /// stay perfectly finite in the file - so [`Geometry::drawable`] turns those
284    /// orphans into holes and drops them. Reshaping to segments first destroys
285    /// the volume topology, nothing is an orphan of a segment list, and every
286    /// node survives. On `h+g_PaleoGTM.epc` that is 37 772 points against the
287    /// mesh's 36 363, and **156 099 of the wireframe's indices addressed
288    /// vertices that were not there**.
289    ///
290    /// Out-of-range indices are not an error a viewer reports; WebGL reads
291    /// whatever the buffer holds, so the edges land somewhere - near the origin,
292    /// as it happened. A wrong picture with no message is this workspace's
293    /// least favourite kind of failure.
294    ///
295    /// # Why the orphans are holed first
296    ///
297    /// So that the same nodes are dropped, in the same order, by the same
298    /// compaction. The survivors of a reshaped geometry are then exactly the
299    /// survivors of the original, and an index means the same thing in both -
300    /// which is the property a caller drawing one buffer with two index lists
301    /// needs, and the one it cannot check for itself.
302    pub fn wireframe(&self) -> Result<Vec<[u32; 2]>> {
303        let holed = Geometry {
304            kind: self.kind,
305            patches: self
306                .patches
307                .iter()
308                .map(|patch| Patch {
309                    index: patch.index,
310                    node_offset: patch.node_offset,
311                    points: crate::geometry::orphans_as_holes(&patch.points, &patch.topology).into_owned(),
312                    topology: patch.topology.clone(),
313                    closed: Vec::new(),
314                })
315                .collect(),
316        };
317        Ok(holed.as_shape(Shape::Lines)?.drawable().lines)
318    }
319
320    /// One patch that says *where* this object is, rather than what it is.
321    fn summarise(&self, shape: Shape) -> Result<Geometry> {
322        let nodes: Vec<Point> = self
323            .patches
324            .iter()
325            .flat_map(|patch| patch.points.iter().copied())
326            .filter(crate::defined::is_defined)
327            .collect();
328        if nodes.is_empty() {
329            return Err(Error::shape("an object with no placed node", "a summary"));
330        }
331
332        let (points, topology) = match shape {
333            Shape::Centroid => {
334                let count = nodes.len() as f64;
335                let mean = |axis: usize| nodes.iter().map(|p| p[axis]).sum::<f64>() / count;
336                (vec![[mean(0), mean(1), mean(2)]], Topology::Points)
337            }
338            _ => {
339                let ring = hull(&nodes);
340                let indices: Vec<u32> = (0..ring.len() as u32).collect();
341                // Fewer than three points cannot bound an area - a straight
342                // line of nodes, or two of them. The nodes themselves are the
343                // honest answer, and a caller who asked for an area gets told
344                // by the geometry type rather than by a degenerate polygon.
345                let topology = if ring.len() >= 3 {
346                    Topology::Polygons(vec![indices])
347                } else {
348                    Topology::Points
349                };
350                (ring, topology)
351            }
352        };
353
354        Ok(Geometry {
355            kind: self.kind,
356            patches: vec![Patch {
357                index: 0,
358                node_offset: 0,
359                points,
360                topology,
361                closed: Vec::new(),
362            }],
363        })
364    }
365}
366
367/// The convex hull of a set of points, in XY, counterclockwise.
368///
369/// Andrew's monotone chain: sort, then sweep the lower and upper boundaries,
370/// dropping any point that does not turn left. `O(n log n)`, dominated by the
371/// sort, and exact in the sense that matters - every input point is inside or
372/// on the result, and no smaller convex polygon is.
373///
374/// Z is carried through from the point that contributed each corner rather than
375/// recomputed. A hull is a *plan* view, so its corners have no depth of their
376/// own; taking the one that was actually there beats inventing a mean.
377fn hull(points: &[Point]) -> Vec<Point> {
378    let mut sorted = points.to_vec();
379    sorted.sort_by(|a, b| a[0].total_cmp(&b[0]).then(a[1].total_cmp(&b[1])));
380    sorted.dedup_by(|a, b| a[0] == b[0] && a[1] == b[1]);
381    if sorted.len() < 3 {
382        return sorted;
383    }
384
385    // > 0 when o→a→b turns left.
386    let cross =
387        |o: &Point, a: &Point, b: &Point| (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
388
389    let mut chain: Vec<Point> = Vec::with_capacity(sorted.len() * 2);
390    for pass in 0..2 {
391        let start = chain.len();
392        let iter: Box<dyn Iterator<Item = &Point>> = if pass == 0 {
393            Box::new(sorted.iter())
394        } else {
395            Box::new(sorted.iter().rev())
396        };
397        for point in iter {
398            while chain.len() >= start + 2
399                && cross(&chain[chain.len() - 2], &chain[chain.len() - 1], point) <= 0.0
400            {
401                chain.pop();
402            }
403            chain.push(*point);
404        }
405        // The last point of each pass is the first of the next.
406        chain.pop();
407    }
408    chain
409}
410
411/// One patch's topology, in the shape asked for.
412/// The edges of a set of cells, each stored **once**.
413///
414/// Undirected: the shared edge of two neighbouring cells is one line, not two
415/// drawn on top of each other - the same de-duplication reasoning as a
416/// volume's boundary, one dimension down. The pair is normalised low-first for
417/// the lookup and kept in first-seen order in the output, so the result is
418/// stable for a stable input.
419fn unique_edges<'a>(cells: impl Iterator<Item = &'a [u32]>) -> Vec<[u32; 2]> {
420    let mut seen = std::collections::HashSet::new();
421    let mut out = Vec::new();
422    for ring in cells {
423        for at in 0..ring.len() {
424            let (a, b) = (ring[at], ring[(at + 1) % ring.len()]);
425            if a == b {
426                continue;
427            }
428            if seen.insert((a.min(b), a.max(b))) {
429                out.push([a, b]);
430            }
431        }
432    }
433    out
434}
435
436pub(crate) fn reshape(topology: &Topology, points: &[Point], shape: Shape) -> Result<Topology> {
437    let wanted = match shape {
438        Shape::Auto => topology.natural_shape(),
439        other => other,
440    };
441    if topology.is_already(wanted) {
442        return Ok(topology.clone());
443    }
444
445    match wanted {
446        // Always available: dropping what joins the nodes needs nothing from
447        // the file.
448        Shape::Points => Ok(Topology::Points),
449
450        // Only from polylines, and the reason is a limit worth stating rather
451        // than working around - until `Topology::Segments` existed. The edges
452        // are the **cells'**, never the triangulation's: a quad contributes
453        // four, not the diagonal its drawing splits it by. A volume goes
454        // through its boundary first, so its wireframe is the outside's edges,
455        // the same rule as its surface.
456        Shape::Lines => match topology {
457            Topology::Polylines(_) | Topology::Segments(_) => Ok(topology.clone()),
458            Topology::Triangles(cells) => Ok(Topology::Segments(unique_edges(
459                cells.iter().map(|cell| cell.as_slice()),
460            ))),
461            Topology::Quads(cells) => Ok(Topology::Segments(unique_edges(
462                cells.iter().map(|cell| cell.as_slice()),
463            ))),
464            Topology::Polygons(rings) => Ok(Topology::Segments(unique_edges(
465                rings.iter().map(|ring| ring.as_slice()),
466            ))),
467            Topology::Grid { fastest, slowest } => {
468                let cells = grid_cells(*fastest, *slowest, points);
469                Ok(Topology::Segments(unique_edges(
470                    cells.iter().map(|cell| cell.as_slice()),
471                )))
472            }
473            Topology::Hexahedra { cells, .. } => {
474                let boundary = boundary_of(cells, points);
475                Ok(Topology::Segments(unique_edges(
476                    boundary.iter().map(|cell| cell.as_slice()),
477                )))
478            }
479            Topology::Polyhedra(cells) => {
480                let boundary = cells.boundary();
481                Ok(Topology::Segments(unique_edges(
482                    boundary.iter().map(|ring| ring.as_slice()),
483                )))
484            }
485            _ => Err(refuse(topology, "lines")),
486        },
487
488        Shape::Surface => match topology {
489            Topology::Triangles(_) | Topology::Quads(_) | Topology::Polygons(_) => Ok(topology.clone()),
490            Topology::Grid { fastest, slowest } => {
491                Ok(Topology::Quads(grid_cells(*fastest, *slowest, points)))
492            }
493            Topology::Hexahedra { cells, .. } => Ok(Topology::Quads(boundary_of(cells, points))),
494            // The same rule one level of generality up. A hexahedron's faces
495            // are quadrilaterals and its sharing has to be rediscovered by
496            // hashing corner sets; an unstructured cell's faces are rings of
497            // any length and the file already says which cells meet at which
498            // face - so the boundary is counted, not reconstructed, and the
499            // result is `Polygons` because a face is not always four corners.
500            Topology::Polyhedra(cells) => Ok(Topology::Polygons(cells.boundary())),
501            _ => Err(refuse(topology, "a surface")),
502        },
503
504        Shape::Volume => match topology {
505            Topology::Hexahedra { .. } | Topology::Polyhedra(_) => Ok(topology.clone()),
506            _ => Err(refuse(topology, "a volume")),
507        },
508
509        // `Auto` was resolved above; the summaries never reach here, because
510        // `Geometry::as_shape` handles them across the whole object.
511        Shape::Auto | Shape::Outline | Shape::Centroid => Ok(topology.clone()),
512    }
513}
514
515/// The refusal, naming what was asked of what.
516///
517/// Both ends, always: a caller who wrote `--as PolylineSet=surface` needs to be
518/// told which rule of theirs met which object, not that "an export failed".
519fn refuse(topology: &Topology, wanted: &'static str) -> Error {
520    Error::shape(topology.describe(), wanted)
521}
522
523/// The cells of a 2D grid, four corners each, wound the right way round.
524///
525/// The corner indices come from the rule RESQML writes for a `Grid2dPatch` -
526/// *"Index = FastestIndex + FastestAxisCount × SlowestIndex"* - so nothing here
527/// is a convention this crate chose. The **winding** is not stated anywhere, so
528/// it is not assumed either: the signed area is measured and the ring reversed
529/// when it comes out negative, which is what RFC 7946 asks of a GeoJSON
530/// exterior ring and what a renderer reads as front-facing.
531///
532/// A cell whose corners fall outside the point array is skipped rather than
533/// clamped: the counts and the geometry disagreeing is a malformed file, and
534/// bending an index would draw a cell across the grid.
535fn grid_cells(fastest: usize, slowest: usize, points: &[Point]) -> Vec<[u32; 4]> {
536    if fastest < 2 || slowest < 2 {
537        return Vec::new();
538    }
539    let mut cells = Vec::with_capacity((fastest - 1) * (slowest - 1));
540    for j in 0..slowest - 1 {
541        for i in 0..fastest - 1 {
542            let corner = |di: usize, dj: usize| ((j + dj) * fastest + i + di) as u32;
543            let ring = [corner(0, 0), corner(1, 0), corner(1, 1), corner(0, 1)];
544            if ring.iter().any(|c| *c as usize >= points.len()) {
545                continue;
546            }
547            cells.push(wound(ring, points));
548        }
549    }
550    cells
551}
552
553/// A ring whose signed area in the XY plane is positive.
554///
555/// Measured rather than assumed - the same move `ijk::oriented` makes for a
556/// hexahedron, and for the same reason: the flags that would decide it a priori
557/// interact, and each combination is a chance to be wrong. A degenerate ring -
558/// zero area, a collapsed cell - is left as it came, because there is no
559/// orientation to find and reversing it would only be noise.
560fn wound(ring: [u32; 4], points: &[Point]) -> [u32; 4] {
561    let mut area = 0.0;
562    for corner in 0..4 {
563        let a = points[ring[corner] as usize];
564        let b = points[ring[(corner + 1) % 4] as usize];
565        area += a[0] * b[1] - b[0] * a[1];
566    }
567    if area < 0.0 {
568        [ring[3], ring[2], ring[1], ring[0]]
569    } else {
570        ring
571    }
572}
573
574/// The outside of a hexahedral grid: the faces used by exactly one cell.
575///
576/// Every face of every cell would draw the interior ones twice, back to back -
577/// which renders as z-fighting and multiplies a big grid's file size for
578/// nothing anyone can see. A face shared by two cells is interior *by
579/// definition*, so the boundary is the faces that appear once, and that is a
580/// count rather than a guess.
581///
582/// The six faces come from the corner order [`Topology::Hexahedra`] already
583/// guarantees (VTK's), so their winding is inherited rather than chosen.
584fn boundary_of(cells: &[[u32; 8]], points: &[Point]) -> Vec<[u32; 4]> {
585    // VTK's hexahedron: bottom 0-1-2-3, top 4-5-6-7.
586    const FACES: [[usize; 4]; 6] = [
587        [0, 3, 2, 1],
588        [4, 5, 6, 7],
589        [0, 1, 5, 4],
590        [1, 2, 6, 5],
591        [2, 3, 7, 6],
592        [3, 0, 4, 7],
593    ];
594
595    let mut seen: HashMap<[u32; 4], (usize, [u32; 4])> = HashMap::new();
596    for cell in cells {
597        for face in FACES {
598            let ring = [cell[face[0]], cell[face[1]], cell[face[2]], cell[face[3]]];
599            // The key ignores winding and starting corner, so the same face
600            // seen from either of its two cells hashes the same.
601            let mut key = ring;
602            key.sort_unstable();
603            let entry = seen.entry(key).or_insert((0, ring));
604            entry.0 += 1;
605        }
606    }
607    seen.into_values()
608        .filter(|(count, _)| *count == 1)
609        .map(|(_, ring)| wound(ring, points))
610        .collect()
611}