diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e72c3a8c..e93f5eab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -209,6 +209,42 @@ fn fillet_the_edges_between_two_overlapping_cylinders() { assert!(rel(v, expect) < 2e-4, "{v} vs {expect}"); } +/// The top outline of two overlapping cylinders whose concave edges were rounded: the walls of +/// the rounds meet the cylinders along the rolling-ball contact lines, straight but not lines. +#[test] +fn fillet_and_chamfer_the_top_loop_of_rounded_overlapping_cylinders() { + use std::f64::consts::{PI, TAU}; + let (big, round, h) = (25.0_f64, 5.0_f64, 10.0); + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": big})); + run(&mut s, "sketch.circle.center", json!({"center": [-big, 0], "radius": big})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": h, "profiles": [0, 1, 2]})); + // Plan view: ball centres at x = −12.5, |y| = yb; each round's arc turns by th, each big arc by + // TAU − 2·a1, and the loop turns by TAU in all. + let half = big / 2.0; + let yb = ((big + round).powi(2) - half * half).sqrt(); + run(&mut s, "solid.fillet", json!({"edges": [[-half, half * 3f64.sqrt(), 5], [-half, -half * 3f64.sqrt(), 5]], "radius": round})); + let (a1, th) = (yb.atan2(half), 2.0 * (half / (big + round)).asin()); + let base = volume(&mut s); + let top = [[big, 0.0, h], [-2.0 * big, 0.0, h], [-half, yb - round, h], [-half, round - yb, h]]; + // Removed volume by Pappus: the corner's section swept along the loop at its centroid's offset. + let removed = |area: f64, xb: f64| area * (2.0 * (TAU - 2.0 * a1) * (big - xb) + 2.0 * th * (round + xb)); + run(&mut s, "solid.fillet", json!({"edges": top, "radius": 2})); + let r: f64 = 2.0; + let expect = base - removed(r * r * (1.0 - PI / 4.0), (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)) * r); + let v = volume(&mut s); + assert!((v - expect).abs() < 1.5, "{v} vs {expect}"); + let m = run(&mut s, "inspect.measure", json!({})); + assert_eq!(m["bodies"][0]["kernel"]["faces"].as_u64(), Some(10), "{m}"); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "solid.chamfer", json!({"edges": top, "distance": 2})); + let expect = base - removed(2.0, 2.0 / 3.0); + let v = volume(&mut s); + assert!((v - expect).abs() < 1.5, "{v} vs {expect}"); +} + /// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole. #[test] fn box_fillet_cut_part() { diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index 31c6ce00..e3653a47 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -97,6 +97,22 @@ fn seg_of(e: &mt::Edge, tol: f64) -> Option { Some(Seg::Arc { a, c: centre, axis, angle, mid: m }) } +/// Whether an edge is a straight segment: a line, or a curve whose points all lie on the chord +/// (the contact lines of a rolling-ball blend between parallel walls are such curves). +fn is_straight(e: &mt::Edge, tol: f64) -> bool { + use mt::{BoundedCurve, ParametricCurve}; + if matches!(e.curve(), mt::Curve::Line(_)) { + return true; + } + let (a, b) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point())); + if a.dist(b) < tol { + return false; + } + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + (0..=16).all(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0)).dist_to_segment(a, b) < tol) +} + /// `p` turned about the line (c, axis) by `ang`. fn turn(p: Vec3, c: Vec3, axis: Vec3, ang: f64) -> Vec3 { let k = axis.normalized().unwrap_or(Vec3::Z); @@ -518,7 +534,7 @@ fn build( } let (fa, fb) = (e.absolute_front().clone(), e.absolute_back().clone()); let other = if &fa == v { &fb } else { &fa }; - if !matches!(e.curve(), mt::Curve::Line(_)) { + if !is_straight(&e, (size * 1e-5).max(1e-6)) { return Err(unsupported("the wall seams must be straight")); } let face_side = on_face(&e); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8a72aba9..a0b2557d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -69,9 +69,9 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? }; (at + n * (from + offset), Some(n)) } - Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), - // Shell thickness grows into the body. - Kind::Shell { .. } => (at, normal.map(|n| -n)), + Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal), + // Fillet radii, chamfer distances and shell thickness grow into the body. + Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)), Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), Kind::Plastic(_) => (at, normal), Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)), @@ -98,8 +98,8 @@ fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_ Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p)) } -/// The outward direction between the two faces at an edge (where a fillet's radius arrow -/// points). +/// The outward direction between the two faces at an edge (a fillet's radius arrow points the +/// other way, into the body). fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { let faces = m.edge_faces.get(index)?; let mut sum = Vec3::ZERO; diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index b0343591..4553fa61 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -470,6 +470,19 @@ fn a_hole_is_mirrored_as_a_feature() { assert_eq!(cmds[0].1["features"], json!(["Hole1"])); } +/// The radius arrow points into the body: dragging toward the material grows the round. +#[test] +fn fillet_and_chamfer_arrows_point_into_the_body() { + for id in ["solid.fillet", "solid.chamfer"] { + let mut app = boxed(); + let e = edge(&app, Vec3::new(20.0, 0.0, 20.0)); + let d = start(&mut app, vec![e], id); + let (_, dir) = crate::canvas::anchor(&app, &d).unwrap(); + let dir = dir.unwrap(); + assert!(dir.y > 0.5 && dir.z < -0.5, "{id}: {dir:?}"); + } +} + #[test] fn chamfer_types_send_their_values() { let mut app = boxed();