Skip to main content

ogeom_fillet/
facepair.rs

1//! The blend between two faces that share no 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 is the edge blend seated on a line the
7//! solid does not have: found from the planes, cut back to the stretch both
8//! faces actually reach, and handed to the same wedge construction.
9//!
10//! A step is the shape that names the case: a tall block beside a low one,
11//! the tall one's wall and the low one's lid facing each other across a
12//! corner that belongs to neither.
13
14use ogeom_algo::Built;
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_geom::SurfaceGeometry;
17use ogeom_math::{Point, Vector};
18use ogeom_topo::{Model, NodeData, Shape, ShapeType, explore_unique};
19
20use crate::fillet::seated_fillet;
21use crate::support::Seat;
22
23/// Blend two faces of one solid with a rolling ball of the given radius.
24///
25/// The two faces need not touch. What they must do is face each other
26/// across a corner: their planes must meet, both must reach the stretch of
27/// that meeting line the blend will sit on, and the material must fill the
28/// dihedral between them, which is asked of the solid rather than assumed
29/// from the normals, because normals cannot tell a step from a slot.
30///
31/// Planar supports only. A curved face-face blend needs the marching seat
32/// (the spine that is the two offset surfaces' own intersection), which is
33/// recorded as owed in `docs/PLAN.md` rather than guessed at here.
34///
35/// # Errors
36///
37/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
38/// either face is not planar, the planes are parallel, the faces do not
39/// both reach the meeting line, or the radius is not a usable length.
40pub fn blend_faces(
41    model: &mut Model,
42    solid: &Shape,
43    a: &Shape,
44    b: &Shape,
45    radius: f64,
46    tol: Tolerances,
47) -> OgeomResult<Built> {
48    if !radius.is_finite() || radius <= tol.confusion() {
49        ogeom_bail!(Construction, "a blend of radius {radius} rounds nothing");
50    }
51    let (plane_a, normal_a) = planar_face_of(model, a, tol)?;
52    let (plane_b, normal_b) = planar_face_of(model, b, tol)?;
53
54    // The line the two planes meet on: direction from the normals' cross,
55    // a point from the two plane equations plus the direction as a third.
56    let along = normal_a.cross(normal_b);
57    let magnitude = along.magnitude();
58    if magnitude <= tol.angular() {
59        ogeom_bail!(
60            Construction,
61            "the two faces are parallel; there is no corner between them"
62        );
63    }
64    let along = along / magnitude;
65    let seed = meet(plane_a, normal_a, plane_b, normal_b, along, tol)?;
66
67    // Cut the line back to the stretch both faces reach: each face's own
68    // vertices, projected onto it, give the span it can seat a blend over.
69    let span = |face: &Shape| -> OgeomResult<(f64, f64)> {
70        let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
71        for vertex in explore_unique(model, face, ShapeType::Vertex)? {
72            let Some(point) = model
73                .node(&vertex)
74                .and_then(|n| n.data().as_vertex().map(|v| v.point))
75            else {
76                continue;
77            };
78            let placed = vertex.transform(model.datums())?.apply(point);
79            let t = (placed - seed).dot(along);
80            lo = lo.min(t);
81            hi = hi.max(t);
82        }
83        if !lo.is_finite() {
84            ogeom_bail!(Construction, "a face with no vertices seats nothing");
85        }
86        Ok((lo, hi))
87    };
88    let (a0, a1) = span(a)?;
89    let (b0, b1) = span(b)?;
90    let (lo, hi) = (a0.max(b0), a1.min(b1));
91    if hi - lo <= tol.confusion() {
92        ogeom_bail!(
93            Construction,
94            "the two faces do not both reach the line their planes meet on, \
95             so there is no stretch to seat a blend over"
96        );
97    }
98
99    // Which way does the corner turn? The solid answers, and the question
100    // has to be asked in the right place: the quadrant opposite both
101    // normals is material either way (that is what makes both faces
102    // outward-facing), so it tells a convex corner from a concave one not
103    // at all. The *side* quadrants do. Around a convex edge the material is
104    // the opposite quadrant alone; around a concave one, a step's inner
105    // corner, it is three of the four, and a side probe lands in it.
106    let unit = |v: Vector| -> OgeomResult<Vector> {
107        let m = v.magnitude();
108        if m <= tol.angular() {
109            ogeom_bail!(Construction, "the faces meet too sharply to seat a blend");
110        }
111        Ok(v / m)
112    };
113    let middle = seed + along * f64::midpoint(lo, hi);
114    let step = (hi - lo).min(radius) * 1e-3 + tol.confusion();
115    let mut convex = true;
116    for side in [unit(normal_a - normal_b)?, unit(normal_b - normal_a)?] {
117        if matches!(
118            ogeom_algo::classify_in_solid_exact(model, solid, middle + side * step, tol)?,
119            ogeom_algo::Containment::In
120        ) {
121            convex = false;
122        }
123    }
124
125    let seat = Seat {
126        start: seed + along * lo,
127        end: seed + along * hi,
128        along,
129        normals: [normal_a, normal_b],
130        faces: [a.clone(), b.clone()],
131        convex,
132    };
133    seated_fillet(model, solid, &seat, radius, None, tol)
134}
135
136/// A face's plane origin and its outward normal, refusing anything curved.
137fn planar_face_of(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<(Point, Vector)> {
138    let Some(node) = model.node(face) else {
139        ogeom_bail!(Dangling, "face is not in this model");
140    };
141    let NodeData::Face(data) = node.data() else {
142        ogeom_bail!(Construction, "expected a face");
143    };
144    let Some(SurfaceGeometry::Plane(plane)) = model.geometry().surface(data.surface) else {
145        ogeom_bail!(
146            Construction,
147            "a face-face blend between curved supports needs the marching \
148             seat; this is the planar form"
149        );
150    };
151    let placement = face.transform(model.datums())?;
152    let origin = placement.apply(plane.plane().frame().origin());
153    let mut normal = placement.apply_vector(plane.plane().normal().vector());
154    if face.orientation() == ogeom_topo::Orientation::Reversed {
155        normal = -normal;
156    }
157    let magnitude = normal.magnitude();
158    if magnitude <= tol.angular() {
159        ogeom_bail!(Construction, "a face with no normal faces nothing");
160    }
161    Ok((origin, normal / magnitude))
162}
163
164/// A point on both planes: the one nearest the two origins' midpoint, found
165/// by solving the two plane equations with the meeting direction as the
166/// third.
167fn meet(
168    origin_a: Point,
169    normal_a: Vector,
170    origin_b: Point,
171    normal_b: Vector,
172    along: Vector,
173    tol: Tolerances,
174) -> OgeomResult<Point> {
175    let rows = [normal_a, normal_b, along];
176    let rhs = [
177        normal_a.dot(origin_a.to_vector()),
178        normal_b.dot(origin_b.to_vector()),
179        along.dot(Point::midpoint(origin_a, origin_b).to_vector()),
180    ];
181    let det = rows[0].dot(rows[1].cross(rows[2]));
182    if det.abs() <= tol.confusion() {
183        ogeom_bail!(Construction, "the two planes do not meet in a line");
184    }
185    // The inverse of a three-by-three whose rows are these: its columns are
186    // the cross products of the other two rows, over the determinant.
187    let c0 = rows[1].cross(rows[2]);
188    let c1 = rows[2].cross(rows[0]);
189    let c2 = rows[0].cross(rows[1]);
190    let v = (c0 * rhs[0] + c1 * rhs[1] + c2 * rhs[2]) / det;
191    Ok(Point::ORIGIN + v)
192}