* 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:
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);
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user