Skip to main content

ogeom_fillet/
analyse.rs

1//! What a blend actually achieved, measured rather than asserted.
2//!
3//! A blend claims two things: that it meets each of its supports along a
4//! curve, and that it meets them *smoothly*: the two surfaces sharing a
5//! normal there. Both are claims about geometry that construction can get
6//! subtly wrong: a fitted section drifts, a marched spine carries its
7//! chord budget, a rebuilt face lands on a neighbour a hair off. This
8//! module reports the two numbers instead of trusting them, with a third
9//! for surface work that asks more: whether the two faces also bend alike
10//! across the edge. Per-point curvature over a face, for curvature maps and
11//! zebra stripes, is [`face_curvature_samples`].
12//!
13//! The report is per shared edge, because that is where the claim lives.
14
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_geom::{Curve3d as _, Surface as _, SurfaceCurvature};
17use ogeom_math::{Direction, Point, Point2};
18use ogeom_topo::{EdgeRepr, Model, NodeData, Orientation, Shape, ShapeType, explore_unique};
19
20/// How well a blend face meets one neighbour along one edge.
21#[derive(Debug, Clone, PartialEq)]
22pub struct BlendContact {
23    /// The shared edge.
24    pub edge: Shape,
25    /// The face on the other side of it.
26    pub neighbour: Shape,
27    /// The largest angle, in radians, between the two surfaces' normals at
28    /// the sampled stations: zero for a tangent join.
29    pub tangency_error: f64,
30    /// The largest difference, in inverse model units, between the two
31    /// surfaces' normal curvatures in the direction square to the edge at
32    /// the sampled stations, signed against one shared sense of the
33    /// normal: zero for a curvature-continuous join. It speaks of a join
34    /// whose `tangency_error` is small; across a crease the two directions
35    /// it compares are not one direction.
36    pub curvature_error: f64,
37    /// The largest distance, in model units, between the edge's curve and
38    /// the two surfaces it is supposed to lie on.
39    pub gap: f64,
40    /// How many stations were sampled along the edge.
41    pub stations: usize,
42}
43
44/// Measure a blend face against every face it shares an edge with.
45///
46/// Every number is sampled: `stations` points spread over each shared
47/// edge's range, the surfaces read at the parameters the edge's own pcurves
48/// give, so nothing is inverted and nothing is guessed. A blend that is
49/// tangent everywhere except between two stations reports zero, which is
50/// the honest limit of sampling and the reason the count is in the report.
51///
52/// An edge whose pcurve on either face is missing cannot be measured this
53/// way at all: it is reported with its `gap` and `tangency_error` set to
54/// infinity rather than quietly skipped, because a blend nobody can measure
55/// is not a blend anybody should trust. Its `curvature_error` is infinite
56/// on the same grounds, and so is that of an edge where no station gave a
57/// curvature on both sides.
58///
59/// # Errors
60///
61/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
62/// `blend` is not a face of `shape`, or `stations` is less than two.
63pub fn analyse_blend(
64    model: &Model,
65    shape: &Shape,
66    blend: &Shape,
67    stations: usize,
68    tol: Tolerances,
69) -> OgeomResult<Vec<BlendContact>> {
70    if stations < 2 {
71        ogeom_bail!(
72            Construction,
73            "a blend measured at {stations} stations is not measured"
74        );
75    }
76    let faces = explore_unique(model, shape, ShapeType::Face)?;
77    if !faces.iter().any(|f| f.node() == blend.node()) {
78        ogeom_bail!(Construction, "that face is not part of this shape");
79    }
80    let surface_of =
81        |face: &Shape| -> Option<(ogeom_topo::SurfaceId, ogeom_geom::SurfaceGeometry)> {
82            let NodeData::Face(data) = model.node(face)?.data() else {
83                return None;
84            };
85            let geometry = model.geometry().surface(data.surface)?.clone();
86            Some((data.surface, geometry))
87        };
88    let Some((blend_id, blend_surface)) = surface_of(blend) else {
89        ogeom_bail!(Construction, "the blend face carries no surface");
90    };
91    let blend_edges = explore_unique(model, blend, ShapeType::Edge)?;
92
93    let mut out = Vec::new();
94    for neighbour in &faces {
95        if neighbour.node() == blend.node() {
96            continue;
97        }
98        let Some((other_id, other_surface)) = surface_of(neighbour) else {
99            continue;
100        };
101        for edge in explore_unique(model, neighbour, ShapeType::Edge)? {
102            if !blend_edges.iter().any(|e| e.node() == edge.node()) {
103                continue;
104            }
105            let Some(data) = model.node(&edge).and_then(|n| n.data().as_edge()) else {
106                continue;
107            };
108            let Some(EdgeRepr::Curve3d { curve, range, .. }) = data.curve3d() else {
109                continue;
110            };
111            let Some(geometry) = model.geometry().curve(*curve).cloned() else {
112                continue;
113            };
114            let chart =
115                |id: ogeom_topo::SurfaceId| -> Option<(ogeom_geom::PlanarCurve, (f64, f64))> {
116                    match data.pcurve_for(id, edge.location())? {
117                        EdgeRepr::PCurve { curve, range, .. } => {
118                            Some((model.geometry().pcurve(*curve)?.clone(), *range))
119                        }
120                        EdgeRepr::Seam { forward, range, .. } => {
121                            Some((model.geometry().pcurve(*forward)?.clone(), *range))
122                        }
123                        _ => None,
124                    }
125                };
126            let (Some((pc_blend, pr_blend)), Some((pc_other, pr_other))) =
127                (chart(blend_id), chart(other_id))
128            else {
129                out.push(BlendContact {
130                    edge: edge.clone(),
131                    neighbour: neighbour.clone(),
132                    tangency_error: f64::INFINITY,
133                    curvature_error: f64::INFINITY,
134                    gap: f64::INFINITY,
135                    stations: 0,
136                });
137                continue;
138            };
139            let (mut worst_angle, mut worst_gap, mut worst_curvature) = (0.0f64, 0.0f64, 0.0f64);
140            let mut curvature_read = false;
141            for k in 0..stations {
142                #[expect(
143                    clippy::cast_precision_loss,
144                    reason = "a station index, far below the mantissa"
145                )]
146                let f = k as f64 / (stations - 1) as f64;
147                let t = (range.1 - range.0).mul_add(f, range.0);
148                let on_curve = geometry.point_at(t, tol)?;
149                let tangent = geometry.d1_at(t, tol)?;
150                let mut normals = Vec::with_capacity(2);
151                let mut across = Vec::with_capacity(2);
152                for ((pcurve, prange), surface) in [
153                    ((&pc_blend, pr_blend), &blend_surface),
154                    ((&pc_other, pr_other), &other_surface),
155                ] {
156                    let pt = (prange.1 - prange.0).mul_add(f, prange.0);
157                    let uv = ogeom_geom::Curve2d::point_at(pcurve, pt, tol)?;
158                    // A pcurve ending on a chart's pole may stand a rounding
159                    // past the chart's window there; the station is read at
160                    // the window's edge, and the gap says what that costs.
161                    let uv = {
162                        use ogeom_geom::Surface as _;
163                        let ((u0, u1), (v0, v1)) = surface.domain();
164                        ogeom_math::Point2::new(
165                            if surface.is_periodic_u() {
166                                uv.x
167                            } else {
168                                uv.x.clamp(u0, u1)
169                            },
170                            if surface.is_periodic_v() {
171                                uv.y
172                            } else {
173                                uv.y.clamp(v0, v1)
174                            },
175                        )
176                    };
177                    worst_gap =
178                        worst_gap.max(surface.point_at(uv.x, uv.y, tol)?.distance(on_curve));
179                    // A station on a chart's pole (a corner patch's own
180                    // corner sits on the ball's pole by construction) has
181                    // no normal from the chart; the stations beside it say
182                    // what the join does there.
183                    if let Ok(normal) = surface.normal_at(uv.x, uv.y, tol) {
184                        normals.push(normal.vector());
185                        // The direction square to the edge in this
186                        // surface's own tangent plane, and how the surface
187                        // bends along it.
188                        across.push(
189                            surface
190                                .curvature_at(uv.x, uv.y, tol)
191                                .ok()
192                                .and_then(|c| c.normal_curvature(normal.vector().cross(tangent))),
193                        );
194                    }
195                }
196                if normals.len() < 2 {
197                    continue;
198                }
199                // Orientation is the topology's business, not the join's:
200                // two faces meeting smoothly may still be wound opposite
201                // ways, so the angle is taken to the nearer sense, and the
202                // curvatures are compared against one normal.
203                let along = normals[0].dot(normals[1]);
204                worst_angle = worst_angle.max(along.abs().min(1.0).acos());
205                if let (Some(a), Some(b)) = (across[0], across[1]) {
206                    let b = if along < 0.0 { -b } else { b };
207                    worst_curvature = worst_curvature.max((a - b).abs());
208                    curvature_read = true;
209                }
210            }
211            out.push(BlendContact {
212                edge: edge.clone(),
213                neighbour: neighbour.clone(),
214                tangency_error: worst_angle,
215                curvature_error: if curvature_read {
216                    worst_curvature
217                } else {
218                    f64::INFINITY
219                },
220                gap: worst_gap,
221                stations,
222            });
223        }
224    }
225    Ok(out)
226}
227
228/// The surface's curvature over a face, on a grid: what a curvature map or
229/// a zebra display draws from.
230///
231/// The box the face's trim spans in its surface's parameters is cut into
232/// `density` by `density` cells, and each cell's centre that lies inside
233/// the trim gives one sample: the point in space and the principal
234/// curvatures there. The trim is read as the mesher reads it, at the
235/// default deflection. A centre where the surface has no normal (a pole,
236/// an apex) has no curvature and gives no sample.
237///
238/// Each sample is placed where the face stands and signed against the
239/// normal the face's mesh carries: the placement moves the point and the
240/// directions, its scale divides the curvatures, and a reversed face turns
241/// the normal round. A convex face reads negative, a cavity positive.
242///
243/// # Errors
244///
245/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
246/// `face` is not a face or `density` is zero;
247/// [`OgeomError::Dangling`](ogeom_core::OgeomError::Dangling) if it is not
248/// in this model.
249pub fn face_curvature_samples(
250    model: &Model,
251    face: &Shape,
252    density: usize,
253    tol: Tolerances,
254) -> OgeomResult<Vec<(Point, SurfaceCurvature)>> {
255    if density == 0 {
256        ogeom_bail!(Construction, "a face sampled on no grid is not sampled");
257    }
258    let rings = ogeom_mesh::face_boundary(model, face, ogeom_mesh::Deflection::default(), tol)?;
259    let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
260        ogeom_bail!(Construction, "expected a face");
261    };
262    let Some(surface) = model.geometry().surface(data.surface) else {
263        ogeom_bail!(Dangling, "face refers to a surface not in this model");
264    };
265    let placement = face.transform(model.datums())?;
266    let flip = (face.orientation() == Orientation::Reversed) != !placement.preserves_handedness();
267
268    let (mut lo, mut hi) = (
269        Point2::new(f64::INFINITY, f64::INFINITY),
270        Point2::new(f64::NEG_INFINITY, f64::NEG_INFINITY),
271    );
272    for p in rings.iter().flatten() {
273        lo = Point2::new(lo.x.min(p.x), lo.y.min(p.y));
274        hi = Point2::new(hi.x.max(p.x), hi.y.max(p.y));
275    }
276
277    let mut out = Vec::new();
278    for i in 0..density {
279        for j in 0..density {
280            #[expect(
281                clippy::cast_precision_loss,
282                reason = "a grid index, far below the mantissa"
283            )]
284            let (fu, fv) = (
285                (i as f64 + 0.5) / density as f64,
286                (j as f64 + 0.5) / density as f64,
287            );
288            let uv = Point2::new(
289                (hi.x - lo.x).mul_add(fu, lo.x),
290                (hi.y - lo.y).mul_add(fv, lo.y),
291            );
292            if !uv.x.is_finite() || !uv.y.is_finite() || !ogeom_mesh::inside_boundary(&rings, uv) {
293                continue;
294            }
295            let Ok(curvature) = surface.curvature_at(uv.x, uv.y, tol) else {
296                continue;
297            };
298            let point = placement.apply(surface.point_at(uv.x, uv.y, tol)?);
299            out.push((point, placed(&curvature, &placement, flip, tol)?));
300        }
301    }
302    Ok(out)
303}
304
305/// A curvature read in a surface's own frame, carried to where its face
306/// stands and signed against the face's normal there.
307///
308/// A similarity `s R` maps the normal `n` to the direction of `s R n`, and
309/// every normal curvature `k` to `k / s` against it; turning the normal
310/// round for the face's orientation negates them again, and the largest
311/// and smallest trade places whenever the sign changes.
312fn placed(
313    c: &SurfaceCurvature,
314    placement: &ogeom_math::Transform,
315    flip: bool,
316    tol: Tolerances,
317) -> OgeomResult<SurfaceCurvature> {
318    let carry = |d: Direction| Direction::new(placement.apply_vector(d.vector()), tol);
319    let sign = if flip { -1.0 } else { 1.0 };
320    let scale = placement.scale_factor();
321    let (a, b) = (sign * c.max / scale, sign * c.min / scale);
322    let normal = carry(c.normal)?;
323    let normal = if flip { -normal } else { normal };
324    let (max_direction, min_direction) = (carry(c.max_direction)?, carry(c.min_direction)?);
325    Ok(if a >= b {
326        SurfaceCurvature {
327            max: a,
328            min: b,
329            max_direction,
330            min_direction,
331            normal,
332        }
333    } else {
334        SurfaceCurvature {
335            max: b,
336            min: a,
337            max_direction: min_direction,
338            min_direction: max_direction,
339            normal,
340        }
341    })
342}