Skip to main content

ogeom_fillet/
asym_law.rs

1//! Asymmetric rounds whose distances vary along a straight edge.
2//!
3//! On a straight edge between two planes the section at a fraction `s` of
4//! the edge is the conic arc of [`crate::asym`] with control points
5//! `A(s) + d1(s) f1`, `A(s)`, `A(s) + d2(s) f2`: the crease `A` runs
6//! linearly along the edge, the legs `f1`, `f2` and the weight stay as they
7//! are, and each distance is its law's cubic Hermite piece. Every control
8//! point of the section, and of the pieces [`crate::asym::conic_pieces`]
9//! splits it into, is affine in the two distances, so over each stretch
10//! where both laws are one cubic the band is the tensor product of their
11//! Bezier forms with the section's: a rational B-spline, cubic along the
12//! edge, exact. The contact lines are the laws' own cubics pushed along
13//! each face, so the band meets each face with its tangent plane all
14//! along.
15
16use crate::asym::{RoundProfile, conic_pieces, legs, straight_seat};
17use crate::law::{Profile, RadiusLaw};
18use crate::support::{face_from_edges, segment_between};
19use ogeom_algo::{Built, make_edge_between, make_vertex};
20use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
21use ogeom_geom::{BSplineCurve, BSplineSurface, Curve, PlaneSurface, SurfaceGeometry};
22use ogeom_math::bspline::Weighted;
23use ogeom_math::{ControlGrid, Direction, KnotVector, Plane, Point, Point2, Vector};
24use ogeom_topo::{Model, Shape, ShapeType, explore_unique};
25
26/// Round a straight edge between two planar faces with a band that meets
27/// `face` at the distance `on_face` gives and the edge's other face at the
28/// distance `on_other` gives, each a law read by the fraction of the edge
29/// from its start, its section `profile` between the two contacts.
30///
31/// The band is exact: over each stretch where both laws are one cubic it
32/// is the section's rational arc swept with its control points moving
33/// along the laws' own cubics, and it meets each face with its tangent
34/// plane along a contact curve. Naming the other face with the laws
35/// swapped builds the same solid, to the bit, and two laws holding one
36/// distance each are [`fillet_edge_distances`](crate::fillet_edge_distances)
37/// by the same call. The band's ends are square caps: each end of the edge
38/// must meet only faces square to it.
39///
40/// What `solid` shares with other shapes is copied before it is worked on
41/// ([`ogeom_algo::on_own_nodes`]), which leaves them as they are.
42///
43/// # Errors
44///
45/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
46/// where a law states no usable distance, `face` is not one of the edge's
47/// two faces, a conic's `rho` is not strictly between 0 and 1, the edge is
48/// not straight between two planes, an end of the edge meets a face not
49/// square to it, or a contact sets back past its face's side; and as
50/// [`fillet_edge_distances`](crate::fillet_edge_distances) where both laws
51/// hold one distance.
52#[allow(
53    clippy::too_many_arguments,
54    reason = "fillet_edge_distances' own, with laws for the distances"
55)]
56pub fn fillet_edge_distances_variable(
57    model: &mut Model,
58    solid: &Shape,
59    edge: &Shape,
60    face: &Shape,
61    on_face: &RadiusLaw,
62    on_other: &RadiusLaw,
63    profile: RoundProfile,
64    tol: Tolerances,
65) -> OgeomResult<Built> {
66    let laws = [
67        Profile::new(on_face, false, tol)?,
68        Profile::new(on_other, false, tol)?,
69    ];
70    if laws.iter().all(|law| law.level(tol)) {
71        let [a, b] = [laws[0].at(0.0).0, laws[1].at(0.0).0];
72        return crate::fillet_edge_distances(model, solid, edge, face, a, b, profile, tol);
73    }
74    ogeom_algo::on_own_nodes(
75        model,
76        solid,
77        &[edge.clone(), face.clone()],
78        |model, solid, parts| {
79            let mut built = varying(model, solid, &parts[0], &parts[1], &laws, profile, tol)?;
80            crate::support::credit_new_faces(model, solid, &parts[0], &mut built)?;
81            Ok(built)
82        },
83    )
84}
85
86/// How many stations along the edge a varying band's contacts are held to
87/// their faces at, ends included.
88const FIT_STATIONS: u32 = 16;
89
90/// [`fillet_edge_distances_variable`] on a shape every node below which is
91/// its own, the laws in the order the request names the faces.
92fn varying(
93    model: &mut Model,
94    solid: &Shape,
95    edge: &Shape,
96    face: &Shape,
97    laws: &[Profile; 2],
98    profile: RoundProfile,
99    tol: Tolerances,
100) -> OgeomResult<Built> {
101    let (seat, named) = straight_seat(model, solid, edge, face, tol)?;
102    // The laws in the seat's face order.
103    let law = |i: usize| if i == named { &laws[0] } else { &laws[1] };
104    let f = legs(&seat, tol)?;
105    let w = profile.weight(f[0].dot(f[1]).clamp(-1.0, 1.0))?;
106    square_ends(model, solid, edge, &seat, tol)?;
107    let length = seat.start.distance(seat.end);
108    let crease = |s: f64| seat.start + seat.along * (length * s);
109    for k in 0..=FIT_STATIONS {
110        let s = f64::from(k) / f64::from(FIT_STATIONS);
111        for (i, (leg, host)) in f.iter().zip(&seat.faces).enumerate() {
112            let distance = law(i).at(s).0;
113            let contact = crease(s) + *leg * distance;
114            if !crate::support::on_face_or_boundary(model, solid, host, contact, tol)? {
115                ogeom_bail!(
116                    Construction,
117                    "an asymmetric round sets back {distance} along a face of the edge, at \
118                     {s:.3} of it, past that face's side; see docs/CAPABILITIES.md, \
119                     fillet.asymmetric-rounds"
120                );
121            }
122        }
123    }
124
125    let sign = if seat.convex { 1.0 } else { -1.0 };
126    let VaryingNet {
127        first,
128        second,
129        rows,
130        weight,
131        u_knots,
132        v_knots,
133        surface,
134    } = varying_net(&seat, [law(0), law(1)], f, w, tol)?;
135    let (nu, nv) = (rows.len(), rows[0].len());
136    let surface_id = model.geometry_mut().add_surface(surface.clone());
137
138    let apex = [seat.start, seat.end];
139    let contact = |end: usize, side: usize| {
140        rows[if end == 0 { 0 } else { nu - 1 }][if side == 0 { 0 } else { nv - 1 }]
141    };
142    let ca = [contact(0, 0), contact(1, 0)];
143    let cb = [contact(0, 1), contact(1, 1)];
144    let va = [
145        make_vertex(model, ca[0]).shape,
146        make_vertex(model, ca[1]).shape,
147    ];
148    let vb = [
149        make_vertex(model, cb[0]).shape,
150        make_vertex(model, cb[1]).shape,
151    ];
152    let vapex = [
153        make_vertex(model, apex[0]).shape,
154        make_vertex(model, apex[1]).shape,
155    ];
156
157    // The contact curves: each face's column of the net, on the band's
158    // rows `v = 0` and `v = 1`.
159    let rail =
160        |model: &mut Model, column: usize, ends: &[Shape; 2], row_v: f64| -> OgeomResult<Shape> {
161            let control = rows.iter().map(|r| r[column]).collect::<Vec<_>>();
162            let curve = Curve::BSpline(BSplineCurve::new(
163                KnotVector::new(u_knots.clone(), 3)?,
164                control,
165                tol,
166            )?);
167            let built = make_edge_between(model, curve, (0.0, 1.0), &ends[0], &ends[1], tol)?.shape;
168            let pcurve = ogeom_geom::BSpline2d::new(
169                KnotVector::new(vec![0.0, 0.0, 1.0, 1.0], 1)?,
170                vec![Point2::new(0.0, row_v), Point2::new(1.0, row_v)],
171                tol,
172            )?;
173            ogeom_algo::attach_pcurve(
174                model,
175                &built,
176                pcurve.into(),
177                surface_id,
178                ogeom_topo::Location::identity(),
179                (0.0, 1.0),
180            )?;
181            Ok(built)
182        };
183    let rail_a = rail(model, 0, &va, 0.0)?;
184    let rail_b = rail(model, nv - 1, &vb, 1.0)?;
185
186    // The end sections: the net's first and last rows, on the band's
187    // columns `u = 0` and `u = 1`.
188    let arc = |model: &mut Model, end: usize| -> OgeomResult<Shape> {
189        let r = &rows[if end == 0 { 0 } else { nu - 1 }];
190        let control = r
191            .iter()
192            .enumerate()
193            .map(|(j, q)| Weighted::new(*q, if j % 2 == 1 { weight } else { 1.0 }, tol))
194            .collect::<OgeomResult<Vec<_>>>()?;
195        let curve = Curve::BSpline(BSplineCurve::rational(
196            KnotVector::new(v_knots.clone(), 2)?,
197            control,
198        )?);
199        let built = make_edge_between(model, curve, (0.0, 1.0), &va[end], &vb[end], tol)?.shape;
200        #[allow(clippy::cast_precision_loss, reason = "0 or 1")]
201        let column = ogeom_geom::Line2d::over(
202            ogeom_math::Axis2::new(Point2::new(end as f64, 0.0), ogeom_math::Direction2::Y),
203            0.0,
204            1.0,
205        )?;
206        ogeom_algo::attach_pcurve(
207            model,
208            &built,
209            column.into(),
210            surface_id,
211            ogeom_topo::Location::identity(),
212            (0.0, 1.0),
213        )?;
214        Ok(built)
215    };
216    let arc0 = arc(model, 0)?;
217    let arc1 = arc(model, 1)?;
218
219    // The band, facing away from the crease, which the wedge holds.
220    let band = {
221        use ogeom_geom::Surface as _;
222        let wire = ogeom_algo::make_wire(
223            model,
224            &[
225                rail_a.clone(),
226                arc1.clone(),
227                rail_b.reversed(),
228                arc0.reversed(),
229            ],
230            tol,
231        )?
232        .shape;
233        let face =
234            ogeom_algo::make_face_on(model, surface_id, std::slice::from_ref(&wire), tol)?.shape;
235        let middle = surface.point_at(0.5, 0.5, tol)?;
236        let (du, dv) = surface.d1_at(0.5, 0.5, tol)?;
237        if du.cross(dv).dot(middle - crease(0.5)) >= 0.0 {
238            face
239        } else {
240            face.reversed()
241        }
242    };
243
244    let crease_edge = segment_between(model, (&vapex[0], apex[0]), (&vapex[1], apex[1]), tol)?;
245    // A leg on each face, between the crease and its contact curve, facing
246    // as the face does on a convex edge and against it on a concave one.
247    let leg = |model: &mut Model,
248               side: usize,
249               rail: &Shape,
250               contacts: &[Point; 2],
251               ends: &[Shape; 2]|
252     -> OgeomResult<Shape> {
253        let outward = seat.normals[side] * sign;
254        let plane = Plane::through(apex[0], Direction::new(outward, tol)?);
255        let reach =
256            (length + contacts[0].distance(apex[0]) + contacts[1].distance(apex[1]) + 1.0) * 2.0;
257        let surface = PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?;
258        let at0 = segment_between(model, (&vapex[0], apex[0]), (&ends[0], contacts[0]), tol)?;
259        let at1 = segment_between(model, (&ends[1], contacts[1]), (&vapex[1], apex[1]), tol)?;
260        let edges = vec![at0, rail.clone(), at1, crease_edge.reversed()];
261        // Crease start, contact, contact, crease end: walked back where
262        // that turns against `outward`.
263        let turn = (contacts[0] - apex[0]).cross(apex[1] - apex[0]);
264        let edges: Vec<Shape> = if turn.dot(outward) < 0.0 {
265            edges.iter().rev().map(Shape::reversed).collect()
266        } else {
267            edges
268        };
269        face_from_edges(model, surface.into(), &edges, tol)
270    };
271    let leg_a = leg(model, first, &rail_a, &ca, &va)?;
272    let leg_b = leg(model, second, &rail_b, &cb, &vb)?;
273
274    let cap = |model: &mut Model, end: usize, arc: &Shape, outward: Vector| -> OgeomResult<Shape> {
275        let plane = Plane::through(apex[end], Direction::new(outward, tol)?);
276        let reach = (apex[end].distance(ca[end]) + apex[end].distance(cb[end]) + 1.0) * 2.0;
277        let surface = PlaneSurface::over(plane, (-reach, reach), (-reach, reach))?;
278        let edges = vec![
279            segment_between(model, (&vapex[end], apex[end]), (&va[end], ca[end]), tol)?,
280            arc.clone(),
281            segment_between(model, (&vb[end], cb[end]), (&vapex[end], apex[end]), tol)?,
282        ];
283        let turn = (ca[end] - apex[end]).cross(cb[end] - apex[end]);
284        let edges: Vec<Shape> = if turn.dot(outward) < 0.0 {
285            edges.iter().rev().map(Shape::reversed).collect()
286        } else {
287            edges
288        };
289        face_from_edges(model, surface.into(), &edges, tol)
290    };
291    let cap0 = cap(model, 0, &arc0, -seat.along)?;
292    let cap1 = cap(model, 1, &arc1, seat.along)?;
293
294    let faces = [leg_a, leg_b, cap0, cap1, band];
295    let wedge = crate::support::wedge_solid(model, &faces, false, tol)?;
296    crate::support::apply_wedge_solid(model, solid, Some(edge), &wedge, !seat.convex, tol)
297}
298
299/// The band of a varying asymmetric round on a straight seat between two
300/// planes: the faces in the order the section runs, the net's rows (one
301/// per control value along the edge, each the section's pieces across),
302/// the pieces' middle weight, both knot vectors, and the surface.
303pub(crate) struct VaryingNet {
304    /// The seat's face the section starts on.
305    pub first: usize,
306    /// The face it ends on.
307    pub second: usize,
308    /// The control points, row by row along the edge.
309    pub rows: Vec<Vec<Point>>,
310    /// The weight of every middle control point across.
311    pub weight: f64,
312    /// The knots along the edge, on the edge's fraction.
313    pub u_knots: Vec<f64>,
314    /// The knots across, on `[0, 1]`.
315    pub v_knots: Vec<f64>,
316    /// The band.
317    pub surface: SurfaceGeometry,
318}
319
320/// [`VaryingNet`] of `seat` whose distance on each face, in the seat's
321/// face order, follows `laws` by the fraction of the edge, its legs `f`
322/// and its section's weight `w`.
323pub(crate) fn varying_net(
324    seat: &crate::support::Seat,
325    laws: [&Profile; 2],
326    f: [Vector; 2],
327    w: f64,
328    tol: Tolerances,
329) -> OgeomResult<VaryingNet> {
330    let length = seat.start.distance(seat.end);
331    let crease = |s: f64| seat.start + seat.along * (length * s);
332    // The faces in the order that turns positively about the crease, so the
333    // section runs from the first face's contact to the second's.
334    let (first, second) = if seat
335        .along
336        .dot(seat.normals[0].cross(seat.normals[1]))
337        .is_sign_positive()
338    {
339        (0, 1)
340    } else {
341        (1, 0)
342    };
343
344    // Along the edge: the union of both laws' pieces, each law one cubic
345    // over each stretch, in Bezier form.
346    let pieces = [laws[first].pieces(tol), laws[second].pieces(tol)];
347    let mut breaks: Vec<f64> = pieces
348        .iter()
349        .flat_map(|p| p.iter().flat_map(|(a, b, _)| [*a, *b]))
350        .collect();
351    breaks.sort_by(f64::total_cmp);
352    breaks.dedup_by(|a, b| (*a - *b).abs() <= tol.parametric());
353    let windowed = |which: usize, a: f64, b: f64| -> [f64; 4] {
354        let middle = 0.5 * (a + b);
355        let &(x0, x1, bezier) = pieces[which]
356            .iter()
357            .find(|(x0, x1, _)| *x0 <= middle && middle <= *x1)
358            .unwrap_or(&pieces[which][0]);
359        bezier_window(bezier, (x0, x1), (a, b))
360    };
361    // The distances' control values along the edge, end to end, and the
362    // crease's at the same places.
363    let mut along: Vec<(f64, f64, Point)> = Vec::new();
364    for (k, span) in breaks.windows(2).enumerate() {
365        let (a, b) = (span[0], span[1]);
366        let (da, db) = (windowed(0, a, b), windowed(1, a, b));
367        for i in usize::from(k > 0)..4 {
368            #[allow(clippy::cast_precision_loss, reason = "a Bezier index")]
369            let t = a + (b - a) * i as f64 / 3.0;
370            along.push((da[i], db[i], crease(t)));
371        }
372    }
373    let mut u_knots = vec![0.0; 4];
374    for &k in &breaks[1..breaks.len() - 1] {
375        u_knots.extend([k; 3]);
376    }
377    u_knots.extend([1.0; 4]);
378
379    // Across: the section's pieces, one row of the net per control value
380    // along the edge.
381    let row = |(da, db, apex): (f64, f64, Point)| {
382        conic_pieces([apex + f[first] * da, apex, apex + f[second] * db], w)
383    };
384    let (_, weight, v_knots) = row(along[0]);
385    let rows: Vec<Vec<Point>> = along.iter().map(|&c| row(c).0).collect();
386    let (nu, nv) = (rows.len(), rows[0].len());
387    let mut net = Vec::with_capacity(nu * nv);
388    for r in &rows {
389        for (j, q) in r.iter().enumerate() {
390            net.push(Weighted::new(
391                *q,
392                if j % 2 == 1 { weight } else { 1.0 },
393                tol,
394            )?);
395        }
396    }
397    let surface: SurfaceGeometry = BSplineSurface::rational(
398        KnotVector::new(u_knots.clone(), 3)?,
399        KnotVector::new(v_knots.clone(), 2)?,
400        ControlGrid::new(net, nu, nv)?,
401    )?
402    .into();
403    Ok(VaryingNet {
404        first,
405        second,
406        rows,
407        weight,
408        u_knots,
409        v_knots,
410        surface,
411    })
412}
413
414/// The cubic Bezier `b` over `(x0, x1)` restricted to `(a, b)` within it,
415/// in Bezier form there: its blossom at the window's ends.
416fn bezier_window(bezier: [f64; 4], (x0, x1): (f64, f64), (a, b): (f64, f64)) -> [f64; 4] {
417    let h = x1 - x0;
418    let (ta, tb) = ((a - x0) / h, (b - x0) / h);
419    // The blossom of the cubic at three parameters, by de Casteljau with a
420    // different parameter at each level.
421    let blossom = |t: [f64; 3]| -> f64 {
422        let mut p = bezier;
423        for (level, &x) in t.iter().enumerate() {
424            for i in 0..3 - level {
425                p[i] = p[i] * (1.0 - x) + p[i + 1] * x;
426            }
427        }
428        p[0]
429    };
430    [
431        blossom([ta, ta, ta]),
432        blossom([ta, ta, tb]),
433        blossom([ta, tb, tb]),
434        blossom([tb, tb, tb]),
435    ]
436}
437
438/// Refuses by name where an end of the straight seat's edge meets a face
439/// of `solid`, other than the edge's own two, whose plane is not square to
440/// the edge: a band capped square there would leave or cut what that face
441/// bounds.
442fn square_ends(
443    model: &Model,
444    solid: &Shape,
445    edge: &Shape,
446    seat: &crate::support::Seat,
447    tol: Tolerances,
448) -> OgeomResult<()> {
449    let Some((a, b)) = ogeom_algo::edge_vertices(model, edge)? else {
450        return Ok(());
451    };
452    for face in explore_unique(model, solid, ShapeType::Face)? {
453        if seat.faces.iter().any(|f| f.is_same(&face)) {
454            continue;
455        }
456        let vertices = explore_unique(model, &face, ShapeType::Vertex)?;
457        let meets = vertices.iter().any(|v| {
458            crate::support::same_occurrence(model, v, &a, tol)
459                || crate::support::same_occurrence(model, v, &b, tol)
460        });
461        if !meets {
462            continue;
463        }
464        let square = match face_normal(model, &face, tol)? {
465            Some(normal) => normal.cross(seat.along).magnitude() <= tol.angular(),
466            None => false,
467        };
468        if !square {
469            ogeom_bail!(
470                Construction,
471                "an asymmetric round whose distances vary is capped square at its edge's \
472                 ends, and an end of this edge meets a face not square to it; see \
473                 docs/CAPABILITIES.md, fillet.asymmetric-rounds"
474            );
475        }
476    }
477    Ok(())
478}
479
480/// A planar face's unit normal, placed; `None` for a face on any other
481/// surface.
482fn face_normal(model: &Model, face: &Shape, tol: Tolerances) -> OgeomResult<Option<Vector>> {
483    use ogeom_geom::Transformable as _;
484    let Some(data) = model.node(face).and_then(|n| n.data().as_face()) else {
485        return Ok(None);
486    };
487    let Some(surface) = model.geometry().surface(data.surface) else {
488        return Ok(None);
489    };
490    let placed = surface
491        .clone()
492        .transformed(&face.transform(model.datums())?, tol)?;
493    Ok(match placed {
494        SurfaceGeometry::Plane(p) => Some(p.plane().normal().vector()),
495        _ => None,
496    })
497}