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}