Files
vectorcraft/crates/tools/src/addons/parametric_shape.rs
T
bflatastic 27ed249b1f Add-ons: Parametric Shape Tool — re-editable stars, polygons, rings, arrows, gears and more
Path Precision Tools gains the Parametric Shape Tool: ten kinds of shape
(rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear,
speech bubble) that stay editable as numbers.

- Geometry in vectorcraft_pathops::addons::pathtools::shapes: each kind's
  outline, corner rounding limited to the middle of a side, a fingerprint
  of the outline, recovery of centre, size and rotation after a move,
  turn or scale, and recognition of plain rectangles, ellipses and
  regular polygons.
- The tool (parametricShape, All Tools drawer and Object › Plug-ins ›
  Path Precision Tools only, no shortcut): drag from the centre (Shift:
  45° steps), arrow keys change the count and values while dragging,
  handles edit a selected shape, double-click places one.
- Commands addon.pathtools.shape.create / .set / .convert, one undo step
  each; params checked (counts 3..1000, sizes up to 16384 pt).
- A shape keeps its numbers in one object-data entry (vc-pathtools-shape)
  that SVG export already leaves out; anchor edits make it a plain path.
- Parametric Shape panel, cursor stand-in, catalog rows in all 25
  languages, docs and tests.
2026-10-30 20:55:14 +07:00

621 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Parametric Shape Tool (Path Precision Tools): shapes that stay editable as numbers. A drag from
//! the centre draws one (`addon.pathtools.shape.create`): the drag's length is its size and its
//! direction its rotation (Shift: 45° steps); ↑/↓ change the kind's count or first value while
//! dragging, ←/→ its second value or corner radius. A click selects a parametric shape and shows
//! its handles (centre, size, rotation, corner radius and one of the kind's own), whose drags run
//! `addon.pathtools.shape.set`; a double-click on the canvas places one with the last settings.
//! Each gesture is one undo step.
//!
//! A parametric shape is an ordinary path that keeps its numbers in one entry of its object data
//! ([`KEY`]) with a fingerprint of the outline they drew. Moved, turned or scaled by any tool it
//! stays editable ([`ParametricShape::recover`]); once its anchors are edited it is a plain path.
use serde_json::{Value, json};
use vectorcraft_doc::hit::hit_test;
use vectorcraft_doc::{Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
use vectorcraft_pathops::addons::pathtools::shapes::fingerprint;
pub use vectorcraft_pathops::addons::pathtools::shapes::{MAX_COUNT, MAX_SIZE, MIN_COUNT, MIN_SIZE, ParametricShape, ShapeKind};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
pub const ID: &str = "parametricShape";
/// The object data entry that holds a parametric shape's numbers (JSON). The `vc-` prefix marks it
/// as VectorCraft's own, which SVG export leaves out of the `data-*` attributes it writes.
pub const KEY: &str = "vc-pathtools-shape";
/// The longest entry read back (a damaged file's longer one is no shape).
const MAX_STORED: usize = 4096;
/// The undo steps' names.
pub const CREATE: &str = "Parametric Shape";
pub const EDIT: &str = "Edit Parametric Shape";
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
/// How far (px) the rotation handle stands above the shape.
const STEM_PX: f64 = 24.0;
/// `s` as commands take it and files keep it.
pub fn to_json(s: &ParametricShape) -> Value {
json!({
"kind": s.kind.id(), "center": [s.center.x, s.center.y], "width": s.width, "height": s.height,
"rotation": s.rotation, "count": s.count, "a": s.a, "b": s.b, "radius": s.radius,
})
}
/// `s` with the numbers `p` gives (as [`to_json`] writes them; any left out stay as they are). A
/// new `kind` comes first, with its own defaults for what `p` leaves out. Every number is checked:
/// the error says which is wrong.
pub fn apply(mut s: ParametricShape, p: &Value) -> Result<ParametricShape, String> {
let given = |key: &str| p.get(key).filter(|v| !v.is_null());
let num = |key: &str| -> Result<Option<f64>, String> {
given(key).map(|v| v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("`{key}` must be a number"))).transpose()
};
if let Some(k) = given("kind") {
let kind = k.as_str().and_then(ShapeKind::parse);
s = s.with_kind(kind.ok_or("`kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble")?);
}
if let Some(c) = given("center") {
let xy = c.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
let (x, y) = xy.ok_or("`center` must be [x, y]")?;
s.center = Point::new(x, y);
}
for (key, slot) in [("width", &mut s.width), ("height", &mut s.height), ("a", &mut s.a), ("b", &mut s.b), ("radius", &mut s.radius)] {
if let Some(v) = num(key)? {
*slot = v;
}
}
if let Some(r) = num("rotation")? {
s.rotation = vectorcraft_geom::normalize_deg(r);
}
if let Some(n) = num("count")? {
if n.fract() != 0.0 || !(f64::from(MIN_COUNT)..=f64::from(MAX_COUNT)).contains(&n) {
return Err("`count` must be a whole number from 3 to 1000".into());
}
s.count = n as u32;
}
s.check().map_err(|(key, lo, hi)| format!("`{key}` must be from {lo} to {hi}"))?;
Ok(s)
}
/// What a parametric shape keeps in its object data ([`KEY`]): its numbers and the fingerprint of
/// `path`, the outline they drew.
pub fn stored(s: &ParametricShape, path: &PathData) -> String {
let mut v = to_json(s);
v["fp"] = json!(format!("{:016x}", fingerprint(path)));
v.to_string()
}
/// The parametric shape object `n` is, if it is one: a path (not a guide) with a [`KEY`] entry
/// whose fingerprint is its outline's, or which it is moved, turned or scaled. `None` for any
/// other object, a damaged entry, or a path whose anchors were edited since.
pub fn read(n: &Node) -> Option<ParametricShape> {
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
let (_, text) = n.extras().attrs.as_deref()?.data.iter().find(|(k, _)| k == KEY)?;
if text.len() > MAX_STORED {
return None;
}
let v: Value = serde_json::from_str(text).ok()?;
let s = apply(ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), &v).ok()?;
let fp = v.get("fp").and_then(Value::as_str).and_then(|f| u64::from_str_radix(f, 16).ok())?;
if fp == fingerprint(path) { Some(s) } else { s.recover(path) }
}
/// The values the arrow keys change while drawing: ↑/↓ the first, ←/→ the second.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Field {
Count,
A,
B,
Radius,
}
/// The kind's (↑/↓, ←/→) values.
fn arrow_fields(kind: ShapeKind) -> (Option<Field>, Option<Field>) {
match kind {
ShapeKind::Rectangle => (None, Some(Field::Radius)),
ShapeKind::Ellipse => (None, None),
ShapeKind::Polygon => (Some(Field::Count), Some(Field::Radius)),
ShapeKind::Star | ShapeKind::Gear => (Some(Field::Count), Some(Field::A)),
ShapeKind::Ring => (Some(Field::A), None),
ShapeKind::Arrow | ShapeKind::Cross | ShapeKind::Heart | ShapeKind::Bubble => (Some(Field::A), Some(Field::B)),
}
}
/// `s` with `field` one step up (`up`) or down: a count by one, a value by 5 % of its range, the
/// corner radius by `step` pt; each kept within its range.
fn stepped(mut s: ParametricShape, field: Field, up: bool, step: f64) -> ParametricShape {
let sign = if up { 1.0 } else { -1.0 };
let value = |v: f64, slot: usize| match s.kind.values().get(slot).copied().flatten() {
Some(r) => (v + sign * 0.05 * (r.max - r.min)).clamp(r.min, r.max),
None => v,
};
match field {
Field::Count => s.count = if up { (s.count + 1).min(MAX_COUNT) } else { s.count.saturating_sub(1).max(MIN_COUNT) },
Field::A => s.a = value(s.a, 0),
Field::B => s.b = value(s.b, 1),
Field::Radius => s.radius = (s.radius + sign * step).clamp(0.0, MAX_SIZE),
}
s
}
/// The handles of a selected shape.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Grip {
/// The centre: moves the shape.
Move,
/// The box's lower right corner: sizes it about its centre (Shift keeps its proportions).
Size,
/// Above the top: turns it (Shift: 45° steps).
Rotate,
/// On the box's top edge, the radius in from its left end: the corner radius.
Corner,
/// One of the kind's own values, where it shows.
Value,
}
/// Where the kind's own value handle is, in the shape's unit box (−1..1 each way, y down).
fn value_spot(s: &ParametricShape) -> Option<Point> {
let n = f64::from(s.count.max(1));
Some(match s.kind {
ShapeKind::Star => {
let (sn, cs) = (std::f64::consts::PI / n).sin_cos();
Point::new(s.a * sn, -s.a * cs)
}
ShapeKind::Ring => Point::new(0.0, -s.a),
ShapeKind::Gear => Point::new(0.0, -(1.0 - s.a)),
ShapeKind::Arrow => Point::new(1.0, -1.0 + 2.0 * s.a),
ShapeKind::Cross => Point::new(-1.0, s.a * (1.0 - s.b)),
ShapeKind::Heart => Point::new(0.0, -1.0 + s.a),
ShapeKind::Bubble => Point::new(s.b, 1.0),
ShapeKind::Rectangle | ShapeKind::Ellipse | ShapeKind::Polygon => return None,
})
}
/// `s` with its own value set from the handle dragged to `u` (unit box), within its range.
fn value_from(mut s: ParametricShape, u: Point) -> ParametricShape {
let clamp = |v: f64, slot: usize| s.kind.values().get(slot).copied().flatten().map_or(v, |r| v.clamp(r.min, r.max));
match s.kind {
ShapeKind::Star | ShapeKind::Ring => s.a = clamp(u.to_vec2().hypot(), 0),
ShapeKind::Gear => s.a = clamp(1.0 - u.to_vec2().hypot(), 0),
ShapeKind::Arrow => s.a = clamp((u.y + 1.0) / 2.0, 0),
ShapeKind::Cross => s.a = clamp(u.y / (1.0 - s.b).max(1e-9), 0),
ShapeKind::Heart => s.a = clamp(u.y + 1.0, 0),
ShapeKind::Bubble => s.b = clamp(u.x, 1),
ShapeKind::Rectangle | ShapeKind::Ellipse | ShapeKind::Polygon => {}
}
s
}
/// The handles of `s` and where they are on the page.
fn grips(cx: &ToolContext, s: &ParametricShape) -> Vec<(Grip, Point)> {
let (f, rx, ry) = (s.frame(), s.width / 2.0, s.height / 2.0);
let mut out = vec![(Grip::Move, s.center), (Grip::Size, f * Point::new(rx, ry)), (Grip::Rotate, f * Point::new(0.0, -ry - cx.tol(STEM_PX)))];
if s.kind.rounds() {
out.push((Grip::Corner, f * Point::new(-rx + s.radius.min(rx), -ry)));
}
if let Some(u) = value_spot(s) {
out.push((Grip::Value, f * Point::new(u.x * rx, u.y * ry)));
}
out
}
/// The angle (degrees clockwise from straight up) of `p` seen from `c`; with Shift in 45° steps.
fn angle(c: Point, p: Point, shift: bool) -> f64 {
let d = p - c;
let deg = d.x.atan2(-d.y).to_degrees();
vectorcraft_geom::normalize_deg(if shift { (deg / 45.0).round() * 45.0 } else { deg })
}
/// A drag under way.
#[derive(Clone, Debug, PartialEq)]
enum Drag {
/// Drawing a shape out from its centre.
Create { center: Point },
/// Dragging a handle of shape `id`, which was `shape` when the drag began at `from`.
Edit { id: NodeId, shape: ParametricShape, grip: Grip, from: Point },
}
pub struct ParametricShapeTool {
/// What the next shape is (its kind and numbers, and the size and rotation a double-click
/// gives it: the last drawn one's).
next: ParametricShape,
drag: Option<Drag>,
/// Where the pointer last was during the drag, and the keys held.
last: (Point, Mods),
}
impl Default for ParametricShapeTool {
fn default() -> Self {
Self { next: ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), drag: None, last: (Point::ZERO, Mods::default()) }
}
}
/// The one selected object when it is an editable parametric shape.
fn selected(cx: &ToolContext) -> Option<(NodeId, ParametricShape)> {
let [id] = cx.selection.objects.as_slice() else { return None };
let s = read(cx.doc.node(*id)?)?;
cx.doc.is_editable(*id).then_some((*id, s))
}
/// The editable parametric shape under `p`.
fn shape_at(cx: &ToolContext, p: Point) -> Option<NodeId> {
let id = hit_test(cx.doc, p, cx.hit_options())?.leaf;
(cx.doc.is_editable(id) && read(cx.doc.node(id)?).is_some()).then_some(id)
}
impl ParametricShapeTool {
/// The shape a drag to `p` with `mods` makes, and the command that makes it.
fn shaped(&self, drag: &Drag, p: Point, mods: Mods) -> (&'static str, ParametricShape, Option<NodeId>) {
let fit = |v: f64| v.clamp(MIN_SIZE, MAX_SIZE);
match *drag {
Drag::Create { center } => {
let r = center.distance(p);
let aspect = self.next.width / self.next.height;
let s = ParametricShape {
center,
width: fit(2.0 * r * aspect),
height: fit(2.0 * r),
rotation: angle(center, p, mods.shift),
..self.next
};
("addon.pathtools.shape.create", s, None)
}
Drag::Edit { id, shape: s, grip, from } => {
let local = s.frame().inverse() * p;
let (rx, ry) = (s.width / 2.0, s.height / 2.0);
let out = match grip {
Grip::Move => ParametricShape { center: s.center + (p - from), ..s },
Grip::Size if mods.shift => {
let k = (local.x.abs() / rx).max(local.y.abs() / ry);
ParametricShape { width: fit(s.width * k), height: fit(s.height * k), ..s }
}
Grip::Size => ParametricShape { width: fit(2.0 * local.x.abs()), height: fit(2.0 * local.y.abs()), ..s },
Grip::Rotate => ParametricShape { rotation: angle(s.center, p, mods.shift), ..s },
Grip::Corner => ParametricShape { radius: (local.x + rx).clamp(0.0, rx.min(MAX_SIZE)), ..s },
Grip::Value => value_from(s, Point::new(local.x / rx, local.y / ry)),
};
("addon.pathtools.shape.set", out, Some(id))
}
}
}
/// The preview of the drag under way at the pointer's last place.
fn preview(&self) -> Option<Action> {
let drag = self.drag.as_ref()?;
let (cmd, s, id) = self.shaped(drag, self.last.0, self.last.1);
let mut p = to_json(&s);
if let Some(id) = id {
p["id"] = json!(id.0);
}
Some(Action::Preview(cmd.into(), p))
}
}
impl Tool for ParametricShapeTool {
fn id(&self) -> &'static str {
ID
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => vec![],
PointerKind::Down => {
let tol = cx.point_tol();
let grip = selected(cx).and_then(|(id, s)| {
let near = grips(cx, &s).into_iter().filter(|(_, at)| at.distance(ev.pos) <= tol);
near.min_by(|a, b| a.1.distance(ev.pos).total_cmp(&b.1.distance(ev.pos))).map(|(g, _)| (id, s, g))
});
if let Some((id, shape, grip)) = grip {
self.drag = Some(Drag::Edit { id, shape, grip, from: ev.pos });
self.last = (ev.pos, ev.mods);
return vec![Action::Begin(EDIT.into())];
}
if let Some(id) = shape_at(cx, ev.pos) {
return if cx.selection.objects == [id] { vec![] } else { vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))] };
}
let (center, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.drag = Some(Drag::Create { center });
self.last = (center, ev.mods);
vec![Action::Begin(CREATE.into())]
}
PointerKind::Drag | PointerKind::Up => {
if self.drag.is_none() {
return vec![];
}
self.last = (ev.pos, ev.mods);
let Some(preview) = self.preview() else { return vec![] };
if ev.kind == PointerKind::Drag {
return vec![preview];
}
let Some(drag) = self.drag.take() else { return vec![] };
match drag {
// A click draws nothing (a double-click places a shape).
Drag::Create { center } if center.distance(ev.pos) <= cx.tol(2.0) => vec![Action::Cancel],
Drag::Create { .. } => {
if let Action::Preview(_, p) = &preview
&& let Ok(s) = apply(self.next, p)
{
self.next = ParametricShape { center: Point::ZERO, ..s };
}
vec![preview, Action::Commit]
}
Drag::Edit { from, .. } if from == ev.pos => vec![Action::Cancel],
Drag::Edit { .. } => vec![preview, Action::Commit],
}
}
PointerKind::DoubleClick => {
if self.drag.is_some() || shape_at(cx, ev.pos).is_some() {
return vec![];
}
let s = ParametricShape { center: ev.pos, ..self.next };
vec![Action::Exec("addon.pathtools.shape.create".into(), to_json(&s))]
}
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.drag.take().is_some() {
return vec![Action::Cancel];
}
if !matches!(self.drag, Some(Drag::Create { .. })) {
return vec![];
}
let (primary, secondary) = arrow_fields(self.next.kind);
let (field, up) = match key {
ToolKey::Up => (primary, true),
ToolKey::Down => (primary, false),
ToolKey::Right => (secondary, true),
ToolKey::Left => (secondary, false),
_ => return vec![],
};
let Some(field) = field else { return vec![] };
self.next = stepped(self.next, field, up, cx.keyboard_increment.max(0.01));
self.preview().into_iter().collect()
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
match self.drag {
Some(Drag::Create { .. }) => matches!(key, ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right | ToolKey::Escape),
Some(Drag::Edit { .. }) => key == ToolKey::Escape,
None => false,
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(Drag::Create { center }) = &self.drag {
let (_, s, _) = self.shaped(&Drag::Create { center: *center }, self.last.0, self.last.1);
let text = format!("{}\nAngle: {:.1}°", cx.size_label(s.width, s.height), s.rotation);
return vec![Overlay::Measure { p: self.last.0 + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text }];
}
let Some((_, s)) = selected(cx) else { return vec![] };
// The shape's box, then its handles.
let (rx, ry) = (s.width / 2.0, s.height / 2.0);
let mut bp = BezPath::new();
let f: Affine = s.frame();
let corners = [(-rx, -ry), (rx, -ry), (rx, ry), (-rx, ry)].map(|(x, y)| f * Point::new(x, y));
for (i, p) in corners.iter().enumerate() {
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
}
bp.close_path();
let mut out = vec![Overlay::Path { path: bp, color: BLUE, width: 1.0, dashed: true }];
for (grip, p) in grips(cx, &s) {
if grip == Grip::Rotate {
out.push(Overlay::Line { a: f * Point::new(0.0, -ry), b: p, color: BLUE, dashed: false });
}
out.push(match grip {
Grip::Move | Grip::Size => Overlay::Anchor { p, color: BLUE, filled: grip == Grip::Move, size: 7.0 },
Grip::Rotate => Overlay::Handle { p, color: BLUE },
Grip::Corner | Grip::Value => Overlay::CurvePoint { p, color: BLUE, filled: grip == Grip::Value, size: 7.0 },
});
}
out
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::ParametricShape
}
/// The next shape's numbers (its size and rotation are a double-click's).
fn options(&self) -> Value {
let mut v = to_json(&self.next);
if let Some(o) = v.as_object_mut() {
o.remove("center");
}
v
}
fn set_option(&mut self, key: &str, v: &Value) {
if key != "center"
&& let Ok(s) = apply(self.next, &json!({ key: v }))
{
self.next = s;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::{Appearance, Document, Selection};
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
/// A document with a parametric star about (250, 250), 100 × 100: the document and its id.
fn doc_with_star() -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let s = ParametricShape::new(ShapeKind::Star, Point::new(250.0, 250.0), 100.0, 100.0);
let path = s.path().unwrap();
let mut n = Node::path(id, path.clone(), Appearance::default_art());
n.edit_attrs(|a| a.data.push((KEY.into(), stored(&s, &path))));
d.insert(Some(l), 0, n).unwrap();
(d, id)
}
fn preview_of(acts: &[Action]) -> (String, Value) {
match acts.first() {
Some(Action::Preview(c, p)) => (c.clone(), p.clone()),
other => panic!("{other:?}"),
}
}
#[test]
fn a_drag_from_the_centre_draws_sized_and_turned_and_arrows_change_its_numbers() {
let (d, _) = doc_with_rect();
let (sel, p) = (Selection::default(), paint());
let cx = cx(&d, &sel, &p);
let mut t = ParametricShapeTool::default();
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)), vec![Action::Begin(CREATE.into())]);
// 40 pt to the right: 80 across, turned a quarter clockwise.
let acts = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 300.0));
let (cmd, p) = preview_of(&acts);
assert_eq!(cmd, "addon.pathtools.shape.create");
assert_eq!(
(p["kind"].clone(), p["center"].clone(), p["width"].clone(), p["rotation"].clone()),
(json!("polygon"), json!([300.0, 300.0]), json!(80.0), json!(90.0))
);
assert!(t.claims_key(&cx, ToolKey::Up) && t.busy());
// ↑ adds a side, ← takes a point off the corner radius (none left: stays 0), → adds one.
let (_, p) = preview_of(&t.key(&cx, ToolKey::Up, Mods::default()));
assert_eq!(p["count"], json!(7));
let (_, p) = preview_of(&t.key(&cx, ToolKey::Right, Mods::default()));
assert_eq!(p["radius"], json!(1.0));
// Shift turns in 45° steps.
let shifted = ev(PointerKind::Drag, 340.0, 290.0).with_mods(Mods { shift: true, ..Mods::default() });
let (_, p) = preview_of(&t.pointer(&cx, &shifted));
assert_eq!(p["rotation"], json!(90.0));
let up = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0));
assert!(
matches!(up.as_slice(), [Action::Preview(_, p), Action::Commit] if p["rotation"] == json!(0.0) && p["height"] == json!(100.0)),
"{up:?}"
);
assert!(!t.busy() && !t.claims_key(&cx, ToolKey::Up), "arrows nudge again");
// The next shape remembers the numbers; a double-click places it.
assert_eq!((t.options()["count"].clone(), t.options()["radius"].clone()), (json!(7), json!(1.0)));
let acts = t.pointer(&cx, &ev(PointerKind::DoubleClick, 50.0, 400.0));
let [Action::Exec(cmd, p)] = acts.as_slice() else { panic!("{acts:?}") };
assert_eq!(
(cmd.as_str(), p["center"].clone(), p["count"].clone(), p["height"].clone()),
("addon.pathtools.shape.create", json!([50.0, 400.0]), json!(7), json!(100.0))
);
// A click without a drag draws nothing; Escape drops a drag.
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)), vec![Action::Cancel]);
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::ParametricShape);
}
#[test]
fn each_kinds_arrow_keys_step_within_range() {
let s = ParametricShape::new(ShapeKind::Star, Point::ZERO, 10.0, 10.0);
assert_eq!(stepped(s, Field::Count, false, 1.0).count, 4);
assert_eq!(stepped(ParametricShape { count: 3, ..s }, Field::Count, false, 1.0).count, 3);
assert_eq!(stepped(ParametricShape { count: MAX_COUNT, ..s }, Field::Count, true, 1.0).count, MAX_COUNT);
assert!((stepped(s, Field::A, true, 1.0).a - 0.5495).abs() < 1e-9);
assert_eq!(stepped(ParametricShape { a: 1.0, ..s }, Field::A, true, 1.0).a, 1.0);
for kind in ShapeKind::ALL {
let (up, side) = arrow_fields(kind);
let s = ParametricShape::new(kind, Point::ZERO, 10.0, 10.0);
for f in [up, side].into_iter().flatten() {
assert_eq!(stepped(s, f, true, 1.0).check(), Ok(()), "{kind:?} {f:?}");
assert_eq!(stepped(s, f, false, 1.0).check(), Ok(()), "{kind:?} {f:?}");
}
}
}
#[test]
fn a_click_selects_a_shape_and_its_handles_drag_into_set() {
let (d, id) = doc_with_star();
let p = paint();
let none = Selection::default();
let mut t = ParametricShapeTool::default();
let acts = t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 250.0, 250.0));
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))]);
assert!(t.overlays(&cx(&d, &none, &p)).is_empty());
let mut sel = Selection::default();
sel.set([id]);
let cx = cx(&d, &sel, &p);
let ov = t.overlays(&cx);
assert!(matches!(ov.first(), Some(Overlay::Path { dashed: true, .. })) && ov.len() >= 6, "{ov:?}");
// The size handle, at the box's lower right corner: 150 × 120 now.
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)), vec![Action::Begin(EDIT.into())]);
let (cmd, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 325.0, 310.0)));
assert_eq!(
(cmd.as_str(), p["id"].clone(), p["width"].clone(), p["height"].clone()),
("addon.pathtools.shape.set", json!(id.0), json!(150.0), json!(120.0))
);
assert!(matches!(t.pointer(&cx, &ev(PointerKind::Up, 325.0, 310.0)).as_slice(), [Action::Preview(..), Action::Commit]));
// The centre moves it.
t.pointer(&cx, &ev(PointerKind::Down, 251.0, 249.0));
let (_, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 261.0, 229.0)));
assert_eq!(p["center"], json!([260.0, 230.0]));
t.key(&cx, ToolKey::Escape, Mods::default());
// The rotation handle above the top.
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 200.0 - STEM_PX));
let (_, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 350.0, 250.0)));
assert_eq!(p["rotation"], json!(90.0));
t.key(&cx, ToolKey::Escape, Mods::default());
// The star's inner radius, on its first inner corner.
let inner = Point::new(250.0, 250.0) + Vec2::new((36f64).to_radians().sin(), -(36f64).to_radians().cos()) * 25.0;
t.pointer(&cx, &ev(PointerKind::Down, inner.x, inner.y));
let (_, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 210.0)));
assert!((p["a"].as_f64().unwrap() - 0.8).abs() < 1e-9, "{p}");
t.key(&cx, ToolKey::Escape, Mods::default());
// The corner radius on the top edge.
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
let (_, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 212.0, 200.0)));
assert_eq!(p["radius"], json!(12.0));
// Pressing and letting go of a handle without moving changes nothing.
t.key(&cx, ToolKey::Escape, Mods::default());
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Cancel]);
// A double-click on a shape places none.
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 250.0, 250.0)).is_empty());
}
#[test]
fn stored_numbers_are_read_back_only_while_the_path_is_the_shape() {
let (mut d, id) = doc_with_star();
let s = read(d.node(id).unwrap()).unwrap();
assert_eq!((s.kind, s.center, s.count), (ShapeKind::Star, Point::new(250.0, 250.0), 5));
// Moved: still the shape, about its new centre.
d.node_mut(id).unwrap().path_data_mut().unwrap().transform(Affine::translate((10.0, 0.0)));
assert_eq!(read(d.node(id).unwrap()).map(|s| s.center), Some(Point::new(260.0, 250.0)));
// An anchor moved: a plain path.
d.node_mut(id).unwrap().path_data_mut().unwrap().anchor_mut(0, 2).unwrap().p.x += 3.0;
assert_eq!(read(d.node(id).unwrap()), None);
// Damaged entries are no shape.
for bad in ["", "{", "[]", r#"{"kind":"blob"}"#, r#"{"kind":"star","count":5000,"fp":"0"}"#, r#"{"kind":"star"}"#] {
let mut n = d.node(id).unwrap().clone();
n.edit_attrs(|a| a.data = vec![(KEY.into(), bad.into())]);
assert_eq!(read(&n), None, "{bad}");
}
let plain = Node::path(NodeId(99), PathData::default(), Appearance::default_art());
assert_eq!(read(&plain), None);
}
#[test]
fn options_set_the_next_shape_and_refuse_bad_values() {
let mut t = ParametricShapeTool::default();
t.set_option("kind", &json!("gear"));
t.set_option("count", &json!(20));
t.set_option("count", &json!(5000));
t.set_option("a", &json!(0.3));
t.set_option("width", &json!(-4));
t.set_option("kind", &json!("cloud"));
let o = t.options();
assert_eq!((o["kind"].clone(), o["count"].clone(), o["a"].clone(), o["width"].clone()), (json!("gear"), json!(20), json!(0.3), json!(100.0)));
assert!(o.get("center").is_none());
assert!(apply(t.next, &json!({"center": [1, "x"]})).is_err());
assert_eq!(apply(t.next, &json!({"rotation": 450})).map(|s| s.rotation), Ok(90.0));
}
}