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:
1 parent
d114c58540
commit
4dbc0efff9
6 files changed
+619
-136
No files matched your search
+61
-3
@@ -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(®ions, 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
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user