mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 14:46:03 +00:00
With one Objects on Path group selected whose objects have room to move, the Selection and Objects on Path tools show a diamond below the first object. Dragging it moves every object along the path together, keeping the distances between them (object.objectsOnPath.options with start), in one undo step; Escape cancels it, and the first object's distance along the path shows next to the pointer. On an open path the widget shows only when the distance from the first object to the last is shorter than the path, and the objects stop at its ends; on a closed path they go on round across the first anchor. Where Auto or a Space value the open path is too short for sets the distance, the drag also sets spacing to the distance as drawn.
1473 lines
74 KiB
Rust
1473 lines
74 KiB
Rust
//! The Objects on Path tool (#2545): a click on a path attaches the selected objects to it
|
||
//! (`object.objectsOnPath.attach`). A path they can attach to is highlighted under the pointer,
|
||
//! and the pointer shows [`Cursor::ObjectsOnPath`] over it. The tool's options, `pivot` (0-8, row
|
||
//! 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.
|
||
//!
|
||
//! The Space widget: with one Objects on Path group of two or more objects selected, the same
|
||
//! tools show a square handle beyond the group's last object, ahead along the path. Dragging it
|
||
//! along the path sets the distance between the objects' attachment points
|
||
//! (`object.objectsOnPath.options` with `spacing` and `start`): the first object stays where it is
|
||
//! and the others move toward or away from it. With Alt (Option on macOS) the objects spread from
|
||
//! both ends toward or away from the center of an open path, or of the first anchor of a closed
|
||
//! one. The distance shows next to the pointer in the document's units, the drag is previewed and
|
||
//! kept as one undo step on release, and Escape cancels it.
|
||
//!
|
||
//! The Move All widget: with one Objects on Path group selected whose objects have room to move
|
||
//! along its path, the same tools show a diamond beyond the group's first object, below it.
|
||
//! Dragging it along the path moves every object along it as far, keeping the distances between
|
||
//! them (`object.objectsOnPath.options` with `start`). On an open path, the widget shows only when
|
||
//! the distance from the first object to the last is shorter than the path, and the objects stop
|
||
//! at its ends; on a closed path they go on round across its first anchor. The first object's
|
||
//! distance along the path shows next to the pointer in the document's units, the drag is
|
||
//! previewed and kept as one undo step on release, and Escape cancels it.
|
||
//!
|
||
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>,
|
||
//! <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html>.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::blend::Spine;
|
||
use vectorcraft_doc::objects_on_path::{CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop};
|
||
use vectorcraft_doc::{Document, Node, 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 Space widget's center sits this far (screen px) beyond the last object, ahead along the path.
|
||
const SPACE_OFFSET_PX: f64 = 16.0;
|
||
/// Half the side of the Space widget's square (screen px), as the canvas draws it.
|
||
pub const SPACE_HALF_PX: f32 = 3.5;
|
||
/// How near (screen px) the pointer must be to hit the Space widget.
|
||
const SPACE_HIT_PX: f64 = 6.0;
|
||
|
||
/// The Move All widget's center sits this far (screen px) beyond the first object, on the side its
|
||
/// bottom faces.
|
||
const MOVE_ALL_OFFSET_PX: f64 = 16.0;
|
||
/// Half the diagonal of the Move All widget's diamond (screen px), as the canvas draws it.
|
||
pub const MOVE_ALL_HALF_PX: f32 = 4.5;
|
||
/// How near (screen px) the pointer must be to hit the Move All widget.
|
||
const MOVE_ALL_HIT_PX: f64 = 6.0;
|
||
|
||
/// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with
|
||
/// its record, its children and its path: only when the selection is that one editable group, on
|
||
/// a path with length, and no anchors are Direct-Selected.
|
||
fn one_group<'a>(
|
||
doc: &'a Document,
|
||
selection: &Selection,
|
||
zoom: f64,
|
||
) -> Option<(NodeId, &'a ObjectsOnPath, &'a [std::sync::Arc<vectorcraft_doc::Node>], &'a vectorcraft_geom::PathData)> {
|
||
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)?)?;
|
||
Some((*group, rec, children.as_slice(), path))
|
||
}
|
||
|
||
/// The direction the top of the object `m` faces: the -y direction of the frame it is drawn in
|
||
/// (the document's y axis points down), so a reflected or sheared frame turns it with the art's
|
||
/// top. A unit vector in the document's axes.
|
||
fn top(m: &Member) -> Vec2 {
|
||
let [_, _, a2, a3, _, _] = m.frame.as_coeffs();
|
||
let len = a2.hypot(a3);
|
||
if len.is_finite() && len > 1e-12 {
|
||
Vec2::new(-a2 / len, -a3 / len)
|
||
} else {
|
||
let (sin, cos) = m.angle.sin_cos();
|
||
Vec2::new(sin, -cos)
|
||
}
|
||
}
|
||
|
||
/// The point `gap` (points) beyond the bounds of the object `m` (one of `children`), from its
|
||
/// attachment point `from` in the direction `dir`. `None` when it is not finite.
|
||
fn beside(doc: &Document, children: &[std::sync::Arc<Node>], m: &Member, from: Point, dir: Vec2, gap: f64) -> Option<Point> {
|
||
let b = children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?;
|
||
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 - from).dot(dir)).fold(0.0, f64::max);
|
||
let at = from + dir * (reach + gap);
|
||
(at.x.is_finite() && at.y.is_finite() && from.x.is_finite() && from.y.is_finite()).then_some(at)
|
||
}
|
||
|
||
/// 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<Self> {
|
||
let (group, rec, children, path) = one_group(doc, selection, zoom)?;
|
||
let first = rec.members.first()?;
|
||
let (pivot, _) = stop(path, rec.members.len(), rec.spacing, rec.start, 0)?;
|
||
let at = beside(doc, children, first, pivot, top(first), ROTATE_ALL_OFFSET_PX / zoom)?;
|
||
Some(Self { 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> {
|
||
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<Self> {
|
||
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<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("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<Action> {
|
||
if self.began { vec![Action::Commit] } else { vec![] }
|
||
}
|
||
|
||
/// The Rotate readout next to the pointer.
|
||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
if !self.began || !cx.measurement_labels {
|
||
return vec![];
|
||
}
|
||
vec![Overlay::Measure { p: self.at, text: format!("{:.1}°", self.rotate) }]
|
||
}
|
||
}
|
||
|
||
/// The Space widget of a selected Objects on Path group.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct SpaceWidget {
|
||
pub group: NodeId,
|
||
/// The last object's attachment point, in the document.
|
||
pub last: Point,
|
||
/// The distance along the path to the last object's attachment point (points).
|
||
pub last_length: f64,
|
||
/// The widget's center in the document.
|
||
pub at: Point,
|
||
/// The path's direction at the last object's attachment point: a unit vector in the
|
||
/// document's axes, in which the widget sits beyond the object.
|
||
pub dir: Vec2,
|
||
/// The length of the path (points).
|
||
pub len: f64,
|
||
/// Is the path closed?
|
||
pub closed: bool,
|
||
/// The number of objects on the path: 2 or more.
|
||
pub count: usize,
|
||
/// Where the first object attaches along the path (points from its first anchor), as drawn.
|
||
pub start: f64,
|
||
/// The distance along the path between neighboring objects' attachment points (points), as drawn.
|
||
pub gap: f64,
|
||
/// The group's Space value (`None`: Auto) and start, as saved: more than is drawn when the path
|
||
/// is too short for them.
|
||
pub saved: (Option<f64>, f64),
|
||
}
|
||
|
||
impl SpaceWidget {
|
||
/// The widget of the selection at `zoom` (screen px per document point): only when the
|
||
/// selection is one editable Objects on Path group with two or more objects on a path with
|
||
/// length, and no anchors are Direct-Selected. It sits beyond the last object's bounds, ahead
|
||
/// along the path's direction there.
|
||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||
let (group, rec, children, path) = one_group(doc, selection, zoom)?;
|
||
let count = rec.members.len();
|
||
if count < 2 {
|
||
return None;
|
||
}
|
||
let (len, closed) = path_length(path)?;
|
||
let (start, gap) = layout(len, closed, count, rec.spacing, rec.start);
|
||
let (last, angle) = stop(path, count, rec.spacing, rec.start, count - 1)?;
|
||
let dir = Vec2::new(angle.cos(), angle.sin());
|
||
let m = rec.members.last()?;
|
||
let b = children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?;
|
||
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 - last).dot(dir)).fold(0.0, f64::max);
|
||
let at = last + dir * (reach + SPACE_OFFSET_PX / zoom);
|
||
let finite = at.x.is_finite() && at.y.is_finite() && last.x.is_finite() && last.y.is_finite();
|
||
let last_length = nth_length(len, closed, start, gap, count - 1);
|
||
finite.then_some(Self { group, last, last_length, at, dir, len, closed, count, start, gap, saved: (rec.spacing, rec.start) })
|
||
}
|
||
|
||
/// The widget of `cx` when `p` is within reach of it.
|
||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||
Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(SPACE_HIT_PX))
|
||
}
|
||
}
|
||
|
||
/// Is `p` over the Space widget?
|
||
pub fn over_space(cx: &ToolContext, p: Point) -> bool {
|
||
SpaceWidget::at_point(cx, p).is_some()
|
||
}
|
||
|
||
/// The distance along the path of the Objects on Path group of `w` in `doc` to the point nearest
|
||
/// `p` ([`Spine::nearest`]: past the ends of an open path, along the lines of its end segments),
|
||
/// staying near `from` when the pointer is following the path from there. The line from the last
|
||
/// object's attachment point through the widget counts as the path too, as far as the pointer is
|
||
/// ahead of that object along it, so a widget beyond a corner measures from where it is. The flag
|
||
/// says whether the point is on that line, where the distance runs on past the end of a closed path
|
||
/// instead of round it.
|
||
fn length_near(doc: &Document, w: &SpaceWidget, p: Point, from: Option<f64>) -> Option<(f64, bool)> {
|
||
let n = doc.node(w.group)?;
|
||
let rec = n.extras().objects_on_path.as_deref()?;
|
||
let path = attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?;
|
||
let spine = Spine::new(path)?;
|
||
let bias = |s: f64| from.filter(|f| f.is_finite()).map_or(0.0, |f| spine.apart(s, f) / 2.0);
|
||
let ahead = (p - w.last).dot(w.dir);
|
||
let line = (ahead >= 0.0).then(|| {
|
||
let s = w.last_length + ahead;
|
||
((p - w.last).cross(w.dir).abs() + bias(s), s)
|
||
});
|
||
let (s, on_line) = match (spine.nearest(p, from), line) {
|
||
(Some(on), Some(line)) if line.0 < on.0 => (line.1, true),
|
||
(Some(on), _) => (on.1, false),
|
||
(None, line) => (line?.1, true),
|
||
};
|
||
s.is_finite().then_some((s, on_line))
|
||
}
|
||
|
||
/// Dragging the Space widget: the distance the pointer moves along the path moves the last object
|
||
/// along it as far, and the objects between follow evenly. With Alt the first object moves the
|
||
/// other way as far, so the objects spread from both ends.
|
||
#[derive(Clone, Debug)]
|
||
pub struct SpaceDrag {
|
||
widget: SpaceWidget,
|
||
start: Point,
|
||
began: bool,
|
||
/// The distance along the path to the point nearest the pointer at the last drag (points).
|
||
last: f64,
|
||
/// The distance the pointer has moved along the path since the press (points).
|
||
along: f64,
|
||
/// The distance between neighboring objects the drag sets (points).
|
||
gap: f64,
|
||
at: Point,
|
||
}
|
||
|
||
impl SpaceDrag {
|
||
/// The drag has left the click: the distance is changing.
|
||
pub fn adjusting(&self) -> bool {
|
||
self.began
|
||
}
|
||
|
||
/// Start a drag when the press `ev` is on the Space widget.
|
||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||
let widget = SpaceWidget::at_point(cx, ev.pos)?;
|
||
let (last, _) = length_near(cx.doc, &widget, ev.pos, None)?;
|
||
Some(Self { widget, start: ev.pos, began: false, last, along: 0.0, gap: widget.gap, at: ev.pos })
|
||
}
|
||
|
||
/// The pointer moved to `p` from `self.at`: add the distance it moved along the path. On the
|
||
/// line through the widget, from a point ahead of the last object, that is how far it moved
|
||
/// along the line; otherwise it takes the shorter way round a closed path.
|
||
fn follow(&mut self, doc: &Document, p: Point) {
|
||
let w = &self.widget;
|
||
let Some((t, on_line)) = length_near(doc, w, p, Some(self.last)) else { return };
|
||
let before = (self.at - w.last).dot(w.dir);
|
||
let step = if on_line && before >= 0.0 {
|
||
(p - w.last).dot(w.dir) - before
|
||
} else if w.closed {
|
||
(t - self.last + w.len / 2.0).rem_euclid(w.len) - w.len / 2.0
|
||
} else {
|
||
t - self.last
|
||
};
|
||
if step.is_finite() {
|
||
self.along += step;
|
||
self.last = t;
|
||
}
|
||
}
|
||
|
||
/// The Space and start the drag sets: without `alt` the first object stays and the last one goes
|
||
/// no further than the end of an open path; with `alt` the objects are centered on an open path,
|
||
/// or on the first anchor of a closed one, and spread over the whole path at most. On a closed
|
||
/// path the objects go no more than once around.
|
||
fn spacing_at(&self, alt: bool) -> (f64, f64) {
|
||
let w = &self.widget;
|
||
let k = (w.count - 1) as f64;
|
||
let along = self.along;
|
||
let round = w.len / w.count as f64;
|
||
if alt {
|
||
let most = if w.closed { round } else { w.len / k };
|
||
let gap = (w.gap + 2.0 * along / k).clamp(0.0, most);
|
||
let span = gap * k;
|
||
(gap, if w.closed { (-span / 2.0).rem_euclid(w.len) } else { (w.len - span) / 2.0 })
|
||
} else {
|
||
let most = if w.closed { round } else { (w.len - w.start) / k };
|
||
((w.gap + along / k).clamp(0.0, most.max(0.0)), w.start)
|
||
}
|
||
}
|
||
|
||
/// The pointer moved to `p`; `alt` spreads the objects from both ends.
|
||
pub fn drag(&mut self, cx: &ToolContext, p: Point, alt: bool) -> 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("Objects on Path Options".into()));
|
||
}
|
||
self.follow(cx.doc, p);
|
||
let (gap, start) = self.spacing_at(alt);
|
||
self.gap = gap;
|
||
self.at = p;
|
||
// Where the objects stay as they are drawn, the group keeps its saved values: Auto, or a
|
||
// Space value the path is too short for.
|
||
let w = &self.widget;
|
||
let (spacing, start) = if (gap - w.gap).abs() < 1e-9 && (start - w.start).abs() < 1e-9 {
|
||
(w.saved.0.map_or_else(|| json!("auto"), |g| json!(g)), json!(w.saved.1))
|
||
} else {
|
||
(json!(gap), json!(start))
|
||
};
|
||
out.push(Action::Preview("object.objectsOnPath.options".into(), json!({ "ids": [w.group.0], "spacing": spacing, "start": start })));
|
||
out
|
||
}
|
||
|
||
pub fn finish(self) -> Vec<Action> {
|
||
if self.began { vec![Action::Commit] } else { vec![] }
|
||
}
|
||
|
||
/// The distance between the objects next to the pointer, in the document's units.
|
||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
if !self.began || !cx.measurement_labels {
|
||
return vec![];
|
||
}
|
||
vec![Overlay::Measure { p: self.at, text: cx.len(self.gap) }]
|
||
}
|
||
}
|
||
|
||
/// The Move All widget of a selected Objects on Path group.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct MoveAllWidget {
|
||
pub group: NodeId,
|
||
/// The first object's attachment point, in the document.
|
||
pub first: Point,
|
||
/// The widget's center in the document.
|
||
pub at: Point,
|
||
/// The length of the path (points).
|
||
pub len: f64,
|
||
/// Is the path closed?
|
||
pub closed: bool,
|
||
/// Where the first object attaches along the path (points from its first anchor), as drawn.
|
||
pub start: f64,
|
||
/// The farthest along an open path the first object can attach with the others still on it
|
||
/// (points); the path's length on a closed path.
|
||
pub most: f64,
|
||
/// The Space value a drag sets with the start: the distance between the objects as drawn, when
|
||
/// the saved value would draw them apart differently from another start (Auto, or a Space value
|
||
/// an open path is too short for). `None` keeps the saved value.
|
||
pub gap: Option<f64>,
|
||
/// The group's start, as saved.
|
||
pub saved: f64,
|
||
}
|
||
|
||
impl MoveAllWidget {
|
||
/// 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, no anchors are Direct-Selected, and the objects have room to move: on an open path,
|
||
/// the distance from the first object to the last is shorter than the path. It sits beyond the
|
||
/// first object's bounds, on the side its bottom faces.
|
||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||
let (group, rec, children, path) = one_group(doc, selection, zoom)?;
|
||
let count = rec.members.len();
|
||
let first = rec.members.first()?;
|
||
let (len, closed) = path_length(path)?;
|
||
let (start, gap) = layout(len, closed, count, rec.spacing, rec.start);
|
||
let most = if closed { len } else { len - gap * count.saturating_sub(1) as f64 };
|
||
if !closed && (!most.is_finite() || most <= 1e-9) {
|
||
return None;
|
||
}
|
||
// On an open path, Auto and a Space value the path is too short for draw the objects apart
|
||
// by what is left of the path after the start, so another start would change the distance.
|
||
let keeps = closed || count < 2 || rec.spacing.is_some_and(|g| (g - gap).abs() < 1e-9);
|
||
let (at_first, _) = stop(path, count, rec.spacing, rec.start, 0)?;
|
||
let at = beside(doc, children, first, at_first, -top(first), MOVE_ALL_OFFSET_PX / zoom)?;
|
||
let finite = [len, start, most, rec.start].iter().all(|v| v.is_finite());
|
||
finite.then_some(Self { group, first: at_first, at, len, closed, start, most, gap: (!keeps).then_some(gap), saved: rec.start })
|
||
}
|
||
|
||
/// The widget of `cx` when `p` is within reach of it.
|
||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||
Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(MOVE_ALL_HIT_PX))
|
||
}
|
||
}
|
||
|
||
/// Is `p` over the Move All widget?
|
||
pub fn over_move_all(cx: &ToolContext, p: Point) -> bool {
|
||
MoveAllWidget::at_point(cx, p).is_some()
|
||
}
|
||
|
||
/// Dragging the Move All widget: the distance the pointer moves along the path moves every object
|
||
/// along it as far, keeping the distances between them. On an open path the objects stop at its
|
||
/// ends, and on a closed path they go on round across its first anchor.
|
||
#[derive(Clone, Debug)]
|
||
pub struct MoveAllDrag {
|
||
widget: MoveAllWidget,
|
||
start: Point,
|
||
began: bool,
|
||
/// The distance along the path to the point nearest the pointer at the last drag (points).
|
||
last: f64,
|
||
/// Where the drag puts the first object along the path (points from its first anchor).
|
||
to: f64,
|
||
at: Point,
|
||
}
|
||
|
||
impl MoveAllDrag {
|
||
/// The drag has left the click: the objects are moving.
|
||
pub fn adjusting(&self) -> bool {
|
||
self.began
|
||
}
|
||
|
||
/// Start a drag when the press `ev` is on the Move All widget.
|
||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||
let widget = MoveAllWidget::at_point(cx, ev.pos)?;
|
||
let (_, last) = group_spine(cx.doc, widget.group)?.nearest(ev.pos, Some(widget.start))?;
|
||
last.is_finite().then_some(Self { widget, start: ev.pos, began: false, last, to: widget.start, at: ev.pos })
|
||
}
|
||
|
||
/// The pointer moved to `p`: move the first object as far along the path as the point nearest
|
||
/// the pointer moved, the shorter way round a closed path. On an open path it stays between
|
||
/// the first anchor and [`MoveAllWidget::most`], so moving back from beyond an end moves the
|
||
/// objects back at once.
|
||
fn follow(&mut self, doc: &Document, p: Point) {
|
||
let w = &self.widget;
|
||
let Some((_, t)) = group_spine(doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return };
|
||
let step = if w.closed { (t - self.last + w.len / 2.0).rem_euclid(w.len) - w.len / 2.0 } else { t - self.last };
|
||
if !t.is_finite() || !step.is_finite() {
|
||
return;
|
||
}
|
||
self.last = t;
|
||
self.to = if w.closed {
|
||
let s = (self.to + step).rem_euclid(w.len);
|
||
if s.is_finite() && s < w.len { s } else { 0.0 }
|
||
} else {
|
||
(self.to + step).clamp(0.0, w.most)
|
||
};
|
||
}
|
||
|
||
/// The pointer moved to `p`.
|
||
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("Objects on Path Options".into()));
|
||
}
|
||
self.follow(cx.doc, p);
|
||
self.at = p;
|
||
let w = &self.widget;
|
||
// Where the objects stay as they are drawn, the group keeps its saved values.
|
||
let params = if (self.to - w.start).abs() < 1e-9 {
|
||
json!({ "ids": [w.group.0], "start": w.saved })
|
||
} else if let Some(gap) = w.gap {
|
||
json!({ "ids": [w.group.0], "start": self.to, "spacing": gap })
|
||
} else {
|
||
json!({ "ids": [w.group.0], "start": self.to })
|
||
};
|
||
out.push(Action::Preview("object.objectsOnPath.options".into(), params));
|
||
out
|
||
}
|
||
|
||
pub fn finish(self) -> Vec<Action> {
|
||
if self.began { vec![Action::Commit] } else { vec![] }
|
||
}
|
||
|
||
/// The first object's distance along the path next to the pointer, in the document's units.
|
||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
if !self.began || !cx.measurement_labels {
|
||
return vec![];
|
||
}
|
||
vec![Overlay::Measure { p: self.at, text: cx.len(self.to) }]
|
||
}
|
||
}
|
||
|
||
/// The path of the Objects on Path group `g` of `doc`, to measure along.
|
||
fn group_spine(doc: &Document, g: NodeId) -> Option<Spine> {
|
||
let n = doc.node(g)?;
|
||
let rec = n.extras().objects_on_path.as_deref()?;
|
||
Spine::new(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)
|
||
}
|
||
|
||
/// 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
|
||
/// something else is selected.
|
||
pub fn target(cx: &ToolContext, p: Point) -> Option<(NodeId, BezPath)> {
|
||
let h = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?;
|
||
let n = cx.doc.node(h.leaf)?;
|
||
let path = attachable_path(n)?;
|
||
if h.ancestry.iter().filter(|a| **a != h.leaf).any(|a| cx.doc.node(*a).is_some_and(is_objects_on_path)) {
|
||
return None;
|
||
}
|
||
if vectorcraft_doc::objects_on_path::held(cx.doc, h.leaf).is_some() {
|
||
return None;
|
||
}
|
||
cx.selection.objects.iter().any(|id| *id != h.leaf).then(|| (h.leaf, path.to_bezpath()))
|
||
}
|
||
|
||
pub struct ObjectsOnPathTool {
|
||
/// The path under the pointer the selection can attach to, and its outline.
|
||
hover: Option<(NodeId, BezPath)>,
|
||
/// Where the press was, while the button is down.
|
||
press: Option<Point>,
|
||
/// The Rotate All widget drag, while the button is down after a press on the widget.
|
||
rotating: Option<RotateAllDrag>,
|
||
/// The Space widget drag, while the button is down after a press on the widget.
|
||
spacing: Option<SpaceDrag>,
|
||
/// The Move All widget drag, while the button is down after a press on the widget.
|
||
moving: Option<MoveAllDrag>,
|
||
/// 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.
|
||
rotate: f64,
|
||
}
|
||
|
||
impl Default for ObjectsOnPathTool {
|
||
fn default() -> Self {
|
||
Self { hover: None, press: None, rotating: None, spacing: None, moving: None, pivot: CENTER, rotate: 0.0 }
|
||
}
|
||
}
|
||
|
||
impl Tool for ObjectsOnPathTool {
|
||
fn id(&self) -> &'static str {
|
||
"objectsOnPath"
|
||
}
|
||
|
||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||
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![],
|
||
};
|
||
}
|
||
if let Some(d) = &mut self.spacing {
|
||
return match ev.kind {
|
||
PointerKind::Drag => d.drag(cx, p, ev.mods.alt),
|
||
PointerKind::Up => self.spacing.take().map(SpaceDrag::finish).unwrap_or_default(),
|
||
_ => vec![],
|
||
};
|
||
}
|
||
if let Some(d) = &mut self.moving {
|
||
return match ev.kind {
|
||
PointerKind::Drag => d.drag(cx, p),
|
||
PointerKind::Up => self.moving.take().map(MoveAllDrag::finish).unwrap_or_default(),
|
||
_ => vec![],
|
||
};
|
||
}
|
||
match ev.kind {
|
||
PointerKind::Move => {
|
||
self.hover = if over_rotate_all(cx, p) || over_space(cx, p) || over_move_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![];
|
||
}
|
||
if let Some(d) = SpaceDrag::hit(cx, ev) {
|
||
self.spacing = Some(d);
|
||
self.hover = None;
|
||
return vec![];
|
||
}
|
||
if let Some(d) = MoveAllDrag::hit(cx, ev) {
|
||
self.moving = Some(d);
|
||
self.hover = None;
|
||
return vec![];
|
||
}
|
||
self.press = Some(p);
|
||
vec![]
|
||
}
|
||
PointerKind::Up => {
|
||
// A press that moved away is not a click.
|
||
let Some(start) = self.press.take().filter(|q| q.distance(p) < cx.tol(3.0)) else { return vec![] };
|
||
let Some((path, _)) = target(cx, start) else { return vec![] };
|
||
self.hover = None;
|
||
vec![Action::Exec("object.objectsOnPath.attach".into(), json!({ "path": path.0, "pivot": self.pivot, "rotate": self.rotate }))]
|
||
}
|
||
_ => vec![],
|
||
}
|
||
}
|
||
|
||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
let mut o: Vec<Overlay> =
|
||
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));
|
||
}
|
||
if let Some(d) = &self.spacing {
|
||
o.extend(d.overlays(cx));
|
||
}
|
||
if let Some(d) = &self.moving {
|
||
o.extend(d.overlays(cx));
|
||
}
|
||
o
|
||
}
|
||
|
||
fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor {
|
||
let idle = self.spacing.is_none() && self.moving.is_none();
|
||
if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) {
|
||
Cursor::Rotate
|
||
} else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) {
|
||
Cursor::ArrowHollow
|
||
} else if self.moving.is_some() || over_move_all(cx, p) {
|
||
Cursor::Move
|
||
} else if target(cx, p).is_some() {
|
||
Cursor::ObjectsOnPath
|
||
} else {
|
||
Cursor::Arrow
|
||
}
|
||
}
|
||
|
||
/// Escape while the button is down cancels the click, so the release attaches nothing, or the
|
||
/// Rotate All, Space or Move All drag, so the objects go back to the Rotate, Space or place
|
||
/// they had.
|
||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||
if key == ToolKey::Escape {
|
||
self.press = None;
|
||
self.hover = None;
|
||
let rotated = self.rotating.take().is_some_and(|d| d.adjusting());
|
||
let spaced = self.spacing.take().is_some_and(|d| d.adjusting());
|
||
let moved = self.moving.take().is_some_and(|d| d.adjusting());
|
||
if rotated || spaced || moved {
|
||
return vec![Action::Cancel];
|
||
}
|
||
}
|
||
vec![]
|
||
}
|
||
|
||
fn options(&self) -> Value {
|
||
json!({ "pivot": self.pivot, "rotate": self.rotate })
|
||
}
|
||
|
||
/// `pivot` takes a whole number from 0 to 8 and `rotate` a finite number, clamped to -360 to
|
||
/// 360; other values are ignored.
|
||
fn set_option(&mut self, key: &str, value: &Value) {
|
||
match key {
|
||
"pivot" => {
|
||
if let Some(n) = value.as_u64().filter(|n| *n <= 8) {
|
||
self.pivot = n as u8;
|
||
}
|
||
}
|
||
"rotate" => {
|
||
if let Some(r) = value.as_f64().filter(|r| r.is_finite()) {
|
||
self.rotate = r.clamp(-360.0, 360.0);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn busy(&self) -> bool {
|
||
self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some()
|
||
}
|
||
|
||
fn transforming(&self) -> bool {
|
||
self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting)
|
||
|| self.spacing.as_ref().is_some_and(SpaceDrag::adjusting)
|
||
|| self.moving.as_ref().is_some_and(MoveAllDrag::adjusting)
|
||
}
|
||
}
|
||
|
||
pub fn create(id: &str) -> Option<Box<dyn crate::Tool>> {
|
||
(id == "objectsOnPath").then(|| Box::new(ObjectsOnPathTool::default()) as Box<dyn crate::Tool>)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use std::sync::Arc;
|
||
|
||
use vectorcraft_doc::{Appearance, Node, NodeKind};
|
||
use vectorcraft_geom::{Rect, shapes};
|
||
|
||
use super::*;
|
||
use crate::testutil::{cx, paint};
|
||
|
||
fn doc() -> (Document, NodeId, NodeId) {
|
||
let mut d = Document::new(500.0, 500.0);
|
||
let l = d.layers[0].id;
|
||
let line = d.alloc_id();
|
||
let square = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::path(line, shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), Appearance::default_art())).unwrap();
|
||
d.insert(Some(l), 1, Node::path(square, shapes::rectangle(Rect::new(50.0, 50.0, 70.0, 70.0)), Appearance::default_art())).unwrap();
|
||
(d, line, square)
|
||
}
|
||
|
||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||
PointerEvent::new(kind, x, y)
|
||
}
|
||
|
||
#[test]
|
||
fn a_click_on_a_path_attaches_the_selection_to_it() {
|
||
let (d, line, square) = doc();
|
||
let mut sel = Selection::default();
|
||
sel.set([square]);
|
||
let paint = paint();
|
||
let cx = cx(&d, &sel, &paint);
|
||
let mut t = ObjectsOnPathTool::default();
|
||
let on = Point::new(200.0, 300.0);
|
||
assert_eq!(t.cursor(&cx, on, Mods::default()), Cursor::ObjectsOnPath);
|
||
assert_eq!(t.cursor(&cx, Point::new(200.0, 500.0), Mods::default()), Cursor::Arrow, "away from paths");
|
||
t.pointer(&cx, &ev(PointerKind::Move, on.x, on.y));
|
||
assert_eq!(t.overlays(&cx).len(), 1, "the path is highlighted");
|
||
t.pointer(&cx, &ev(PointerKind::Down, on.x, on.y));
|
||
let acts = t.pointer(&cx, &ev(PointerKind::Up, on.x, on.y));
|
||
assert!(matches!(acts.as_slice(), [Action::Exec(id, p)] if id == "object.objectsOnPath.attach" && p["path"] == line.0), "{acts:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_click_sends_the_tool_options_pivot_and_rotate() {
|
||
let (d, _, square) = doc();
|
||
let mut sel = Selection::default();
|
||
sel.set([square]);
|
||
let paint = paint();
|
||
let cx = cx(&d, &sel, &paint);
|
||
let mut t = ObjectsOnPathTool::default();
|
||
assert_eq!(t.options(), json!({ "pivot": 4, "rotate": 0.0 }), "the center, upright");
|
||
t.set_option("pivot", &json!(0));
|
||
t.set_option("rotate", &json!(45.5));
|
||
// Values out of range are ignored.
|
||
t.set_option("pivot", &json!(9));
|
||
t.set_option("rotate", &json!("x"));
|
||
assert_eq!(t.options(), json!({ "pivot": 0, "rotate": 45.5 }));
|
||
// Rotate is kept within the dialog's -360 to 360.
|
||
t.set_option("rotate", &json!(450));
|
||
assert_eq!(t.options()["rotate"], 360.0);
|
||
t.set_option("rotate", &json!(-720.5));
|
||
assert_eq!(t.options()["rotate"], -360.0);
|
||
t.set_option("rotate", &json!(45.5));
|
||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0));
|
||
let acts = t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0));
|
||
assert!(matches!(acts.as_slice(), [Action::Exec(_, p)] if p["pivot"] == 0 && p["rotate"] == 45.5), "{acts:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn escape_during_the_press_cancels_the_click() {
|
||
let (d, _, square) = doc();
|
||
let mut sel = Selection::default();
|
||
sel.set([square]);
|
||
let paint = paint();
|
||
let cx = cx(&d, &sel, &paint);
|
||
let mut t = ObjectsOnPathTool::default();
|
||
t.pointer(&cx, &ev(PointerKind::Move, 200.0, 300.0));
|
||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0));
|
||
assert!(t.busy());
|
||
t.key(&cx, ToolKey::Escape, Mods::default());
|
||
assert!(!t.busy() && t.overlays(&cx).is_empty());
|
||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0)).is_empty(), "the release attaches nothing");
|
||
}
|
||
|
||
#[test]
|
||
fn nothing_attaches_without_other_objects_selected() {
|
||
let (d, line, _) = doc();
|
||
let mut sel = Selection::default();
|
||
sel.set([line]);
|
||
let paint = paint();
|
||
let cx = cx(&d, &sel, &paint);
|
||
let mut t = ObjectsOnPathTool::default();
|
||
assert_eq!(t.cursor(&cx, Point::new(200.0, 300.0), Mods::default()), Cursor::Arrow);
|
||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0));
|
||
assert!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0)).is_empty());
|
||
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<vectorcraft_geom::Affine>) -> (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<f64> {
|
||
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<NodeId> = 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<dyn Tool>; 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<dyn Tool>; 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();
|
||
let l = d.layers[0].id;
|
||
let key = d.alloc_id();
|
||
d.insert(Some(l), 2, Node::path(key, shapes::line(Point::new(0.0, 100.0), Point::new(400.0, 100.0)), Appearance::default_art())).unwrap();
|
||
let blend = d.alloc_id();
|
||
d.wrap(&[line, key], |children| Node::new(blend, NodeKind::Blend { children, spec: Default::default() })).unwrap();
|
||
let mask = d.alloc_id();
|
||
let art = d.alloc_id();
|
||
d.insert(Some(l), 2, Node::path(mask, shapes::rectangle(Rect::new(100.0, 380.0, 300.0, 430.0)), Appearance::default_art())).unwrap();
|
||
d.insert(Some(l), 3, Node::path(art, shapes::rectangle(Rect::new(250.0, 390.0, 290.0, 420.0)), Appearance::default_art())).unwrap();
|
||
let clip = d.alloc_id();
|
||
d.wrap(&[mask, art], |children| {
|
||
let mut g = Node::group(clip, children);
|
||
g.set_clips(true);
|
||
g
|
||
})
|
||
.unwrap();
|
||
assert_eq!(d.node(clip).and_then(|g| g.children()).and_then(|c| c.first()).map(|c| c.id), Some(mask));
|
||
let mut sel = Selection::default();
|
||
sel.set([square]);
|
||
let paint = paint();
|
||
let cx = cx(&d, &sel, &paint);
|
||
let mut t = ObjectsOnPathTool::default();
|
||
for (p, what) in [((150.0, 400.0), "a clipping path"), ((200.0, 300.0), "a blend's key path"), ((200.0, 100.0), "a blend's other key path")] {
|
||
assert_eq!(t.cursor(&cx, Point::new(p.0, p.1), Mods::default()), Cursor::Arrow, "{what}");
|
||
t.pointer(&cx, &ev(PointerKind::Move, p.0, p.1));
|
||
assert!(t.overlays(&cx).is_empty(), "{what} is not highlighted");
|
||
t.pointer(&cx, &ev(PointerKind::Down, p.0, p.1));
|
||
assert!(t.pointer(&cx, &ev(PointerKind::Up, p.0, p.1)).is_empty(), "a click on {what} attaches nothing");
|
||
}
|
||
}
|
||
|
||
/// [`group_doc`] with the group's Space `spacing` from `start`, its objects placed again.
|
||
fn spaced_doc(spacing: Option<f64>, start: f64) -> (Document, NodeId, Selection) {
|
||
let (mut d, g, sel) = group_doc(0.0);
|
||
let empty = Document::new(1.0, 1.0);
|
||
let symbols = empty.symbol_boxes();
|
||
let n = d.node_mut(g).unwrap();
|
||
let rec = n.extras().objects_on_path.as_deref().cloned().unwrap();
|
||
n.extras_mut().objects_on_path = Some(Box::new(vectorcraft_doc::objects_on_path::ObjectsOnPath { spacing, start, ..rec }));
|
||
vectorcraft_doc::objects_on_path::place(n, &symbols);
|
||
(d, g, sel)
|
||
}
|
||
|
||
/// The Space and start a drag's last action previews, to 1/1,000,000 pt.
|
||
fn spaced(acts: &[Action]) -> Option<(f64, f64)> {
|
||
let round = |v: f64| (v * 1e6).round() / 1e6 + 0.0;
|
||
match acts.last() {
|
||
Some(Action::Preview(cmd, p)) if cmd == "object.objectsOnPath.options" => {
|
||
Some((round(p["spacing"].as_f64()?), round(p["start"].as_f64()?)))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn alt(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||
PointerEvent { mods: Mods { alt: true, ..Mods::default() }, ..ev(kind, x, y) }
|
||
}
|
||
|
||
#[test]
|
||
fn the_space_widget_sits_beyond_the_last_object_ahead_along_the_path() {
|
||
// Auto: the second square sits on the end (400, 300) and spans 390 to 410.
|
||
let (d, g, sel) = group_doc(0.0);
|
||
let w = SpaceWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert_eq!((w.group, w.count, w.closed), (g, 2, false));
|
||
assert!(near(w.last, Point::new(400.0, 300.0)) && near(w.at, Point::new(426.0, 300.0)), "{w:?}");
|
||
assert!((w.len - 400.0).abs() < 1e-9 && w.start == 0.0 && (w.gap - 400.0).abs() < 1e-9, "{w:?}");
|
||
assert!(near(SpaceWidget::of(&d, &sel, 2.0).unwrap().at, Point::new(418.0, 300.0)));
|
||
// With a Space set it follows the last object, and Rotate All follows the first one.
|
||
let (d, _, sel) = spaced_doc(Some(100.0), 50.0);
|
||
let w = SpaceWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert!(near(w.last, Point::new(150.0, 300.0)) && near(w.at, Point::new(176.0, 300.0)), "{w:?}");
|
||
assert!(near(RotateAllWidget::of(&d, &sel, 1.0).unwrap().pivot, Point::new(50.0, 300.0)));
|
||
// No widget for an object in the group, or for a locked group.
|
||
let children: Vec<NodeId> = 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!(SpaceWidget::of(&d, &one, 1.0).is_none());
|
||
let (mut d, g, sel) = group_doc(0.0);
|
||
if let Some(n) = d.node_mut(g) {
|
||
n.locked = true;
|
||
}
|
||
assert!(SpaceWidget::of(&d, &sel, 1.0).is_none());
|
||
}
|
||
|
||
/// Dragging the widget 100 pt back along the path brings the last object 100 pt nearer the
|
||
/// first one, which stays; with Alt the first one comes as far the other way. Switching Alt
|
||
/// during the drag works from where the drag began.
|
||
#[test]
|
||
fn dragging_the_space_widget_spaces_the_objects_from_the_first_one() {
|
||
let (d, g, sel) = group_doc(0.0);
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let at = SpaceWidget::of(&d, &sel, 1.0).unwrap().at;
|
||
let tools: [Box<dyn Tool>; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())];
|
||
for mut t in tools {
|
||
let id = t.id();
|
||
assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::ArrowHollow, "{id}");
|
||
assert!(t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)).is_empty(), "{id}");
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, at.x - 100.0, at.y + 30.0));
|
||
assert_eq!(a.first(), Some(&Action::Begin("Objects on Path Options".into())), "{id}");
|
||
assert!(matches!(a.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{a:?}");
|
||
assert_eq!(spaced(&a), Some((300.0, 0.0)), "{id}: the first object stays");
|
||
assert!(t.transforming() && t.cursor(&c, at, Mods::default()) == Cursor::ArrowHollow, "{id}");
|
||
let label = c.len(300.0);
|
||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if *text == label)), "{id}");
|
||
assert_eq!(spaced(&t.pointer(&c, &alt(PointerKind::Drag, at.x - 100.0, at.y))), Some((200.0, 100.0)), "{id}: from both ends");
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, at.x - 100.0, at.y))), Some((300.0, 0.0)), "{id}: Alt let go");
|
||
// No further than the end, where the objects are as drawn and the group stays Auto; and no
|
||
// less than 0.
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, at.x + 50.0, at.y));
|
||
assert!(matches!(a.last(), Some(Action::Preview(_, p)) if p["spacing"] == "auto" && p["start"] == 0.0), "{id}: {a:?}");
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, at.x - 900.0, at.y))), Some((0.0, 0.0)), "{id}");
|
||
assert_eq!(spaced(&t.pointer(&c, &alt(PointerKind::Drag, at.x - 900.0, at.y))), Some((0.0, 200.0)), "{id}");
|
||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)), vec![Action::Commit], "{id}");
|
||
assert!(!t.busy(), "{id}");
|
||
// 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(), "{id}");
|
||
}
|
||
}
|
||
|
||
/// A Space value the path is too short for stays when a drag leaves the objects where they are
|
||
/// drawn, and a drag that moves them sets the drawn value.
|
||
#[test]
|
||
fn a_drag_that_leaves_the_objects_as_drawn_keeps_the_saved_space() {
|
||
let (d, _, sel) = spaced_doc(Some(1000.0), 0.0);
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let w = SpaceWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert_eq!((w.gap, w.saved), (400.0, (Some(1000.0), 0.0)), "drawn to the end of the 400 pt path");
|
||
let mut t = ObjectsOnPathTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y));
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x + 50.0, w.at.y))), Some((1000.0, 0.0)));
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x - 50.0, w.at.y))), Some((350.0, 0.0)));
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x, w.at.y))), Some((1000.0, 0.0)), "back where it began");
|
||
}
|
||
|
||
/// On a closed path the objects go no more than once around, and Alt centers them on the
|
||
/// first anchor.
|
||
#[test]
|
||
fn dragging_the_space_widget_on_a_closed_path_wraps_at_the_first_anchor() {
|
||
let w = SpaceWidget {
|
||
group: NodeId(1),
|
||
last: Point::ZERO,
|
||
last_length: 300.0,
|
||
at: Point::ZERO,
|
||
dir: Vec2::new(1.0, 0.0),
|
||
len: 400.0,
|
||
closed: true,
|
||
count: 4,
|
||
start: 0.0,
|
||
gap: 100.0,
|
||
saved: (None, 0.0),
|
||
};
|
||
let moved = |along| SpaceDrag { widget: w, start: Point::ZERO, began: true, last: 0.0, along, gap: w.gap, at: Point::ZERO };
|
||
assert_eq!(moved(30.0).spacing_at(false), (100.0, 0.0), "a quarter of the way round at most");
|
||
assert_eq!(moved(-60.0).spacing_at(false), (80.0, 0.0));
|
||
assert_eq!(moved(-60.0).spacing_at(true), (60.0, 310.0), "the span of 180 centered on the first anchor");
|
||
}
|
||
|
||
/// An Objects on Path group of two 20 × 20 squares on an L: 400 pt to the right from (0, 300),
|
||
/// then 300 pt up, with Space `spacing`; and a selection of the group.
|
||
fn corner_doc(spacing: Option<f64>) -> (Document, Selection) {
|
||
let points = [Point::new(0.0, 300.0), Point::new(400.0, 300.0), Point::new(400.0, 0.0)];
|
||
path_doc(vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, false)), spacing)
|
||
}
|
||
|
||
/// An Objects on Path group of two 20 × 20 squares on `path`, with Space `spacing`; and a
|
||
/// selection of the group.
|
||
fn path_doc(path: vectorcraft_geom::PathData, spacing: Option<f64>) -> (Document, 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, path, 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 rec = vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], CENTER, 0.0);
|
||
let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath { spacing, ..rec };
|
||
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, sel)
|
||
}
|
||
|
||
/// On an L-shaped path the drag counts the distance along the path, round the corner.
|
||
#[test]
|
||
fn a_space_drag_follows_the_path_round_a_corner() {
|
||
let (d, sel) = corner_doc(None);
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
// The path is 700 pt long; the second object sits on its end (400, 0), and the widget above it.
|
||
let w = SpaceWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert!(near(w.last, Point::new(400.0, 0.0)) && (w.gap - 700.0).abs() < 1e-9, "{w:?}");
|
||
let mut t = ObjectsOnPathTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y));
|
||
// The press, 26 pt beyond the end, is 726 pt along. Down the upright leg to 500 pt along,
|
||
// then round the corner onto the level one at 300 pt along.
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 400.0, 200.0))), Some((474.0, 0.0)));
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 300.0, 310.0))), Some((274.0, 0.0)));
|
||
}
|
||
|
||
/// With the last object just short of the corner, the widget sits past the corner; the drag
|
||
/// measures from the widget, so the objects follow the pointer from the start.
|
||
#[test]
|
||
fn a_space_widget_past_a_corner_measures_from_where_it_is() {
|
||
let (d, sel) = corner_doc(Some(390.0));
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let w = SpaceWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert!(near(w.last, Point::new(390.0, 300.0)) && near(w.at, Point::new(416.0, 300.0)) && w.last_length == 390.0, "{w:?}");
|
||
let mut t = ObjectsOnPathTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y));
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 396.0, 300.0))), Some((370.0, 0.0)), "20 pt back");
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 386.0, 300.0))), Some((360.0, 0.0)));
|
||
}
|
||
|
||
/// On a closed path, a drag far ahead along the line through the widget spreads the objects
|
||
/// apart, however far the pointer goes at once.
|
||
#[test]
|
||
fn a_long_drag_ahead_on_a_closed_path_spreads_the_objects() {
|
||
// The square's outline is 800 pt round, from (100, 100) to the right.
|
||
let (d, sel) = path_doc(shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), Some(100.0));
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let w = SpaceWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert!(near(w.last, Point::new(200.0, 100.0)) && near(w.at, Point::new(226.0, 100.0)), "{w:?}");
|
||
let mut t = ObjectsOnPathTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y));
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 236.0, 100.0))), Some((110.0, 0.0)));
|
||
assert_eq!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, 1000.0, 100.0))), Some((400.0, 0.0)), "half way round at most");
|
||
}
|
||
|
||
/// The start a Move All drag's last action previews, to 1/1,000,000 pt, and whether it also
|
||
/// sets a Space value.
|
||
fn moved(acts: &[Action]) -> Option<(f64, Option<f64>)> {
|
||
let round = |v: f64| (v * 1e6).round() / 1e6 + 0.0;
|
||
match acts.last() {
|
||
Some(Action::Preview(cmd, p)) if cmd == "object.objectsOnPath.options" && p.get("rotate").is_none() => {
|
||
Some((round(p["start"].as_f64()?), p.get("spacing").and_then(Value::as_f64).map(round)))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// A document holding an Objects on Path group of one 20 × 20 square on the line from (0, 300)
|
||
/// to (400, 300), and a selection of the group.
|
||
fn single_doc() -> (Document, Selection) {
|
||
let mut d = Document::new(500.0, 500.0);
|
||
let l = d.layers[0].id;
|
||
let (line, a, g) = (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 rec = vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &[&a], CENTER, 0.0);
|
||
let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a)], 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, sel)
|
||
}
|
||
|
||
#[test]
|
||
fn the_move_all_widget_sits_below_the_first_object_when_the_objects_have_room() {
|
||
// Space 100 from 50: the first square spans 290 to 310 down the page, and the widget is
|
||
// 16 px below its bottom. The two objects take 100 pt of the 400 pt path, so the first
|
||
// one can go from 0 to 300 along it.
|
||
let (d, g, sel) = spaced_doc(Some(100.0), 50.0);
|
||
let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert_eq!(w.group, g);
|
||
assert!(near(w.first, Point::new(50.0, 300.0)) && near(w.at, Point::new(50.0, 326.0)), "{w:?}");
|
||
assert!(w.start == 50.0 && (w.most - 300.0).abs() < 1e-9 && w.gap.is_none() && !w.closed, "{w:?}");
|
||
assert!(near(MoveAllWidget::of(&d, &sel, 2.0).unwrap().at, Point::new(50.0, 318.0)));
|
||
// Auto spreads the objects from one end of an open path to the other: no room to move.
|
||
let (d, _, sel) = group_doc(0.0);
|
||
assert!(MoveAllWidget::of(&d, &sel, 1.0).is_none(), "Auto");
|
||
// Nor with a Space value that reaches the end.
|
||
let (d, _, sel) = spaced_doc(Some(400.0), 0.0);
|
||
assert!(MoveAllWidget::of(&d, &sel, 1.0).is_none(), "the whole path");
|
||
// On a closed path the objects always have room, Auto included.
|
||
let (d, sel) = path_doc(shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), None);
|
||
let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert!(w.closed && (w.most - 800.0).abs() < 1e-9 && w.gap.is_none(), "{w:?}");
|
||
// One object on an open path can move along all of it.
|
||
let (d, sel) = single_doc();
|
||
let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert!(w.start == 0.0 && (w.most - 400.0).abs() < 1e-9 && w.gap.is_none(), "{w:?}");
|
||
// No widget for an object in the group, or for a locked group.
|
||
let (d, _, sel) = spaced_doc(Some(100.0), 50.0);
|
||
let children: Vec<NodeId> = 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!(MoveAllWidget::of(&d, &one, 1.0).is_none());
|
||
let (mut d, g, sel) = spaced_doc(Some(100.0), 50.0);
|
||
if let Some(n) = d.node_mut(g) {
|
||
n.locked = true;
|
||
}
|
||
assert!(MoveAllWidget::of(&d, &sel, 1.0).is_none());
|
||
}
|
||
|
||
/// Dragging the widget 80 pt along the path moves both objects 80 pt, keeping the Space, with
|
||
/// the Selection tool and with the Objects on Path tool, previewed while dragging and kept as
|
||
/// one undo step on release. On an open path the objects stop at its ends, and coming back
|
||
/// moves them back at once.
|
||
#[test]
|
||
fn dragging_the_move_all_widget_moves_every_object_along_the_path() {
|
||
let (d, g, sel) = spaced_doc(Some(100.0), 50.0);
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let at = MoveAllWidget::of(&d, &sel, 1.0).unwrap().at;
|
||
let tools: [Box<dyn Tool>; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())];
|
||
for mut t in tools {
|
||
let id = t.id();
|
||
assert_eq!(t.cursor(&c, at, Mods::default()), Cursor::Move, "{id}");
|
||
assert!(t.pointer(&c, &ev(PointerKind::Down, at.x, at.y)).is_empty(), "{id}");
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, at.x + 80.0, at.y + 10.0));
|
||
assert_eq!(a.first(), Some(&Action::Begin("Objects on Path Options".into())), "{id}");
|
||
assert!(matches!(a.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{a:?}");
|
||
assert_eq!(moved(&a), Some((130.0, None)), "{id}: only the start changes");
|
||
assert!(t.transforming() && t.cursor(&c, at, Mods::default()) == Cursor::Move, "{id}");
|
||
let label = c.len(130.0);
|
||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if *text == label)), "{id}");
|
||
// Shift and Alt change nothing.
|
||
let mods = Mods { shift: true, alt: true, ..Mods::default() };
|
||
assert_eq!(moved(&t.pointer(&c, &PointerEvent { mods, ..ev(PointerKind::Drag, at.x + 90.0, at.y) })), Some((140.0, None)), "{id}");
|
||
// No further than where the last object reaches the end, and back at once.
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, at.x + 900.0, at.y))), Some((300.0, None)), "{id}");
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, at.x + 880.0, at.y))), Some((280.0, None)), "{id}");
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, at.x - 900.0, at.y))), Some((0.0, None)), "{id}");
|
||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)), vec![Action::Commit], "{id}");
|
||
assert!(!t.busy(), "{id}");
|
||
// 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 + 1.0, at.y)).is_empty(), "{id}");
|
||
}
|
||
}
|
||
|
||
/// Where the saved Space would draw the objects apart differently from another start (Auto,
|
||
/// or a value the path is too short for), the drag sets the distance as drawn with the start;
|
||
/// back where it began, the group keeps its saved values.
|
||
#[test]
|
||
fn a_move_all_drag_keeps_the_distance_the_objects_are_drawn_at() {
|
||
for (spacing, what) in [(Some(1000.0), "a Space value too large for the path"), (None, "Auto")] {
|
||
let (d, _, sel) = spaced_doc(spacing, 100.0);
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert!((w.most - 100.0).abs() < 1e-9 && w.gap == Some(300.0), "{what}: {w:?}");
|
||
let mut t = ObjectsOnPathTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y));
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x - 40.0, w.at.y))), Some((60.0, Some(300.0))), "{what}");
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, w.at.x, w.at.y));
|
||
assert_eq!(moved(&a), Some((100.0, None)), "{what}: back where it began");
|
||
}
|
||
}
|
||
|
||
/// On an L-shaped path the drag counts the distance along the path, round the corner.
|
||
#[test]
|
||
fn a_move_all_drag_follows_the_path_round_a_corner() {
|
||
let (d, sel) = corner_doc(Some(100.0));
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert!(near(w.at, Point::new(0.0, 326.0)) && (w.most - 600.0).abs() < 1e-9, "{w:?}");
|
||
let mut t = ObjectsOnPathTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y));
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 200.0, 326.0))), Some((200.0, None)));
|
||
// Up the upright leg, 200 pt from the corner.
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 426.0, 100.0))), Some((600.0, None)), "no further than the end");
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 426.0, 200.0))), Some((500.0, None)));
|
||
}
|
||
|
||
/// On a closed path the objects go on round across the first anchor, both ways.
|
||
#[test]
|
||
fn a_move_all_drag_on_a_closed_path_goes_round_across_the_first_anchor() {
|
||
// The square's outline is 800 pt round, from (100, 100) to the right.
|
||
let (d, sel) = path_doc(shapes::rectangle(Rect::new(100.0, 100.0, 300.0, 300.0)), Some(100.0));
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap();
|
||
assert!(near(w.first, Point::new(100.0, 100.0)) && near(w.at, Point::new(100.0, 126.0)), "{w:?}");
|
||
let mut t = ObjectsOnPathTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y));
|
||
// Down the left side, back before the first anchor.
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 100.0, 150.0))), Some((776.0, None)));
|
||
// Round the corner onto the top, ahead of it again.
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, 130.0, 100.0))), Some((56.0, None)));
|
||
}
|
||
|
||
/// One object moves along all of an open path.
|
||
#[test]
|
||
fn a_move_all_drag_moves_a_single_object_to_the_end() {
|
||
let (d, sel) = single_doc();
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let w = MoveAllWidget::of(&d, &sel, 1.0).unwrap();
|
||
let mut t = crate::select::SelectionTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, w.at.x, w.at.y));
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x + 150.0, w.at.y))), Some((150.0, None)));
|
||
assert_eq!(moved(&t.pointer(&c, &ev(PointerKind::Drag, w.at.x + 600.0, w.at.y))), Some((400.0, None)));
|
||
}
|
||
|
||
/// Escape during a Move All drag cancels it: the readout is removed, and the release keeps
|
||
/// nothing.
|
||
#[test]
|
||
fn escape_cancels_a_move_all_drag() {
|
||
let (d, _, sel) = spaced_doc(Some(100.0), 50.0);
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let at = MoveAllWidget::of(&d, &sel, 1.0).unwrap().at;
|
||
let tools: [Box<dyn Tool>; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())];
|
||
for mut t in tools {
|
||
t.pointer(&c, &ev(PointerKind::Down, at.x, at.y));
|
||
assert!(moved(&t.pointer(&c, &ev(PointerKind::Drag, at.x + 40.0, at.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());
|
||
}
|
||
}
|
||
|
||
/// Escape during a Space drag cancels it: the readout is removed, and the release keeps
|
||
/// nothing.
|
||
#[test]
|
||
fn escape_cancels_a_space_drag() {
|
||
let (d, _, sel) = group_doc(0.0);
|
||
let paint = paint();
|
||
let c = cx(&d, &sel, &paint);
|
||
let at = SpaceWidget::of(&d, &sel, 1.0).unwrap().at;
|
||
let tools: [Box<dyn Tool>; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())];
|
||
for mut t in tools {
|
||
t.pointer(&c, &ev(PointerKind::Down, at.x, at.y));
|
||
assert!(spaced(&t.pointer(&c, &ev(PointerKind::Drag, at.x - 40.0, at.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());
|
||
}
|
||
}
|
||
}
|