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}