* 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
@@ -355,6 +355,20 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
}
|
||||
}
|
||||
}
|
||||
"rect_rounded" => {
|
||||
// Straight sides between quarter arcs of radius 3 at the corners.
|
||||
pen.line(&[(7.0, 6.0), (17.0, 6.0)], a);
|
||||
pen.line(&[(20.0, 9.0), (20.0, 15.0)], a);
|
||||
pen.line(&[(17.0, 18.0), (7.0, 18.0)], a);
|
||||
pen.line(&[(4.0, 15.0), (4.0, 9.0)], a);
|
||||
let q = std::f32::consts::FRAC_PI_2;
|
||||
pen.arc(17.0, 9.0, 3.0, 0.0, q, a);
|
||||
pen.arc(7.0, 9.0, 3.0, q, 2.0 * q, a);
|
||||
pen.arc(7.0, 15.0, 3.0, 2.0 * q, 3.0 * q, a);
|
||||
pen.arc(17.0, 15.0, 3.0, 3.0 * q, 4.0 * q, a);
|
||||
pen.dot(4.0, 18.0, ink);
|
||||
pen.dot(20.0, 6.0, ink);
|
||||
}
|
||||
"circle" | "circle2" | "circle3" => {
|
||||
pen.circle(12.0, 12.0, 8.0, Color32::TRANSPARENT, a);
|
||||
match name {
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
"cmd.sketch_polygon_inscribed": "Inscribed Polygon",
|
||||
"cmd.sketch_project_to_surface": "Project To Surface",
|
||||
"cmd.sketch_rectangle_center": "Center Rectangle",
|
||||
"cmd.sketch_rectangle_rounded": "Rounded Rectangle",
|
||||
"cmd.sketch_rectangle_three_point": "3-Point Rectangle",
|
||||
"cmd.sketch_rectangle_two_point": "2-Point Rectangle",
|
||||
"cmd.sketch_redefine": "Redefine Sketch Plane",
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
"cmd.sketch_polygon_inscribed": "内接ポリゴン",
|
||||
"cmd.sketch_project_to_surface": "サーフェスに投影",
|
||||
"cmd.sketch_rectangle_center": "中心指定の長方形",
|
||||
"cmd.sketch_rectangle_rounded": "角丸長方形",
|
||||
"cmd.sketch_rectangle_three_point": "3点指定長方形",
|
||||
"cmd.sketch_rectangle_two_point": "2点指定長方形",
|
||||
"cmd.sketch_redefine": "スケッチ平面を再定義",
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
"cmd.sketch_polygon_inscribed": "内接多边形",
|
||||
"cmd.sketch_project_to_surface": "投影到曲面",
|
||||
"cmd.sketch_rectangle_center": "中心矩形",
|
||||
"cmd.sketch_rectangle_rounded": "圆角矩形",
|
||||
"cmd.sketch_rectangle_three_point": "三点矩形",
|
||||
"cmd.sketch_rectangle_two_point": "两点矩形",
|
||||
"cmd.sketch_redefine": "重定义草图平面",
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn boxes_for(cmd: &str, n: usize) -> Vec<DimBox> {
|
||||
match (cmd, n) {
|
||||
("sketch.line", n) if n >= 1 => vec![b("Length", Length), b("Angle", Angle)],
|
||||
("sketch.rectangle.two_point" | "sketch.rectangle.center", 1) => vec![b("Width", Length), b("Height", Length)],
|
||||
("sketch.rectangle.rounded", 1) => vec![b("Width", Length), b("Height", Length), b("Radius", Length)],
|
||||
("sketch.circle.center", 1) => vec![b("Diameter", Length)],
|
||||
("sketch.arc.center_point", 1) => vec![b("Radius", Length)],
|
||||
("sketch.arc.center_point", 2) => vec![b("Sweep", Angle)],
|
||||
@@ -74,7 +75,7 @@ pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 {
|
||||
let ang = lk("Angle").unwrap_or_else(|| snap_axis(v.angle()));
|
||||
a + Vec2::from_angle(ang) * len
|
||||
}
|
||||
("sketch.rectangle.two_point", 1) => {
|
||||
("sketch.rectangle.two_point" | "sketch.rectangle.rounded", 1) => {
|
||||
let d = h - a;
|
||||
Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * sign(d.y)))
|
||||
}
|
||||
@@ -150,6 +151,16 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> {
|
||||
((e.y - a.y).abs(), Vec2::new(e.x + sx * 2.0 * off, (a.y + e.y) * 0.5)),
|
||||
]
|
||||
}
|
||||
("sketch.rectangle.rounded", 1) => {
|
||||
let (sx, sy) = (sign(e.x - a.x), sign(e.y - a.y));
|
||||
let r = rounded_radius(app, t, a, e);
|
||||
vec![
|
||||
((e.x - a.x).abs(), Vec2::new((a.x + e.x) * 0.5, a.y - sy * off)),
|
||||
((e.y - a.y).abs(), Vec2::new(e.x + sx * 2.0 * off, (a.y + e.y) * 0.5)),
|
||||
// Outside the first corner, away from the cursor (a box there would take the click).
|
||||
(r, Vec2::new(a.x - sx * 2.0 * off, a.y - sy * off)),
|
||||
]
|
||||
}
|
||||
("sketch.rectangle.center", 1) => {
|
||||
let d = e - a;
|
||||
let (sx, sy) = (sign(d.x), sign(d.y));
|
||||
@@ -230,7 +241,10 @@ pub fn dimensions(t: &Tool) -> Value {
|
||||
.dim_history
|
||||
.iter()
|
||||
.chain(&t.dims)
|
||||
.filter(|dimension| dimension.locked && !matches!(dimension.label, "Sides" | "Rho"))
|
||||
// A rounded rectangle's radius is its own parameter (sent with the shape), not a dimension.
|
||||
.filter(|dimension| {
|
||||
dimension.locked && !matches!(dimension.label, "Sides" | "Rho") && !(t.cmd == "sketch.rectangle.rounded" && dimension.label == "Radius")
|
||||
})
|
||||
.map(|dimension| (dimension.label.to_ascii_lowercase(), json!(dimension.value)))
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
Value::Object(values)
|
||||
@@ -267,6 +281,21 @@ pub fn validate(app: &SolveApp, t: &Tool) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The corner radius a rounded rectangle from `a` to `b` gets: the typed Radius, or a fifth of
|
||||
/// the shorter side.
|
||||
pub fn rounded_radius(app: &SolveApp, t: &Tool, a: Vec2, b: Vec2) -> f64 {
|
||||
locked(app, t, "Radius").unwrap_or_else(|| 0.2 * (b.x - a.x).abs().min((b.y - a.y).abs()))
|
||||
}
|
||||
|
||||
/// The radius to send with the rounded rectangle: the typed expression (it becomes the radius
|
||||
/// parameter as written, "d1 / 2" included), or the default as a number.
|
||||
pub fn rounded_radius_value(app: &SolveApp, t: &Tool, a: Vec2, b: Vec2) -> Value {
|
||||
match t.dims.iter().chain(&t.dim_history).find(|d| d.label == "Radius" && d.locked) {
|
||||
Some(d) => json!(d.value),
|
||||
None => json!(rounded_radius(app, t, a, b)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A locked box value by label (for the polygon side count).
|
||||
pub fn locked_value(app: &SolveApp, t: &Tool, label: &str) -> Option<f64> {
|
||||
locked(app, t, label)
|
||||
|
||||
@@ -207,3 +207,45 @@ fn a_rho_outside_zero_to_one_is_refused_before_it_reaches_the_command() {
|
||||
assert_eq!(tool.dims.iter().map(|d| d.value.as_str()).collect::<Vec<_>>(), ["5"], "the refused value stays in its box");
|
||||
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "the refusal is reported");
|
||||
}
|
||||
|
||||
/// Rounded Rectangle (#891): typed width, height and corner radius make one profile whose
|
||||
/// size is the typed overall size; the radius stays its own parameter.
|
||||
#[test]
|
||||
fn rounded_rectangle_takes_typed_width_height_and_radius() {
|
||||
let mut h = drawing("sketch.rectangle.rounded");
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().dims.iter().map(|d| d.label).collect::<Vec<_>>(), ["Width", "Height", "Radius"]);
|
||||
type_value(&mut h, "30");
|
||||
tab(&mut h, false);
|
||||
type_value(&mut h, "16");
|
||||
tab(&mut h, false);
|
||||
assert_focus(&h, 2);
|
||||
type_value(&mut h, "4");
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"key": "Enter"})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
let kinds: Vec<_> = sketch.sketch.curves.iter().map(|c| matches!(c.kind, solidcraft_engine::sketch::CurveKind::Arc { .. })).collect();
|
||||
assert_eq!(kinds.iter().filter(|arc| **arc).count(), 4, "four corner arcs");
|
||||
assert_eq!(kinds.len(), 8);
|
||||
assert_eq!(sketch.profiles.len(), 1);
|
||||
let want = 30.0 * 16.0 - (4.0 - std::f64::consts::PI) * 16.0;
|
||||
assert!((sketch.profiles[0].area - want).abs() < 1e-6, "area {} want {want}", sketch.profiles[0].area);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step");
|
||||
}
|
||||
|
||||
/// Without a typed radius the corners get a fifth of the shorter side.
|
||||
#[test]
|
||||
fn rounded_rectangle_clicks_use_a_default_radius() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
h.step(&json!({"start": "sketch.rectangle.rounded"})).unwrap();
|
||||
h.step(&json!({"click": {"sketch": [0, 0]}})).unwrap();
|
||||
h.step(&json!({"click": {"sketch": [40, 20]}})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert_eq!(sketch.profiles.len(), 1, "{:?}", h.app.status);
|
||||
let (w, hh) = (40.0, 20.0);
|
||||
let r: f64 = 0.2 * 20.0;
|
||||
let want = w * hh - (4.0 - std::f64::consts::PI) * r * r;
|
||||
assert!((sketch.profiles[0].area - want).abs() < 0.05 * want, "area {} want about {want}", sketch.profiles[0].area);
|
||||
}
|
||||
@@ -11,7 +11,10 @@ pub struct Family {
|
||||
|
||||
pub const FAMILIES: &[Family] = &[
|
||||
Family { label: "Line", commands: &["sketch.line", "sketch.line.midpoint"] },
|
||||
Family { label: "Rectangle", commands: &["sketch.rectangle.two_point", "sketch.rectangle.three_point", "sketch.rectangle.center"] },
|
||||
Family {
|
||||
label: "Rectangle",
|
||||
commands: &["sketch.rectangle.two_point", "sketch.rectangle.three_point", "sketch.rectangle.center", "sketch.rectangle.rounded"],
|
||||
},
|
||||
Family {
|
||||
label: "Circle",
|
||||
commands: &[
|
||||
|
||||
@@ -802,7 +802,7 @@ mod tests {
|
||||
h.app.start("sketch.rectangle.two_point");
|
||||
h.settle();
|
||||
let rect = crate::sketch_families::family("sketch.rectangle.two_point").unwrap();
|
||||
assert_eq!(rect.commands.len(), 3);
|
||||
assert_eq!(rect.commands.len(), 4);
|
||||
assert_eq!(pressed_modes(&h, rect), ["sketch.rectangle.two_point"]);
|
||||
assert!(crate::scenario::handle_at("sketch_palette:feature:sketch.circle.center").is_none(), "only the running family is shown");
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ impl Tool {
|
||||
"sketch.line" => ("sketch.line", Kind::Draw(0)),
|
||||
"sketch.rectangle.two_point" => ("sketch.rectangle.two_point", Kind::Draw(2)),
|
||||
"sketch.rectangle.center" => ("sketch.rectangle.center", Kind::Draw(2)),
|
||||
"sketch.rectangle.rounded" => ("sketch.rectangle.rounded", Kind::Draw(2)),
|
||||
"sketch.rectangle.three_point" => ("sketch.rectangle.three_point", Kind::Draw(3)),
|
||||
"sketch.circle.center" => ("sketch.circle.center", Kind::Draw(2)),
|
||||
"sketch.circle.two_point" => ("sketch.circle.two_point", Kind::Draw(2)),
|
||||
@@ -98,6 +99,7 @@ pub fn hint(id: &str) -> String {
|
||||
"sketch.line" => "Line: click points; right-click or Esc to finish".into(),
|
||||
"sketch.rectangle.two_point" => "Rectangle: click two opposite corners".into(),
|
||||
"sketch.rectangle.center" => "Center rectangle: click the centre, then a corner".into(),
|
||||
"sketch.rectangle.rounded" => "Rounded rectangle: click two opposite corners (Tab to the radius box to type the corner radius)".into(),
|
||||
"sketch.rectangle.three_point" => "3-point rectangle: click two points of one edge, then the width".into(),
|
||||
"sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(),
|
||||
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
|
||||
@@ -347,6 +349,9 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
|
||||
let params = match t.cmd {
|
||||
"sketch.rectangle.two_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}),
|
||||
"sketch.rectangle.center" => json!({"center": xy(&p[0]), "corner": xy(&p[1])}),
|
||||
"sketch.rectangle.rounded" => {
|
||||
json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "radius": crate::sketch_dims::rounded_radius_value(app, t, p[0].0, p[1].0)})
|
||||
}
|
||||
"sketch.rectangle.three_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "p2": xy(&p[2])}),
|
||||
"sketch.circle.center" => json!({"center": arg(&p[0]), "radius": d(0, 1)}),
|
||||
"sketch.circle.two_point" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}),
|
||||
@@ -729,6 +734,11 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
|
||||
line(h, c);
|
||||
line(c, *a);
|
||||
}
|
||||
("sketch.rectangle.rounded", [a]) => {
|
||||
let r = crate::sketch_dims::rounded_radius(app, t, *a, h);
|
||||
let outline: Vec<_> = rounded_outline(*a, h, r).into_iter().map(|q| ss.plane.to_world(q)).collect();
|
||||
paint_sketch_path(&outline, crate::sketch_palette::mode(app), painter, proj, stroke);
|
||||
}
|
||||
("sketch.rectangle.center", [c]) => {
|
||||
let d = h - *c;
|
||||
let k =
|
||||
@@ -745,3 +755,28 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed outline of the rectangle with corners `a`, `b` and corner radius `r` (clamped to
|
||||
/// fit), as points for a preview: counter-clockwise, arcs sampled, first point repeated last.
|
||||
pub(crate) fn rounded_outline(a: Vec2, b: Vec2, r: f64) -> Vec<Vec2> {
|
||||
let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y)));
|
||||
let r = if r.is_finite() { r.clamp(0.0, 0.5 * (hi.x - lo.x).min(hi.y - lo.y)) } else { 0.0 };
|
||||
let q = std::f64::consts::FRAC_PI_2;
|
||||
// Corner centres and the angle each arc starts at, CCW from the bottom-right corner.
|
||||
let corners = [
|
||||
(Vec2::new(hi.x - r, lo.y + r), -q),
|
||||
(Vec2::new(hi.x - r, hi.y - r), 0.0),
|
||||
(Vec2::new(lo.x + r, hi.y - r), q),
|
||||
(Vec2::new(lo.x + r, lo.y + r), 2.0 * q),
|
||||
];
|
||||
let mut pts = Vec::with_capacity(4 * 9 + 1);
|
||||
for (c, a0) in corners {
|
||||
for i in 0..=8 {
|
||||
pts.push(c + Vec2::from_angle(a0 + q * f64::from(i) / 8.0) * r);
|
||||
}
|
||||
}
|
||||
if let Some(first) = pts.first().copied() {
|
||||
pts.push(first);
|
||||
}
|
||||
pts
|
||||
}
|
||||
Reference in new issue
Block a user