Skip to main content

ogeom_heal/
same_parameter.rs

1//! Verifying (and where needed, widening into truth) the `same_parameter`
2//! claim.
3//!
4//! An edge carries several representations of one curve, and nearly every
5//! algorithm evaluates whichever is convenient, assuming the answers are
6//! interchangeable within the edge's tolerance. The flag that records this is
7//! set false whenever a representation is added: honest, but pessimistic:
8//! every primitive's edges claim a disagreement they do not have. This is the
9//! repair the flag's own documentation demands: measure the actual
10//! disagreement, and either confirm the claim or widen the edge's tolerance
11//! until the claim is true. Either way, afterwards the flag *means* something.
12
13use ogeom_core::{OgeomResult, Tolerances};
14use ogeom_geom::{Curve2d as _, Curve3d as _, Surface as _};
15use ogeom_topo::{EdgeRepr, Filter, Model, NodeData, Shape, ShapeType, TShapeId, explore};
16
17/// What one repair pass did.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub struct SameParameterReport {
20    /// Edges examined.
21    pub checked: usize,
22    /// Edges whose representations already agreed within tolerance.
23    pub agreed: usize,
24    /// Edges whose tolerance had to widen to make the claim true.
25    pub widened: usize,
26    /// Edges with no pcurves to disagree with; trivially true.
27    pub trivial: usize,
28}
29
30/// Verify every edge under `shape` and make its `same_parameter` flag true.
31///
32/// Each pcurve is sampled against the edge's own curve at matched parameters
33/// (the same linear range mapping the triangulator uses), and the worst gap
34/// decides: within the edge's tolerance, the claim is confirmed; beyond it,
35/// the tolerance widens to cover what was measured, which makes the claim
36/// true by making the tolerance honest. Degenerate edges and edges with no
37/// pcurves are trivially true.
38///
39/// # Errors
40///
41/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if the shape
42/// or a representation resolves to nothing.
43pub fn repair_same_parameter(
44    model: &mut Model,
45    shape: &Shape,
46    tol: Tolerances,
47) -> OgeomResult<SameParameterReport> {
48    const SAMPLES: usize = 24;
49    let mut report = SameParameterReport::default();
50    let mut done: Vec<TShapeId> = Vec::new();
51    for edge in explore(model, shape, Filter::OfType(ShapeType::Edge))? {
52        if done.contains(&edge.node()) {
53            continue;
54        }
55        done.push(edge.node());
56        report.checked += 1;
57
58        let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
59            continue;
60        };
61        let data = data.clone();
62        let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
63            // A degenerate edge's pcurve is its whole story; there is nothing
64            // for it to disagree with.
65            report.trivial += 1;
66            set_flag(model, &edge, true);
67            continue;
68        };
69        let Some(curve) = model.geometry().curve(*curve).cloned() else {
70            report.trivial += 1;
71            continue;
72        };
73        let (ca, cb) = *range;
74
75        // Every pcurve representation, seam sides included.
76        let mut pairs: Vec<(
77            ogeom_geom::PlanarCurve,
78            (f64, f64),
79            ogeom_geom::SurfaceGeometry,
80        )> = Vec::new();
81        for repr in &data.representations {
82            match repr {
83                EdgeRepr::PCurve {
84                    curve: pc,
85                    surface,
86                    range,
87                    ..
88                } => {
89                    if let (Some(p), Some(s)) = (
90                        model.geometry().pcurve(*pc).cloned(),
91                        model.geometry().surface(*surface).cloned(),
92                    ) {
93                        pairs.push((p, *range, s));
94                    }
95                }
96                EdgeRepr::Seam {
97                    forward,
98                    reversed,
99                    surface,
100                    range,
101                    ..
102                } => {
103                    for pc in [forward, reversed] {
104                        if let (Some(p), Some(s)) = (
105                            model.geometry().pcurve(*pc).cloned(),
106                            model.geometry().surface(*surface).cloned(),
107                        ) {
108                            pairs.push((p, *range, s));
109                        }
110                    }
111                }
112                EdgeRepr::Curve3d { .. } => {}
113                _ => {}
114            }
115        }
116        if pairs.is_empty() {
117            report.trivial += 1;
118            set_flag(model, &edge, true);
119            continue;
120        }
121
122        let mut worst = 0.0_f64;
123        for (pcurve, prange, surface) in &pairs {
124            for i in 0..=SAMPLES {
125                #[allow(clippy::cast_precision_loss)]
126                let t = ca + (cb - ca) * i as f64 / SAMPLES as f64;
127                // The linear range mapping every consumer uses.
128                let u = if (cb - ca).abs() <= f64::MIN_POSITIVE {
129                    prange.0
130                } else {
131                    prange.0 + (prange.1 - prange.0) * (t - ca) / (cb - ca)
132                };
133                let on_curve = curve.point_at(t, tol)?;
134                let uv = pcurve.point_at(u, tol)?;
135                let lifted = surface.point_at(uv.x, uv.y, tol)?;
136                worst = worst.max(on_curve.distance(lifted));
137            }
138        }
139
140        let within = {
141            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
142                continue;
143            };
144            data.tolerance.get()
145        };
146        if worst <= within {
147            report.agreed += 1;
148        } else {
149            report.widened += 1;
150            if let Some(node) = model.node_mut(&edge)
151                && let NodeData::Edge(data) = node.data_mut()
152            {
153                data.tolerance = data.tolerance.widen_to(worst + tol.confusion());
154            }
155        }
156        set_flag(model, &edge, true);
157    }
158    Ok(report)
159}
160
161/// Set an edge's `same_parameter` claim.
162fn set_flag(model: &mut Model, edge: &Shape, agrees: bool) {
163    if let Some(node) = model.node_mut(edge)
164        && let NodeData::Edge(data) = node.data_mut()
165    {
166        data.assert_same_parameter(agrees);
167    }
168}
169
170#[cfg(test)]
171#[allow(clippy::unwrap_used, clippy::expect_used)]
172mod tests {
173    use super::*;
174    use ogeom_math::Frame;
175
176    const T: Tolerances = Tolerances::millimetres();
177
178    fn all_flags_true(model: &Model, shape: &Shape) -> bool {
179        explore(model, shape, Filter::OfType(ShapeType::Edge))
180            .unwrap()
181            .iter()
182            .all(|e| {
183                model
184                    .node(e)
185                    .and_then(|n| n.data().as_edge())
186                    .is_some_and(ogeom_topo::EdgeData::same_parameter)
187            })
188    }
189
190    #[test]
191    fn a_primitives_edges_agree_and_the_flag_finally_says_so() {
192        let mut model = Model::new();
193        let solid = ogeom_algo::make_cylinder(&mut model, Frame::WORLD, 2.0, 5.0, T).unwrap();
194        assert!(
195            !all_flags_true(&model, &solid.shape),
196            "the builder is honest: unverified means false"
197        );
198        let report = repair_same_parameter(&mut model, &solid.shape, T).unwrap();
199        assert_eq!(report.widened, 0, "a native primitive has nothing to widen");
200        assert!(report.agreed > 0);
201        assert!(all_flags_true(&model, &solid.shape));
202    }
203
204    #[test]
205    fn a_disagreeing_pcurve_widens_the_tolerance_into_truth() {
206        use ogeom_geom::{Line2d, LineCurve, PlaneSurface};
207        use ogeom_math::{Plane, Point, Point2};
208        let mut model = Model::new();
209        let curve: ogeom_geom::Curve =
210            LineCurve::segment(Point::new(0.0, 0.0, 0.0), Point::new(10.0, 0.0, 0.0), T)
211                .unwrap()
212                .into();
213        let edge = ogeom_algo::make_edge(&mut model, curve, (0.0, 10.0), T)
214            .unwrap()
215            .shape;
216        let surface = model.geometry_mut().add_surface(
217            PlaneSurface::over(Plane::new(Frame::WORLD), (-20.0, 20.0), (-20.0, 20.0))
218                .unwrap()
219                .into(),
220        );
221        // A pcurve half a unit off the curve it claims to follow.
222        let off = Line2d::segment(Point2::new(0.0, 0.5), Point2::new(10.0, 0.5), T).unwrap();
223        ogeom_algo::attach_pcurve(
224            &mut model,
225            &edge,
226            off.into(),
227            surface,
228            ogeom_topo::Location::identity(),
229            (0.0, 10.0),
230        )
231        .unwrap();
232
233        let report = repair_same_parameter(&mut model, &edge, T).unwrap();
234        assert_eq!(report.widened, 1);
235        let data = model.node(&edge).unwrap().data().as_edge().unwrap();
236        assert!(data.same_parameter());
237        assert!(
238            data.tolerance.get() >= 0.5,
239            "the tolerance covers the measured gap, got {}",
240            data.tolerance.get()
241        );
242    }
243}