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.
This commit is contained in:
bflatastic
2026-10-30 20:55:14 +07:00
parent b496226ab5
commit 27ed249b1f
48 changed files with 2551 additions and 24 deletions
+3
View File
@@ -5,6 +5,7 @@
pub mod anchor_eraser;
pub mod extend_path;
pub mod measure_inspect;
pub mod parametric_shape;
pub mod smart_pen;
use crate::{Tool, ToolInfo};
@@ -14,6 +15,7 @@ pub const PATH_TOOLS: &[ToolInfo] = &[
ToolInfo { id: anchor_eraser::ID, label: "Anchor Eraser Brush", shortcut: None, icon: "dc-remove-brush" },
ToolInfo { id: extend_path::ID, label: "Extend Path Tool", shortcut: None, icon: "dc-arrow-extend" },
ToolInfo { id: measure_inspect::ID, label: "Measure & Inspect Tool", shortcut: None, icon: "ruler" },
ToolInfo { id: parametric_shape::ID, label: "Parametric Shape Tool", shortcut: None, icon: "hexagon" },
];
/// Smart Pen's tool, a flyout group of its own.
@@ -37,6 +39,7 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
anchor_eraser::ID => Box::new(anchor_eraser::AnchorEraserTool::default()),
extend_path::ID => Box::new(extend_path::ExtendPathTool::default()),
measure_inspect::ID => Box::new(measure_inspect::MeasureInspectTool::default()),
parametric_shape::ID => Box::new(parametric_shape::ParametricShapeTool::default()),
smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()),
_ => return None,
})
+620
View File
@@ -0,0 +1,620 @@
//! 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));
}
}
+6 -1
View File
@@ -177,6 +177,9 @@ pub enum Cursor {
ExtendPath,
/// The Measure & Inspect tool (Path Precision Tools add-on).
MeasureInspect,
/// The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a shape out from its
/// centre; a selected shape's handles edit it.
ParametricShape,
/// The Smart Pen Tool (Smart Pen add-on) away from paths, where a click starts a new path (over
/// a path or while drawing it shows the Pen's and Direct Selection's cursors).
SmartPen,
@@ -274,6 +277,7 @@ impl Cursor {
| Cursor::AnchorEraser
| Cursor::ExtendPath
| Cursor::MeasureInspect
| Cursor::ParametricShape
| Cursor::SmartPen => Cursor::Crosshair,
c => c,
}
@@ -311,7 +315,8 @@ impl Cursor {
| Cursor::Scissors
| Cursor::AnchorEraser
| Cursor::ExtendPath
| Cursor::MeasureInspect => Some(Cursor::Crosshair),
| Cursor::MeasureInspect
| Cursor::ParametricShape => Some(Cursor::Crosshair),
Cursor::TypeVertical => Some(Cursor::Text),
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
Cursor::SmartPen => Some(Cursor::Pen),
+6
View File
@@ -696,6 +696,12 @@ group = "Drawing and editing tools"
shown_when = ["The Measure & Inspect tool (Path Precision Tools add-on): drag to measure distance and angle; hover a path to read its lengths, area, curvature and angles."]
hotspot = "Exact, because precision matters here (an open centre suits it)."
[[cursor]]
id = "ParametricShape"
group = "Drawing and editing tools"
shown_when = ["The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a re-editable shape out from its centre; a click selects one and shows its handles."]
hotspot = "Exact: the centre of the shape being drawn."
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
# The brush size is drawn by the app as a circle around the cursor.
+2
View File
@@ -70,6 +70,8 @@ const ROWS: &[Row] = &[
(&[crate::addons::anchor_eraser::ID], None, &["size", "tolerance", "pressure"]),
(&[crate::addons::extend_path::ID], None, &["mode"]),
(&[crate::addons::smart_pen::ID], None, &["next"]),
// The kind first: a new kind resets the values after it.
(&[crate::addons::parametric_shape::ID], None, &["kind", "count", "a", "b", "radius", "width", "height", "rotation"]),
// Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes).
(
&["flare"],