diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 80e51276b..4766f0f6d 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -341,6 +341,7 @@ shown_when = [ "The Gradient tool over its rotate zone, or rotating.", "Puppet Warp with Alt near a selected pin.", "The bounding-box rotate cursor when the pointer is exactly on the box's centre.", + "Selection and Objects on Path over an Objects on Path group's Rotate All widget, and while dragging it.", ] hotspot = "Below the arc, in its empty middle." look = "An arc with an arrowhead at each end. RotateToward is the same arc, turned." diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index f08dc9313..47aff8910 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -4,18 +4,168 @@ //! by row from the top left; 4, the center) and `rotate` (degrees), go with each attach, so they //! set how the objects attached next attach; groups attached before keep theirs. //! +//! The Rotate All widget: with one Objects on Path group selected, the Selection tool and this +//! tool show a round handle beside the group's first object. Dragging it turns every object of the +//! group about its attachment point by the angle the pointer turns about the first object's +//! attachment point: the angle is added to the group's Rotate (`object.objectsOnPath.options`), +//! previewed while dragging and kept as one undo step on release. Shift turns in steps of 15°, and +//! Escape cancels the drag. +//! //! Behavior: . use serde_json::{Value, json}; -use vectorcraft_doc::NodeId; -use vectorcraft_doc::objects_on_path::{CENTER, attachable_path, is_objects_on_path}; -use vectorcraft_geom::{BezPath, Point}; +use vectorcraft_doc::objects_on_path::{CENTER, attachable_path, is_objects_on_path, stops}; +use vectorcraft_doc::{Document, NodeId, Selection}; +use vectorcraft_geom::{BezPath, Point, Vec2}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// The highlight of the path under the pointer. const HIGHLIGHT: [u8; 3] = [0x4f, 0x9d, 0xff]; +/// The Rotate All widget's center sits this far (screen px) beyond the first object, on the side +/// its top faces. +const ROTATE_ALL_OFFSET_PX: f64 = 16.0; +/// The Rotate All widget's radius (screen px), as the canvas draws it. +pub const ROTATE_ALL_RADIUS_PX: f32 = 4.0; +/// How near (screen px) the pointer must be to hit the Rotate All widget. +const ROTATE_ALL_HIT_PX: f64 = 6.0; +/// The step (degrees) a Shift-drag of the Rotate All widget turns by. +const ROTATE_ALL_STEP: f64 = 15.0; + +/// The Rotate All widget of a selected Objects on Path group. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RotateAllWidget { + pub group: NodeId, + /// The first object's attachment point, in the document: a drag turns about it. + pub pivot: Point, + /// The widget's center in the document. + pub at: Point, + /// The group's Rotate (degrees). + pub rotate: f64, +} + +impl RotateAllWidget { + /// The widget of the selection at `zoom` (screen px per document point): only when the + /// selection is one editable Objects on Path group with at least one object on a path with + /// length, and no anchors are Direct-Selected. + pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option { + if !selection.anchors.is_empty() || !zoom.is_finite() || zoom <= 0.0 { + return None; + } + let [group] = selection.objects.as_slice() else { return None }; + if !doc.editable()(*group) { + return None; + } + let n = doc.node(*group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let children = n.children()?; + let path = attachable_path(children.iter().find(|c| c.id == rec.path)?)?; + let first = rec.members.first()?; + // The first object attaches at the path's start, whatever the number of objects. + let (pivot, _) = *stops(path, 1)?.first()?; + let art = children.iter().find(|c| c.id == first.id)?; + let b = art.geometric_bounds_with(&doc.symbol_boxes())?; + // The object's top is the -y direction of the frame it is drawn in (the document's y axis + // points down), so a reflected or sheared frame moves the widget with the art's top. + let [_, _, a2, a3, _, _] = first.frame.as_coeffs(); + let len = a2.hypot(a3); + let up = if len.is_finite() && len > 1e-12 { + Vec2::new(-a2 / len, -a3 / len) + } else { + let (sin, cos) = first.angle.sin_cos(); + Vec2::new(sin, -cos) + }; + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)]; + let reach = corners.iter().map(|c| (*c - pivot).dot(up)).fold(0.0, f64::max); + let at = pivot + up * (reach + ROTATE_ALL_OFFSET_PX / zoom); + let finite = at.x.is_finite() && at.y.is_finite() && pivot.x.is_finite() && pivot.y.is_finite(); + finite.then_some(Self { group: *group, pivot, at, rotate: rec.rotate }) + } + + /// The widget of `cx` when `p` is within reach of it. + fn at_point(cx: &ToolContext, p: Point) -> Option { + Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(ROTATE_ALL_HIT_PX)) + } +} + +/// Is `p` over the Rotate All widget? +pub fn over_rotate_all(cx: &ToolContext, p: Point) -> bool { + RotateAllWidget::at_point(cx, p).is_some() +} + +/// Dragging the Rotate All widget: the group's Rotate follows the angle the pointer turns about +/// the first object's attachment point, counter-clockwise on the page, over as many turns as the +/// drag makes. +#[derive(Clone, Debug)] +pub struct RotateAllDrag { + widget: RotateAllWidget, + start: Point, + began: bool, + /// The pointer's direction from the pivot at the last drag (radians, the document's axes). + last: f64, + /// The degrees the pointer has turned counter-clockwise on the page since the press. + turned: f64, + /// The Rotate the drag sets. + rotate: f64, + at: Point, +} + +impl RotateAllDrag { + /// 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 the Rotate All widget. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = RotateAllWidget::at_point(cx, ev.pos)?; + let d = ev.pos - widget.pivot; + let last = d.y.atan2(d.x); + let last = if last.is_finite() { last } else { 0.0 }; + Some(Self { widget, start: ev.pos, began: false, last, turned: 0.0, rotate: widget.rotate, at: ev.pos }) + } + + /// The pointer moved to `p`; `shift` turns in steps of 15°. + pub fn drag(&mut self, cx: &ToolContext, p: Point, shift: bool) -> Vec { + 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("Objects on Path Options".into())); + } + let d = p - self.widget.pivot; + if d.hypot() > 1e-9 { + let now = d.y.atan2(d.x); + let step = (now - self.last + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + if step.is_finite() { + self.turned -= step.to_degrees(); + self.last = now; + } + } + let turned = if shift { (self.turned / ROTATE_ALL_STEP).round() * ROTATE_ALL_STEP } else { self.turned }; + let r = self.widget.rotate + turned; + self.rotate = if r.abs() > 360.0 { r % 360.0 } else { r } + 0.0; + self.at = p; + out.push(Action::Preview("object.objectsOnPath.options".into(), json!({ "ids": [self.widget.group.0], "rotate": self.rotate }))); + out + } + + pub fn finish(self) -> Vec { + if self.began { vec![Action::Commit] } else { vec![] } + } + + /// The Rotate readout next to the pointer. + pub fn overlays(&self, cx: &ToolContext) -> Vec { + if !self.began || !cx.measurement_labels { + return vec![]; + } + vec![Overlay::Measure { p: self.at, text: format!("{:.1}°", self.rotate) }] + } +} + /// The path a click at `p` attaches the selected objects to: the path object hit there, when it can /// take objects, is in no Objects on Path group, is not a clipping path or part of a compound path, /// blend, envelope, repeat or Intertwine group ([`vectorcraft_doc::objects_on_path::held`]), and @@ -38,6 +188,8 @@ pub struct ObjectsOnPathTool { hover: Option<(NodeId, BezPath)>, /// Where the press was, while the button is down. press: Option, + /// The Rotate All widget drag, while the button is down after a press on the widget. + rotating: Option, /// The reference point of each object attached next (0-8, row by row from the top left). pivot: u8, /// The angle of each object attached next, in degrees counter-clockwise from upright on the path. @@ -46,7 +198,7 @@ pub struct ObjectsOnPathTool { impl Default for ObjectsOnPathTool { fn default() -> Self { - Self { hover: None, press: None, pivot: CENTER, rotate: 0.0 } + Self { hover: None, press: None, rotating: None, pivot: CENTER, rotate: 0.0 } } } @@ -57,12 +209,24 @@ impl Tool for ObjectsOnPathTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; + if let Some(d) = &mut self.rotating { + return match ev.kind { + PointerKind::Drag => d.drag(cx, p, ev.mods.shift), + PointerKind::Up => self.rotating.take().map(RotateAllDrag::finish).unwrap_or_default(), + _ => vec![], + }; + } match ev.kind { PointerKind::Move => { - self.hover = target(cx, p); + self.hover = if over_rotate_all(cx, p) { None } else { target(cx, p) }; vec![] } PointerKind::Down => { + if let Some(d) = RotateAllDrag::hit(cx, ev) { + self.rotating = Some(d); + self.hover = None; + return vec![]; + } self.press = Some(p); vec![] } @@ -77,19 +241,34 @@ impl Tool for ObjectsOnPathTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - self.hover.iter().map(|(_, path)| Overlay::Path { path: path.clone(), color: HIGHLIGHT, width: 2.0, dashed: false }).collect() + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut o: Vec = + self.hover.iter().map(|(_, path)| Overlay::Path { path: path.clone(), color: HIGHLIGHT, width: 2.0, dashed: false }).collect(); + if let Some(d) = &self.rotating { + o.extend(d.overlays(cx)); + } + o } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { - if target(cx, p).is_some() { Cursor::ObjectsOnPath } else { Cursor::Arrow } + if self.rotating.is_some() || over_rotate_all(cx, p) { + Cursor::Rotate + } else if target(cx, p).is_some() { + Cursor::ObjectsOnPath + } else { + Cursor::Arrow + } } - /// Escape while the button is down cancels the click: the release attaches nothing. + /// Escape while the button is down cancels the click, so the release attaches nothing, or the + /// Rotate All drag, so the objects go back to the Rotate they had. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape { self.press = None; self.hover = None; + if self.rotating.take().is_some_and(|d| d.adjusting()) { + return vec![Action::Cancel]; + } } vec![] } @@ -117,7 +296,11 @@ impl Tool for ObjectsOnPathTool { } fn busy(&self) -> bool { - self.press.is_some() + self.press.is_some() || self.rotating.is_some() + } + + fn transforming(&self) -> bool { + self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) } } @@ -127,7 +310,9 @@ pub fn create(id: &str) -> Option> { #[cfg(test)] mod tests { - use vectorcraft_doc::{Appearance, Document, Node, NodeKind, Selection}; + use std::sync::Arc; + + use vectorcraft_doc::{Appearance, Node, NodeKind}; use vectorcraft_geom::{Rect, shapes}; use super::*; @@ -221,6 +406,169 @@ mod tests { assert!(matches!(d.node(line).map(|n| &n.kind), Some(NodeKind::Path { .. }))); } + /// A document holding an Objects on Path group (the line from (0, 300) to (400, 300), with two + /// 20 × 20 squares attached by their centers, the first on (0, 300)) whose Rotate is `rotate`, + /// and a selection of the group. + fn group_doc(rotate: f64) -> (Document, NodeId, Selection) { + group_doc_framed(rotate, None) + } + + /// [`group_doc`] with every object drawn in `frame` (mapping its square onto itself) before it + /// is placed on the path. + fn group_doc_framed(rotate: f64, frame: Option) -> (Document, NodeId, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (line, a, b, g) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = Node::path(line, shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), Appearance::default_art()); + let a = Node::path(a, shapes::rectangle(Rect::new(50.0, 50.0, 70.0, 70.0)), Appearance::default_art()); + let b = Node::path(b, shapes::rectangle(Rect::new(100.0, 50.0, 120.0, 70.0)), Appearance::default_art()); + let mut rec = vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], CENTER, rotate); + if let Some(f) = frame { + for m in &mut rec.members { + m.frame = f; + } + } + let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a), Arc::new(b)], clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + vectorcraft_doc::objects_on_path::place(&mut group, &Document::new(1.0, 1.0).symbol_boxes()); + d.insert(Some(l), 0, group).unwrap(); + let mut sel = Selection::default(); + sel.set([g]); + (d, g, sel) + } + + /// The pointer `deg` degrees counter-clockwise on the page from straight above the first + /// object's attachment point (0, 300), 26 points from it. + fn around(deg: f64) -> Point { + let (sin, cos) = deg.to_radians().sin_cos(); + Point::new(-26.0 * sin, 300.0 - 26.0 * cos) + } + + fn near(a: Point, b: Point) -> bool { + a.distance(b) < 1e-9 + } + + /// The Rotate a drag's last action previews. + fn previewed(acts: &[Action]) -> Option { + match acts.last() { + Some(Action::Preview(cmd, p)) if cmd == "object.objectsOnPath.options" => p["rotate"].as_f64(), + _ => None, + } + } + + #[test] + fn the_rotate_all_widget_sits_above_the_first_object() { + let (d, g, sel) = group_doc(0.0); + let w = RotateAllWidget::of(&d, &sel, 1.0).unwrap(); + // The first square spans 290 to 310 down the page: the widget is 16 px above its top. + assert_eq!(w.group, g); + assert!(near(w.pivot, Point::new(0.0, 300.0)) && near(w.at, Point::new(0.0, 274.0)), "{w:?}"); + assert!(near(RotateAllWidget::of(&d, &sel, 2.0).unwrap().at, Point::new(0.0, 282.0))); + // Turned by Rotate, the widget goes round with the object's top. + let (d, _, sel) = group_doc(90.0); + let w = RotateAllWidget::of(&d, &sel, 1.0).unwrap(); + assert!(near(w.at, Point::new(-26.0, 300.0)), "{w:?}"); + // Drawn upside down (reflected across the line through their centers), the objects have + // their tops facing down the page, and the widget goes below the first one. + let flip = vectorcraft_geom::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 120.0]); + let (df, _, sf) = group_doc_framed(0.0, Some(flip)); + let wf = RotateAllWidget::of(&df, &sf, 1.0).unwrap(); + assert!(near(wf.pivot, Point::new(0.0, 300.0)) && near(wf.at, Point::new(0.0, 326.0)), "{wf:?}"); + // No widget for nothing selected, for an object in the group, or for two objects. + assert!(RotateAllWidget::of(&d, &Selection::default(), 1.0).is_none()); + let children: Vec = d.node(sel.objects[0]).and_then(|n| n.children()).unwrap().iter().map(|c| c.id).collect(); + let mut one = Selection::default(); + one.set([children[1]]); + assert!(RotateAllWidget::of(&d, &one, 1.0).is_none()); + let (d2, line, square) = doc(); + let mut two = Selection::default(); + two.set([line, square]); + assert!(RotateAllWidget::of(&d2, &two, 1.0).is_none()); + // Nor for a group that is locked. + let (mut d3, g3, sel3) = group_doc(0.0); + if let Some(n) = d3.node_mut(g3) { + n.locked = true; + } + assert!(RotateAllWidget::of(&d3, &sel3, 1.0).is_none()); + } + + /// Dragging the widget a quarter turn counter-clockwise adds 90° to the group's Rotate, with + /// the Selection tool and with the Objects on Path tool, previewed while dragging and kept as + /// one undo step on release. + #[test] + fn dragging_the_rotate_all_widget_adds_the_angle_to_rotate() { + let (d, g, sel) = group_doc(30.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let at = RotateAllWidget::of(&d, &sel, 1.0).unwrap().at; + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::Rotate, "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)).is_empty(), "{}", t.id()); + // The press is 30° round from straight above the attachment point, as Rotate turns the + // first object; the pointer goes on to 120°. + let q = around(120.0); + let a = t.pointer(&c, &ev(PointerKind::Drag, q.x, q.y)); + assert_eq!(a.first(), Some(&Action::Begin("Objects on Path Options".into())), "{}", t.id()); + assert!(matches!(a.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{a:?}"); + assert!((previewed(&a).unwrap() - 120.0).abs() < 1e-9, "{a:?}"); + assert!(t.transforming() && t.cursor(&c, q, Mods::default()) == Cursor::Rotate); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "120.0°")), "{}", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, q.x, q.y)), vec![Action::Commit], "{}", t.id()); + assert!(!t.busy()); + // A click on the widget that does not move changes nothing. + assert!(t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}", t.id()); + } + } + + /// Shift turns in 15° steps, and a drag that goes on round the attachment point keeps adding + /// to the angle past half a turn. + #[test] + fn shift_turns_the_objects_in_steps_of_fifteen_degrees() { + let (d, _, sel) = group_doc(0.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + let at = around(0.0); + t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)); + let q = around(20.0); + let shift = PointerEvent { mods: Mods { shift: true, ..Mods::default() }, ..ev(PointerKind::Drag, q.x, q.y) }; + assert!((previewed(&t.pointer(&c, &shift)).unwrap() - 15.0).abs() < 1e-9); + let q = around(-38.0); + let shift = PointerEvent { mods: Mods { shift: true, ..Mods::default() }, ..ev(PointerKind::Drag, q.x, q.y) }; + assert!((previewed(&t.pointer(&c, &shift)).unwrap() + 45.0).abs() < 1e-9); + let q = around(20.0); + assert!((previewed(&t.pointer(&c, &ev(PointerKind::Drag, q.x, q.y))).unwrap() - 20.0).abs() < 1e-9, "without Shift"); + let mut last = 0.0; + for deg in [100.0, 180.0, 260.0] { + let q = around(deg); + last = previewed(&t.pointer(&c, &ev(PointerKind::Drag, q.x, q.y))).unwrap(); + } + assert!((last - 260.0).abs() < 1e-9, "{last}"); + } + + /// Escape during a Rotate All drag cancels it: the readout is removed, and the release keeps + /// nothing. + #[test] + fn escape_cancels_a_rotate_all_drag() { + let (d, _, sel) = group_doc(0.0); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + let at = around(0.0); + t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)); + let q = around(60.0); + assert!(previewed(&t.pointer(&c, &ev(PointerKind::Drag, q.x, q.y))).is_some()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{}", t.id()); + assert!(!t.busy() && !t.transforming(), "{}", t.id()); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { .. })), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, at.x, at.y)).is_empty(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}: the release keeps nothing", t.id()); + } + } + #[test] fn a_clipping_path_or_a_blend_key_is_not_a_target() { let (mut d, line, square) = doc(); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 737a29f67..f9a706dd0 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -44,6 +44,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; +use crate::objects_on_path::{RotateAllDrag, over_rotate_all}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -96,6 +97,8 @@ enum State { Corner(CornerDrag), /// Dragging a live ellipse's pie widget. Pie(PieDrag), + /// Dragging an Objects on Path group's Rotate All widget. + RotateAll(RotateAllDrag), /// Move or rotate a live mirror axis. Mirror(crate::mirror::MirrorDrag), /// Drag a grid repeat's row, column or corner handle or a spacing slider. @@ -609,7 +612,7 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, the Rotate All widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. @@ -670,6 +673,7 @@ impl Tool for SelectionTool { State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true, State::Corner(c) => c.adjusting(), State::Pie(d) => d.adjusting(), + State::RotateAll(d) => d.adjusting(), State::Bracket(b) => b.adjusting(), State::Mirror(d) => d.adjusting(), State::GridRepeat(d) => d.adjusting(), @@ -824,6 +828,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::RotateAll(mut d)) => { + let out = d.drag(cx, p, m.shift); + self.state = State::RotateAll(d); + out + } + (PointerKind::Up, State::RotateAll(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); @@ -877,6 +890,7 @@ impl Tool for SelectionTool { 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(cx)), + State::RotateAll(d) => o.extend(d.overlays(cx)), State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), _ => {} } @@ -921,6 +935,7 @@ impl Tool for SelectionTool { State::Moving { began: true, .. } => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow }, State::Corner(_) => return Cursor::CornerRadius, State::Pie(_) => return Cursor::ArrowHollow, + State::RotateAll(_) => return Cursor::Rotate, State::Bracket(_) => return Cursor::PathBracket, State::Loaded { .. } => return Cursor::LoadedText, _ => {} @@ -934,6 +949,9 @@ impl Tool for SelectionTool { if pie::over_widget(cx, p) { return Cursor::ArrowHollow; } + if over_rotate_all(cx, p) { + return Cursor::Rotate; + } if over_bracket(cx, p) { return Cursor::PathBracket; } @@ -1192,7 +1210,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, the Rotate All widget 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); @@ -1202,6 +1220,10 @@ impl SelectionTool { self.state = State::Pie(d); return Some(vec![]); } + if let Some(d) = RotateAllDrag::hit(cx, ev) { + self.state = State::RotateAll(d); + return Some(vec![]); + } if let Some(b) = BracketDrag::hit(cx, ev) { self.state = State::Bracket(b); return Some(vec![]); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 4f7e83258..0978fd97b 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -288,6 +288,21 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: p.circle_stroke(c, r, Stroke::new(1.0, color)); } } + rotate_all_widget(p, xf, tool, &st.doc, &st.selection); +} + +/// The Rotate All widget (Selection and Objects on Path tools, one Objects on Path group +/// selected): joined by a line to the first object's attachment point. It stays while dragged. +fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { + use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget}; + if !matches!(tool, "selection" | "objectsOnPath") { + return; + } + let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) else { return }; + let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); + p.line_segment([on, c], Stroke::new(1.0, color)); + p.circle_filled(c, ROTATE_ALL_RADIUS_PX, Color32::WHITE); + p.circle_stroke(c, ROTATE_ALL_RADIUS_PX, Stroke::new(1.0, color)); } /// Record where the Type tool's IME candidate window goes this pass, if it has an editor. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 1d1eb4314..18303a082 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -3888,6 +3888,34 @@ fn a_selected_ellipse_shows_its_pie_widgets() { assert_eq!(widgets(&mut app), 0, "no bounding box, no widgets"); } +/// A selected Objects on Path group shows its Rotate All widget with the Selection and Objects on +/// Path tools. +#[test] +fn a_selected_objects_on_path_group_shows_its_rotate_all_widget() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let line = id(app.session.execute("shape.line", &json!({"x1": 20, "y1": 200, "x2": 380, "y2": 200})).unwrap()); + let a = id(app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()); + let b = id(app.session.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 20, "height": 20})).unwrap()); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + app.session.execute("object.objectsOnPath.attach", &json!({"path": line})).unwrap(); + let ctx = egui::Context::default(); + let widgets = |app: &mut VectorcraftApp| { + let r = vectorcraft_tools::objects_on_path::ROTATE_ALL_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), 1); + app.select_tool("objectsOnPath"); + assert_eq!(widgets(&mut app), 1); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app), 0); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(widgets(&mut app), 0, "an object in the group, not the group"); +} + /// 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 { let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; diff --git a/docs/mcp.md b/docs/mcp.md index 6ea25ccbe..4a21662da 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5847,6 +5847,13 @@ use; groups attached before keep their Pivot and Rotate. Reset (or Cancel while `ui.dialogReset`) puts the center and 0 back and keeps the dialog open. The options last across tool switches and are saved with the preferences. +With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget, a round +handle beyond the top of the group's first object joined by a line to its attachment point. Dragging it turns every +object about its attachment point by the angle the pointer turns about the first object's attachment point, +counter-clockwise on the page: the drag previews `object.objectsOnPath.options {ids: [group], rotate}` with the +group's Rotate plus that angle and keeps it as one undo step on release. Shift turns in steps of 15°, and Escape +cancels the drag. + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4d50fe995..de66ce2fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -947,7 +947,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. - Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. The Space, Move All, Rotate All and Select/Move widgets and Detach are missing. + Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Space, Move All and Select/Move widgets and Detach are missing. Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged.