Fillets and chamfers meeting at corners on curved faces: what #935 leaves (#854)

* Fillets and chamfers meeting at corners on curved faces, on top of #935

What #935 and #926 leave of #851 and the matrix:

- Corner build for blends meeting on faces of any surface (curvecorner.rs):
  the knife-edge tip's round with the arc's or the back edge's, edges
  picked in any order, a round ending in an earlier round of its radius
  (as a feature of its own), and chamfers meeting at the arc's corner.
  Its end curves are fitted by chord length: a uniform parameter
  through points clustered at the ends doubled back on itself there,
  and the display planner refused the face.
- A straight edge ending at a curved face (the back edge into the
  knife-edge tip): rolled, or cut by the face in the planar corner
  build (blend_corner.rs).
- A rolling ball turning about a rounded corner of its own radius
  (a pivot: a piece of sphere or cone).
- Seams that are an earlier blend's straight contact (fitted curves)
  count as straight.
- No spline across a joint where the strip's curvature jumps (a line
  into a small round): each strip ends on the joint's section.
- Sections at a chain's vertices on the faces of the edge from it
  alone.
- Every new fillet path passes #949's check for openings.

Tests measure volumes through the display plan, as #935's do.
Action matrix: 51 more fillets and chamfers pass (sweep, tangent arc
chain, sheet-metal hem, bracket holes), none fewer. Robustness seed 33
recorded (its last fillet builds differently; display and measured
volumes now agree).

Co-Authored-By: bherbruck <brennen.herbruck@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi

* Corner blend tests: scale, seed 33's volumes, tighter engine bounds

From the review of #854:

- seed_33_display_plan_and_measure_agree: seed 33's bodies measure
  the same through the display plan and the measure (2e-5), which the
  golden's re-record rests on.
- the_arc_rounded_after_the_back_edge_matches_both_together: the order
  that still leaves 0.05 mm³ at the tip, as an ignored test.
- corner_blends_hold_at_any_scale: the cases this branch adds on the
  #851 part, at 0.01x and 100x, take off what they take at full size
  times k³ (within 5e-6, checked to 1e-4).
- The engine tests on the part as drawn measure through the display
  plan; bounds are about twice what each reads (2e-4 to 3e-3), and the
  front corner's sets are checked against the kernel's CSG grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015bV2ntwLfUHV4JnCMPSyRi

---------

Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-14 07:40:23 -04:00
1 parent 27374ca289
commit 32d3cf6877
13 files changed
+2739 -129

No files matched your search

@@ -729,3 +729,197 @@ fn cutting_a_text_removes_each_letter_whether_its_letters_stand_apart_or_touch()
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.
fn knife_edge_part() -> Session {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0.0, 0.0], "p1": [41.5, -21.0]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "text_at": [36.5, -8.5], "value": "21 mm"}));
run(&mut s, "sketch.line", json!({"infer": true, "points": ["p2", [61.5, -21.0]]}));
run(&mut s, "sketch.dimension", json!({"entities": ["l5"], "text_at": [54.5, -26.5], "value": "21"}));
run(&mut s, "sketch.circle.center", json!({"center": "l5.end", "radius": 21.0}));
run(&mut s, "sketch.line", json!({"infer": true, "points": ["p3", [62.166666666666664, 0.0]]}));
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "l6"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"direction": "positive", "distance": "10 mm", "operation": "new", "profiles": [0, 1], "sketch": 1}));
s
}
/// The volume of the session's bodies through the display plan (the measure's mesh cuts across
/// the concave arc). It holds to about 1e-3 of what a blend removes here; the bounds below are
/// about twice what each test reads.
fn planned(s: &Session) -> f64 {
s.model.state().bodies.iter().map(|b| b.body.display_plan(2e-4).unwrap().evaluate().measure().volume).sum()
}
/// The middle of the part's arc.
const KNIFE_ARC_MID: [f64; 2] = [62.166666666666664 - 21.0 * std::f64::consts::FRAC_1_SQRT_2, -21.0 + 21.0 * std::f64::consts::FRAC_1_SQRT_2];
/// Simpson's rule over the round's corner (2 × 2 less a quarter disc) at distance ρ = 21 + x
/// from the arc's axis, times `length(ρ)`.
fn round_along(length: impl Fn(f64) -> f64) -> f64 {
let (r, n) = (2.0f64, 2000);
let h = r / n as f64;
(0..=n)
.map(|i| {
let x = i as f64 * h;
let w = if i == 0 || i == n { 1.0 } else { [2.0, 4.0][i % 2] };
w * (r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) * length(21.0 + x)
})
.sum::<f64>()
* h
/ 3.0
}
/// Its edges are stored the other way round from their curves, which once chained the arc
/// with the front edge across their corner. Rounding the arc at the bottom and the top removes
/// the round's section along it, cut off at the tip by the back face's plane: ρ asin(21 / ρ).
#[test]
fn fillet_an_arc_that_runs_into_a_knife_edge_tip() {
let mut s = knife_edge_part();
let before = planned(&s);
let [x, y] = KNIFE_ARC_MID;
run(&mut s, "solid.fillet", json!({"edges": [[x, y, 0.0]], "radius": 2}));
run(&mut s, "solid.fillet", json!({"edges": [[x, y, 10.0]], "radius": 2}));
let removed = round_along(|rho| rho * (21.0 / rho).asin());
let after = planned(&s);
assert!(rel(before - after, 2.0 * removed) < 2e-3, "removed {} vs {}", before - after, 2.0 * removed);
}
/// The part's front corner (the front face into the arc) rounded with 2 first, then the bottom
/// run (front edge, the corner round's arc, the arc) with 2 as well: a round on a round of the
/// same radius. Along the front edge a straight round; at the corner the ball turns about one
/// point, a piece of sphere (θ r³ / 6 over the corner's θ); along the arc the torus section from
/// the corner round to the tip.
#[test]
fn fillet_a_run_over_a_corner_rounded_with_the_same_radius() {
let mut s = knife_edge_part();
run(&mut s, "solid.fillet", json!({"edges": [[41.1667, -21.0, 5.0]], "radius": 2}));
let before = planned(&s);
let [x, y] = KNIFE_ARC_MID;
// The corner round: about (62.17 - √(23² - 2²), -19), its arc's middle 2 from there.
let cx = 62.166666666666664 - (23.0f64 * 23.0 - 4.0).sqrt();
let joint = 2.0f64.atan2((23.0f64 * 23.0 - 4.0).sqrt());
let theta = std::f64::consts::FRAC_PI_2 - joint;
let corner_mid = [cx + 2.0 * (theta / 2.0).sin(), -19.0 - 2.0 * (theta / 2.0).cos()];
run(&mut s, "solid.fillet", json!({"edges": [[x, y, 0.0], [corner_mid[0], corner_mid[1], 0.0], [20.0, -21.0, 0.0]], "radius": 2}));
let removed = (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * cx + theta * 8.0 / 6.0 + round_along(|rho| rho * ((21.0 / rho).asin() - joint));
let after = planned(&s);
assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after);
}
/// Straight edges of the part ending at its arc wall: the long front edge at the top (the wall
/// cuts the round off at x = 62.17 − √(21² − d²), d from the front face) and the back edge at
/// the top, which runs into the knife-edge tip (x = 62.17 − √(21² − (21 − d)²), d from the back
/// face).
#[test]
fn fillet_straight_edges_that_end_at_the_arc_wall() {
let cx = 62.166666666666664;
for (edge, cut) in [([20.0, -21.0, 10.0], 0.0), ([30.0, 0.0, 10.0], 21.0)] {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [edge], "radius": 2}));
let removed = round_along(|rho| {
let d = rho - 21.0;
cx - (441.0 - (cut - d) * (cut - d)).sqrt()
});
let after = planned(&s);
assert!(rel(before - after, removed) < 3e-3, "{edge:?}: removed {} vs {removed}", before - after);
}
}
/// The vertical knife edge at the tip (the arc and the back face meeting at no angle) rounds to
/// the ball 2 from both, about 13 back from the tip: the cusp beyond the ball, on the tip's side
/// of its contacts, all the way up.
#[test]
fn fillet_the_knife_edge_at_the_tip() {
let cx = 62.166666666666664;
let (kx, ky) = (cx - (23.0f64 * 23.0 - 19.0 * 19.0).sqrt(), -2.0);
let (hx, hy) = (0.002, 0.0005);
let mut area = 0.0;
let mut x = kx + hx / 2.0;
while x < cx {
let lo = -21.0 + (441.0 - (x - cx) * (x - cx)).max(0.0).sqrt();
for j in 0..((0.0 - lo) / hy).ceil() as usize {
let (y0, y1) = (lo + j as f64 * hy, (lo + (j + 1) as f64 * hy).min(0.0));
let y = (y0 + y1) * 0.5;
let a = (y - ky).atan2(x - kx);
if (x - kx).hypot(y - ky) > 2.0 && a >= (-21.0 - ky).atan2(cx - kx) {
area += (y1 - y0) * hx;
}
}
x += hx;
}
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [[cx, 0.0, 5.0]], "radius": 2}));
let after = planned(&s);
assert!(rel(before - after, area * 10.0) < 5e-4, "removed {} vs {}", before - after, area * 10.0);
let mut s = knife_edge_part();
run(&mut s, "solid.chamfer", json!({"edges": [[cx, 0.0, 5.0]], "distance": 2}));
assert!(before - planned(&s) > before - after, "the chamfer takes the ball's bulge off too");
}
/// Rounds of the edges at the arc's top front corner, two or all three together (r 2): they meet
/// in creases (with all three, a piece of sphere fills the corner). Each set takes off its rounds
/// alone, less what each alone takes in a box about the corner, plus what they take there
/// together: the CSG grid of the kernel's `rounds_meeting_at_a_corner_of_an_arc_wall`
/// (curvecorner_tests.rs, 0.02 mm cells), within its accuracy.
#[test]
fn fillet_edges_meeting_at_the_front_corner_of_the_arc() {
// In the box about the corner: what the long edge's, the arc's and the upright's rounds take
// alone, and what two or all three take together.
let corner = [2.719176, 3.015864, 1.863];
let together = [(vec![0, 2], 3.886936), (vec![0, 1], 4.971256), (vec![1, 2], 4.19864), (vec![0, 1, 2], 6.3332)];
let [x, y] = KNIFE_ARC_MID;
let (long, arc, upright) = ([20.0, -21.0, 10.0], [x, y, 10.0], [41.1667, -21.0, 5.0]);
let off = |edges: Vec<[f64; 3]>| {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": edges, "radius": 2}));
before - planned(&s)
};
let edges = [long, arc, upright];
let alone = edges.map(|e| off(vec![e]));
for (which, both) in together {
let want = which.iter().map(|i| alone[*i] - corner[*i]).sum::<f64>() + both;
let got = off(which.iter().map(|i| edges[*i]).collect());
assert!((got - want).abs() < 0.03, "{which:?}: removed {got} vs {want}");
}
}
/// The long top front edge rounded, then the small front-left upright as a fillet of its own
/// (as in the app): the new round ends in the earlier one, taking off what both take together.
#[test]
fn fillet_the_long_edge_then_the_small_left_edge() {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0]], "radius": 2}));
run(&mut s, "solid.fillet", json!({"edges": [[0.0, -21.0, 5.0]], "radius": 2}));
let one_by_one = before - planned(&s);
let mut s = knife_edge_part();
run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0], [0.0, -21.0, 5.0]], "radius": 2}));
let together = before - planned(&s);
// Within the display meshes' accuracy (the two are built of different faces).
assert!(rel(one_by_one, together) < 2e-4, "one by one {one_by_one} vs together {together}");
}
/// The long top front edge and the front-left vertical edge rounded together (r 2): the long
/// round ends at the arc wall, the two meet at the top-left corner (the top-left edge sharp).
/// Each removes its own round (the long one cut off by the wall), less what both would take at
/// the corner: in its 2 mm cube, what lies outside both quarter circles, ∫ (2 − √(4 − t²))² dt
/// over 0..2 = 16 − 8/3 − 4π.
#[test]
fn fillet_two_edges_meeting_at_a_corner_one_running_into_the_arc_wall() {
let mut s = knife_edge_part();
let before = planned(&s);
run(&mut s, "solid.fillet", json!({"edges": [[20.0, -21.0, 10.0], [0.0, -21.0, 5.0]], "radius": 2}));
let long = round_along(|rho| 62.166666666666664 - (441.0 - (rho - 21.0) * (rho - 21.0)).sqrt());
let upright = (1.0 - std::f64::consts::FRAC_PI_4) * 4.0 * 10.0;
let removed = long + upright - (16.0 - 8.0 / 3.0 - 4.0 * std::f64::consts::PI);
let after = planned(&s);
assert!(rel(before - after, removed) < 1e-3, "removed {} vs {removed}", before - after);
}
+15
View File
@@ -559,3 +559,18 @@ fn a_boundary_found_across_a_seam_is_not_used() {
let r = fuzz::run(20, 8);
assert_eq!(r.outcome.kind, "ok", "seed 20: {:?}\n{}", r.outcome, r.script());
}
/// Seed 33 ends in a fillet on its second body (#854's corner blends build it differently from
/// before). Its display plan and its measured volume agree for every body (on main before #854,
/// that body read 4439.42 through the display plan and 4443.51 measured).
#[test]
fn seed_33_display_plan_and_measure_agree() {
let r = fuzz::run(33, 8);
assert_eq!(r.outcome.kind, "ok", "seed 33: {:?}\n{}", r.outcome, r.script());
let s = script(r.script());
for b in &s.model.state().bodies {
let planned = b.body.display_plan(2e-4).unwrap().evaluate().measure().volume;
let measured = solidcraft_kernel::measure(&b.body).unwrap().volume;
assert!((planned - measured).abs() < 2e-5 * measured, "{}: planned {planned}, measured {measured}", b.name);
}
}
+188 -47
View File
@@ -495,6 +495,18 @@ fn blend_geometry(
return Err(unsupported("G2 fillets require straight edges around each planar end face"));
}
let g = faces.get(ga).ok_or_else(|| KernelError::Failed("face".into()))?;
// An end face running on smoothly from a side, an earlier round of this radius (its
// end an arc of it): the blend ends in that round, which takes a corner of its own
// (`curveblend::corner_blend`), the same as rounding both together.
let normal = |f: &mt::Face| crate::curveblend::project(f, p, None).map(|x| x.1);
let of_radius = |e: &mt::Edge| crate::curveblend::circle_of(e, r * 1e-4).is_some_and(|c| (c.1 - r).abs() <= r * 1e-4);
if let Some(ng) = normal(g)
&& [i1, i2].iter().filter_map(|i| faces.get(*i)).filter_map(|f| normal(f)).any(|ns| ns.dot(ng) > 1.0 - 1e-6)
&& shape == Shape::Round
&& (of_radius(&e1) || of_radius(&e2))
{
return Err(unsupported("a blend ending in an earlier round"));
}
let surf = g.surface();
let surf_b = faces.get(gb).ok_or_else(|| KernelError::Failed("face".into()))?.surface();
let on_tol = (size * 1e-5).max(1e-6);
@@ -1134,8 +1146,14 @@ fn orders<T: Clone>(items: &[T]) -> Vec<Vec<T>> {
/// boundary leaves its surface (a blend that ran on past the end of a round's surface, which a
/// round of a curved edge does not have) another order that has none is preferred.
fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
blend_with(body, edges, r, shape, what, false)
}
/// [`blend`]; `curved_ends`: also roll a ball along a straight edge between planes that ends at
/// a curved face (the last resort, after the corner build for an earlier round there).
fn blend_with(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, curved_ends: bool) -> Result<Body> {
let sound = |b: &Body| crate::tess::boundaries_on_surfaces(&b.solid, (b.size() * 1e-4).max(1e-6));
let first = blend_in_order(body, edges, r, shape, what);
let first = blend_in_order(body, edges, r, shape, what, curved_ends);
let reorder = (2..=MAX_REORDERED).contains(&edges.len())
&& match &first {
Ok(b) => !sound(b),
@@ -1147,7 +1165,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
}
let mut fallback: Option<Body> = None;
for order in orders(edges).into_iter().skip(1) {
if let Ok(done) = blend_in_order(body, &order, r, shape, what) {
if let Ok(done) = blend_in_order(body, &order, r, shape, what, curved_ends) {
if sound(&done) {
return Ok(done);
}
@@ -1164,7 +1182,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
}
}
fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, curved_ends: bool) -> Result<Body> {
if !(r.is_finite() && r > 1e-6 && r < 1e6) {
return Err(KernelError::Invalid(format!("{what} size must be positive")));
}
@@ -1178,6 +1196,16 @@ fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str)
let counts = record::counts(&[body]);
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = crate::boolean_record::healed(body)?;
// The last resort only for edges it treats otherwise: else it repeats the refusal.
if curved_ends {
let solid = cur.deep_copy();
let ids = edge_ids(&cur, &solid, edges)?;
let other =
Body::unique_edges(&solid).iter().any(|e| ids.contains(&e.id()) && !curved_case(&solid, e, false) && curved_case(&solid, e, true));
if !other {
return Err(unsupported("no edge ending at a curved face"));
}
}
// Every edge of a convex planar body: rounded as a whole (sphere corners).
if shape == Shape::Round && edges.len() >= 6 {
let solid = cur.deep_copy();
@@ -1207,8 +1235,8 @@ fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str)
{
let _ = groups;
let loop_pts: Vec<Vec3> = edges.iter().filter(|p| !rest.iter().any(|q| q.dist(**p) < 1e-12)).copied().collect();
cur = record::after(blend_in_order(&cur, &rest, r, shape, what)?, &cur, &counts);
return Ok(record::after(blend_in_order(&cur, &loop_pts, r, shape, what)?, &cur, &counts));
cur = record::after(blend_in_order(&cur, &rest, r, shape, what, curved_ends)?, &cur, &counts);
return Ok(record::after(blend_in_order(&cur, &loop_pts, r, shape, what, curved_ends)?, &cur, &counts));
}
// A loop the loop blend refuses for its shape (seams that aren't straight, walls it can't
// trim) is blended edge by edge below instead: a ball rolls along its smooth chain.
@@ -1236,11 +1264,11 @@ fn blend_in_order(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str)
Err(_) => {}
}
}
blend_each(cur, edges, r, shape, what, &counts)
blend_each(cur, edges, r, shape, what, &counts, curved_ends)
}
/// The edges blended one at a time (see [`blend`]), `cur` being the healed body.
fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, counts: &[(usize, usize)]) -> Result<Body> {
fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, counts: &[(usize, usize)], curved_ends: bool) -> Result<Body> {
let counts = counts.to_vec();
// One at a time, in whatever order works: a run along a planar face's loop (bridging arcs
// that earlier blends left between selected edges, so corners round off), a single edge,
@@ -1251,6 +1279,30 @@ fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, c
// refused away from every later blend would be refused the same way again: it is not tried
// again (a refusal costs a rolling-ball attempt, seconds each on a long curved edge).
let mut refused: Vec<(Vec3, (Vec3, Vec3), KernelError)> = Vec::new();
// A chain runs on along the edges smooth with a selected one in the body as given, not along
// a round this blend has made meanwhile (which would take in edges nobody selected, or not,
// by the order the edges come in).
let along: Vec<mt::Edge> = {
let solid = cur.deep_copy();
let all = Body::unique_edges(&solid);
let tol = (cur.size() * 2e-3).max(1e-3);
let mut list: Vec<mt::Edge> = Vec::new();
for p in edges {
if let Some((idx, _)) = cur.nearest_edge(*p, tol)?
&& let Some(e) = all.get(idx)
{
let ids = smooth_chain(&solid, e).unwrap_or_else(|| vec![e.id()]);
for x in all.iter().filter(|x| ids.contains(&x.id())) {
if !list.iter().any(|y| y.id() == x.id()) {
list.push(x.clone());
}
}
}
}
list
};
let near_chain = (cur.size() * 1e-6).max(1e-7);
let on_chains = |e: &mt::Edge| lies_on(e, &along, near_chain);
for _ in 0..(2 * edges.len() + 4) {
if todo.is_empty() {
return Ok(cur);
@@ -1315,11 +1367,23 @@ fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, c
};
// A straight edge running on smoothly into curved ones (a flange into its bend, a
// side into a rounded corner): the blend follows the whole smooth chain, as the
// rolling ball would.
let chain = smooth_chain(&solid, &edge);
let runs_on = !curved_case(&solid, &edge)
// rolling ball would. A fillet doesn't run on into an earlier round's arc of its
// radius: it ends in that round (as if both were rounded together).
let chain = smooth_chain_within(&solid, &edge, &on_chains);
let into_round = |ids: &[mt::EdgeID]| {
Body::unique_edges(&solid).iter().any(|x| {
ids.contains(&x.id())
&& x.id() != edge.id()
&& [edge.front(), edge.back()].iter().any(|v| x.front() == *v || x.back() == *v)
&& crate::curveblend::circle_of(x, size * 1e-6).is_some_and(|c| (c.1 - r).abs() <= r * 1e-4)
})
};
let own = edge.id();
let runs_on = !curved_case(&solid, &edge, false)
&& chain.as_ref().is_some_and(|ids| {
ids.len() > 1 && Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && !matches!(x.curve(), mt::Curve::Line(_)))
ids.len() > 1
&& Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && !matches!(x.curve(), mt::Curve::Line(_)))
&& !(shape == Shape::Round && into_round(ids))
});
let local = || guard(what, || blend_one(&solid, &edge, size, r, shape));
let attempt = match chain.as_deref().filter(|_| runs_on).map(roll) {
@@ -1333,16 +1397,24 @@ fn blend_each(mut cur: Body, edges: &[Vec3], r: f64, shape: Shape, what: &str, c
None => match local() {
Ok(o) => Ok((o, vec![j])),
// A curved edge, or one between curved faces: roll a ball along its smooth chain.
Err(e) if !curved_case(&solid, &edge) => Err(e),
Err(e) if !curved_case(&solid, &edge, curved_ends) => Err(e),
Err(e) => match chain.as_deref() {
// The chain that cannot be rolled (it runs on into the edge an earlier round
// left where it met another face): the edge alone, ending on that round.
Some(ids) => roll(ids)
.or_else(|first| if ids.len() > 1 { roll(&[edge.id()]).map_err(|_| first) } else { Err(first) })
.map_err(|e2| match e2 {
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
e2 => KernelError::Failed(format!("{e2} ({e})")),
}),
// left where it met another face): the edge alone, ending on that round. A
// straight edge into an earlier round of this radius: on its own.
Some(ids) => {
let ids = if shape == Shape::Round && !curved_case(&solid, &edge, false) && into_round(ids) {
std::slice::from_ref(&own)
} else {
ids
};
roll(ids).or_else(|first| if ids.len() > 1 { roll(&[edge.id()]).map_err(|_| first) } else { Err(first) }).map_err(|e2| {
match e2 {
KernelError::TooLarge(m) => KernelError::TooLarge(format!("{m} ({e})")),
e2 => KernelError::Failed(format!("{e2} ({e})")),
}
})
}
None => Err(e),
},
},
@@ -1500,12 +1572,16 @@ fn run_on_loop(cur: &Body, solid: &Solid, pts: &[Vec3]) -> Result<Option<(Vec<mt
Ok(best)
}
/// Is the edge curved, or does it lie between faces that aren't both planar?
fn curved_case(solid: &Solid, edge: &mt::Edge) -> bool {
/// Is the edge curved, or does it lie between faces that aren't both planar (or, with
/// `curved_ends`, end at one)?
fn curved_case(solid: &Solid, edge: &mt::Edge, curved_ends: bool) -> bool {
if !matches!(edge.curve(), mt::Curve::Line(_)) {
return true;
}
solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).any(|f| !matches!(f.oriented_surface(), mt::Surface::Plane(_)))
// Between curved faces, or (when asked) ending at one: a straight edge running into a wall.
let curved = |f: &mt::Face| !matches!(f.oriented_surface(), mt::Surface::Plane(_));
solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).any(curved)
|| (curved_ends && solid.face_iter().filter(|f| f.vertex_iter().any(|v| v == *edge.front() || v == *edge.back())).any(curved))
}
/// Whether the two faces of `edge` meet tangentially along it (sampled at a quarter, the
@@ -1531,6 +1607,32 @@ fn tangent_faces(solid: &Solid, edge: &mt::Edge) -> bool {
/// The chain of edges continuing `edge` smoothly at its ends (its ids): closed, or open where
/// the next edge turns sharply.
fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
smooth_chain_within(solid, edge, &|_| true)
}
/// Whether edge `e` runs along one of `along` (is a piece of it): points through it lie on one.
fn lies_on(e: &mt::Edge, along: &[mt::Edge], tol: f64) -> bool {
use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter};
if along.iter().any(|a| a.id() == e.id()) {
return true;
}
let c = e.curve();
let (t0, t1) = c.range_tuple();
[0.25, 0.5, 0.75].iter().all(|f| {
let p = c.subs(t0 + (t1 - t0) * f);
along.iter().any(|a| {
let ac = a.curve();
let (a0, a1) = ac.range_tuple();
let d = |t: f64| crate::body::from_p3(ac.subs(t)).dist(crate::body::from_p3(p));
let Some(hint) = (0..=16).map(|i| a0 + (a1 - a0) * i as f64 / 16.0).min_by(|x, y| d(*x).total_cmp(&d(*y))) else { return false };
let t = ac.search_nearest_parameter(p, hint, 50).unwrap_or(hint).clamp(a0, a1);
d(t).min(d(hint)) <= tol
})
})
}
/// [`smooth_chain`], walking on only over edges `keep` allows.
fn smooth_chain_within(solid: &Solid, edge: &mt::Edge, keep: &dyn Fn(&mt::Edge) -> bool) -> Option<Vec<mt::EdgeID>> {
use mt::{BoundedCurve, ParametricCurve};
let all = Body::unique_edges(solid);
// Along the edge as it runs from its front to its back (an edge may run against its curve).
@@ -1550,7 +1652,7 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
let next = all.iter().filter(|e| !ids.contains(&e.id()) && (*e.front() == v || *e.back() == v)).find_map(|e| {
let fwd = *e.front() == v;
let t = if fwd { dir(e, false)? } else { -dir(e, true)? };
(t.dot(d) > 1.0 - 1e-4 && !smooth_seam(solid, e)).then(|| (e.clone(), fwd))
(t.dot(d) > 1.0 - 1e-4 && !smooth_seam(solid, e) && keep(e)).then(|| (e.clone(), fwd))
});
let Some((e, fwd)) = next else { return Some(false) };
ids.push(e.id());
@@ -1713,21 +1815,52 @@ fn closed(b: Body) -> Result<Body> {
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
body.require_brep("fillet")?;
// Corners the local blends refuse (an earlier round at an edge's end): radius per edge.
blend(body, edges, radius, Shape::Round, "fillet").and_then(closed).or_else(|e| {
let sel: Vec<(Vec3, f64)> = edges.iter().map(|p| (*p, radius)).collect();
// The local blend's refusal, unless only the corner build found the radius too large.
crate::blend_corner::fillet_radii(body, &sel)
.and_then(closed)
.map_err(|e2| match (&e, e2) {
(KernelError::TooLarge(_), _) => e,
(_, e2 @ KernelError::TooLarge(_)) => e2,
_ => e,
})
// Three edges at a vertex of any faces end in a sphere.
.or_else(|e| {
if edges.len() >= 3 { crate::blend_vertex::fillet_ball_vertices(body, edges, radius).and_then(closed).map_err(|_| e) } else { Err(e) }
})
})
blend(body, edges, radius, Shape::Round, "fillet")
.and_then(closed)
.or_else(|e| {
let sel: Vec<(Vec3, f64)> = edges.iter().map(|p| (*p, radius)).collect();
// The local blend's refusal, unless only the corner build found the radius too large.
crate::blend_corner::fillet_radii(body, &sel)
.and_then(closed)
.map_err(|e2| keep_size(e, e2))
// Three edges at a vertex of any faces end in a sphere.
.or_else(|e| {
if edges.len() >= 3 {
crate::blend_vertex::fillet_ball_vertices(body, edges, radius).and_then(closed).map_err(|_| e)
} else {
Err(e)
}
})
})
// Straight edges ending at a curved face that isn't an earlier round's: a rolling ball
// trimmed by the face. Refused, the refusal above stands.
.or_else(|e| blend_with(body, edges, radius, Shape::Round, "fillet", true).and_then(closed).map_err(|_| e))
// Blends meeting at corners on curved faces: all at once.
.or_else(|e| corners(body, edges, radius, Shape::Round, "fillet").and_then(closed).map_err(|e2| keep_size(e, e2)))
}
/// The first refusal, unless only the second found the blend too large (a smaller one may fit).
fn keep_size(e: KernelError, e2: KernelError) -> KernelError {
match (&e, e2) {
(KernelError::TooLarge(_), _) => e,
(_, e2 @ KernelError::TooLarge(_)) => e2,
_ => e,
}
}
/// Blend the edges together, rebuilding the corners where they meet on faces of any surface
/// (see [`crate::curveblend::corner_blend`]).
fn corners(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
let counts = record::counts(&[body]);
let cur = crate::boolean_record::healed(body)?;
let size = cur.size();
let solid = cur.deep_copy();
let ids = edge_ids(&cur, &solid, edges)?;
if ids.len() != edges.len() {
return Err(KernelError::Invalid("no edge near a selected point".into()));
}
let (made, rec) = guard(what, || crate::curveblend::corner_blend(&solid, &ids, size, r, shape == Shape::Round))?;
Ok(record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts))
}
/// The step of the radius search: three significant digits of the requested radius.
@@ -1925,7 +2058,7 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
return Ok(result);
}
// Coplanar neighbours as one face (as after a join), so corners look as they should.
let mut cur = crate::boolean_record::healed(body)?;
let cur = crate::boolean_record::healed(body)?;
if let Some(groups) = loop_groups(&cur, edges)? {
let whole = || -> Result<Body> {
let mut lc = cur.clone();
@@ -1951,14 +2084,22 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
Err(_) => {}
}
}
for p in edges {
let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) {
Ok(Some(b)) => b,
_ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")?,
};
cur = record::after(step, &cur, &counts);
}
Ok(cur)
let start = cur.clone();
let one_by_one = || -> Result<Body> {
let mut cur = start.clone();
for p in edges {
let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) {
Ok(Some(b)) => b,
// A chain running on into curved faces, or an edge ending at one: as for fillets.
_ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")
.or_else(|e| blend_with(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer", true).map_err(|_| e))?,
};
cur = record::after(step, &cur, &counts);
}
Ok(cur)
};
// Chamfers meeting at corners on curved faces: all at once.
one_by_one().or_else(|e| corners(body, edges, distance, Shape::Flat, "chamfer").map_err(|e2| keep_size(e, e2)))
}
/// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left).
+181 -8
View File
@@ -128,6 +128,150 @@ fn hit(v: Vec3, u: Vec3, t: Vec3, s: f64) -> Result<Vec3> {
Ok(v + u * (s / k))
}
/// Unit direction in which a (curved) edge leaves its end vertex `v`.
fn leaving(e: &mt::Edge, v: &mt::Vertex) -> Option<Vec3> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
let (t0, t1) = c.range_tuple();
let at_front = e.absolute_front() == v;
let d = c.der(if at_front { t0 } else { t1 });
let d = Vec3::new(d.x, d.y, d.z);
(if at_front { d } else { d * -1.0 }).normalized()
}
/// Point where the contact line at setback `s` (in-face direction `t`) crosses a curved edge
/// leaving `v`: the first along it from `v`.
fn crossing(e: &mt::Edge, v: Vec3, t: Vec3, s: f64) -> Result<Vec3> {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
let (t0, t1) = c.range_tuple();
let from_front = crate::body::from_p3(e.absolute_front().point()).dist(v) <= crate::body::from_p3(e.absolute_back().point()).dist(v);
let at = |f: f64| crate::body::from_p3(c.subs(if from_front { t0 + (t1 - t0) * f } else { t1 - (t1 - t0) * f }));
let g = |f: f64| (at(f) - v).dot(t) - s;
let steps = 256;
let mut lo = 0.0;
for i in 1..=steps {
let hi = i as f64 / steps as f64;
if g(hi) >= 0.0 {
let (mut a, mut b) = (lo, hi);
for _ in 0..60 {
let m = (a + b) * 0.5;
if g(m) >= 0.0 {
b = m;
} else {
a = m;
}
}
return Ok(at((a + b) * 0.5));
}
lo = hi;
}
Err(too_large("a fillet larger than the faces next to it"))
}
/// The cylinder's (a chamfer's plane's) section by a curved end face `g`, from `a` (on the
/// contact line with in-face direction `ta`) to `b` (`tb`) round the edge's end `v` (its other
/// end `far`): points of the blend's cross-sections on the face, interpolated; within tolerance
/// of both.
#[allow(clippy::too_many_arguments)]
fn curved_section(
sel: &Sel,
g: &mt::Face,
(a, ta): (Vec3, Vec3),
(b, tb): (Vec3, Vec3),
v: Vec3,
far: Vec3,
round: bool,
size: f64,
) -> Result<mt::Curve> {
use mt::ParametricCurve;
let tolerance = (size * 1e-5).clamp(1e-5, 0.001);
let fail = || too_large("a fillet larger than the faces next to it");
// Position along the edge, and the cross-section there (fraction 0 on `ta`'s side).
let along = |x: Vec3| (x - sel.p0).dot(sel.d);
let point = |f: f64, l: f64| -> Vec3 {
let apex = sel.p0 + sel.d * l;
let (pa, pb) = (apex + ta * sel.s, apex + tb * sel.s);
if round { crate::body::from_p3(conic(pa, apex, pb, sel.w).subs(f)) } else { pa + (pb - pa) * f }
};
// The face's surface, each search started from the last one's answer (the points come in
// small steps).
let surface = g.oriented_surface();
let last = std::cell::Cell::new(None::<(f64, f64)>);
let side = |x: Vec3| -> f64 {
use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
let found = last
.get()
.and_then(|hint| surface.search_nearest_parameter(p3(x), Some(hint), 20))
.or_else(|| surface.search_nearest_parameter(p3(x), None, 100));
found.map_or(f64::NAN, |(u, w)| {
last.set(Some((u, w)));
let n = surface.normal(u, w);
(x - crate::body::from_p3(surface.subs(u, w))).dot(Vec3::new(n.x, n.y, n.z))
})
};
let (la, lb) = (along(a), along(b));
// Out of the material: toward the edge's end.
let outward = if along(v) >= along(far) { 1.0 } else { -1.0 };
let step = ((lb - la).abs().max(sel.s * 0.05)) / 16.0;
let root = |f: f64| -> Option<f64> {
let guess = la + (lb - la) * f;
let gv = side(point(f, guess));
if !gv.is_finite() {
return None;
}
let dir = if gv < 0.0 { outward } else { -outward };
let mut prev = (guess, gv);
for i in 1..=4096 {
let l = guess + dir * step * i as f64;
let cur = (l, side(point(f, l)));
if !cur.1.is_finite() {
return None;
}
if (prev.1 < 0.0) != (cur.1 < 0.0) {
let (mut x, mut y) = (prev, cur);
for _ in 0..60 {
let m = (x.0 + y.0) * 0.5;
let gm = side(point(f, m));
if (gm < 0.0) == (x.1 < 0.0) {
x = (m, gm);
} else {
y = (m, gm);
}
}
return Some((x.0 + y.0) * 0.5);
}
prev = cur;
}
None
};
// Closer together toward the ends, where the face may only touch the blend.
let across = |t: f64| (1.0 - (std::f64::consts::PI * t).cos()) * 0.5;
let mut m = 8;
loop {
let mut pts = vec![a];
for i in 1..m {
let f = across(i as f64 / m as f64);
pts.push(point(f, root(f).ok_or_else(fail)?));
}
pts.push(b);
let curve = crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("fillet: end curve".into()))?;
let accurate = (0..m).all(|i| {
let t = (i as f64 + 0.5) / m as f64;
let f = across(t);
let x = crate::body::from_p3(curve.subs(t));
root(f).is_some_and(|l| point(f, l).dist(x) <= tolerance) && side(x).abs() <= tolerance
});
if accurate {
return Ok(mt::Curve::BSplineCurve(curve));
}
if m >= 128 {
return Err(KernelError::Failed("fillet: could not fit the blend's end in a curved face".into()));
}
m *= 2;
}
}
/// Point in a face where the contact lines of two edges from `v` (directions `u1`, `u2`, in-face
/// directions `t1`, `t2`, setbacks `s1`, `s2`) cross.
fn meet(v: Vec3, (u1, t1, s1): (Vec3, Vec3, f64), (u2, t2, s2): (Vec3, Vec3, f64)) -> Result<Vec3> {
@@ -292,18 +436,26 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
if eids.len() != 3 || fids.len() != 3 {
return Err(unsupported("fillets ending where more than three edges meet"));
}
if fids.iter().any(|f| normal(*f).is_none()) {
// Where one fillet ends, the face it ends on may be curved (and the edges it shares
// with the others): the blend ends in that face's section of the cylinder.
let chosen = eids.iter().filter(|id| sels.contains_key(id)).count();
let sides: Vec<usize> = eids.iter().filter_map(|id| sels.get(id)).flat_map(|s| [s.fa, s.fb]).collect();
if fids.iter().any(|f| normal(*f).is_none() && (chosen != 1 || sides.contains(f))) {
return Err(unsupported("fillets ending on curved faces"));
}
let pv = vtx(v);
let mut list = Vec::new();
for id in eids {
let e = edges.get(id).ok_or_else(|| KernelError::Failed("edge".into()))?;
if !matches!(e.curve(), mt::Curve::Line(_)) {
let u = if matches!(e.curve(), mt::Curve::Line(_)) {
let other = if e.absolute_front() == v { e.absolute_back() } else { e.absolute_front() };
(vtx(other) - pv).normalized()
} else if chosen == 1 && !sels.contains_key(id) {
leaving(e, v)
} else {
return Err(unsupported("fillets ending next to curved edges"));
}
let other = if e.absolute_front() == v { e.absolute_back() } else { e.absolute_front() };
let u = (vtx(other) - pv).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
.ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
list.push((*id, u));
}
around.insert(*vid, Around { edges: list });
@@ -345,14 +497,29 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
let s1 = sel(*e1)?;
let (f12, f31) = (face_with(*e1, *e2)?, face_with(*e1, *e3)?);
let f23 = face_with(*e2, *e3)?;
let p2 = hit(*u2, s1.t(f12), s1.s, f12)?;
let p3_ = hit(*u3, s1.t(f31), s1.s, f31)?;
// Along a curved neighbouring edge: where it crosses the contact line.
let reach = |e: mt::EdgeID, u: Vec3, f: usize| -> Result<Vec3> {
match edges.get(&e) {
Some(x) if !matches!(x.curve(), mt::Curve::Line(_)) => crossing(x, v, s1.t(f), s1.s),
_ => hit(u, s1.t(f), s1.s, f),
}
};
let p2 = reach(*e2, *u2, f12)?;
let p3_ = reach(*e3, *u3, f31)?;
let (a, b) = (builder::vertex(p3(p2)), builder::vertex(p3(p3_)));
endpt.insert((*e2, *vid, None), a.clone());
endpt.insert((*e1, *vid, Some(f12)), a.clone());
endpt.insert((*e3, *vid, None), b.clone());
endpt.insert((*e1, *vid, Some(f31)), b.clone());
let c = if round { edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))? } else { builder::line(&a, &b) };
let c = match faces.get(f23) {
Some(g) if normal(f23).is_none() => {
let e1v = edges.get(e1).ok_or_else(|| KernelError::Failed("edge".into()))?;
let far = if e1v.absolute_front().id() == *vid { vtx(e1v.absolute_back()) } else { vtx(e1v.absolute_front()) };
edge(&a, &b, curved_section(s1, g, (p2, s1.t(f12)), (p3_, s1.t(f31)), v, far, round, size)?)?
}
_ if round => edge(&a, &b, mt::Curve::NurbsCurve(conic(p2, v, p3_, s1.w)))?,
_ => builder::line(&a, &b),
};
list.push((c, vec![Owner::Face(f23), Owner::Cyl(*e1)]));
}
([(ea, ua), (eb, ub)], [(e3, u3)]) => {
@@ -467,7 +634,13 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
if (pb - pa).dot(orig) <= size * 1e-9 * orig.len() {
return Err(too_large("a fillet larger than the faces next to it"));
}
let x = builder::line(&a, &b);
let x = if matches!(e.curve(), mt::Curve::Line(_)) {
builder::line(&a, &b)
} else {
// A curved edge next to a fillet's end, shortened along its own curve.
let moved = |n: &mt::Vertex, o: &mt::Vertex| (n != o).then(|| n.clone());
crate::curveblend::trim_curved_seam(&e.absolute_clone(), moved(&a, e.absolute_front()), moved(&b, e.absolute_back()), size)?
};
lines.insert((e.id(), f), x.clone());
Ok(x)
};
+26 -2
View File
@@ -1332,10 +1332,34 @@ fn cubic_basis(knots: &[f64], n_ctrl: usize, u: f64) -> Vec<f64> {
/// Cubic B-spline through points at uniform parameters (clamped, knots by averaging).
pub(crate) fn interpolate_cubic(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Point3>> {
let m = pts.len();
if m < 4 {
let u: Vec<f64> = (0..m).map(|i| i as f64 / (m.max(2) - 1) as f64).collect();
interpolate_cubic_at(pts, &u)
}
/// [`interpolate_cubic`] by chord length: the parameters (0 to 1) in proportion to the distance
/// along the points. Points closer together toward an end (where a curve leaves another
/// tangentially) then don't slow the curve to a stop there, where a uniform parameter lets it
/// double back on itself.
pub(crate) fn interpolate_cubic_chordal(pts: &[Vec3]) -> Option<(mt::BSplineCurve<mt::Point3>, Vec<f64>)> {
let mut u = vec![0.0];
for w in pts.windows(2) {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return None };
u.push(u.last().copied().unwrap_or(0.0) + a.dist(*b));
}
let total = u.last().copied().filter(|t| t.is_finite() && *t > 0.0)?;
let u: Vec<f64> = u.iter().map(|x| x / total).collect();
if u.windows(2).any(|w| w.first() >= w.get(1)) {
return None;
}
interpolate_cubic_at(pts, &u).map(|c| (c, u))
}
/// The cubic B-spline through `pts` at parameters `u` (rising from 0 to 1), its knots averaged.
fn interpolate_cubic_at(pts: &[Vec3], u: &[f64]) -> Option<mt::BSplineCurve<mt::Point3>> {
let m = pts.len();
if m < 4 || u.len() != m {
return None;
}
let u: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
let mut knots = vec![0.0; 4];
for j in 1..(m - 3) {
knots.push((j..j + 3).filter_map(|i| u.get(i)).sum::<f64>() / 3.0);
+375 -43
View File
@@ -459,6 +459,24 @@ fn solve(
cusp: f64,
extra: Option<(usize, Vec3, Vec3)>,
tol: f64,
) -> Option<Section> {
solve_from(f1, f2, p, t, r, sigma, cusp, extra, tol, None)
}
/// [`solve`], started from the centre `start` when given (the corner builder's balls at a knife
/// edge start beside the last one).
#[allow(clippy::too_many_arguments)]
fn solve_from(
f1: &[&mt::Face],
f2: &[&mt::Face],
p: Vec3,
t: Vec3,
r: f64,
sigma: f64,
cusp: f64,
extra: Option<(usize, Vec3, Vec3)>,
tol: f64,
start: Option<Vec3>,
) -> Option<Section> {
// The nearest face of a side (several faces on one smooth surface group). Each face's
// search starts from its last answer: the centre moves little between iterations. An
@@ -484,7 +502,9 @@ fn solve(
let (q2, n2) = near(f2, p)?;
let _ = (q1, q2);
let den = 1.0 + n1.dot(n2);
let mut c = if cusp != 0.0 && den.abs() < KNIFE {
let mut c = if let Some(c) = start {
c
} else if cusp != 0.0 && den.abs() < KNIFE {
// The normals are (nearly) opposite: `(n1 + n2) / den` has no direction. The centre is r
// inside face 1 and slides along it, away from the edge, until it is r from face 2 too.
let w = n1.cross(t).normalized()? * cusp;
@@ -552,6 +572,21 @@ fn solve(
None
}
/// Whether the strip's curvature jumps where two runs of sections meet (each three sections from
/// the joint outward, the joint's first): for the centres or either contact, the difference of
/// the circles through them would move the middle section by more than a tenth of `tolerance`.
fn bends(a: [&Section; 3], b: [&Section; 3], tolerance: f64) -> bool {
let curvature = |p: [Vec3; 3]| {
let (x, y, z) = (p[1] - p[0], p[2] - p[1], p[2] - p[0]);
let d = x.len() * y.len() * z.len();
if d > 1e-300 { 2.0 * x.cross(y).len() / d } else { 0.0 }
};
let h = a[0].c.dist(a[1].c);
[|s: &Section| s.c, |s: &Section| s.q1, |s: &Section| s.q2]
.iter()
.any(|f| (curvature(a.map(f)) - curvature(b.map(f))).abs() * h * h > tolerance * 0.1)
}
/// Homogeneous control points of the circular (or straight) cross-section from q1 to q2.
fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
let h = |p: Vec3, w: f64| mt::Vector4::new(p.x * w, p.y * w, p.z * w, w);
@@ -570,10 +605,168 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
Some([h(s.q1, 1.0), h(m, half), h(s.q2, 1.0)])
}
/// Where to start the ball at a knife edge through `p` (direction `t`, the sides running on from
/// it along `into`): the first point along them, from the edge, where they are a diameter
/// apart, midway.
fn knife_start(o1: &[&mt::Face], o2: &[&mt::Face], p: Vec3, t: Vec3, into: Vec3, r: f64) -> Option<Vec3> {
let w = (into - t * into.dot(t)).normalized()?;
(1..=256).find_map(|k| {
let x = p + w * (r * k as f64 / 8.0);
let a = o1.iter().filter_map(|f| project(f, x, None)).min_by(|a, b| a.0.dist(x).total_cmp(&b.0.dist(x)))?;
let b = o2.iter().filter_map(|f| project(f, x, None)).min_by(|a, b| a.0.dist(x).total_cmp(&b.0.dist(x)))?;
let c = (a.0 + b.0) * 0.5;
(a.0.dist(b.0) >= 2.0 * r).then(|| c - t * (c - p).dot(t))
})
}
/// Whether two faces meeting at no angle at `pm` (the first's outward normal `n1`, running on
/// from the edge along `into`) hold the material between them: a little way in, the second is
/// behind the first.
fn knife_convex(f1: &mt::Face, f2: &mt::Face, pm: Vec3, into: Vec3, r: f64) -> Option<bool> {
let x = pm + into * r;
let (a, b) = (project(f1, x, None)?, project(f2, x, None)?);
Some((b.0 - a.0).dot(a.1) < 0.0)
}
/// The nearest point of a face's (oriented) surface to `x`, its outward normal and parameters;
/// `last`: where the last search on it ended (the points asked about come in small steps).
fn nearest_on(surface: &mt::Surface, last: &std::cell::Cell<Option<(f64, f64)>>, x: Vec3) -> Option<(Vec3, Vec3, (f64, f64))> {
use mt::{ParametricSurface, ParametricSurface3D, SearchNearestParameter};
// The nearest of the search from the last answer, a fresh one (the first time, also from
// a grid over the surface's parameters: a fresh search on a conic can settle on its far
// side) and either kept within the parameters (past them a conic runs on round).
let (ru, rv) = surface.try_range_tuple();
let ((u0, u1), (v0, v1)) = (ru.unwrap_or((f64::NEG_INFINITY, f64::INFINITY)), rv.unwrap_or((f64::NEG_INFINITY, f64::INFINITY)));
let inside = |uv: (f64, f64)| uv.0 >= u0 - 1e-9 && uv.0 <= u1 + 1e-9 && uv.1 >= v0 - 1e-9 && uv.1 <= v1 + 1e-9;
let warm = last.get().and_then(|hint| surface.search_nearest_parameter(p3(x), Some(hint), 20));
let mut found: Vec<(f64, f64)> = warm.into_iter().collect();
if !warm.is_some_and(inside) {
found.extend(surface.search_nearest_parameter(p3(x), None, 100));
}
if last.get().is_none() && [u0, u1, v0, v1].iter().all(|x| x.is_finite()) {
for i in 0..5 {
for j in 0..5 {
let hint = (u0 + (u1 - u0) * (i as f64 + 0.5) / 5.0, v0 + (v1 - v0) * (j as f64 + 0.5) / 5.0);
found.extend(surface.search_nearest_parameter(p3(x), Some(hint), 50));
}
}
}
let clamped: Vec<(f64, f64)> = found.iter().map(|(u, v)| (u.clamp(u0, u1), v.clamp(v0, v1))).collect();
found.extend(clamped);
let d = |uv: &(f64, f64)| from_p3(surface.subs(uv.0, uv.1)).dist(x);
let uv = found.into_iter().filter(|uv| d(uv).is_finite()).min_by(|a, b| d(a).total_cmp(&d(b)))?;
last.set(Some(uv));
let n = surface.normal(uv.0, uv.1);
Some((from_p3(surface.subs(uv.0, uv.1)), Vec3::new(n.x, n.y, n.z).normalized()?, uv))
}
/// The strip with parameters in proportion to its lengths across and along (the same surface).
/// The mesher triangulates a face in its parameters: on a unit square for a strip a few
/// millimetres wide and tens long, the points of a slanted end fan out into triangles so long
/// along the curve that they turn over.
fn in_proportion(surface: &mt::Surface) -> Result<mt::Surface> {
use mt::ParametricSurface;
let fail = || KernelError::Failed("curve blend: strip parameters".into());
let mt::Surface::NurbsSurface(ns) = surface else { return Err(fail()) };
let length = |at: &dyn Fn(f64) -> Vec3| (0..32).map(|i| at(i as f64 / 32.0).dist(at((i + 1) as f64 / 32.0))).sum::<f64>();
let across = length(&|u| from_p3(surface.subs(u, 0.5)));
let along = length(&|v| from_p3(surface.subs(0.5, v)));
if !(across.is_finite() && along.is_finite() && across > 1e-9 && along > 1e-9) {
return Err(fail());
}
let bs = ns.non_rationalized();
let (mut uk, mut vk) = bs.knot_vecs().clone();
uk.transform(across, 0.0);
vk.transform(along, 0.0);
let bs = mt::BSplineSurface::try_new((uk, vk), bs.control_points().clone()).map_err(|_| fail())?;
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)))
}
/// A chain edge the ball turns about instead of rolling along: an arc of the blend's radius on a
/// planar side (`side`), curving round the ball (a rounded corner rounded again). The ball's
/// centre stays at `c`, it touches the planar side only at the arc's centre `o`, and the blend
/// there is a piece of sphere (of cone, for a chamfer) about the axis through `c` along `axis`.
#[derive(Clone, Copy, Debug)]
struct Pivot {
c: Vec3,
o: Vec3,
axis: Vec3,
side: usize,
}
/// The circle an edge lies on (centre, radius, axis), if it is an arc of one within `tol`.
pub(crate) fn circle_of(e: &mt::Edge, tol: f64) -> Option<(Vec3, f64, Vec3)> {
use mt::{BoundedCurve, ParametricCurve};
let curve = e.oriented_curve();
let (t0, t1) = curve.range_tuple();
let at = |f: f64| from_p3(curve.subs(t0 + (t1 - t0) * f));
let (a, u, v) = (at(0.0), at(0.5) - at(0.0), at(1.0) - at(0.0));
let w = u.cross(v);
if w.len2() < 1e-24 {
return None;
}
let o = a + (v * u.len2() - u * v.len2()).cross(w) * (1.0 / (2.0 * w.len2()));
let (rho, axis) = (o.dist(a), w.normalized()?);
(0..=8).map(|i| at(i as f64 / 8.0)).all(|p| (p.dist(o) - rho).abs() <= tol && (p - o).dot(axis).abs() <= tol).then_some((o, rho, axis))
}
/// The surface the section (homogeneous controls from contact 1 to contact 2) sweeps turning by
/// `angle` about the axis through `c` along the unit `axis`: rational quadratic arcs in v, a
/// quarter turn at most each, so it is exact.
fn revolve(ctrl: [mt::Vector4; 3], c: Vec3, axis: Vec3, angle: f64) -> Option<mt::Surface> {
let pieces = ((angle.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8);
let step = angle / pieces as f64;
let half = (step / 2.0).cos();
if !(half.is_finite() && half > 1e-6) {
return None;
}
let rot = |x: Vec3, phi: f64| {
let (s, co) = phi.sin_cos();
x * co + axis.cross(x) * s + axis * (axis.dot(x) * (1.0 - co))
};
let h = |p: Vec3, w: f64| mt::Vector4::new(p.x * w, p.y * w, p.z * w, w);
let mut rows = Vec::with_capacity(3);
for q in ctrl {
if !(q.w.is_finite() && q.w > 1e-12) {
return None;
}
let x = Vec3::new(q.x / q.w, q.y / q.w, q.z / q.w) - c;
let along = axis * axis.dot(x);
let mut row = vec![h(c + x, q.w)];
for i in 0..pieces {
let a0 = step * i as f64;
row.push(h(c + along + rot(x - along, a0 + step / 2.0) * (1.0 / half), q.w * half));
row.push(h(c + rot(x, a0 + step), q.w));
}
rows.push(row);
}
let mut knots = vec![0.0; 3];
for i in 1..pieces {
let k = i as f64 / pieces as f64;
knots.extend([k, k]);
}
knots.extend([1.0; 3]);
let bs = mt::BSplineSurface::try_new((mt::KnotVec::bezier_knot(2), mt::KnotVec::from(knots)), rows).ok()?;
Some(mt::Surface::NurbsSurface(mt::NurbsSurface::new(bs)))
}
/// Whether the edge is straight: a line, or a curve on the segment between its ends within
/// `tol` (an earlier blend's contact along a straight edge is a fitted curve).
fn straight(e: &mt::Edge, tol: f64) -> bool {
use mt::{BoundedCurve, ParametricCurve};
let c = e.curve();
if matches!(c, mt::Curve::Line(_)) {
return true;
}
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
let (t0, t1) = c.range_tuple();
a.dist(b) > tol && (0..=16).all(|i| from_p3(c.subs(t0 + (t1 - t0) * i as f64 / 16.0)).dist_to_segment(a, b) <= tol)
}
/// A curved seam (an arc of an end face) shortened to the blend's contacts: the part of its
/// curve between the new ends `a` and `b` (`None` keeps that end), which must lie on it, in
/// order, short of each other.
fn trim_curved_seam(seam: &mt::Edge, a: Option<mt::Vertex>, b: Option<mt::Vertex>, size: f64) -> Result<mt::Edge> {
pub(crate) fn trim_curved_seam(seam: &mt::Edge, a: Option<mt::Vertex>, b: Option<mt::Vertex>, size: f64) -> Result<mt::Edge> {
// A closed seam (a full circle starting and ending at one vertex) has no "between": which
// end a contact replaces, and which way round the kept piece runs, are both ambiguous.
if seam.front() == seam.back() {
@@ -771,6 +964,52 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
if n1.dot(n2) > 1.0 - 1e-6 {
return Err(unsupported("blending a smooth edge"));
}
// Arcs on a planar side that curve round the ball: its centre runs round them at their
// radius less the blend's. Less than nothing is too large; nothing is a pivot.
let mut pivots: Vec<Option<Pivot>> = vec![None; n];
for (k, e) in chain.iter().enumerate() {
let Some((o, rho, _)) = circle_of(e, tol * 1e3) else { continue };
for side in [1, 2] {
let Some(f) = (if side == 1 { side1.get(k) } else { side2.get(k) }).and_then(|i| faces.get(*i)) else { continue };
let mt::Surface::Plane(pl) = f.oriented_surface() else { continue };
let Some(np) = Vec3::new(pl.normal().x, pl.normal().y, pl.normal().z).normalized() else { continue };
// Into the face (left of the edge as the face runs it, about its outward normal).
let Some(used) = f.boundary_iters().into_iter().flatten().find(|x| x.id() == e.id()) else { continue };
let Some((pm, tm)) = ({
use mt::{BoundedCurve, ParametricCurve};
let c = used.oriented_curve();
let (a, b) = c.range_tuple();
let d = c.der((a + b) * 0.5);
Vec3::new(d.x, d.y, d.z).normalized().map(|t| (from_p3(c.subs((a + b) * 0.5)), t))
}) else {
continue;
};
if (o - pm).dot(np.cross(tm)) <= 0.0 {
continue;
}
if rho < r - tol * 1e3 {
return Err(too_large("the blend is larger than a corner radius"));
}
if (rho - r).abs() > tol * 1e3 {
continue;
}
// The ball sits on the planar side at the arc's centre; the other side must be a round
// about the same axis, the ball touching it all along.
let c = o + np * (sigma * r);
let (a1, a2) = edge_sides(&[k]);
let other = if side == 1 { a2 } else { a1 };
let reach = other.iter().filter_map(|f| project(f, c, None)).map(|x| x.0.dist(c)).fold(f64::INFINITY, f64::min);
if (reach - r).abs() <= size * 1e-4 {
if let Some(slot) = pivots.get_mut(k) {
*slot = Some(Pivot { c, o, axis: np, side });
}
break;
}
}
}
if pivots.iter().all(Option::is_some) {
return Err(unsupported("rounding again only rounded corners of the blend's radius"));
}
// Seams at the chain's vertices: edges there that aren't in the chain, and their side.
let chain_ids: Vec<mt::EdgeID> = chain.iter().map(|e| e.id()).collect();
let mut seams: Vec<Vec<(usize, mt::Edge)>> = Vec::with_capacity(nv);
@@ -834,6 +1073,21 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
.ok_or_else(|| KernelError::Failed("tangent".into()))?;
let here = seams.get(k).cloned().unwrap_or_default();
let at_end = end_faces.iter().any(|(kk, _)| *kk == k);
// Next to a pivot the ball is at its centre, touching the other side square above the
// vertex.
let pivoting: Vec<Pivot> = [(k < n).then_some(k), if k > 0 { Some(k - 1) } else { closed.then_some(n - 1) }]
.into_iter()
.flatten()
.filter_map(|i| pivots.get(i).copied().flatten())
.collect();
if let Some(pv) = pivoting.first().copied() {
if at_end || pivoting.iter().any(|x| x.c.dist(pv.c) > tol * 1e3) {
return Err(unsupported("a rounded corner of the blend's radius where the edge chain ends or turns"));
}
let q = pv.c + (p - pv.o);
vsec.push(if pv.side == 1 { Section { c: pv.c, q1: pv.o, q2: q } } else { Section { c: pv.c, q1: q, q2: pv.o } });
continue;
}
if at_end {
// A plane square to the edge holds the blend's end section. Any other end face: the
// blend runs on past the end and is cut back where it meets that face.
@@ -856,7 +1110,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
// The plane through the seam square to that side's surface.
let o = from_p3(seam.front().point());
let d = (from_p3(seam.back().point()) - o).normalized().ok_or_else(|| KernelError::Failed("seam".into()))?;
if !matches!(seam.curve(), mt::Curve::Line(_)) {
if !straight(&seam, tol * 10.0) {
return Err(unsupported("a curved seam where the edge chain turns"));
}
let fs = if side == 1 { &s1 } else { &s2 };
@@ -871,13 +1125,17 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
Some((side, o, d.cross(nn).normalized().ok_or_else(|| KernelError::Failed("seam plane".into()))?))
}
};
let (a1, a2) = edge_sides(&vertex_edges(k));
// On the faces of the edge from this vertex alone (the last edge's at an open chain's
// end): the other edge's faces are untrimmed here and can run on near the ball where it no
// longer is (a wall past the round at a corner, which a ball rolling round it pokes
// through).
let (a1, a2) = edge_sides(&[k.min(n - 1)]);
let s = solve(&a1, &a2, p, t, r, sigma, cusp, extra, tol * 100.0).ok_or_else(|| unsupported("the ball doesn't fit along the edge"))?;
// A seam on the other side too must pass through that side's contact.
if let Some((side, seam)) = here.get(1).filter(|_| !at_end) {
let q = if *side == 1 { s.q1 } else { s.q2 };
let (a, b) = (from_p3(seam.front().point()), from_p3(seam.back().point()));
if !matches!(seam.curve(), mt::Curve::Line(_)) || q.dist_to_segment(a, b) > tol * 1e3 {
if !straight(seam, tol * 10.0) || q.dist_to_segment(a, b) > tol * 1e3 {
return Err(unsupported("seams on both sides of a corner of the edge chain"));
}
}
@@ -962,8 +1220,13 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
if n.checked_mul(samples).is_none_or(|count| count > MAX_CHAIN_SAMPLES) {
return Err(unsupported("curve blend exceeds the section fitting limit"));
}
let mut guides = Vec::with_capacity(n);
// No guide along a pivot: its sections are the two at its ends.
let mut guides: Vec<Option<Guide>> = Vec::with_capacity(n);
for (k, e) in chain.iter().enumerate() {
if pivots.get(k).copied().flatten().is_some() {
guides.push(None);
continue;
}
let mut list = vec![*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?];
for j in 1..samples - 1 {
list.push(section_at(k, e, j as f64 / (samples - 1) as f64)?);
@@ -976,14 +1239,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
list.extend(ext.iter().copied());
}
}
guides.push(Guide::new(list)?);
guides.push(Some(Guide::new(list)?));
}
// Intersection curves inherit their leader's piecewise parameter speed. Fitting
// those parameters creates oscillations even on a geometrically smooth spine.
// Resample by distance along the rolling centres, projecting each interpolated
// centre back onto the exact two offset surfaces before it becomes a section.
let uniform_section = |k: usize, along: f64| -> Result<Section> {
let guide = guides.get(k).ok_or_else(|| KernelError::Failed("spine guide".into()))?;
let guide = guides.get(k).and_then(Option::as_ref).ok_or_else(|| KernelError::Failed("spine guide".into()))?;
if along == 0.0 {
return guide.sections.first().copied().ok_or_else(|| KernelError::Failed("section".into()));
}
@@ -996,13 +1259,26 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
.ok_or_else(|| unsupported("the ball doesn't fit along the resampled spine"))
};
let esec: Vec<Vec<Section>> = (0..n)
.map(|k| (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::<Result<Vec<_>>>())
.map(|k| match guides.get(k) {
Some(Some(_)) => (0..samples).map(|j| uniform_section(k, j as f64 / (samples - 1) as f64)).collect::<Result<Vec<_>>>(),
_ => Ok(vec![
*vsec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?,
*vsec.get((k + 1) % nv).ok_or_else(|| KernelError::Failed("section".into()))?,
]),
})
.collect::<Result<_>>()?;
// Each strip's neighbouring sections at its own spacing (edges of different lengths
// have differently spaced samples, which makes a uniform interpolation overshoot).
let length = |k: usize| guides.get(k).and_then(|g| g.lengths.last().copied()).unwrap_or(0.0);
let mut fits = Vec::with_capacity(n);
let length = |k: usize| guides.get(k).and_then(Option::as_ref).and_then(|g| g.lengths.last().copied()).unwrap_or(0.0);
let rolls = |k: usize| guides.get(k).is_some_and(Option::is_some);
let mut fits: Vec<Option<Fit>> = Vec::with_capacity(n);
for k in 0..n {
// A pivot's surface is made whole (`revolve`); a strip next to one ends on the joint's
// section on its own.
if !rolls(k) {
fits.push(None);
continue;
}
let h = length(k) / (samples - 1) as f64;
let spaced = |nb: usize, from_end: bool| -> Result<Option<Vec<Section>>> {
let l = length(nb);
@@ -1018,18 +1294,38 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
}
Ok(Some(out))
};
let before = if closed || k > 0 { spaced((k + n - 1) % n, true)? } else { Some(Vec::new()) };
let after = if closed || k + 1 < n { spaced((k + 1) % n, false)? } else { Some(Vec::new()) };
let (prev, next) = ((k + n - 1) % n, (k + 1) % n);
let list = esec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?;
// Not across a joint where the strip's curvature jumps (a line or a wide arc into a
// small round): a spline smooth through it spreads the jump over the sections beside
// it, and the error falls only with the square of their spacing. The strip then ends
// on the joint's section on its own, which the next one shares.
let smooth = |near: [Option<&Section>; 3], beyond: [Option<&Section>; 3]| match (near, beyond) {
([Some(a), Some(b), Some(c)], [Some(x), Some(y), Some(z)]) => !bends([a, b, c], [x, y, z], fit_tolerance),
_ => false,
};
let before = match (closed || k > 0) && rolls(prev) {
true => spaced(prev, true)?
.map(|b| if smooth([list.first(), list.get(1), list.get(2)], [list.first(), b.get(1), b.first()]) { b } else { Vec::new() }),
false => Some(Vec::new()),
};
let after = match (closed || k + 1 < n) && rolls(next) {
true => spaced(next, false)?.map(|a| {
let m = list.len();
let tail = [list.last(), m.checked_sub(2).and_then(|i| list.get(i)), m.checked_sub(3).and_then(|i| list.get(i))];
if smooth(tail, [list.last(), a.first(), a.get(1)]) { a } else { Vec::new() }
}),
false => Some(Vec::new()),
};
let fit = match (before, after) {
(Some(b), Some(a)) => crate::guard("fit curve blend", || fit_strip(list, &b, &a, round))?,
_ => crate::guard("fit curve blend", || fit_sections(&esec, k, closed, round))?,
};
fits.push(fit);
fits.push(Some(fit));
}
let mut accurate = true;
'edges: for k in 0..n {
let fit = fits.get(k).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
let Some(Some(fit)) = fits.get(k) else { continue };
for j in 0..samples - 1 {
for fraction in [0.25, 0.5, 0.75] {
let along = (j as f64 + fraction) / (samples - 1) as f64;
@@ -1054,7 +1350,7 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut caps: Vec<(usize, usize, f64, f64, mt::Curve)> = Vec::new();
for (k, fi, _, _, tangent) in &slanted {
let ke = if *k == 0 { 0 } else { n - 1 };
let fit = fits.get(ke).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
let fit = fits.get(ke).and_then(Option::as_ref).ok_or_else(|| KernelError::Failed("curve blend fit".into()))?;
let g = faces.get(*fi).ok_or_else(|| KernelError::Failed("end face".into()))?;
let at = vsec.get(*k).copied().ok_or_else(|| KernelError::Failed("section".into()))?;
let seam = |s: u8| seams.get(*k).and_then(|l| l.iter().find(|(x, _)| *x as u8 == s)).map(|(_, e)| e);
@@ -1085,6 +1381,16 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
*b = builder::vertex(curve.subs(hi));
}
}
// Along a pivot the planar side's contact is one point: one vertex at both its ends.
for (k, pv) in pivots.iter().enumerate() {
let Some(pv) = pv else { continue };
let vs = if pv.side == 1 { &mut v1 } else { &mut v2 };
if let Some(v) = vs.get(k).cloned()
&& let Some(slot) = vs.get_mut((k + 1) % nv)
{
*slot = v;
}
}
// A chain end (a cap, or the section at a square end) on the far end of its seam uses that
// seam up: that vertex is the end.
let mut consumed: HashMap<mt::EdgeID, mt::Vertex> = HashMap::new();
@@ -1117,15 +1423,48 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
};
arcs.push(e);
}
// Outward: toward the ball centre on a concave edge, away on a convex one.
let oriented = |mut surface: mt::Surface, list: &[Section]| -> Result<mt::Surface> {
use mt::{ParametricSurface3D, SearchNearestParameter};
let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?;
let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r;
if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) {
let nn = surface.normal(u, v);
let want = (s.c - mid) * sigma;
if Vec3::new(nn.x, nn.y, nn.z).dot(want) < 0.0 {
surface = mt::Invertible::inverse(&surface);
}
}
Ok(surface)
};
// Contact curves and blend faces per edge.
let mut c1: Vec<mt::Edge> = Vec::with_capacity(n);
let mut c2: Vec<mt::Edge> = Vec::with_capacity(n);
// (None: the planar side's contact along a pivot, a point.)
let mut c1: Vec<Option<mt::Edge>> = Vec::with_capacity(n);
let mut c2: Vec<Option<mt::Edge>> = Vec::with_capacity(n);
let mut blends: Vec<mt::Surface> = Vec::with_capacity(n);
for (k, list) in esec.iter().enumerate() {
let k1 = (k + 1) % nv;
let (a1, b1, a2, b2) = (v1.get(k), v1.get(k1), v2.get(k), v2.get(k1));
let (Some(a1), Some(b1), Some(a2), Some(b2)) = (a1, b1, a2, b2) else { return Err(fail("vertex")) };
let fit = fits.get(k).ok_or_else(|| fail("fit"))?;
if let Some(pv) = pivots.get(k).copied().flatten() {
// The section at the start turned about the axis to the one at the end; the other
// side's contact is the circle its end runs on.
let (s0, s1e) = (list.first().ok_or_else(|| fail("section"))?, list.last().ok_or_else(|| fail("section"))?);
let (qa, qb) = if pv.side == 1 { (s0.q2, s1e.q2) } else { (s0.q1, s1e.q1) };
let (a, b) = (qa - pv.c, qb - pv.c);
let angle = pv.axis.dot(a.cross(b)).atan2(a.dot(b));
let (sn, cs) = (angle / 2.0).sin_cos();
let mid = pv.c + a * cs + pv.axis.cross(a) * sn + pv.axis * (pv.axis.dot(a) * (1.0 - cs));
let surface = section_controls(s0, round).and_then(|ctrl| revolve(ctrl, pv.c, pv.axis, angle)).ok_or_else(|| fail("pivot"))?;
let (from, to) = if pv.side == 1 { (a2, b2) } else { (a1, b1) };
let arc = builder::circle_arc(from, to, p3(mid));
let (e1, e2) = if pv.side == 1 { (None, Some(arc)) } else { (Some(arc), None) };
c1.push(e1);
c2.push(e2);
blends.push(oriented(surface, list)?);
continue;
}
let fit = fits.get(k).and_then(Option::as_ref).ok_or_else(|| fail("fit"))?;
let [cv1, cv2] = &fit.contacts;
// Contacts past a slanted end stop at the end curve.
let (mut cv1, mut cv2) = (cv1.clone(), cv2.clone());
@@ -1143,28 +1482,12 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
cv2 = cv2.cut(*t2);
}
}
let e1 = mt::Edge::new(a1, b1, cv1);
let e2 = mt::Edge::new(a2, b2, cv2);
let mut surface = fit.surface.clone();
// Outward: toward the ball centre on a concave edge, away on a convex one.
{
use mt::{ParametricSurface3D, SearchNearestParameter};
let s = list.get(list.len() / 2).ok_or_else(|| fail("section"))?;
let mid = s.c + ((s.q1 - s.c) + (s.q2 - s.c)).normalized().ok_or_else(|| fail("mid"))? * r;
if let Some((u, v)) = surface.search_nearest_parameter(p3(mid), None, 100) {
let nn = surface.normal(u, v);
let want = (s.c - mid) * sigma;
if Vec3::new(nn.x, nn.y, nn.z).dot(want) < 0.0 {
surface = mt::Invertible::inverse(&surface);
}
}
}
c1.push(e1);
c2.push(e2);
blends.push(surface);
c1.push(Some(mt::Edge::new(a1, b1, cv1)));
c2.push(Some(mt::Edge::new(a2, b2, cv2)));
blends.push(oriented(fit.surface.clone(), list)?);
}
// Rebuild the side faces: chain edges become contact curves, seams start at contacts.
let mut subst: HashMap<mt::EdgeID, (mt::Edge, mt::Edge)> = HashMap::new();
let mut subst: HashMap<mt::EdgeID, (Option<mt::Edge>, Option<mt::Edge>)> = HashMap::new();
for (k, e) in chain.iter().enumerate() {
let (Some(x), Some(y)) = (c1.get(k), c2.get(k)) else { return Err(fail("contact")) };
subst.insert(e.id(), (x.clone(), y.clone()));
@@ -1293,7 +1616,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
let mut es = Vec::new();
for e in w.edge_iter() {
let ne = if let Some((x, y)) = subst.get(&e.id()) {
let base = if on1 { x } else { y };
// A point along a pivot: the contacts beside it meet there.
let Some(base) = (if on1 { x } else { y }) else { continue };
// Same direction as the chain edge, or reversed.
let chain_e = chain.iter().find(|c| c.id() == e.id()).ok_or_else(|| fail("edge"))?;
if e.front() == chain_e.front() { base.clone() } else { base.inverse() }
@@ -1328,11 +1652,15 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
.map(|e| e.front() == chain_e.front())
.ok_or_else(|| fail("edge use"))?;
// Side 1 runs the contact forward (as the chain) when it ran the edge forward.
// A pivot's planar contact is a point: three sides.
let wire: mt::Wire = if used_fwd {
vec![e1.inverse(), ak.clone(), e2.clone(), ak1.inverse()].into()
[e1.as_ref().map(|e| e.inverse()), Some(ak.clone()), e2.clone(), Some(ak1.inverse())]
} else {
vec![e1.clone(), ak1.clone(), e2.inverse(), ak.inverse()].into()
};
[e1.clone(), Some(ak1.clone()), e2.as_ref().map(|e| e.inverse()), Some(ak.inverse())]
}
.into_iter()
.flatten()
.collect();
let face = mt::Face::try_new(vec![wire], bf.clone()).map_err(|e| fail(&e.to_string()))?;
rec.blend(&face, [chain_e.id()]);
out.push(face);
@@ -1345,6 +1673,10 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
})
}
#[path = "curvecorner.rs"]
mod corner;
pub(crate) use corner::corner_blend;
#[cfg(test)]
#[path = "curveblend_tests.rs"]
mod tests;
+237
View File
@@ -5,6 +5,7 @@ use mt::{BoundedCurve, ParametricCurve, ParametricSurface, ParametricSurface3D,
use super::*;
use crate::blend::plane_normal;
use crate::issue_851_tests::planned_volume;
use crate::{Body, BoolOp, boolean, cylinder, fillet};
fn tee(scale: f64, origin: Vec3) -> Body {
@@ -408,3 +409,239 @@ fn a_smooth_seam_is_refused_as_having_no_corner() {
assert!(fillet(&one, &[Vec3::new(20.0, 0.0, 20.0)], 1.0).is_ok());
assert!(matches!(fillet(&one, &[Vec3::new(35.0, 30.0, 10.0)], 1.0), Err(KernelError::Invalid(_))));
}
/// A 40 × 40 × 20 box whose front wall is an arc of radius 40 about a Z axis: `concave` cuts a
/// cylinder away from the front (the wall curves into the part), otherwise the part is the box
/// within the cylinder (the wall curves out). The arc meets the planar side walls x = 0 and
/// x = 40 at an angle, not square. With the bottom arc's midpoint, the axis's base point and the
/// side of the wall the material is on (+1 away from the axis, −1 toward it).
fn arc_walled_box(concave: bool) -> (Body, Vec3, Vec3, f64) {
let block = crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
let (axis, op, side) =
if concave { (Vec3::new(20.0, -30.0, 0.0), BoolOp::Cut, 1.0) } else { (Vec3::new(20.0, 70.0, 0.0), BoolOp::Intersect, -1.0) };
let tool = cylinder(axis, Vec3::Z, 40.0, 20.0).unwrap();
let body = boolean(&block, &tool, op).unwrap().unwrap();
(body, axis + Vec3::new(0.0, side * 40.0, 0.0), axis, side)
}
/// Volume the blend removes along the bottom arc: the corner's cross-section at each distance
/// ρ from the axis (`depth(x)`, x ∈ [0, size] from the wall into the material), times the arc
/// of radius ρ between the side walls.
fn removed_volume(side: f64, size: f64, depth: impl Fn(f64) -> f64) -> f64 {
let steps = 4000;
let h = size / steps as f64;
(0..=steps)
.map(|i| {
let x = i as f64 * h;
let rho = 40.0 + side * x;
let w = if i == 0 || i == steps {
1.0
} else if i % 2 == 1 {
4.0
} else {
2.0
};
w * depth(x) * 2.0 * rho * (20.0 / rho).asin()
})
.sum::<f64>()
* h
/ 3.0
}
#[test]
fn rolling_ball_along_an_arc_ends_in_side_walls_at_an_angle() {
// The bottom edge of an arc wall ends in the side walls at an angle (#556). The round is the
// torus about the cylinder's axis (centre circle of radius 40 ± r at height r), trimmed by
// the side walls; the chamfer is the cone between the two contacts.
let r = 2.0;
for concave in [true, false] {
let (body, mid, axis, side) = arc_walled_box(concave);
let v0 = planned_volume(&body);
for round in [true, false] {
let what = format!("{} {}", if concave { "concave" } else { "convex" }, if round { "fillet" } else { "chamfer" });
let blend = if round { fillet(&body, &[mid], r) } else { crate::chamfer(&body, &[mid], r) }.unwrap_or_else(|e| panic!("{what}: {e}"));
assert!(blend.validity().is_empty(), "{what}: {:?}", blend.validity());
let volume = planned_volume(&blend);
let removed =
if round { removed_volume(side, r, |x| r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) } else { removed_volume(side, r, |x| r - x) };
assert!((v0 - volume - removed).abs() < removed * 3e-3, "{what}: removed {} vs {removed}", v0 - volume);
// The blend face stays between the side walls and, for the round, on the torus.
let surfaces: Vec<_> =
blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
assert!(!surfaces.is_empty(), "{what}: no blend face");
let mesh = blend.tessellate_with(0.0005, true).unwrap();
let (lo, hi) = mesh.positions.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p.x), hi.max(p.x)));
assert!(lo > -1e-3 && hi < 40.0 + 1e-3, "{what}: the blend runs past the side walls: x {lo}..{hi}");
if round {
// Sections of the round across the arc: the quarter circle about the centre
// ring (radius 40 ± r about the axis, at height r) from contact to contact.
let ring = 40.0 + side * r;
let mut worst: f64 = 0.0;
for i in 0..=40 {
let x = 40.0 * i as f64 / 40.0;
for a in 1..8 {
let t = std::f64::consts::FRAC_PI_2 * a as f64 / 8.0;
let (rho, z) = (ring - side * r * t.cos(), r - r * t.sin());
let dx = x - axis.x;
let dy = (rho * rho - dx * dx).sqrt() * if concave { 1.0 } else { -1.0 };
let p = Vec3::new(x, axis.y + dy, z);
worst = worst.max(nearest_blend(&surfaces, p).0);
}
}
assert!(worst < 1e-3, "{what}: blend surface is {worst} mm off the torus");
}
}
}
}
#[test]
fn rolling_ball_past_a_slanted_end_face_is_too_large_and_reduces() {
// On the outward-curving wall a round of 4 would run its bottom contact into the back face
// before it reaches the side wall (the contact circle of radius 36 meets x = 0 at y ≈ 40.07):
// too large, and the largest that fits rounds the edge.
let (body, mid, _, _) = arc_walled_box(false);
let e = fillet(&body, &[mid], 4.0).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
let (r, blend) = crate::fillet_max(&body, &[mid], 4.0).unwrap();
assert!(r > 3.0 && r < 4.0, "{r}");
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
}
#[test]
fn rolling_ball_runs_into_a_knife_edge_tip() {
// The corner of a block outside a cylinder of radius 21 about (21, −21): its wall meets the
// back face y = 0 tangentially at (21, 0) and the left face x = 0 at (0, −21), knife-edge
// tips at both ends of the arc. The round of the bottom arc runs into both, cut off by the
// end faces' planes, which touch the wall contact at the tips without crossing it: at
// distance ρ from the axis it runs from x = 0 to y = 0, ρ (asin(21 / ρ) − acos(21 / ρ)).
let block = crate::box_solid(Vec3::new(0.0, -21.0, 0.0), Vec3::new(21.0, 0.0, 10.0)).unwrap();
let body = boolean(&block, &cylinder(Vec3::new(21.0, -21.0, -1.0), Vec3::Z, 21.0, 12.0).unwrap(), BoolOp::Cut).unwrap().unwrap();
let mid = Vec3::new(21.0 - 21.0 * std::f64::consts::FRAC_1_SQRT_2, -21.0 + 21.0 * std::f64::consts::FRAC_1_SQRT_2, 0.0);
let r = 2.0;
let blend = fillet(&body, &[mid], r).unwrap();
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
let (n, h) = (4000, r / 4000.0);
let removed = (0..=n)
.map(|i| {
let x = i as f64 * h;
let rho = 21.0 + x;
let w = if i == 0 || i == n {
1.0
} else if i % 2 == 1 {
4.0
} else {
2.0
};
w * (r - (r * r - (r - x) * (r - x)).max(0.0).sqrt()) * rho * ((21.0 / rho).asin() - (21.0 / rho).acos())
})
.sum::<f64>()
* h
/ 3.0;
let got = planned_volume(&body) - planned_volume(&blend);
assert!((got - removed).abs() < removed * 5e-3, "removed {got} vs {removed}");
}
#[test]
fn rolling_ball_turns_about_a_rounded_corner_of_its_radius() {
// The box with the inward-curving wall, its 60° corner at x = 0 rounded (r 2) first. The
// bottom edges then run: the side wall's, the corner round's (an arc of radius 2) and the
// arc wall's into the slanted side wall at x = 40. Rounded again with r 2 the ball can't
// roll round the corner: it turns about one point there, and the blend is a piece of sphere.
// It is what the rolling round tends to as its radius tends to the corner's, too large past
// it, and its mesh is closed.
let (body, _, _, _) = arc_walled_box(true);
let body = fillet(&body, &[Vec3::new(0.0, 4.641016151377546, 10.0)], 2.0).unwrap();
// The corner round's centre is 4 from the corner along its bisector, (2, 8.105): its arc's
// middle is 2 back toward the corner.
let ends = [Vec3::new(0.0, 25.0, 0.0), Vec3::new(1.0, 6.373, 0.0), Vec3::new(20.0, 10.0, 0.0)];
let v0 = planned_volume(&body);
let removed = |r: f64| {
let b = fillet(&body, &ends, r).unwrap_or_else(|e| panic!("r {r}: {e}"));
assert!(b.validity().is_empty(), "r {r}: {:?}", b.validity());
v0 - planned_volume(&b)
};
let (a, b, at) = (removed(1.96), removed(1.98), removed(2.0));
let expected = 2.0 * b - a;
assert!((at - expected).abs() < expected * 2e-3, "removed {at} at r 2, {a} and {b} before it");
let e = fillet(&body, &ends, 2.5).unwrap_err();
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
let (r, _) = crate::fillet_max(&body, &ends, 2.5).unwrap();
assert!(r > 1.5 && r <= 2.0 + 1e-9, "{r}");
}
#[test]
fn rolling_ball_along_a_straight_edge_ends_in_a_curved_face() {
// The box with the inward-curving wall: the bottom edge of the side wall x = 0 runs from the
// back to the curved wall (#556). The round of radius 2 along it is cut off by that wall:
// at d from the side wall it runs from y = −30 + √(40² − (d − 20)²) to the back at y = 40.
let (body, _, _, _) = arc_walled_box(true);
let r = 2.0;
for round in [true, false] {
let blend =
if round { fillet(&body, &[Vec3::new(0.0, 25.0, 0.0)], r) } else { crate::chamfer(&body, &[Vec3::new(0.0, 25.0, 0.0)], r) }.unwrap();
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
let (n, h) = (4000, r / 4000.0);
let removed = (0..=n)
.map(|i| {
let d = i as f64 * h;
let w = if i == 0 || i == n {
1.0
} else if i % 2 == 1 {
4.0
} else {
2.0
};
let depth = if round { r - (r * r - (r - d) * (r - d)).max(0.0).sqrt() } else { r - d };
w * depth * (40.0 - (-30.0 + (1600.0 - (d - 20.0) * (d - 20.0)).sqrt()))
})
.sum::<f64>()
* h
/ 3.0;
let (m0, m1) = (body.tessellate_with(0.0005, true).unwrap(), blend.tessellate_with(0.0005, true).unwrap());
let got = m0.measure().volume - m1.measure().volume;
assert!((got - removed).abs() < removed * 3e-3, "round {round}: removed {got} vs {removed}");
let volume_about = |o: Vec3| {
let at = |i: u32| m1.positions[i as usize] - o;
m1.triangles.iter().map(|t| at(t[0]).dot(at(t[1]).cross(at(t[2]))) / 6.0).sum::<f64>()
};
let shift = volume_about(Vec3::ZERO) - volume_about(Vec3::new(-100.0, 50.0, 30.0));
assert!(shift.abs() < 1e-2, "round {round}: the mesh is not closed (volume moves {shift} with the origin)");
}
}
#[test]
fn chamfer_a_chain_of_a_line_and_an_arc_into_a_slanted_end() {
// A 40 × 40 block, 20 high, whose front runs straight for 20 and then along an arc of
// radius 29 about (20, 29), tangent to it, up to (40, 8): the right face meets the arc at an
// angle. The top front chain chamfered by 2 (the planes' and the cone's sections are 2 × 2
// corners, 2 − w high at w in from the edge): 2 a millimetre along the line, and along the
// arc out to the right face, at each distance ρ from its axis, the height times the arc of
// radius ρ inside x ≤ 40.
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2};
let start = -std::f64::consts::FRAC_PI_2;
let end = (-21.0f64).atan2(20.0);
let segs = vec![
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(20.0, 0.0) },
Seg2::Arc { center: Vec2::new(20.0, 29.0), radius: 29.0, start, sweep: end - start },
Seg2::Line { a: Vec2::new(40.0, 8.0), b: Vec2::new(40.0, 40.0) },
Seg2::Line { a: Vec2::new(40.0, 40.0), b: Vec2::new(0.0, 40.0) },
Seg2::Line { a: Vec2::new(0.0, 40.0), b: Vec2::new(0.0, 0.0) },
];
let body = crate::extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
let volume = |b: &Body| b.tessellate_with(0.0005, true).unwrap().measure().volume;
let made = crate::chamfer(&body, &[Vec3::new(10.0, 0.0, 20.0)], 2.0).unwrap();
let n = 2000;
let h = 2.0 / n as f64;
let arc = (0..=n)
.map(|i| {
let rho = 27.0 + i as f64 * h;
let w = if i == 0 || i == n { 1.0 } else { [2.0, 4.0][i % 2] };
w * (rho - 27.0) * rho * (20.0 / rho).asin()
})
.sum::<f64>()
* h
/ 3.0;
let want = 40.0 + arc;
let got = volume(&body) - volume(&made);
assert!((got - want).abs() < want * 1e-3, "removed {got} vs {want}");
}
File diff suppressed because it is too large. Load diff
+312
View File
@@ -0,0 +1,312 @@
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use crate::Body;
const X0: f64 = 41.0 + 1.0 / 6.0;
/// The arc's axis.
const CX: f64 = X0 + 21.0;
/// The arc part: a 41⅙ × 21 rectangle and, beside it, the corner a circle of radius 21 about
/// (CX, −21) leaves against the top line it touches (its arc square to the front, a knife edge
/// at the tip), extruded 10.
fn arc_part() -> Body {
scaled_arc_part(1.0)
}
/// [`arc_part`] scaled by `k` about the origin.
fn scaled_arc_part(k: f64) -> Body {
let p = |x: f64, y: f64| Vec2::new(x * k, y * k);
let segs = vec![
Seg2::Line { a: p(0.0, 0.0), b: p(0.0, -21.0) },
Seg2::Line { a: p(0.0, -21.0), b: p(X0, -21.0) },
Seg2::Arc { center: p(CX, -21.0), radius: 21.0 * k, start: std::f64::consts::PI, sweep: -std::f64::consts::FRAC_PI_2 },
Seg2::Line { a: p(CX, 0.0), b: p(0.0, 0.0) },
];
crate::extrude(&Plane::XY, &[Region2 { outer: Loop2 { segs }, holes: vec![] }], 0.0, 10.0 * k).unwrap().pop().unwrap()
}
/// Points on the edges at the top: the long front edge, the arc, the back edge; the uprights
/// between the front and the arc, and at the tip (a knife edge); the left top edge and the
/// left front upright.
const LONG: Vec3 = Vec3::new(20.0, -21.0, 10.0);
fn arc_top() -> Vec3 {
let a = std::f64::consts::FRAC_PI_4;
Vec3::new(CX - 21.0 * a.cos(), -21.0 + 21.0 * a.sin(), 10.0)
}
const BACK: Vec3 = Vec3::new(30.0, 0.0, 10.0);
const UPRIGHT: Vec3 = Vec3::new(X0, -21.0, 5.0);
const TIP: Vec3 = Vec3::new(CX, 0.0, 5.0);
const LEFT: Vec3 = Vec3::new(0.0, -10.0, 10.0);
const LEFT_FRONT: Vec3 = Vec3::new(0.0, -21.0, 5.0);
/// The volume of the body's display mesh, asserting it is closed (the legacy mesher can misread
/// a zero-angle face corner such as the tip's).
fn volume(b: &Body) -> f64 {
crate::issue_851_tests::planned_volume(b)
}
/// What a blend (radius or distance 2) of the edges at `pts` takes off the part.
fn removed(round: bool, pts: &[Vec3]) -> f64 {
let body = arc_part();
let b = if round { crate::fillet(&body, pts, 2.0) } else { crate::chamfer(&body, pts, 2.0) }.unwrap();
volume(&body) - volume(&b)
}
/// The part's material at `p`.
fn in_part(p: Vec3) -> bool {
(0.0..=CX).contains(&p.x) && (-21.0..=0.0).contains(&p.y) && (0.0..=10.0).contains(&p.z) && (p.x <= X0 || (p.x - CX).hypot(p.y + 21.0) >= 21.0)
}
/// Rounds of radius 2 about the top front corner of the arc: what each edge's round alone
/// takes off there (between the edge and the ball, on the edge's side of its contacts).
fn off_long(p: Vec3) -> bool {
in_part(p) && p.y < -19.0 && p.z > 8.0 && (p.y + 19.0).hypot(p.z - 8.0) > 2.0
}
/// The upright's ball, 2 from the front and 23 from the arc's axis.
fn upright_centre() -> f64 {
CX - (23.0f64 * 23.0 - 4.0).sqrt()
}
fn off_upright(p: Vec3) -> bool {
let (dx, dy) = (p.x - upright_centre(), p.y + 19.0);
let a = dy.atan2(dx);
in_part(p) && a >= -std::f64::consts::FRAC_PI_2 && a <= (-2.0f64).atan2(CX - upright_centre()) && dx.hypot(dy) > 2.0
}
fn off_arc(p: Vec3) -> bool {
let rho = (p.x - CX).hypot(p.y + 21.0);
in_part(p) && p.y >= -21.0 && rho < 23.0 && p.z > 8.0 && (rho - 23.0).hypot(p.z - 8.0) > 2.0
}
/// Kept when all three are rounded and the corner with them: within 2 of a ball centre 2 from
/// all three faces (z ≤ 8, y ≥ −19, 23 from the arc's axis), the nearest on a face, an edge
/// or the corner of that region.
fn kept_by_balls(p: Vec3) -> bool {
let c = Vec3::new(upright_centre(), -19.0, 8.0);
let inside = |q: Vec3| q.z <= 8.0 + 1e-12 && q.y >= -19.0 - 1e-12 && (q.x - CX).hypot(q.y + 21.0) >= 23.0 - 1e-12;
let mut near =
vec![c, Vec3::new(p.x.min(c.x), -19.0, 8.0), Vec3::new(c.x, -19.0, p.z.min(8.0)), Vec3::new(p.x, p.y, 8.0), Vec3::new(p.x, -19.0, p.z)];
let (dx, dy) = (p.x - CX, p.y + 21.0);
let d = dx.hypot(dy);
if d > 1e-9 {
let (ux, uy) = (dx / d, dy / d);
near.push(Vec3::new(CX + ux * 23.0, -21.0 + uy * 23.0, p.z));
near.push(Vec3::new(CX + ux * 23.0, -21.0 + uy * 23.0, 8.0));
}
in_part(p) && (inside(p) || near.iter().filter(|q| inside(**q)).any(|q| q.dist(p) <= 2.0))
}
/// Volume of the points of the box about the corner where `f` holds (midpoint rule).
fn corner_volume(f: impl Fn(Vec3) -> bool) -> f64 {
let h = 0.02;
let (lo, hi) = (Vec3::new(38.0, -21.0, 7.5), Vec3::new(42.5, -17.5, 10.0));
let n = |a: f64, b: f64| ((b - a) / h).round() as usize;
let mut count = 0usize;
for i in 0..n(lo.x, hi.x) {
for j in 0..n(lo.y, hi.y) {
for k in 0..n(lo.z, hi.z) {
if f(lo + Vec3::new((i as f64 + 0.5) * h, (j as f64 + 0.5) * h, (k as f64 + 0.5) * h)) {
count += 1;
}
}
}
}
count as f64 * h * h * h
}
/// Two or three of the edges at the arc's top front corner rounded together: the strips cut
/// each other in a crease (the long one running on past it to end in the arc wall, as far as
/// its contact reaches along the arc's edge), and with all three a piece of the ball touching
/// the three faces fills the corner. What they take off: each round alone, less what each alone
/// takes in a box about the corner, plus what they take there together (rolling-ball rounds
/// trim each other where each one's corner region ends; with three, what no ball inside the
/// part reaches).
#[test]
fn rounds_meeting_at_a_corner_of_an_arc_wall() {
let offs: [(Vec3, fn(Vec3) -> bool); 3] = [(LONG, off_long), (arc_top(), off_arc), (UPRIGHT, off_upright)];
let alone: Vec<f64> = offs.iter().map(|(p, f)| removed(true, &[*p]) - corner_volume(f)).collect();
for which in [vec![0, 2], vec![0, 1], vec![1, 2], vec![0, 1, 2]] {
let pts: Vec<Vec3> = which.iter().map(|i| offs[*i].0).collect();
let fs: Vec<fn(Vec3) -> bool> = which.iter().map(|i| offs[*i].1).collect();
let together = if which.len() == 3 { corner_volume(|p| in_part(p) && !kept_by_balls(p)) } else { corner_volume(|p| fs.iter().any(|f| f(p))) };
let want = which.iter().map(|i| alone[*i]).sum::<f64>() + together;
let got = removed(true, &pts);
assert!((got - want).abs() < 0.03, "{which:?}: removed {got} vs {want}");
}
}
/// The ball at the knife-edge tip: 2 below the top line and 23 from the arc's axis.
fn tip_ball() -> Vec2 {
Vec2::new(CX - (23.0f64 * 23.0 - 19.0 * 19.0).sqrt(), -2.0)
}
/// Integral over the cusp between the arc and the top line (midpoint rule, the last cell of
/// each column cut at the top line), from `x0` to the tip.
fn over_cusp(x0: f64, f: impl Fn(f64, f64) -> f64) -> f64 {
let (hx, hy) = (0.001, 0.0002);
let mut sum = 0.0;
let mut x = x0 + hx / 2.0;
while x < CX {
let lo = -21.0 + (441.0 - (x - CX) * (x - CX)).max(0.0).sqrt();
let n = ((0.0 - lo) / hy).ceil().max(0.0) as usize;
sum += (0..n)
.map(|j| {
let (y0, y1) = (lo + j as f64 * hy, (lo + (j + 1) as f64 * hy).min(0.0));
f(x, (y0 + y1) * 0.5) * (y1 - y0)
})
.sum::<f64>()
* hx;
x += hx;
}
sum
}
/// Whether the tip's round takes off (x, y), all the way up: the cusp beyond the ball, on the
/// tip's side of its contacts.
fn off_tip(x: f64, y: f64) -> bool {
let k = tip_ball();
let a = (y - k.y).atan2(x - k.x);
(x - k.x).hypot(y - k.y) > 2.0 && a >= (-21.0 - k.y).atan2(CX - k.x) && a <= std::f64::consts::FRAC_PI_2
}
/// The knife-edge tip (the arc and the top line meeting at no angle) rounds to the ball 2 from
/// both, 13 back from the tip; its chamfer cuts the cusp at the chord between the same contacts.
#[test]
fn a_knife_edge_tip_rounds_where_the_ball_fits() {
let k = tip_ball();
let round = over_cusp(k.x - 1.0, |x, y| if off_tip(x, y) { 1.0 } else { 0.0 }) * 10.0;
let got = removed(true, &[TIP]);
assert!((got - round).abs() < round * 1e-3, "round: removed {got} vs {round}");
// The chord from the top line's contact to the arc's.
let (a, b) = (Vec2::new(k.x, 0.0), k + (Vec2::new(CX, -21.0) - k) * (2.0 / 23.0));
let beyond = |x: f64, y: f64| (b.x - a.x) * (y - a.y) - (b.y - a.y) * (x - a.x) > 0.0;
let flat = over_cusp(k.x - 1.0, |x, y| if beyond(x, y) { 1.0 } else { 0.0 }) * 10.0;
let got = removed(false, &[TIP]);
assert!((got - flat).abs() < flat * 1e-3, "chamfer: removed {got} vs {flat}");
}
/// The tip's round with the arc's or the back edge's at the top, where all three meet: as at
/// the front corner, each alone less what both would take twice — over the tip's corner region,
/// the other's (above 8 + √(4 − d²), d across from its ball's path).
#[test]
fn rounds_meeting_at_a_knife_edge_tip() {
let k = tip_ball();
let tip = removed(true, &[TIP]);
let arc_d = |x: f64, y: f64| 23.0 - (x - CX).hypot(y + 21.0);
let back_d = |_: f64, y: f64| y + 2.0;
for (edge, d) in [(arc_top(), &arc_d as &dyn Fn(f64, f64) -> f64), (BACK, &back_d)] {
let both = over_cusp(k.x - 1.0, |x, y| {
let d = d(x, y);
if off_tip(x, y) && d > 0.0 && d < 2.0 { 2.0 - (4.0 - d * d).sqrt() } else { 0.0 }
});
let want = tip + removed(true, &[edge]) - both;
let got = removed(true, &[edge, TIP]);
assert!((got - want).abs() < want * 1e-3, "{edge:?}: removed {got} vs {want}");
}
}
/// Chamfers of the long top edge and the arc's top edge meeting at the front corner: their
/// planes (the arc's a cone) cut each other. Each alone, less what both take: over the top's
/// corner, 10 − z above both 8 + d (d across from the front face, or from the arc toward its axis).
#[test]
fn chamfers_meeting_at_a_corner_of_an_arc_wall() {
let h = 0.002;
let mut both = 0.0;
for i in 0..((44.0 - 36.0) / h) as usize {
for j in 0..(3.0 / h) as usize {
let (x, y) = (36.0 + (i as f64 + 0.5) * h, -21.0 + (j as f64 + 0.5) * h);
let rho = (x - CX).hypot(y + 21.0);
if in_part(Vec3::new(x, y, 9.99)) && y < -19.0 && rho < 23.0 {
both += (2.0 - (y + 21.0).max(rho - 21.0)).max(0.0) * h * h;
}
}
}
let want = removed(false, &[LONG]) + removed(false, &[arc_top()]) - both;
let got = removed(false, &[LONG, arc_top()]);
assert!((got - want).abs() < want * 1e-3, "removed {got} vs {want}");
}
/// A fillet over an earlier one at the end of its edge, as a feature of its own: the new round
/// ends in the earlier one (running on past it across the next face when the earlier one's end
/// is shorter than the new round), taking off what the two take together.
#[test]
fn a_round_ending_in_an_earlier_round_matches_both_together() {
for (first, then) in [
(LONG, LEFT),
(LONG, LEFT_FRONT),
(LONG, arc_top()),
(arc_top(), LONG),
(LONG, UPRIGHT),
(arc_top(), UPRIGHT),
// At the knife-edge tip, where the first round's end takes in the tip's corner.
(arc_top(), TIP),
(BACK, TIP),
(arc_top(), BACK),
] {
one_then_the_other(first, then);
}
}
/// The back edge rounded first, then the arc's top edge: the arc's round leaves 0.05 mm³ more at
/// the knife-edge tip than both together, or the other order.
#[test]
#[ignore = "not yet: the arc's round after the back edge's leaves 0.05 mm³ more at the tip"]
fn the_arc_rounded_after_the_back_edge_matches_both_together() {
one_then_the_other(BACK, arc_top());
}
/// The edge at `first` rounded (2), then the one at `then` as a fillet of its own, against both
/// rounded together.
fn one_then_the_other(first: Vec3, then: Vec3) {
let body = arc_part();
let step = crate::fillet(&body, &[first], 2.0).unwrap();
let after = crate::fillet(&step, &[then], 2.0).unwrap();
let v = volume(&after);
let together = volume(&crate::fillet(&body, &[first, then], 2.0).unwrap());
// Within the display meshes' accuracy (two different B-reps, meshed apart).
assert!((v - together).abs() < 5e-3, "{first:?} then {then:?}: {v} vs together {together}");
}
/// Edges rounded together come out the same in any order: a round made first doesn't carry the
/// next one on along its arc into edges nobody selected.
#[test]
fn rounds_together_in_any_order() {
let body = arc_part();
for (a, b) in [(UPRIGHT, LONG), (TIP, arc_top()), (TIP, BACK)] {
let v = |x: &[Vec3]| volume(&crate::fillet(&body, x, 2.0).unwrap());
let (ab, ba) = (v(&[a, b]), v(&[b, a]));
assert!((ab - ba).abs() < 1e-3, "{a:?} + {b:?}: {ab} vs {ba}");
}
}
/// The blends this part needs the corner build for (and the back edge, rolled into the tip) at
/// 0.01 and 100 times the part's size, the radius scaled with it: they take off what they take at
/// full size, scaled by the cube, so the build's tolerances and limits follow the model's size.
#[test]
fn corner_blends_hold_at_any_scale() {
let cases: [(&str, Vec<Vec<Vec3>>); 6] = [
("tip and arc", vec![vec![TIP, arc_top()]]),
("arc and tip", vec![vec![arc_top(), TIP]]),
("back into the tip", vec![vec![BACK]]),
("arc, then tip", vec![vec![arc_top()], vec![TIP]]),
("back, then tip", vec![vec![BACK], vec![TIP]]),
("arc, then back", vec![vec![arc_top()], vec![BACK]]),
];
// What a blend takes off the part scaled by `k`, over k³, through a display plan of matching
// accuracy.
let removed = |k: f64, steps: &[Vec<Vec3>]| {
let volume = |b: &Body| b.display_plan(2e-4 * k).unwrap().evaluate().measure().volume;
let body = scaled_arc_part(k);
let mut b = body.clone();
for pts in steps {
let pts: Vec<Vec3> = pts.iter().map(|p| *p * k).collect();
b = crate::fillet(&b, &pts, 2.0 * k).unwrap_or_else(|e| panic!("at {k}x: {steps:?}: {e}"));
assert!(b.validity().is_empty(), "at {k}x: {steps:?}: {:?}", b.validity());
}
(volume(&body) - volume(&b)) / (k * k * k)
};
for (name, steps) in &cases {
let full = removed(1.0, steps);
for k in [0.01, 100.0] {
let got = removed(k, steps);
assert!((got - full).abs() < full * 1e-4, "{name} at {k}x: removed {got} (scaled back) vs {full}");
}
}
}
+1 -1
View File
@@ -163,7 +163,7 @@ fn arc_top_removed(size: f64, round: bool) -> f64 {
/// Volume through the display planner (the mesh the app draws): closed and exact where the
/// legacy mesher behind `measure` can leak at a zero-angle corner like the one at the tip.
fn planned_volume(b: &Body) -> f64 {
pub(crate) fn planned_volume(b: &Body) -> f64 {
let mesh = b.display_plan(2e-4).unwrap().evaluate();
let key = |p: Vec3| [p.x.to_bits(), p.y.to_bits(), p.z.to_bits()];
let mut uses: std::collections::HashMap<([u64; 3], [u64; 3]), i32> = std::collections::HashMap::new();
+21
View File
@@ -271,6 +271,27 @@ fn build(
side = sd;
walls.push(wf);
}
// A run ends in the cross-section through its end vertex: the face there must hold it (a
// plane square to the run). One at an angle cuts the blend's two contacts at different
// places, which the rolling-ball blend handles.
if open {
let ends = [
(edges.first().map(|e| e.front().clone()), segs.first().map(|s| (s.start(), *s))),
(edges.last().map(|e| e.back().clone()), segs.last().map(|s| (s.end(), *s))),
];
for (v, seg) in ends {
let (Some(v), Some((p, s))) = (v, seg) else { return Err(KernelError::Failed("run end".into())) };
let t = s.tangent(p).ok_or_else(|| unsupported("degenerate loop edge"))?;
let square = faces
.iter()
.enumerate()
.filter(|(gi, g)| *gi != fi && !walls.contains(gi) && g.vertex_iter().any(|x| x == v))
.all(|(_, g)| plane_normal(g).is_some_and(|gn| gn.dot(t).abs() > 1.0 - 1e-6));
if !square {
return Err(unsupported("a run of edges ending at a face at an angle"));
}
}
}
// Cross-section at a loop point: into the face (m) and up the wall (t, away from the edge,
// up a concave wall and down a convex one). The ball of radius r touches both at the
// setback s = r / tan(φ/2), φ the angle between m and t; its centre is on the bisector.
+27 -27
View File
@@ -8454,20 +8454,20 @@
"Wave/F2:side:a1": "curved.axis curved.tangent_plane",
"Wave/F2:side:a1|F2:side:a2": "edge.axis edge.normal_plane edge.project",
"Wave/F2:side:a1|F2:side:l3": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.normal_plane+edit edge.project",
"Wave/F2:side:a1|F2:start": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a1|F2:top": "!edge.chamfer !edge.fillet edge.normal_plane edge.project edge.project+edit",
"Wave/F2:side:a1|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a1|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit",
"Wave/F2:side:a2": "curved.axis curved.tangent_plane",
"Wave/F2:side:a2|F2:side:a3": "edge.axis edge.normal_plane edge.normal_plane+edit edge.project",
"Wave/F2:side:a2|F2:start": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a2|F2:top": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a2|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a2|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a3": "curved.axis curved.tangent_plane",
"Wave/F2:side:a3|F2:side:a4": "edge.axis edge.normal_plane edge.normal_plane+edit edge.project",
"Wave/F2:side:a3|F2:start": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a3|F2:top": "!edge.chamfer !edge.fillet edge.normal_plane edge.project edge.project+edit",
"Wave/F2:side:a3|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a3|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit",
"Wave/F2:side:a4": "curved.axis curved.tangent_plane",
"Wave/F2:side:a4|F2:side:l1": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project edge.project+edit",
"Wave/F2:side:a4|F2:start": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a4|F2:top": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a4|F2:start": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:a4|F2:top": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Wave/F2:side:l1": "face.angle_plane !face.draft_neutral face.extrude face.extrude+edit face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch",
"Wave/F2:side:l1|F2:side:l2": "edge.axis edge.chamfer edge.fillet edge.fillet+edit edge.normal_plane edge.project",
"Wave/F2:side:l1|F2:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
@@ -9166,7 +9166,7 @@
},
"oracle/73-sm-hem": {
"Body1/F2:face:0": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
"Body1/F2:face:0|F2:face:3#[F2:face:0,F2:face:1,F2:face:4]": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F2:face:0|F2:face:3#[F2:face:0,F2:face:1,F2:face:4]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F2:face:0|F2:face:4": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F2:face:0|F2:face:5#[F2:face:0,F2:face:1,F2:face:4]": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F2:face:0|F3:face:10": "edge.axis edge.normal_plane edge.project",
@@ -9179,7 +9179,7 @@
"Body1/F2:face:3#[F2:face:0,F2:face:1,F2:face:4]|F2:face:3#[F3:face:10,F3:face:11,F3:face:9]": "edge.axis edge.normal_plane edge.project",
"Body1/F2:face:3#[F2:face:0,F2:face:1,F2:face:4]|F2:face:4": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]": "face.angle_plane face.extrude !face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
"Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]|F3:face:10": "edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]|F3:face:10": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]|F3:face:11": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F2:face:3#[F3:face:10,F3:face:11,F3:face:9]|F3:face:9": "edge.axis edge.normal_plane edge.project",
"Body1/F2:face:4": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
@@ -9466,7 +9466,7 @@
"Body1/F4:face:10|F4:face:14": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F4:face:10|F4:face:19": "edge.axis edge.normal_plane edge.project",
"Body1/F4:face:11": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch",
"Body1/F4:face:11|F4:face:12": "edge.axis edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F4:face:11|F4:face:12": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F4:face:11|F4:face:13": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F4:face:11|F4:face:14": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F4:face:11|F4:face:20": "edge.axis edge.normal_plane edge.project",
@@ -11277,38 +11277,38 @@
"Body1/F3:end|F3:side:l4#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#1|F3:side:l1#2": "edge.axis edge.normal_plane edge.project",
"Body1/F3:side:l1#1|F3:side:l1#[F3:start]": "edge.axis edge.normal_plane edge.project",
"Body1/F3:side:l1#1|F3:side:l2#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#1|F3:side:l4#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#1|F3:side:l2#1": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#1|F3:side:l4#1": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#2|F3:side:l1#[F3:end]": "edge.axis edge.normal_plane edge.project",
"Body1/F3:side:l1#2|F3:side:l2#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#2|F3:side:l4#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#2|F3:side:l2#2": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#2|F3:side:l4#2": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#[F3:end]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch",
"Body1/F3:side:l1#[F3:end]|F3:side:l2#[F3:end]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#[F3:end]|F3:side:l4#[F3:end]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#[F3:end]|F3:side:l2#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#[F3:end]|F3:side:l4#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#[F3:start]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch",
"Body1/F3:side:l1#[F3:start]|F3:side:l2#[F3:start]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#[F3:start]|F3:side:l4#[F3:start]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#[F3:start]|F3:side:l2#[F3:start]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#[F3:start]|F3:side:l4#[F3:start]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l1#[F3:start]|F3:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l2#1": "curved.axis curved.tangent_plane",
"Body1/F3:side:l2#1|F3:side:l2#[F3:start]": "edge.axis edge.normal_plane edge.project",
"Body1/F3:side:l2#1|F3:side:l3#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l2#1|F3:side:l3#1": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l2#2": "curved.axis curved.tangent_plane",
"Body1/F3:side:l2#2|F3:side:l2#[F3:end]": "edge.axis edge.normal_plane edge.project",
"Body1/F3:side:l2#2|F3:side:l3#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l2#2|F3:side:l3#2": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l2#[F3:end]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch",
"Body1/F3:side:l2#[F3:end]|F3:side:l3#[F3:end]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l2#[F3:end]|F3:side:l3#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l2#[F3:start]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch",
"Body1/F3:side:l2#[F3:start]|F3:side:l3#[F3:start]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l2#[F3:start]|F3:side:l3#[F3:start]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l2#[F3:start]|F3:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l3#1|F3:side:l3#2": "edge.axis edge.normal_plane edge.project",
"Body1/F3:side:l3#1|F3:side:l3#[F3:start]": "edge.axis edge.normal_plane edge.project",
"Body1/F3:side:l3#1|F3:side:l4#1": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l3#1|F3:side:l4#1": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l3#2|F3:side:l3#[F3:end]": "edge.axis edge.normal_plane edge.project",
"Body1/F3:side:l3#2|F3:side:l4#2": "!edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l3#2|F3:side:l4#2": "edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l3#[F3:end]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull !face.shell face.sketch",
"Body1/F3:side:l3#[F3:end]|F3:side:l4#[F3:end]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l3#[F3:end]|F3:side:l4#[F3:end]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l3#[F3:start]": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull !face.shell face.sketch",
"Body1/F3:side:l3#[F3:start]|F3:side:l4#[F3:start]": "edge.axis !edge.chamfer !edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l3#[F3:start]|F3:side:l4#[F3:start]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l3#[F3:start]|F3:start": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
"Body1/F3:side:l4#1": "curved.axis curved.tangent_plane",
"Body1/F3:side:l4#1|F3:side:l4#[F3:start]": "edge.axis edge.normal_plane edge.project",
+2 -1
View File
@@ -34,7 +34,7 @@
"30": {"area":853.525389,"bodies":1,"edges":23,"error":null,"faces":10,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","chamfer"],"unsupported":0,"volume":1337.14821},
"31": {"area":10195.46137,"bodies":3,"edges":69,"error":null,"faces":30,"outcome":"ok","rejected":1,"shells":3,"steps":["box","cut box","fillet","join box","circular pattern","cut box","circular pattern","hole"],"unsupported":1,"volume":56416.127527},
"32": {"area":3220.802769,"bodies":1,"edges":51,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","cut box","cut box","join box"],"unsupported":0,"volume":7565.955897},
"33": {"area":5285.817318,"bodies":2,"edges":59,"error":null,"faces":27,"outcome":"ok","rejected":0,"shells":3,"steps":["revolved section","fillet","join box","cut cylinder","chamfer","join box","chamfer","fillet"],"unsupported":2,"volume":13859.24945},
"33": {"area":5286.736353,"bodies":2,"edges":59,"error":null,"faces":27,"outcome":"ok","rejected":0,"shells":3,"steps":["revolved section","fillet","join box","cut cylinder","chamfer","join box","chamfer","fillet"],"unsupported":2,"volume":13858.467983},
"34": {"area":1086.303472,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.365275},
"35": {"area":4605.630216,"bodies":1,"edges":26,"error":null,"faces":11,"outcome":"ok","rejected":0,"shells":1,"steps":["box","fillet","join box"],"unsupported":0,"volume":13965.526982},
"36": {"area":10408.825748,"bodies":1,"edges":60,"error":null,"faces":26,"outcome":"ok","rejected":0,"shells":2,"steps":["revolved section","cut cylinder","join box","fillet","join cylinder"],"unsupported":0,"volume":55729.313103},
@@ -112,6 +112,7 @@
{"reason":"the last-resort tool nudge is tried the other way when it fails or leaves sliver faces the operands didn't have: seed 43's join builds and the design runs to the end, 31 and 78 come out with fewer faces (#827, #897)","seeds":[31,43,78]},
{"reason":"main's fillet changes since the last record (#926, #949) move seeds 33 and 91: a fillet step builds differently or is picked differently","seeds":[33,91]},
{"reason":"hole tangent inside a stepped boss: touching sews split zero-thickness curved walls, so a pattern of holes spaced by their diameter (touching) in a stepped body cuts exactly instead of by a tool nudge: 42 builds more faces (the walls split where the holes touch), 51 picks other edges afterwards","seeds":[42,51]},
{"reason":"corner blends on curved faces (#854): seed 33's last fillet (its second body) is built differently; its display-plan and measured volumes now agree (4442.73 mm³; on main they read 4439.42 and 4443.51)","seeds":[33]},
{"reason":"#952: a hole placed on a face drills into the material; seed 81's hole moves its volume by 0.1 mm\u00b3 (area by 0.04 mm\u00b2)","seeds":[81]}
]
}