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 <nrq6993-a11y@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-10-22 13:38:04 +07:00
committed by bflatastic
co-authored by nrq6993-a11y
parent d6e64cb080
commit 5359c92a19
7 changed files with 186 additions and 41 deletions
+37 -4
View File
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
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<Value> {
}
fn arc(s: &mut Session, p: &Value) -> Result<Value> {
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
+28 -10
View File
@@ -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);
}
}
+1 -1
View File
@@ -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"]),
+4 -3
View File
@@ -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"],
_ => &[],
}
}
+86 -12
View File
@@ -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<Value, String> {
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<Value, String> {
("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"),
}),
)
}
+8 -1
View File
@@ -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
+22 -10
View File
@@ -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) {