Skip to main content

ogeom_fillet/
corner2d.rs

1//! 2D fillets and chamfers: rounding and beveling a wire's corner.
2//!
3//! The sketch-plane cousins of the edge blends. A corner where two straight
4//! edges of a wire meet is replaced by a tangent arc (the fillet) or a
5//! straight cut at set distances (the chamfer); the two edges are trimmed
6//! back on their own curves, and the wire is rebuilt with the connector in
7//! the corner's place. The tangent construction for corners with curved
8//! sides (a line meeting an arc, two arcs) is the 2D tangency problem
9//! proper (docs/PARITY.md, fillet.corners-2d) rather than approximated here.
10
11use crate::support::edge_curve;
12use ogeom_algo::{Built, History, edge_vertices, make_edge_between, make_vertex, make_wire};
13use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
14use ogeom_geom::Curve3d as _;
15use ogeom_geom::{CircleCurve, Curve};
16use ogeom_math::{Circle, Direction, Frame, Point, Vector};
17use ogeom_topo::{Filter, Model, Orientation, Shape, ShapeType, explore};
18
19/// Round a corner of a wire with an arc tangent to both of its edges.
20///
21/// `vertex` names the corner; the two edges meeting there must be straight.
22/// The result is a new wire with the two edges trimmed to the tangency points
23/// and the arc between them: the corner vertex is deleted, the edges are
24/// modified into their trimmed selves, and the arc is generated from the
25/// vertex.
26///
27/// # Errors
28///
29/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the vertex is
30/// not a corner of the wire between two straight edges, the edges are
31/// collinear, `radius` is not a usable length, or the tangency points fall
32/// off either edge.
33pub fn fillet_corner_2d(
34    model: &mut Model,
35    wire: &Shape,
36    vertex: &Shape,
37    radius: f64,
38    tol: Tolerances,
39) -> OgeomResult<Built> {
40    if !radius.is_finite() || radius <= tol.confusion() {
41        ogeom_bail!(Construction, "a fillet of radius {radius} rounds nothing");
42    }
43    let corner = corner_of(model, wire, vertex, tol)?;
44    corner.opening(tol)?;
45    // The tangent circle's centre lies on each side's offset locus (the
46    // parallel line for a straight side, the concentric circle for an arc),
47    // and where two loci cross is a candidate. GccAna's question, answered
48    // the same way: enumerate the loci, intersect in closed form, and keep
49    // the qualified candidate nearest the corner: centre on the corner's
50    // inner side, both tangency feet on the edges themselves.
51    let plane_z = {
52        let n = corner.sides[0].away.cross(corner.sides[1].away);
53        Direction::new(n, tol)?
54    };
55    let frame = Frame::new(
56        corner.point,
57        plane_z,
58        Direction::new(corner.sides[0].away, tol)?,
59        tol,
60    )?;
61    let flat = |p: Point| {
62        let l = frame.to_local(p);
63        ogeom_math::Point2::new(l.x, l.y)
64    };
65    let lift = |q: ogeom_math::Point2| {
66        frame.origin() + frame.x().vector() * q.x + frame.y().vector() * q.y
67    };
68    let loci = |side: &Side| -> OgeomResult<Vec<ogeom_geom::PlanarCurve>> {
69        Ok(match &side.curve {
70            Curve::Line(l) => {
71                let o = flat(l.axis().location);
72                let d = flat(l.axis().location + l.axis().direction.vector()) - o;
73                let d = d / d.magnitude();
74                let n = ogeom_math::Vector2::new(-d.y, d.x);
75                let axis = |shift: f64| {
76                    ogeom_math::Direction2::new(d, tol)
77                        .map(|dir| ogeom_math::Axis2::new(o + n * shift, dir))
78                };
79                vec![
80                    ogeom_geom::Line2d::over(axis(radius)?, -1e6, 1e6)?.into(),
81                    ogeom_geom::Line2d::over(axis(-radius)?, -1e6, 1e6)?.into(),
82                ]
83            }
84            Curve::Circle(c) => {
85                let centre = flat(c.circle().centre());
86                let big = c.circle().radius();
87                let mut out: Vec<ogeom_geom::PlanarCurve> = vec![
88                    ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(
89                        ogeom_math::Frame2::new(centre, ogeom_math::Direction2::X),
90                        big + radius,
91                        tol,
92                    )?)
93                    .into(),
94                ];
95                if big - radius > tol.confusion() {
96                    out.push(
97                        ogeom_geom::Circle2d::new(ogeom_math::Circle2::new(
98                            ogeom_math::Frame2::new(centre, ogeom_math::Direction2::X),
99                            big - radius,
100                            tol,
101                        )?)
102                        .into(),
103                    );
104                }
105                out
106            }
107            _ => ogeom_bail!(Construction, "a corner side is neither line nor arc"),
108        })
109    };
110    // Where does a candidate centre touch a side, and how far along the edge
111    // is that from the corner in the side's own parameter?
112    let foot = |side: &Side, c2: ogeom_math::Point2| -> Option<(ogeom_math::Point2, f64)> {
113        match &side.curve {
114            Curve::Line(l) => {
115                let o = flat(l.axis().location);
116                let d = flat(l.axis().location + l.axis().direction.vector()) - o;
117                let d = d / d.magnitude();
118                let t = o + d * ((c2 - o).dot(d));
119                let corner2 = flat(side.curve.point_at(side.at, tol).ok()?);
120                let away2 = {
121                    let a = flat(lift(corner2) + side.away) - corner2;
122                    a / a.magnitude()
123                };
124                Some((t, (t - corner2).dot(away2)))
125            }
126            Curve::Circle(circle) => {
127                let centre = flat(circle.circle().centre());
128                let big = circle.circle().radius();
129                let v = c2 - centre;
130                let m = v.magnitude();
131                if m <= tol.confusion() {
132                    return None;
133                }
134                let t = centre + v * (big / m);
135                let corner2 = flat(side.curve.point_at(side.at, tol).ok()?);
136                let a = (corner2 - centre).y.atan2((corner2 - centre).x);
137                let b = (t - centre).y.atan2((t - centre).x);
138                let mut delta = b - a;
139                let tau = core::f64::consts::TAU;
140                while delta > core::f64::consts::PI {
141                    delta -= tau;
142                }
143                while delta < -core::f64::consts::PI {
144                    delta += tau;
145                }
146                // Positive when the foot lies along the away direction: the
147                // circle's own winding in the chart says which sign that is.
148                let winding = {
149                    let d1 = flat(lift(corner2) + side.away * 1.0) - corner2;
150                    let radial = corner2 - centre;
151                    (radial.x * d1.y - radial.y * d1.x).signum()
152                };
153                Some((t, delta * winding))
154            }
155            _ => None,
156        }
157    };
158    let corner2 = flat(corner.point);
159    let bis2 = {
160        let a = flat(corner.point + corner.sides[0].away) - corner2;
161        let b = flat(corner.point + corner.sides[1].away) - corner2;
162        let u = a + b;
163        u / u.magnitude()
164    };
165    let mut best: Option<(ogeom_math::Point2, [ogeom_math::Point2; 2], [f64; 2], f64)> = None;
166    for la in loci(&corner.sides[0])? {
167        for lb in loci(&corner.sides[1])? {
168            let found = ogeom_intersect::intersect_curves_2d(
169                &la,
170                &lb,
171                ogeom_intersect::CurveCurveOptions::default(),
172                tol,
173            )?;
174            for crossing in &found.crossings {
175                let c2 = crossing.point;
176                if (c2 - corner2).dot(bis2) <= tol.confusion() {
177                    continue;
178                }
179                let (Some((t0, d0)), Some((t1, d1))) =
180                    (foot(&corner.sides[0], c2), foot(&corner.sides[1], c2))
181                else {
182                    continue;
183                };
184                // Both feet forward of the corner, within their edges.
185                let room0 = corner.sides[0].room;
186                let room1 = corner.sides[1].room;
187                if d0 <= tol.parametric()
188                    || d1 <= tol.parametric()
189                    || d0 >= room0 - tol.parametric()
190                    || d1 >= room1 - tol.parametric()
191                {
192                    continue;
193                }
194                let score = c2.distance(corner2);
195                if best.as_ref().is_none_or(|(_, _, _, held)| score < *held) {
196                    best = Some((c2, [t0, t1], [d0, d1], score));
197                }
198            }
199        }
200    }
201    let Some((centre2, feet, deltas, _)) = best else {
202        ogeom_bail!(
203            Construction,
204            "no circle of radius {radius} is tangent to both sides within \
205             their own extents"
206        );
207    };
208    let centre = lift(centre2);
209    let contacts = [lift(feet[0]), lift(feet[1])];
210    let trim_deltas = deltas;
211    let arc = |model: &mut Model, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
212        let w1 = contacts[0] - centre;
213        let w2 = contacts[1] - centre;
214        let z = Direction::new(w1.cross(w2), tol)?;
215        let x = Direction::new(w1, tol)?;
216        let frame = Frame::new(centre, z, x, tol)?;
217        let circle = Circle::new(frame, radius, tol)?;
218        let sweep = w1.cross(w2).magnitude().atan2(w1.dot(w2));
219        Ok(make_edge_between(
220            model,
221            Curve::Circle(CircleCurve::new(circle)),
222            (0.0, sweep),
223            from,
224            to,
225            tol,
226        )?
227        .shape)
228    };
229    rebuild(model, wire, vertex, &corner, trim_deltas, arc, tol)
230}
231
232/// Cut a corner of a wire, trimming `first` back along the earlier edge and
233/// `second` along the later, joined by a straight segment.
234///
235/// "Earlier" and "later" follow the wire's own traversal order through the
236/// corner.
237///
238/// # Errors
239///
240/// As [`fillet_corner_2d`].
241pub fn chamfer_corner_2d(
242    model: &mut Model,
243    wire: &Shape,
244    vertex: &Shape,
245    first: f64,
246    second: f64,
247    tol: Tolerances,
248) -> OgeomResult<Built> {
249    for distance in [first, second] {
250        if !distance.is_finite() || distance <= tol.confusion() {
251            ogeom_bail!(Construction, "a chamfer of {distance} cuts nothing");
252        }
253    }
254    let corner = corner_of(model, wire, vertex, tol)?;
255    corner.opening(tol)?;
256    // Distances are arc lengths along each side; on an arc that is an angle.
257    let delta = |side: &Side, d: f64| -> f64 {
258        match &side.curve {
259            Curve::Circle(c) => d / c.circle().radius(),
260            _ => d,
261        }
262    };
263    let deltas = [
264        delta(&corner.sides[0], first),
265        delta(&corner.sides[1], second),
266    ];
267    let contacts = [
268        corner.sides[0].curve.point_at(
269            deltas[0].mul_add(corner.sides[0].sense, corner.sides[0].at),
270            tol,
271        )?,
272        corner.sides[1].curve.point_at(
273            deltas[1].mul_add(corner.sides[1].sense, corner.sides[1].at),
274            tol,
275        )?,
276    ];
277    let cut = |model: &mut Model, from: &Shape, to: &Shape| -> OgeomResult<Shape> {
278        let line = ogeom_geom::LineCurve::segment(contacts[0], contacts[1], tol)?;
279        let curve = Curve::Line(line);
280        let domain = ogeom_geom::Curve3d::domain(&curve);
281        Ok(make_edge_between(model, curve, domain, from, to, tol)?.shape)
282    };
283    rebuild(model, wire, vertex, &corner, deltas, cut, tol)
284}
285
286/// One side of a corner: the wire edge running into or out of it.
287struct Side {
288    /// Position in the wire's ordered edge list.
289    index: usize,
290    /// The occurrence as the wire uses it, orientation included.
291    used: Shape,
292    /// The side's own curve.
293    curve: Curve,
294    /// Unit tangent at the corner, pointing along the edge.
295    away: Vector,
296    /// The corner's parameter on the edge's own curve.
297    at: f64,
298    /// `+1` when walking away from the corner increases the parameter.
299    sense: f64,
300    /// How much parameter the edge has to give before its far end.
301    room: f64,
302    /// The far end's vertex.
303    far: Shape,
304}
305
306/// A corner of a wire: the shared point and its two sides, in traversal
307/// order: `sides[0]` runs into the corner, `sides[1]` out of it.
308struct Corner {
309    point: Point,
310    edges: Vec<Shape>,
311    sides: [Side; 2],
312}
313
314impl Corner {
315    /// The opening angle between the two sides, strictly inside `(0, π)`.
316    fn opening(&self, tol: Tolerances) -> OgeomResult<f64> {
317        let (a, b) = (self.sides[0].away, self.sides[1].away);
318        let angle = a.cross(b).magnitude().atan2(a.dot(b));
319        if angle <= tol.angular() || angle >= core::f64::consts::PI - tol.angular() {
320            ogeom_bail!(
321                Construction,
322                "the corner's edges are collinear; there is no corner to blend"
323            );
324        }
325        Ok(angle)
326    }
327}
328
329/// Find the corner `vertex` makes in `wire`: the two adjacent straight edges
330/// and the geometry the trims run on.
331fn corner_of(model: &Model, wire: &Shape, vertex: &Shape, tol: Tolerances) -> OgeomResult<Corner> {
332    if model.kind_of(wire)? != ShapeType::Wire {
333        ogeom_bail!(Construction, "a 2D blend rounds a corner of a wire");
334    }
335    let edges = explore(model, wire, Filter::OfType(ShapeType::Edge))?;
336    if edges.len() < 2 {
337        ogeom_bail!(Construction, "a corner needs at least two edges");
338    }
339    let n = edges.len();
340    let mut found: Option<(usize, usize)> = None;
341    for i in 0..n {
342        let j = (i + 1) % n;
343        let Some((_, end)) = edge_vertices(model, &edges[i])? else {
344            continue;
345        };
346        let Some((start, _)) = edge_vertices(model, &edges[j])? else {
347            continue;
348        };
349        if end.node() == vertex.node() && start.node() == vertex.node() {
350            found = Some((i, j));
351            break;
352        }
353    }
354    let Some((i, j)) = found else {
355        ogeom_bail!(
356            Construction,
357            "the vertex is not a corner between two consecutive edges of the \
358             wire"
359        );
360    };
361
362    let point = {
363        let Some(node) = model.node(vertex) else {
364            ogeom_bail!(Dangling, "vertex is not in this model");
365        };
366        let Some(data) = node.data().as_vertex() else {
367            ogeom_bail!(Construction, "vertex node holds no point");
368        };
369        vertex.transform(model.datums())?.apply(data.point)
370    };
371
372    let side = |model: &Model, index: usize, corner_at_end: bool| -> OgeomResult<Side> {
373        let used = edges[index].clone();
374        let (curve, range) = edge_curve(model, &used, tol)?;
375        if !matches!(curve, Curve::Line(_) | Curve::Circle(_)) {
376            ogeom_bail!(
377                Construction,
378                "a corner blend against a free-form side has no closed-form \
379                 tangency; lines and arcs do"
380            );
381        }
382        // The corner sits at the traversal end (or start), which for a
383        // reversed occurrence is the stored range's other bound.
384        let reversed = used.orientation() == Orientation::Reversed;
385        let at_high = corner_at_end != reversed;
386        let (at, sense, room) = if at_high {
387            (range.1, -1.0, range.1 - range.0)
388        } else {
389            (range.0, 1.0, range.1 - range.0)
390        };
391        let away = {
392            let d = curve.d1_at(at, tol)? * sense;
393            let m = d.magnitude();
394            if m <= tol.confusion() {
395                ogeom_bail!(Construction, "a corner edge is degenerate at its corner");
396            }
397            d / m
398        };
399        let Some((start, end)) = edge_vertices(model, &used)? else {
400            ogeom_bail!(Construction, "a corner edge has no bounding vertices");
401        };
402        let far = if corner_at_end { start } else { end };
403        Ok(Side {
404            index,
405            used,
406            curve,
407            away,
408            at,
409            sense,
410            room,
411            far,
412        })
413    };
414    let sides = [side(model, i, true)?, side(model, j, false)?];
415    Ok(Corner {
416        point,
417        edges,
418        sides,
419    })
420}
421
422/// Trim both sides, build the connector between the new vertices, and
423/// reassemble the wire with history.
424fn rebuild(
425    model: &mut Model,
426    wire: &Shape,
427    vertex: &Shape,
428    corner: &Corner,
429    trims: [f64; 2],
430    connector: impl FnOnce(&mut Model, &Shape, &Shape) -> OgeomResult<Shape>,
431    tol: Tolerances,
432) -> OgeomResult<Built> {
433    let mut history = History::new();
434    let mut trimmed: Vec<Shape> = Vec::with_capacity(2);
435    let mut joints: Vec<Shape> = Vec::with_capacity(2);
436    for (side, trim) in corner.sides.iter().zip(trims) {
437        if trim >= side.room - tol.parametric() {
438            ogeom_bail!(
439                Construction,
440                "a trim of {trim} consumes the whole edge; the blend reaches \
441                 past the corner's neighbours"
442            );
443        }
444        let (curve, range) = edge_curve(model, &side.used, tol)?;
445        let contact = trim.mul_add(side.sense, side.at);
446        let new_range = if side.sense > 0.0 {
447            (contact, range.1)
448        } else {
449            (range.0, contact)
450        };
451        let joint = make_vertex(model, curve.point_at(contact, tol)?).shape;
452        // The trimmed edge runs between the far vertex and the new tangency
453        // vertex, on the same curve, with the same orientation flag the wire
454        // used before.
455        let (from, to) = if side.sense > 0.0 {
456            (joint.clone(), side.far.clone())
457        } else {
458            (side.far.clone(), joint.clone())
459        };
460        let mut new_edge = make_edge_between(model, curve, new_range, &from, &to, tol)?.shape;
461        if side.used.orientation() == Orientation::Reversed {
462            new_edge = new_edge.reversed();
463        }
464        history.modify(&side.used, new_edge.clone());
465        trimmed.push(new_edge);
466        joints.push(joint);
467    }
468
469    let joined = connector(model, &joints[0], &joints[1])?;
470    history.generate(vertex, joined.clone());
471    history.delete(vertex);
472
473    let mut edges: Vec<Shape> = Vec::with_capacity(corner.edges.len() + 1);
474    for (k, e) in corner.edges.iter().enumerate() {
475        if k == corner.sides[0].index {
476            edges.push(trimmed[0].clone());
477            edges.push(joined.clone());
478        } else if k == corner.sides[1].index {
479            edges.push(trimmed[1].clone());
480        } else {
481            edges.push(e.clone());
482        }
483    }
484    // The corner pair may wrap the list's end; rotate so the two halves stay
485    // adjacent in traversal order.
486    if corner.sides[1].index < corner.sides[0].index {
487        edges.rotate_left(corner.sides[1].index + 1);
488    }
489    let built = make_wire(model, &edges, tol)?;
490    history.modify(wire, built.shape.clone());
491    Ok(Built::new(built.shape, history))
492}