Taper any profile, sketch text included (#1014)

A tapered extrude now offsets spline and conic curves as well as lines and arcs, so sketch text and any other profile can take a taper, and the join of tapered letters onto a face finds its boolean result.
This commit is contained in:
phanoob authored and GitHub committed 2026-10-14 09:47:44 -04:00
1 parent d114c58540
commit 4dbc0efff9
6 files changed
+619 -136

No files matched your search

+93
View File
@@ -490,6 +490,99 @@ fn tapered_extrude_of_lines_and_arcs() {
assert!(e.to_string().contains("arc meets another edge at a corner"), "{e}");
}
/// A circle drawn as four conics (the way free curves come, as in text) tapered inward: each
/// conic's control polygon moves in by d, which for a circle is the exact smaller circle, so
/// the solid is a cone frustum.
#[test]
fn tapered_extrude_of_conics() {
let (r, h, a) = (10.0, 12.0, 8f64.to_radians());
let w = std::f64::consts::FRAC_1_SQRT_2;
let p = |x: f64, y: f64| Vec2::new(x, y);
let disc = Loop2 {
segs: vec![
Seg2::Conic { a: p(r, 0.0), apex: p(r, r), b: p(0.0, r), w },
Seg2::Conic { a: p(0.0, r), apex: p(-r, r), b: p(-r, 0.0), w },
Seg2::Conic { a: p(-r, 0.0), apex: p(-r, -r), b: p(0.0, -r), w },
Seg2::Conic { a: p(0.0, -r), apex: p(r, -r), b: p(r, 0.0), w },
],
};
let b = extrude_tapered(&Plane::XY, &Region2 { outer: disc, holes: vec![] }, h, 1.0, -a).unwrap();
let r1 = r - h * a.tan();
let v = PI * h / 3.0 * (r * r + r1 * r1 + r * r1);
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 1e-3, "{} vs {v}", m.volume);
assert!((m.bbox.max.x - r).abs() < 1e-6 && (m.bbox.max.z - h).abs() < 1e-6, "{:?}", m.bbox);
}
/// A wedge whose tip is a flat line far shorter than the taper moves its sides in: the tip line
/// goes, the two long sides meet, and its side is the flat triangle up to there. The solid is a
/// prismatoid, its volume h/6 · (A₀ + 4·A_mid + A₁).
#[test]
fn tapered_extrude_swallows_a_short_tip() {
let (h, a) = (2.0, 5f64.to_radians());
let d = h * a.tan();
let pts = [Vec2::new(0.0, -2.0), Vec2::new(20.0, -0.01), Vec2::new(20.0, 0.01), Vec2::new(0.0, 2.0)];
let b = extrude_tapered(&Plane::XY, &Region2 { outer: Loop2::polygon(&pts), holes: vec![] }, h, 1.0, -a).unwrap();
// The top: the three long lines moved in by d (to their left, the loop running
// counter-clockwise), meeting each other.
let moved = |p: Vec2, q: Vec2| {
let t = (q - p).normalized().unwrap();
(p + t.perp() * d, t)
};
let meet = |(p0, t0): (Vec2, Vec2), (p1, t1): (Vec2, Vec2)| p0 + t0 * ((p1 - p0).cross(t1) / t0.cross(t1));
let (low, high, left) = (moved(pts[0], pts[1]), moved(pts[2], pts[3]), moved(pts[3], pts[0]));
let top = [meet(left, low), meet(low, high), meet(high, left)];
let area = |q: &[Vec2]| 0.5 * q.iter().zip(q.iter().cycle().skip(1)).map(|(a, b)| a.cross(*b)).sum::<f64>();
// Halfway up, each side edge's midpoint (the two tip corners both run to the top's tip).
let mid = [pts[0].lerp(top[0], 0.5), pts[1].lerp(top[1], 0.5), pts[2].lerp(top[1], 0.5), pts[3].lerp(top[2], 0.5)];
let v = h / 6.0 * (area(&pts) + 4.0 * area(&mid) + area(&top));
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 1e-6, "{} vs {v}", m.volume);
assert_eq!(m.merged.faces, 6, "bottom, top, three sides and the tip's triangle");
}
/// A taper from below the profile, with a foot: the sides stand straight for the foot's height,
/// then slope on to the top, and the section at the profile's plane is the profile.
#[test]
fn tapered_extrude_between_with_a_foot() {
let a = 10f64.to_radians();
let sq = Region2 {
outer: Loop2::polygon(&[Vec2::new(-20.0, -20.0), Vec2::new(20.0, -20.0), Vec2::new(20.0, 20.0), Vec2::new(-20.0, 20.0)]),
holes: vec![],
};
let (from, to, foot) = (-2.0, 10.0, 0.5);
let b = extrude_tapered_between(&Plane::XY, &sq, from, to, foot, 1.0, a).unwrap();
let side = |z: f64| 40.0 + 2.0 * z * a.tan();
let (s0, s1, rise) = (side(from), side(to), to - from - foot);
let v = foot * s0 * s0 + rise / 3.0 * (s0 * s0 + s1 * s1 + s0 * s1);
let m = measure(&b).unwrap();
assert!(rel(m.volume, v) < 1e-6, "{} vs {v}", m.volume);
assert!((m.bbox.min.z - from).abs() < 1e-6 && (m.bbox.max.z - to).abs() < 1e-6, "{:?}", m.bbox);
assert_eq!(m.merged.faces, 10);
assert!(extrude_tapered_between(&Plane::XY, &sq, from, to, 7.0, 1.0, a).is_err(), "a foot taller than half the solid");
}
/// A taper steeper than a profile's details is refused, and says why: a slot narrower than the
/// taper closes; a ring's hole smaller than it closes.
#[test]
fn tapered_extrude_refuses_a_taper_steeper_than_the_details() {
let slot = Loop2::polygon(&[
Vec2::new(0.0, 0.0),
Vec2::new(10.0, 0.0),
Vec2::new(10.0, 10.0),
Vec2::new(5.1, 10.0),
Vec2::new(5.1, 2.0),
Vec2::new(4.9, 2.0),
Vec2::new(4.9, 10.0),
Vec2::new(0.0, 10.0),
]);
let e = extrude_tapered(&Plane::XY, &Region2 { outer: slot, holes: vec![] }, 2.0, 1.0, 10f64.to_radians()).unwrap_err();
assert!(e.to_string().contains("the taper closes the profile"), "{e}");
let ring = Region2 { outer: Loop2::circle(Vec2::ZERO, 5.0), holes: vec![Loop2::circle(Vec2::ZERO, 0.3).reversed()] };
let e = extrude_tapered(&Plane::XY, &ring, 2.0, 1.0, 10f64.to_radians()).unwrap_err();
assert!(e.to_string().contains("the taper closes the profile"), "{e}");
}
#[test]
fn polyhedra_shell_and_draft() {
let b = box_solid(Vec3::ZERO, Vec3::new(50.0, 40.0, 30.0)).unwrap();