* Rounded Rectangle sketch tool (#891) A new SKETCH > CREATE command, sketch.rectangle.rounded, in the Rectangle family: two opposite corners and a corner radius make a 2-point rectangle whose four corners are filleted alike, as one undo step. The radius is one parameter (one radius dimension, the other arcs equal to it), so changing it changes all four corners. While drawing, Width, Height and Radius boxes take typed values; typed width and height dimension the overall size between opposite sides. Without a typed radius the corners get a fifth of the shorter side. Fixes #891 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Rounded Rectangle in the Simplified Chinese catalog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
19bf854dc9
commit
97ff7578c2
15 files changed
+330
-9
No files matched your search
@@ -52,6 +52,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("rect3")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1: first edge; p2: point on the opposite edge", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.rounded", "Rounded Rectangle", rect_rounded)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect_rounded")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("p0, p1: opposite corners [x,y]; radius: expr (the corner radius, less than half the shorter side); construction?. A 2-point rectangle whose four corners are filleted alike: one radius dimension (`param`), the other arcs equal to it. Returns sides {bottom, right, top, left} (line ids) and arcs (the corner arcs: bottom-left, bottom-right, top-right, top-left)", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.rectangle.center", "Center Rectangle", rect_center)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("rect_center")
|
||||
@@ -930,6 +935,60 @@ fn rect_two_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(rect_result(out, names, info))
|
||||
}
|
||||
|
||||
/// A 2-point rectangle with its corners filleted, as one undo step: the rectangle and the
|
||||
/// fillet run in a scratch session that replaces the design only when both succeed.
|
||||
fn rect_rounded(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.rectangle.rounded";
|
||||
let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?);
|
||||
let r_expr = expr(p, "radius").ok_or_else(|| bad(cmd, "`radius` must be a number or an expression"))?;
|
||||
let r = s.doc.eval(&r_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 half = 0.5 * (a.x - b.x).abs().min((a.y - b.y).abs());
|
||||
if half > 1e-9 && r >= half - 1e-9 {
|
||||
return Err(bad(cmd, format!("the radius must be less than half the shorter side ({half:.3} mm)")));
|
||||
}
|
||||
let mut rect = p.clone();
|
||||
if let Some(o) = rect.as_object_mut() {
|
||||
o.remove("radius");
|
||||
}
|
||||
let mut scratch = s.scratch();
|
||||
let mut out = scratch.execute("sketch.rectangle.two_point", &rect)?;
|
||||
let corners: Vec<Value> = ["bottom_left", "bottom_right", "top_right", "top_left"]
|
||||
.iter()
|
||||
.filter_map(|k| out["corners"].get(*k).filter(|v| v.is_string()).cloned())
|
||||
.collect();
|
||||
if corners.len() != 4 {
|
||||
return Err(bad(cmd, "the rectangle's corners are missing"));
|
||||
}
|
||||
let mut fillet = json!({"points": corners, "radius": r_expr});
|
||||
if let Some(sketch) = p.get("sketch") {
|
||||
fillet["sketch"] = sketch.clone();
|
||||
}
|
||||
let rounded = scratch.execute("sketch.fillet", &fillet)?;
|
||||
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
|
||||
scratch.undo.clear();
|
||||
s.accept_computed(scratch);
|
||||
let arcs = rounded.get("curves").cloned().unwrap_or_else(|| json!([]));
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
// The sharp corners are gone (the lines end at the arcs now).
|
||||
o.remove("corners");
|
||||
if let (Some(Value::Array(curves)), Value::Array(extra)) = (o.get_mut("curves"), &arcs) {
|
||||
curves.extend(extra.iter().cloned());
|
||||
}
|
||||
if let (Some(Value::Array(cons)), Some(Value::Array(extra))) = (o.get_mut("constraints"), rounded.get("constraints")) {
|
||||
cons.extend(extra.iter().cloned());
|
||||
}
|
||||
o.insert("arcs".into(), arcs);
|
||||
o.insert("param".into(), rounded.get("param").cloned().unwrap_or(Value::Null));
|
||||
if let Some(info) = rounded.get("sketch") {
|
||||
o.insert("sketch".into(), info.clone());
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn rect_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.rectangle.center";
|
||||
let (c, k) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "corner")?);
|
||||
@@ -2325,6 +2384,10 @@ fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[path = "sketch_rect_tests.rs"]
|
||||
mod rect_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_rounded_rect_tests.rs"]
|
||||
mod rounded_rect_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_ux_tests.rs"]
|
||||
mod ux_tests;
|
||||
|
||||
@@ -12,7 +12,9 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let create = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name a supported sketch creator"))?;
|
||||
let roles: &[&str] = match create {
|
||||
"sketch.line" => &["length", "angle"],
|
||||
"sketch.rectangle.two_point" | "sketch.rectangle.center" | "sketch.rectangle.three_point" => &["width", "height"],
|
||||
"sketch.rectangle.two_point" | "sketch.rectangle.center" | "sketch.rectangle.three_point" | "sketch.rectangle.rounded" => {
|
||||
&["width", "height"]
|
||||
}
|
||||
"sketch.circle.center" | "sketch.circle.two_point" | "sketch.circle.three_point" => &["radius", "diameter"],
|
||||
"sketch.arc.center_point" => &["radius", "sweep"],
|
||||
"sketch.slot.center_to_center" | "sketch.slot.overall" => &["length", "width"],
|
||||
@@ -82,6 +84,13 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (entities, ty) = match (create, role.as_str()) {
|
||||
("sketch.line", "length") => (vec![curve(0)?], "length"),
|
||||
("sketch.line", "angle") => (vec![curve(0)?], "angle"),
|
||||
// A rounded rectangle's sides stop at the arcs: its size is between opposite sides.
|
||||
("sketch.rectangle.rounded", "width" | "height") => {
|
||||
let [a, b] = if role == "width" { ["left", "right"] } else { ["bottom", "top"] };
|
||||
let side =
|
||||
|k: &str| out["sides"].get(k).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return its sides"));
|
||||
(vec![side(a)?, side(b)?], "distance")
|
||||
}
|
||||
(c, "width" | "height") if c.starts_with("sketch.rectangle.") => (vec![curve(usize::from(role == "height"))?], "length"),
|
||||
(c, "radius" | "diameter") if c.starts_with("sketch.circle.") => (vec![curve(0)?], role.as_str()),
|
||||
("sketch.arc.center_point", "radius" | "sweep") => (vec![curve(0)?], role.as_str()),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Rounded Rectangle (#891): a 2-point rectangle whose corners are filleted alike, in one step.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
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 ids(v: &Value) -> Vec<String> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn sketch() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The evaluated (solved) sketch.
|
||||
fn solved(s: &Session) -> solidcraft_sketch::Sketch {
|
||||
let id = s.active_sketch.expect("an open sketch");
|
||||
s.model.state().sketch(id).map(|ss| ss.sketch.clone()).expect("an evaluated sketch")
|
||||
}
|
||||
|
||||
/// The corner arcs' radii, from the evaluated sketch.
|
||||
fn radii(s: &Session, arcs: &[String]) -> Vec<f64> {
|
||||
let sk = solved(s);
|
||||
arcs.iter()
|
||||
.map(|id| {
|
||||
let i = sk.curves.iter().position(|c| &c.id == id).expect("arc in sketch");
|
||||
match &sk.curves[i].kind {
|
||||
solidcraft_sketch::CurveKind::Arc { c, a, .. } => sk.point(*c).unwrap().dist(sk.point(*a).unwrap()),
|
||||
k => panic!("{id} is not an arc: {k:?}"),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_rectangle_is_four_lines_and_four_equal_arcs_in_one_closed_profile() {
|
||||
let mut s = sketch();
|
||||
let r = run(&mut s, "sketch.rectangle.rounded", json!({"p0": [2, 3], "p1": [42, 23], "radius": 5}));
|
||||
let (curves, arcs) = (ids(&r["curves"]), ids(&r["arcs"]));
|
||||
assert_eq!(curves.len(), 8, "{r}");
|
||||
assert_eq!(arcs.len(), 4, "{r}");
|
||||
assert!(r["sides"]["bottom"].is_string() && r["sides"]["left"].is_string(), "{r}");
|
||||
assert!(r.get("corners").is_none(), "the sharp corners are gone: {r}");
|
||||
assert!(r["param"].is_string(), "one radius parameter: {r}");
|
||||
for radius in radii(&s, &arcs) {
|
||||
assert!((radius - 5.0).abs() < 1e-6, "{radius}");
|
||||
}
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
let profiles = si["profiles"].as_array().expect("profiles");
|
||||
assert_eq!(profiles.len(), 1, "{si}");
|
||||
let area = profiles[0]["area_mm2"].as_f64().unwrap();
|
||||
let want = 40.0 * 20.0 - (4.0 - std::f64::consts::PI) * 25.0;
|
||||
assert!((area - want).abs() < 1e-6, "area {area}, want {want}");
|
||||
assert_ne!(si["status"], "conflict", "{si}");
|
||||
assert!(si["status"].as_str().is_none_or(|t| !t.contains("over")), "{si}");
|
||||
// Position and size remain free; the radius is dimensioned and the arcs are equal.
|
||||
assert_eq!(si["dof"], 4, "{si}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_rectangle_is_one_undo_step() {
|
||||
let mut s = sketch();
|
||||
run(&mut s, "sketch.rectangle.rounded", json!({"p0": [0, 0], "p1": [30, 30], "radius": 4}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let sk = solved(&s);
|
||||
assert!(sk.curves.is_empty(), "undo removes the whole shape: {:?}", sk.curves.iter().map(|c| &c.id).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_radius_parameter_drives_all_four_corners() {
|
||||
let mut s = sketch();
|
||||
let r = run(&mut s, "sketch.rectangle.rounded", json!({"p0": [-10, -5], "p1": [30, 25], "radius": "3 mm"}));
|
||||
let arcs = ids(&r["arcs"]);
|
||||
let param = r["param"].as_str().unwrap().to_string();
|
||||
run(&mut s, "parameters.change", json!({"name": param, "expression": "6 mm"}));
|
||||
for radius in radii(&s, &arcs) {
|
||||
assert!((radius - 6.0).abs() < 1e-6, "{radius}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radius_too_large_or_not_positive_is_refused_and_draws_nothing() {
|
||||
for radius in [json!(10), json!(12), json!(0), json!(-2), json!("nope")] {
|
||||
let mut s = sketch();
|
||||
let err = s.execute("sketch.rectangle.rounded", &json!({"p0": [0, 0], "p1": [40, 20], "radius": radius}));
|
||||
assert!(err.is_err(), "radius {radius} accepted");
|
||||
let sk = solved(&s);
|
||||
assert!(sk.curves.is_empty(), "radius {radius} left curves behind");
|
||||
}
|
||||
let mut s = sketch();
|
||||
assert!(s.execute("sketch.rectangle.rounded", &json!({"p0": [0, 0], "p1": [40, 20]})).is_err(), "radius is required");
|
||||
}
|
||||
|
||||
/// Typed width and height (the drawing tool's boxes) dimension the overall size, between
|
||||
/// opposite sides, not the straight part of a side.
|
||||
#[test]
|
||||
fn typed_width_and_height_measure_the_whole_rounded_rectangle() {
|
||||
let mut s = sketch();
|
||||
let r = run(
|
||||
&mut s,
|
||||
"sketch.draw.dimensioned",
|
||||
json!({"command": "sketch.rectangle.rounded", "params": {"p0": [1, 1], "p1": [31, 21], "radius": 4}, "dimensions": {"width": "50 mm", "height": 25}}),
|
||||
);
|
||||
assert!(r["dimensions"]["width"].is_object() && r["dimensions"]["height"].is_object(), "{r}");
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
let area = si["profiles"][0]["area_mm2"].as_f64().unwrap();
|
||||
let want = 50.0 * 25.0 - (4.0 - std::f64::consts::PI) * 16.0;
|
||||
assert!((area - want).abs() < 1e-6, "area {area}, want {want}: {si}");
|
||||
assert_eq!(si["dof"], 2, "only the position is free: {si}");
|
||||
}
|
||||
Reference in new issue
Block a user