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, discretize};
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.
51///
52/// # Errors
53///
54/// As [`triangulate_face`](crate::triangulate::triangulate_face), plus
55/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if a handle fails to
56/// resolve.
57pub fn tessellate(
58    model: &mut Model,
59    shape: &Shape,
60    deflection: Deflection,
61    tol: Tolerances,
62) -> OgeomResult<Tessellated> {
63    deflection.validate()?;
64    let mut done = Tessellated {
65        faces: 0,
66        edges: 0,
67        triangles: 0,
68        deflection_met: true,
69    };
70
71    // Every face drawn to the chords the faces agree to draw their shared
72    // edges to (a narrow face's edges finer than asked, and the faces
73    // across them the same) in one pass that yields the meshes and the
74    // chords together, the chords then serving the polylines too.
75    ogeom_core::progress::stage("tessellate: faces");
76    let faces: Vec<Shape> = ogeom_topo::explore(model, shape, Filter::OfType(ShapeType::Face))?
77        .into_iter()
78        .collect();
79    let (meshes, chords) = crate::triangulate::face_meshes(model, &faces, deflection, tol)?;
80    let along = |edge: &Shape| -> Deflection {
81        match chords.get(&edge.node().index()) {
82            Some(chord) => Deflection {
83                chord: *chord,
84                ..deflection
85            },
86            None => deflection,
87        }
88    };
89
90    // Edges first. A face's triangulation is built from its boundary edges, so
91    // doing them in the other order would store a face mesh whose boundary the
92    // edge polylines then contradict.
93    ogeom_core::progress::stage("tessellate: edges");
94    let edges = explore_unique(model, shape, ShapeType::Edge)?;
95    let edge_total = edges.len() as u64;
96    for (at, edge) in edges.into_iter().enumerate() {
97        ogeom_core::progress::checkpoint()?;
98        ogeom_core::progress::stage_at("tessellate: edges", at as u64 + 1, edge_total);
99        if attach_polyline(model, &edge, along(&edge), tol)? {
100            done.edges += 1;
101        }
102    }
103
104    // Faces in two phases: the expensive computation (triangulation and the
105    // edge paths through it) reads the model immutably and runs in parallel,
106    // one result slot per face in face order; the attachment then mutates
107    // sequentially in that same order. The split is what makes the output
108    // bit-identical at any thread count: nothing about scheduling can reach
109    // the model.
110    let read_model: &Model = model;
111    // Counted with an atomic because the workers finish in their own order:
112    // each announcement carries a distinct `done`, all of them reach the
113    // sink exactly once, and the consumer's bar may briefly see them out of
114    // sequence, which is what completion order means.
115    let face_total = faces.len() as u64;
116    let faces_done = std::sync::atomic::AtomicU64::new(0);
117    type FaceWork = (Triangulation, Vec<(Shape, Vec<u32>)>);
118    // The meshes are handed over by the job that matches their edges; a
119    // shared slice cannot give them away, so each sits behind a lock it is
120    // taken from once.
121    let jobs: Vec<(&Shape, std::sync::Mutex<Option<OgeomResult<Triangulation>>>)> = faces
122        .iter()
123        .zip(meshes)
124        .map(|(face, mesh)| (face, std::sync::Mutex::new(Some(mesh))))
125        .collect();
126    let computed: Vec<OgeomResult<FaceWork>> =
127        ogeom_core::parallel::map_ordered(&jobs, |_, (face, slot)| {
128            ogeom_core::progress::checkpoint()?;
129            let face: &Shape = face;
130            let mesh = slot
131                .lock()
132                .ok()
133                .and_then(|mut held| held.take())
134                .unwrap_or_else(|| {
135                    Err(ogeom_core::ogeom_err!(
136                        Construction,
137                        "a face's mesh was taken twice"
138                    ))
139                })?;
140            ogeom_core::progress::stage_at(
141                "tessellate: faces",
142                faces_done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1,
143                face_total,
144            );
145
146            // Each boundary edge's path through this mesh, as node indices:
147            // the PolygonOnTriangulation representation. Matched while the
148            // mesh is still owned, attached after it is stored.
149            let mut paths: Vec<(Shape, Vec<u32>)> = Vec::new();
150            let mut seen: Vec<(ogeom_topo::TShapeId, ogeom_topo::Location)> = Vec::new();
151            for edge in ogeom_topo::explore(read_model, face, Filter::OfType(ShapeType::Edge))? {
152                let key = (edge.node(), edge.location().clone());
153                if seen.contains(&key) {
154                    continue;
155                }
156                seen.push(key);
157                let points =
158                    crate::triangulate::polyline_of_edge(read_model, &edge, along(&edge), tol)?;
159                if points.len() < 2 {
160                    continue;
161                }
162                if let Some(indices) =
163                    index_path(&mesh, &points, edge_reach(read_model, &edge, tol))
164                {
165                    paths.push((edge, indices));
166                }
167            }
168            Ok((mesh, paths))
169        });
170
171    for (face, work) in faces.iter().zip(computed) {
172        let face = face.clone();
173        let (mesh, paths) = work?;
174        done.triangles += mesh.triangle_count();
175        done.deflection_met &= mesh.deflection_met;
176
177        let id = model.geometry_mut().add_triangulation(mesh);
178        for (edge, indices) in paths {
179            let Some(node) = model.node_mut(&edge) else {
180                continue;
181            };
182            let NodeData::Edge(data) = node.data_mut() else {
183                continue;
184            };
185            data.representations.push(EdgeRepr::PolygonOnTriangulation {
186                triangulation: id,
187                indices,
188                location: edge.location().clone(),
189            });
190        }
191
192        let Some(node) = model.node_mut(&face) else {
193            ogeom_bail!(Dangling, "face is not in this model");
194        };
195        let NodeData::Face(data) = node.data_mut() else {
196            ogeom_bail!(Construction, "face node holds no face data");
197        };
198        data.triangulation = Some(id);
199        done.faces += 1;
200    }
201    Ok(done)
202}
203
204/// How far a polyline point may sit from its mesh node and still be it:
205/// the edge's own recorded tolerance, floored at a resolution the weld uses.
206fn edge_reach(model: &Model, edge: &Shape, tol: Tolerances) -> f64 {
207    let recorded = model
208        .node(edge)
209        .and_then(|n| n.data().as_edge())
210        .map_or(0.0, |d| d.tolerance.get());
211    recorded.max(tol.confusion() * 1e3)
212}
213
214/// The polyline's node indices in the mesh, chosen so consecutive indices
215/// are triangle edges.
216///
217/// A position may name several nodes (a seam's two chart columns lift to
218/// the same points), so matching by position alone can jump between the
219/// copies. Candidates come from position (exact bits, else within `reach`),
220/// and the walk picks, at each step, a candidate adjacent in the mesh to the
221/// one before it; the first point tries each of its candidates as a start.
222/// `None` if no adjacency-respecting path exists.
223fn index_path(mesh: &Triangulation, points: &[ogeom_math::Point], reach: f64) -> Option<Vec<u32>> {
224    use std::collections::{HashMap, HashSet};
225    let mut by_bits: HashMap<[u64; 3], Vec<u32>> = HashMap::new();
226    for (i, p) in mesh.positions.iter().enumerate() {
227        #[allow(clippy::cast_possible_truncation)]
228        by_bits
229            .entry([p.x.to_bits(), p.y.to_bits(), p.z.to_bits()])
230            .or_default()
231            .push(i as u32);
232    }
233    let mut adjacent: HashSet<(u32, u32)> = HashSet::new();
234    for t in &mesh.triangles {
235        for i in 0..3 {
236            let (a, b) = (t[i], t[(i + 1) % 3]);
237            adjacent.insert((a.min(b), a.max(b)));
238        }
239    }
240    let candidates = |p: &ogeom_math::Point| -> Vec<u32> {
241        if let Some(exact) = by_bits.get(&[p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]) {
242            return exact.clone();
243        }
244        let mut near: Vec<(f64, u32)> = Vec::new();
245        for (i, q) in mesh.positions.iter().enumerate() {
246            let d = q.distance(*p);
247            if d <= reach {
248                #[allow(clippy::cast_possible_truncation)]
249                near.push((d, i as u32));
250            }
251        }
252        near.sort_by(|a, b| a.0.total_cmp(&b.0));
253        near.into_iter().map(|(_, i)| i).collect()
254    };
255
256    let walk = |start: u32| -> Option<Vec<u32>> {
257        let mut out = vec![start];
258        for p in &points[1..] {
259            let previous = *out.last()?;
260            let next = candidates(p)
261                .into_iter()
262                .find(|&c| adjacent.contains(&(previous.min(c), previous.max(c))))?;
263            out.push(next);
264        }
265        Some(out)
266    };
267    candidates(points.first()?).into_iter().find_map(walk)
268}
269
270/// The triangulation stored on a face, if one has been built.
271#[must_use]
272pub fn triangulation_of<'a>(model: &'a Model, face: &Shape) -> Option<&'a Triangulation> {
273    let NodeData::Face(data) = model.node(face)?.data() else {
274        return None;
275    };
276    model.geometry().triangulation(data.triangulation?)
277}
278
279/// The polyline stored on an edge, if one has been built.
280#[must_use]
281pub fn polyline_of(model: &Model, edge: &Shape) -> Option<(Vec<ogeom_math::Point>, Vec<f64>)> {
282    let NodeData::Edge(data) = model.node(edge)?.data() else {
283        return None;
284    };
285    data.representations.iter().find_map(|repr| match repr {
286        EdgeRepr::Polyline {
287            points, parameters, ..
288        } => Some((points.clone(), parameters.clone())),
289        _ => None,
290    })
291}
292
293/// Discretize an edge and store the polyline on it, replacing any earlier one.
294///
295/// Returns whether a polyline was stored; an edge with no 3D curve (a
296/// degenerate edge at a cone's apex) has nothing to discretize.
297fn attach_polyline(
298    model: &mut Model,
299    edge: &Shape,
300    deflection: Deflection,
301    tol: Tolerances,
302) -> OgeomResult<bool> {
303    let Some(node) = model.node(edge) else {
304        ogeom_bail!(Dangling, "edge is not in this model");
305    };
306    let NodeData::Edge(data) = node.data() else {
307        ogeom_bail!(Construction, "edge node holds no edge data");
308    };
309    let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
310        return Ok(false);
311    };
312    let Some(geometry) = model.geometry().curve(*curve) else {
313        ogeom_bail!(Dangling, "curve is not in this model");
314    };
315    let line = discretize(geometry, *range, deflection, tol)?;
316
317    let Some(node) = model.node_mut(edge) else {
318        ogeom_bail!(Dangling, "edge is not in this model");
319    };
320    let NodeData::Edge(data) = node.data_mut() else {
321        ogeom_bail!(Construction, "edge node holds no edge data");
322    };
323    data.representations.retain(|repr| {
324        !matches!(
325            repr,
326            EdgeRepr::Polyline { .. } | EdgeRepr::PolygonOnTriangulation { .. }
327        )
328    });
329    data.add(EdgeRepr::Polyline {
330        points: line.points,
331        parameters: line.parameters,
332        location: ogeom_topo::Location::identity(),
333        deflection: deflection.chord,
334    });
335    Ok(true)
336}
337
338#[cfg(test)]
339#[allow(clippy::unwrap_used, clippy::expect_used)]
340mod tests {
341    use super::*;
342    use ogeom_algo::make_box;
343    use ogeom_math::Frame;
344
345    const T: Tolerances = Tolerances::millimetres();
346
347    fn fine() -> Deflection {
348        Deflection {
349            chord: 1e-3,
350            angular: 0.05,
351            ..Deflection::default()
352        }
353    }
354
355    #[test]
356    fn tessellating_a_box_stores_a_mesh_on_every_face_and_edge() {
357        let mut model = Model::new();
358        let built = make_box(&mut model, Frame::WORLD, (2.0, 3.0, 4.0), T).unwrap();
359
360        let done = tessellate(&mut model, &built.shape, fine(), T).unwrap();
361        assert_eq!(done.faces, 6);
362        assert_eq!(done.edges, 12);
363        assert_eq!(done.triangles, 12);
364        assert!(done.deflection_met);
365
366        for face in explore_unique(&model, &built.shape, ShapeType::Face).unwrap() {
367            let mesh = triangulation_of(&model, &face).expect("face has no triangulation");
368            assert_eq!(mesh.triangle_count(), 2);
369        }
370        for edge in explore_unique(&model, &built.shape, ShapeType::Edge).unwrap() {
371            let (points, parameters) = polyline_of(&model, &edge).expect("edge has no polyline");
372            assert_eq!(points.len(), parameters.len());
373            assert_eq!(points.len(), 2, "a straight edge is its own polyline");
374        }
375    }
376
377    #[test]
378    fn the_cached_boundary_agrees_with_the_cached_faces() {
379        // The point of storing the polyline's parameters. If the two caches
380        // were built independently they would disagree by a hair along every
381        // shared edge, and the stored form would have gaps the exact geometry
382        // does not.
383        let mut model = Model::new();
384        let built = make_box(&mut model, Frame::WORLD, (1.0, 2.0, 3.0), T).unwrap();
385        tessellate(&mut model, &built.shape, fine(), T).unwrap();
386
387        for edge in explore_unique(&model, &built.shape, ShapeType::Edge).unwrap() {
388            let (points, _) = polyline_of(&model, &edge).unwrap();
389            for face in
390                ogeom_topo::ancestors_of(&model, &built.shape, &edge, ShapeType::Face).unwrap()
391            {
392                let mesh = triangulation_of(&model, &face).unwrap();
393                for p in &points {
394                    assert!(
395                        mesh.positions.iter().any(|q| q.is_equal(*p, T)),
396                        "the face's mesh has no vertex at {p:?}, which its edge's \
397                         polyline passes through"
398                    );
399                }
400            }
401        }
402    }
403
404    #[test]
405    fn a_narrow_face_draws_its_edges_finer_and_its_neighbours_agree() {
406        // A disc a fraction of a chord thick: its rim is narrower than the
407        // chord, so it draws its circles finer than asked. The two flat
408        // faces share those circles and must draw them to the same points,
409        // or the stored meshes, assembled face by face, crack along the
410        // rim, and the stored polylines would side with one face or the
411        // other.
412        let mut model = Model::new();
413        let coarse = Deflection {
414            chord: 1.0,
415            ..Deflection::default()
416        };
417        let disc = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 10.0, 0.5, T).unwrap();
418        tessellate(&mut model, &disc.shape, coarse, T).unwrap();
419
420        let mut refined = 0;
421        for edge in explore_unique(&model, &disc.shape, ShapeType::Edge).unwrap() {
422            let (points, _) = polyline_of(&model, &edge).unwrap();
423            let alone = crate::triangulate::polyline_of_edge(&model, &edge, coarse, T).unwrap();
424            if points.len() > alone.len() {
425                refined += 1;
426            }
427            for face in
428                ogeom_topo::ancestors_of(&model, &disc.shape, &edge, ShapeType::Face).unwrap()
429            {
430                let mesh = triangulation_of(&model, &face).unwrap();
431                for p in &points {
432                    assert!(
433                        mesh.positions.iter().any(|q| q.is_equal(*p, T)),
434                        "the face's mesh has no vertex at {p:?}, which its edge's \
435                         polyline passes through"
436                    );
437                }
438            }
439        }
440        assert!(
441            refined >= 2,
442            "the rim's circles are drawn finer than the coarse chord asks"
443        );
444    }
445
446    #[test]
447    fn tessellating_again_replaces_rather_than_accumulates() {
448        // A cache built to a different deflection is not the one that was
449        // asked for, and keeping both would leave the reader picking.
450        let mut model = Model::new();
451        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
452
453        tessellate(&mut model, &built.shape, Deflection::default(), T).unwrap();
454        tessellate(&mut model, &built.shape, fine(), T).unwrap();
455
456        for edge in explore_unique(&model, &built.shape, ShapeType::Edge).unwrap() {
457            let NodeData::Edge(data) = model.node(&edge).unwrap().data() else {
458                unreachable!()
459            };
460            let polylines = data
461                .representations
462                .iter()
463                .filter(|r| matches!(r, EdgeRepr::Polyline { .. }))
464                .count();
465            assert_eq!(polylines, 1, "the earlier polyline was left behind");
466        }
467    }
468
469    #[test]
470    fn a_shape_with_no_faces_tessellates_to_nothing_rather_than_failing() {
471        let mut model = Model::new();
472        let vertex = model.add_point(ogeom_math::Point::ORIGIN);
473        let done = tessellate(&mut model, &vertex, fine(), T).unwrap();
474        assert_eq!(done.faces, 0);
475        assert_eq!(done.edges, 0);
476        assert_eq!(done.triangles, 0);
477        assert!(done.deflection_met);
478        assert!(triangulation_of(&model, &vertex).is_none());
479        assert!(polyline_of(&model, &vertex).is_none());
480    }
481
482    #[test]
483    fn an_unusable_deflection_is_refused_before_anything_is_stored() {
484        let mut model = Model::new();
485        let built = make_box(&mut model, Frame::WORLD, (1.0, 1.0, 1.0), T).unwrap();
486        let bad = Deflection {
487            chord: 0.0,
488            ..Deflection::default()
489        };
490        assert!(tessellate(&mut model, &built.shape, bad, T).is_err());
491
492        let face = explore_unique(&model, &built.shape, ShapeType::Face).unwrap()[0].clone();
493        assert!(triangulation_of(&model, &face).is_none());
494    }
495}
496#[cfg(test)]
497#[allow(clippy::unwrap_used)]
498mod polygon_on_tests {
499    use super::*;
500    use ogeom_core::Tolerances;
501    use ogeom_math::Frame;
502    use ogeom_topo::{EdgeRepr, Filter, ShapeType, explore};
503
504    const T: Tolerances = Tolerances::millimetres();
505
506    fn fine() -> Deflection {
507        Deflection {
508            chord: 1e-2,
509            ..Deflection::default()
510        }
511    }
512
513    #[test]
514    fn every_edge_walks_its_faces_triangulations_by_index() {
515        let mut model = Model::new();
516        let solid = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
517        tessellate(&mut model, &solid.shape, fine(), T).unwrap();
518
519        let mut checked = 0;
520        for face in explore(&model, &solid.shape, Filter::OfType(ShapeType::Face)).unwrap() {
521            let mesh_id = {
522                let ogeom_topo::NodeData::Face(data) = model.node(&face).unwrap().data() else {
523                    panic!("face data");
524                };
525                data.triangulation.unwrap()
526            };
527            let mesh = model.geometry().triangulation(mesh_id).unwrap();
528            // Triangle edge set for the adjacency check.
529            let mut edges_of = std::collections::HashSet::new();
530            for t in &mesh.triangles {
531                for i in 0..3 {
532                    let (a, b) = (t[i], t[(i + 1) % 3]);
533                    edges_of.insert((a.min(b), a.max(b)));
534                }
535            }
536            for edge in explore(&model, &face, Filter::OfType(ShapeType::Edge)).unwrap() {
537                let data = model.node(&edge).unwrap().data().as_edge().unwrap();
538                if data.degenerate {
539                    continue;
540                }
541                let paths: Vec<&Vec<u32>> = data
542                    .representations
543                    .iter()
544                    .filter_map(|r| match r {
545                        EdgeRepr::PolygonOnTriangulation {
546                            triangulation,
547                            indices,
548                            ..
549                        } if *triangulation == mesh_id => Some(indices),
550                        _ => None,
551                    })
552                    .collect();
553                assert!(
554                    !paths.is_empty(),
555                    "an edge of a tessellated face walks its triangulation"
556                );
557                for indices in paths {
558                    assert!(indices.len() >= 2);
559                    for pair in indices.windows(2) {
560                        let key = (pair[0].min(pair[1]), pair[0].max(pair[1]));
561                        assert!(
562                            edges_of.contains(&key),
563                            "consecutive indices are a triangle edge of the mesh: \
564                             {:?} at {:?} and {:?}",
565                            pair,
566                            mesh.positions[pair[0] as usize],
567                            mesh.positions[pair[1] as usize]
568                        );
569                    }
570                    checked += 1;
571                }
572            }
573        }
574        assert!(checked >= 6, "rings, seam sides and rims all walked");
575    }
576}