Skip to main content

ogeom_fillet/
fits.rs

1//! The largest radius a round, or distance a chamfer, seats at: read from
2//! where the ball's contacts walk across the faces as it grows, without
3//! building a band.
4//!
5//! A ball of radius `r` on an edge whose faces turn by `φ` touches each
6//! face `r · tan(φ/2)` back from the edge, square to it, at the stations
7//! the request check holds it to. As `r` grows each contact walks across
8//! its face, and the largest radius whose contacts all stay on their faces
9//! is where the first walk leaves its face. On a plane, and along a ruling
10//! of a cylinder or a cone, the walk is straight in the face's chart, and
11//! where it leaves the region the check reads the face by is exact; on
12//! other surfaces the walk curves, and is followed by stations along it.
13//! Two bands overlap where a contact of one comes within the other's
14//! setback of its edge: the contact's distance to that edge changes no
15//! faster than the contact walks, so the radius where it first does is
16//! approached from below in steps that cannot pass it.
17
18use crate::support::{chart_holds, distance_to_edge, edge_curve, face_mesh, same_occurrence};
19use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
20use ogeom_geom::{Curve3d as _, Surface as _, SurfaceGeometry, Transformable as _};
21use ogeom_math::{Point, Point2, Vector};
22use ogeom_topo::{Filter, Model, NodeData, Orientation, Shape, ShapeType, explore};
23
24/// Stations along the walk of a contact on a surface where it curves.
25const WALK_STATIONS: u32 = 256;
26
27/// Where one contact of an edge's ball walks as the radius grows: from the
28/// edge's point `from`, along the unit `step` square to the edge into
29/// `face`, `factor` (the tangent of half the faces' turn) per unit of
30/// radius, the point found on `surface` (the face's, placed).
31struct Walk {
32    face: Shape,
33    surface: SurfaceGeometry,
34    from: Point,
35    step: Vector,
36    factor: f64,
37}
38
39impl Walk {
40    /// The contact a setback `sigma` back from the edge.
41    fn contact(&self, sigma: f64, tol: Tolerances) -> OgeomResult<(Point, Point2)> {
42        let found =
43            ogeom_algo::project_on_surface(&self.surface, self.from + self.step * sigma, 16, tol)?;
44        let (u, v) = found.parameters;
45        Ok((found.point, Point2::new(u, v)))
46    }
47}
48
49/// How far a walk stays on its face: the setback where it first leaves
50/// the face's region, at most the `up` it was followed to, and how fast
51/// its contact moves per unit of setback.
52struct Reach {
53    setback: f64,
54    speed: f64,
55}
56
57/// The walks of `edge`'s ball, two at each station [`ball_fits`] reads
58/// (stations a quarter, half and three quarters along it), on each of the
59/// edge's two faces of `solid`. None for an edge not between two faces;
60/// stations where the faces meet tangent are left out, as the check leaves
61/// them.
62///
63/// [`ball_fits`]: crate::support::ball_fits
64///
65/// # Errors
66///
67/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) where the side
68/// of the edge a face lies on reads differently at the hair's steps the
69/// check reads it at: that walk's direction depends on the radius.
70fn walks(model: &Model, solid: &Shape, edge: &Shape, tol: Tolerances) -> OgeomResult<Vec<Walk>> {
71    let mut found = Vec::new();
72    let Ok((curve, range)) = edge_curve(model, edge, tol) else {
73        return Ok(found);
74    };
75    let mut sides: Vec<(Shape, bool)> = Vec::new();
76    for face in explore(model, solid, Filter::OfType(ShapeType::Face))? {
77        for e in explore(model, &face, Filter::OfType(ShapeType::Edge))? {
78            if same_occurrence(model, &e, edge, tol) {
79                sides.push((face.clone(), e.orientation() == Orientation::Reversed));
80                break;
81            }
82        }
83    }
84    let [(face0, rev0), (face1, rev1)] = sides.as_slice() else {
85        return Ok(found);
86    };
87    let normal_at = |face: &Shape, p: Point| -> OgeomResult<Option<(SurfaceGeometry, Vector)>> {
88        let Some(NodeData::Face(data)) = model.node(face).map(|n| n.data()) else {
89            return Ok(None);
90        };
91        let Some(surface) = model.geometry().surface(data.surface) else {
92            return Ok(None);
93        };
94        let placed = surface
95            .clone()
96            .transformed(&face.transform(model.datums())?, tol)?;
97        let (u, v) = ogeom_algo::project_on_surface(&placed, p, 16, tol)?.parameters;
98        let Ok(n) = placed.normal_at(u, v, tol) else {
99            return Ok(None);
100        };
101        let n = if face.orientation() == Orientation::Reversed {
102            -n.vector()
103        } else {
104            n.vector()
105        };
106        Ok(Some((placed, n)))
107    };
108    for fraction in [0.25, 0.5, 0.75] {
109        let t = range.0 + (range.1 - range.0) * fraction;
110        let p = curve.point_at(t, tol)?;
111        let d = curve.d1_at(t, tol)?;
112        let m = d.magnitude();
113        if m <= tol.confusion() {
114            continue;
115        }
116        let tangent = d / m;
117        let (Some((s0, n0)), Some((s1, n1))) = (normal_at(face0, p)?, normal_at(face1, p)?) else {
118            continue;
119        };
120        let turn = n0.dot(n1).clamp(-1.0, 1.0).acos();
121        if turn <= tol.angular() * 1e3 {
122            continue;
123        }
124        let factor = (turn * 0.5).tan();
125        // Into each face: square to the edge and the normal, on the side
126        // a hair's step lands in the face, for every hair the check steps.
127        let into = |face: &Shape, surface: &SurfaceGeometry, n: Vector, reversed: bool| {
128            let walk = if reversed { -tangent } else { tangent };
129            let d = n.cross(walk);
130            let l = d.magnitude();
131            if l <= 0.0 {
132                return Ok(None);
133            }
134            let d = d / l;
135            let lands = |step: Vector| -> OgeomResult<bool> {
136                let (u, v) = ogeom_algo::project_on_surface(surface, p + step, 16, tol)?.parameters;
137                chart_holds(model, face, Point2::new(u, v), tol)
138            };
139            // The check's hair grows with the setback, from ten confusion
140            // distances to 1e5. Landing on both sides, it takes the first
141            // guess, and holds to it while the contact that way is on the
142            // face, as it is wherever the walk below holds.
143            let mut side: Option<Vector> = None;
144            for decade in 1..=5 {
145                let hair = tol.confusion() * 10_f64.powi(decade);
146                let here = match [lands(d * hair)?, lands(-d * hair)?] {
147                    [true, _] => d,
148                    [false, true] => -d,
149                    [false, false] => continue,
150                };
151                if side.is_some_and(|s| s.dot(here) < 0.0) {
152                    ogeom_bail!(
153                        NotDone,
154                        "the face beside the edge through {p:?} reads on different sides of \
155                         it at the steps the check takes, which grow with the radius"
156                    );
157                }
158                side = Some(here);
159            }
160            Ok(side)
161        };
162        let (Some(d0), Some(d1)) = (into(face0, &s0, n0, *rev0)?, into(face1, &s1, n1, *rev1)?)
163        else {
164            continue;
165        };
166        for (face, surface, step) in [(face0, s0, d0), (face1, s1, d1)] {
167            found.push(Walk {
168                face: face.clone(),
169                surface,
170                from: p,
171                step,
172                factor,
173            });
174        }
175    }
176    Ok(found)
177}
178
179/// How far `walk` stays on its face, followed up to the setback `up`.
180///
181/// On a plane, cylinder or cone, a walk that lies on the surface at its
182/// start, middle and end is a line on it (a line meets a quadric in two
183/// points unless it lies on it) and runs straight in the chart, and where
184/// it leaves the face's region is found exactly, triangle by triangle of
185/// the mesh the check reads the face by. Otherwise it is followed by
186/// [`WALK_STATIONS`] stations and the first it leaves at is bisected.
187fn reach(model: &Model, walk: &Walk, up: f64, tol: Tolerances) -> OgeomResult<Reach> {
188    let conf = tol.confusion();
189    let (p0, a) = walk.contact(0.0, tol)?;
190    let (pm, m) = walk.contact(up * 0.5, tol)?;
191    let (p1, b) = walk.contact(up, tol)?;
192    let quadric = matches!(
193        walk.surface,
194        SurfaceGeometry::Plane(_) | SurfaceGeometry::Cylinder(_) | SurfaceGeometry::Cone(_)
195    );
196    let on = |p: Point, sigma: f64| p.distance(walk.from + walk.step * sigma) <= conf;
197    let mid = Point2::new(f64::midpoint(a.x, b.x), f64::midpoint(a.y, b.y));
198    let chart_span =
199        a.x.abs()
200            .max(a.y.abs())
201            .max(b.x.abs())
202            .max(b.y.abs())
203            .max(1.0);
204    if quadric
205        && on(p0, 0.0)
206        && on(pm, up * 0.5)
207        && on(p1, up)
208        && (m.x - mid.x).abs().max((m.y - mid.y).abs()) <= chart_span * 1e-12
209    {
210        let mesh = face_mesh(model, &walk.face, tol)?;
211        return Ok(Reach {
212            setback: up * straight_exit(&mesh, a, b),
213            speed: 1.0,
214        });
215    }
216    // A contact found where the surface's window clamps the search, off
217    // square from the point walked to, stands past the surface's edge.
218    let holds = |sigma: f64| -> OgeomResult<bool> {
219        let target = walk.from + walk.step * sigma;
220        let (at, uv) = walk.contact(sigma, tol)?;
221        let Ok(n) = walk.surface.normal_at(uv.x, uv.y, tol) else {
222            return Ok(false);
223        };
224        let off = target - at;
225        let n = n.vector();
226        if (off - n * n.dot(off)).magnitude() > conf {
227            return Ok(false);
228        }
229        chart_holds(model, &walk.face, uv, tol)
230    };
231    let mut speed = 1.0_f64;
232    let mut last = (0.0, p0);
233    for k in 1..=WALK_STATIONS {
234        let sigma = up * f64::from(k) / f64::from(WALK_STATIONS);
235        if !holds(sigma)? {
236            let (mut lo, mut hi) = (last.0, sigma);
237            for _ in 0..48 {
238                let mid = f64::midpoint(lo, hi);
239                if holds(mid)? {
240                    lo = mid;
241                } else {
242                    hi = mid;
243                }
244            }
245            return Ok(Reach { setback: lo, speed });
246        }
247        let (at, _) = walk.contact(sigma, tol)?;
248        speed = speed.max(at.distance(last.1) / (sigma - last.0) * 2.0);
249        last = (sigma, at);
250    }
251    Ok(Reach { setback: up, speed })
252}
253
254/// Where the chart segment from `a` to `b` first leaves the region a
255/// face's mesh lays over its chart, as a fraction of the way along it: 0
256/// where it starts outside, 1 where it never leaves. A point is in the
257/// region where it lies in a triangle or on one's side, as
258/// [`chart_holds`] reads it.
259fn straight_exit(mesh: &ogeom_topo::Triangulation, a: Point2, b: Point2) -> f64 {
260    let chart = |i: u32| {
261        let (u, v) = mesh.parameters[i as usize];
262        Point2::new(u, v)
263    };
264    let span = mesh
265        .parameters
266        .iter()
267        .fold(0.0_f64, |m, (u, v)| m.max(u.abs()).max(v.abs()))
268        .max(1.0);
269    let eps = span * 1e-9;
270    let (dx, dy) = (b.x - a.x, b.y - a.y);
271    // Each side's cross product along the segment, alpha + beta s.
272    let side = |p: Point2, q: Point2| {
273        let (ex, ey) = (q.x - p.x, q.y - p.y);
274        (ex * (a.y - p.y) - ey * (a.x - p.x), ex * dy - ey * dx)
275    };
276    // Where alpha + beta s >= bound over [0, 1].
277    let at_least = |(alpha, beta): (f64, f64), bound: f64, (lo, hi): (f64, f64)| {
278        let excess = alpha - bound;
279        if beta == 0.0 {
280            return if excess >= 0.0 { (lo, hi) } else { (1.0, 0.0) };
281        }
282        let root = -excess / beta;
283        if beta > 0.0 {
284            (lo.max(root), hi)
285        } else {
286            (lo, hi.min(root))
287        }
288    };
289    let mut spans: Vec<(f64, f64)> = Vec::new();
290    for t in &mesh.triangles {
291        let [p, q, r] = t.map(chart);
292        let sides = [side(p, q), side(q, r), side(r, p)];
293        // Every side's cross product at least -eps, or every one at most
294        // eps: in the triangle either way it winds.
295        let up = sides
296            .iter()
297            .fold((0.0, 1.0), |span, s| at_least(*s, -eps, span));
298        let down = sides
299            .iter()
300            .fold((0.0, 1.0), |span, s| at_least((-s.0, -s.1), -eps, span));
301        for span in [up, down] {
302            if span.0 <= span.1 {
303                spans.push(span);
304            }
305        }
306    }
307    spans.sort_by(|x, y| x.0.total_cmp(&y.0));
308    let mut reached: Option<f64> = None;
309    for (lo, hi) in spans {
310        match reached {
311            None if lo <= 0.0 => reached = Some(hi),
312            Some(r) if lo <= r => reached = Some(r.max(hi)),
313            _ => {}
314        }
315    }
316    reached.unwrap_or(0.0)
317}
318
319/// The largest measure up to `up_to` at which every contact of `edge`'s
320/// ball is on its face at every station, where a contact stands `per` of
321/// the measure back from the edge, with each walk and its reach.
322fn edge_largest(
323    model: &Model,
324    solid: &Shape,
325    edge: &Shape,
326    up_to: f64,
327    per: impl Fn(&Walk) -> f64,
328    tol: Tolerances,
329) -> OgeomResult<(f64, Vec<(Walk, Reach)>)> {
330    let conf = tol.confusion();
331    let mut largest = up_to;
332    let mut found = Vec::new();
333    for walk in walks(model, solid, edge, tol)? {
334        let rate = per(&walk);
335        if rate <= 0.0 {
336            continue;
337        }
338        let bounds = ogeom_algo::shape_bounds(model, &walk.face, tol)?;
339        // Past its face's diagonal from the edge, a contact is off it.
340        let up = (up_to * rate).min(bounds.diagonal() + conf);
341        let reached = reach(model, &walk, up, tol)?;
342        if reached.setback < up || up < up_to * rate {
343            largest = largest.min((reached.setback - conf) / rate);
344        }
345        found.push((walk, reached));
346    }
347    Ok((largest, found))
348}
349
350/// The largest measure up to `up_to` at which every contact of a bevel on
351/// `edge` is on its face at every station, where the bevel's leg on a
352/// face runs `per` of the measure along it.
353pub(crate) fn legs_largest(
354    model: &Model,
355    solid: &Shape,
356    edge: &Shape,
357    up_to: f64,
358    per: impl Fn(&Shape) -> f64,
359    tol: Tolerances,
360) -> OgeomResult<f64> {
361    crate::support::keeping_face_meshes(|| {
362        Ok(edge_largest(model, solid, edge, up_to, |w| per(&w.face), tol)?.0)
363    })
364}
365
366/// The largest radius up to `up_to` at which a ball rolling along `edge`
367/// of `solid` sits with both contacts on their faces at every station the
368/// request check reads, so that [`fillet_edges`](crate::fillet_edges) on
369/// that edge alone is not refused for setback at it or at any radius
370/// below it. None where no radius above the confusion distance fits.
371///
372/// Read from the seat, without building a band: each contact walks square
373/// to the edge, `r · tan(φ/2)` into its face, and the answer is where the
374/// first walk leaves its face, a confusion distance short. On a plane,
375/// and along a ruling of a cylinder or a cone, that is exact against the
376/// region the check reads the face by; on other surfaces the walk is
377/// followed by stations, and a sliver of the face's outside narrower than
378/// their spacing can pass between two. A contact the check lets run off a
379/// face out of the solid, where another station holds the ball, bounds the
380/// answer here all the same, so the answer can lie under the largest
381/// radius the check lets through. Refusals other than for setback (a seat
382/// the round does not speak, a corner) are outside its promise.
383///
384/// # Errors
385///
386/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
387/// `up_to` is not a usable length;
388/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) where which
389/// way a contact walks into its face depends on the radius, or the radius
390/// found does not pass the check (a station whose answer the walk does not
391/// predict).
392pub fn largest_fillet_radius(
393    model: &Model,
394    solid: &Shape,
395    edge: &Shape,
396    up_to: f64,
397    tol: Tolerances,
398) -> OgeomResult<Option<f64>> {
399    largest_fillet_radius_all(model, solid, std::slice::from_ref(edge), up_to, tol)
400}
401
402/// The largest radius up to `up_to` at which the bands of `edges` all sit
403/// with their contacts on their faces, as [`largest_fillet_radius`] reads
404/// each, and no band's contact comes within another's setback of its edge
405/// across a face they share, the overlap
406/// [`fillet_edges`](crate::fillet_edges) refuses. Edges meeting at a
407/// vertex are corner mates, whose bands meet there by design, and are not
408/// held apart; vertices and faces are told apart by placement as well as
409/// node, as that refusal tells them. None where no radius above the
410/// confusion distance fits.
411///
412/// A contact's distance to another edge changes no faster than the
413/// contact walks, and the edge's setback grows linearly, so the radius
414/// where the two first meet is approached from below in steps that cannot
415/// pass it.
416///
417/// # Errors
418///
419/// As [`largest_fillet_radius`], and
420/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
421/// `edges` is empty.
422pub fn largest_fillet_radius_all(
423    model: &Model,
424    solid: &Shape,
425    edges: &[Shape],
426    up_to: f64,
427    tol: Tolerances,
428) -> OgeomResult<Option<f64>> {
429    let conf = tol.confusion();
430    if !up_to.is_finite() || up_to <= conf {
431        ogeom_bail!(
432            Construction,
433            "a largest radius up to {up_to} asks for nothing"
434        );
435    }
436    if edges.is_empty() {
437        ogeom_bail!(Construction, "a request of no edges has no largest radius");
438    }
439    crate::support::keeping_face_meshes(|| -> OgeomResult<Option<f64>> {
440        let mut largest = up_to;
441        let mut seats = Vec::with_capacity(edges.len());
442        for edge in edges {
443            let (radius, walks) = edge_largest(model, solid, edge, up_to, |w| w.factor, tol)?;
444            largest = largest.min(radius);
445            seats.push(walks);
446        }
447        if largest <= conf {
448            return Ok(None);
449        }
450        let ends = |edge: &Shape| -> OgeomResult<Vec<Shape>> {
451            Ok(ogeom_algo::edge_vertices(model, edge)?
452                .map(|(a, b)| vec![a, b])
453                .unwrap_or_default())
454        };
455        for (i, first) in edges.iter().enumerate() {
456            for (j, second) in edges.iter().enumerate() {
457                if i == j {
458                    continue;
459                }
460                let (own, other) = (ends(first)?, ends(second)?);
461                if own.iter().any(|v| other.iter().any(|o| o.is_same(v))) {
462                    continue;
463                }
464                let Ok((curve, range)) = edge_curve(model, second, tol) else {
465                    continue;
466                };
467                for (walk, reached) in &seats[i] {
468                    // The other edge's widest setback per unit radius on
469                    // this face, if it has a contact there.
470                    let Some(strip) = seats[j]
471                        .iter()
472                        .filter(|(w, _)| w.face.is_same(&walk.face))
473                        .map(|(w, _)| w.factor)
474                        .reduce(f64::max)
475                    else {
476                        continue;
477                    };
478                    // The gap between the contact and the other edge's
479                    // strip, shrinking at most this fast per unit radius.
480                    let rate = walk.factor * reached.speed + strip;
481                    let gap = |r: f64| -> OgeomResult<f64> {
482                        let (at, _) = walk.contact(r * walk.factor, tol)?;
483                        Ok(distance_to_edge(&curve, range, at, tol)? - strip * r)
484                    };
485                    let mut r = 0.0_f64;
486                    for _ in 0..100_000 {
487                        if r >= largest {
488                            break;
489                        }
490                        let g = gap(r)?;
491                        // Where the gap closes to a confusion distance, a
492                        // confusion distance short.
493                        if g <= conf {
494                            largest = (r - conf / rate).max(0.0);
495                            break;
496                        }
497                        r += g / rate;
498                    }
499                    largest = largest.min(r);
500                }
501            }
502        }
503        if largest <= conf {
504            return Ok(None);
505        }
506        // The answer passes the request's own check, or it is not one.
507        if let Err(refusal) =
508            crate::fillet::request_fits(model, solid, edges, &vec![largest; edges.len()], tol)
509        {
510            ogeom_bail!(
511                NotDone,
512                "the largest radius read from the seats, {largest}, does not pass the \
513                 request's check: {}",
514                refusal.error
515            );
516        }
517        Ok(Some(largest))
518    })
519}