Skip to main content

axiolid_construct/
fillet_variable.rs

1//! Variable-radius (tapered) fillet blends (ADR 0051).
2//!
3//! # Why this is not a cone
4//!
5//! A constant-radius fillet on a vertical edge sweeps a cylinder. Tapering
6//! the radius linearly with height does NOT give a right circular cone: the
7//! blend centre moves along the bisector as the radius grows, so the axis
8//! and the rulings disagree. The surface is a cone in the projective sense
9//! -- every ruling passes through one apex -- but an OBLIQUE one, and
10//! `Surface::Cone` is right-circular only. Measured obliqueness is far above
11//! numerical noise at every interior angle and taper tested (ADR 0051).
12//!
13//! Each horizontal section is still an exact circular arc, so the surface is
14//! a linear loft between two rational quadratic arcs. That is representable
15//! exactly as a rational B-spline, and that is what this module builds.
16
17use axiolid_contracts::{GeomError, GeomResult};
18use axiolid_core::{Point2, Point3, Scalar};
19use axiolid_curve::KnotSpec;
20use axiolid_surface::BSplineSurface;
21
22use crate::feature::BlendCorner;
23
24/// A fillet whose radius changes linearly along the extrusion.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct TaperedFillet {
27    /// Corner index in the profile ring.
28    pub corner: usize,
29    /// Radius at the bottom cap.
30    pub bottom_radius: Scalar,
31    /// Radius at the top cap.
32    pub top_radius: Scalar,
33}
34
35/// Control points and weight of one rational quadratic arc.
36///
37/// A circular arc of sweep `angle` is exact as a rational quadratic with
38/// control points start, shoulder, end and middle weight `cos(angle/2)`.
39/// The shoulder is the intersection of the two end tangents, which sits at
40/// `radius / cos(angle/2)` from the centre along the arc's bisector.
41fn arc_control_points(
42    centre: Point2,
43    start: Point2,
44    sweep: Scalar,
45    height: Scalar,
46) -> Option<([Point3; 3], Scalar)> {
47    let half = sweep.abs() / 2.0;
48    let weight = half.cos();
49    // A half-turn or more puts the tangent intersection at infinity, so a
50    // single rational quadratic cannot span it.
51    if weight <= Scalar::EPSILON {
52        return None;
53    }
54    let radial = start - centre;
55    let radius = radial.length();
56    if radius <= Scalar::EPSILON {
57        return None;
58    }
59
60    let (sin, cos) = sweep.sin_cos();
61    // Rotate the start radius by the full sweep to reach the end point,
62    // which keeps both ends exactly on the circle by construction.
63    let end = Point2::new(
64        centre.x + radial.x * cos - radial.y * sin,
65        centre.y + radial.x * sin + radial.y * cos,
66    );
67    // Shoulder lies on the bisector of the two radii.
68    let mid_angle = sweep / 2.0;
69    let (mid_sin, mid_cos) = mid_angle.sin_cos();
70    let bisector = Point2::new(
71        radial.x * mid_cos - radial.y * mid_sin,
72        radial.x * mid_sin + radial.y * mid_cos,
73    );
74    let shoulder_distance = radius / weight;
75    let shoulder = Point2::new(
76        centre.x + bisector.x / radius * shoulder_distance,
77        centre.y + bisector.y / radius * shoulder_distance,
78    );
79
80    Some((
81        [
82            Point3::new(start.x, start.y, height),
83            Point3::new(shoulder.x, shoulder.y, height),
84            Point3::new(end.x, end.y, height),
85        ],
86        weight,
87    ))
88}
89
90/// The tapered blend surface: a linear loft between two rational arcs.
91///
92/// `u` runs along the arc (degree 2, rational), `v` along the extrusion
93/// (degree 1, exact for a linear taper). Both bounding arcs are exact
94/// circles, so every horizontal section of the loft is an exact circle too.
95pub fn tapered_blend_surface(
96    bottom: &BlendCorner,
97    top: &BlendCorner,
98    height: Scalar,
99) -> GeomResult<BSplineSurface> {
100    let (bottom_points, bottom_weight) =
101        arc_control_points(bottom.centre, bottom.start, bottom.sweep, 0.0).ok_or_else(|| {
102            GeomError::Degenerate("tapered fillet arc spans half a turn or more".to_owned())
103        })?;
104    let (top_points, top_weight) = arc_control_points(top.centre, top.start, top.sweep, height)
105        .ok_or_else(|| {
106            GeomError::Degenerate("tapered fillet arc spans half a turn or more".to_owned())
107        })?;
108
109    Ok(BSplineSurface {
110        u_degree: 2,
111        v_degree: 1,
112        control_points: vec![
113            vec![bottom_points[0], top_points[0]],
114            vec![bottom_points[1], top_points[1]],
115            vec![bottom_points[2], top_points[2]],
116        ],
117        u_knots: vec![0.0, 1.0],
118        u_multiplicities: vec![3, 3],
119        v_knots: vec![0.0, 1.0],
120        v_multiplicities: vec![2, 2],
121        // The arc weight is constant along v, so the same pair repeats.
122        weights: Some(vec![
123            vec![1.0, 1.0],
124            vec![bottom_weight, top_weight],
125            vec![1.0, 1.0],
126        ]),
127        u_closed: false,
128        v_closed: false,
129        knot_spec: KnotSpec::Unspecified,
130        self_intersect: Some(false),
131    })
132}