mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 06:07:06 -06:00
Add-ons: Path Precision Tools — Corner Tool and Slide Anchor
Path Precision Tools gains two tools, kept out of the toolbars (All Tools drawer and Object › Plug-ins › Path Precision Tools, no shortcuts): - Corner Tool (cornerTool): editable corners on any path through the existing Live Corners. Hover shows an eligible anchor, a click cuts a corner with the tool's radius and kind, a drag sizes it (Shift: whole values), a corner's marker selects it and loads its values, resizes it and removes it at 0 or on a double-click; a corner never grows past what fits; an Alt-marquee cuts every eligible anchor inside. - Corners section in the Anchor Inspector: radius, kind, selected count, Apply to Selected Anchors. - Slide Anchor Along Path (slideAnchor): a drag runs an anchor along its curve, the neighbouring segments refitted by the keep-shape kernel; Shift snaps where the tangent is a multiple of 45° from the constrain angle. - Commands addon.pathtools.cornerSet, cornerRemove and slideAnchor, one undo step each, params validated and covered by the command sweep. - Cursor stand-ins, catalog rows in every language, docs and tests.
This commit is contained in:
@@ -3,9 +3,11 @@
|
||||
//! submenu selects them. Like every tool they only emit commands (`addon.<name>.*`).
|
||||
|
||||
pub mod anchor_eraser;
|
||||
pub mod corner_tool;
|
||||
pub mod extend_path;
|
||||
pub mod measure_inspect;
|
||||
pub mod parametric_shape;
|
||||
pub mod slide_anchor;
|
||||
pub mod smart_pen;
|
||||
|
||||
use crate::{Tool, ToolInfo};
|
||||
@@ -16,6 +18,8 @@ pub const PATH_TOOLS: &[ToolInfo] = &[
|
||||
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" },
|
||||
ToolInfo { id: corner_tool::ID, label: "Corner Tool", shortcut: None, icon: "dc-rounded-rect" },
|
||||
ToolInfo { id: slide_anchor::ID, label: "Slide Anchor Along Path", shortcut: None, icon: "dc-anchor" },
|
||||
];
|
||||
|
||||
/// Smart Pen's tool, a flyout group of its own.
|
||||
@@ -37,9 +41,11 @@ pub fn heading(id: &str) -> Option<&'static str> {
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
Some(match id {
|
||||
anchor_eraser::ID => Box::new(anchor_eraser::AnchorEraserTool::default()),
|
||||
corner_tool::ID => Box::new(corner_tool::CornerTool::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()),
|
||||
slide_anchor::ID => Box::new(slide_anchor::SlideAnchorTool::default()),
|
||||
smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()),
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Corner Tool (Path Precision Tools): editable corners on any path, selected or not, through the
|
||||
//! app's Live Corners (`addon.pathtools.cornerSet` / `cornerRemove`). Over an anchor that can take
|
||||
//! a corner, the anchor shows; a click cuts one with the tool's radius and kind, a drag sizes it
|
||||
//! (Shift: whole values of the ruler's unit). A cut corner shows a marker at the centre of its cut:
|
||||
//! a click selects the corner and loads its radius and kind into the tool, a drag resizes it (to 0
|
||||
//! it goes), a double-click removes it. A corner can't grow past what fits. Alt-marquee cuts
|
||||
//! corners on every anchor inside; a plain marquee selects them, and a click away deselects.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, LiveCorners, NodeId, NodeKind};
|
||||
use vectorcraft_geom::corners::Corner;
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::builder::HIGHLIGHT_RED;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// The tool's id.
|
||||
pub const ID: &str = "cornerTool";
|
||||
/// The radius a click cuts by default (pt).
|
||||
pub const RADIUS: f64 = 10.0;
|
||||
/// The largest radius asked for (pt).
|
||||
pub const MAX_RADIUS: f64 = 1e6;
|
||||
/// The undo step of a drag.
|
||||
const LABEL: &str = "Corner Radius";
|
||||
const SET: &str = "addon.pathtools.cornerSet";
|
||||
const REMOVE: &str = "addon.pathtools.cornerRemove";
|
||||
/// How far (px) the pointer travels before a press is a drag.
|
||||
const DRAG_PX: f64 = 3.0;
|
||||
/// Most markers drawn at once.
|
||||
const MAX_MARKERS: usize = 2000;
|
||||
const MARKER_PX: f32 = 7.0;
|
||||
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
|
||||
|
||||
/// A corner the tool can act on.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Spot {
|
||||
id: NodeId,
|
||||
corner: Corner,
|
||||
/// Maps the corner's space into the document.
|
||||
xf: Affine,
|
||||
/// The radius it is drawn with: 0 for a sharp corner.
|
||||
drawn: f64,
|
||||
kind: CornerKind,
|
||||
}
|
||||
|
||||
impl Spot {
|
||||
fn of(lc: &LiveCorners, id: NodeId, corner: &Corner) -> Self {
|
||||
Self { id, corner: *corner, xf: lc.xf, drawn: lc.drawn_radius(corner.index), kind: lc.kind(corner.index) }
|
||||
}
|
||||
|
||||
/// Where it shows: a cut corner's marker, the centre of a round cut, or a sharp corner's anchor.
|
||||
fn at(&self) -> Point {
|
||||
self.xf * self.corner.on_bisector(self.drawn / self.corner.sin())
|
||||
}
|
||||
|
||||
/// The params naming it.
|
||||
fn items(&self) -> Value {
|
||||
json!([{ "id": self.id.0, "anchors": [self.corner.index] }])
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cut corner of the editable paths.
|
||||
fn cut_corners(doc: &Document) -> Vec<Spot> {
|
||||
let editable = doc.editable();
|
||||
let mut out = vec![];
|
||||
doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Path { live: Some(_), guide: false, .. })
|
||||
&& editable(n.id)
|
||||
&& let Some(lc) = LiveCorners::of(n)
|
||||
{
|
||||
out.extend(lc.corners.iter().filter(|c| lc.drawn_radius(c.index) > 0.0).map(|c| Spot::of(&lc, n.id, c)));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The sharp corner of an editable path nearest `p` within the point tolerance.
|
||||
fn sharp_corner_at(cx: &ToolContext, p: Point) -> Option<Spot> {
|
||||
let (tol, editable) = (cx.point_tol(), cx.doc.editable());
|
||||
let mut best: Option<(f64, Spot)> = None;
|
||||
cx.doc.walk(|n| {
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
|
||||
if !path.anchors().any(|(_, _, a)| a.p.distance(p) <= tol) || !editable(n.id) {
|
||||
return;
|
||||
}
|
||||
let Some(lc) = LiveCorners::of(n) else { return };
|
||||
for c in lc.corners.iter().filter(|c| lc.drawn_radius(c.index) <= 0.0) {
|
||||
let s = Spot::of(&lc, n.id, c);
|
||||
let d = s.at().distance(p);
|
||||
if d <= tol && best.as_ref().is_none_or(|b| d < b.0) {
|
||||
best = Some((d, s));
|
||||
}
|
||||
}
|
||||
});
|
||||
best.map(|(_, s)| s)
|
||||
}
|
||||
|
||||
/// The corners of the editable paths inside `r`, by path: anchor indices of each uncut outline.
|
||||
fn corners_inside(cx: &ToolContext, r: Rect) -> BTreeMap<NodeId, Vec<usize>> {
|
||||
let editable = cx.doc.editable();
|
||||
let mut out = BTreeMap::new();
|
||||
cx.doc.walk(|n| {
|
||||
if !matches!(n.kind, NodeKind::Path { guide: false, .. }) || !editable(n.id) {
|
||||
return;
|
||||
}
|
||||
let Some(lc) = LiveCorners::of(n) else { return };
|
||||
let inside: Vec<usize> = lc.corners.iter().filter(|c| r.contains(lc.xf * c.at)).map(|c| c.index).collect();
|
||||
if !inside.is_empty() {
|
||||
out.insert(n.id, inside);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The largest radius `spot` draws with the corners around it as they are.
|
||||
fn max_radius(doc: &Document, spot: &Spot) -> f64 {
|
||||
let Some(lc) = doc.node(spot.id).and_then(LiveCorners::of) else { return 0.0 };
|
||||
let radii: Vec<f64> = (0..lc.base.anchor_count()).map(|k| lc.drawn_radius(k)).collect();
|
||||
spot.corner.max_shared_radius(&lc.corners, &radii, |k| k == spot.corner.index)
|
||||
}
|
||||
|
||||
/// `select.anchors` params selecting `corners` of path `id`.
|
||||
fn select_params(cx: &ToolContext, id: NodeId, corners: &[usize], mode: &str) -> Option<Value> {
|
||||
let lc = cx.doc.node(id).and_then(LiveCorners::of)?;
|
||||
let anchors: Vec<_> = lc.anchors_of(&corners.iter().copied().collect()).into_iter().collect();
|
||||
Some(json!({ "id": id.0, "anchors": anchors, "mode": mode }))
|
||||
}
|
||||
|
||||
/// A document revision and the cut corners it has.
|
||||
type Marks = ((u64, u64), Arc<Vec<Spot>>);
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
enum State {
|
||||
#[default]
|
||||
Idle,
|
||||
/// Pressed on a corner: a drag sizes it.
|
||||
Corner {
|
||||
spot: Box<Spot>,
|
||||
start: Point,
|
||||
/// The largest radius that fits.
|
||||
max: f64,
|
||||
/// The drag has left the click.
|
||||
began: bool,
|
||||
/// The radius the drag asks for.
|
||||
radius: f64,
|
||||
/// Where the pointer is.
|
||||
at: Point,
|
||||
},
|
||||
/// `cut`: an Alt-marquee, which cuts corners; else it selects them.
|
||||
Marquee { start: Point, cur: Point, cut: bool, toggle: bool },
|
||||
}
|
||||
|
||||
pub struct CornerTool {
|
||||
radius: f64,
|
||||
kind: CornerKind,
|
||||
state: State,
|
||||
hover: Option<Spot>,
|
||||
/// The cut corners of the document as it was at a revision, for hovering and the markers.
|
||||
cache: Cell<Option<Marks>>,
|
||||
}
|
||||
|
||||
impl Default for CornerTool {
|
||||
fn default() -> Self {
|
||||
Self { radius: RADIUS, kind: CornerKind::Round, state: State::Idle, hover: None, cache: Cell::new(None) }
|
||||
}
|
||||
}
|
||||
|
||||
impl CornerTool {
|
||||
fn markers(&self, cx: &ToolContext) -> Arc<Vec<Spot>> {
|
||||
let kept = self.cache.take().filter(|(rev, _)| *rev == cx.revision);
|
||||
let list = kept.map_or_else(|| Arc::new(cut_corners(cx.doc)), |(_, list)| list);
|
||||
self.cache.set(Some((cx.revision, list.clone())));
|
||||
list
|
||||
}
|
||||
|
||||
/// The corner at `p`: a cut corner's marker, else a sharp corner's anchor.
|
||||
fn spot_at(&self, cx: &ToolContext, p: Point) -> Option<Spot> {
|
||||
let (marks, tol) = (self.markers(cx), cx.point_tol());
|
||||
let dist = |s: &&Spot| s.at().distance(p);
|
||||
let mark = marks.iter().filter(|s| dist(s) <= tol).min_by(|a, b| dist(a).total_cmp(&dist(b)));
|
||||
mark.cloned().or_else(|| sharp_corner_at(cx, p))
|
||||
}
|
||||
|
||||
/// The command a drag of `spot` to `radius` stands for.
|
||||
fn sized(&self, spot: &Spot, radius: f64) -> Action {
|
||||
if radius > 0.0 {
|
||||
let kind = if spot.drawn > 0.0 { spot.kind } else { self.kind };
|
||||
Action::Preview(SET.into(), json!({ "items": spot.items(), "radius": radius, "kind": kind }))
|
||||
} else {
|
||||
Action::Preview(REMOVE.into(), json!({ "items": spot.items() }))
|
||||
}
|
||||
}
|
||||
|
||||
fn drag_corner(&mut self, cx: &ToolContext, p: Point, mods: Mods) -> Vec<Action> {
|
||||
let State::Corner { spot, start, max, began, radius, at } = &mut self.state else { return vec![] };
|
||||
*at = p;
|
||||
let mut out = vec![];
|
||||
if !*began {
|
||||
if p.distance(*start) < cx.tol(DRAG_PX) {
|
||||
return out;
|
||||
}
|
||||
*began = true;
|
||||
out.push(Action::Begin(LABEL.into()));
|
||||
}
|
||||
let inv = spot.xf.inverse();
|
||||
let change = spot.corner.radius_change(inv * p - inv * *start);
|
||||
// From a sharp corner a drag either way sizes it; a cut one grows inward and shrinks back.
|
||||
let asked = if spot.drawn > 0.0 { spot.drawn + change } else { change.abs() };
|
||||
let asked = if mods.shift { cx.unit.to_pt(cx.unit.from_pt(asked).round()) } else { asked };
|
||||
*radius = asked.min(*max).max(0.0);
|
||||
let (spot, r) = (spot.clone(), *radius);
|
||||
out.push(self.sized(&spot, r));
|
||||
out
|
||||
}
|
||||
|
||||
/// A press on a corner let go without a drag.
|
||||
fn click_corner(&self, cx: &ToolContext, spot: &Spot, mods: Mods) -> Vec<Action> {
|
||||
if spot.drawn <= 0.0 {
|
||||
return vec![Action::Exec(SET.into(), json!({ "items": spot.items(), "radius": self.radius, "kind": self.kind }))];
|
||||
}
|
||||
let mode = if mods.shift { "toggle" } else { "set" };
|
||||
select_params(cx, spot.id, &[spot.corner.index], mode).map_or_else(Vec::new, |p| vec![Action::Exec("select.anchors".into(), p)])
|
||||
}
|
||||
|
||||
fn finish_marquee(&self, cx: &ToolContext, r: Rect, cut: bool, toggle: bool) -> Vec<Action> {
|
||||
let inside = corners_inside(cx, r);
|
||||
if cut {
|
||||
if inside.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let items: Vec<Value> = inside.iter().map(|(id, corners)| json!({ "id": id.0, "anchors": corners })).collect();
|
||||
return vec![Action::Exec(SET.into(), json!({ "items": items, "radius": self.radius, "kind": self.kind }))];
|
||||
}
|
||||
if inside.is_empty() && !toggle {
|
||||
return vec![Action::Exec("select.none".into(), json!({}))];
|
||||
}
|
||||
let modes = std::iter::once(if toggle { "toggle" } else { "set" }).chain(std::iter::repeat(if toggle { "toggle" } else { "add" }));
|
||||
inside
|
||||
.iter()
|
||||
.zip(modes)
|
||||
.filter_map(|((id, corners), mode)| Some(Action::Exec("select.anchors".into(), select_params(cx, *id, corners, mode)?)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for CornerTool {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
!matches!(self.state, State::Idle)
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.hover = self.spot_at(cx, p);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
self.hover = None;
|
||||
let spot = if ev.mods.alt { None } else { self.spot_at(cx, p) };
|
||||
self.state = match spot {
|
||||
Some(spot) => {
|
||||
// A cut corner's values are the tool's from now on, as the panel shows.
|
||||
if spot.drawn > 0.0 {
|
||||
(self.radius, self.kind) = (spot.drawn, spot.kind);
|
||||
}
|
||||
let max = max_radius(cx.doc, &spot);
|
||||
State::Corner { radius: spot.drawn, spot: Box::new(spot), start: p, max, began: false, at: p }
|
||||
}
|
||||
None => State::Marquee { start: p, cur: p, cut: ev.mods.alt, toggle: ev.mods.shift },
|
||||
};
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
if let State::Marquee { cur, .. } = &mut self.state {
|
||||
*cur = p;
|
||||
return vec![];
|
||||
}
|
||||
self.drag_corner(cx, p, ev.mods)
|
||||
}
|
||||
PointerKind::Up => match std::mem::take(&mut self.state) {
|
||||
State::Idle => vec![],
|
||||
State::Marquee { start, cut, toggle, .. } => self.finish_marquee(cx, Rect::from_points(start, p), cut, toggle),
|
||||
state @ State::Corner { began: true, .. } => {
|
||||
self.state = state;
|
||||
let mut out = self.drag_corner(cx, p, ev.mods);
|
||||
if let State::Corner { spot, radius, .. } = std::mem::take(&mut self.state) {
|
||||
// Back where it started: nothing to keep.
|
||||
if spot.drawn <= 0.0 && radius <= 0.0 {
|
||||
return vec![Action::Cancel];
|
||||
}
|
||||
}
|
||||
out.push(Action::Commit);
|
||||
out
|
||||
}
|
||||
State::Corner { spot, .. } => self.click_corner(cx, &spot, ev.mods),
|
||||
},
|
||||
PointerKind::DoubleClick => {
|
||||
self.state = State::Idle;
|
||||
match self.spot_at(cx, p).filter(|s| s.drawn > 0.0) {
|
||||
Some(s) => vec![Action::Exec(REMOVE.into(), json!({ "items": s.items() }))],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key != ToolKey::Escape {
|
||||
return vec![];
|
||||
}
|
||||
match std::mem::take(&mut self.state) {
|
||||
State::Corner { began: true, .. } => vec![Action::Cancel],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.busy() && key == ToolKey::Escape
|
||||
}
|
||||
|
||||
fn hover_ended(&mut self, _cx: &ToolContext) {
|
||||
self.hover = None;
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let ring = |p, color, filled| Overlay::CurvePoint { p, color, filled, size: MARKER_PX };
|
||||
match &self.state {
|
||||
State::Marquee { start, cur, .. } => return vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
||||
State::Corner { began: true, radius, max, at, .. } => {
|
||||
let mut out = vec![];
|
||||
if cx.measurement_labels {
|
||||
out.push(Overlay::Measure { p: *at, text: format!("Radius: {}", cx.len(*radius)) });
|
||||
}
|
||||
// At the limit of what fits.
|
||||
if *max > 0.0 && *radius >= *max {
|
||||
out.push(ring(*at, HIGHLIGHT_RED, false));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut out: Vec<Overlay> = self.markers(cx).iter().take(MAX_MARKERS).map(|s| ring(s.at(), BLUE, false)).collect();
|
||||
match &self.hover {
|
||||
Some(s) if s.drawn > 0.0 => out.push(ring(s.at(), BLUE, true)),
|
||||
Some(s) => out.push(Overlay::Anchor { p: s.at(), color: BLUE, filled: true, size: MARKER_PX }),
|
||||
None => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::CornerTool
|
||||
}
|
||||
|
||||
fn options(&self) -> Value {
|
||||
json!({ "radius": self.radius, "kind": self.kind })
|
||||
}
|
||||
|
||||
fn set_option(&mut self, key: &str, v: &Value) {
|
||||
match key {
|
||||
"radius" => {
|
||||
if let Some(r) = v.as_f64().filter(|r| (0.0..=MAX_RADIUS).contains(r)) {
|
||||
self.radius = r;
|
||||
}
|
||||
}
|
||||
"kind" => {
|
||||
if let Ok(k) = serde_json::from_value(v.clone()) {
|
||||
self.kind = k;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,161 @@
|
||||
use vectorcraft_doc::corners::set_corners;
|
||||
use vectorcraft_doc::{Appearance, Node, Selection};
|
||||
use vectorcraft_geom::{PathData, SubPath};
|
||||
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
|
||||
/// A square (100, 100)–(300, 300), plain: anchors from the top left, clockwise.
|
||||
fn doc_with_square() -> (Document, NodeId) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let pts = [(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)].map(|(x, y)| Point::new(x, y));
|
||||
d.insert(Some(l), 0, Node::path(id, PathData::single(SubPath::polyline(&pts, true)), Appearance::default_art())).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// [`doc_with_square`] with a chamfer of radius 20 on corner 1, whose marker is at (280, 120).
|
||||
fn doc_with_corner() -> (Document, NodeId) {
|
||||
let (mut d, id) = doc_with_square();
|
||||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!() };
|
||||
set_corners(path, live, &[1].into(), Some(20.0), Some(CornerKind::Chamfer));
|
||||
(d, id)
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
fn with(mut e: PointerEvent, f: impl Fn(&mut Mods)) -> PointerEvent {
|
||||
f(&mut e.mods);
|
||||
e
|
||||
}
|
||||
|
||||
fn radius_of(a: Option<&Action>) -> f64 {
|
||||
let Some(Action::Preview(_, p)) = a else { panic!("{a:?}") };
|
||||
p["radius"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_shows_the_anchor_and_a_click_cuts_with_the_tools_values() {
|
||||
let (d, id) = doc_with_square();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = CornerTool::default();
|
||||
t.set_option("radius", &json!(15));
|
||||
t.set_option("kind", &json!("invertedRound"));
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Move, 301.0, 101.0)).is_empty());
|
||||
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Anchor { filled: true, .. }]), "{:?}", t.overlays(&cx));
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 200.0, 200.0));
|
||||
assert!(t.overlays(&cx).is_empty(), "nothing there");
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0)).is_empty());
|
||||
assert!(t.busy());
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 100.0));
|
||||
assert_eq!(up, vec![Action::Exec(SET.into(), json!({"items": [{"id": id.0, "anchors": [1]}], "radius": 15.0, "kind": "invertedRound"}))]);
|
||||
assert!(!t.busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_from_an_anchor_sizes_the_corner_and_stops_at_what_fits() {
|
||||
let (d, id) = doc_with_square();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = CornerTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0));
|
||||
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0));
|
||||
assert_eq!(drag.first(), Some(&Action::Begin(LABEL.into())));
|
||||
assert!((radius_of(drag.get(1)) - 20.0).abs() < 1e-9, "{drag:?}");
|
||||
let Some(Action::Preview(cmd, params)) = drag.get(1) else { panic!() };
|
||||
assert_eq!((cmd.as_str(), params["items"][0]["id"].clone(), params["kind"].clone()), (SET, json!(id.0), json!("round")));
|
||||
// Shift: whole values.
|
||||
let shifted = t.pointer(&cx, &with(ev(PointerKind::Drag, 276.6, 123.4), |m| m.shift = true));
|
||||
assert_eq!(radius_of(shifted.first()), 23.0);
|
||||
// Past what fits: as far as the sharp corners next to it, marked at the limit.
|
||||
let far = t.pointer(&cx, &ev(PointerKind::Drag, 0.0, 400.0));
|
||||
assert!((radius_of(far.first()) - 200.0).abs() < 1e-9, "{far:?}");
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::CurvePoint { color, .. } if *color == HIGHLIGHT_RED)));
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.contains("Radius: 200"))));
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 280.0, 120.0));
|
||||
assert!(matches!(up.as_slice(), [Action::Preview(..), Action::Commit]), "{up:?}");
|
||||
// Back on the anchor: nothing to keep. Escape cancels.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 100.0)), vec![Action::Cancel]);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 100.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 280.0, 120.0));
|
||||
assert!(t.claims_key(&cx, ToolKey::Escape));
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert!(!t.busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_corners_marker_selects_loads_resizes_and_removes() {
|
||||
let (d, id) = doc_with_corner();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = CornerTool::default();
|
||||
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::CurvePoint { filled: false, .. }]), "the marker shows");
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 281.0, 119.0));
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::CurvePoint { filled: true, .. })), "hovered");
|
||||
// A click selects the corner's anchors and loads its values.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 280.0, 120.0));
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 280.0, 120.0));
|
||||
let Some(Action::Exec(cmd, params)) = up.first() else { panic!("{up:?}") };
|
||||
assert_eq!((cmd.as_str(), params["id"].clone(), params["mode"].clone()), ("select.anchors", json!(id.0), json!("set")));
|
||||
assert_eq!(params["anchors"], json!([[0, 1], [0, 2]]));
|
||||
assert_eq!(t.options(), json!({"radius": 20.0, "kind": "chamfer"}));
|
||||
// A drag resizes it, its kind kept; to nothing it goes.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 280.0, 120.0));
|
||||
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 270.0, 130.0));
|
||||
assert!((radius_of(drag.get(1)) - 30.0).abs() < 1e-9, "{drag:?}");
|
||||
assert!(matches!(drag.get(1), Some(Action::Preview(_, p)) if p["kind"] == json!("chamfer")));
|
||||
let gone = t.pointer(&cx, &ev(PointerKind::Up, 310.0, 90.0));
|
||||
assert!(matches!(gone.as_slice(), [Action::Preview(c, _), Action::Commit] if c == REMOVE), "{gone:?}");
|
||||
// A double-click removes it.
|
||||
let dbl = t.pointer(&cx, &ev(PointerKind::DoubleClick, 280.0, 120.0));
|
||||
assert_eq!(dbl, vec![Action::Exec(REMOVE.into(), json!({"items": [{"id": id.0, "anchors": [1]}]}))]);
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 300.0, 300.0)).is_empty(), "a sharp corner has nothing to remove");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_marquee_cuts_every_corner_inside_and_a_plain_one_selects() {
|
||||
let (d, id) = doc_with_corner();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = CornerTool::default();
|
||||
// Alt over an anchor starts a marquee too; the chamfered corner counts, the far side doesn't.
|
||||
let alt = |e| with(e, |m| m.alt = true);
|
||||
t.pointer(&cx, &alt(ev(PointerKind::Down, 50.0, 50.0)));
|
||||
t.pointer(&cx, &alt(ev(PointerKind::Drag, 350.0, 200.0)));
|
||||
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Marquee(_)]));
|
||||
let up = t.pointer(&cx, &alt(ev(PointerKind::Up, 350.0, 200.0)));
|
||||
assert_eq!(up, vec![Action::Exec(SET.into(), json!({"items": [{"id": id.0, "anchors": [0, 1]}], "radius": RADIUS, "kind": "round"}))]);
|
||||
t.pointer(&cx, &alt(ev(PointerKind::Down, 400.0, 400.0)));
|
||||
assert!(t.pointer(&cx, &alt(ev(PointerKind::Up, 450.0, 450.0))).is_empty(), "nothing inside");
|
||||
// Without Alt the corners inside are selected; a click away deselects.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 200.0, 200.0));
|
||||
let Some(Action::Exec(cmd, params)) = up.first() else { panic!("{up:?}") };
|
||||
assert_eq!((cmd.as_str(), params["anchors"].clone(), params["mode"].clone()), ("select.anchors", json!([[0, 0]]), json!("set")));
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 400.0, 400.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 400.0, 400.0)), vec![Action::Exec("select.none".into(), json!({}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_paths_are_left_alone_and_options_are_checked() {
|
||||
let (mut d, id) = doc_with_corner();
|
||||
d.node_mut(id).unwrap().locked = true;
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = CornerTool::default();
|
||||
assert!(t.overlays(&cx).is_empty());
|
||||
t.pointer(&cx, &ev(PointerKind::Move, 100.0, 100.0));
|
||||
assert!(t.overlays(&cx).is_empty());
|
||||
for bad in [json!(-1), json!(1e308), json!("x")] {
|
||||
t.set_option("radius", &bad);
|
||||
}
|
||||
t.set_option("kind", &json!("spiky"));
|
||||
assert_eq!(t.options(), json!({"radius": RADIUS, "kind": "round"}));
|
||||
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::CornerTool);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Slide Anchor Along Path (Path Precision Tools): drag an anchor, selected or not, and it runs
|
||||
//! along the curve it sits on, the two segments beside it refitted so the outline stays
|
||||
//! (`addon.pathtools.slideAnchor`, previewed while dragging, one undo step). Shift snaps to where
|
||||
//! the path's tangent is at a multiple of 45° from the constrain angle. Ends of open paths don't
|
||||
//! slide.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Point, SubPath};
|
||||
use vectorcraft_pathops::addons::pathtools::{slide_stops, slide_t_at};
|
||||
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// The tool's id.
|
||||
pub const ID: &str = "slideAnchor";
|
||||
/// The undo step's name.
|
||||
const LABEL: &str = "Slide Anchor Along Path";
|
||||
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
|
||||
const SIZE: f32 = 7.0;
|
||||
|
||||
/// An anchor that can slide.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Grip {
|
||||
id: NodeId,
|
||||
subpath: usize,
|
||||
anchor: usize,
|
||||
p: Point,
|
||||
/// The subpath as it was.
|
||||
sp: SubPath,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SlideAnchorTool {
|
||||
hover: Option<Grip>,
|
||||
/// The anchor being dragged and how far along.
|
||||
drag: Option<(Grip, f64)>,
|
||||
}
|
||||
|
||||
/// The anchor of an editable path nearest `p` within the point tolerance that can slide.
|
||||
fn grip_at(cx: &ToolContext, p: Point) -> Option<Grip> {
|
||||
let (tol, editable) = (cx.point_tol(), cx.doc.editable());
|
||||
let mut best: Option<(f64, NodeId, usize, usize, Point)> = None;
|
||||
cx.doc.walk(|n| {
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
|
||||
for (si, ai, a) in path.anchors() {
|
||||
let d = a.p.distance(p);
|
||||
let open_end = path.subpaths.get(si).is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len()));
|
||||
if d <= tol && !open_end && best.is_none_or(|b| d < b.0) && editable(n.id) {
|
||||
best = Some((d, n.id, si, ai, a.p));
|
||||
}
|
||||
}
|
||||
});
|
||||
let (_, id, subpath, anchor, at) = best?;
|
||||
let sp = cx.doc.node(id)?.path_data()?.subpaths.get(subpath)?.clone();
|
||||
Some(Grip { id, subpath, anchor, p: at, sp })
|
||||
}
|
||||
|
||||
/// How far along a drag to `p` takes `g`: with Shift, to the nearest place where the tangent is at
|
||||
/// a multiple of 45° from the constrain angle.
|
||||
fn slide_to(cx: &ToolContext, g: &Grip, p: Point, mods: Mods) -> f64 {
|
||||
let t = slide_t_at(&g.sp, g.anchor, p).unwrap_or(0.0);
|
||||
let stops = if mods.shift { slide_stops(&g.sp, g.anchor, cx.constrain_angle) } else { vec![] };
|
||||
stops.into_iter().min_by(|a, b| (a - t).abs().total_cmp(&(b - t).abs())).unwrap_or(t)
|
||||
}
|
||||
|
||||
impl Tool for SlideAnchorTool {
|
||||
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 => {
|
||||
self.hover = grip_at(cx, ev.pos);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
let Some(g) = grip_at(cx, ev.pos) else { return vec![] };
|
||||
self.hover = None;
|
||||
self.drag = Some((g, 0.0));
|
||||
vec![Action::Begin(LABEL.into())]
|
||||
}
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
let Some((g, _)) = self.drag.take() else { return vec![] };
|
||||
let t = slide_to(cx, &g, ev.pos, ev.mods);
|
||||
let params = json!({ "id": g.id.0, "subpath": g.subpath, "anchor": g.anchor, "t": t });
|
||||
let preview = Action::Preview("addon.pathtools.slideAnchor".into(), params);
|
||||
if ev.kind == PointerKind::Drag {
|
||||
self.drag = Some((g, t));
|
||||
return vec![preview];
|
||||
}
|
||||
// Back where it started: nothing to keep.
|
||||
if t == 0.0 { vec![Action::Cancel] } else { vec![preview, Action::Commit] }
|
||||
}
|
||||
PointerKind::DoubleClick => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.drag.take().is_some() { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.drag.is_some() && key == ToolKey::Escape
|
||||
}
|
||||
|
||||
fn hover_ended(&mut self, _cx: &ToolContext) {
|
||||
self.hover = None;
|
||||
}
|
||||
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
let anchor = |p, filled| Overlay::Anchor { p, color: BLUE, filled, size: SIZE };
|
||||
match (&self.drag, &self.hover) {
|
||||
(Some((g, _)), _) => vec![anchor(g.p, false)],
|
||||
(None, Some(g)) => vec![anchor(g.p, true)],
|
||||
(None, None) => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::SlideAnchor
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::{Appearance, Document, Node, Selection};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
/// A document with an open zigzag (100, 100), (200, 100), (300, 100) and a circle-ish curve.
|
||||
fn doc_with_line() -> (Document, NodeId) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let pts = [Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(300.0, 100.0)];
|
||||
d.insert(Some(l), 0, Node::path(id, PathData::single(SubPath::polyline(&pts, false)), Appearance::default_art())).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_an_anchor_previews_the_slide_and_commits() {
|
||||
let (d, id) = doc_with_line();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = SlideAnchorTool::default();
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Move, 201.0, 101.0)).is_empty());
|
||||
assert!(matches!(t.overlays(&cx).as_slice(), [Overlay::Anchor { filled: true, .. }]));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 201.0, 101.0)), vec![Action::Begin(LABEL.into())]);
|
||||
assert!(t.busy());
|
||||
let drag = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 130.0));
|
||||
let Some(Action::Preview(cmd, params)) = drag.first() else { panic!("{drag:?}") };
|
||||
assert_eq!(cmd, "addon.pathtools.slideAnchor");
|
||||
assert_eq!((params["id"].clone(), params["subpath"].clone(), params["anchor"].clone()), (json!(id.0), json!(0), json!(1)));
|
||||
assert!((params["t"].as_f64().unwrap() - 0.5).abs() < 1e-6);
|
||||
// The other way along the path.
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 175.0, 90.0));
|
||||
assert!(matches!(up.as_slice(), [Action::Preview(_, p), Action::Commit] if p["t"].as_f64().unwrap() < 0.0), "{up:?}");
|
||||
assert!(!t.busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_where_it_started_nothing_is_kept_and_escape_cancels() {
|
||||
let (d, _) = doc_with_line();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = SlideAnchorTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 100.0)), vec![Action::Cancel]);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 100.0));
|
||||
t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 100.0));
|
||||
assert!(t.claims_key(&cx, ToolKey::Escape));
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert!(!t.busy() && !t.claims_key(&cx, ToolKey::Escape));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ends_and_empty_canvas_do_nothing() {
|
||||
let (d, _) = doc_with_line();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = SlideAnchorTool::default();
|
||||
for (x, y) in [(100.0, 100.0), (300.0, 100.0), (150.0, 300.0)] {
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Down, x, y)).is_empty(), "({x}, {y})");
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::Up, x, y)).is_empty());
|
||||
}
|
||||
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::SlideAnchor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_snaps_to_where_the_tangent_is_at_45_degrees() {
|
||||
// A smooth anchor on a curve turning from east to south through south-east.
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let a = |x: f64, y: f64, h: (f64, f64), o: (f64, f64)| vectorcraft_geom::Anchor {
|
||||
p: Point::new(x, y),
|
||||
h_in: Point::new(h.0, h.1),
|
||||
h_out: Point::new(o.0, o.1),
|
||||
kind: vectorcraft_geom::AnchorKind::Smooth,
|
||||
};
|
||||
let sp = SubPath::new(
|
||||
vec![
|
||||
a(0.0, 100.0, (0.0, 100.0), (0.0, 100.0)),
|
||||
a(100.0, 100.0, (100.0, 100.0), (155.0, 100.0)),
|
||||
a(200.0, 200.0, (200.0, 145.0), (200.0, 200.0)),
|
||||
],
|
||||
false,
|
||||
);
|
||||
d.insert(Some(l), 0, Node::path(id, PathData::single(sp.clone()), Appearance::default_art())).unwrap();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = SlideAnchorTool::default();
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0));
|
||||
let mut shifted = ev(PointerKind::Drag, 140.0, 105.0);
|
||||
shifted.mods.shift = true;
|
||||
let drag = t.pointer(&cx, &shifted);
|
||||
let Some(Action::Preview(_, params)) = drag.first() else { panic!("{drag:?}") };
|
||||
let at = params["t"].as_f64().unwrap();
|
||||
assert!((at - 0.5).abs() < 1e-6, "half way round a symmetric quarter turn: {at}");
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,12 @@ pub enum Cursor {
|
||||
/// 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 Corner Tool (Path Precision Tools add-on): a click or drag on an anchor cuts a corner;
|
||||
/// a corner's marker resizes it.
|
||||
CornerTool,
|
||||
/// The Slide Anchor Along Path tool (Path Precision Tools add-on): a drag runs an anchor along
|
||||
/// the path.
|
||||
SlideAnchor,
|
||||
/// 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,
|
||||
@@ -278,6 +284,8 @@ impl Cursor {
|
||||
| Cursor::ExtendPath
|
||||
| Cursor::MeasureInspect
|
||||
| Cursor::ParametricShape
|
||||
| Cursor::CornerTool
|
||||
| Cursor::SlideAnchor
|
||||
| Cursor::SmartPen => Cursor::Crosshair,
|
||||
c => c,
|
||||
}
|
||||
@@ -316,7 +324,9 @@ impl Cursor {
|
||||
| Cursor::AnchorEraser
|
||||
| Cursor::ExtendPath
|
||||
| Cursor::MeasureInspect
|
||||
| Cursor::ParametricShape => Some(Cursor::Crosshair),
|
||||
| Cursor::ParametricShape
|
||||
| Cursor::CornerTool
|
||||
| Cursor::SlideAnchor => Some(Cursor::Crosshair),
|
||||
Cursor::TypeVertical => Some(Cursor::Text),
|
||||
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
|
||||
Cursor::SmartPen => Some(Cursor::Pen),
|
||||
|
||||
@@ -702,6 +702,18 @@ 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."
|
||||
|
||||
[[cursor]]
|
||||
id = "CornerTool"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Corner Tool (Path Precision Tools add-on): a click or drag on an anchor where two sides meet cuts a corner there; a drag on a corner's marker resizes it; an Alt-drag cuts corners on every anchor inside the marquee."]
|
||||
hotspot = "Exact: the anchor or marker being picked."
|
||||
|
||||
[[cursor]]
|
||||
id = "SlideAnchor"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Slide Anchor Along Path tool (Path Precision Tools add-on): a drag moves an anchor along the curve it sits on."]
|
||||
hotspot = "Exact: the anchor being dragged follows it along the path."
|
||||
|
||||
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
|
||||
# The brush size is drawn by the app as a circle around the cursor.
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const ROWS: &[Row] = &[
|
||||
// The add-ons' tools (`crate::addons`).
|
||||
(&[crate::addons::anchor_eraser::ID], None, &["size", "tolerance", "pressure"]),
|
||||
(&[crate::addons::extend_path::ID], None, &["mode"]),
|
||||
(&[crate::addons::corner_tool::ID], None, &["radius", "kind"]),
|
||||
(&[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"]),
|
||||
|
||||
Reference in New Issue
Block a user