Files
vectorcraft/crates/tools/src/corners.rs
T

840 lines
41 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.
//! Live Corners: the widgets inside the corners of the selected paths. The Selection tool shows
//! them on live rectangles and polygons, Direct Selection on any path (a star, a pen path) at each
//! corner anchor: one that isn't smooth, where two sides, straight or curved, meet at an angle. Dragging one rounds (or
//! sharpens) the corners whose widgets show together, on every selected path (#938): every corner
//! of a path selected whole, those with a selected anchor when Direct Selection picked some. Alt-clicking a widget cycles their corner
//! kind (round, inverted round, chamfer); double-clicking one opens the Corners dialog. The
//! Selection and Direct Selection tools share this, and the canvas draws the widgets from the same
//! geometry.
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Document, LiveCorners, LiveShape, NodeId, NodeKind, Selection};
use vectorcraft_geom::corners::{Corner, cut_corners};
use vectorcraft_geom::shapes::CornerKind;
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
use crate::{Action, Overlay, PointerEvent, ToolContext};
/// The dialog a double-click on a corner widget opens: `{id, corners}`.
pub const DIALOG: &str = "corners";
/// Widgets sit at least this far inside their corner along each side of a right angle (screen px;
/// √2 times it along the bisector, at any angle), further in once the radius is.
const MIN_INSET_PX: f64 = 10.0;
/// Corners with a side shorter than this on screen (px) hide their widgets.
const MIN_SIDE_PX: f64 = 3.0 * MIN_INSET_PX;
/// One corner's widget.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Widget {
/// The corner, in the corners' own space.
corner: Corner,
/// The radius it is set to (document units), the one it is drawn with (no larger than its
/// neighbours leave room for) and its kind.
radius: f64,
drawn: f64,
kind: CornerKind,
/// The angle between its sides in the document, in degrees.
angle: f64,
/// The widget's centre in the document.
at: Point,
}
/// The corner widgets of one selected path.
#[derive(Clone, Debug, PartialEq)]
struct PathWidgets {
id: NodeId,
/// Maps the corners' space into the document (a live rectangle's own; else none).
xf: Affine,
/// The widgets that show (and which a drag edits), in anchor order.
widgets: Vec<Widget>,
/// Every corner of the path shows its widget.
all: bool,
}
impl PathWidgets {
/// The widgets of path `id` at `zoom` (screen px per document point), if it has any: any path
/// with Direct Selection (`any_path`), live rectangles and polygons with Selection. `id` is
/// editable ([`Document::is_editable`]).
fn of(doc: &Document, selection: &Selection, id: NodeId, zoom: f64, any_path: bool) -> Option<Self> {
if selection.segments.contains_key(&id) {
return None;
}
let NodeKind::Path { path, live, .. } = &doc.node(id)?.kind else { return None };
if !any_path && !matches!(live.as_deref(), Some(LiveShape::Rectangle { .. } | LiveShape::Polygon { .. })) {
return None;
}
let lc = LiveCorners::new(path, live.as_deref())?;
let det = lc.xf.determinant().abs();
if det < 1e-12 {
return None;
}
// Screen pixels per unit of the corners' space (a rectangle's may be skewed).
let scale = zoom * det.sqrt();
let picked = lc.picked(selection.partial(id));
let widgets: Vec<_> = lc
.corners
.iter()
.filter(|c| picked.contains(&c.index) && c.lu.min(c.lv) * scale >= MIN_SIDE_PX)
.map(|c| {
// On the bisector at the centre of the arc as drawn, at least the minimum inset
// in and at most half the shorter side.
let (radius, drawn) = (lc.radius(c.index), lc.drawn_radius(c.index));
let k = (drawn / c.sin()).max(MIN_INSET_PX / (scale * (1.0 + c.cos()).sqrt())).min(c.lu.min(c.lv) / 2.0);
Widget { corner: *c, radius, drawn, kind: lc.kind(c.index), angle: angle_in(lc.xf, c), at: lc.xf * c.on_bisector(k) }
})
.collect();
let all = widgets.len() == lc.corners.len();
(!widgets.is_empty()).then_some(Self { id, xf: lc.xf, widgets, all })
}
/// The corners whose widgets show: anchor indices of the path's uncut outline.
fn corners(&self) -> Vec<usize> {
self.widgets.iter().map(|w| w.corner.index).collect()
}
}
/// The corner widgets of the selected paths that have some.
#[derive(Clone, Debug, PartialEq)]
pub struct CornerWidgets {
paths: Vec<PathWidgets>,
}
impl CornerWidgets {
/// The widgets at `zoom` (screen px per document point), if the selection has them: on any
/// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection.
pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option<Self> {
let editable = doc.editable();
let paths: Vec<_> =
selection.objects.iter().filter(|id| editable(**id)).filter_map(|id| PathWidgets::of(doc, selection, *id, zoom, any_path)).collect();
(!paths.is_empty()).then_some(Self { paths })
}
/// Selection & Anchor Display → Hide Corner Widget for angles greater than: the widgets of
/// corners wider than `max` degrees (in the document) hide; none if all do.
pub fn within_angle(mut self, max: f64) -> Option<Self> {
for p in &mut self.paths {
let n = p.widgets.len();
p.widgets.retain(|w| w.angle <= max + 1e-9);
p.all &= p.widgets.len() == n;
}
self.paths.retain(|p| !p.widgets.is_empty());
(!self.paths.is_empty()).then_some(self)
}
/// The widgets on show: [`Self::of`] with View → Show Corner Widget `on`, less those wider
/// than `max_angle` ([`Self::within_angle`]).
pub fn showing(doc: &Document, selection: &Selection, zoom: f64, any_path: bool, on: bool, max_angle: f64) -> Option<Self> {
if !on {
return None;
}
Self::of(doc, selection, zoom, any_path)?.within_angle(max_angle)
}
/// The widgets the active tool can drag (Direct Selection: `any_path`).
pub fn for_tool(cx: &ToolContext, any_path: bool) -> Option<Self> {
Self::showing(cx.doc, cx.selection, cx.zoom, any_path, cx.corner_widgets, cx.corner_widget_max_angle)
}
/// The centres of the widgets that show.
pub fn visible(&self) -> impl Iterator<Item = Point> + '_ {
self.visible_on().map(|(_, at)| at)
}
/// The centres of the widgets that show, each with the object it belongs to.
pub fn visible_on(&self) -> impl Iterator<Item = (NodeId, Point)> + '_ {
self.paths.iter().flat_map(|p| p.widgets.iter().map(move |w| (p.id, w.at)))
}
/// The corners whose widgets show, path after path: anchor indices of each path's uncut
/// outline.
pub fn corners(&self) -> Vec<usize> {
self.paths.iter().flat_map(PathWidgets::corners).collect()
}
/// The widget nearest to `p` within `tol` (document units): its path's and its own index.
fn hit(&self, p: Point, tol: f64) -> Option<(usize, usize)> {
let d = |&(i, k): &(usize, usize)| self.widget(i, k).map_or(f64::INFINITY, |w| w.at.distance(p));
self.paths
.iter()
.enumerate()
.flat_map(|(i, path)| (0..path.widgets.len()).map(move |k| (i, k)))
.filter(|ik| d(ik) <= tol)
.min_by(|a, b| d(a).total_cmp(&d(b)))
}
fn widget(&self, path: usize, k: usize) -> Option<&Widget> {
self.paths.get(path).and_then(|p| p.widgets.get(k))
}
/// `object.setLiveShape` params setting `key` on the shown corners (every corner of a path
/// unless some are hidden, which `corners` then leaves out): one path by its `id`, several as
/// `items`.
fn command(&self, key: &str, value: Value) -> Value {
let corners = |p: &PathWidgets| (!p.all).then(|| p.corners());
let mut out = Map::new();
match self.paths.as_slice() {
[p] => {
out.insert("id".into(), json!(p.id.0));
if let Some(c) = corners(p) {
out.insert("corners".into(), json!(c));
}
}
paths => {
let items: Vec<Value> = paths
.iter()
.map(|p| match corners(p) {
Some(c) => json!({ "id": p.id.0, "corners": c }),
None => json!({ "id": p.id.0 }),
})
.collect();
out.insert("items".into(), Value::Array(items));
}
}
out.insert(key.into(), value);
Value::Object(out)
}
}
/// The angle between corner `c`'s sides once `xf` maps them into the document, in degrees.
fn angle_in(xf: Affine, c: &Corner) -> f64 {
let [a, b, cc, d, _, _] = xf.as_coeffs();
let map = |v: Vec2| Vec2::new(a * v.x + cc * v.y, b * v.x + d * v.y);
let (u, v) = (map(c.u), map(c.v));
u.cross(v).abs().atan2(u.dot(v)).to_degrees()
}
/// Is `p` over a corner widget the active tool would drag (Direct Selection: `any_path`)?
pub fn over_widget(cx: &ToolContext, p: Point, any_path: bool) -> bool {
CornerWidgets::for_tool(cx, any_path).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
}
/// A double-click on a corner widget opens the Corners dialog for the shown corners of its path.
pub fn double_click(cx: &ToolContext, p: Point, any_path: bool) -> Option<Action> {
let w = CornerWidgets::for_tool(cx, any_path)?;
let (i, _) = w.hit(p, cx.tol(5.0))?;
let path = w.paths.get(i)?;
Some(Action::Dialog(DIALOG.into(), json!({ "id": path.id.0, "corners": path.corners() })))
}
/// A path's uncut outline with its corners' radii and kinds, and the corners' space (to outline
/// the corners at their largest).
#[derive(Clone, Debug)]
struct Outline {
base: PathData,
/// The corners of `base`.
corners: Vec<Corner>,
radii: Vec<f64>,
kinds: Vec<CornerKind>,
xf: Affine,
}
/// Dragging a corner widget: the radius follows the pointer along the corner's bisector. An
/// Alt-click (no drag) cycles the corner kind instead.
#[derive(Clone, Debug)]
pub struct CornerDrag {
widgets: CornerWidgets,
/// The dragged widget: its path's and its own index among the shown ones.
widget: (usize, usize),
/// Each shown path's outline, in the order of [`CornerWidgets`]' paths.
outlines: Vec<Outline>,
start: Point,
began: bool,
alt: bool,
radius: f64,
/// The pointer went past fully round: the dragged corner is at its largest radius.
at_limit: bool,
at: Point,
/// Direct Selection: what a click (no drag) on the pressed widget selects.
click: Option<CornerClick>,
}
/// Direct Selection: the drawn anchors of a widget's corner, which a click on the widget selects
/// (Shift: toggles), as a click on the corner's anchor does.
#[derive(Clone, Debug)]
struct CornerClick {
id: NodeId,
anchors: Vec<(usize, usize)>,
shift: bool,
}
impl CornerDrag {
/// The drag has left the click: the corner radius is changing.
pub fn adjusting(&self) -> bool {
self.began
}
/// Start a drag when the press `ev` is on a widget of the selection (Direct Selection:
/// `any_path`).
pub fn hit(cx: &ToolContext, ev: &PointerEvent, any_path: bool) -> Option<Self> {
let (widgets, p) = (CornerWidgets::for_tool(cx, any_path)?, ev.pos);
let widget = widgets.hit(p, cx.tol(5.0))?;
let radius = widgets.widget(widget.0, widget.1)?.radius;
let click = any_path
.then(|| {
let path = widgets.paths.get(widget.0)?;
let corner = path.widgets.get(widget.1)?.corner.index;
let lc = cx.doc.node(path.id).and_then(LiveCorners::of)?;
Some(CornerClick { id: path.id, anchors: lc.anchors_of(&[corner].into()).into_iter().collect(), shift: ev.mods.shift })
})
.flatten();
let outlines = widgets
.paths
.iter()
.map(|w| {
let lc = cx.doc.node(w.id).and_then(LiveCorners::of)?;
let n = lc.base.anchor_count();
let (radii, kinds) = ((0..n).map(|k| lc.radius(k)).collect(), (0..n).map(|k| lc.kind(k)).collect());
Some(Outline { corners: lc.corners.clone(), base: lc.base.into_owned(), radii, kinds, xf: w.xf })
})
.collect::<Option<Vec<_>>>()?;
Some(Self { widgets, widget, outlines, start: p, began: false, alt: ev.mods.alt, radius, at_limit: false, at: p, click })
}
/// The start radius (as drawn: no larger than fits) plus the pointer's travel along the
/// corner's bisector (in the corners' own units), from square to fully round; and whether the
/// travel reached fully round.
fn radius_at(&self, p: Point) -> (f64, bool) {
let (path, k) = self.widget;
let (Some(w), Some(xf)) = (self.widgets.widget(path, k), self.widgets.paths.get(path).map(|p| p.xf)) else {
return (self.radius, self.at_limit);
};
let inv = xf.inverse();
let max = self.max_radius(path, &w.corner);
let r = w.drawn + w.corner.radius_change(inv * p - inv * self.start);
(r.min(max).max(0.0), max > 0.0 && r >= max)
}
/// The largest radius `corner` of shown path `path` draws while the drag sets every shown
/// corner of that path to one radius: up to the cuts of its neighbours, as far as its own
/// sides when they are square (#2332).
fn max_radius(&self, path: usize, corner: &Corner) -> f64 {
let (Some(shown), Some(o)) = (self.widgets.paths.get(path), self.outlines.get(path)) else { return corner.max_radius() };
corner.max_shared_radius(&o.corners, &o.radii, |k| shown.widgets.iter().any(|w| w.corner.index == k))
}
/// The cuts of the edited corners that are at their largest radius, in document coordinates.
fn corners_at_limit(&self) -> BezPath {
let mut out = BezPath::new();
for (n, (shown, o)) in self.widgets.paths.iter().zip(&self.outlines).enumerate() {
let shown = &shown.widgets;
let mut radii = o.radii.clone();
for w in shown {
if let Some(r) = radii.get_mut(w.corner.index) {
*r = self.radius;
}
}
let full = |k: &usize| shown.iter().any(|w| w.corner.index == *k && self.radius >= self.max_radius(n, &w.corner) - 1e-9);
let (path, sources) = cut_corners(&o.base, &radii, &o.kinds);
for (sp, from) in path.transformed(o.xf).subpaths.iter().zip(&sources) {
// A corner's cut is the segment between its two anchors (the last one, for a
// closed subpath's first corner).
for i in 0..sp.segment_count() {
let (a, b) = (from.get(i), from.get(i + 1).or(from.first()));
if a == b && a.is_some_and(full) {
let c = sp.segment(i);
out.move_to(c.p0);
out.curve_to(c.p1, c.p2, c.p3);
}
}
}
}
out
}
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
let mut out = vec![];
if !self.began {
if p.distance(self.start) < cx.tol(3.0) {
return out;
}
self.began = true;
out.push(Action::Begin("Corner Radius".into()));
}
(self.radius, self.at_limit) = self.radius_at(p);
self.at = p;
out.push(Action::Preview("object.setLiveShape".into(), self.widgets.command("radius", json!(self.radius))));
out
}
pub fn finish(self) -> Vec<Action> {
if self.began {
vec![Action::Commit]
} else if let Some(w) = self.widgets.widget(self.widget.0, self.widget.1).filter(|_| self.alt) {
vec![Action::Exec("object.setLiveShape".into(), self.widgets.command("kind", json!(w.kind.next())))]
} else if let Some(c) = self.click {
// Direct Selection: the click selects the widget's corner, so the next drag on this
// widget rounds that corner alone (its widget is then the only one showing).
let mode = if c.shift { "toggle" } else { "set" };
vec![Action::Exec("select.anchors".into(), json!({"id": c.id.0, "anchors": c.anchors, "mode": mode}))]
} else {
vec![]
}
}
/// The radius readout next to the pointer; at the largest radius, the corners there in red.
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if !self.began {
return vec![];
}
let mut out = vec![];
if cx.measurement_labels {
out.push(Overlay::Measure { p: self.at, text: format!("Radius: {}", cx.len(self.radius)) });
}
if self.at_limit {
out.push(Overlay::Path { path: self.corners_at_limit(), color: crate::builder::HIGHLIGHT_RED, width: 2.0, dashed: false });
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use crate::{Mods, PointerKind};
use vectorcraft_doc::{Appearance, Node};
/// A live 100 × 100 rectangle at (100, 100) with corner radius `r`.
fn live_rect(r: f64, xf: Affine) -> (Document, NodeId) {
live_shape(100.0, 100.0, r, xf)
}
/// A live `w` × `h` rectangle with corner radius `r`.
fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf };
doc_with(live.to_path(), Some(live))
}
/// A document holding one path with its live shape.
fn doc_with(path: PathData, live: Option<LiveShape>) -> (Document, NodeId) {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
let id = d.alloc_id();
let mut n = Node::path(id, path, Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = live.map(Box::new);
}
d.insert(Some(l), 0, n).unwrap();
(d, id)
}
/// A five-pointed star at (200, 200) of radii 60 and 30 (#511): a plain path.
fn star() -> (Document, NodeId) {
doc_with(vectorcraft_geom::shapes::star(Point::new(200.0, 200.0), 60.0, 30.0, 5, 0.0), None)
}
fn selected(id: NodeId) -> Selection {
let mut s = Selection::default();
s.add(id);
s
}
fn pts(w: &CornerWidgets) -> Vec<Point> {
w.visible().collect()
}
fn radius(a: &Action) -> f64 {
let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") };
assert_eq!(c, "object.setLiveShape");
v["radius"].as_f64().unwrap()
}
#[test]
fn widgets_sit_inside_each_corner() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap();
assert_eq!(pts(&w), [Point::new(110.0, 110.0), Point::new(190.0, 110.0), Point::new(190.0, 190.0), Point::new(110.0, 190.0)]);
// A larger radius moves them to the arc centres; zooming in keeps a 10 px minimum.
let (d, id) = live_rect(20.0, Affine::translate((100.0, 100.0)));
assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap())[0], Point::new(120.0, 120.0));
assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 4.0, false).unwrap())[2], Point::new(180.0, 180.0));
// Hidden when the shape is tiny on screen and without a single selection; a plain path
// shows them with Direct Selection only.
assert!(CornerWidgets::of(&d, &selected(id), 0.2, true).is_none());
let (d, id) = doc_with_rect();
assert!(CornerWidgets::of(&d, &selected(id), 1.0, false).is_none());
assert_eq!(pts(&CornerWidgets::of(&d, &selected(id), 1.0, true).unwrap())[0], Point::new(110.0, 110.0));
assert!(CornerWidgets::of(&d, &Selection::default(), 1.0, true).is_none());
}
#[test]
fn dragging_a_widget_inward_rounds_all_corners_in_one_step() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
for mut t in [crate::create("selection"), crate::create("directSelection")] {
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0));
assert_eq!(a[0], Action::Begin("Corner Radius".into()));
assert!((radius(&a[1]) - 15.0).abs() < 1e-9);
assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "Radius: 15.00 pt")));
let quiet = ToolContext { measurement_labels: false, ..crate::testutil::cx(&d, &s, &p) };
assert!(!t.overlays(&quiet).iter().any(|o| matches!(o, Overlay::Measure { .. })));
// Past the middle the corner is fully round; back outside it is square.
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 100.0))[0]), 50.0);
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 260.0))[0]), 0.0);
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 260.0)), vec![Action::Commit]);
}
}
/// #938: with two rectangles selected, both show their widgets, and a drag on either rounds
/// the corners of both in one step; with Direct Selection, each its shown corners.
#[test]
fn widgets_of_several_selected_paths_drag_together() {
let (mut d, a) = live_rect(0.0, Affine::translate((100.0, 100.0)));
let live = LiveShape::Rectangle {
w: 100.0,
h: 100.0,
radii: [0.0; 4],
kinds: Default::default(),
radii_relative: false,
xf: Affine::translate((300.0, 100.0)),
};
let b = d.alloc_id();
let mut n = Node::path(b, live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(Box::new(live));
}
let l = d.layers[0].id;
d.insert(Some(l), 1, n).unwrap();
let mut s = selected(a);
s.add(b);
assert_eq!(pts(&CornerWidgets::of(&d, &s, 1.0, false).unwrap()).len(), 8);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 390.0, 191.0));
let preview = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 375.0, 176.0));
assert!((radius(&preview[1]) - 15.0).abs() < 1e-9);
let Action::Preview(_, v) = &preview[1] else { panic!() };
assert_eq!(v["items"], json!([{"id": a.0}, {"id": b.0}]));
// Direct Selection with one anchor of the first: its corner, and the whole second.
s.anchors.insert(a, [(0, 2)].into());
let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap();
assert_eq!(w.corners(), [2, 0, 1, 2, 3]);
assert_eq!(w.command("radius", json!(5.0)), json!({"items": [{"id": a.0, "corners": [2]}, {"id": b.0}], "radius": 5.0}));
}
/// #442: on a 200 × 80 rectangle the widgets sit at the centres of the corners as drawn, also
/// with a radius past half the shorter side; a drag past fully round stops every corner there
/// and outlines them in red.
#[test]
fn widgets_of_a_non_square_rectangle_follow_the_drawn_corners() {
let (d, id) = live_shape(200.0, 80.0, 60.0, Affine::translate((100.0, 100.0)));
let s = selected(id);
let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap();
let centres = [Point::new(140.0, 140.0), Point::new(260.0, 140.0), Point::new(260.0, 140.0), Point::new(140.0, 140.0)];
assert_eq!(pts(&w), centres);
let p = paint();
let cx = cx(&d, &s, &p);
let red = |t: &dyn crate::Tool| {
t.overlays(&cx).into_iter().find_map(|o| match o {
Overlay::Path { path, color, .. } if color == crate::builder::HIGHLIGHT_RED => Some(path),
_ => None,
})
};
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 140.0, 140.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 200.0));
assert_eq!(radius(&a[1]), 40.0);
// The four arcs, each a quarter circle of radius 40 starting on a side.
let arcs = red(t.as_ref()).expect("the limit shows");
let starts: Vec<_> =
arcs.elements().iter().filter_map(|e| if let vectorcraft_geom::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect();
assert_eq!(starts, [Point::new(260.0, 100.0), Point::new(300.0, 140.0), Point::new(140.0, 180.0), Point::new(100.0, 140.0)]);
// Back below the limit, the outline goes.
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[0]), 30.0);
assert!(red(t.as_ref()).is_none());
}
/// #442: a rectangle saved stretched (an uneven scale in its transform, before #291) shows its
/// widgets and drags its radius in document units, as `object.setLiveShape` sets it.
#[test]
fn a_stretched_rectangle_drags_its_radius_in_document_units() {
// 50 × 80 with 10 pt corners stretched 4 × 1: drawn 200 × 80, a mean radius of 20.
let (d, id) = live_shape(50.0, 80.0, 10.0, Affine::translate((100.0, 100.0)) * Affine::scale_non_uniform(4.0, 1.0));
let s = selected(id);
let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap();
assert_eq!(pts(&w)[0], Point::new(120.0, 120.0));
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = crate::create("directSelection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 120.0, 120.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 110.0, 110.0));
assert!((radius(&a[1]) - 10.0).abs() < 1e-9, "{a:?}");
}
#[test]
fn drag_follows_a_rotated_shape_and_a_click_does_nothing() {
// Rotated 90°: the shape's top-left corner is at document (200, 100).
let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2);
let (d, id) = live_rect(10.0, xf);
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
let w = CornerWidgets::of(&d, &s, 1.0, false).unwrap();
assert!(pts(&w)[0].distance(Point::new(190.0, 110.0)) < 1e-9);
let mut t = crate::create("directSelection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 120.0));
assert!((radius(&a[1]) - 20.0).abs() < 1e-9);
// A click without a drag leaves the shape (and the undo history) alone.
let mut t = crate::create("selection");
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0)).is_empty());
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 110.0)).is_empty());
}
/// Direct Selection: a click on a widget (no drag) selects its corner, as a click on the
/// corner's anchor does, so the next drag on that widget rounds that corner alone; Shift
/// toggles the corner. The Selection tool's click still does nothing.
#[test]
fn a_click_on_a_widget_with_direct_selection_selects_its_corner() {
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
let w = pts(&CornerWidgets::of(&d, &s, 1.0, true).unwrap())[2];
// The bottom-right corner of a rounded rectangle is drawn with two anchors.
let anchors: Vec<(usize, usize)> = LiveCorners::of(d.node(id).unwrap()).unwrap().anchors_of(&[2].into()).into_iter().collect();
assert_eq!(anchors.len(), 2);
let mut t = crate::create("directSelection");
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "set"}))]);
let shift = Mods { shift: true, ..Mods::default() };
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y).with_mods(shift));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y).with_mods(shift));
assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": anchors, "mode": "toggle"}))]);
// With that corner selected, its widget alone shows, and a drag rounds it alone
// (`direct_selected_corners_show_their_widgets_alone_and_round_alone`).
let mut part = selected(id);
part.anchors.insert(id, anchors.iter().copied().collect());
assert_eq!(CornerWidgets::of(&d, &part, 1.0, true).unwrap().corners(), [2]);
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, w.x, w.y));
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, w.x, w.y)).is_empty());
}
#[test]
fn direct_selected_corners_show_their_widgets_alone_and_round_alone() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
// Direct Selection picked the bottom-right anchor.
let mut s = selected(id);
s.anchors.insert(id, [(0, 2)].into());
let p = paint();
let cx = cx(&d, &s, &p);
let w = CornerWidgets::of(&d, &s, 1.0, true).unwrap();
assert_eq!(w.corners(), [2]);
assert_eq!(pts(&w), [Point::new(190.0, 190.0)]);
assert!(!over_widget(&cx, Point::new(110.0, 110.0), true), "the other corners hide theirs");
let mut t = crate::create("directSelection");
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0));
assert_eq!(a[0], Action::Begin("Corner Radius".into()));
assert_eq!(a[1], Action::Preview("object.setLiveShape".into(), json!({"id": id.0, "radius": 15.0, "corners": [2]})));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 175.0, 176.0)), vec![Action::Commit]);
}
/// #2332: one corner rounded alone goes past half the side, until its cut reaches the
/// neighbouring anchors (the whole side of a square), or a rounded neighbour's cut.
#[test]
fn a_corner_rounded_alone_rounds_up_to_its_neighbours() {
for (top_right, max) in [(0.0, 100.0), (30.0, 70.0)] {
let live = LiveShape::Rectangle {
w: 100.0,
h: 100.0,
radii: [0.0, top_right, 0.0, 0.0],
kinds: Default::default(),
radii_relative: false,
xf: Affine::translate((100.0, 100.0)),
};
let (d, id) = doc_with(live.to_path(), Some(live));
// Direct Selection picked the bottom-right anchor.
let mut s = selected(id);
s.anchors.insert(id, [(0, if top_right > 0.0 { 3 } else { 2 })].into());
let p = paint();
let cx = cx(&d, &s, &p);
assert_eq!(CornerWidgets::of(&d, &s, 1.0, true).unwrap().corners(), [2]);
let mut t = crate::create("directSelection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 190.0));
assert!((radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 130.0, 130.0))[1]) - 60.0).abs() < 1e-9);
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "60 is below the limit");
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.0, 0.0))[0]), max);
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Path { .. })), "the limit shows");
}
}
#[test]
fn alt_click_cycles_the_kind_and_double_click_opens_the_dialog() {
let (d, id) = live_rect(10.0, Affine::translate((100.0, 100.0)));
let p = paint();
let alt = Mods { alt: true, ..Mods::default() };
let whole = selected(id);
let mut part = selected(id);
part.anchors.insert(id, [(0, 0), (0, 7)].into());
for (s, extra) in [(&whole, json!({})), (&part, json!({"corners": [0]}))] {
let cx = cx(&d, s, &p);
let mut want = json!({"id": id.0, "kind": "invertedRound"});
want.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
for tool in ["selection", "directSelection"] {
let mut t = crate::create(tool);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 110.0).with_mods(alt)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 110.0, 110.0).with_mods(alt));
assert_eq!(a, vec![Action::Exec("object.setLiveShape".into(), want.clone())], "{tool}");
}
}
// A double-click on a widget opens Corners for the shown corners; elsewhere it doesn't.
let cx1 = cx(&d, &part, &p);
let a = crate::create("directSelection").pointer(&cx1, &PointerEvent::new(PointerKind::DoubleClick, 110.0, 110.0));
assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0]}))]);
let cx2 = cx(&d, &whole, &p);
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 190.0, 110.0));
assert_eq!(a, vec![Action::Dialog(DIALOG.into(), json!({"id": id.0, "corners": [0, 1, 2, 3]}))]);
// Off the widgets, the Selection tool's double-click isolates the shape (Double Click To
// Isolate) instead.
let a = crate::create("selection").pointer(&cx2, &PointerEvent::new(PointerKind::DoubleClick, 150.0, 150.0));
assert_eq!(a, vec![Action::Exec("object.isolate".into(), json!({"id": id.0}))]);
}
/// Hide Corner Widget for angles greater than (#394): a rectangle's right angles hide below
/// 90°; sheared, only its acute corners keep their widgets.
#[test]
fn corners_wider_than_the_preference_hide_their_widgets() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap();
assert_eq!(w.clone().within_angle(177.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3]));
assert_eq!(w.clone().within_angle(90.0).map(|w| w.corners()), Some(vec![0, 1, 2, 3]));
assert!(w.within_angle(89.0).is_none());
// Sheared by 30°: 60° at the top-left and bottom-right corners, 120° at the others.
let shear = Affine::translate((100.0, 100.0)) * Affine::new([1.0, 0.0, 30f64.to_radians().tan(), 1.0, 0.0, 0.0]);
let (d, id) = live_rect(0.0, shear);
let w = CornerWidgets::of(&d, &selected(id), 1.0, false).unwrap();
assert_eq!(w.clone().within_angle(100.0).map(|w| w.corners()), Some(vec![0, 2]));
let s = selected(id);
let p = paint();
let c = ToolContext { corner_widget_max_angle: 100.0, ..cx(&d, &s, &p) };
assert!(over_widget(&c, pts(&w)[0], false));
assert!(!over_widget(&c, pts(&w)[1], false), "the 120° corner's widget is hidden");
}
#[test]
fn widgets_off_or_elsewhere_keep_the_tools_behaviour() {
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
let s = selected(id);
let p = paint();
let mut c = cx(&d, &s, &p);
assert!(over_widget(&c, Point::new(110.0, 110.0), false));
assert!(!over_widget(&c, Point::new(150.0, 150.0), false));
c.corner_widgets = false;
assert!(!over_widget(&c, Point::new(110.0, 110.0), true));
// With the widgets hidden, a drag from the same spot moves the object.
let mut t = crate::create("selection");
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 110.0, 110.0));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 110.0));
assert_eq!(a[0], Action::Begin("Move".into()));
}
/// #511: Direct Selection shows a widget in each of a star's ten corners (the Selection tool
/// none: a star isn't a live shape); dragging a tip's inward rounds them all as one step.
#[test]
fn a_star_shows_widgets_in_every_corner_with_direct_selection() {
let (d, id) = star();
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
assert!(CornerWidgets::for_tool(&cx, false).is_none());
let w = CornerWidgets::for_tool(&cx, true).expect("widgets on a plain path");
assert_eq!(w.corners(), (0..10).collect::<Vec<_>>());
// The top tip's widget sits 10 √2 px down its bisector.
let tip = pts(&w)[0];
assert!(tip.distance(Point::new(200.0, 140.0 + 10.0 * 2f64.sqrt())) < 1e-9, "{tip:?}");
let mut t = crate::create("directSelection");
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0));
assert_eq!(a[0], Action::Begin("Corner Radius".into()));
// The widget follows the arc's centre: r / sin(angle / 2) along the bisector.
let c = vectorcraft_geom::corners::path_corners(&star_path(&d, id))[0];
let want = 10.0 * (c.angle_deg() / 2.0).to_radians().sin();
assert!((radius(&a[1]) - want).abs() < 1e-9, "{a:?} want {want}");
assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0 && v.get("corners").is_none()), "every corner: {a:?}");
// The Selection tool drags the star instead.
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, tip.x, tip.y + 10.0))[0], Action::Begin("Move".into()));
}
fn star_path(d: &Document, id: NodeId) -> PathData {
let Some(NodeKind::Path { path, .. }) = d.node(id).map(|n| &n.kind) else { panic!("not a path") };
path.clone()
}
/// #511: a Direct-Selected anchor of the star shows its corner's widget alone, and the
/// preference hides the star's wide inner corners (about 125°) below their angle.
#[test]
fn star_corners_follow_the_anchors_and_the_angle_preference() {
let (d, id) = star();
let mut s = selected(id);
s.anchors.insert(id, [(0, 3)].into());
let p = paint();
let c = cx(&d, &s, &p);
let w = CornerWidgets::for_tool(&c, true).unwrap();
assert_eq!(w.corners(), [3]);
let mut t = crate::create("directSelection");
let at = pts(&w)[0];
t.pointer(&c, &PointerEvent::new(PointerKind::Down, at.x, at.y));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, at.x + 20.0, at.y + 20.0));
assert!(matches!(&a[1], Action::Preview(_, v) if v["corners"] == json!([3])), "{a:?}");
// Corners wider than 90° hide: the five tips keep theirs.
let whole = selected(id);
let c = ToolContext { corner_widget_max_angle: 90.0, ..cx(&d, &whole, &p) };
let w = CornerWidgets::for_tool(&c, true).unwrap();
assert_eq!(w.corners(), [0, 2, 4, 6, 8]);
assert_eq!(w.command("radius", json!(4.0))["corners"], json!([0, 2, 4, 6, 8]));
}
/// A live polygon shows its widgets to the Selection tool too; past every corner's limit the
/// cuts outline in red.
#[test]
fn a_live_polygon_rounds_with_either_tool() {
let live =
LiveShape::Polygon { radius: 80.0, sides: 6, xf: Affine::translate((200.0, 200.0)), radii: vec![], kinds: vec![], radii_relative: false };
let (d, id) = doc_with(live.to_path(), Some(live));
let s = selected(id);
let p = paint();
let cx = cx(&d, &s, &p);
for any_path in [false, true] {
assert_eq!(CornerWidgets::for_tool(&cx, any_path).unwrap().corners().len(), 6);
}
let tip = pts(&CornerWidgets::for_tool(&cx, false).unwrap())[0];
let mut t = crate::create("selection");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, tip.x, tip.y));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 200.0, 300.0));
// A hexagon's 120° corners stop where their cuts meet: 40 · tan 60°.
assert!((radius(&a[1]) - 40.0 * 3f64.sqrt()).abs() < 1e-9, "{a:?}");
let red = t
.overlays(&cx)
.into_iter()
.any(|o| matches!(o, Overlay::Path { color, path, .. } if color == crate::builder::HIGHLIGHT_RED && path.elements().len() == 12));
assert!(red, "six arcs at the limit");
}
/// A pen path's corners show widgets, curved ones too (#1144); its ends don't.
#[test]
fn a_pen_path_shows_widgets_on_its_corners_only() {
let mut sp = vectorcraft_geom::SubPath::polyline(
&[Point::new(100.0, 100.0), Point::new(300.0, 100.0), Point::new(300.0, 300.0), Point::new(100.0, 300.0)],
false,
);
sp.anchors[2].h_out = Point::new(250.0, 350.0);
let (d, id) = doc_with(PathData::single(sp), None);
let s = selected(id);
let p = paint();
let w = CornerWidgets::for_tool(&cx(&d, &s, &p), true).unwrap();
assert_eq!(w.corners(), [1, 2]);
assert_eq!(pts(&w)[0], Point::new(290.0, 110.0));
}
}