diff --git a/crates/engine/src/cmd/sketch_corner_tests.rs b/crates/engine/src/cmd/sketch_corner_tests.rs new file mode 100644 index 00000000..344e8fea --- /dev/null +++ b/crates/engine/src/cmd/sketch_corner_tests.rs @@ -0,0 +1,98 @@ +//! Sketch Fillet and Chamfer at corners (#46): line–line, line–arc and arc–arc corners, the +//! corners of a constrained rectangle, several corners at once, and radii too large for the +//! corner (a clear error that leaves the sketch as it was). + +use serde_json::{Value, json}; + +use crate::Session; + +fn new_sketch() -> Session { + let mut s = Session::default(); + s.execute("sketch.create", &json!({"plane": "XY"})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn curves(r: &Value) -> Vec { + r["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() +} + +fn sk(s: &Session) -> solvecraft_sketch::Sketch { + s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone() +} + +fn solved(s: &Session) -> bool { + s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.ok() +} + +fn area(s: &Session) -> f64 { + s.model.state().sketch(s.active_sketch.unwrap()).unwrap().profiles.iter().map(|p| p.area).sum() +} + +#[test] +fn fillet_and_chamfer_corners() { + use std::f64::consts::PI; + // A constrained 40 × 20 rectangle: a fillet r5 on one corner keeps it solved and closed, + // with the area of the corner taken off. + let mut s = new_sketch(); + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]})); + let c = curves(&r); + let a0 = area(&s); + run(&mut s, "sketch.fillet", json!({"a": c[0], "b": c[1], "radius": 5})); + assert!(solved(&s)); + assert!((area(&s) - (a0 - (25.0 - PI * 25.0 / 4.0))).abs() < 1e-6, "{}", area(&s)); + // Several corners at once: the rest of the rectangle's corners. + let pts: Vec = { + let k = sk(&s); + let li = |id: &str| k.curve_index(id).unwrap(); + [(1, 2), (2, 3), (3, 0)] + .iter() + .map(|(x, y)| { + let (cx, cy) = (&k.curves[li(&c[*x])].kind, &k.curves[li(&c[*y])].kind); + let shared = cx.point_ids().into_iter().find(|p| cy.point_ids().contains(p)).unwrap(); + k.points[shared].id.clone() + }) + .collect() + }; + run(&mut s, "sketch.fillet", json!({"points": pts, "radius": 5})); + assert!(solved(&s)); + assert!((area(&s) - (a0 - 4.0 * (25.0 - PI * 25.0 / 4.0))).abs() < 1e-6, "{}", area(&s)); + // Too large for a corner: a clear error, nothing changed. + let mut s = new_sketch(); + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]})); + let c = curves(&r); + let n = sk(&s).curves.len(); + let e = s.execute("sketch.fillet", &json!({"a": c[0], "b": c[1], "radius": 30})).unwrap_err().to_string(); + assert!(e.contains("too large") || e.contains("does not fit"), "{e}"); + assert_eq!(sk(&s).curves.len(), n); + let e = s.execute("sketch.chamfer.equal_distance", &json!({"a": c[0], "b": c[1], "distance": 25})).unwrap_err().to_string(); + assert!(e.contains("too large") || e.contains("does not fit") || e.contains("longer"), "{e}"); + assert_eq!(sk(&s).curves.len(), n); + // A line meeting an arc, and two arcs (a slot's end rounded into a line). + let mut s = new_sketch(); + let l = curves(&run(&mut s, "sketch.line", json!({"points": [[-20, 10], [0, 10]]})))[0].clone(); + let a = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": format!("{l}.end"), "end": [10, 0], "through": [7.0710678, 7.0710678]}))) + [0] + .clone(); + run(&mut s, "sketch.fillet", json!({"a": l, "b": a, "radius": 2})); + assert!(solved(&s)); + let mut s = new_sketch(); + let a1 = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": [-10, 0], "end": [0, 0], "through": [-5, -5]})))[0].clone(); + let a2 = curves(&run(&mut s, "sketch.arc.three_point", json!({"start": format!("{a1}.end"), "end": [10, 0], "through": [5, 2]})))[0].clone(); + run(&mut s, "sketch.fillet", json!({"a": a1, "b": a2, "radius": 1})); + assert!(solved(&s)); + // Chamfers keep a closed profile. + let mut s = new_sketch(); + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]})); + let c = curves(&r); + let a0 = area(&s); + run(&mut s, "sketch.chamfer.equal_distance", json!({"a": c[0], "b": c[1], "distance": 4})); + assert!(solved(&s)); + assert!((area(&s) - (a0 - 8.0)).abs() < 1e-6, "{}", area(&s)); +} diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index d5d860ee..76c710e2 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -17,7 +17,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "MODIFY") .icon("sketch_fillet") .enabled(in_sketch) - .params("point: corner shared by two curves (\"l1.end\") | a, b: two lines or arcs meeting at a corner; radius: expr"), + .params("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal) | a, b: two lines or arcs meeting at a corner; radius: expr"), CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal) .at("SKETCH", "MODIFY") .icon("sketch_chamfer") @@ -480,48 +480,67 @@ fn cut_corner(sk: &mut Sketch, la: usize, lb: usize, c: usize, fa: usize, fb: us fn fillet(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.fillet"; let r_expr = expr(p, "radius").ok_or_else(|| bad(cmd, "`radius` must be a number or an expression"))?; + // Several corners at once (`points`), each like a single `point`. + let corners: Vec = match p.get("points").and_then(Value::as_array) { + Some(a) if !a.is_empty() && a.len() <= 500 => a.iter().map(|q| json!({ "point": q })).collect(), + Some(_) => return Err(bad(cmd, "`points` must list 1…500 corners")), + None => vec![p.clone()], + }; let (out, info) = edit(s, p, cmd, false, |sk, doc| { let r = doc.eval(&r_expr, solvecraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?; if !(r > 1e-9 && r < 1e8) { return Err(bad(cmd, "radius must be positive")); } - let (la, lb, c, fa, fb) = match corner(sk, p, cmd) { - Ok(x) => x, - // Not two lines: a corner with an arc. - Err(e) => { - let Some((arc, cons)) = round_corner(sk, p, r, cmd)? else { return Err(e) }; - let mut cons = cons; - let pname = doc.new_model_param(&r_expr, "mm"); - cons.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?); - return Ok((ids_of(sk, &[arc]), cons, pname)); - } - }; - let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default()); - let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; - let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; - let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs(); - if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 { - return Err(bad(cmd, "the lines are parallel")); - } - let d = r / half.tan(); - let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?; - let center = pc + bis * (r / half.sin()); - let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?; - let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default()); - let ccw = (qa - center).cross(qb - center) > 0.0; - let arc = if ccw { - sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)? - } else { - sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)? - }; - let mut cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?]; let pname = doc.new_model_param(&r_expr, "mm"); - cons.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?); - Ok((ids_of(sk, &[arc]), cons, pname)) + let (mut arcs, mut cons) = (Vec::new(), Vec::new()); + for q in &corners { + let (arc, mut c) = fillet_corner(sk, q, r, cmd)?; + // One radius for them all: the first carries the dimension, the rest are equal to it. + match arcs.first() { + None => c.push(sk.add_constraint(ConstraintKind::Radius { c: arc, value: r }, Some(pname.clone()))?), + Some(first) => c.push(add_c(sk, ConstraintKind::Equal { a: *first, b: arc })?), + } + cons.extend(c); + arcs.push(arc); + } + Ok((ids_of(sk, &arcs), cons, pname)) })?; Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "sketch": info})) } +/// Round one corner (`point` or `a`, `b`) with an arc of radius `r` tangent to both curves: +/// the arc and the tangent constraints. +fn fillet_corner(sk: &mut Sketch, p: &Value, r: f64, cmd: &str) -> Result<(usize, Vec)> { + let (la, lb, c, fa, fb) = match corner(sk, p, cmd) { + Ok(x) => x, + // Not two lines: a corner with an arc. + Err(e) => return round_corner(sk, p, r, cmd)?.ok_or(e), + }; + let (pc, pa, pb) = (sk.point(c).unwrap_or_default(), sk.point(fa).unwrap_or_default(), sk.point(fb).unwrap_or_default()); + let u = (pa - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; + let v = (pb - pc).normalized().ok_or_else(|| bad(cmd, "degenerate line"))?; + let half = 0.5 * u.cross(v).atan2(u.dot(v)).abs(); + if half < 1e-6 || half > std::f64::consts::FRAC_PI_2 - 1e-9 { + return Err(bad(cmd, "the lines are parallel")); + } + let d = r / half.tan(); + if d >= pa.dist(pc) - 1e-9 || d >= pb.dist(pc) - 1e-9 { + return Err(bad(cmd, format!("the radius is too large for that corner (it needs {d:.3} mm of each line)"))); + } + let bis = (u + v).normalized().ok_or_else(|| bad(cmd, "the lines are parallel"))?; + let center = pc + bis * (r / half.sin()); + let (ta, tb, _) = cut_corner(sk, la, lb, c, fa, fb, d, d, cmd)?; + let (qa, qb) = (sk.point(ta).unwrap_or_default(), sk.point(tb).unwrap_or_default()); + let ccw = (qa - center).cross(qb - center) > 0.0; + let arc = if ccw { + sk.add_arc(center, qa, qb, [None, Some(ta), Some(tb)], None)? + } else { + sk.add_arc(center, qb, qa, [None, Some(tb), Some(ta)], None)? + }; + let cons = vec![add_c(sk, ConstraintKind::Tangent { a: la, b: arc })?, add_c(sk, ConstraintKind::Tangent { a: lb, b: arc })?]; + Ok((arc, cons)) +} + /// Fillet a corner where a line meets an arc, or two arcs meet: the arc of radius `r` touching /// both, nearest the corner; both curves are cut back to it. None when the corner is not of /// that kind. @@ -1167,3 +1186,7 @@ mod tests; #[cfg(test)] #[path = "sketch_robust_tests.rs"] mod robust_tests; + +#[cfg(test)] +#[path = "sketch_corner_tests.rs"] +mod corner_tests;