Sketch Fillet: several corners at once (points: one radius dimension, the other arcs equal to it); a radius too large for a line corner is a clear error naming the length it needs, and nothing changes; tests for a constrained rectangle's corners (areas exact), line-arc and arc-arc corners, too-large fillets and chamfers, chamfer areas
Fixes #46 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
This commit is contained in:
1 parent
307ef11c0a
commit
61f86d073d
2 files changed
+154
-33
No files matched your search
@@ -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<String> {
|
||||
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<String> = {
|
||||
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));
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> = 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<String>)> {
|
||||
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;
|
||||
Reference in new issue
Block a user