Skip to main content

ogeom_fillet/
facepair.rs

1//! The blend between two faces of one solid, which need not share an edge.
2//!
3//! A rolling ball does not care whether the solid has an edge where the two
4//! supports would meet. It cares where they *would* meet (for two planes,
5//! their own line of intersection) and rolls in the corner that line
6//! defines. So a face-face blend between planes is the edge blend seated on
7//! a line the solid does not have: found from the planes, cut back to the
8//! stretch both faces actually reach, and handed to the same wedge
9//! construction.
10//!
11//! A step is the shape that names the case: a tall block beside a low one,
12//! the tall one's wall and the low one's lid facing each other across a
13//! corner that belongs to neither.
14//!
15//! Curved faces that meet along edges of the solid are blended along those
16//! edges by the edge blend, exact or marched. Curved faces that share no
17//! edge are blended where the ball's section is the same all along the
18//! corner (surfaces sharing a direction or an axis) by sweeping the corner
19//! between the round and the crease from that section, and otherwise by
20//! marching the ball round where the two surfaces cross and building the
21//! corner from the marched band, as the edge blend does along an edge.
22//! Either corner is cut off or fused on.
23
24use ogeom_algo::Built;
25use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
26use ogeom_geom::SurfaceGeometry;
27use ogeom_math::{Point, Vector};
28use ogeom_topo::{Model, NodeData, Shape, ShapeType, explore_unique};
29
30use crate::fillet::seated_fillet;
31use crate::support::Seat;
32
33/// The stations along a two-plane run where each contact is asked to lie
34/// on its face, the middles of as many equal stretches as the swept
35/// check's first reading. A contact found off its face proves the round
36/// would stand on no face there; a face notched between two stations is
37/// not seen.
38const CONTACT_STATIONS: u32 = 16;
39
40/// Blend two faces of one solid with a rolling ball of the given radius.
41///
42/// The two faces need not touch. What they must do is face each other
43/// across a corner, and the material must fill that corner (the ball rolls
44/// inside it and the blend takes the corner off) or leave it open (the
45/// ball rolls in the open and the blend fills it), which is asked of the
46/// solid rather than assumed from the normals, because normals cannot tell
47/// a step from a slot.
48///
49/// - Two planes: their meeting line must cross the stretch both faces
50///   reach, and the blend is a cylinder of `radius` along it.
51/// - A curved face and another face sharing edges of the solid: the corner
52///   is those edges, and the blend is the edge blend of all of them at
53///   once ([`fillet_edges`](crate::fillet_edges)): a cylinder or torus
54///   where the seat has a closed form, a B-spline band fitted through the
55///   marched ball otherwise (B-spline, cone, sphere and torus hosts
56///   included). Where the shared edges stop short of closing round a loop
57///   because another face stands over the crease between them (a wedge
58///   fused into the corner), while the ball touches both faces all the way
59///   round a closed seat, the blend is that closed round, built as between
60///   faces sharing no edge: the other face is taken in whole where every
61///   ball clears it.
62/// - A curved face and another face sharing no edge, their surfaces
63///   sharing a direction (planes and cylinders along it) or an axis (planes
64///   square to it, cylinders, cones, spheres and tori about it): the ball
65///   touches each face along a line or a circle, and the blend is a
66///   cylinder or a torus of `radius` over the run both faces reach, or all
67///   the way round, ended in the ball's section through the end of the
68///   solid's edges along the crease where those end first. The corner it
69///   takes off or fills is the section between the ball's arc and where
70///   the two surfaces cross, swept over the run; the faces are trimmed to
71///   their lines of contact by the boolean that applies it. Where the
72///   surfaces cross in more than one corner the ball rolls in (a drum's
73///   flat and its wall), every such corner is blended. A strip between the
74///   faces (an older, smaller round) that lies wholly in the corner goes
75///   with it.
76/// - A curved face and another face sharing no edge whose surfaces share
77///   no direction or axis (a B-spline face, a cylinder at a slant to a
78///   plane): the ball is marched along where the two surfaces cross, the
79///   surfaces carried on past the faces for it (a plane's or a cylinder's
80///   window widened, a B-spline patch continued, each written back as the
81///   face's own surface). The blend is a B-spline band fitted through the
82///   ball's arcs, and the corner between the band and the crease, bounded
83///   by the two surfaces, is cut off or fused on as the edge blend's is:
84///   all the way round where the ball touches both faces round a closed
85///   seat, and otherwise over the stretch where it touches both, capped at
86///   each end by a plane face in the ball's section where its line of
87///   contact leaves a face (a crease running off the solid, or a face that
88///   holds part of the seat), or, where the solid's edges along the crease
89///   end first (a crease running off a block's side while the ball still
90///   rolls over its top), in the section through their end. Where both
91///   faces let the ball go round a closed seat, never at the same place,
92///   the round is a run over each stretch both hold it, each capped at
93///   both ends and applied in turn. The band rides both faces within a
94///   tenth of a degree. An older, smaller round between
95///   the faces that every ball clears goes with the corner.
96///
97/// # Errors
98///
99/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction), by
100/// name, if the radius is not a usable length, and:
101///
102/// - for two planes, if they are parallel or the faces do not both reach
103///   the meeting line, or the ball touching both touches a face's plane
104///   off the face at a station of the run (a face narrower than the ball's
105///   setback), or another face of the solid reaches into its lune, the
106///   part of it between its centre and its arc, at a station of the run (a
107///   strip between them, such as a chamfer, that the ball crosses rather
108///   than takes off whole). A face reaching into the back of the ball only,
109///   behind its centre (the far side of a part thinner than the ball), is
110///   clear of the round;
111/// - for a curved face, if the two are one face or either is not a face
112///   of the solid;
113/// - for a curved face sharing edges with the other, as
114///   [`fillet_edges`](crate::fillet_edges), and, where the ball touches
115///   both faces all the way round a closed seat the shared edges do not
116///   close, if it reaches into another face of the solid at a station of
117///   the run;
118/// - for a curved face sharing no edge with the other, if their surfaces
119///   do not cross; and, for each side of the faces the ball could roll on,
120///   if no ball touches both faces there, its round would be a torus
121///   crossing its own axis, or the solid beside the middle of the round is
122///   not what that side needs (material behind the faces, open in front of
123///   them), or another face of the solid reaches into the ball's lune at a
124///   station of the run (a chamfer or a wedge between them it crosses),
125///   other than a face the round runs off at the run's ends or along the
126///   crease, or the corners the ball rolls in on a side stand close enough
127///   that their rounds could meet. It is also refused where a ball seats on
128///   both sides;
129/// - for a marched seat, on a side, if no ball seats along where the
130///   surfaces cross, more than one crossing holds one touching both faces,
131///   its line of contact comes back to a face where it left it, or the
132///   march stops while the ball still touches both faces; and if a face is
133///   a trimmed or offset surface. A line of contact crossing a face more
134///   than once gives a capped run over each stretch both faces hold.
135///
136/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) where whether
137/// another face comes inside the ball between two stations cannot be told
138/// within the halvings allowed.
139pub fn blend_faces(
140    model: &mut Model,
141    solid: &Shape,
142    a: &Shape,
143    b: &Shape,
144    radius: f64,
145    tol: Tolerances,
146) -> OgeomResult<Built> {
147    if !radius.is_finite() || radius <= tol.confusion() {
148        ogeom_bail!(Construction, "a blend of radius {radius} rounds nothing");
149    }
150    if !(is_planar(model, a)? && is_planar(model, b)?) {
151        return curved_blend(model, solid, a, b, radius, tol);
152    }
153    let (plane_a, normal_a) = planar_face_of(model, a, tol)?;
154    let (plane_b, normal_b) = planar_face_of(model, b, tol)?;
155
156    // The line the two planes meet on: direction from the normals' cross,
157    // a point from the two plane equations plus the direction as a third.
158    let along = normal_a.cross(normal_b);
159    let magnitude = along.magnitude();
160    if magnitude <= tol.angular() {
161        ogeom_bail!(
162            Construction,
163            "the two faces are parallel; there is no corner between them"
164        );
165    }
166    let along = along / magnitude;
167    let seed = meet(plane_a, normal_a, plane_b, normal_b, along, tol)?;
168
169    // Cut the line back to the stretch both faces reach: each face's own
170    // vertices, projected onto it, give the span it can seat a blend over.
171    let span = |face: &Shape| -> OgeomResult<(f64, f64)> {
172        let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
173        for vertex in explore_unique(model, face, ShapeType::Vertex)? {
174            let Some(point) = model
175                .node(&vertex)
176                .and_then(|n| n.data().as_vertex().map(|v| v.point))
177            else {
178                continue;
179            };
180            let placed = vertex.transform(model.datums())?.apply(point);
181            let t = (placed - seed).dot(along);
182            lo = lo.min(t);
183            hi = hi.max(t);
184        }
185        if !lo.is_finite() {
186            ogeom_bail!(Construction, "a face with no vertices seats nothing");
187        }
188        Ok((lo, hi))
189    };
190    let (a0, a1) = span(a)?;
191    let (b0, b1) = span(b)?;
192    let (lo, hi) = (a0.max(b0), a1.min(b1));
193    if hi - lo <= tol.confusion() {
194        ogeom_bail!(
195            Construction,
196            "the two faces do not both reach the line their planes meet on, \
197             so there is no stretch to seat a blend over"
198        );
199    }
200
201    // Which way does the corner turn? The solid answers, and the question
202    // has to be asked in the right place: the quadrant opposite both
203    // normals is material either way (that is what makes both faces
204    // outward-facing), so it tells a convex corner from a concave one not
205    // at all. The *side* quadrants do. Around a convex edge the material is
206    // the opposite quadrant alone; around a concave one, a step's inner
207    // corner, it is three of the four, and a side probe lands in it.
208    let unit = |v: Vector| -> OgeomResult<Vector> {
209        let m = v.magnitude();
210        if m <= tol.angular() {
211            ogeom_bail!(Construction, "the faces meet too sharply to seat a blend");
212        }
213        Ok(v / m)
214    };
215    let middle = seed + along * f64::midpoint(lo, hi);
216    let step = (hi - lo).min(radius) * 1e-3 + tol.confusion();
217    let mut convex = true;
218    for side in [unit(normal_a - normal_b)?, unit(normal_b - normal_a)?] {
219        if matches!(
220            ogeom_algo::classify_in_solid_exact(model, solid, middle + side * step, tol)?,
221            ogeom_algo::Containment::In
222        ) {
223            convex = false;
224        }
225    }
226
227    // The ball touching both planes, behind both on a convex corner and in
228    // front of both on a concave one: its centre `o` off the line has
229    // `n_a . o = n_b . o = -r` (or `r`), so `o = -r (n_a + n_b) / (1 + n_a . n_b)`.
230    // It is the only ball touching both planes on that side, so where another
231    // face of the solid reaches inside it (a strip between the faces, such as
232    // a chamfer, crossing its arc) no ball rolls in the corner touching the
233    // two faces alone, and the wedge would leave that face standing between
234    // two pieces of round. The planes are not parallel, so `1 + n_a . n_b`
235    // is positive.
236    let k = normal_a.dot(normal_b);
237    let side = if convex { -1.0 } else { 1.0 };
238    let centre = seed + (normal_a + normal_b) * (side * radius / (1.0 + k));
239    let contacts = [
240        centre - normal_a * (side * radius),
241        centre - normal_b * (side * radius),
242    ];
243    if let Some(at) = crate::sheet_curved::face_inside_rolling_ball(
244        model,
245        solid,
246        [a, b],
247        [centre, seed],
248        contacts,
249        along,
250        radius,
251        (lo, hi - lo),
252        tol,
253    )? {
254        ogeom_bail!(
255            Construction,
256            "no ball of radius {radius} rolls between the two faces: {}",
257            stands_inside(at)
258        );
259    }
260
261    // Each contact must lie on its own face along the run. A contact off
262    // the face (the face narrower than the ball's setback) touches only
263    // the face's plane, and the round's rail there would stand on no face:
264    // a fill hanging past the face's far edge, or a cut running into the
265    // face beyond it.
266    let deflection = ogeom_mesh::Deflection {
267        chord: (radius * 1e-3).max(tol.confusion() * 1e2),
268        ..ogeom_mesh::Deflection::default()
269    };
270    for (face, contact, name) in [(a, contacts[0], "first"), (b, contacts[1], "second")] {
271        for k in 0..CONTACT_STATIONS {
272            let t = (hi - lo).mul_add((f64::from(k) + 0.5) / f64::from(CONTACT_STATIONS), lo);
273            let at = contact + along * t;
274            if ogeom_algo::classify_on_face(model, face, at, deflection, tol)?
275                == ogeom_algo::Containment::Out
276            {
277                ogeom_bail!(
278                    Construction,
279                    "no ball of radius {radius} rolls between the two faces: the ball touching \
280                     both touches the {name} face's plane at ({:.6}, {:.6}, {:.6}), off the face",
281                    at.x,
282                    at.y,
283                    at.z
284                );
285            }
286        }
287    }
288
289    let seat = Seat {
290        start: seed + along * lo,
291        end: seed + along * hi,
292        along,
293        normals: [normal_a, normal_b],
294        faces: [a.clone(), b.clone()],
295        convex,
296    };
297    seated_fillet(model, solid, &seat, radius, None, &[], tol)
298}
299
300/// Why a ball touching both faces rounds nothing: it reaches into another
301/// face of the solid at `at`.
302fn stands_inside(at: Point) -> String {
303    format!(
304        "the ball touching both cuts into another face of the solid at ({:.6}, {:.6}, {:.6}), \
305         a face standing between them (a strip such as a chamfer) that a round of the two \
306         faces alone would leave standing between two pieces of round",
307        at.x, at.y, at.z
308    )
309}
310
311/// A face's plane origin and its outward normal, refusing anything curved.
312pub(crate) fn planar_face_of(
313    model: &Model,
314    face: &Shape,
315    tol: Tolerances,
316) -> OgeomResult<(Point, Vector)> {
317    let Some(node) = model.node(face) else {
318        ogeom_bail!(Dangling, "face is not in this model");
319    };
320    let NodeData::Face(data) = node.data() else {
321        ogeom_bail!(Construction, "expected a face");
322    };
323    let Some(SurfaceGeometry::Plane(plane)) = model.geometry().surface(data.surface) else {
324        ogeom_bail!(Construction, "expected a planar face");
325    };
326    let placement = face.transform(model.datums())?;
327    let origin = placement.apply(plane.plane().frame().origin());
328    let mut normal = placement.apply_vector(plane.plane().normal().vector());
329    if face.orientation() == ogeom_topo::Orientation::Reversed {
330        normal = -normal;
331    }
332    let magnitude = normal.magnitude();
333    if magnitude <= tol.angular() {
334        ogeom_bail!(Construction, "a face with no normal faces nothing");
335    }
336    Ok((origin, normal / magnitude))
337}
338
339/// A point on both planes: the one nearest the two origins' midpoint, found
340/// by solving the two plane equations with the meeting direction as the
341/// third.
342pub(crate) fn meet(
343    origin_a: Point,
344    normal_a: Vector,
345    origin_b: Point,
346    normal_b: Vector,
347    along: Vector,
348    tol: Tolerances,
349) -> OgeomResult<Point> {
350    let rows = [normal_a, normal_b, along];
351    let rhs = [
352        normal_a.dot(origin_a.to_vector()),
353        normal_b.dot(origin_b.to_vector()),
354        along.dot(Point::midpoint(origin_a, origin_b).to_vector()),
355    ];
356    let det = rows[0].dot(rows[1].cross(rows[2]));
357    if det.abs() <= tol.confusion() {
358        ogeom_bail!(Construction, "the two planes do not meet in a line");
359    }
360    // The inverse of a three-by-three whose rows are these: its columns are
361    // the cross products of the other two rows, over the determinant.
362    let c0 = rows[1].cross(rows[2]);
363    let c1 = rows[2].cross(rows[0]);
364    let c2 = rows[0].cross(rows[1]);
365    let v = (c0 * rhs[0] + c1 * rhs[1] + c2 * rhs[2]) / det;
366    Ok(Point::ORIGIN + v)
367}
368
369/// Whether a face lies on a plane.
370fn is_planar(model: &Model, face: &Shape) -> OgeomResult<bool> {
371    let Some(node) = model.node(face) else {
372        ogeom_bail!(Dangling, "face is not in this model");
373    };
374    let NodeData::Face(data) = node.data() else {
375        ogeom_bail!(Construction, "expected a face");
376    };
377    Ok(matches!(
378        model.geometry().surface(data.surface),
379        Some(SurfaceGeometry::Plane(_))
380    ))
381}
382
383/// The blend between two faces at least one of which is curved.
384///
385/// Faces that meet along edges of the solid have their corner there: the
386/// ball rolls along those edges, and the blend is the edge blend of all of
387/// them at once, exact where the seat has a closed form and marched where
388/// it does not. Faces that share no edge are blended from one section
389/// swept along the corner where their surfaces share a direction or an
390/// axis, and from the ball marched round where the surfaces cross
391/// otherwise.
392fn curved_blend(
393    model: &mut Model,
394    solid: &Shape,
395    a: &Shape,
396    b: &Shape,
397    radius: f64,
398    tol: Tolerances,
399) -> OgeomResult<Built> {
400    if a.is_same(b) {
401        ogeom_bail!(Construction, "a face is not blended against itself");
402    }
403    let own = explore_unique(model, solid, ShapeType::Face)?;
404    for (name, face) in [("first", a), ("second", b)] {
405        if !own.iter().any(|f| f.is_same(face)) {
406            ogeom_bail!(
407                Construction,
408                "the {name} face is not a face of the solid being blended"
409            );
410        }
411    }
412    let theirs = explore_unique(model, b, ShapeType::Edge)?;
413    let shared: Vec<Shape> = explore_unique(model, a, ShapeType::Edge)?
414        .into_iter()
415        .filter(|e| {
416            theirs
417                .iter()
418                .any(|t| crate::support::same_occurrence(model, t, e, tol))
419        })
420        .collect();
421    // Shared edges that close round a loop are the whole corner. Open ones
422    // end where the crease leaves a face, or where another face of the
423    // solid stands over it (a wedge fused into the corner) while the ball
424    // still rolls on the two faces past it. Where the ball rolls touching
425    // both all the way round one closed seat, its round is the blend
426    // whatever stands over the crease: built where every ball clears the
427    // other faces, refused where one reaches into a face. Otherwise the
428    // shared edges are the corner.
429    if !shared.is_empty() {
430        if closes_round(model, &shared)? {
431            return crate::fillet::fillet_edges(model, solid, &shared, radius, tol);
432        }
433        let held = crate::sheet_curved::surfaces_of(model, [a, b])?;
434        let reads = match read_sides(model, solid, a, b, radius, tol) {
435            Ok(reads) => reads,
436            Err(ogeom_core::OgeomError::Construction(_) | ogeom_core::OgeomError::NotDone(_)) => {
437                Vec::new()
438            }
439            Err(e) => return Err(e),
440        };
441        let mut round = reads
442            .into_iter()
443            .filter(|read| read.closed && read.corners == 1)
444            .filter_map(|read| match read.made {
445                Made::Rounds(corner) => Some(Ok((corner, read.behind))),
446                Made::Crosses(at) => Some(Err(at)),
447                Made::Misses(_) => None,
448            });
449        if let (Some(found), None) = (round.next(), round.next()) {
450            let (corner, behind) = match found {
451                Ok(seated) => seated,
452                Err(at) => ogeom_bail!(
453                    Construction,
454                    "no ball of radius {radius} rounds a corner between the two faces: the \
455                     ball rolls on both all the way round, and {}",
456                    stands_inside(at)
457                ),
458            };
459            return apply(model, solid, corner, behind, radius, tol);
460        }
461        crate::sheet_curved::put_back(model, held);
462        return crate::fillet::fillet_edges(model, solid, &shared, radius, tol);
463    }
464
465    // The faces' windows, widened to read the ball, are put back where no
466    // blend is made.
467    let held = crate::sheet_curved::surfaces_of(model, [a, b])?;
468    let blended = blend_apart(model, solid, a, b, radius, tol);
469    if blended.is_err() {
470        crate::sheet_curved::put_back(model, held);
471    }
472    blended
473}
474
475/// The blend between two faces that share no edge.
476fn blend_apart(
477    model: &mut Model,
478    solid: &Shape,
479    a: &Shape,
480    b: &Shape,
481    radius: f64,
482    tol: Tolerances,
483) -> OgeomResult<Built> {
484    let reads = read_sides(model, solid, a, b, radius, tol)?;
485    let mut seated = Vec::new();
486    let mut misses: Vec<(&str, String)> = Vec::new();
487    for read in reads {
488        let side = if read.behind { "behind" } else { "in front of" };
489        match read.made {
490            Made::Rounds(corner) => seated.push((corner, read.behind)),
491            Made::Crosses(at) => misses.push((side, stands_inside(at))),
492            Made::Misses(why) => misses.push((side, why)),
493        }
494    }
495    let (wedge, behind) = match seated.len() {
496        1 => seated.remove(0),
497        0 => {
498            // A reason that holds on both sides is said once.
499            let said = if misses.len() == 2 && misses[0].1 == misses[1].1 {
500                misses[0].1.clone()
501            } else {
502                misses
503                    .iter()
504                    .map(|(side, why)| format!("{side} them, {why}"))
505                    .collect::<Vec<_>>()
506                    .join("; ")
507            };
508            ogeom_bail!(
509                Construction,
510                "no ball of radius {radius} rounds a corner between the two faces: {said}"
511            )
512        }
513        _ => ogeom_bail!(
514            Construction,
515            "a ball of radius {radius} rounds a corner both behind and in front of the two \
516             faces; which one to blend is ambiguous"
517        ),
518    };
519    apply(model, solid, wedge, behind, radius, tol)
520}
521
522/// What a ball on one side of two faces makes of the corner between them.
523enum Made {
524    /// It rolls on the two faces alone, beside the corner that side needs.
525    Rounds(Corner),
526    /// It rolls beside the corner that side needs, but reaches into another
527    /// face of the solid at this point.
528    Crosses(Point),
529    /// It rounds no corner on that side, for this reason.
530    Misses(String),
531}
532
533/// One side's reading: behind both faces or in front of them, whether the
534/// ball's seat closes round a loop touching both faces all the way, how
535/// many corners the ball rolls in there, and what it makes of them.
536struct SideRead {
537    behind: bool,
538    closed: bool,
539    corners: usize,
540    made: Made,
541}
542
543/// The ball read on each side of two faces that share no edge, or whose
544/// shared edges leave part of the crease: behind both, where it rolls in
545/// the material, then in front of both. The corner is swept from one
546/// section where the surfaces share a direction or an axis, and marched
547/// along where they cross otherwise. A side counts only where the solid
548/// agrees, the corner beside the round being material for the first and
549/// open for the second.
550fn read_sides(
551    model: &mut Model,
552    solid: &Shape,
553    a: &Shape,
554    b: &Shape,
555    radius: f64,
556    tol: Tolerances,
557) -> OgeomResult<Vec<SideRead>> {
558    let swept = crate::sheet_curved::closed_form_between(model, [a, b], tol)?;
559    let hosts = if swept {
560        None
561    } else {
562        Some(crate::pair_marched::hosts_of(model, [a, b], radius, tol)?)
563    };
564    let mut reads = Vec::with_capacity(2);
565    for behind in [true, false] {
566        let built = match &hosts {
567            None => crate::sheet_curved::face_seats(model, [a, b], radius, behind, tol).and_then(
568                |seats| {
569                    let closed = seats.iter().all(crate::sheet_curved::FaceSeat::closed);
570                    let mut corners = Vec::with_capacity(seats.len());
571                    for seat in &seats {
572                        let (wedge, [corner, lune], run) =
573                            crate::sheet_curved::corner_wedge(model, seat, solid, tol)?;
574                        let inside = crate::sheet_curved::face_inside_seat(
575                            model,
576                            solid,
577                            [a, b],
578                            seat,
579                            run,
580                            tol,
581                        )?;
582                        // A lune no face stands in is all material or all
583                        // open, as the ball's side is, whatever a strip
584                        // between the faces (an older, smaller round) leaves
585                        // of the corner.
586                        let probe = if inside.is_none() { lune } else { corner };
587                        corners.push((wedge, probe, inside));
588                    }
589                    Ok((Corners::Swept(corners), closed))
590                },
591            ),
592            Some(hosts) => crate::pair_marched::pair_seat(
593                model,
594                [a, b],
595                hosts,
596                radius,
597                behind,
598                Some(solid),
599                tol,
600            )
601            .and_then(|seat| {
602                if seat.probes().is_empty() {
603                    ogeom_bail!(Invariant, "a marched seat on the crease has no probe");
604                }
605                let closed = seat.closed();
606                let inside =
607                    crate::pair_marched::face_inside(model, solid, [a, b], &seat, radius, tol)?;
608                let probes = seat
609                    .probes()
610                    .iter()
611                    .map(|&[corner, lune]| if inside.is_none() { lune } else { corner })
612                    .collect();
613                Ok((Corners::Marched(Box::new(seat), probes, inside), closed))
614            }),
615        };
616        let (corners, closed) = match built {
617            Ok(built) => built,
618            Err(
619                ogeom_core::OgeomError::Construction(why) | ogeom_core::OgeomError::NotDone(why),
620            ) => {
621                reads.push(SideRead {
622                    behind,
623                    closed: false,
624                    corners: 0,
625                    made: Made::Misses(why.to_string()),
626                });
627                continue;
628            }
629            Err(e) => return Err(e),
630        };
631        // Every corner on the side must round for the side to.
632        let read = |model: &mut Model, probe: Point, inside: Option<Point>| -> OgeomResult<_> {
633            let material = matches!(
634                ogeom_algo::classify_in_solid_exact(model, solid, probe, tol)?,
635                ogeom_algo::Containment::In
636            );
637            Ok(match (material == behind, inside) {
638                (true, Some(at)) => Some(Made::Crosses(at)),
639                (true, None) => None,
640                (false, _) => {
641                    let found = if material { "material" } else { "open" };
642                    Some(Made::Misses(format!(
643                        "the solid beside the round is {found}"
644                    )))
645                }
646            })
647        };
648        let count = match &corners {
649            Corners::Swept(corners) => corners.len(),
650            Corners::Marched(..) => 1,
651        };
652        let made = match corners {
653            Corners::Swept(corners) => {
654                let mut wedges = Vec::with_capacity(corners.len());
655                let mut fails = None;
656                for (wedge, probe, inside) in corners {
657                    if let Some(made) = read(model, probe, inside)? {
658                        fails = Some(made);
659                        break;
660                    }
661                    wedges.push(wedge);
662                }
663                fails.unwrap_or(Made::Rounds(Corner::Swept(wedges)))
664            }
665            Corners::Marched(seat, probes, inside) => {
666                // Every run of the seat must stand where the side says.
667                let mut fails = None;
668                for probe in probes {
669                    if let Some(made) = read(model, probe, inside)? {
670                        fails = Some(made);
671                        break;
672                    }
673                }
674                fails.unwrap_or(Made::Rounds(Corner::Marched(seat)))
675            }
676        };
677        reads.push(SideRead {
678            behind,
679            closed,
680            corners: count,
681            made,
682        });
683    }
684    Ok(reads)
685}
686
687/// The corners read on one side before the solid is asked: each swept
688/// corner with its probe and any point of another face inside its ball's
689/// lune, or the marched seat with a probe for each of its runs and any
690/// point of another face inside a ball of any of them.
691enum Corners {
692    Swept(Vec<(Shape, Point, Option<Point>)>),
693    Marched(
694        Box<crate::pair_marched::PairSeat>,
695        Vec<Point>,
696        Option<Point>,
697    ),
698}
699
700/// Whether edges close round loops: every vertex ending one of them ends
701/// two.
702fn closes_round(model: &Model, edges: &[Shape]) -> OgeomResult<bool> {
703    let mut ends: Vec<(ogeom_topo::TShapeId, usize)> = Vec::new();
704    for edge in edges {
705        let Some((first, last)) = ogeom_algo::edge_vertices(model, edge)? else {
706            return Ok(false);
707        };
708        for vertex in [first, last] {
709            match ends.iter_mut().find(|(node, _)| *node == vertex.node()) {
710                Some((_, count)) => *count += 1,
711                None => ends.push((vertex.node(), 1)),
712            }
713        }
714    }
715    Ok(ends.iter().all(|&(_, count)| count == 2))
716}
717
718/// The corner taken off (`behind`) or filled.
719fn apply(
720    model: &mut Model,
721    solid: &Shape,
722    wedge: Corner,
723    behind: bool,
724    radius: f64,
725    tol: Tolerances,
726) -> OgeomResult<Built> {
727    match wedge {
728        Corner::Swept(wedges) => {
729            // Corners that stand apart, taken off or filled one after the
730            // other.
731            let mut built: Option<Built> = None;
732            for wedge in &wedges {
733                let from = built.as_ref().map_or(solid, |b| &b.shape).clone();
734                let next = if behind {
735                    ogeom_bool::cut(model, &from, wedge, tol)?
736                } else {
737                    ogeom_bool::fuse(model, &from, wedge, tol)?
738                };
739                built = Some(match built {
740                    None => next,
741                    Some(earlier) => {
742                        let history = earlier.history.then(&next.history);
743                        Built::new(next.shape, history)
744                    }
745                });
746            }
747            let Some(built) = built else {
748                ogeom_bail!(Invariant, "a swept blend has no corner to take off or fill");
749            };
750            Ok(built)
751        }
752        Corner::Marched(seat) => {
753            crate::pair_marched::apply_to_solid(model, solid, *seat, radius, behind, tol)
754        }
755    }
756}
757
758/// The corners a curved face blend takes off or fills: the swept solids
759/// themselves, one for each corner the faces share, or the marched seat its
760/// wedge is built from.
761enum Corner {
762    Swept(Vec<Shape>),
763    Marched(Box<crate::pair_marched::PairSeat>),
764}