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` |