From 97ff7578c2e1d211a044c249973547ca2aaff6d5 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Tue, 13 Oct 2026 23:52:10 -0400 Subject: [PATCH] Rounded Rectangle sketch tool (#891) (#948) * 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 * Rounded Rectangle in the Simplified Chinese catalog Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/sketch.rs | 63 ++++++++++ crates/engine/src/cmd/sketch_dimensioned.rs | 11 +- .../src/cmd/sketch_rounded_rect_tests.rs | 119 ++++++++++++++++++ crates/ui-egui/src/icons.rs | 14 +++ crates/ui-egui/src/locales/en.json | 1 + crates/ui-egui/src/locales/ja.json | 1 + crates/ui-egui/src/locales/zh.json | 1 + crates/ui-egui/src/sketch_dims.rs | 33 ++++- crates/ui-egui/src/sketch_dims_tests.rs | 42 +++++++ crates/ui-egui/src/sketch_families.rs | 5 +- crates/ui-egui/src/sketch_palette.rs | 2 +- crates/ui-egui/src/tools.rs | 35 ++++++ .../tests/scenarios/s51_sketch_families.json | 4 +- docs/features.md | 5 +- docs/i18n_catalog_ja.md | 3 +- 15 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/cmd/sketch_rounded_rect_tests.rs diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 39da5523..ea079e12 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -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 { 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 { + 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 = ["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 { 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 { #[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; diff --git a/crates/engine/src/cmd/sketch_dimensioned.rs b/crates/engine/src/cmd/sketch_dimensioned.rs index 168ace7e..62d0897d 100644 --- a/crates/engine/src/cmd/sketch_dimensioned.rs +++ b/crates/engine/src/cmd/sketch_dimensioned.rs @@ -12,7 +12,9 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result { 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 { 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()), diff --git a/crates/engine/src/cmd/sketch_rounded_rect_tests.rs b/crates/engine/src/cmd/sketch_rounded_rect_tests.rs new file mode 100644 index 00000000..4621cc9b --- /dev/null +++ b/crates/engine/src/cmd/sketch_rounded_rect_tests.rs @@ -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 { + 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 { + 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::>()); +} + +#[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}"); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2ba27f21..83cfbde4 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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 { diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json index ba894edd..329170bf 100644 --- a/crates/ui-egui/src/locales/en.json +++ b/crates/ui-egui/src/locales/en.json @@ -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", diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json index 7986b9d4..b25c4cfe 100644 --- a/crates/ui-egui/src/locales/ja.json +++ b/crates/ui-egui/src/locales/ja.json @@ -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": "スケッチ平面を再定義", diff --git a/crates/ui-egui/src/locales/zh.json b/crates/ui-egui/src/locales/zh.json index e6353c54..c7e39a0e 100644 --- a/crates/ui-egui/src/locales/zh.json +++ b/crates/ui-egui/src/locales/zh.json @@ -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": "重定义草图平面", diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index b5db6e8c..5dadeb32 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -38,6 +38,7 @@ pub fn boxes_for(cmd: &str, n: usize) -> Vec { 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::>(); 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 { locked(app, t, label) diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs index 2920f8d3..2f04f207 100644 --- a/crates/ui-egui/src/sketch_dims_tests.rs +++ b/crates/ui-egui/src/sketch_dims_tests.rs @@ -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::>(), ["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::>(), ["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); +} diff --git a/crates/ui-egui/src/sketch_families.rs b/crates/ui-egui/src/sketch_families.rs index 0802e4be..a6c2a60c 100644 --- a/crates/ui-egui/src/sketch_families.rs +++ b/crates/ui-egui/src/sketch_families.rs @@ -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: &[ diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index 5b98103e..ac376ec0 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -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"); } diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index f720665f..5a2924c8 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -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, 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 { + 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 +} diff --git a/crates/ui-egui/tests/scenarios/s51_sketch_families.json b/crates/ui-egui/tests/scenarios/s51_sketch_families.json index b4878f93..801d4aa4 100644 --- a/crates/ui-egui/tests/scenarios/s51_sketch_families.json +++ b/crates/ui-egui/tests/scenarios/s51_sketch_families.json @@ -1,10 +1,12 @@ [ {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, {"click":{"handle":"family:Rectangle"}}, - {"expect":{"shown_handles":["dropdown:sketch.rectangle.two_point","dropdown:sketch.rectangle.three_point","dropdown:sketch.rectangle.center"],"hidden_handles":["dropdown:sketch.circle.center"]}}, + {"expect":{"shown_handles":["dropdown:sketch.rectangle.two_point","dropdown:sketch.rectangle.three_point","dropdown:sketch.rectangle.center","dropdown:sketch.rectangle.rounded"],"hidden_handles":["dropdown:sketch.circle.center"]}}, {"key":"ArrowDown"}, {"expect":{"shown_handles":["dropdown_hot:sketch.rectangle.two_point"]}}, {"key":"ArrowUp"}, + {"expect":{"shown_handles":["dropdown_hot:sketch.rectangle.rounded"]}}, + {"key":"ArrowUp"}, {"expect":{"shown_handles":["dropdown_hot:sketch.rectangle.center"]}}, {"key":"Enter"}, {"expect":{"tool":"sketch.rectangle.center"}}, diff --git a/docs/features.md b/docs/features.md index 2675dea4..242d104a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,8 +1,8 @@ # Features -SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **342 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). +SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **343 commands.** Planned (registered, not built yet): 6. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md). -## Sketch (98) +## Sketch (99) | Command | Id | Toolbar | |---|---|---| @@ -13,6 +13,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm | Draw Dimensioned Sketch Geometry | `sketch.draw.dimensioned` | – | | 2-Point Rectangle (R) | `sketch.rectangle.two_point` | SKETCH › CREATE | | 3-Point Rectangle | `sketch.rectangle.three_point` | SKETCH › CREATE | +| Rounded Rectangle | `sketch.rectangle.rounded` | SKETCH › CREATE | | Center Rectangle | `sketch.rectangle.center` | SKETCH › CREATE | | Center Diameter Circle (C) | `sketch.circle.center` | SKETCH › CREATE | | 2-Point Circle | `sketch.circle.two_point` | SKETCH › CREATE | diff --git a/docs/i18n_catalog_ja.md b/docs/i18n_catalog_ja.md index 2a7c24d0..c2b5d192 100644 --- a/docs/i18n_catalog_ja.md +++ b/docs/i18n_catalog_ja.md @@ -1,4 +1,4 @@ -# SolidCraft 英語・日本語 ローカライズ対照リスト (全 846 件) +# SolidCraft 英語・日本語 ローカライズ対照リスト (全 847 件) | 英語 (English) | 日本語 (Japanese) | キー名 (Key) | | :--- | :--- | :--- | @@ -670,6 +670,7 @@ | Rim Edge | リムエッジ | `Rim Edge` | | Roll History Marker | 履歴マーカーを移動 | `cmd.timeline_roll_to` | | Roll History Marker Here | 履歴マーカーをここに移動 | `Roll History Marker Here` | +| Rounded Rectangle | 角丸長方形 | `cmd.sketch_rectangle_rounded` | | Rule | ルール | `Rule` | | Save | 保存 | `ui.save` | | Save As | 名前を付けて保存 | `ui.save_as` |