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