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

+61 -3
View File
@@ -1789,6 +1789,26 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi:
(lo2, hi2)
}
/// How high (mm) a tapered tool's sides stand straight where it starts on a face it can't run
/// on into.
const TAPER_FOOT: f64 = 0.01;
/// Whether a face of the targets (all bodies when none are named) lies in the plane at height
/// `h` along `plane`'s normal.
fn face_level_at(st: &ModelState, plane: &Plane, h: f64, targets: &[String]) -> bool {
let n = plane.normal();
st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).any(|b| {
let m = b.mesh();
let tol = (b.body.size() * 1e-7).max(1e-9);
m.triangles.iter().any(|t| {
m.tri(t).is_some_and(|[a, b, c]| {
(b - a).cross(c - a).normalized().is_some_and(|normal| normal.dot(n).abs() > 1.0 - 1e-9)
&& ((a - plane.origin).dot(n) - h).abs() < tol
})
})
})
}
/// The body a face-based feature applies to: by name, or the one whose surface is nearest the
/// first point.
fn body_at(st: &ModelState, body: &Option<String>, points: &[Vec3]) -> Result<usize> {
@@ -2582,13 +2602,51 @@ fn feature_tools_with_constructor(
Some(crate::lineage::extrude_cap_role(from)),
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { from - len } else { from + len })),
];
return regions
// How far a tool grows past its profile at either end, at most.
let mut growth: f64 = 0.0;
let tools: Vec<Traced> = regions
.iter()
.map(|r| {
let body = kernel::extrude_tapered(&start_plane, r, len, sign, taper)?;
// An end on a face of the target runs on into it, as a prism's does: tapered
// sides standing on a face are curved where they meet it (along text, say),
// and the boolean finds no result there. The sides keep their slope, so
// the profile stays the section at the start.
let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) };
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo, hi, *operation, targets);
let (a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) };
// A start that can't run on (the profile hangs over the face's edge):
// the sides stand straight for a hair first, so the boolean can push the
// face the tool stands on into the body.
let foot = if a >= 0.0 && matches!(operation, Operation::Join | Operation::Cut) && face_level_at(st, &ss.plane, from, targets)
{
TAPER_FOOT.min((b - a) * 0.01)
} else {
0.0
};
let body = kernel::extrude_tapered_between(&start_plane, r, a, b, foot, sign, taper)?;
growth = growth.max(a.abs().max(b.abs()) * taper.tan().abs());
Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None))
})
.collect();
.collect::<Result<_>>()?;
// Profiles apart by more than the tools grow (the letters of a text): the tools
// are apart too, and go into the body as one, in one boolean rather than one per
// profile, each on a body grown or cut by the ones before. (A join meeting several
// bodies keeps joining them tool by tool; sheet metal keeps cutting tool by tool.)
let sheet =
if targets.is_empty() { !st.sheets.is_empty() } else { targets.iter().any(|t| st.sheets.iter().any(|sheet| &sheet.body == t)) };
let one_target = if targets.is_empty() { st.bodies.len() == 1 } else { targets.len() == 1 };
let at_once = match operation {
Operation::Cut => !sheet,
Operation::Join => one_target && !sheet,
_ => false,
};
if at_once && tools.len() > 1 && regions_apart(&regions, APART_GAP + 2.0 * growth) {
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
if let Ok(Some(one)) = kernel::join_apart(&bodies) {
return Ok(vec![Traced::step(one, &tools.iter().collect::<Vec<_>>())]);
}
}
return Ok(tools);
}
let off = from
+ match &extent.start_offset {
@@ -730,6 +730,41 @@ fn cutting_a_text_removes_each_letter_whether_its_letters_stand_apart_or_touch()
}
}
/// A text joined onto a block with a taper: its letters are spline and conic outlines whose
/// sides slope, standing on the block's face (run on into it) or hanging over its edge (a
/// straight foot first). An outward taper adds a little more than the straight text; one
/// steeper than the letters' strokes is refused, saying so.
#[test]
fn a_tapered_text_joins_a_block_on_its_face_or_hanging_over_its_edge() {
for (at, hanging) in [([5.0, 12.0], false), ([150.0, 12.0], true)] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[200,40]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":-10}));
run(&mut s, "sketch.create", json!({"plane":"XY"}));
run(&mut s, "sketch.text", json!({"text":"tres long","at":at,"height":8}));
let state = s.world_state();
let ss = state.sketch(s.active_sketch.unwrap()).unwrap();
// The letters' area, counters left out (a letter's hole is its own profile too).
let sorted = |ids: &[String]| {
let mut ids = ids.to_vec();
ids.sort();
ids
};
let counter = |p: &solidcraft_sketch::Profile| ss.profiles.iter().any(|q| q.hole_curves.iter().any(|h| sorted(h) == sorted(&p.outer_curves)));
let area: f64 = ss.profiles.iter().filter(|p| !counter(p)).map(|p| p.area).sum();
run(&mut s, "sketch.finish", json!({}));
let steep = s.execute("solid.extrude", &json!({"distance":2,"operation":"join","taper":"15 deg"})).unwrap_err();
assert!(steep.to_string().contains("the taper closes the profile"), "{steep}");
run(&mut s, "solid.extrude", json!({"distance":2,"operation":"join","taper":"3 deg"}));
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let (block, text) = (200.0 * 40.0 * 10.0, 2.0 * area);
assert!(volume > block + text && volume < block + 1.3 * text, "hanging {hanging}: {volume} for {text} mm³ of straight text");
assert_eq!(s.world_state().bodies.len(), 1);
}
}
/// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top
/// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front
/// face and in a knife-edge tip where it runs into the back face.
+422 -132
View File
@@ -16,11 +16,6 @@ fn finite(x: f64, what: &str) -> Result<f64> {
if x.is_finite() && x.abs() < 1e7 { Ok(x) } else { Err(KernelError::Invalid(format!("{what} = {x}"))) }
}
/// A closed wire in 3D from a sketch loop.
pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
wire_sources(plane, lp).map(|(wire, _)| wire)
}
/// A closed wire in 3D from a sketch loop, with each edge's segment and that segment's
/// position in the loop (a big arc makes two edges).
fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2, usize)>)> {
@@ -73,33 +68,39 @@ fn chain_sources(plane: &Plane, segs: &[Seg2], closed: bool) -> Result<(mt::Wire
let mut sources = Vec::with_capacity(n);
for (i, s) in segs.iter().enumerate() {
let (Some(a), Some(b)) = (verts.get(i), verts.get(if closed { (i + 1) % n } else { i + 1 })) else { continue };
let e: mt::Edge = match *s {
Seg2::Line { .. } => builder::line(a, b),
Seg2::Arc { sweep, .. } => {
if sweep.abs() < 1e-9 {
return Err(KernelError::Invalid("zero-length arc".into()));
}
// Split big arcs so every piece is well conditioned.
if sweep.abs() > std::f64::consts::PI * 1.5 {
let vm = builder::vertex(p3(plane.to_world(s.mid())));
let first = builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))));
sources.push((first.clone(), *s, i));
edges.push(first);
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75))))
} else {
builder::circle_arc(a, b, p3(plane.to_world(s.mid())))
}
}
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))?
}
};
sources.push((e.clone(), *s, i));
edges.push(e);
for e in seg_edges(plane, s, a, b)? {
sources.push((e.clone(), *s, i));
edges.push(e);
}
}
Ok((edges.into(), sources))
}
/// The edges of one segment from `a` to `b` (a big arc makes two).
fn seg_edges(plane: &Plane, s: &Seg2, a: &mt::Vertex, b: &mt::Vertex) -> Result<Vec<mt::Edge>> {
Ok(match *s {
Seg2::Line { .. } => vec![builder::line(a, b)],
Seg2::Arc { sweep, .. } => {
if sweep.abs() < 1e-9 {
return Err(KernelError::Invalid("zero-length arc".into()));
}
// Split big arcs so every piece is well conditioned.
if sweep.abs() > std::f64::consts::PI * 1.5 {
let vm = builder::vertex(p3(plane.to_world(s.mid())));
vec![
builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25)))),
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75)))),
]
} else {
vec![builder::circle_arc(a, b, p3(plane.to_world(s.mid())))]
}
}
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
vec![crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))?]
}
})
}
/// An edge of a profile wire: the segment it was built from and the profile segments under it.
pub(crate) struct ProfileEdge {
pub edge: mt::Edge,
@@ -113,13 +114,6 @@ pub(crate) struct ProfileWire {
pub edges: Vec<ProfileEdge>,
}
impl ProfileWire {
/// The profile segments under each edge, in wire order.
fn numbers(&self) -> Vec<Vec<usize>> {
self.edges.iter().map(|e| e.numbers.clone()).collect()
}
}
/// The wires of a region oriented as the builders build it ([`NumberedRegion::oriented`]), each
/// with what its edges were built from.
fn region_wires(plane: &Plane, r: &NumberedRegion) -> Result<Vec<ProfileWire>> {
@@ -537,154 +531,450 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
}
/// Offset every segment of a loop to its right by `d` (outward for a region's outer loop and
/// holes alike). Loops of lines (mitred corners), loops of arcs on one circle, and loops of lines
/// and arcs that meet the arcs tangentially are supported.
fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
/// holes alike), each segment as one of the same kind (`None`: a line the offset swallows, whose
/// neighbours now meet at one point). Loops of arcs on one circle, and loops of lines, arcs and
/// free curves (spline and conic pieces, such as the outlines of text) where the arcs meet their
/// neighbours tangentially are supported.
fn offset_loop(lp: &Loop2, d: f64) -> Result<Vec<Option<Seg2>>> {
if d == 0.0 {
return Ok(lp.clone());
return Ok(lp.segs.iter().copied().map(Some).collect());
}
let all_arcs = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }));
if all_arcs {
let segs = lp
return lp
.segs
.iter()
.map(|s| match *s {
Seg2::Arc { center, radius, start, sweep } => {
let r = radius + sweep.signum() * d;
if r > 1e-6 {
Ok(Seg2::Arc { center, radius: r, start, sweep })
Ok(Some(Seg2::Arc { center, radius: r, start, sweep }))
} else {
Err(KernelError::Invalid("the taper closes the profile".into()))
}
}
_ => Err(KernelError::Failed("not supported yet: tapered profiles mixing lines and arcs".into())),
_ => Err(KernelError::Failed("taper: profile loop".into())),
})
.collect::<Result<Vec<_>>>()?;
return Ok(Loop2 { segs });
.collect();
}
if lp.segs.iter().any(|s| matches!(s, Seg2::Arc { .. })) {
return offset_lines_and_arcs(lp, d);
}
let n = lp.segs.len();
let mut lines: Vec<(Vec2, Vec2)> = Vec::with_capacity(n);
for s in &lp.segs {
let Seg2::Line { a, b } = *s else { return Err(KernelError::Failed("not supported yet: tapered profiles mixing lines and arcs".into())) };
let dir = (b - a).normalized().ok_or_else(|| KernelError::Invalid("zero-length edge".into()))?;
let right = Vec2::new(dir.y, -dir.x);
lines.push((a + right * d, dir));
}
let mut pts = Vec::with_capacity(n);
for i in 0..n {
let (Some(&(p0, d0)), Some(&(p1, d1))) = (lines.get((i + n - 1) % n), lines.get(i)) else { continue };
let den = d0.cross(d1);
if den.abs() < 1e-12 {
pts.push(p1);
continue;
}
let t = (p1 - p0).cross(d1) / den;
pts.push(p0 + d0 * t);
}
let out = Loop2::polygon(&pts);
if out.signed_area().signum() != lp.signed_area().signum() || out.signed_area().abs() < 1e-9 {
return Err(KernelError::Invalid("the taper closes the profile".into()));
}
Ok(out)
offset_segments(lp, d)
}
/// [`offset_loop`] for a loop of lines and arcs. Each arc keeps its centre and angles and its
/// radius moves by `d` (so its side stays an exact cone), each line moves `d` to its right, and
/// two lines meeting at a corner are mitred. An arc has to meet its neighbours tangentially: at
/// a corner its side would meet the next one along a conic, not a straight ruling.
fn offset_lines_and_arcs(lp: &Loop2, d: f64) -> Result<Loop2> {
/// Where the line through `p` along `tin` and the one through `q` along `tout` meet once each
/// has moved `d` to its right (`q` moved, where they run on together).
fn mitre(p: Vec2, tin: Vec2, q: Vec2, tout: Vec2, d: f64) -> Vec2 {
let right = |t: Vec2| Vec2::new(t.y, -t.x);
let (p0, p1) = (p + right(tin) * d, q + right(tout) * d);
let den = tin.cross(tout);
if den.abs() < 1e-12 { p1 } else { p0 + tin * ((p1 - p0).cross(tout) / den) }
}
/// A free curve's control polygon moved `d` to its right, from `a` to `b` (where its moved
/// neighbours meet it): each leg moves `d` to its right and each inner control point goes where
/// its two moved legs meet. For a curve that turns little against `d` this stays close to the
/// true offset, and its ends run along the curve's end tangents, so its side meets its
/// neighbours' along straight rulings.
fn offset_polygon(ctrl: &[Vec2], a: Vec2, b: Vec2, d: f64) -> Option<Vec<Vec2>> {
let legs: Vec<Option<Vec2>> = ctrl.iter().zip(ctrl.iter().skip(1)).map(|(p, q)| (*q - *p).normalized()).collect();
// A leg of no length takes the direction of the nearest leg after it, else before it.
let dirs: Vec<Vec2> = (0..legs.len())
.map(|i| legs.iter().skip(i).flatten().next().or_else(|| legs.iter().take(i).rev().flatten().next()).copied())
.collect::<Option<_>>()?;
let m = ctrl.len();
let mut out = Vec::with_capacity(m);
for (i, p) in ctrl.iter().enumerate() {
out.push(if i == 0 {
a
} else if i + 1 == m {
b
} else {
mitre(*p, *dirs.get(i - 1)?, *p, *dirs.get(i)?, d)
});
}
out.iter().all(|p| p.is_finite()).then_some(out)
}
/// [`offset_loop`] for a loop of lines, arcs and free curves. Each arc keeps its centre and
/// angles and its radius moves by `d` (so its side stays an exact cone), each line moves `d` to
/// its right, and each free curve moves its control polygon ([`offset_polygon`]). Where two
/// moved segments part (a corner the offset goes round) they are carried on along their end
/// tangents to where they meet; where they run into each other (a corner the offset goes into)
/// both are cut where they cross. A line shorter than the offset can turn around between its
/// moved neighbours (the flat tip of a letter's stroke): it goes, and its neighbours meet. An arc
/// has to meet its neighbours tangentially: at a corner its side would meet the next one along a
/// conic, not a straight ruling.
fn offset_segments(lp: &Loop2, d: f64) -> Result<Vec<Option<Seg2>>> {
let closes = || KernelError::Invalid("the taper closes the profile".into());
let right = |v: Vec2| Vec2::new(v.y, -v.x);
let at_corner = || KernelError::Failed("not supported yet: tapered profiles where an arc meets another edge at a corner".into());
let bad = || KernelError::Failed("taper: profile loop".into());
let right = |t: Vec2| Vec2::new(t.y, -t.x);
let n = lp.segs.len();
let moved: Vec<Option<Seg2>> = lp
.segs
.iter()
.map(|s| match *s {
Seg2::Line { .. } => Ok(None),
Seg2::Arc { center, radius, start, sweep } => {
let r = radius + sweep.signum() * d;
if r > 1e-6 { Ok(Some(Seg2::Arc { center, radius: r, start, sweep })) } else { Err(closes()) }
}
_ => Err(KernelError::Failed("not supported yet: tapered profiles with splines or conics".into())),
_ => Ok(None),
})
.collect::<Result<_>>()?;
// Where each segment starts after the offset.
let mut joints = Vec::with_capacity(n);
for (i, s) in lp.segs.iter().enumerate() {
let k = (i + n - 1) % n;
let (Some(prev), Some(prev_moved), Some(this_moved)) = (lp.segs.get(k), moved.get(k), moved.get(i)) else {
return Err(KernelError::Failed("taper: profile loop".into()));
};
let (tin, tout) = (prev.end_tangent(), s.start_tangent());
let tangent = tin.cross(tout).abs() < 1e-6 && tin.dot(tout) > 0.0;
joints.push(match (prev_moved, this_moved) {
(Some(a), _) if tangent => a.end(),
(None, Some(b)) if tangent => b.start(),
(None, None) => {
let v = s.start();
let (p0, p1) = (v + right(tin) * d, v + right(tout) * d);
let den = tin.cross(tout);
if den.abs() < 1e-12 { p1 } else { p0 + tin * ((p1 - p0).cross(tout) / den) }
}
_ => return Err(KernelError::Failed("not supported yet: tapered profiles where an arc meets another edge at a corner".into())),
});
}
let mut segs = Vec::with_capacity(n);
for (i, s) in lp.segs.iter().enumerate() {
let (Some(&a), Some(&b), Some(m)) = (joints.get(i), joints.get((i + 1) % n), moved.get(i)) else {
return Err(KernelError::Failed("taper: profile loop".into()));
};
match m {
Some(arc) => {
if arc.start().dist(a) > 1e-6 || arc.end().dist(b) > 1e-6 {
return Err(KernelError::Failed("not supported yet: tapered profiles where an arc meets another edge at a corner".into()));
let mut gone = vec![false; n];
// Each round takes away at least one line, or ends.
let pieces = loop {
let kept: Vec<usize> = (0..n).filter(|&i| !gone.get(i).copied().unwrap_or(true)).collect();
if kept.len() < 2 {
return Err(closes());
}
// Where each kept segment starts and ends once moved, and the corners where two moved
// segments run into each other (the one before, the one after).
let (mut starts, mut ends) = (vec![Vec2::ZERO; n], vec![Vec2::ZERO; n]);
let mut into: Vec<(usize, usize)> = Vec::new();
for (j, &i) in kept.iter().enumerate() {
let Some(&k) = kept.get((j + kept.len() - 1) % kept.len()) else { return Err(bad()) };
let (Some(prev), Some(s), Some(prev_moved), Some(this_moved)) = (lp.segs.get(k), lp.segs.get(i), moved.get(k), moved.get(i)) else {
return Err(bad());
};
let (tin, tout) = (prev.end_tangent(), s.start_tangent());
let adjacent = (k + 1) % n == i;
let turn = tin.cross(tout);
let tangent = adjacent && turn.abs() < 1e-6 && tin.dot(tout) > 0.0;
let (end, start) = match (prev_moved, this_moved) {
(Some(a), _) if tangent => (a.end(), a.end()),
(None, Some(b)) if tangent => (b.start(), b.start()),
// Two sides running back towards each other across a gone tip: the stroke is
// thinner than the taper.
(None, None) if !adjacent && turn.abs() < 1e-9 => return Err(closes()),
(None, None) if !tangent && turn * d < 0.0 => {
into.push((k, i));
(prev.end() + right(tin) * d, s.start() + right(tout) * d)
}
segs.push(*arc);
}
None => {
// A line that turned around has been swallowed by its neighbours.
if (b - a).dot(s.end() - s.start()) <= 0.0 {
return Err(closes());
(None, None) => {
let m = mitre(prev.end(), tin, s.start(), tout, d);
(m, m)
}
segs.push(Seg2::Line { a, b });
_ => return Err(at_corner()),
};
if let (Some(e), Some(st)) = (ends.get_mut(k), starts.get_mut(i)) {
(*e, *st) = (end, start);
}
}
}
let out = Loop2 { segs };
let mut pieces: Vec<Option<Seg2>> = vec![None; n];
for &i in &kept {
let (Some(s), Some(m), Some(&a), Some(&b)) = (lp.segs.get(i), moved.get(i), starts.get(i), ends.get(i)) else { return Err(bad()) };
let curve = |ctrl: &[Vec2]| offset_polygon(ctrl, a, b, d).ok_or_else(closes);
let piece = match (m, *s) {
(Some(arc), _) => {
if arc.start().dist(a) > 1e-6 || arc.end().dist(b) > 1e-6 {
return Err(at_corner());
}
*arc
}
(None, Seg2::Line { .. }) => Seg2::Line { a, b },
(None, Seg2::Cubic { p0, p1, p2, p3 }) => match curve(&[p0, p1, p2, p3])?[..] {
[p0, p1, p2, p3] => Seg2::Cubic { p0, p1, p2, p3 },
_ => return Err(bad()),
},
(None, Seg2::Conic { a: c0, apex, b: c1, w }) => match curve(&[c0, apex, c1])?[..] {
[a, apex, b] => Seg2::Conic { a, apex, b, w },
_ => return Err(bad()),
},
(None, Seg2::Bezier { n: degree, p }) => {
let k = (degree as usize).clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE);
let mut q = p;
for (dst, src) in q.iter_mut().zip(curve(p.get(..=k).ok_or_else(bad)?)?) {
*dst = src;
}
Seg2::Bezier { n: degree, p: q }
}
(None, Seg2::Arc { .. }) => return Err(bad()),
};
if let Some(slot) = pieces.get_mut(i) {
*slot = Some(piece);
}
}
// Cut the two pieces at each corner the offset goes into where they cross; pieces that
// don't cross meet where their end tangents do.
for &(k, i) in &into {
let (Some(Some(p)), Some(Some(q))) = (pieces.get(k).copied(), pieces.get(i).copied()) else { return Err(bad()) };
let (p, q) = match meet(&p, &q) {
Some((tp, tq, x)) => (with_end(&p.split_at(tp).0, x), with_start(&q.split_at(tq).1, x)),
None => {
let x = mitre(p.end(), p.end_tangent(), q.start(), q.start_tangent(), 0.0);
(with_end(&p, x), with_start(&q, x))
}
};
if let Some(a) = pieces.get_mut(k) {
*a = Some(p);
}
if let Some(b) = pieces.get_mut(i) {
*b = Some(q);
}
}
let mut more = false;
for &i in &kept {
// A line that turned around has been swallowed by its neighbours.
if let (Some(Seg2::Line { a: s0, b: s1 }), Some(Some(Seg2::Line { a, b }))) = (lp.segs.get(i), pieces.get(i))
&& (*b - *a).dot(*s1 - *s0) <= 0.0
&& let Some(g) = gone.get_mut(i)
{
*g = true;
more = true;
}
}
if !more {
break pieces;
}
};
let out = Loop2 { segs: pieces.iter().flatten().copied().collect() };
if out.signed_area().signum() != lp.signed_area().signum() || out.signed_area().abs() < 1e-9 {
return Err(closes());
}
Ok(out)
Ok(pieces)
}
/// Where the end of `p` crosses the start of `q`, nearest the corner between them: the
/// parameters on each and the point. On polylines of a few dozen points each, the point taken
/// half way between the two crossing pieces.
fn meet(p: &Seg2, q: &Seg2) -> Option<(f64, f64, Vec2)> {
const SAMPLES: usize = 48;
let samples = |s: &Seg2| -> Vec<(f64, Vec2)> {
let n = if matches!(s, Seg2::Line { .. }) { 1 } else { SAMPLES };
(0..=n).map(|k| k as f64 / n as f64).map(|t| (t, s.point_at(t))).collect()
};
let (sp, sq) = (samples(p), samples(q));
let mut best: Option<(f64, f64)> = None;
for (a, b) in sp.iter().zip(sp.iter().skip(1)) {
for (c, e) in sq.iter().zip(sq.iter().skip(1)) {
let (r, s) = (b.1 - a.1, e.1 - c.1);
let den = r.cross(s);
if den.abs() < 1e-15 {
continue;
}
let (u, v) = ((c.1 - a.1).cross(s) / den, (c.1 - a.1).cross(r) / den);
if !((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)) {
continue;
}
let (tp, tq) = (a.0 + (b.0 - a.0) * u, c.0 + (e.0 - c.0) * v);
// Nearest the corner: little of `p` left behind its crossing, little of `q` before.
if best.is_none_or(|(bp, bq)| (1.0 - tp) + tq < (1.0 - bp) + bq) {
best = Some((tp, tq));
}
}
}
let (tp, tq) = best?;
// Not at an end: a piece cut to nothing has no length left to rule.
if tp <= 1e-9 || tq >= 1.0 - 1e-9 {
return None;
}
Some((tp, tq, (p.point_at(tp) + q.point_at(tq)) * 0.5))
}
/// `s` ending at `x` (its last control point moved there).
fn with_end(s: &Seg2, x: Vec2) -> Seg2 {
match *s {
Seg2::Line { a, .. } => Seg2::Line { a, b: x },
Seg2::Cubic { p0, p1, p2, .. } => Seg2::Cubic { p0, p1, p2, p3: x },
Seg2::Conic { a, apex, w, .. } => Seg2::Conic { a, apex, b: x, w },
Seg2::Bezier { n, p } => {
let mut q = p;
if let Some(last) = q.get_mut((n as usize).clamp(1, solidcraft_geom::MAX_BEZIER_DEGREE)) {
*last = x;
}
Seg2::Bezier { n, p: q }
}
Seg2::Arc { .. } => *s,
}
}
/// `s` starting at `x` (its first control point moved there).
fn with_start(s: &Seg2, x: Vec2) -> Seg2 {
match *s {
Seg2::Line { b, .. } => Seg2::Line { a: x, b },
Seg2::Cubic { p1, p2, p3, .. } => Seg2::Cubic { p0: x, p1, p2, p3 },
Seg2::Conic { apex, b, w, .. } => Seg2::Conic { a: x, apex, b, w },
Seg2::Bezier { n, p } => {
let mut q = p;
if let Some(first) = q.first_mut() {
*first = x;
}
Seg2::Bezier { n, p: q }
}
Seg2::Arc { .. } => *s,
}
}
/// Most pairs of polyline pieces [`offset_crosses`] compares before it gives up (and says no).
const CROSSING_BUDGET: usize = 20_000_000;
/// Whether the offset loops of a region cross themselves or each other, on polylines within
/// `tol` of them: the taper is too steep for the profile's details (a stroke thinner than the
/// taper, a curve tighter than it, two loops it brings together).
fn offset_crosses(loops: &[Vec<Option<Seg2>>], tol: f64) -> bool {
let lines: Vec<Vec<Vec2>> = loops
.iter()
.map(|l| {
let mut pts: Vec<Vec2> = Vec::new();
for s in l.iter().flatten() {
let p = s.polyline(tol);
pts.extend(p.iter().skip(if pts.is_empty() { 0 } else { 1 }));
}
pts
})
.collect();
// Each piece: its loop, its place in the loop, its ends.
let mut pieces: Vec<(usize, usize, usize, Vec2, Vec2)> = Vec::new();
for (k, pts) in lines.iter().enumerate() {
for (i, (a, b)) in pts.iter().zip(pts.iter().skip(1)).enumerate() {
pieces.push((k, i, pts.len() - 1, *a, *b));
}
}
pieces.sort_by(|p, q| p.3.x.min(p.4.x).total_cmp(&q.3.x.min(q.4.x)));
let side = |p: Vec2, q: Vec2, r: Vec2| (q - p).cross(r - p);
let mut work = 0usize;
for (n, &(k, i, len, a, b)) in pieces.iter().enumerate() {
let right = a.x.max(b.x);
for &(k2, i2, _, c, d) in pieces.iter().skip(n + 1) {
if c.x.min(d.x) > right {
break;
}
work += 1;
if work > CROSSING_BUDGET {
return false;
}
// Neighbours in one loop share an end.
if k == k2 && (i.abs_diff(i2) <= 1 || i.abs_diff(i2) == len.saturating_sub(1)) {
continue;
}
if a.y.max(b.y) < c.y.min(d.y) || c.y.max(d.y) < a.y.min(b.y) {
continue;
}
let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d));
if d1 * d2 < 0.0 && d3 * d4 < 0.0 {
return true;
}
}
}
false
}
/// The side faces of one loop of a tapered extrude through `levels` of it, bottom to top (the
/// loop offset in a plane, one entry per profile segment, `None` where the offset swallowed a
/// line), with the bottom and top wires. Between two levels each side is ruled between a
/// segment's two offsets, through straight edges shared with its neighbours; a line swallowed
/// at one level makes a flat triangle up to where its neighbours meet. `numbers`: the profile
/// segments under each segment.
fn taper_loop(levels: &[(Plane, &[Option<Seg2>])], numbers: impl Fn(usize) -> Vec<usize>) -> Result<(Vec<Side>, mt::Wire, mt::Wire)> {
let bad = || KernelError::Failed("taper: profile loop".into());
let n = levels.first().map_or(0, |(_, segs)| segs.len());
if levels.len() < 2 || levels.iter().any(|(_, segs)| segs.len() != n) {
return Err(bad());
}
// Each level's vertices (each segment's start: its own, or for a swallowed one the next kept
// one's) and each kept segment's edges.
type Level = (Vec<mt::Vertex>, Vec<Option<Vec<mt::Edge>>>);
let built: Vec<Level> = levels
.iter()
.map(|(plane, segs)| {
let own: Vec<Option<mt::Vertex>> = segs.iter().map(|s| s.map(|s| builder::vertex(p3(plane.to_world(s.start()))))).collect();
let v: Vec<mt::Vertex> =
(0..n).map(|i| (0..n).find_map(|k| own.get((i + k) % n).cloned().flatten()).ok_or_else(bad)).collect::<Result<_>>()?;
let edges = (0..n)
.map(|i| match (segs.get(i).copied().flatten(), v.get(i), v.get((i + 1) % n)) {
(Some(s), Some(a), Some(b)) => seg_edges(plane, &s, a, b).map(Some),
_ => Ok(None),
})
.collect::<Result<_>>()?;
Ok((v, edges))
})
.collect::<Result<_>>()?;
let mut rulings: std::collections::HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = std::collections::HashMap::new();
let mut ruling = |a: &mt::Vertex, b: &mt::Vertex| rulings.entry((a.id(), b.id())).or_insert_with(|| builder::line(a, b)).clone();
let flat = |wire: mt::Wire| builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("taper sides: {e}")));
let mut sides = Vec::new();
for ((bv, bottom), (tv, top)) in built.iter().zip(built.iter().skip(1)) {
for i in 0..n {
let j = (i + 1) % n;
let (Some(b0), Some(b1), Some(t0), Some(t1)) = (bv.get(i), bv.get(j), tv.get(i), tv.get(j)) else { return Err(bad()) };
let prov = Some(record::profile(&numbers(i)));
match (bottom.get(i).and_then(Option::as_ref), top.get(i).and_then(Option::as_ref)) {
(Some(low), Some(high)) => {
if low.len() != high.len() {
return Err(bad());
}
for (e, t) in low.iter().zip(high) {
let wire: mt::Wire = vec![e.clone(), ruling(e.back(), t.back()), t.inverse(), ruling(e.front(), t.front()).inverse()].into();
let surface = mt::BSplineSurface::homotopy(e.oriented_curve().lift_up(), t.oriented_curve().lift_up());
sides.push((mt::Face::new(vec![wire], mt::Surface::NurbsSurface(mt::NurbsSurface::new(surface))), prov.clone()));
}
}
(Some(low), None) => {
let [e] = &low[..] else { return Err(bad()) };
sides.push((flat(vec![e.clone(), ruling(e.back(), t1), ruling(e.front(), t0).inverse()].into())?, prov));
}
(None, Some(high)) => {
let [t] = &high[..] else { return Err(bad()) };
sides.push((flat(vec![ruling(b1, t.back()), t.inverse(), ruling(b0, t.front()).inverse()].into())?, prov));
}
(None, None) => {}
}
}
}
let wire =
|level: Option<&Level>| -> mt::Wire { level.map(|(_, edges)| edges.iter().flatten().flatten().cloned().collect()).unwrap_or_default() };
Ok((sides, wire(built.first()), wire(built.last())))
}
/// Extrude one region by `length` along `dir_sign` × the plane normal with a taper angle
/// (radians; positive grows the profile).
pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f64, taper: f64) -> Result<Body> {
let (length, taper) = (finite(length, "distance")?, finite(taper, "taper")?);
if length <= 1e-6 {
extrude_tapered_between(plane, region, 0.0, length, 0.0, dir_sign, taper)
}
/// [`extrude_tapered`] from `from` to `to` along `dir_sign` × the plane normal, the region
/// being the section at 0: its sides run on through it, so `from` below 0 starts the solid
/// that far before the profile (into a face the profile lies on), at the profile shrunk or
/// grown by the taper. With a `foot` (a height, less than `to - from`), the sides stand
/// straight for that height above `from` and only then slope, up to the same top: a face the
/// solid stands on can then be pushed into the body it joins, as a prism's can.
pub fn extrude_tapered_between(plane: &Plane, region: &Region2, from: f64, to: f64, foot: f64, dir_sign: f64, taper: f64) -> Result<Body> {
let (from, to, foot, taper) = (finite(from, "start")?, finite(to, "distance")?, finite(foot, "foot")?, finite(taper, "taper")?);
if to - from <= 1e-6 {
return Err(KernelError::Invalid("extrude distance is zero".into()));
}
if !(0.0..(to - from) * 0.5).contains(&foot) {
return Err(KernelError::Invalid(format!("taper foot = {foot}")));
}
if taper.abs() >= std::f64::consts::FRAC_PI_2 - 1e-3 {
return Err(KernelError::Invalid("taper must be between −90° and 90°".into()));
}
region_ok(region)?;
let d = length * taper.tan();
let top_plane = plane.offset(length * dir_sign.signum());
let (t, sign) = (taper.tan(), dir_sign.signum());
let (bottom_plane, top_plane) = (plane.offset(from * sign), plane.offset(to * sign));
let loops0 = oriented(region)?;
let loops1: Vec<Loop2> = loops0.loops().map(|l| offset_loop(&l.lp, d)).collect::<Result<_>>()?;
let ends = |h: f64| loops0.loops().map(|l| offset_loop(&l.lp, h * t)).collect::<Result<Vec<_>>>();
let (bottoms, tops) = (ends(from)?, ends(to)?);
// Polylines within a small part of the larger offset of it.
let tol = (from.abs().max(to.abs()) * t.abs() * 0.02).max(1e-6);
if t != 0.0 && (offset_crosses(&bottoms, tol) || offset_crosses(&tops, tol)) {
return Err(KernelError::Invalid(
"the taper closes the profile: at this distance its sides would cross (a stroke, a curve or a gap narrower than the taper); try a smaller taper or distance".into(),
));
}
guard("tapered extrude", || {
let p0 = region_wires(plane, &loops0)?;
let w0 = wires_of(&p0);
let w1: Vec<mt::Wire> = loops1.iter().map(|l| wire(&top_plane, l)).collect::<Result<_>>()?;
let mut sides: Vec<Side> = Vec::new();
for (a, b) in p0.iter().zip(&w1) {
let shell = builder::try_wire_homotopy(&a.wire, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
// The offset loop keeps the loop's segments, edge for edge.
let numbers = a.numbers();
sides.extend(record::ruled(&shell, &a.wire, b, &numbers, &numbers));
let (mut w0, mut w1): (Vec<mt::Wire>, Vec<mt::Wire>) = (Vec::new(), Vec::new());
for ((l, b), t) in loops0.loops().zip(&bottoms).zip(&tops) {
let mut levels = vec![(bottom_plane, &b[..])];
if foot > 0.0 {
levels.push((plane.offset((from + foot) * sign), &b[..]));
}
levels.push((top_plane, &t[..]));
let (s, below, above) = taper_loop(&levels, |i| l.numbers(i).to_vec())?;
sides.extend(s);
w0.push(below);
w1.push(above);
}
let (faces, prov): (Vec<mt::Face>, Vec<Option<FaceProv>>) = sides.into_iter().unzip();
// The sides, then the bottom and top as pushed below.
+3 -1
View File
@@ -100,7 +100,9 @@ pub use blend_corner::fillet_radii;
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use build::{PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus};
pub use build::{
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep, torus,
};
pub use delete_face::delete_faces;
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
+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();
+5
View File
@@ -18,6 +18,11 @@ Extruding into existing material selects **Cut**. In an empty component it selec
choose **New Body** explicitly to keep an addition separate. Changing the default does not alter
operations already saved in a design.
A taper slopes the sides of any profile, sketch text included: lines, arcs and spline or conic
curves move in or out together, and a flat tip shorter than the taper closes to a point. A
taper steeper than the profile's details (a stroke, a curve or a gap narrower than it) is
refused with that reason; a smaller taper or distance builds.
Editing an extrusion retains its signed distances, From/To Object inputs, taper expressions
and explicit body targets. Revolve edits retain world, sketch and explicit-line axes, both
angle expressions and To Object targets. An unchanged **All profiles** selection continues