diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 17ddd7f5..51302bf2 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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, points: &[Vec3]) -> Result { @@ -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 = 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::>()?; + // 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 = 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::>())]); + } + } + return Ok(tools); } let off = from + match &extent.start_offset { diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 3b0e688a..7e2792bc 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -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. diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index acb2175e..149893cc 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -16,11 +16,6 @@ fn finite(x: f64, what: &str) -> Result { 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 { - 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> { + 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, } -impl ProfileWire { - /// The profile segments under each edge, in wire order. - fn numbers(&self) -> Vec> { - 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> { @@ -537,154 +531,450 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result { } /// 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 { +/// 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>> { 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::>>()?; - 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 { +/// 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> { + let legs: Vec> = 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 = (0..legs.len()) + .map(|i| legs.iter().skip(i).flatten().next().or_else(|| legs.iter().take(i).rev().flatten().next()).copied()) + .collect::>()?; + 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>> { 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> = 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::>()?; - // 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 = (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> = 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>], tol: f64) -> bool { + let lines: Vec> = loops + .iter() + .map(|l| { + let mut pts: Vec = 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])], numbers: impl Fn(usize) -> Vec) -> Result<(Vec, 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, Vec>>); + let built: Vec = levels + .iter() + .map(|(plane, segs)| { + let own: Vec> = segs.iter().map(|s| s.map(|s| builder::vertex(p3(plane.to_world(s.start()))))).collect(); + let v: Vec = + (0..n).map(|i| (0..n).find_map(|k| own.get((i + k) % n).cloned().flatten()).ok_or_else(bad)).collect::>()?; + 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::>()?; + Ok((v, edges)) + }) + .collect::>()?; + 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 { - 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 { + 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 = loops0.loops().map(|l| offset_loop(&l.lp, d)).collect::>()?; + let ends = |h: f64| loops0.loops().map(|l| offset_loop(&l.lp, h * t)).collect::>>(); + 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 = loops1.iter().map(|l| wire(&top_plane, l)).collect::>()?; let mut sides: Vec = 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, Vec) = (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, Vec>) = sides.into_iter().unzip(); // The sides, then the bottom and top as pushed below. diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 2ecf305b..fa8b0cf8 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 31da05b4..b871c1b1 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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::(); + // 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(); diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 4f34994e..e7b671a4 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -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