Skip to main content

ogeom_mesh/
attach.rs

1//! Storing a tessellation back onto the model.
2//!
3//! [`triangulate`](crate::triangulate::triangulate) computes a mesh and hands it to the
4//! caller. This puts one on the shape itself, as a representation alongside the
5//! exact geometry (`docs/DATA_MODEL.md` ยง6): a polyline on each edge, a
6//! triangulation on each face.
7//!
8//! # Why cache at all
9//!
10//! A viewer redraws at sixty frames a second and cannot re-solve a NURBS patch
11//! each time. Data exchange writes the mesh, not the surface. Both want the
12//! same answer every time they ask, which a cache guarantees and recomputation
13//! does not: two calls with the same deflection can differ in their last bits,
14//! and a display that flickers along a shared edge is the visible result.
15//!
16//! # Why the polyline keeps its parameters
17//!
18//! An edge's cached polyline and the boundary of a face's cached triangulation
19//! have to be the same points, or the stored form has gaps where the exact
20//! geometry has none. They agree because both come from the edge's 3D curve at
21//! one set of parameters, so the parameters are stored, not just the points.
22
23use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
24use ogeom_topo::{
25    EdgeRepr, Filter, Model, NodeData, Shape, ShapeType, Triangulation, explore_unique,
26};
27
28use crate::discretize::{Deflection, Polyline};
29
30/// What a tessellation pass produced.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct Tessellated {
33    /// How many faces received a triangulation.
34    pub faces: usize,
35    /// How many edges received a polyline.
36    pub edges: usize,
37    /// How many triangles were produced in total.
38    pub triangles: usize,
39    /// Whether every face and edge met the requested deflection.
40    ///
41    /// `false` says the stored mesh is coarser than asked for, which a caller
42    /// about to quote a tolerance needs to know.
43    pub deflection_met: bool,
44}
45
46/// Tessellate every face and edge below `shape`, storing the result on the
47/// model.
48///
49/// Replaces any tessellation already stored: a cache built to a different
50/// deflection is not the one that was asked for. Faces, edges and vertices
51/// below `shape` that other shapes hold as well are copied first
52/// ([`Model::unshare`]), so their stored tessellations stay as they are.
53///
54/// # Errors
55///
56/// As [`triangulate_face`](crate::triangulate::triangulate_face), plus
57/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
58/// resolve.
59pub fn tessellate(
60    model: &mut Model,
61    shape: &Shape,
62    deflection: Deflection,
63    tol: Tolerances,
64) -> OgeomResult<Tessellated> {
65    deflection.validate()?;
66    model.unshare(shape)?;
67    let mut done = Tessellated {
68        faces: 0,
69        edges: 0,
70        triangles: 0,
71        deflection_met: true,
72    };
73
74    // Every face drawn to the chords the faces agree to draw their shared
75    // edges to (a narrow face's edges finer than asked, and the faces
76    // across them the same) in one pass that yields the meshes and the
77    // chords together, the chords then serving the polylines too.
78    ogeom_core::progress::stage("tessellate: faces");
79    let faces: Vec<Shape> = ogeom_topo::explore(model, shape, Filter::OfType(ShapeType::Face))?
80        .into_iter()
81        .collect();
82    let (meshes, chords) = crate::triangulate::face_meshes(model, &faces, deflection, None, tol)?;
83    let along = |edge: &Shape| -> Deflection {
84        match chords.get(&edge.node().index()) {
85            Some(chord) => Deflection {
86                chord: *chord,
87                ..deflection
88            },
89            None => deflection,
90        }
91    };
92
93    // Edges first. A face's triangulation is built from its boundary edges, so
94    // doing them in the other order would store a face mesh whose boundary the
95    // edge polylines then contradict.
96    ogeom_core::progress::stage("tessellate: edges");
97    // Each edge's polyline is kept by node for the faces' paths below: the
98    // discretization is deterministic, so a face asking again would get
99    // these same points.
100    let edges = explore_unique(model, shape, ShapeType::Edge)?;
101    let edge_total = edges.len() as u64;
102    let mut lines: ogeom_core::FastMap<u32, Polyline> = ogeom_core::FastMap::default();
103    for (at, edge) in edges.into_iter().enumerate() {
104        ogeom_core::progress::checkpoint()?;
105        ogeom_core::progress::stage_at("tessellate: edges", at as u64 + 1, edge_total);
106        let deflection = along(&edge);
107        if let Some(line) = edge_line(model, &edge, deflection, tol)? {
108            lines
109                .entry(edge.node().index())
110                .or_insert_with(|| line.clone());
111            store_polyline(model, &edge, line, deflection)?;
112            done.edges += 1;
113        }
114    }
115
116    // Faces in two phases: the expensive computation (triangulation and the
117    // edge paths through it) reads the model immutably and runs in parallel,
118    // one result slot per face in face order; the attachment then mutates
119    // sequentially in that same order. The split is what makes the output
120    // bit-identical at any thread count: nothing about scheduling can reach
121    // the model.
122    let read_model: &Model = model;
123    // Counted with an atomic because the workers finish in their own order:
124    // each announcement carries a distinct `done`, all of them reach the
125    // sink exactly once, and the consumer's bar may briefly see them out of
126    // sequence, which is what completion order means.
127    let face_total = faces.len() as u64;
128    let faces_done = std::sync::atomic::AtomicU64::new(0);
129    type FaceWork = (Triangulation, Vec<(Shape, Vec<u32>)>);
130    // The meshes are handed over by the job that matches their edges; a
131    // shared slice cannot give them away, so each sits behind a lock it is
132    // taken from once.
133    let jobs: Vec<(&Shape, std::sync::Mutex<Option<OgeomResult<Triangulation>>>)> = faces
134        .iter()
135        .zip(meshes)
136        .map(|(face, mesh)| (face, std::sync::Mutex::new(Some(mesh))))
137        .collect();
138    let computed: Vec<OgeomResult<FaceWork>> =
139        ogeom_core::parallel::map_ordered(&jobs, |_, (face, slot)| {
140            ogeom_core::progress::checkpoint()?;
141            let face: &Shape = face;
142            let mesh = slot
143                .lock()
144                .ok()
145                .and_then(|mut held| held.take())
146                .unwrap_or_else(|| {
147                    Err(ogeom_core::ogeom_err!(
148                        Construction,
149                        "a face's mesh was taken twice"
150                    ))
151                })?;
152            ogeom_core::progress::stage_at(
153                "tessellate: faces",
154                faces_done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1,
155                face_total,
156            );
157
158            // Each boundary edge's path through this mesh, as node indices:
159            // the PolygonOnTriangulation representation. Matched while the
160            // mesh is still owned, attached after it is stored.
161            let mut paths: Vec<(Shape, Vec<u32>)> = Vec::new();
162            let mut seen: Vec<(ogeom_topo::TShapeId, ogeom_topo::Location)> = Vec::new();
163            let index = MeshIndex::of(&mesh);
164            for edge in ogeom_topo::explore(read_model, face, Filter::OfType(ShapeType::Edge))? {
165                let key = (edge.node(), edge.location().clone());
166                if seen.contains(&key) {
167                    continue;
168                }
169                seen.push(key);
170                // An edge with no 3D curve has no polyline and no path.
171                let Some(line) = lines.get(&edge.node().index()) else {
172                    continue;
173                };
174                if line.points.len() < 2 {
175                    continue;
176                }
177                let placement = edge.transform(read_model.datums())?;
178                let mut points: Vec<ogeom_math::Point> =
179                    line.points.iter().map(|p| placement.apply(*p)).collect();
180                if edge.orientation() == ogeom_topo::Orientation::Reversed {
181                    points.reverse();
182                }
183                if let Some(indices) =
184                    index.path(&mesh, &points, edge_reach(read_model, &edge, tol))
185                {
186                    paths.push((edge, indices));
187                }
188            }
189            Ok((mesh, paths))
190        });
191
192    for (face, work) in faces.iter().zip(computed) {
193        let face = face.clone();
194        let (mesh, paths) = work?;
195        done.triangles += mesh.triangle_count();
196        done.deflection_met &= mesh.deflection_met;
197
198        let id = model.geometry_mut().add_triangulation(mesh);
199        for (edge, indices) in paths {
200            let Some(node) = model.node_mut(&edge) else {
201                continue;
202            };
203            let NodeData::Edge(data) = node.data_mut() else {
204                continue;
205            };
206            data.representations.push(EdgeRepr::PolygonOnTriangulation {
207                triangulation: id,
208                indices,
209                location: edge.location().clone(),
210            });
211        }
212
213        let Some(node) = model.node_mut(&face) else {
214            ogeom_bail!(Dangling, "face is not in this model");
215        };
216        let NodeData::Face(data) = node.data_mut() else {
217            ogeom_bail!(Construction, "face node holds no face data");
218        };
219        data.triangulation = Some(id);
220        done.faces += 1;
221    }
222    Ok(done)
223}
224
225/// How far a polyline point may sit from its mesh node and still be it:
226/// the edge's own recorded tolerance, floored at a resolution the weld uses.
227fn edge_reach(model: &Model, edge: &Shape, tol: Tolerances) -> f64 {
228    let recorded = model
229        .node(edge)
230        .and_then(|n| n.data().as_edge())
231        .map_or(0.0, |d| d.tolerance.get());
232    recorded.max(tol.confusion() * 1e3)
233}
234
235/// A face mesh's nodes by position and its triangle edges, for walking
236/// the face's edges through it.
237struct MeshIndex {
238    /// Every node at each position, by the bits of its coordinates, in
239    /// node order.
240    by_bits: ogeom_core::FastMap<[u64; 3], Vec<u32>>,
241    /// Every triangle edge, as (smaller, larger) node index.
242    adjacent: ogeom_core::FastSet<(u32, u32)>,
243}
244
245impl MeshIndex {
246    fn of(mesh: &Triangulation) -> Self {
247        let mut by_bits: ogeom_core::FastMap<[u64; 3], Vec<u32>> = ogeom_core::FastMap::default();
248        by_bits.reserve(mesh.positions.len());
249        for (i, p) in mesh.positions.iter().enumerate() {
250            #[allow(clippy::cast_possible_truncation)]
251            by_bits.entry(bits(*p)).or_default().push(i as u32);
252        }
253        let mut adjacent: ogeom_core::FastSet<(u32, u32)> = ogeom_core::FastSet::default();
254        adjacent.reserve(mesh.triangles.len() * 3 / 2);
255        for t in &mesh.triangles {
256            for i in 0..3 {
257                let (a, b) = (t[i], t[(i + 1) % 3]);
258                adjacent.insert((a.min(b), a.max(b)));
259            }
260        }
261        Self { by_bits, adjacent }
262    }
263
264    /// The nodes standing for `p`: those at exactly its position, else
265    /// every node within `reach`, nearest first.
266    fn candidates<'a>(
267        &'a self,
268        mesh: &Triangulation,
269        p: ogeom_math::Point,
270        reach: f64,
271    ) -> std::borrow::Cow<'a, [u32]> {
272        if let Some(exact) = self.by_bits.get(&bits(p)) {
273            return std::borrow::Cow::Borrowed(exact);
274        }
275        let mut near: Vec<(f64, u32)> = Vec::new();
276        for (i, q) in mesh.positions.iter().enumerate() {
277            let d = q.distance(p);
278            if d <= reach {
279                #[allow(clippy::cast_possible_truncation)]
280                near.push((d, i as u32));
281            }
282        }
283        near.sort_by(|a, b| a.0.total_cmp(&b.0));
284        std::borrow::Cow::Owned(near.into_iter().map(|(_, i)| i).collect())
285    }
286
287    /// The polyline's node indices in the mesh, chosen so consecutive
288    /// indices are triangle edges.
289    ///
290    /// A position may name several nodes (a seam's two chart columns lift
291    /// to the same points), so matching by position alone can jump between
292    /// the copies. Candidates come from position (exact bits, else within
293    /// `reach`), and the walk picks, at each step, a candidate adjacent in
294    /// the mesh to the one before it; the first point tries each of its
295    /// candidates as a start. `None` if no adjacency-respecting path exists.
296    fn path(
297        &self,
298        mesh: &Triangulation,
299        points: &[ogeom_math::Point],
300        reach: f64,
301    ) -> Option<Vec<u32>> {
302        let walk = |start: u32| -> Option<Vec<u32>> {
303            let mut out = Vec::with_capacity(points.len());
304            out.push(start);
305            for p in &points[1..] {
306                let previous = *out.last()?;
307                let next = self
308                    .candidates(mesh, *p, reach)
309                    .iter()
310                    .copied()
311                    .find(|&c| self.adjacent.contains(&(previous.min(c), previous.max(c))))?;
312                out.push(next);
313            }
314            Some(out)
315        };
316        self.candidates(mesh, *points.first()?, reach)
317            .iter()
318            .copied()
319            .find_map(walk)
320    }
321}
322
323/// A position's coordinates as bits, the key exact matching uses.
324fn bits(p: ogeom_math::Point) -> [u64; 3] {
325    [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
326}
327
328/// The triangulation stored on a face, if one has been built.
329#[must_use]
330pub fn triangulation_of<'a>(model: &'a Model, face: &Shape) -> Option<&'a Triangulation> {
331    let NodeData::Face(data) = model.node(face)?.data() else {
332        return None;
333    };
334    model.geometry().triangulation(data.triangulation?)
335}
336
337/// The polyline stored on an edge, if one has been built.
338#[must_use]
339pub fn polyline_of(model: &Model, edge: &Shape) -> Option<(Vec<ogeom_math::Point>, Vec<f64>)> {
340    let NodeData::Edge(data) = model.node(edge)?.data() else {
341        return None;
342    };
343    data.representations.iter().find_map(|repr| match repr {
344        EdgeRepr::Polyline {
345            points, parameters, ..
346        } => Some((points.clone(), parameters.clone())),
347        _ => None,
348    })
349}
350
351/// An edge's polyline, the points its faces draw it through
352/// ([`edge_polyline`](crate::triangulate::edge_polyline)), or `None` for
353/// an edge with no 3D curve (a degenerate edge at a cone's apex has nothing
354/// to discretize).
355fn edge_line(
356    model: &Model,
357    edge: &Shape,
358    deflection: Deflection,
359    tol: Tolerances,
360) -> OgeomResult<Option<Polyline>> {
361    let Some(node) = model.node(edge) else {
362        ogeom_bail!(Dangling, "edge is not in this model");
363    };
364    let NodeData::Edge(data) = node.data() else {
365        ogeom_bail!(Construction, "edge node holds no edge data");
366    };
367    crate::triangulate::edge_polyline(model, data, deflection, tol)
368}
369
370/// Store a polyline on an edge, replacing any earlier one and the paths
371/// through face meshes that came with it.
372fn store_polyline(
373    model: &mut Model,
374    edge: &Shape,
375    line: Polyline,
376    deflection: Deflection,
377) -> OgeomResult<()> {
378    let Some(node) = model.node_mut(edge) else {
379        ogeom_bail!(Dangling, "edge is not in this model");
380    };
381    let NodeData::Edge(data) = node.data_mut() else {
382        ogeom_bail!(Construction, "edge node holds no edge data");
383    };
384    data.representations.retain(|repr| {
385        !matches!(
386            repr,
387            EdgeRepr::Polyline { .. } | EdgeRepr::PolygonOnTriangulation { .. }
388        )
389    });
390    data.add(EdgeRepr::Polyline {
391        points: line.points,
392        parameters: line.parameters,
393        location: ogeom_topo::Location::identity(),
394        deflection: deflection.chord,
395    });
396    Ok(())
397}
398
399#[cfg(test)]
400#[allow(clippy::unwrap_used, clippy::expect_used)]
401mod tests {
402    use super::*;
403    use ogeom_algo::make_box;
404    use ogeom_math::Frame;
405
406    const T: Tolerances = Tolerances::millimetres();
407
408    fn fine() -> Deflection {
409        Deflection {
410            chord: 1e-3,
411            angular: 0.05,
412            ..Deflection::default()
413        }
414    }
415
416    #[test]
417    fn tessellating_a_box_stores_a_mesh_on_every_face_and_edge() {
418        let mut model = Model::new();
419        let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
420
421        let done = tessellate(&mut model, &built.shape, fine(), T).unwrap();
422        assert_eq!(done.faces, 6);
423        assert_eq!(done.edges, 12);
424        assert_eq!(done.triangles, 12);
425        assert!(done.deflection_met);
426
427        for face in explore_unique(&model, &built.shape, ShapeType::Face).unwrap() {
428            let mesh = triangulation_of(&model, &face).expect("face has no triangulation");
429            assert_eq!(mesh.triangle_count(), 2);
430        }
431        for edge in explore_unique(&model, &built.shape, ShapeType::Edge).unwrap() {
432            let (points, parameters) = polyline_of(&model, &edge).expect("edge has no polyline");
433            assert_eq!(points.len(), parameters.len());
434            assert_eq!(points.len(), 2, "a straight edge is its own polyline");
435        }
436    }
437
438    #[test]
439    fn the_cached_boundary_agrees_with_the_cached_faces() {
440        // The point of storing the polyline's parameters. If the two caches
441        // were built independently they would disagree by a hair along every
442        // shared edge, and the stored form would have gaps the exact geometry
443        // does not.
444        let mut model = Model::new();
445        let built = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
446        tessellate(&mut model, &built.shape, fine(), T).unwrap();
447
448        for edge in explore_unique(&model, &built.shape, ShapeType::Edge).unwrap() {
449            let (points, _) = polyline_of(&model, &edge).unwrap();
450            for face in
451                ogeom_topo::ancestors_of(&model, &built.shape, &edge, ShapeType::Face).unwrap()
452            {
453                let mesh = triangulation_of(&model, &face).unwrap();
454                for p in &points {
455                    assert!(
456                        mesh.positions.iter().any(|q| q.is_equal(*p, T)),
457                        "the face's mesh has no vertex at {p:?}, which its edge's \
458                         polyline passes through"
459                    );
460                }
461            }
462        }
463    }
464
465    #[test]
466    fn a_narrow_face_draws_its_edges_finer_and_its_neighbours_agree() {
467        // A disc a fraction of a chord thick: its rim is narrower than the
468        // chord, so it draws its circles finer than asked. The two flat
469        // faces share those circles and must draw them to the same points,
470        // or the stored meshes, assembled face by face, crack along the
471        // rim, and the stored polylines would side with one face or the
472        // other.
473        let mut model = Model::new();
474        let coarse = Deflection {
475            chord: 1.0,
476            ..Deflection::default()
477        };
478        let disc = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 10.0, 0.5, T).unwrap();
479        tessellate(&mut model, &disc.shape, coarse, T).unwrap();
480
481        let mut refined = 0;
482        for edge in explore_unique(&model, &disc.shape, ShapeType::Edge).unwrap() {
483            let (points, _) = polyline_of(&model, &edge).unwrap();
484            let alone = crate::triangulate::polyline_of_edge(&model, &edge, coarse, T).unwrap();
485            if points.len() > alone.len() {
486                refined += 1;
487            }
488            for face in
489                ogeom_topo::ancestors_of(&model, &disc.shape, &edge, ShapeType::Face).unwrap()
490            {
491                let mesh = triangulation_of(&model, &face).unwrap();
492                for p in &points {
493                    assert!(
494                        mesh.positions.iter().any(|q| q.is_equal(*p, T)),
495                        "the face's mesh has no vertex at {p:?}, which its edge's \
496                         polyline passes through"
497                    );
498                }
499            }
500        }
501        assert!(
502            refined >= 2,
503            "the rim's circles are drawn finer than the coarse chord asks"
504        );
505    }
506
507    #[test]
508    fn tessellating_again_replaces_rather_than_accumulates() {
509        // A cache built to a different deflection is not the one that was
510        // asked for, and keeping both would leave the reader picking.
511        let mut model = Model::new();
512        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
513
514        tessellate(&mut model, &built.shape, Deflection::default(), T).unwrap();
515        tessellate(&mut model, &built.shape, fine(), T).unwrap();
516
517        for edge in explore_unique(&model, &built.shape, ShapeType::Edge).unwrap() {
518            let NodeData::Edge(data) = model.node(&edge).unwrap().data() else {
519                unreachable!()
520            };
521            let polylines = data
522                .representations
523                .iter()
524                .filter(|r| matches!(r, EdgeRepr::Polyline { .. }))
525                .count();
526            assert_eq!(polylines, 1, "the earlier polyline was left behind");
527        }
528    }
529
530    #[test]
531    fn a_shape_with_no_faces_tessellates_to_nothing_rather_than_failing() {
532        let mut model = Model::new();
533        let vertex = model.add_point(ogeom_math::Point::ORIGIN);
534        let done = tessellate(&mut model, &vertex, fine(), T).unwrap();
535        assert_eq!(done.faces, 0);
536        assert_eq!(done.edges, 0);
537        assert_eq!(done.triangles, 0);
538        assert!(done.deflection_met);
539        assert!(triangulation_of(&model, &vertex).is_none());
540        assert!(polyline_of(&model, &vertex).is_none());
541    }
542
543    #[test]
544    fn an_unusable_deflection_is_refused_before_anything_is_stored() {
545        let mut model = Model::new();
546        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
547        let bad = Deflection {
548            chord: 0.0,
549            ..Deflection::default()
550        };
551        assert!(tessellate(&mut model, &built.shape, bad, T).is_err());
552
553        let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
554        assert!(triangulation_of(&model, &face).is_none());
555    }
556}
557#[cfg(test)]
558#[allow(clippy::unwrap_used)]
559mod polygon_on_tests {
560    use super::*;
561    use ogeom_core::Tolerances;
562    use ogeom_math::Frame;
563    use ogeom_topo::{EdgeRepr, Filter, ShapeType, explore};
564
565    const T: Tolerances = Tolerances::millimetres();
566
567    fn fine() -> Deflection {
568        Deflection {
569            chord: 1e-2,
570            ..Deflection::default()
571        }
572    }
573
574    #[test]
575    fn every_edge_walks_its_faces_triangulations_by_index() {
576        let mut model = Model::new();
577        let solid = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
578        tessellate(&mut model, &solid.shape, fine(), T).unwrap();
579
580        let mut checked = 0;
581        for face in explore(&model, &solid.shape, Filter::OfType(ShapeType::Face)).unwrap() {
582            let mesh_id = {
583                let ogeom_topo::NodeData::Face(data) = model.node(&face).unwrap().data() else {
584                    panic!("face data");
585                };
586                data.triangulation.unwrap()
587            };
588            let mesh = model.geometry().triangulation(mesh_id).unwrap();
589            // Triangle edge set for the adjacency check.
590            let mut edges_of = ogeom_core::FastSet::default();
591            for t in &mesh.triangles {
592                for i in 0..3 {
593                    let (a, b) = (t[i], t[(i + 1) % 3]);
594                    edges_of.insert((a.min(b), a.max(b)));
595                }
596            }
597            for edge in explore(&model, &face, Filter::OfType(ShapeType::Edge)).unwrap() {
598                let data = model.node(&edge).unwrap().data().as_edge().unwrap();
599                if data.degenerate {
600                    continue;
601                }
602                let paths: Vec<&Vec<u32>> = data
603                    .representations
604                    .iter()
605                    .filter_map(|r| match r {
606                        EdgeRepr::PolygonOnTriangulation {
607                            triangulation,
608                            indices,
609                            ..
610                        } if *triangulation == mesh_id => Some(indices),
611                        _ => None,
612                    })
613                    .collect();
614                assert!(
615                    !paths.is_empty(),
616                    "an edge of a tessellated face walks its triangulation"
617                );
618                for indices in paths {
619                    assert!(indices.len() >= 2);
620                    for pair in indices.windows(2) {
621                        let key = (pair[0].min(pair[1]), pair[0].max(pair[1]));
622                        assert!(
623                            edges_of.contains(&key),
624                            "consecutive indices are a triangle edge of the mesh: \
625                             {:?} at {:?} and {:?}",
626                            pair,
627                            mesh.positions[pair[0] as usize],
628                            mesh.positions[pair[1] as usize]
629                        );
630                    }
631                    checked += 1;
632                }
633            }
634        }
635        assert!(checked >= 6, "rings, seam sides and rims all walked");
636    }
637
638    #[test]
639    fn each_path_runs_through_the_points_the_edge_draws_on_its_own() {
640        // A face's path is matched from the polyline stored on the edge,
641        // carried into the face's placement and direction. It must name
642        // the same positions, bit for bit, the edge discretized alone in
643        // that occurrence gives, or the stored path and the stored
644        // polyline describe two different boundaries.
645        let mut model = Model::new();
646        let solid = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
647        tessellate(&mut model, &solid.shape, fine(), T).unwrap();
648
649        let mut compared = 0;
650        for face in explore(&model, &solid.shape, Filter::OfType(ShapeType::Face)).unwrap() {
651            let mesh_id = model
652                .node(&face)
653                .unwrap()
654                .data()
655                .as_face()
656                .unwrap()
657                .triangulation;
658            let mesh = model.geometry().triangulation(mesh_id.unwrap()).unwrap();
659            for edge in explore(&model, &face, Filter::OfType(ShapeType::Edge)).unwrap() {
660                let alone = crate::triangulate::polyline_of_edge(&model, &edge, fine(), T).unwrap();
661                let data = model.node(&edge).unwrap().data().as_edge().unwrap();
662                for repr in &data.representations {
663                    let EdgeRepr::PolygonOnTriangulation {
664                        triangulation,
665                        indices,
666                        ..
667                    } = repr
668                    else {
669                        continue;
670                    };
671                    if Some(*triangulation) != mesh_id {
672                        continue;
673                    }
674                    let walked: Vec<_> = indices
675                        .iter()
676                        .map(|&i| mesh.positions[i as usize])
677                        .collect();
678                    let forward = walked == alone;
679                    let backward = walked.iter().rev().eq(alone.iter());
680                    assert!(forward || backward, "a stored path left its edge's points");
681                    compared += 1;
682                }
683            }
684        }
685        assert!(compared >= 6, "every face's edges compared");
686    }
687}