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}