mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Ellipse: dragging a pie widget on the canvas sets its pie angle
The Selection tool shows two pie widgets just outside a selected live ellipse while the bounding box shows: one at the Pie Start Angle and one at the Pie End Angle. Dragging one sets its angle to the angle of the outline's point that lies in the direction of the pointer from the ellipse's center, in one undo step, through `object.setLiveShape`'s `pieStart` or `pieEnd`. Double-clicking one makes the ellipse whole again (0° to 360°). Where the two widgets coincide, as on a whole ellipse, a press takes the End widget. Docs: docs/target-app-parity.md, docs/ui-parity.md.
This commit is contained in:
@@ -331,6 +331,56 @@ fn an_ellipse_takes_pie_angles_and_inverts() {
|
||||
assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err());
|
||||
}
|
||||
|
||||
/// The Selection tool's pie widgets: dragging the End widget from 0° to straight up sets Pie End
|
||||
/// Angle to 90 in one undo step; on an inverted pie a drag moves only the dragged angle; a
|
||||
/// double-click on a widget makes the ellipse whole again in one step.
|
||||
#[test]
|
||||
fn dragging_and_double_clicking_the_pie_widgets() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
let id = NodeId(s.execute("shape.ellipse", &json!({"x": 50, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
let pie = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Path { live: Some(l), .. } => match &**l {
|
||||
LiveShape::Ellipse { pie, .. } => *pie,
|
||||
k => panic!("{k:?}"),
|
||||
},
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9;
|
||||
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
let gesture = |s: &mut Session, events: &[(PointerKind, f64, f64)]| {
|
||||
for (kind, x, y) in events {
|
||||
s.pointer(&PointerEvent::new(*kind, *x, *y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
};
|
||||
// The center is (150, 150); both widgets of the whole ellipse sit 12 pt right of (250, 150).
|
||||
let n = steps(&s);
|
||||
gesture(&mut s, &[(PointerKind::Down, 262.0, 150.0), (PointerKind::Drag, 150.0, 20.0), (PointerKind::Up, 150.0, 20.0)]);
|
||||
assert!(near(pie(&s), (0.0, 90.0)), "{:?}", pie(&s));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
// Inverted, the pie runs from 90° to 360°: dragging the End widget (back at 0°) to 270°
|
||||
// leaves the start at 90°.
|
||||
s.execute("object.setLiveShape", &json!({"invertPie": true})).unwrap();
|
||||
assert!(near(pie(&s), (90.0, 360.0)));
|
||||
gesture(&mut s, &[(PointerKind::Down, 262.0, 150.0), (PointerKind::Drag, 150.0, 280.0), (PointerKind::Up, 150.0, 280.0)]);
|
||||
assert!(near(pie(&s), (90.0, 270.0)), "{:?}", pie(&s));
|
||||
// The Start widget sits 12 pt above the top of the outline, at 90°.
|
||||
let n = steps(&s);
|
||||
gesture(
|
||||
&mut s,
|
||||
&[(PointerKind::Down, 150.0, 88.0), (PointerKind::Up, 150.0, 88.0), (PointerKind::Down, 150.0, 88.0), (PointerKind::Up, 150.0, 88.0)],
|
||||
);
|
||||
assert_eq!(steps(&s), n, "two clicks record no undo step");
|
||||
gesture(&mut s, &[(PointerKind::DoubleClick, 150.0, 88.0)]);
|
||||
assert_eq!(pie(&s), (0.0, 360.0));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(pie(&s), (90.0, 270.0)));
|
||||
}
|
||||
|
||||
/// #812: a polygon's Polygon Properties: its angle, radius and side length, each one undo step;
|
||||
/// Make Sides Equal after an uneven scale; bad values are errors.
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod meshedit;
|
||||
pub mod params;
|
||||
pub mod pathtype;
|
||||
pub mod pen;
|
||||
pub mod pie;
|
||||
pub mod place;
|
||||
pub mod printtiling;
|
||||
pub mod rulerguide;
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Pie widgets: two handles just outside a selected live ellipse, at its Pie Start Angle and Pie
|
||||
//! End Angle. Where the two coincide, as on a whole ellipse (0° to 360°), a press takes the End
|
||||
//! widget. The Selection tool shows them while the bounding box shows. Dragging one sets its angle
|
||||
//! (`object.setLiveShape`'s `pieStart` or `pieEnd`) to the angle of the outline's point that lies
|
||||
//! in the direction of the pointer from the ellipse's center, in one undo step; double-clicking
|
||||
//! either makes the shape a whole ellipse again. The Selection tool hits the widgets and the canvas
|
||||
//! draws them from the same geometry.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
|
||||
use crate::{Action, Overlay, PointerEvent, ToolContext};
|
||||
|
||||
/// A widget's center sits this far (screen px) outside the outline, on the line from the
|
||||
/// ellipse's center through the outline's point at its angle.
|
||||
const OFFSET_PX: f64 = 12.0;
|
||||
/// The widget's radius (screen px), as the canvas draws it.
|
||||
pub const RADIUS_PX: f32 = 3.5;
|
||||
/// How near (screen px) the pointer must be to hit a widget.
|
||||
const HIT_PX: f64 = 5.0;
|
||||
|
||||
/// Which of a pie's two angles a widget sets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PieEnd {
|
||||
Start,
|
||||
End,
|
||||
}
|
||||
|
||||
impl PieEnd {
|
||||
/// The `object.setLiveShape` param that sets this angle.
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
PieEnd::Start => "pieStart",
|
||||
PieEnd::End => "pieEnd",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One pie widget of a selected live ellipse.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PieWidget {
|
||||
pub id: NodeId,
|
||||
pub end: PieEnd,
|
||||
/// The outline's point at the widget's angle, in the document.
|
||||
pub on: Point,
|
||||
/// The widget's center in the document.
|
||||
pub at: Point,
|
||||
}
|
||||
|
||||
/// A live ellipse: `w` × `h` with its top-left at the origin, mapped into the document by `xf`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Ellipse {
|
||||
w: f64,
|
||||
h: f64,
|
||||
xf: Affine,
|
||||
}
|
||||
|
||||
impl Ellipse {
|
||||
/// Object `id`'s live ellipse and its pie angles, unless it has no area or `xf` can't be
|
||||
/// inverted.
|
||||
fn of(doc: &Document, id: NodeId) -> Option<(Self, (f64, f64))> {
|
||||
let NodeKind::Path { live, .. } = &doc.node(id)?.kind else { return None };
|
||||
let Some(LiveShape::Ellipse { w, h, pie, xf }) = live.as_deref() else { return None };
|
||||
let finite = w.is_finite() && h.is_finite() && xf.as_coeffs().iter().all(|c| c.is_finite());
|
||||
let usable = finite && w.abs() > 1e-9 && h.abs() > 1e-9 && xf.determinant().abs() > 1e-12;
|
||||
usable.then_some((Self { w: *w, h: *h, xf: *xf }, *pie))
|
||||
}
|
||||
|
||||
fn radii(&self) -> (f64, f64) {
|
||||
(self.w.abs() / 2.0, self.h.abs() / 2.0)
|
||||
}
|
||||
|
||||
fn center(&self) -> Point {
|
||||
self.xf * Point::new(self.w / 2.0, self.h / 2.0)
|
||||
}
|
||||
|
||||
/// The outline's point at pie angle `deg` (degrees counterclockwise from the right, as
|
||||
/// `shapes::ellipse_pie` measures them), in the document. 360° gives the very point 0° does.
|
||||
fn point_at(&self, deg: f64) -> Point {
|
||||
let ((rx, ry), t) = (self.radii(), deg.rem_euclid(360.0).to_radians());
|
||||
self.xf * Point::new(self.w / 2.0 + rx * t.cos(), self.h / 2.0 - ry * t.sin())
|
||||
}
|
||||
|
||||
/// The pie angle (0 to 360 degrees) whose outline point lies on the ray from the center
|
||||
/// toward `p` (0 when `p` is the center).
|
||||
fn angle_toward(&self, p: Point) -> f64 {
|
||||
let ((rx, ry), q) = (self.radii(), self.xf.inverse() * p);
|
||||
let a = (-(q.y - self.h / 2.0) / ry).atan2((q.x - self.w / 2.0) / rx).to_degrees();
|
||||
if a.is_finite() { a.rem_euclid(360.0) + 0.0 } else { 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl PieWidget {
|
||||
/// The widgets of the selected live ellipses at `zoom` (screen px per document point), each
|
||||
/// ellipse's End widget before its Start widget. Empty while anchors are Direct-Selected.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Vec<Self> {
|
||||
if !selection.anchors.is_empty() || !zoom.is_finite() || zoom <= 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
let shown = |id: &&NodeId| doc.is_editable(**id) && !selection.segments.contains_key(*id);
|
||||
let mut out = vec![];
|
||||
for id in selection.objects.iter().filter(shown) {
|
||||
let Some((e, (start, end))) = Ellipse::of(doc, *id) else { continue };
|
||||
let c = e.center();
|
||||
for (which, deg) in [(PieEnd::End, end), (PieEnd::Start, start)] {
|
||||
let on = e.point_at(deg);
|
||||
let len = (on - c).hypot();
|
||||
if len > 1e-9 {
|
||||
out.push(Self { id: *id, end: which, on, at: on + (on - c) * (OFFSET_PX / zoom / len) });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The widgets the Selection tool can drag (its bounding box showing).
|
||||
pub fn for_tool(cx: &ToolContext) -> Vec<Self> {
|
||||
if !cx.show_bbox {
|
||||
return vec![];
|
||||
}
|
||||
Self::of(cx.doc, cx.selection, cx.zoom)
|
||||
}
|
||||
|
||||
/// The widget of `cx` nearest `p` within reach; of two at one spot, the first listed.
|
||||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||||
let d = |w: &Self| w.at.distance(p);
|
||||
Self::for_tool(cx).into_iter().filter(|w| d(w) <= cx.tol(HIT_PX)).min_by(|a, b| d(a).total_cmp(&d(b)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` over a pie widget?
|
||||
pub fn over_widget(cx: &ToolContext, p: Point) -> bool {
|
||||
PieWidget::at_point(cx, p).is_some()
|
||||
}
|
||||
|
||||
/// A double-click on a pie widget makes its ellipse whole: 0° to 360°.
|
||||
pub fn double_click(cx: &ToolContext, p: Point) -> Option<Action> {
|
||||
let w = PieWidget::at_point(cx, p)?;
|
||||
Some(Action::Exec("object.setLiveShape".into(), json!({ "id": w.id.0, "pieStart": 0, "pieEnd": 360 })))
|
||||
}
|
||||
|
||||
/// Dragging a pie widget: its angle follows the outline's point that lies in the direction of the
|
||||
/// pointer from the ellipse's center.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PieDrag {
|
||||
widget: PieWidget,
|
||||
ellipse: Ellipse,
|
||||
start: Point,
|
||||
began: bool,
|
||||
angle: f64,
|
||||
at: Point,
|
||||
}
|
||||
|
||||
impl PieDrag {
|
||||
/// The drag has left the click: the angle is changing.
|
||||
pub fn adjusting(&self) -> bool {
|
||||
self.began
|
||||
}
|
||||
|
||||
/// Start a drag when the press `ev` is on a pie widget of the selection.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = PieWidget::at_point(cx, ev.pos)?;
|
||||
let (ellipse, _) = Ellipse::of(cx.doc, widget.id)?;
|
||||
Some(Self { widget, ellipse, start: ev.pos, began: false, angle: 0.0, at: ev.pos })
|
||||
}
|
||||
|
||||
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("Live Shape".into()));
|
||||
}
|
||||
self.angle = self.ellipse.angle_toward(p);
|
||||
self.at = p;
|
||||
let mut params = json!({ "id": self.widget.id.0 });
|
||||
params[self.widget.end.key()] = json!(self.angle);
|
||||
out.push(Action::Preview("object.setLiveShape".into(), params));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
/// The angle readout next to the pointer.
|
||||
pub fn overlays(&self) -> Vec<Overlay> {
|
||||
if !self.began {
|
||||
return vec![];
|
||||
}
|
||||
vec![Overlay::Measure { p: self.at, text: format!("{:.1}°", self.angle) }]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::select::SelectionTool;
|
||||
use crate::testutil::*;
|
||||
use crate::{Cursor, Mods, PointerKind, Tool};
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
|
||||
/// A document holding a live 200 × 100 ellipse at (100, 100) with pie angles `pie`, and a
|
||||
/// selection of it.
|
||||
fn doc_with_ellipse(pie: (f64, f64)) -> (Document, NodeId, Selection) {
|
||||
ellipse_doc(200.0, 100.0, pie, Affine::translate((100.0, 100.0)))
|
||||
}
|
||||
|
||||
/// A document holding a live `w` × `h` ellipse placed by `xf` with pie angles `pie`, and a
|
||||
/// selection of it.
|
||||
fn ellipse_doc(w: f64, h: f64, pie: (f64, f64), xf: Affine) -> (Document, NodeId, Selection) {
|
||||
let live = LiveShape::Ellipse { w, h, pie, xf };
|
||||
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, live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(Box::new(live));
|
||||
}
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
(d, id, s)
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, p: Point) -> PointerEvent {
|
||||
PointerEvent::new(kind, p.x, p.y)
|
||||
}
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-9
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_widgets_sit_outside_the_outline_at_the_pie_angles() {
|
||||
// A whole ellipse: both at 0°, 12 px right of the outline at any zoom.
|
||||
let (d, id, s) = doc_with_ellipse((0.0, 360.0));
|
||||
let w = PieWidget::of(&d, &s, 1.0);
|
||||
assert_eq!(w.iter().map(|w| w.end).collect::<Vec<_>>(), [PieEnd::End, PieEnd::Start]);
|
||||
assert!(w.iter().all(|w| w.id == id && near(w.on, Point::new(300.0, 150.0)) && near(w.at, Point::new(312.0, 150.0))), "{w:?}");
|
||||
assert!(near(PieWidget::of(&d, &s, 4.0)[0].at, Point::new(303.0, 150.0)));
|
||||
// A quarter: the End widget above the center, the Start widget right of it.
|
||||
let (d, _, s) = doc_with_ellipse((0.0, 90.0));
|
||||
let w = PieWidget::of(&d, &s, 1.0);
|
||||
assert!(near(w[0].on, Point::new(200.0, 100.0)) && near(w[0].at, Point::new(200.0, 88.0)), "{w:?}");
|
||||
assert!(near(w[1].at, Point::new(312.0, 150.0)), "{w:?}");
|
||||
// No widgets with the bounding box hidden, with nothing selected, or on another shape.
|
||||
let p = paint();
|
||||
assert!(PieWidget::for_tool(&ToolContext { show_bbox: false, ..cx(&d, &s, &p) }).is_empty());
|
||||
assert!(PieWidget::of(&d, &Selection::default(), 1.0).is_empty());
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
assert!(PieWidget::of(&d, &s, 1.0).is_empty());
|
||||
}
|
||||
|
||||
/// Dragging the End widget of a whole ellipse from 0° to straight up sets Pie End Angle to 90
|
||||
/// in one step, with the angle shown next to the pointer.
|
||||
#[test]
|
||||
fn dragging_the_end_widget_sets_the_end_angle() {
|
||||
let (d, id, s) = doc_with_ellipse((0.0, 360.0));
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
assert_eq!(t.cursor(&c, Point::new(312.0, 150.0), Mods::default()), Cursor::ArrowHollow);
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, Point::new(312.0, 150.0))).is_empty());
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, Point::new(200.0, 20.0)));
|
||||
assert_eq!(a[0], Action::Begin("Live Shape".into()));
|
||||
let Action::Preview(cmd, v) = &a[1] else { panic!("{a:?}") };
|
||||
assert_eq!((cmd.as_str(), &v["id"]), ("object.setLiveShape", &json!(id.0)));
|
||||
assert!((v["pieEnd"].as_f64().unwrap() - 90.0).abs() < 1e-9, "{v}");
|
||||
assert!(v.get("pieStart").is_none(), "{v}");
|
||||
assert!(t.transforming());
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "90.0°")));
|
||||
// Below the center and to the left: 225°, on the ellipse's own angles (not the page's).
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, Point::new(100.0, 200.0)));
|
||||
assert!(matches!(&a[0], Action::Preview(_, v) if (v["pieEnd"].as_f64().unwrap() - 225.0).abs() < 1e-9), "{a:?}");
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, Point::new(100.0, 200.0))), vec![Action::Commit]);
|
||||
// A click that doesn't move sends no action.
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, Point::new(312.0, 150.0))).is_empty());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, Point::new(312.0, 150.0))).is_empty());
|
||||
}
|
||||
|
||||
/// On a whole ellipse the two widgets sit at the very same point, whatever its size, place or
|
||||
/// angle, so a press there always takes the End widget.
|
||||
#[test]
|
||||
fn a_whole_ellipse_gives_a_press_its_end_widget() {
|
||||
let p = paint();
|
||||
for (w, h, xf) in [
|
||||
(100.0, 100.0, Affine::IDENTITY),
|
||||
(37.5, 81.25, Affine::translate((13.0, 7.0))),
|
||||
(120.0, 60.0, Affine::translate((200.0, 150.0)) * Affine::rotate(0.7)),
|
||||
] {
|
||||
let (d, _, s) = ellipse_doc(w, h, (0.0, 360.0), xf);
|
||||
let ws = PieWidget::of(&d, &s, 1.0);
|
||||
assert_eq!(ws[0].at, ws[1].at, "{ws:?}");
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, ws[0].at));
|
||||
let center = xf * Point::new(w / 2.0, h / 2.0);
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, center + (xf * Point::new(w / 2.0, 0.0) - center) * 2.0));
|
||||
assert!(matches!(&a[1], Action::Preview(_, v) if v.get("pieEnd").is_some() && v.get("pieStart").is_none()), "{a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Start widget sets Pie Start Angle and leaves the end where it is.
|
||||
#[test]
|
||||
fn dragging_the_start_widget_sets_the_start_angle() {
|
||||
let (d, _, s) = doc_with_ellipse((0.0, 90.0));
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let mut t = SelectionTool::default();
|
||||
t.pointer(&c, &ev(PointerKind::Down, Point::new(312.0, 150.0)));
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, Point::new(50.0, 150.0)));
|
||||
let Action::Preview(_, v) = &a[1] else { panic!("{a:?}") };
|
||||
assert!((v["pieStart"].as_f64().unwrap() - 180.0).abs() < 1e-9, "{v}");
|
||||
assert!(v.get("pieEnd").is_none(), "{v}");
|
||||
}
|
||||
|
||||
/// A double-click on either widget makes the ellipse whole again.
|
||||
#[test]
|
||||
fn a_double_click_on_a_widget_resets_the_pie() {
|
||||
let (d, id, s) = doc_with_ellipse((30.0, 120.0));
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let reset = vec![Action::Exec("object.setLiveShape".into(), json!({ "id": id.0, "pieStart": 0, "pieEnd": 360 }))];
|
||||
for w in PieWidget::of(&d, &s, 1.0) {
|
||||
let mut t = SelectionTool::default();
|
||||
for k in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up] {
|
||||
assert_eq!(t.pointer(&c, &ev(k, w.at)), vec![], "{k:?}");
|
||||
}
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::DoubleClick, w.at)), reset);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
//! Guides, or with them off View › Snap to Point, snap it),
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! drag a live rectangle's or polygon's corner widget to round its corners (Alt-click cycles their kind,
|
||||
//! double-click opens the Corners dialog), double-click a group or an object to enter isolation
|
||||
//! double-click opens the Corners dialog), drag a live ellipse's pie widgets to set its pie angles
|
||||
//! (double-click makes it whole again: [`crate::pie`]), double-click a group or an object to enter isolation
|
||||
//! mode (Double Click To Isolate), Cmd/Ctrl-click to select the object behind (Command Click to Select Objects Behind),
|
||||
//! click or drag a ruler guide ([`crate::rulerguide`]), drag the brackets of type on a path
|
||||
//! ([`crate::pathtype`]), double-click the type widget to convert point type to area type and back
|
||||
@@ -24,6 +25,7 @@ use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_dra
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::Targets;
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::pie::{self, PieDrag};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
use crate::shaperwidget;
|
||||
use crate::typewidget;
|
||||
@@ -66,6 +68,8 @@ enum State {
|
||||
},
|
||||
/// Dragging a Live Corners widget.
|
||||
Corner(CornerDrag),
|
||||
/// Dragging a live ellipse's pie widget.
|
||||
Pie(PieDrag),
|
||||
/// Dragging a bracket of type on a path.
|
||||
Bracket(BracketDrag),
|
||||
}
|
||||
@@ -281,6 +285,7 @@ impl Tool for SelectionTool {
|
||||
match &self.state {
|
||||
State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true,
|
||||
State::Corner(c) => c.adjusting(),
|
||||
State::Pie(d) => d.adjusting(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -300,7 +305,8 @@ impl Tool for SelectionTool {
|
||||
match (ev.kind, self.state.clone()) {
|
||||
(PointerKind::DoubleClick, _) => {
|
||||
self.state = State::Idle;
|
||||
if let Some(a) = corners::double_click(cx, p, false).or_else(|| typewidget::double_click(cx, p)) {
|
||||
if let Some(a) = corners::double_click(cx, p, false).or_else(|| pie::double_click(cx, p)).or_else(|| typewidget::double_click(cx, p))
|
||||
{
|
||||
return vec![a];
|
||||
}
|
||||
// The Shaper Group's mode widget answers each click of a double-click on it.
|
||||
@@ -325,12 +331,16 @@ impl Tool for SelectionTool {
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Down, _) => {
|
||||
// 1. Live Corners widgets and type on a path's brackets, then the bounding-box
|
||||
// handles of the current selection.
|
||||
// 1. Live Corners widgets, pie widgets and type on a path's brackets, then the
|
||||
// bounding-box handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, false) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(d) = PieDrag::hit(cx, ev) {
|
||||
self.state = State::Pie(d);
|
||||
return vec![];
|
||||
}
|
||||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||||
self.state = State::Bracket(b);
|
||||
return vec![];
|
||||
@@ -456,6 +466,15 @@ impl Tool for SelectionTool {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::Pie(mut d)) => {
|
||||
let out = d.drag(cx, p);
|
||||
self.state = State::Pie(d);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::Pie(d)) => {
|
||||
self.state = State::Idle;
|
||||
d.finish()
|
||||
}
|
||||
(PointerKind::Drag, State::Bracket(mut b)) => {
|
||||
let out = b.drag(cx, p, m.cmd);
|
||||
self.state = State::Bracket(b);
|
||||
@@ -513,6 +532,7 @@ impl Tool for SelectionTool {
|
||||
match &self.state {
|
||||
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
State::Corner(c) => o.extend(c.overlays(cx)),
|
||||
State::Pie(d) => o.extend(d.overlays()),
|
||||
_ => {}
|
||||
}
|
||||
o.extend(self.guides.iter().cloned());
|
||||
@@ -535,6 +555,7 @@ impl Tool for SelectionTool {
|
||||
State::Scaling { handle, bx, .. } => return handle_cursor(handle, bx.angle),
|
||||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
State::Pie(_) => return Cursor::ArrowHollow,
|
||||
State::Bracket(_) => return Cursor::PathBracket,
|
||||
_ => {}
|
||||
}
|
||||
@@ -544,6 +565,9 @@ impl Tool for SelectionTool {
|
||||
if over_widget(cx, p, false) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if pie::over_widget(cx, p) {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
if over_bracket(cx, p) {
|
||||
return Cursor::PathBracket;
|
||||
}
|
||||
|
||||
@@ -2035,6 +2035,16 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
// Pie widgets (Selection tool, with View › Show Bounding Box, on live ellipses): each joined
|
||||
// by a line to the outline's point at its angle. They stay while one is dragged.
|
||||
if tool == "selection" && app.ui.view.bounding_box {
|
||||
for w in vectorcraft_tools::pie::PieWidget::of(&st.doc, &st.selection, xf.zoom) {
|
||||
let (on, c, r, color) = (xf.to_screen(w.on), xf.to_screen(w.at), vectorcraft_tools::pie::RADIUS_PX, c32(st.doc.layer_color(w.id)));
|
||||
p.line_segment([on, c], Stroke::new(1.0, color));
|
||||
p.circle_filled(c, r, Color32::WHITE);
|
||||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// While the Type tool edits, let the system IME compose (egui-winit allows it only in frames that
|
||||
@@ -3975,6 +3985,28 @@ mod tests {
|
||||
assert!(widget(&mut app).is_empty(), "no bounding box, no widget");
|
||||
}
|
||||
|
||||
/// A selected live ellipse shows its two pie widgets with the Selection tool while the
|
||||
/// bounding box shows.
|
||||
#[test]
|
||||
fn a_selected_ellipse_shows_its_pie_widgets() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap();
|
||||
app.session.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let widgets = |app: &mut VectorcraftApp| {
|
||||
let r = vectorcraft_tools::pie::RADIUS_PX;
|
||||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == r && c.fill == Color32::WHITE)).count()
|
||||
};
|
||||
app.select_tool("selection");
|
||||
assert_eq!(widgets(&mut app), 2);
|
||||
app.select_tool("directSelection");
|
||||
assert_eq!(widgets(&mut app), 0);
|
||||
app.select_tool("selection");
|
||||
app.run("view.boundingBox", json!({})).unwrap();
|
||||
assert_eq!(widgets(&mut app), 0, "no bounding box, no widgets");
|
||||
}
|
||||
|
||||
/// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`.
|
||||
fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec<Shape> {
|
||||
let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() };
|
||||
|
||||
@@ -476,7 +476,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Drawing modes:** in Draw Inside, a double-click with a selection tool outside the object being drawn into sets the drawing mode to Draw Normal, as Shift+D does.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners. A filled Pencil path keeps a stroke of None (#1804, part of #1679). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661).
|
||||
Curvature point markers are circular, with no direction handles. While drawing, its own point feedback replaces generic selection markers; existing paths use the selection overlay, including partial selections and Hide Edges. Anchor Size applies, and Direct Selection and Pen retain their handles. The real egui-frame regressions in `crates/ui-egui/src/canvas.rs` verify marker positions, counts and unchanged documents (#1236, in part); the existing-path interpolation request remains open. The curve through the points (`curvature_spline` in `crates/tools/src/draw2.rs`) makes each stretch of three points follow the circle through them: a smooth point's handles lie along that circle's tangent, each `chord / (3 cos²(α/2))` long (α between handle and chord, capped at 90°), and the ends of an open path mirror their neighbour's tangent in their chord, so a J or a U curves evenly, a turn tighter than 30° comes to a point, and uneven spacing doesn't loop; the rubber band, a dragged or inserted point and `path.convertAnchor` to smooth use the same rule, while the Pencil's fitting keeps its own; `tests_draw2.rs` checks that the rubber band is the path the click makes (#1236, in part). The Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512).
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812; the on-canvas pie widget is still to come). A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). A click with a grid tool asks for the size, the divider counts and the grid options (#1377, in part): a skew for each set of dividers, Use Outside Rectangle As Frame or Create Compound Path From Ellipses, and Fill Grid; they are parameters of `shape.rectangularGrid` and `shape.polarGrid`, stay as the tool's options and apply to drags too. The curve a skew percentage follows and the direction of the radial skew are our own (the public documentation gives neither), and the dialogs have no reference point locator. An ellipse's Pie Start and End Angle and Invert Pie (Transform and Properties panels' Ellipse Properties, `object.setLiveShape {pieStart, pieEnd, invertPie}`) cut it as a pie (#812). With the bounding box showing, the Selection tool shows a selected ellipse's two pie widgets just outside it; dragging one sets its angle in one undo step and double-clicking one makes the ellipse whole again. A live polygon's Polygon Properties there: side count, angle, corner type and radius, radius, side length and Make Sides Equal once an uneven scale has made its sides differ (`object.setLiveShape {sides, polygonAngle, polygonRadius, sideLength, makeSidesEqual}`, part of #812); a star is a plain path, as in Illustrator. The Flare Tool Options last as the tool's options: the next flare dragged out uses them, a click's dialog shows them and double-clicking the tool opens them (part of #812).
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
Align, Distribute, Distribute Spacing, Reflect and Shear return an error for any `horizontal`, `vertical`, `to`, `axis`, `bounds` or `spacing` value they don't take, listing the choices, and Align returns one when a call gives neither `horizontal` nor `vertical`; nothing moves and no undo step is recorded. Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing).
|
||||
|
||||
@@ -80,6 +80,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Escape cancels a Direct Selection drag | done | #1803 (#1743) |
|
||||
| Live Corners widgets (radius readout, Alt-click cycles kind, double-click the Corners dialog) | done | a Direct Selection click on a widget selects its corner (#1239); a press-and-drag on a widget of a path selected whole still rounds every corner (#908), and adding widgets to the selection by Shift-click (#1232) is missing; the bounding box hides while a widget is dragged with the Selection tool (#1229); a round corner next to a curved side curves smoothly instead of sticking out of the shape (#1512) |
|
||||
| Corners between curved sides (#1144), Relative rounding | partial | Relative rounding missing |
|
||||
| Pie widgets on a live ellipse: drag one to set Pie Start or End Angle, double-click one to make the ellipse whole | done | Selection tool, with the bounding box showing |
|
||||
| Isolation mode by double-click (groups and single objects), breadcrumbs, Esc to exit | done | Single paths, compound paths, images, meshes and placed files isolate too ([helpx: Isolate objects](https://helpx.adobe.com/africa/illustrator/desktop/manage-objects/select-objects/isolate-objects.html)) |
|
||||
| "Magnetic" moves of selected anchors with a modifier (#955 request) | missing | not an Illustrator feature as documented; under discussion |
|
||||
| Coinciding anchors of overlapping objects: the selected object's anchor is taken, also under another object | done | #1508 |
|
||||
|
||||
Reference in New Issue
Block a user