mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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:
+3
-3
File diff suppressed because one or more lines are too long
+84
-1
@@ -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 };
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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", ¶ms, 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)));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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}),
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user