Merge pull request #914 from storytold/feat/812-tool-options

Part of #812: polygon properties, Flare Tool Options, more tool double-clicks
This commit is contained in:
BFlat
2026-10-10 13:23:32 +07:00
committed by GitHub
27 changed files with 964 additions and 76 deletions
+3 -3
View File
File diff suppressed because one or more lines are too long
+84 -1
View File
@@ -174,6 +174,61 @@ impl LiveShape {
spread(radii, *sides as usize);
spread(kinds, *sides as usize);
}
/// A polygon's radius in the document, centre to vertex: its own radius by the mean scale of
/// `xf` (exact while its sides are equal). None for other shapes.
pub fn polygon_radius(&self) -> Option<f64> {
let LiveShape::Polygon { radius, xf, .. } = self else { return None };
Some(radius * xf.determinant().abs().sqrt())
}
/// A polygon's angle: the counterclockwise degrees in [0, 360) its first vertex is turned by
/// from straight up (the Rotate field's sense). None for other shapes.
pub fn polygon_angle(&self) -> Option<f64> {
let LiveShape::Polygon { .. } = self else { return None };
// No −0° or 360° from rounding: a polygon drawn upright reads 0°.
let a = (-self.angle_deg()).rem_euclid(360.0);
Some(if a > 360.0 - 1e-9 { 0.0 } else { a + 0.0 })
}
/// Whether a polygon's sides are all as long: `xf` scales it evenly and doesn't shear it. True
/// for other shapes.
pub fn polygon_sides_equal(&self) -> bool {
let LiveShape::Polygon { xf, .. } = self else { return true };
let [a, b, c, d, _, _] = xf.as_coeffs();
let (sx, sy) = (a.hypot(b), c.hypot(d));
(sx - sy).abs() <= 1e-9 * sx.max(sy) && (a * c + b * d).abs() <= 1e-9 * sx * sy
}
/// Give a polygon the document radius `r` (centre to vertex, see [`LiveShape::polygon_radius`]).
pub fn set_polygon_radius(&mut self, r: f64) {
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let k = xf.determinant().abs().sqrt();
if k > 1e-12 && r.is_finite() {
*radius = r.max(0.0) / k;
}
}
/// The document radius that makes a polygon's sides `side` long (with its sides equal).
pub fn polygon_radius_for_side(&self, side: f64) -> Option<f64> {
let LiveShape::Polygon { sides, .. } = self else { return None };
Some(side / (2.0 * (std::f64::consts::PI / f64::from((*sides).max(3))).sin()))
}
/// Turn a polygon about its centre to `angle` (degrees, as [`LiveShape::polygon_angle`]).
pub fn set_polygon_angle(&mut self, angle: f64) {
let Some(now) = self.polygon_angle() else { return };
if let LiveShape::Polygon { xf, .. } = self
&& angle.is_finite()
{
let centre = *xf * Point::ZERO;
*xf = Affine::rotate_about((now - angle).to_radians(), centre) * *xf;
}
}
/// Make a polygon's sides equal (Make Sides Equal): drop the uneven scale and the shear from
/// `xf`, keeping its centre, angle, reflection and [`LiveShape::polygon_radius`].
pub fn make_sides_equal(&mut self) {
let Some(r) = self.polygon_radius() else { return };
let LiveShape::Polygon { radius, xf, .. } = self else { return };
let [a, b, _, _, e, f] = xf.as_coeffs();
let flip = if xf.determinant() < 0.0 { Affine::FLIP_Y } else { Affine::IDENTITY };
*xf = Affine::translate((e, f)) * Affine::rotate(b.atan2(a)) * flip;
*radius = r;
}
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
/// the caller must regenerate the path with [`LiveShape::to_path`].
///
@@ -253,7 +308,7 @@ impl LiveShape {
radii.iter_mut().for_each(|r| *r /= k);
true
}
/// Rotation angle of the live shape in degrees (shown in the Properties panel).
/// Rotation angle of the live shape in degrees, clockwise on the page (y points down).
pub fn angle_deg(&self) -> f64 {
match self {
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
@@ -1357,6 +1412,34 @@ mod tests {
assert_eq!(path.label(), "Path");
}
#[test]
fn a_polygon_reports_and_takes_its_radius_angle_and_equal_sides() {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
// A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page.
let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0);
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![] };
assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal());
p.set_polygon_angle(45.0);
assert!(close(p.polygon_angle().unwrap(), 45.0));
let centre = p.to_path().bounds().unwrap().center();
assert!(close(centre.x, 50.0) && close(centre.y, 50.0), "turned about its centre: {centre:?}");
p.set_polygon_radius(30.0);
assert!(close(p.polygon_radius().unwrap(), 30.0));
// A hexagon's side is as long as its radius.
assert!(close(p.polygon_radius_for_side(12.0).unwrap(), 12.0));
// Stretched, its sides differ; Make Sides Equal keeps its centre, angle and mean radius.
p.transform(Affine::scale_non_uniform(2.0, 1.0));
assert!(!p.polygon_sides_equal());
let (r, a) = (p.polygon_radius().unwrap(), p.polygon_angle().unwrap());
p.make_sides_equal();
assert!(p.polygon_sides_equal() && close(p.polygon_radius().unwrap(), r) && close(p.polygon_angle().unwrap(), a));
let LiveShape::Polygon { xf, .. } = &p else { panic!("polygon") };
assert_eq!(*xf * Point::ZERO, Point::new(100.0, 50.0));
// Other shapes have none of it.
let e = LiveShape::Ellipse { w: 1.0, h: 1.0, pie: (0.0, 360.0), xf: Affine::IDENTITY };
assert_eq!((e.polygon_radius(), e.polygon_angle()), (None, None));
}
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
+28 -8
View File
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Live Shape Properties",
[],
None,
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared)",
"{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors without handles between two straight sides): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
has_selection,
set_live_shape
),
@@ -1095,24 +1095,44 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
.transpose()?;
let radius = p.get("radius").and_then(Value::as_f64);
let sides = p.get("sides").and_then(Value::as_u64);
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let angle = |k: &str| -> Result<Option<f64>> {
// A number that must be `what` (None when absent).
let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result<Option<f64>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_f64()
.filter(|a| a.is_finite())
.map(|a| Some(a.rem_euclid(360.0)))
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be an angle in degrees, not {v}"))),
.filter(|x| x.is_finite() && ok(*x))
.map(Some)
.ok_or_else(|| bad("object.setLiveShape", format!("`{k}` must be {what}, not {v}"))),
}
};
let angle = |k: &str| Ok::<_, EngineError>(number(k, |_| true, "an angle in degrees")?.map(|a| a.rem_euclid(360.0)));
let length = |k: &str| number(k, |x| x > 0.0 && x <= crate::MAX_COORD, "a length in points above 0");
// An ellipse's pie (Ellipse Properties: Pie Start and End Angle, degrees; Invert Pie swaps them).
let (pie_start, pie_end) = (angle("pieStart")?, angle("pieEnd")?);
let invert = p.get("invertPie").and_then(Value::as_bool) == Some(true);
// A polygon's Polygon Properties: its angle, radius or side length, Make Sides Equal.
let (polygon_angle, polygon_radius, side) = (angle("polygonAngle")?, length("polygonRadius")?, length("sideLength")?);
let equal = p.get("makeSidesEqual").and_then(Value::as_bool) == Some(true);
s.edit("Live Shape", |d, sel| {
for id in &ids {
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
if let (Some(l @ LiveShape::Polygon { .. }), Some(n)) = (live.as_mut(), sides) {
l.set_sides(n);
if let Some(l @ LiveShape::Polygon { .. }) = live.as_mut()
&& (sides.is_some() || polygon_angle.is_some() || polygon_radius.is_some() || side.is_some() || equal)
{
if let Some(n) = sides {
l.set_sides(n);
}
if equal {
l.make_sides_equal();
}
if let Some(a) = polygon_angle {
l.set_polygon_angle(a);
}
// A side length sets the radius that gives it (with the new side count).
if let Some(r) = side.and_then(|len| l.polygon_radius_for_side(len)).or(polygon_radius) {
l.set_polygon_radius(r);
}
*path = l.to_path();
}
if let Some(l @ LiveShape::Ellipse { .. }) = live.as_mut()
+39
View File
@@ -307,3 +307,42 @@ fn an_ellipse_takes_pie_angles_and_inverts() {
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
}
/// #812: a polygon's Polygon Properties: its angle, radius and side length, each one undo step;
/// Make Sides Equal after an uneven scale; bad values are errors.
#[test]
fn a_polygon_takes_its_angle_radius_side_length_and_equal_sides() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = NodeId(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap()["id"].as_u64().unwrap());
let live = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Path { live: Some(l @ LiveShape::Polygon { .. }), path, .. } => (l.clone(), path.bounds().unwrap()),
k => panic!("{k:?}"),
};
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
run(&mut s, json!({"polygonAngle": 90}));
let (l, b) = live(&s);
assert!(close(l.polygon_angle().unwrap(), 90.0), "{l:?}");
// Its first vertex, straight up before, points left now: the hexagon is 100 wide, 86.6 tall.
assert!(close(b.width(), 100.0) && close(b.height(), 86.602_540_378) && close(b.center().x, 200.0), "{b:?}");
run(&mut s, json!({"polygonRadius": 80}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 80.0));
// A hexagon's side is as long as its radius; with 4 sides the radius is side / √2.
run(&mut s, json!({"sideLength": 30}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 30.0));
run(&mut s, json!({"sides": 4, "sideLength": 30}));
assert!(close(live(&s).0.polygon_radius().unwrap(), 30.0 / 2f64.sqrt()));
s.execute("edit.undo", &json!({})).unwrap();
let LiveShape::Polygon { sides, .. } = live(&s).0 else { panic!("polygon") };
assert_eq!(sides, 6, "one undo step");
// Scaled unevenly its sides differ; Make Sides Equal makes them equal again, in place.
s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap();
let (l, b) = live(&s);
assert!(!l.polygon_sides_equal() && l.polygon_angle().is_some());
run(&mut s, json!({"makeSidesEqual": true}));
let (l, after) = live(&s);
assert!(l.polygon_sides_equal() && close(after.center().x, b.center().x) && close(after.center().y, b.center().y), "{b:?} → {after:?}");
for bad in [json!({"polygonRadius": 0}), json!({"sideLength": -3}), json!({"polygonRadius": "big"}), json!({"polygonAngle": "left"})] {
assert!(s.execute("object.setLiveShape", &bad).is_err(), "{bad}");
}
}
+31
View File
@@ -397,6 +397,37 @@ fn area_options_fit_junk() {
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// A live polygon's Polygon Properties with junk values (the fixtures have no polygon), drawn
/// plain, scaled unevenly and flattened to nothing: never a panic, and the document stays sound.
#[test]
fn polygon_properties_junk() {
let mut cases: Vec<Value> = vec![json!({"makeSidesEqual": true}), json!({"sides": u64::MAX, "sideLength": 1e-300, "makeSidesEqual": true})];
for key in ["polygonRadius", "sideLength", "polygonAngle"] {
for v in junk_values() {
cases.push(json!({ key: v }));
}
}
let mut failures = vec![];
for scale in [None, Some((300.0, 20.0)), Some((1e-300, 100.0))] {
for p in &cases {
let mut s = Fixture::Multi.session();
let Ok(_) = s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 40, "sides": 7, "rotation": 10})) else { continue };
if let Some((sx, sy)) = scale {
let _ = s.execute("object.scale", &json!({"sx": sx, "sy": sy}));
}
match catch_quiet(|| s.execute("object.setLiveShape", p)) {
Err(m) => failures.push(format!("PANIC object.setLiveShape {p} [{scale:?}]: {m}")),
Ok(_) => {
if let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
failures.push(format!("object.setLiveShape {p} [{scale:?}]: {e}"));
}
}
}
}
}
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
}
/// After a successful fuzzed call, the document still round-trips and exports.
#[test]
fn fuzzed_calls_keep_documents_serializable() {
+122 -10
View File
@@ -1,8 +1,9 @@
//! Flare, Reshape, Shaper and Graph tools.
//!
//! Flare: drag sets the centre and its size (click without dragging opens the Flare Tool Options),
//! then a click sets the end point of the rings. Reshape: drag a point of a selected path; the
//! engine's `path.reshape` adds an anchor there if needed and moves the neighbourhood smoothly.
//! then a click sets the end point of the rings; the rest comes from the tool's options (the Flare
//! Tool Options, which last). Reshape: drag a point of a selected path; the engine's
//! `path.reshape` adds an anchor there if needed and moves the neighbourhood smoothly.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Vec2};
@@ -15,13 +16,35 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
"flare" => Box::new(FlareTool::default()),
"reshape" => Box::new(ReshapeTool::default()),
"shaper" => Box::new(ShaperTool::default()),
id if vectorcraft_doc::GraphKind::parse(id).is_some() && id.ends_with("Graph") => Box::new(GraphTool::new(id)),
id if is_graph_tool(id) => Box::new(GraphTool::new(id)),
_ => return None,
})
}
/// Whether `id` is one of the graph tools (`columnGraph`, `pieGraph`…).
pub fn is_graph_tool(id: &str) -> bool {
id.ends_with("Graph") && vectorcraft_doc::GraphKind::parse(id).is_some()
}
/// The Flare Tool Options as `shape.flare` takes them: (key, default, least, most). The diameter
/// and the rings' path length are in points, the counts whole, the direction in degrees, the
/// rest percentages.
pub const FLARE_OPTIONS: [(&str, f64, f64, f64); 12] = [
("diameter", 100.0, 0.0, 1000.0),
("opacity", 50.0, 0.0, 100.0),
("brightness", 30.0, 0.0, 100.0),
("growth", 20.0, 0.0, 300.0),
("fuzziness", 50.0, 0.0, 100.0),
("rays", 15.0, 0.0, 50.0),
("longest", 300.0, 0.0, 1000.0),
("rayFuzziness", 100.0, 0.0, 100.0),
("pathLength", 300.0, 0.0, 1000.0),
("rings", 10.0, 0.0, 50.0),
("largest", 50.0, 0.0, 250.0),
("direction", 45.0, 0.0, 360.0),
];
/// Flare Tool: centre drag, then a click for the rings' end point.
#[derive(Default)]
pub struct FlareTool {
/// Pressed at (centre) and the current drag point.
drag: Option<(Point, Point)>,
@@ -29,14 +52,62 @@ pub struct FlareTool {
placed: Option<(Point, f64)>,
hover: Point,
began: bool,
/// Ray count changed with ↑/↓ during the gesture (None = the command's default, 15).
/// Ray count changed with ↑/↓ during the gesture (None = the options' count).
rays: Option<u64>,
/// The options' values, in [`FLARE_OPTIONS`] order.
values: [f64; 12],
/// Whether the flare has rays and rings (the options' Rays and Rings checkboxes).
rays_on: bool,
rings_on: bool,
}
impl Default for FlareTool {
fn default() -> Self {
Self {
drag: None,
placed: None,
hover: Point::ZERO,
began: false,
rays: None,
values: FLARE_OPTIONS.map(|(_, default, _, _)| default),
rays_on: true,
rings_on: true,
}
}
}
/// Whether Flare Tool Options value `key` is a count (whole).
fn is_count(key: &str) -> bool {
matches!(key, "rays" | "rings")
}
/// Flare Tool Options value `x` of `key` as JSON: a count as a whole number.
fn option_json(key: &str, x: f64) -> Value {
if is_count(key) { json!(x as u64) } else { json!(x) }
}
/// `shape.flare`'s params for a flare at `c` drawn with the Flare Tool Options `o` (as the tool
/// reports them, [`Tool::options`]): with Rays or Rings off it has none.
pub fn flare_params(o: &Value, c: Point) -> Value {
let mut v = json!({ "cx": c.x, "cy": c.y });
for (key, default, ..) in FLARE_OPTIONS {
v[key] = o.get(key).cloned().unwrap_or_else(|| option_json(key, default));
}
for (on, key) in [("raysOn", "rays"), ("ringsOn", "rings")] {
if o.get(on).and_then(Value::as_bool) == Some(false) {
v[key] = json!(0);
}
}
v
}
impl FlareTool {
/// `shape.flare`'s params: the options, with the dragged centre and diameter, the ray count
/// ↑/↓ chose and the rings' end point once clicked.
fn params(&self, c: Point, diameter: f64, end: Option<Point>) -> Value {
let mut v = json!({ "cx": c.x, "cy": c.y, "diameter": diameter.max(1.0) });
if let Some(r) = self.rays {
let mut v = flare_params(&self.options(), c);
v["diameter"] = json!(diameter.max(1.0));
if let Some(r) = self.rays.filter(|_| self.rays_on) {
v["rays"] = json!(r);
}
if let Some(e) = end {
@@ -60,6 +131,7 @@ impl Tool for FlareTool {
PointerKind::Down => {
if self.placed.is_none() {
self.drag = Some((ev.pos, ev.pos));
self.rays = None;
}
vec![]
}
@@ -106,8 +178,8 @@ impl Tool for FlareTool {
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
// ↑/↓ while drawing add/remove rays.
if matches!(key, ToolKey::Up | ToolKey::Down) && self.began {
let r = self.rays.unwrap_or(15);
if matches!(key, ToolKey::Up | ToolKey::Down) && self.began && self.rays_on {
let r = self.rays.unwrap_or_else(|| self.options()["rays"].as_u64().unwrap_or(0));
self.rays = Some(if key == ToolKey::Up { (r + 1).min(50) } else { r.saturating_sub(1) });
let preview = match (self.drag, self.placed) {
(Some((c, p)), _) => self.params(c, 2.0 * p.distance(c), None),
@@ -126,7 +198,7 @@ impl Tool for FlareTool {
vec![]
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
// Switching away after the centre drag keeps the flare with its default rings.
// Switching away after the centre drag keeps the flare with the rings its options give.
self.drag = None;
if self.placed.take().is_some() && std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] }
}
@@ -142,6 +214,24 @@ impl Tool for FlareTool {
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
let mut v = json!({ "raysOn": self.rays_on, "ringsOn": self.rings_on });
for ((key, ..), x) in FLARE_OPTIONS.iter().zip(self.values) {
v[*key] = option_json(key, x);
}
v
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"raysOn" => self.rays_on = v.as_bool().unwrap_or(true),
"ringsOn" => self.rings_on = v.as_bool().unwrap_or(true),
_ => {
let Some((slot, (_, default, lo, hi))) = self.values.iter_mut().zip(FLARE_OPTIONS).find(|(_, (k, ..))| *k == key) else { return };
let x = v.as_f64().filter(|x| x.is_finite()).unwrap_or(default).clamp(lo, hi);
*slot = if is_count(key) { x.round() } else { x };
}
}
}
}
/// Reshape Tool: drag a point on a selected path.
@@ -378,6 +468,28 @@ impl Tool for GraphTool {
mod tests {
use super::*;
/// The Flare Tool Options: kept within their ranges, and what the next drag draws with.
#[test]
fn flare_options_last_and_draw_the_next_flare() {
let mut t = FlareTool::default();
assert_eq!(t.options()["rays"], json!(15));
for (k, v) in [("diameter", json!(5000)), ("rays", json!(7.6)), ("opacity", json!("x")), ("ringsOn", json!(false)), ("nope", json!(1))] {
t.set_option(k, &v);
}
let o = t.options();
assert_eq!(
(o["diameter"].as_f64(), o["rays"].as_u64(), o["opacity"].as_f64(), o["ringsOn"].as_bool()),
(Some(1000.0), Some(8), Some(50.0), Some(false))
);
assert!(o.get("nope").is_none());
// A drag sets the centre and diameter; the options give the rest, rings off.
let p = t.params(Point::new(10.0, 20.0), 40.0, None);
assert_eq!((p["cx"].as_f64(), p["diameter"].as_f64(), p["rays"].as_u64(), p["rings"].as_u64()), (Some(10.0), Some(40.0), Some(8), Some(0)));
assert_eq!(p["longest"].as_f64(), Some(300.0));
t.set_option("raysOn", &json!(false));
assert_eq!(t.params(Point::ZERO, 1.0, None)["rays"].as_u64(), Some(0));
}
#[test]
fn create_covers_both() {
assert_eq!(create("flare").unwrap().id(), "flare");
+23 -1
View File
@@ -14,7 +14,8 @@ type Row = (&'static [&'static str], Option<&'static str>, &'static [&'static st
/// The Liquify tools.
pub const LIQUIFY: &[&str] = &["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"];
const SYMBOLISM: &[&str] =
/// The Symbolism tools.
pub const SYMBOLISM: &[&str] =
&["symbolSprayer", "symbolShifter", "symbolScruncher", "symbolSizer", "symbolSpinner", "symbolStainer", "symbolScreener", "symbolStyler"];
const ROWS: &[Row] = &[
@@ -35,6 +36,27 @@ const ROWS: &[Row] = &[
(&["star"], None, &["points"]),
(&["freeTransform"], None, &["constrain"]),
(&["artboard"], None, &["moveArt", "scaleArt"]),
// Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes).
(
&["flare"],
None,
&[
"diameter",
"opacity",
"brightness",
"growth",
"fuzziness",
"raysOn",
"rays",
"longest",
"rayFuzziness",
"ringsOn",
"pathLength",
"rings",
"largest",
"direction",
],
),
];
/// The stores `tool`'s persistent options live in, each with the option keys it holds.
+21 -2
View File
@@ -1,5 +1,5 @@
//! Artboard Options (double-click with the Artboard tool): the artboard's fields, sizes parsed
//! with units.
//! Artboard Options (double-click with the Artboard tool, or double-click the tool): the
//! artboard's fields, sizes parsed with units.
use serde_json::{Value, json};
@@ -9,6 +9,25 @@ use crate::state::Dialog;
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Artboard Options").into(), confirm, ..DialogSpec::FORM };
/// The dialog kind of Artboard Options.
pub const KIND: &str = "artboardOptions";
/// Open Artboard Options for the active artboard: the Artboard tool's while it is in use, else the
/// window's.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let index = if app.session.tool_id() == "artboard" {
app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(0)
} else {
app.view().map_or(0, |v| v.artboard)
};
let st = app.session.active().ok_or("no document")?;
let a = st.doc.artboards.get(index).ok_or("no artboard")?;
let r = a.rect;
let fields = json!({"index": index, "name": a.name, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()});
app.ui.dialog = Some(Dialog::new(KIND, fields));
Ok(json!({ "dialog": KIND }))
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = Value::Object(d.fields.clone());
for k in ["x", "y", "width", "height"] {
+10 -6
View File
@@ -63,12 +63,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
label(ui, tl!("Corner:"));
ui.vertical(|ui| {
for k in CornerKind::ALL {
let name = match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
};
if widgets::radio(ui, name, Some(k) == current, true) {
if widgets::radio(ui, kind_label(k), Some(k) == current, true) {
d.fields.insert("kind".into(), json!(k));
}
}
@@ -81,6 +76,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
false
}
/// A corner kind's name, in the UI language.
pub(crate) fn kind_label(k: CornerKind) -> &'static str {
match k {
CornerKind::Round => tl!("Round"),
CornerKind::InvertedRound => tl!("Inverted Round"),
CornerKind::Chamfer => tl!("Chamfer"),
}
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = json!({"id": d.fields.get("id"), "corners": d.fields.get("corners")});
if let Some(k) = d.fields.get("kind") {
+203
View File
@@ -0,0 +1,203 @@
//! Flare Tool Options (double-click the Flare tool, or click with it): the options the Flare tool
//! keeps, which draw the next flare dragged out, last across tool switches and are saved with the
//! preferences. OK sets them with `tool.setOption {tool: "flare", values}`; opened by a click, it
//! then draws a flare there with them (`shape.flare`, on the active perspective plane while the
//! grid shows).
//!
//! Fields: the options (`vectorcraft_tools::extra::FLARE_OPTIONS`): `diameter` and `pathLength`
//! (points), `opacity`, `brightness`, `growth`, `fuzziness`, `longest`, `rayFuzziness` and
//! `largest` (%), `rays` and `rings` (counts), `direction` (degrees); `raysOn` and `ringsOn`; and
//! `x`, `y` where a click opened it.
use serde_json::{Map, Value, json};
use vectorcraft_geom::Point;
use vectorcraft_tools::extra::{FLARE_OPTIONS, flare_params};
use super::document_setup::check;
use super::{DialogResult, DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the Flare Tool Options.
pub const KIND: &str = "flareOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Flare Tool Options").into(), body, confirm, min_width: 440.0, ..DialogSpec::FORM };
/// Width of the label column and of the fields.
const LABEL_W: f32 = 78.0;
const FIELD_W: f32 = 90.0;
/// Open the Flare Tool Options with the tool's values; `at`: where a click opened them, to draw a
/// flare there on OK.
pub fn open(app: &mut VectorcraftApp, at: Option<Point>) -> Value {
let mut fields = match app.session.tool_options_of("flare") {
Value::Object(o) => o,
_ => Map::new(),
};
if let Some(p) = at {
fields.insert("x".into(), json!(p.x));
fields.insert("y".into(), json!(p.y));
}
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
json!({ "dialog": KIND })
}
/// A row for option `key`, kept within its range: a distance in `unit`, else a number.
fn field(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit, (key, label): (&str, &str)) {
let Some(&(_, default, lo, hi)) = FLARE_OPTIONS.iter().find(|(k, ..)| *k == key) else { return };
widgets::label_row(ui, label, LABEL_W, |ui| match key {
"diameter" | "pathLength" => {
if form::length(ui, d, key, unit, FIELD_W) {
let v = d.f64(key, default).clamp(lo, hi);
d.fields.insert(key.into(), json!(v));
}
}
_ => {
let suffix = match key {
"rays" | "rings" => "",
"direction" => "°",
_ => "%",
};
if let Some(v) = widgets::range_field(ui, ("flare", key), d.f64(key, default), lo..=hi, suffix, 0, FIELD_W) {
d.fields.insert(key.into(), json!(v));
}
}
});
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
// Center and Halo, then Rays and Rings, side by side as two rows of groups.
egui::Grid::new("flare-groups").num_columns(2).spacing([28.0, 14.0]).show(ui, |ui| {
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Center"));
for f in [("diameter", tl!("Diameter:")), ("opacity", tl!("Opacity:")), ("brightness", tl!("Brightness:"))] {
field(ui, d, unit, f);
}
});
ui.vertical(|ui| {
widgets::subheader(ui, tl!("Halo"));
for f in [("growth", tl!("Growth:")), ("fuzziness", tl!("Fuzziness:"))] {
field(ui, d, unit, f);
}
});
ui.end_row();
ui.vertical(|ui| {
check(ui, d, "raysOn", tl!("Rays"));
let on = d.bool("raysOn");
ui.add_enabled_ui(on, |ui| {
for f in [("rays", tl!("Number:")), ("longest", tl!("Longest:")), ("rayFuzziness", tl!("Fuzziness:"))] {
field(ui, d, unit, f);
}
});
});
ui.vertical(|ui| {
check(ui, d, "ringsOn", tl!("Rings"));
let on = d.bool("ringsOn");
ui.add_enabled_ui(on, |ui| {
for f in [("pathLength", tl!("Path:")), ("rings", tl!("Number:")), ("largest", tl!("Largest:")), ("direction", tl!("Direction:"))] {
field(ui, d, unit, f);
}
});
});
ui.end_row();
});
false
}
/// OK: set the tool's options; opened by a click, draw a flare there with them.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
let mut values: Map<String, Value> =
FLARE_OPTIONS.iter().filter(|(k, ..)| d.fields.contains_key(*k)).map(|(k, default, ..)| (k.to_string(), json!(d.f64(k, *default)))).collect();
for key in ["raysOn", "ringsOn"] {
values.insert(key.into(), json!(d.bool(key)));
}
let at = d.fields.get("x").and_then(Value::as_f64).zip(d.fields.get("y").and_then(Value::as_f64)).map(|(x, y)| Point::new(x, y));
let options = run_and_close(app, "tool.setOption", json!({ "tool": "flare", "values": values }))?;
let Some(at) = at else { return Ok(options) };
let params = flare_params(&options, at);
match vectorcraft_engine::perspective_click(&app.session, "shape.flare", &params, at) {
Some((id, p)) => app.run(&id, p),
None => app.run("shape.flare", params),
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
use vectorcraft_engine::doc::NodeKind;
use vectorcraft_tools::{PointerEvent, PointerKind};
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 600, "height": 600})).unwrap();
app
}
/// The labels the open dialog paints.
fn painted(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
/// The children of the newest flare (a group named Flare), by name.
fn flare_parts(app: &VectorcraftApp) -> Vec<String> {
let doc = &app.session.doc().unwrap().doc;
let flare = doc.layers[0].children().unwrap().iter().rev().find(|n| n.name.as_deref() == Some("Flare")).expect("a flare");
let NodeKind::Group { children, .. } = &flare.kind else { panic!("a flare is a group") };
children.iter().filter_map(|c| c.name.clone()).collect()
}
#[test]
fn the_toolbar_opens_the_options_and_ok_keeps_them_for_the_next_drag() {
let mut app = app();
app.run("tool.options", json!({"tool": "flare"})).unwrap();
let text = painted(&mut app);
for label in ["Flare Tool Options", "Center", "Diameter:", "Halo", "Growth:", "Rays", "Longest:", "Rings", "Path:", "Largest:", "Direction:"]
{
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.f64("diameter", 0.0), d.f64("rays", 0.0), d.bool("raysOn"), d.f64("direction", 0.0)), (100.0, 15.0, true, 45.0));
for (k, v) in [("rays", json!(4)), ("ringsOn", json!(false)), ("growth", json!(80))] {
d.fields.insert(k.into(), v);
}
let objects = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(objects(&app), 0, "OK from the toolbar draws nothing");
let o = app.session.tool_options_of("flare");
assert_eq!((o["rays"].as_u64(), o["ringsOn"].as_bool(), o["growth"].as_f64()), (Some(4), Some(false), Some(80.0)));
// A drag draws with them: four rays, no rings.
app.select_tool("flare");
let view = app.view_info();
for (kind, x, y) in [(PointerKind::Down, 200.0, 200.0), (PointerKind::Drag, 240.0, 200.0), (PointerKind::Up, 240.0, 200.0)] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, x, y), view);
}
for kind in [PointerKind::Down, PointerKind::Up] {
crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 400.0, 400.0), view);
}
let parts = flare_parts(&app);
assert!(!parts.iter().any(|p| p == "Ring"), "{parts:?}");
let doc = &app.session.doc().unwrap().doc;
let flare = doc.layers[0].children().unwrap().last().unwrap();
let rays = flare.children().unwrap().iter().find(|c| c.name.as_deref() == Some("Rays")).unwrap();
assert_eq!(rays.path_data().unwrap().subpaths.len(), 4);
}
#[test]
fn a_click_fills_in_the_options_and_draws_a_flare_there() {
let mut app = app();
app.run("tool.setOption", json!({"tool": "flare", "values": {"rings": 3}})).unwrap();
crate::dialogs::open_tool_dialog(&mut app, "flare", json!({"x": 300, "y": 300}));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.kind.as_str(), d.f64("rings", 0.0), d.f64("x", 0.0)), (KIND, 3.0, 300.0));
super::super::confirm(&mut app).unwrap();
let parts = flare_parts(&app);
assert_eq!(parts.iter().filter(|p| *p == "Ring").count(), 3, "{parts:?}");
// Out-of-range values are kept within the option's range.
app.run("tool.setOption", json!({"tool": "flare", "values": {"opacity": 900, "diameter": -4}})).unwrap();
let o = app.session.tool_options_of("flare");
assert_eq!((o["opacity"].as_f64(), o["diameter"].as_f64()), (Some(100.0), Some(0.0)));
}
}
+6 -2
View File
@@ -8,7 +8,7 @@
mod about;
mod all_tools;
mod artboard_options;
pub mod artboard_options;
pub mod blend_options;
pub mod brush_options;
pub mod color_balance;
@@ -29,6 +29,7 @@ mod export_as;
mod export_for_screens;
pub mod eyedropper;
pub mod file_info;
pub mod flare_options;
pub mod flatten;
pub mod flattener_presets;
mod form;
@@ -78,6 +79,7 @@ pub mod spot_colors;
pub(crate) mod svg_options;
pub mod swatch_conflict;
pub mod swatch_options;
pub mod symbolism_options;
mod text_export;
pub mod text_import;
mod tiff_bmp_tga;
@@ -204,7 +206,7 @@ registry! {
Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC,
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
ArtboardOptions: ["artboardOptions"] => artboard_options::SPEC,
ArtboardOptions: [artboard_options::KIND] => artboard_options::SPEC,
AllTools: ["allTools"] => all_tools::SPEC,
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
Recolor: [recolor::KIND] => recolor::SPEC,
@@ -276,6 +278,8 @@ registry! {
Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC,
LiquifyOptions: [liquify::KIND] => liquify::SPEC,
FreehandOptions: [freehand::KIND] => freehand::SPEC,
FlareOptions: [flare_options::KIND] => flare_options::SPEC,
SymbolismOptions: [symbolism_options::KIND] => symbolism_options::SPEC,
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
@@ -0,0 +1,81 @@
//! Symbolism Tools Options (double-click a Symbolism tool): the brush the eight tools share, which
//! lasts across tool switches and is saved with the preferences. OK sets it with
//! `tool.setOption {tool, values}`.
//!
//! Fields: `tool`; `diameter` (points), `intensity` and `density` (Symbol Set Density), 1–10.
use serde_json::{Map, Value, json};
use super::{DialogResult, DialogSpec, form, run_and_close};
use crate::state::Dialog;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of the Symbolism Tools Options.
pub const KIND: &str = "symbolismOptions";
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |_| tl!("Symbolism Tools Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM };
/// The options the dialog edits.
const KEYS: [&str; 3] = ["diameter", "intensity", "density"];
/// Width of the label column.
const LABEL_W: f32 = 130.0;
/// Open the options of Symbolism tool `tool` (one of `vectorcraft_tools::settings::SYMBOLISM`)
/// with their current values.
pub fn open(app: &mut VectorcraftApp, tool: &str) -> Value {
let o = app.session.tool_options_of(tool);
let mut fields: Map<String, Value> = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect();
fields.insert("tool".into(), json!(tool));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
json!({ "dialog": KIND })
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let unit = app.session.general_unit();
widgets::label_row(ui, tl!("Diameter:"), LABEL_W, |ui| {
form::length(ui, d, "diameter", unit, 100.0);
});
for (key, label) in [("intensity", tl!("Intensity:")), ("density", tl!("Symbol Set Density:"))] {
widgets::label_row(ui, label, LABEL_W, |ui| {
if let Some(v) = widgets::range_field(ui, ("symbolism", key), d.f64(key, 5.0), 1.0..=10.0, "", 0, 100.0) {
d.fields.insert(key.into(), json!(v));
}
});
}
false
}
/// OK: set the brush the Symbolism tools share (the tool keeps it within its ranges).
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
let values: Map<String, Value> = KEYS.iter().filter(|k| d.fields.contains_key(**k)).map(|k| (k.to_string(), json!(d.f64(k, 0.0)))).collect();
run_and_close(app, "tool.setOption", json!({ "tool": d.str("tool"), "values": values }))
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
#[test]
fn the_symbolism_tools_share_the_options_ok_sets() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("tool.options", json!({"tool": "symbolSizer"})).unwrap();
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx()));
for label in ["Symbolism Tools Options", "Diameter:", "Intensity:", "Symbol Set Density:"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.str("tool"), d.f64("diameter", 0.0), d.f64("intensity", 0.0)), ("symbolSizer".to_string(), 80.0, 5.0));
for (k, v) in [("diameter", json!(120)), ("intensity", json!(9)), ("density", json!(2))] {
d.fields.insert(k.into(), v);
}
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
// The Sprayer has them too: one brush for the eight tools.
let o = app.session.tool_options_of("symbolSprayer");
assert_eq!((o["diameter"].as_f64(), o["intensity"].as_f64(), o["density"].as_f64()), (Some(120.0), Some(9.0), Some(2.0)));
}
}
+5 -6
View File
@@ -26,12 +26,11 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
Ok(v) => Dialog::new("command", json!({"__command": "graph.setData", "__label": "Graph Data", "csv": v["csv"]})),
Err(_) => return,
},
// Flare Tool Options (Center / Halo / Rays / Rings), applied through the generic command dialog.
"flare" => Dialog::new(
"command",
json!({"__command": "shape.flare", "__label": "Flare Tool Options", "cx": x, "cy": y, "diameter": 100, "opacity": 50, "brightness": 30,
"growth": 20, "fuzziness": 50, "rays": 15, "longest": 300, "rayFuzziness": 100, "pathLength": 300, "rings": 10, "largest": 50, "direction": 45}),
),
// A Flare tool click: the Flare Tool Options, with the tool's values; OK draws a flare there.
"flare" => {
super::flare_options::open(app, Some(vectorcraft_geom::Point::new(x, y)));
return;
}
"rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => {
let mut base = match kind {
"rotate" => json!({"angle": 0}),
+16
View File
@@ -228,6 +228,8 @@
Auto-Collapse Iconic Panels Contraer automáticamente los paneles de iconos
Ellipse Properties: Propiedades de la elipse:
Invert Pie Invertir sector
Side Length: Longitud del lado:
Make Sides Equal Igualar los lados
Pie End Angle: Ángulo final del sector:
Pie Start Angle: Ángulo inicial del sector:
Show Tool Group Labels Mostrar etiquetas de grupos de herramientas
@@ -988,6 +990,20 @@
Flare Destello
Flare Tool Herramienta Destello
Flare Tool Options Opciones de la herramienta Destello
Halo Halo
Rays Rayos
Rings Anillos
Diameter: Diámetro:
Brightness: Brillo:
Growth: Crecimiento:
Fuzziness: Desenfoque:
Number: Número:
Longest: Más largo:
Path: Trazado:
Largest: Más grande:
Direction: Dirección:
Symbolism Tools Options Opciones de las herramientas de símbolos
Symbol Set Density: Densidad del conjunto de símbolos:
Flat Image Imagen acoplada
Flat Logo Logotipo plano
Flatness: Lisura:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -229,6 +229,8 @@
Auto-Collapse Iconic Panels Réduire automatiquement les panneaux en icônes
Ellipse Properties: Propriétés de l’ellipse :
Invert Pie Inverser le secteur
Side Length: Longueur du côté :
Make Sides Equal Égaliser les côtés
Pie End Angle: Angle de fin du secteur :
Pie Start Angle: Angle de début du secteur :
Show Tool Group Labels Afficher les libellés des groupes d'outils
@@ -987,6 +989,20 @@
Flare Halo
Flare Tool Outil Halo
Flare Tool Options Options de l’outil Halo
Halo Halo
Rays Rayons
Rings Anneaux
Diameter: Diamètre :
Brightness: Luminosité :
Growth: Croissance :
Fuzziness: Flou :
Number: Nombre :
Longest: Le plus long :
Path: Tracé :
Largest: Le plus grand :
Direction: Direction :
Symbolism Tools Options Options des outils de symbole
Symbol Set Density: Densité du jeu de symboles :
Flat Image Image aplatie
Flat Logo Logo en aplats
Flatness: Planéité :
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -228,6 +228,8 @@
Auto-Collapse Iconic Panels Comprimi automaticamente i pannelli a icona
Ellipse Properties: Proprietà dell’ellisse:
Invert Pie Inverti settore
Side Length: Lunghezza del lato:
Make Sides Equal Rendi uguali i lati
Pie End Angle: Angolo finale del settore:
Pie Start Angle: Angolo iniziale del settore:
Show Tool Group Labels Mostra etichette dei gruppi di strumenti
@@ -987,6 +989,20 @@
Flare Riflesso
Flare Tool Strumento Riflesso
Flare Tool Options Opzioni strumento Riflesso
Halo Alone
Rays Raggi
Rings Anelli
Diameter: Diametro:
Brightness: Luminosità:
Growth: Crescita:
Fuzziness: Sfocatura:
Number: Numero:
Longest: Più lungo:
Path: Tracciato:
Largest: Più grande:
Direction: Direzione:
Symbolism Tools Options Opzioni strumenti simboli
Symbol Set Density: Densità del set di simboli:
Flat Image Immagine appiattita
Flat Logo Logo piatto
Flatness: Planarità:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -354,6 +354,8 @@
Fill & Stroke 塗りと線
Fill Color カラー (塗り)
Invert Pie 扇形を反転
Side Length: 辺の長さ:
Make Sides Equal 辺の長さをそろえる
Opacity 不透明度
Pie End Angle: 扇形の終了角度:
Pie Start Angle: 扇形の開始角度:
@@ -2857,6 +2859,20 @@
Fill: click for swatches, Shift-click for the color mixer 塗り: クリックでスウォッチ、Shift キーを押しながらクリックでカラーミキサー
Fit in View 表示に合わせる
Flare Tool Options フレアツールオプション
Halo ハロー
Rays 光線
Rings リング
Diameter: 直径:
Brightness: 明るさ:
Growth: 拡大:
Fuzziness: ぼかし:
Number: 数:
Longest: 最長:
Path: パス:
Largest: 最大:
Direction: 方向:
Symbolism Tools Options シンボルツールオプション
Symbol Set Density: シンボルセットの密度:
Flattener presets 分割・統合プリセット
Focal Fill 焦点の塗り
Focal Stroke 焦点の線
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -226,6 +226,8 @@
Auto-Collapse Iconic Panels Автоматически сворачивать значки панелей
Ellipse Properties: Свойства эллипса:
Invert Pie Инвертировать сектор
Side Length: Длина стороны:
Make Sides Equal Выровнять стороны
Pie End Angle: Конечный угол сектора:
Pie Start Angle: Начальный угол сектора:
Show Tool Group Labels Показывать названия групп инструментов
@@ -985,6 +987,20 @@
Flare Блик
Flare Tool Инструмент «Блик»
Flare Tool Options Параметры инструмента «Блик»
Halo Ореол
Rays Лучи
Rings Кольца
Diameter: Диаметр:
Brightness: Яркость:
Growth: Рост:
Fuzziness: Размытие:
Number: Количество:
Longest: Самый длинный:
Path: Контур:
Largest: Самое большое:
Direction: Направление:
Symbolism Tools Options Параметры инструментов символов
Symbol Set Density: Плотность набора символов:
Flat Image Плоское изображение
Flat Logo Плоский логотип
Flatness: Плоскостность:
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -222,6 +222,8 @@
Auto-Collapse Iconic Panels Автоматично згортати значки панелей
Ellipse Properties: Властивості еліпса:
Invert Pie Інвертувати сектор
Side Length: Довжина сторони:
Make Sides Equal Вирівняти сторони
Pie End Angle: Кінцевий кут сектора:
Pie Start Angle: Початковий кут сектора:
Show Tool Group Labels Показувати назви груп інструментів
@@ -981,6 +983,20 @@
Flare Відблиск
Flare Tool Інструмент «Відблиск»
Flare Tool Options Параметри інструменту «Відблиск»
Halo Ореол
Rays Промені
Rings Кільця
Diameter: Діаметр:
Brightness: Яскравість:
Growth: Зростання:
Fuzziness: Розмиття:
Number: Кількість:
Longest: Найдовший:
Path: Контур:
Largest: Найбільше:
Direction: Напрямок:
Symbolism Tools Options Параметри інструментів символів
Symbol Set Density: Щільність набору символів:
Flat Image Плоске зображення
Flat Logo Плоский логотип
Flatness: Площинність:
Can't render this file because it contains an unexpected character in line 634 and column 40.
+16
View File
@@ -211,6 +211,8 @@
Auto-Collapse Iconic Panels 自动折叠图标面板
Ellipse Properties: 椭圆属性:
Invert Pie 反转扇形
Side Length: 边长:
Make Sides Equal 使各边等长
Pie End Angle: 扇形终止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 显示工具组标签
@@ -965,6 +967,20 @@
Flare 光晕
Flare Tool 光晕工具
Flare Tool Options 光晕工具选项
Halo 光晕
Rays 射线
Rings 光环
Diameter: 直径:
Brightness: 亮度:
Growth: 增大:
Fuzziness: 模糊度:
Number: 数量:
Longest: 最长:
Path: 路径:
Largest: 最大:
Direction: 方向:
Symbolism Tools Options 符号工具选项
Symbol Set Density: 符号组密度:
Flat Image 平面图像
Flatness: 平滑度:
Flatten Transparency 拼合透明度
Can't render this file because it contains an unexpected character in line 2 and column 73.
+16
View File
@@ -222,6 +222,8 @@
Auto-Collapse Iconic Panels 自動收合圖示面板
Ellipse Properties: 橢圓屬性:
Invert Pie 反轉扇形
Side Length: 邊長:
Make Sides Equal 使各邊等長
Pie End Angle: 扇形終止角度:
Pie Start Angle: 扇形起始角度:
Show Tool Group Labels 顯示工具組標籤
@@ -988,6 +990,20 @@
Flare 光暈
Flare Tool 光暈工具
Flare Tool Options 光暈工具選項
Halo 光暈
Rays 射線
Rings 光環
Diameter: 直徑:
Brightness: 亮度:
Growth: 增大:
Fuzziness: 模糊度:
Number: 數量:
Longest: 最長:
Path: 路徑:
Largest: 最大:
Direction: 方向:
Symbolism Tools Options 符號工具選項
Symbol Set Density: 符號組密度:
Flat Image 平面影像
Flatness: 平滑度:
Flatten Artwork 將圖稿平面化
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11 -7
View File
@@ -353,7 +353,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"tool.options",
"Tool Options…",
"",
"{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values})",
"{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height; OK runs artboard.setProps), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values})",
),
(
"ui.colorGuideOptions",
@@ -2964,6 +2964,14 @@ fn to_field(ctx: &egui::Context, field: egui::Id, e: egui::Event) {
}
/// Invoke a menu/command id with UI side effects (dialogs for "…" commands that need input).
/// Object › Graph › Type… or Data… (`id`): its dialog, with the selected graph's current values
/// (the command's query).
pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result<Value, String> {
let v = app.session.execute(id, &json!({})).map_err(|e| e.to_string())?;
let (label, fields) = if id == "graph.setData" { ("Graph Data", json!({"csv": v["csv"]})) } else { ("Graph Type", v) };
app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})).map(|_| json!({ "dialog": "command" }))
}
pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
if waits_for_ime(app, id) {
return;
@@ -3007,12 +3015,8 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
}
// Object → Graph → Type… / Data…: dialogs with the selected graph's current values.
if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute(id, &json!({})) {
Ok(v) => {
let (label, fields) = if id == "graph.setData" { ("Graph Data", json!({"csv": v["csv"]})) } else { ("Graph Type", v) };
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
}
Err(e) => app.status(e.to_string()),
if let Err(e) = graph_dialog(app, id) {
app.status(e);
}
return;
}
+17 -8
View File
@@ -48,6 +48,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{LiveCorners, Node, StrokeLayer};
use vectorcraft_engine::inspect::StrokeMixed;
use vectorcraft_geom::shapes::CornerKind;
use crate::theme::Tokens;
use crate::widgets::{Live, dim_label};
@@ -61,13 +62,15 @@ pub fn first_selected(app: &VectorcraftApp) -> Option<Node> {
/// The radius the Live Corners of path `n` show in the panels: that of the corners the panels set
/// (the Direct-Selected ones, else every corner), blank when they differ.
pub(crate) fn corner_radius(app: &VectorcraftApp, n: &Node) -> Option<f64> {
corner_style(app, n).0
}
/// The radius and kind of the corners [`corner_radius`] reads, each None when they differ.
pub(crate) fn corner_style(app: &VectorcraftApp, n: &Node) -> (Option<f64>, Option<CornerKind>) {
let partial = app.session.active().and_then(|d| d.selection.partial(n.id));
let corners = LiveCorners::of(n)?;
corners.style(&corners.picked(partial)).0
LiveCorners::of(n).map_or((None, None), |c| c.style(&c.picked(partial)))
}
/// The Corner Radius field of a live rectangle's or polygon's properties: [`corner_radius`],
/// which a new value sets on those corners.
/// The width of a panel's number fields: the Transform fields', two to a row beside the reference
/// point, their labels and the W/H link, so every field in the panel is as wide (#696). Measured
/// from the full row, before its widgets.
@@ -77,17 +80,23 @@ pub(crate) fn field_width(ui: &Ui) -> f32 {
((ui.available_width() - AROUND) / 2.0).clamp(60.0, 110.0)
}
/// The Corner Radius field of a live rectangle's properties: [`corner_radius`], which a new value
/// sets on those corners.
pub(crate) fn corner_radius_row(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str) {
let units = app.session.general_unit();
let fw = field_width(ui);
ui.horizontal(|ui| {
dim_label(ui, tl!("Corner Radius:"));
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), units, fw) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
corner_radius_field(app, ui, n, id, fw);
});
}
/// [`corner_radius_row`]'s field, `width` wide.
pub(crate) fn corner_radius_field(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: impl std::hash::Hash + std::fmt::Debug, width: f32) {
if let Some(r) = crate::widgets::num_field(ui, id, corner_radius(app, n), app.session.general_unit(), width) {
app.run("object.setLiveShape", json!({"radius": r})).ok();
}
}
/// Number of selected objects.
pub(crate) fn selection_len(app: &VectorcraftApp) -> usize {
app.session.active().map(|d| d.selection.len()).unwrap_or(0)
+1 -10
View File
@@ -447,16 +447,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
{
match live {
vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"),
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
let fw = super::field_width(ui);
ui.horizontal(|ui| {
dim_label(ui, tl!("Sides:"));
if let Some(s) = widgets::plain_field(ui, "sides", *sides as f64, "", 0, fw) {
app.run("object.setLiveShape", json!({"sides": s as u32})).ok();
}
});
corner_radius_row(app, ui, &n, "radius");
}
vectorcraft_doc::LiveShape::Polygon { .. } => super::transform::polygon_rows(app, ui, &n, live, "props-polygon"),
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"),
_ => {}
}
+80 -9
View File
@@ -4,10 +4,12 @@
use egui::Ui;
use serde_json::json;
use vectorcraft_doc::NodeKind;
use vectorcraft_doc::{LiveShape, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_geom::shapes::CornerKind;
use super::{corner_radius_row, first_selected, pstate, set_pstate};
use crate::dialogs::corners::kind_label as corner_kind;
use crate::theme::Tokens;
use crate::widgets::{self, menu_item};
use crate::{VectorcraftApp, icons};
@@ -182,15 +184,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::subheader(ui, tl!("Rectangle Properties:"));
corner_radius_row(app, ui, &n, "xfp-radius");
}
vectorcraft_doc::LiveShape::Polygon { sides, .. } => {
vectorcraft_doc::LiveShape::Polygon { .. } => {
widgets::subheader(ui, tl!("Polygon Properties:"));
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Sides:"));
if let Some(s) = widgets::plain_field(ui, "xfp-sides", *sides as f64, "", 0, 60.0) {
app.run("object.setLiveShape", json!({"sides": s.clamp(3.0, 20.0) as u32})).ok();
}
});
corner_radius_row(app, ui, &n, "xfp-radius");
polygon_rows(app, ui, &n, live, "xfp-polygon");
}
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => {
widgets::subheader(ui, tl!("Ellipse Properties:"));
@@ -231,6 +227,56 @@ pub(crate) fn pie_rows(app: &mut VectorcraftApp, ui: &mut Ui, pie: (f64, f64), i
}
}
/// A live polygon's Polygon Properties (Transform and Properties panels): its side count, angle
/// (counterclockwise), corner type and radius, radius (centre to vertex) and side length, and Make
/// Sides Equal, which an uneven scale enables.
pub(crate) fn polygon_rows(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, live: &LiveShape, id: &str) {
let LiveShape::Polygon { sides, .. } = live else { return };
let (units, fw) = (app.session.general_unit(), super::field_width(ui));
let radius = live.polygon_radius();
let side = radius.map(|r| 2.0 * r * (std::f64::consts::PI / f64::from((*sides).max(3))).sin());
let kind = super::corner_style(app, n).1;
let kinds: Vec<&str> = CornerKind::ALL.iter().map(|k| corner_kind(*k)).collect();
// The `object.setLiveShape` param an edited field sets.
let mut set = None;
egui::Grid::new((id, "grid")).num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| {
widgets::dim_label(ui, tl!("Sides:"));
if let Some(s) = widgets::plain_field(ui, (id, "sides"), f64::from(*sides), "", 0, fw) {
set = Some(("sides", json!(s.round().max(3.0) as u64)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Angle:"));
if let Some(a) = widgets::plain_field(ui, (id, "angle"), live.polygon_angle().unwrap_or(0.0), "°", 1, fw) {
set = Some(("polygonAngle", json!(a)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Corner:"));
if let Some(k) = widgets::dropdown_names(ui, (id, "kind"), kind.map_or("", corner_kind), &kinds, fw).and_then(|i| CornerKind::ALL.get(i)) {
set = Some(("kind", json!(k)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Corner Radius:"));
super::corner_radius_field(app, ui, n, (id, "cornerRadius"), fw);
ui.end_row();
widgets::dim_label(ui, tl!("Radius:"));
if let Some(r) = widgets::num_field(ui, (id, "radius"), radius, units, fw) {
set = Some(("polygonRadius", json!(r)));
}
ui.end_row();
widgets::dim_label(ui, tl!("Side Length:"));
if let Some(l) = widgets::num_field(ui, (id, "side"), side, units, fw) {
set = Some(("sideLength", json!(l)));
}
ui.end_row();
});
if ui.add_enabled_ui(!live.polygon_sides_equal(), |ui| widgets::flat_button(ui, tl!("Make Sides Equal"), 130.0)).inner.clicked() {
set = Some(("makeSidesEqual", json!(true)));
}
if let Some((key, v)) = set {
app.run("object.setLiveShape", json!({ key: v })).ok();
}
}
/// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a
/// popover under it, as the Stroke link does with the Stroke panel.
pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -335,6 +381,31 @@ mod tests {
assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}");
}
/// #812: a selected live polygon shows its Polygon Properties in the Transform panel and the
/// Properties panel's Transform section, and Make Sides Equal evens it out after a stretch.
#[test]
fn a_polygon_shows_its_polygon_properties() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.polygon", json!({"cx": 150, "cy": 150, "radius": 50, "sides": 6})).unwrap();
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
let shown = texts(&mut app, draw);
// A hexagon's side is as long as its radius.
for t in ["Sides:", "6", "Angle:", "0°", "Corner:", "Round", "Corner Radius:", "Radius:", "Side Length:", "50 pt", "Make Sides Equal"] {
assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}");
}
}
assert!(texts(&mut app, show).iter().any(|s| s == "Polygon Properties:"));
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
let live = |app: &VectorcraftApp| match &first_selected(app).unwrap().kind {
NodeKind::Path { live: Some(l), .. } => l.clone(),
k => panic!("{k:?}"),
};
assert!(!live(&app).polygon_sides_equal());
app.run("object.setLiveShape", json!({"makeSidesEqual": true})).unwrap();
assert!(live(&app).polygon_sides_equal());
}
#[test]
fn the_artboard_tool_shows_the_active_artboard() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
+42 -2
View File
@@ -312,8 +312,10 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
crate::menus::invoke(app, &id, json!({}));
Ok(json!({ "dialog": dialog }))
}
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
"gradient" => app.run("window.panel", json!({ "panel": "gradient" })),
// The Gradient and Magic Wand tools: their panels.
"gradient" | "magicWand" if app.ui.open_panel.as_deref() == Some(tool) => Ok(json!({ "open": tool })),
"gradient" | "magicWand" => app.run("window.panel", json!({ "panel": tool })),
"artboard" => crate::dialogs::artboard_options::open(app),
"eyedropper" => {
crate::dialogs::eyedropper::open(app);
Ok(json!({ "dialog": crate::dialogs::eyedropper::KIND }))
@@ -326,12 +328,18 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
crate::dialogs::perspective_options::open(app);
Ok(json!({ "dialog": crate::dialogs::perspective_options::KIND }))
}
// The Flare tool: its options, which draw the next flare (OK draws none).
"flare" => Ok(crate::dialogs::flare_options::open(app, None)),
// A double click on the Print Tiling tool puts the pages back where the placement puts them.
"printTiling" => app.run("print.tiling.set", json!({ "reset": true })),
// The Liquify tools: their Tool Options (the Global Brush Dimensions and the tool's own).
_ if vectorcraft_tools::settings::LIQUIFY.contains(&tool) => crate::dialogs::liquify::open(app, tool),
// The freehand tools: their Tool Options (Fidelity, fill, the tolerances, the brush size).
_ if crate::dialogs::freehand::TOOLS.contains(&tool) => crate::dialogs::freehand::open(app, tool),
// The Symbolism tools: the brush they share.
_ if vectorcraft_tools::settings::SYMBOLISM.contains(&tool) => Ok(crate::dialogs::symbolism_options::open(app, tool)),
// The graph tools: Graph Type for the selected graph.
_ if vectorcraft_tools::extra::is_graph_tool(tool) => crate::menus::graph_dialog(app, "graph.setType"),
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
_ => Err(format!("the {tool} tool has no options")),
}
@@ -815,6 +823,38 @@ pub(crate) mod tests {
assert_eq!(frame_in(&mut app, &ctx, 8.0, vec![], 500.0)[0], tools[0]);
}
/// #812: double-clicking the Artboard tool opens Artboard Options of the active artboard, a
/// graph tool Graph Type for the selected graph, and the Magic Wand tool its panel.
#[test]
fn more_tools_open_their_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
// Without the Artboard tool, the window's artboard.
assert_eq!(open_options(&mut app, "artboard").unwrap()["dialog"], "artboardOptions");
let d = app.ui.dialog.take().unwrap();
assert_eq!((d.f64("index", 9.0), d.f64("width", 0.0), d.str("name")), (0.0, 300.0, "Artboard 1".to_string()));
// With it, its active artboard.
app.run("artboard.new", json!({"x": 400, "y": 0, "width": 100, "height": 50})).unwrap();
app.run("tool.select", json!({"tool": "artboard"})).unwrap();
app.run("tool.setOption", json!({"key": "active", "value": 1})).unwrap();
open_options(&mut app, "artboard").unwrap();
let d = app.ui.dialog.take().unwrap();
assert_eq!((d.f64("index", 9.0), d.f64("x", 0.0), d.f64("width", 0.0)), (1.0, 400.0, 100.0));
// Graph Type needs a selected graph.
app.run("select.set", json!({"ids": []})).unwrap();
assert!(open_options(&mut app, "pieGraph").is_err());
assert!(app.ui.dialog.is_none());
app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150})).unwrap();
assert_eq!(open_options(&mut app, "pieGraph").unwrap()["dialog"], "command");
let d = app.ui.dialog.take().unwrap();
assert_eq!((d.str("__command"), d.str("type")), ("graph.setType".to_string(), "column".to_string()));
// The Magic Wand panel opens, and stays open on a second double-click.
open_options(&mut app, "magicWand").unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("magicWand"));
assert_eq!(open_options(&mut app, "magicWand").unwrap()["open"], "magicWand");
assert_eq!(app.ui.open_panel.as_deref(), Some("magicWand"));
}
/// A double-click at `at`, `time` seconds in (a second apart from the last).
fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
+29 -1
View File
@@ -1340,6 +1340,19 @@ dialog `corners`: `kind`, `radius`; OK runs `object.setLiveShape`).
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"radius":5}}}
```
The same command sets the rest of a live shape's properties, as the Properties and Transform panels show them. A
polygon (Polygon Properties): `sides` (3–1000), `polygonAngle` (degrees counterclockwise, its first vertex straight up
at 0), `polygonRadius` (pt, centre to vertex), `sideLength` (pt, which sets the radius) and `makeSidesEqual: true`,
which drops an uneven scale or shear (its sides then differ) and keeps its centre, angle and mean radius; a radius or
length that isn't a number above 0 is an error. An ellipse (Ellipse Properties): `pieStart` and `pieEnd` (degrees
counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse) and `invertPie: true`, which swaps them. Each call is
one undo step.
```json
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"sides":8,"polygonAngle":22.5,"sideLength":40}}}
{"name":"run_command","arguments":{"command":"object.setLiveShape","params":{"makeSidesEqual":true}}}
```
## Use Preview Bounds
With the preference `usePreviewBounds` on (`prefs.set {key: "usePreviewBounds", value: true}`; also the Align panel
@@ -2672,13 +2685,28 @@ active tool, `tool` names another one, and `{}` just reads them; the result is t
tool keeps last across tool switches (and documents) and are saved with the preferences (`toolSettings` in
`prefs.get`'s full object), as the reference app keeps them: the Liquify tools' brush and tool options, Mirror & Cut's
axis and side, Puppet Warp's mesh, the Symbolism brush, the line, grid, pencil, brush and eraser tools' options,
polygon sides and star points. The Liquify tools share one set of Global Brush Dimensions (width, height, angle,
polygon sides and star points, and the Flare Tool Options. The Liquify tools share one set of Global Brush Dimensions (width, height, angle,
intensity), and the Symbolism tools one brush. Interaction state (pins, a reference point) starts afresh.
```json
{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"twirl","values":{"width":60,"rate":90}}}}
```
`tool.options {tool}` opens what double-clicking the tool's button opens (its params doc lists every tool), in the
desktop app. The Flare tool's are its Flare Tool Options (dialog `flareOptions`): `diameter` and `pathLength` (pt),
`opacity`, `brightness`, `growth`, `fuzziness`, `longest`, `rayFuzziness` and `largest` (%), `rays`, `rings`,
`direction` (°), and `raysOn` and `ringsOn` (the Rays and Rings checkboxes; off, the flare has none). OK keeps them
(`tool.setOption {tool: "flare", values}`) and the next flare dragged out uses them; a click with the tool opens the
same dialog, and its OK also draws a flare there. The Artboard tool's are the active artboard's Artboard Options
(dialog `artboardOptions`; the Artboard tool's active artboard while it is in use, else the window's), a graph tool's
the selected graph's Graph Type (an error without one), a Symbolism tool's the Symbolism Tools Options (dialog
`symbolismOptions`: `diameter` in points, `intensity` and `density` 1–10, one brush for the eight tools) and the
Magic Wand tool's its panel.
```json
{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"flare","values":{"rays":24,"ringsOn":false}}}}
```
## Perspective grid
The grid is document data (`perspective.grid.set` edits the model directly). `perspective.grid.get` reads it: