From 5359c92a19ca2de2d4f7b518cc87ce378cbccd26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 04:42:04 +0000 Subject: [PATCH] G11: Arc segment dialog options wired to shape.arc Replace length/angle with Length X/Y, reference point, Type, Base Along, Slope and Fill Arc; extend geom and shape.arc params. Co-authored-by: nrq6993-a11y --- crates/engine/src/cmd/create.rs | 41 ++++++++++-- crates/geom/src/shapes.rs | 38 ++++++++--- crates/tools/src/settings.rs | 2 +- crates/ui-egui/src/dialogs/form.rs | 7 +- crates/ui-egui/src/dialogs/shapes.rs | 98 ++++++++++++++++++++++++---- crates/ui-egui/src/dialogs/tests.rs | 9 ++- crates/ui-egui/src/dialogs/tools.rs | 32 ++++++--- 7 files changed, 186 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 617f3a529..a9a86195d 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -32,7 +32,15 @@ pub fn specs() -> Vec { ), cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line), cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral), - cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc), + cmd!( + "shape.arc", + "Arc", + [], + None, + "{x1, y1, x2, y2, closed?, slope?} or {x, y, lengthX, lengthY, reference?, baseAlong?: x|y, slope?, closed?, fillArc?} → {id}", + has_doc, + arc + ), cmd!( "shape.rectangularGrid", "Rectangular Grid", @@ -393,9 +401,34 @@ fn spiral(s: &mut Session, p: &Value) -> Result { } fn arc(s: &mut Session, p: &Value) -> Result { - let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?); - let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?); - add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, switch_or(p, "closed", false, "shape.arc")?), None), None) + let closed = switch_or(p, "closed", false, "shape.arc")?; + let fill_arc = switch_or(p, "fillArc", false, "shape.arc")?; + let base_x = !matches!(p.get("baseAlong").and_then(Value::as_str), Some("y" | "Y")); + let path = if p.get("lengthX").is_some() || p.get("lengthY").is_some() { + let anchor = Point::new(f64_req(p, "x", "shape.arc")?, f64_req(p, "y", "shape.arc")?); + let reference = p.get("reference").and_then(Value::as_u64).unwrap_or(0) as usize; + shapes::arc_segment( + anchor, + reference, + number_or(p, "lengthX", 100.0, "shape.arc")?, + number_or(p, "lengthY", 100.0, "shape.arc")?, + base_x, + number_or(p, "slope", 50.0, "shape.arc")?, + closed, + ) + } else { + let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?); + let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?); + let slope = number_or(p, "slope", 100.0, "shape.arc")?; + shapes::arc(a, b, slope, closed, base_x) + }; + let fill = if fill_arc { s.paint.fill.clone() } else { vectorcraft_color::Paint::None }; + let look = s.new_art_look( + fill, + if s.paint.stroke.is_none() { vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK) } else { s.paint.stroke.clone() }, + s.paint.stroke_width.max(0.1), + ); + add_look(s, "Arc", path_kind(path, None), look, None) } /// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index e677d34ff..4d9f92281 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -176,17 +176,33 @@ pub fn line(a: Point, b: Point) -> PathData { PathData::single(SubPath::polyline(&[a, b], false)) } -/// Quarter-ellipse arc from `a` to `b` (Arc tool default: concave, open, x-axis base). -pub fn arc(a: Point, b: Point, slope: f64, closed: bool) -> PathData { - // Corner of the arc's bounding box is at (a.x, b.y): the arc bows away from it. - let k = KAPPA * (1.0 + slope.clamp(-1.0, 1.0) * 0.0); - let corner = Point::new(b.x, a.y); - let h1 = a + (corner - a) * k; - let h2 = b + (corner - b) * k; +/// Bounding box of an arc segment when reference point `reference` (0…8) is placed on `anchor`. +pub fn arc_bbox(anchor: Point, reference: usize, length_x: f64, length_y: f64) -> Rect { + let w = length_x.abs().max(1e-9); + let h = length_y.abs().max(1e-9); + let rp = crate::reference_point(Rect::new(0.0, 0.0, w, h), reference.min(8)); + Rect::new(anchor.x - rp.x, anchor.y - rp.y, anchor.x - rp.x + w, anchor.y - rp.y + h) +} + +/// Arc segment from the Arc tool dialog: lengths, reference point, base axis, slope and open/closed. +pub fn arc_segment(anchor: Point, reference: usize, length_x: f64, length_y: f64, base_along_x: bool, slope: f64, closed: bool) -> PathData { + let r = arc_bbox(anchor, reference, length_x, length_y).abs(); + let (a, b) = if base_along_x { (Point::new(r.x0, r.y0), Point::new(r.x1, r.y1)) } else { (Point::new(r.x0, r.y1), Point::new(r.x1, r.y0)) }; + arc(a, b, slope, closed, base_along_x) +} + +/// Quarter-ellipse arc from `a` to `b` (Arc tool default: open, x-axis base when `base_along_x`). +/// `slope` is −100…100 (concave…convex); 0 is a straight chord. +pub fn arc(a: Point, b: Point, slope: f64, closed: bool, base_along_x: bool) -> PathData { + let t = slope.clamp(-100.0, 100.0) / 100.0; + let corner = if t >= 0.0 { Point::new(b.x, a.y) } else { Point::new(a.x, b.y) }; + let k = KAPPA * t.abs(); + let h1 = if k == 0.0 { a } else { a + (corner - a) * k }; + let h2 = if k == 0.0 { b } else { b + (corner - b) * k }; let mut anchors = vec![Anchor { p: a, h_in: a, h_out: h1, kind: AnchorKind::Corner }, Anchor { p: b, h_in: h2, h_out: b, kind: AnchorKind::Corner }]; if closed { - let o = Point::new(a.x, b.y); + let o = if base_along_x { Point::new(a.x, b.y) } else { Point::new(b.x, a.y) }; anchors.push(Anchor::corner(o)); } PathData::single(SubPath::new(anchors, closed)) @@ -531,7 +547,9 @@ mod tests { #[test] fn arc_open_and_closed() { - assert_eq!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, false).anchor_count(), 2); - assert!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, true).subpaths[0].closed); + assert_eq!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, false, true).anchor_count(), 2); + assert!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, true, true).subpaths[0].closed); + let seg = arc_segment(Point::ZERO, 0, 100.0, 50.0, true, 50.0, false); + assert_eq!(seg.anchor_count(), 2); } } diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index 8abb10f44..e395105c0 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -26,7 +26,7 @@ const ROWS: &[Row] = &[ (SYMBOLISM, Some("symbolism"), &["diameter", "intensity", "density"]), (&["mirrorCut"], None, &["axis", "keep"]), (&["puppetWarp"], None, &["showMesh", "expand"]), - (&["arc"], None, &["closed"]), + (&["arc"], None, &["closed", "lengthX", "lengthY", "reference", "baseAlong", "slope", "fillArc"]), (&["spiral"], None, &["decay", "segments", "clockwise"]), (&["rectangularGrid"], None, &["rows", "columns", "rowSkew", "columnSkew", "frame", "fill"]), (&["polarGrid"], None, &["concentric", "radial", "concentricSkew", "radialSkew", "compound", "fill"]), diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 6df5da3e4..ffa232135 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -63,7 +63,8 @@ fn lengths(kind: &str) -> &'static [&'static str] { "roundedRectangle" => &["width", "height", "radius"], "polygon" => &["radius"], "star" => &["radius1", "radius2"], - "lineSegment" | "arc" => &["length"], + "lineSegment" => &["length"], + "arc" => &["lengthX", "lengthY"], "offsetPath" => &["offset"], _ => &[], } @@ -73,7 +74,7 @@ fn lengths(kind: &str) -> &'static [&'static str] { /// number: fields that ↑/↓ step by one (Shift: ten) and that do math, as the distances do. fn suffix(kind: &str, key: &str) -> Option<&'static str> { match (kind, key) { - ("rotate" | "shear" | "reflect" | "move" | "arc" | "lineSegment", "angle") => Some("°"), + ("rotate" | "shear" | "reflect" | "move" | "lineSegment", "angle") => Some("°"), ("scale", "sx" | "sy") => Some("%"), _ => None, } @@ -96,7 +97,7 @@ fn order(kind: &str) -> &'static [&'static str] { "rectangularGrid" => &["width", "height", "rows", "rowSkew", "columns", "columnSkew"], "polarGrid" => &["width", "height", "concentric", "concentricSkew", "radial", "radialSkew"], "artboardOptions" => &["name", "width", "height"], - "arc" | "lineSegment" => &["length", "angle"], + "lineSegment" => &["length", "angle"], _ => &[], } } diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index e043474d7..da21b9fb2 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -2,6 +2,7 @@ //! Star, Line Segment and Arc tool options, and the two grids (size, divider counts and skews, switches). use serde_json::{Value, json}; +use vectorcraft_doc::Unit; use vectorcraft_geom::{Point, Rect}; use super::{DialogSpec, form, run_and_close}; @@ -21,7 +22,7 @@ fn title(kind: &str) -> &'static str { "star" => tl!("Star"), "rectangularGrid" => tl!("Rectangular Grid"), "polarGrid" => tl!("Polar Grid"), - "arc" => tl!("Arc"), + "arc" => tl!("Arc Segment Tool Options"), _ => tl!("Line Segment Tool Options"), } } @@ -49,6 +50,21 @@ fn set_grid_placed(d: &mut Dialog, r: Rect) { d.fields.insert("height".into(), json!(r.height())); } +/// Arc segment dialog: keep reference `refi` on the canvas click (`__anchorX`/`__anchorY`). +pub(crate) fn arc_pin_to_anchor(d: &mut Dialog) { + let anchor = Point::new(d.f64("__anchorX", d.f64("x", 0.0)), d.f64("__anchorY", d.f64("y", 0.0))); + let refi = d.fields.get("reference").and_then(|v| v.as_u64()).map(|n| (n as usize).min(8)).unwrap_or(0); + let w = d.f64("lengthX", 100.0).abs().max(1e-9); + let h = d.f64("lengthY", 100.0).abs().max(1e-9); + let rp = vectorcraft_geom::reference_point(Rect::new(0.0, 0.0, w, h), refi); + d.fields.insert("x".into(), json!(anchor.x - rp.x)); + d.fields.insert("y".into(), json!(anchor.y - rp.y)); +} + +fn arc_reference(d: &Dialog) -> usize { + d.fields.get("reference").and_then(|v| v.as_u64()).map(|n| (n as usize).min(8)).unwrap_or(0) +} + /// Top-left `x`/`y` so reference point `refi` sits on the canvas click (`__anchorX`/`__anchorY`). pub(crate) fn grid_pin_to_anchor(d: &mut Dialog) { let anchor = Point::new(d.f64("__anchorX", d.f64("x", 0.0)), d.f64("__anchorY", d.f64("y", 0.0))); @@ -79,12 +95,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { set_grid_placed(d, artboard_rect(was, refi, None, None, Some(w), Some(h), false)); } }); + } else if d.kind == "arc" { + arc_form(ui, d, unit); } else { form::grid(ui, d, unit); - if d.kind == "arc" { - ui.add_space(8.0); - form::check(ui, d, "closed", tl!("Closed")); - } } let own = match d.kind.as_str() { "rectangularGrid" => ("frame", tl!("Use Outside Rectangle As Frame")), @@ -98,6 +112,50 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +fn arc_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + ui.horizontal(|ui| { + let refi = arc_reference(d); + if let Some(i) = widgets::reference_point(ui, refi) { + d.fields.insert("reference".into(), json!(i)); + arc_pin_to_anchor(d); + } + }); + ui.add_space(4.0); + let rail = crate::theme::Tokens::get(ui.ctx()).input_border; + egui::Grid::new("arc-dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + let lx0 = d.f64("lengthX", 100.0); + form::length_field(ui, d, "lengthX", tl!("Length X-Axis:"), unit); + if d.f64("lengthX", 100.0) != lx0 { + arc_pin_to_anchor(d); + } + let ly0 = d.f64("lengthY", 100.0); + form::length_field(ui, d, "lengthY", tl!("Length Y-Axis:"), unit); + if d.f64("lengthY", 100.0) != ly0 { + arc_pin_to_anchor(d); + } + form::choice(ui, d, "arcType", tl!("Type:"), (form::FIELD_W, form::FIELD_W - 12.0), &[("open", tl!("Open")), ("closed", tl!("Closed"))]); + form::choice( + ui, + d, + "baseAlong", + tl!("Base Along:"), + (form::FIELD_W, form::FIELD_W - 12.0), + &[ + ("x", crate::i18n::tr_ctx(crate::i18n::current(), "axis", "X Axis")), + ("y", crate::i18n::tr_ctx(crate::i18n::current(), "axis", "Y Axis")), + ], + ); + form::row_label(ui, tl!("Slope:")); + ui.vertical(|ui| { + form::slider_field_named(ui, d, "slope", tl!("Slope:"), -100.0..=100.0, "", &|_| rail); + form::slider_ends(ui, form::SLIDER_LABEL, (tl!("Concave"), tl!("Convex"))); + }); + ui.end_row(); + }); + ui.add_space(8.0); + form::check(ui, d, "fillArc", tl!("Fill Arc")); +} + /// The top-left corner of a rectangle or ellipse: the click itself, or with Alt held (draw from centre) the click minus half the size. fn box_origin(d: &Dialog) -> (f64, f64) { let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0)); @@ -120,7 +178,19 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { values[key] = json!(d.f64(key, default).clamp(min, 1000.0) as u64); values }) - .or_else(|| (d.kind == "arc").then(|| json!({"closed": d.bool("closed")}))); + .or_else(|| { + (d.kind == "arc").then(|| { + json!({ + "closed": d.str("arcType").eq_ignore_ascii_case("closed"), + "lengthX": d.f64("lengthX", 100.0), + "lengthY": d.f64("lengthY", 100.0), + "reference": arc_reference(d), + "baseAlong": d.str("baseAlong"), + "slope": d.f64("slope", 50.0), + "fillArc": d.bool("fillArc"), + }) + }) + }); let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0)); let (bx, by) = box_origin(d); let (id, params) = match d.kind.as_str() { @@ -148,15 +218,19 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { ("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()})) } "arc" => { - let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians()); + let anchor = Point::new(d.f64("__anchorX", x), d.f64("__anchorY", y)); ( "shape.arc", json!({ - "x1": x, - "y1": y, - "x2": x + l * a.cos(), - "y2": y - l * a.sin(), - "closed": d.bool("closed"), + "x": anchor.x, + "y": anchor.y, + "lengthX": d.f64("lengthX", 100.0), + "lengthY": d.f64("lengthY", 100.0), + "reference": arc_reference(d), + "baseAlong": d.str("baseAlong"), + "slope": d.f64("slope", 50.0), + "closed": d.str("arcType").eq_ignore_ascii_case("closed"), + "fillArc": d.bool("fillArc"), }), ) } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 6f1f807c5..775232b5e 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1719,10 +1719,17 @@ fn arc_click_opens_options_and_ok_draws() { use vectorcraft_doc::NodeKind; let mut app = app(); open_tool_dialog(&mut app, "arc", json!({"x": 100.0, "y": 50.0})); - assert_eq!(app.ui.dialog.as_ref().unwrap().kind, "arc"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.kind, "arc"); + assert!(d.fields.contains_key("lengthX") && d.fields.contains_key("lengthY")); super::confirm(&mut app).unwrap(); let n = app.session.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap(); assert!(matches!(n.kind, NodeKind::Path { .. })); + let pd = n.path_data().unwrap(); + let a0 = pd.subpaths[0].anchors[0].p; + let a1 = pd.subpaths[0].anchors[1].p; + assert!((a0.x - 100.0).abs() < 1e-6 && (a0.y - 50.0).abs() < 1e-6); + assert!((a1.x - 200.0).abs() < 1e-6 && (a1.y - 150.0).abs() < 1e-6); } /// #1377: Polar Grid's dialog has a skew slider under each divider count, Create Compound Path diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index d13e25cdf..bcad596f8 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -20,16 +20,28 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": app.session.tool_options_of(kind)["points"]})) } "lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})), - "arc" => Dialog::new( - kind, - json!({ - "x": x, - "y": y, - "length": "100 pt", - "angle": 0, - "closed": app.session.tool_options_of(kind).get("closed").and_then(Value::as_bool).unwrap_or(false), - }), - ), + "arc" => { + let opts = app.session.tool_options_of(kind); + let closed = opts.get("closed").and_then(Value::as_bool).unwrap_or(false); + let mut d = Dialog::new( + kind, + json!({ + "__anchorX": x, + "__anchorY": y, + "x": x, + "y": y, + "lengthX": opts.get("lengthX").cloned().unwrap_or(json!("100 pt")), + "lengthY": opts.get("lengthY").cloned().unwrap_or(json!("100 pt")), + "reference": opts.get("reference").cloned().unwrap_or(json!(0)), + "arcType": if closed { "closed" } else { "open" }, + "baseAlong": opts.get("baseAlong").cloned().unwrap_or(json!("x")), + "slope": opts.get("slope").cloned().unwrap_or(json!(50)), + "fillArc": opts.get("fillArc").and_then(Value::as_bool).unwrap_or(false), + }), + ); + super::shapes::arc_pin_to_anchor(&mut d); + d + } // The grids: the tool's stored options (divider counts and skews, switches) at the default size. "rectangularGrid" | "polarGrid" => { let mut obj = match app.session.tool_options_of(kind) {