Files
vectorcraft/crates/tools/src/dimension.rs
T
dotCooCoo ed17e8fc17 Dimension tool: linear dimensions measure horizontally, vertically or aligned
A linear dimension between two clicked points, or dragged out from a
segment, always measured the straight distance between its points and
drew its line parallel to them, so two points at different heights gave
only a slanted dimension. The linear dimensions help describes aligned,
horizontal and vertical linear dimensions: after the second click,
clicking again gives an aligned one and moving the pointer gives a
horizontal or vertical one.
https://helpx.adobe.com/illustrator/desktop/measure-and-align/plot-and-measure/plot-linear-dimensions-of-objects.html

While a linear dimension is being placed, the pointer's position sets
whether it is aligned, horizontal or vertical. The rule is VectorCraft's
own. With the pointer beside the two points (the nearest point of the
line through them lies between them) or on that line, the dimension is
aligned. Past either point, the line through the points and the
perpendicular through that point bound a quarter on each side of the
line. The quarter that holds the direction straight up or down gives
the horizontal distance, and the quarter that holds the direction
straight left or right gives the vertical distance. A click within
reach of the second point, or of where the pointer was for the second
click, places it aligned. Points on one horizontal or vertical line
give an aligned dimension. The dimension stays linked to the anchors it
measures.

- Dimension recipes carry an orientation (aligned by default; files
  without it open as before). The line runs along the axis, and each
  extension line runs from its point to it.
- dimension.create and dimension.update take orientation: aligned,
  horizontal or vertical, for linear dimensions only. Any other value
  is an error. A new orientation keeps the line on the side of the
  points it was on, and an update that changes nothing records no
  undo step.
- A horizontal or vertical dimension keeps its orientation when it or
  the art it measures turns, and shows 0 when its points come into line
  across its axis. A line that lay outside the points stays outside
  them after a turn.
- The hint bar names the two-click placement.

Docs: docs/dimensions.md, the Evidence list in docs/target-app-parity.md.
2026-11-10 16:54:35 -08:00

779 lines
35 KiB
Rust

//! The Dimension tool. Hover a segment (linear), a corner (angular) or a circle or perfect arc
//! (radial) and drag outwards: releasing places the linked dimension. Plain clicks pick what to
//! measure instead: two points (linear) or two straight lines (angular), then a click places it;
//! a click on a circle or arc gives its radius. Escape cancels without leaving any artwork.
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
use serde_json::{Value, json};
use vectorcraft_doc::{
Node,
dimension::{self, Dimension, DimensionMode, DimensionOptions, DimensionSource, Orientation, PointRef, SegmentRef},
};
use vectorcraft_geom::{BezPath, Point, Vec2};
/// The three kinds of dimension the tool draws.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Kind {
#[default]
Linear,
Angular,
Radial,
}
impl Kind {
pub fn id(self) -> &'static str {
match self {
Kind::Linear => "linear",
Kind::Angular => "angular",
Kind::Radial => "radial",
}
}
/// `radius` and `diameter` are the radial kind.
pub fn parse(s: &str) -> Option<Kind> {
match s {
"linear" => Some(Kind::Linear),
"angular" => Some(Kind::Angular),
"radial" | "radius" | "diameter" => Some(Kind::Radial),
_ => None,
}
}
}
/// What the pointer is over.
#[derive(Clone, Debug, PartialEq)]
enum Target {
Segment(SegmentRef),
Corner { first: SegmentRef, second: SegmentRef, at: Point },
Radial { source: DimensionSource, center: Point, radius: f64 },
}
#[derive(Default)]
pub struct DimensionTool {
kind: Kind,
style: DimensionOptions,
/// Where the button went down, and what was under it.
press: Option<(Point, Option<Target>)>,
/// Plain-click picks: points (linear) or straight lines (angular).
points: Vec<(Point, Option<PointRef>)>,
lines: Vec<SegmentRef>,
/// A dimension being placed: previews follow the pointer.
pending: Option<Dimension>,
dragging: bool,
hover: Option<Target>,
at: Point,
/// Whether Shift was down at the last pointer event: the picked line shows constrained.
shift: bool,
began: bool,
/// Smart Guides for the Linear picks, hovering too: the guides show before each click.
snap: crate::guides::DrawSnap,
/// Where the next Linear pick lands, as last worked out for the pointer (for the overlays).
snapped: Option<(Point, Option<PointRef>)>,
/// Whether the press under way began on an anchor a Linear click picks.
press_on_anchor: bool,
/// The second point clicked for the Linear dimension being placed, and where the pointer was
/// for that click (a snap or Shift can move the point away from it): placed with the pointer
/// still at either, it is aligned.
second: Option<(Point, Point)>,
}
/// What a linear dimension between `a` and `b` measures with the pointer at `p` while it is
/// placed (VectorCraft's own rule). Beside the points, where the nearest point of the line
/// through them lies between them, the dimension is aligned: it measures the distance between
/// them. Past either point it measures along an axis. The line through the points and the
/// perpendicular at that point bound a quarter on each side; the pointer's quarter holds either
/// the straight up or down direction, which gives the horizontal distance, or the straight left
/// or right direction, which gives the vertical distance. Points on one horizontal or vertical
/// line only give an aligned dimension.
fn orientation_at(a: Point, b: Point, p: Point) -> Orientation {
let u = b - a;
let len = u.hypot();
if len < 1e-9 {
return Orientation::Aligned;
}
let along = (p - a).dot(u) / (len * len);
if (0.0..=1.0).contains(&along) {
return Orientation::Aligned;
}
// Out past the nearer point, and the side of the line the pointer is on.
let (out, side) = (if along > 1.0 { u } else { -u }, u.cross(p - a));
let axes = [
(Vec2::new(0.0, 1.0), Orientation::Horizontal),
(Vec2::new(0.0, -1.0), Orientation::Horizontal),
(Vec2::new(1.0, 0.0), Orientation::Vertical),
(Vec2::new(-1.0, 0.0), Orientation::Vertical),
];
let within = len * 1e-9;
axes.into_iter()
.find(|(axis, _)| axis.dot(out) > within && u.cross(*axis).abs() > within && u.cross(*axis) * side > 0.0)
.map_or(Orientation::Aligned, |(_, orientation)| orientation)
}
/// How near an anchor (screen pixels) a pick snaps to it.
const ANCHOR_REACH: f64 = 8.0;
/// `at` as a Shift click picks it: the direction from `last` snapped to 45° steps (from the
/// constrain angle), the distance kept.
fn constrained(cx: &ToolContext, last: Point, at: Point) -> Point {
last + vectorcraft_geom::constrain_angle_from(at - last, 45.0, cx.constrain_angle)
}
/// Nearest editable segment, with bounded work and no snapping to dimension artwork itself.
pub fn pick(cx: &ToolContext, p: Point) -> Option<SegmentRef> {
let mut best = None;
let mut dist = cx.tol(8.0);
let mut budget = 20_000usize;
fn visit(n: &Node, p: Point, budget: &mut usize, dist: &mut f64, best: &mut Option<SegmentRef>) {
if *budget == 0 || !n.visible || n.locked || n.extras().dimension.is_some() {
return;
}
if let Some(path) = n.path_data() {
for (si, sp) in path.subpaths.iter().enumerate() {
for i in 0..sp.segment_count() {
if *budget == 0 {
return;
}
*budget -= 1;
use vectorcraft_geom::kurbo::ParamCurveNearest;
let near = sp.segment(i).nearest(p, 1e-4);
if near.distance_sq < *dist * *dist {
*dist = near.distance_sq.sqrt();
*best = Some(SegmentRef { node: n.id, subpath: si, segment: i });
}
}
}
}
if let Some(ch) = n.children() {
for c in ch.iter().rev() {
visit(c, p, budget, dist, best);
}
}
}
for n in cx.doc.layers.iter().rev() {
visit(n, p, &mut budget, &mut dist, &mut best);
}
best
}
/// The anchor of `edge` within reach of `p`, if any.
fn anchor_near(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<(usize, Point)> {
let sp = cx.doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
let next = if edge.segment + 1 < sp.anchors.len() { edge.segment + 1 } else { 0 };
[edge.segment, next]
.into_iter()
.filter_map(|i| Some((i, sp.anchors.get(i)?.p)))
.filter(|(_, a)| a.distance(p) <= cx.tol(ANCHOR_REACH))
.min_by(|(_, a), (_, b)| a.distance(p).total_cmp(&b.distance(p)))
}
/// The anchor nearest `p` within reach, on any editable path (the topmost on a tie), as a link to
/// its path. Bounded work, and dimension artwork is skipped, as in [`pick`].
fn anchor_under(cx: &ToolContext, p: Point) -> Option<(Point, PointRef)> {
type Best = Option<(f64, Point, PointRef)>;
fn visit(n: &Node, p: Point, reach: f64, budget: &mut usize, best: &mut Best) {
if *budget == 0 || !n.visible || n.locked || n.extras().dimension.is_some() {
return;
}
if let Some(path) = n.path_data() {
for (subpath, sp) in path.subpaths.iter().enumerate() {
for (anchor, a) in sp.anchors.iter().enumerate() {
if *budget == 0 {
return;
}
*budget -= 1;
let d = a.p.distance(p);
if d <= reach && best.as_ref().is_none_or(|(nearest, ..)| d < *nearest) {
*best = Some((d, a.p, PointRef { node: n.id, subpath, anchor }));
}
}
}
}
if let Some(ch) = n.children() {
for c in ch.iter().rev() {
visit(c, p, reach, budget, best);
}
}
}
let (reach, mut budget, mut best) = (cx.tol(ANCHOR_REACH), 20_000usize, None);
for n in cx.doc.layers.iter().rev() {
visit(n, p, reach, &mut budget, &mut best);
}
best.map(|(_, at, r)| (at, r))
}
/// The point of `edge` closest to `p`.
fn nearest_on(cx: &ToolContext, edge: &SegmentRef, p: Point) -> Option<Point> {
use vectorcraft_geom::kurbo::ParamCurveNearest;
let sp = cx.doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
if edge.segment >= sp.segment_count() {
return None;
}
let c = sp.segment(edge.segment);
let t = c.nearest(p, 1e-4).t;
use vectorcraft_geom::kurbo::ParamCurve;
let q = c.eval(t);
(q.x.is_finite() && q.y.is_finite()).then_some(q)
}
impl DimensionTool {
/// Where a Linear pick at `p` lands: on the nearest anchor of any path, within reach (kept as
/// a link to its path); else where Smart Guides put it (another object's anchor or centre, in
/// line with one, or for the second point on a construction guide through the first), its
/// guides showing; else on the closest point of the line under the pointer; else `p` itself.
fn snap_linear(&mut self, cx: &ToolContext, p: Point) -> (Point, Option<PointRef>) {
let leave = self.points.last().map(|(first, _)| crate::guides::Leave::segment(cx, *first, false));
let guided = self.snap.hover(cx, p, &[], leave.as_ref());
if let Some((point, reference)) = anchor_under(cx, p) {
self.snap.clear();
return (point, Some(reference));
}
if guided.distance(p) > 1e-9 {
return (guided, None);
}
// Not on an anchor or a guide: snap to the closest point of the line (#2432).
(pick(cx, p).and_then(|e| nearest_on(cx, &e, p)).unwrap_or(p), None)
}
fn target(&self, cx: &ToolContext, p: Point) -> Option<Target> {
let edge = pick(cx, p)?;
match self.kind {
Kind::Linear => Some(Target::Segment(edge)),
Kind::Angular => {
let (anchor, at) = anchor_near(cx, &edge, p)?;
let (first, second) = dimension::corner(cx.doc, edge.node, edge.subpath, anchor)?;
Some(Target::Corner { first, second, at })
}
Kind::Radial => {
if let Some((center, radius)) = dimension::circle(cx.doc, edge.node) {
return Some(Target::Radial { source: DimensionSource::Circle { node: edge.node }, center, radius });
}
let (center, radius) = dimension::arc(cx.doc, &edge)?;
Some(Target::Radial { source: DimensionSource::Arc { edge }, center, radius })
}
}
}
fn recipe(&self, mode: DimensionMode, a: Point, b: Point, vertex: Point, source: Option<DimensionSource>) -> Dimension {
Dimension {
mode,
a,
b,
vertex,
offset: 0.0,
length: a.distance(b),
options: self.style.clone(),
source,
orientation: Orientation::Aligned,
moved: None,
}
}
/// The dimension a target gives when dragged from.
fn dimension_for(&self, target: &Target, p: Point) -> Option<Dimension> {
let mut d = match target {
Target::Segment(edge) => self.recipe(
DimensionMode::Linear,
Point::ZERO,
Point::new(1.0, 0.0),
Point::ZERO,
Some(DimensionSource::Segment { edge: edge.clone() }),
),
Target::Corner { first, second, .. } => self.recipe(
DimensionMode::Angular,
Point::ZERO,
Point::new(1.0, 0.0),
Point::ZERO,
Some(DimensionSource::Angle { first: first.clone(), second: second.clone() }),
),
Target::Radial { source, center, radius } => {
// Dragged outwards: the radius; inwards: the diameter.
let mode = if p.distance(*center) >= *radius { DimensionMode::Radius } else { DimensionMode::Diameter };
self.recipe(mode, *center, p, Point::ZERO, Some(source.clone()))
}
};
d.offset = 30.0;
Some(d)
}
fn params(d: &Dimension) -> Value {
let mut p = json!({"mode":d.mode,"a":[d.a.x,d.a.y],"b":[d.b.x,d.b.y],"vertex":[d.vertex.x,d.vertex.y],"offset":d.offset,"options":d.options});
if let Some(source) = &d.source {
p["source"] = json!(source);
}
if d.orientation != Orientation::Aligned {
p["orientation"] = json!(d.orientation);
}
p
}
fn preview(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
let Some(d) = &mut self.pending else { return vec![] };
if matches!(d.mode, DimensionMode::Radius | DimensionMode::Diameter)
&& let Some(Target::Radial { center, radius, .. }) = self.press.as_ref().and_then(|(_, t)| t.as_ref())
{
d.mode = if p.distance(*center) >= *radius { DimensionMode::Radius } else { DimensionMode::Diameter };
}
if d.mode == DimensionMode::Linear {
// The points the dimension measures: a dragged segment's ends, read from the art.
let ends = match &d.source {
Some(DimensionSource::Segment { edge }) => edge.endpoints(cx.doc),
_ => Some((d.a, d.b)),
};
let again = self.second.is_some_and(|(at, pointer)| at.distance(p).min(pointer.distance(p)) <= cx.tol(ANCHOR_REACH));
d.orientation = match ends {
Some((a, b)) if !again => orientation_at(a, b, p),
_ => Orientation::Aligned,
};
}
d.place(p);
if d.resolve(cx.doc).is_err() {
return vec![];
}
let mut actions = vec![];
if !self.began {
actions.push(Action::Begin("Create Dimension".into()));
self.began = true;
}
actions.push(Action::Preview("dimension.create".into(), Self::params(d)));
actions
}
/// Places the pending dimension at `p` and commits it.
fn place(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
let mut out = self.preview(cx, p);
if self.began {
out.push(Action::Commit);
}
self.reset();
out
}
fn reset(&mut self) {
self.pending = None;
self.press = None;
self.press_on_anchor = false;
self.points.clear();
self.lines.clear();
self.dragging = false;
self.began = false;
self.snap.clear();
self.snapped = None;
self.second = None;
}
/// A plain click: picks a point or a line, or measures a circle's radius.
fn click(&mut self, cx: &ToolContext, p: Point, target: Option<Target>, mods: Mods) -> Vec<Action> {
match self.kind {
Kind::Linear => {
let (mut at, mut reference) = self.snap_linear(cx, p);
if mods.shift
&& let Some((last, _)) = self.points.last()
{
at = constrained(cx, *last, at);
reference = reference.filter(|r| r.position(cx.doc) == Some(at));
}
self.points.push((at, reference));
if let [(a, ra), (b, rb)] = self.points.as_slice() {
let source = (ra.is_some() || rb.is_some()).then(|| DimensionSource::Points { a: ra.clone(), b: rb.clone(), vertex: None });
let mut d = self.recipe(DimensionMode::Linear, *a, *b, Point::ZERO, source);
if d.resolve(cx.doc).is_ok() {
self.second = Some((*b, p));
self.pending = Some(d);
}
self.points.clear();
}
vec![]
}
Kind::Angular => {
let Some(edge) = pick(cx, p).filter(|e| e.is_straight(cx.doc)) else { return vec![] };
if self.lines.first().is_some_and(|first| *first == edge) {
return vec![];
}
self.lines.push(edge);
if let [first, second] = self.lines.as_slice() {
let mut d = self.recipe(
DimensionMode::Angular,
Point::ZERO,
Point::new(1.0, 0.0),
Point::ZERO,
Some(DimensionSource::Angle { first: first.clone(), second: second.clone() }),
);
if d.resolve(cx.doc).is_ok() {
d.offset = 30.0;
self.pending = Some(d);
}
self.lines.clear();
}
vec![]
}
Kind::Radial => {
let Some(target @ Target::Radial { .. }) = target else { return vec![] };
let Some(mut d) = self.dimension_for(&target, p) else { return vec![] };
d.mode = DimensionMode::Radius;
self.pending = Some(d);
self.press = Some((p, Some(target)));
self.place(cx, p)
}
}
}
}
impl Tool for DimensionTool {
fn id(&self) -> &'static str {
"dimension"
}
fn busy(&self) -> bool {
self.pending.is_some() || !self.points.is_empty() || !self.lines.is_empty()
}
fn cursor(&self, _: &ToolContext, _: Point, _: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
json!({"mode":self.kind.id(),"style":self.style})
}
fn set_option(&mut self, key: &str, value: &Value) {
if key == "mode"
&& let Some(kind) = value.as_str().and_then(Kind::parse)
{
self.kind = kind;
}
if key == "style"
&& let Ok(o) = serde_json::from_value::<DimensionOptions>(value.clone())
&& o.validate().is_ok()
{
self.style = o;
}
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
self.at = ev.pos;
self.shift = ev.mods.shift;
match ev.kind {
PointerKind::Move => {
if self.pending.is_some() {
// Placing: the picks are done, their guides go.
self.snap.clear();
self.snapped = None;
return self.preview(cx, ev.pos);
}
self.hover = self.target(cx, ev.pos);
self.snapped = (self.kind == Kind::Linear && self.lines.is_empty()).then(|| self.snap_linear(cx, ev.pos));
if self.snapped.is_none() {
self.snap.clear();
}
vec![]
}
PointerKind::Down => {
if self.pending.is_some() && !self.dragging {
self.press = Some((ev.pos, None));
return self.preview(cx, ev.pos);
}
// A Linear press on an anchor picks the anchor unless it drags past the anchor's
// reach: moving a few pixels before the release does not drag out the dimension of
// a segment under the pointer.
self.press_on_anchor = self.kind == Kind::Linear && anchor_under(cx, ev.pos).is_some();
self.press = Some((ev.pos, self.target(cx, ev.pos)));
vec![]
}
PointerKind::Drag => {
if self.pending.is_some() && !self.dragging && self.press.is_some() {
return self.preview(cx, ev.pos);
}
if self.pending.is_none()
&& let Some((start, Some(target))) = self.press.clone()
&& start.distance(ev.pos) > cx.tol(if self.press_on_anchor { ANCHOR_REACH } else { 3.0 })
&& self.points.is_empty()
&& self.lines.is_empty()
{
self.pending = self.dimension_for(&target, ev.pos);
self.dragging = self.pending.is_some();
}
if self.dragging { self.preview(cx, ev.pos) } else { vec![] }
}
PointerKind::Up => {
if self.dragging {
return self.place(cx, ev.pos);
}
if self.pending.is_some() {
return if self.press.take().is_some() { self.place(cx, ev.pos) } else { vec![] };
}
match self.press.take() {
Some((start, target)) => self.click(cx, start, target, ev.mods),
_ => vec![],
}
}
_ => vec![],
}
}
fn key(&mut self, _: &ToolContext, key: ToolKey, _: Mods) -> Vec<Action> {
if key == ToolKey::Escape {
let cancel = self.began;
self.reset();
return if cancel { vec![Action::Cancel] } else { vec![] };
}
vec![]
}
/// A dropped drag is canceled as by Escape. A dropped click picks nothing, and the points or
/// lines earlier clicks picked stay.
fn drop_press(&mut self, cx: &ToolContext) -> Vec<Action> {
if self.dragging {
return self.key(cx, ToolKey::Escape, Mods::default());
}
self.press = None;
if self.pending.is_some() && self.began {
self.began = false;
vec![Action::Cancel]
} else {
vec![]
}
}
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
self.key(cx, ToolKey::Escape, Mods::default())
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let color = [60, 150, 255];
let mut out = vec![];
let segment = |edge: &SegmentRef, out: &mut Vec<Overlay>| {
if let Some(sp) = cx.doc.node(edge.node).and_then(Node::path_data).and_then(|p| p.subpaths.get(edge.subpath))
&& edge.segment < sp.segment_count()
{
let c = sp.segment(edge.segment);
let mut path = BezPath::new();
path.move_to(c.p0);
path.curve_to(c.p1, c.p2, c.p3);
out.push(Overlay::Path { path, color, width: 3.0, dashed: false });
}
};
if self.pending.is_none() {
match &self.hover {
Some(Target::Segment(edge)) => {
segment(edge, &mut out);
if let Some((a, b)) = edge.endpoints(cx.doc) {
for p in [a, b] {
out.push(Overlay::Anchor { p, color, filled: true, size: 6.0 });
}
if cx.measurement_labels
&& let Ok(text) = self.style.label(a.distance(b), false)
{
out.push(Overlay::Measure { p: self.at + Vec2::new(12.0, 12.0), text });
}
}
}
Some(Target::Corner { first, second, at }) => {
segment(first, &mut out);
segment(second, &mut out);
out.push(Overlay::Anchor { p: *at, color, filled: true, size: 7.0 });
}
Some(Target::Radial { center, radius, .. }) => {
out.push(Overlay::Anchor { p: *center, color, filled: false, size: 6.0 });
let toward = self.at - *center;
if toward.hypot() > 1e-9 {
out.push(Overlay::Line { a: *center, b: *center + toward / toward.hypot() * *radius, color, dashed: true });
}
}
None => {}
}
}
for (p, _) in &self.points {
out.push(Overlay::Anchor { p: *p, color, filled: true, size: 6.0 });
}
for edge in &self.lines {
segment(edge, &mut out);
}
// Linear: show where the next click lands (anchor ring, or a dot on a line), and draw the
// rubber band to that point, so the user sees the snap before clicking (#2516).
let linear_pick = self.kind == Kind::Linear && self.pending.is_none() && self.lines.is_empty();
let snapped = self.snapped.clone().filter(|_| linear_pick);
if let Some((q, reference)) = &snapped
&& (reference.is_some() || q.distance(self.at) > 1e-9)
{
let on_anchor = reference.is_some();
out.push(Overlay::Anchor { p: *q, color, filled: !on_anchor, size: if on_anchor { 10.0 } else { 6.0 } });
}
if let Some((p, _)) = self.points.last() {
let target = snapped.map_or(self.at, |(q, _)| q);
let b = if self.shift { constrained(cx, *p, target) } else { target };
out.push(Overlay::Line { a: *p, b, color, dashed: true });
}
// The Smart Guides of the point the next click picks (after the rubber band).
if linear_pick {
out.extend(self.snap.guides().iter().cloned());
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use vectorcraft_doc::Selection;
/// A dropped click picks nothing and keeps the point an earlier click picked. A dropped drag
/// out from a segment is canceled as by Escape.
#[test]
fn a_dropped_press_keeps_the_earlier_picks() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y);
let mut t = DimensionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 400.0, 400.0));
t.pointer(&c, &ev(PointerKind::Up, 400.0, 400.0));
assert_eq!(t.points.len(), 1);
// The engine sends the release after the drop.
t.pointer(&c, &ev(PointerKind::Down, 450.0, 420.0));
assert!(t.drop_press(&c).is_empty());
assert!(t.pointer(&c, &ev(PointerKind::Up, 450.0, 420.0)).is_empty());
assert_eq!(t.points.len(), 1, "the earlier pick stays and the dropped click picks nothing");
assert!(t.pending.is_none() && t.busy());
let mut t = DimensionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 150.0, 100.0));
let acts = t.pointer(&c, &ev(PointerKind::Drag, 150.0, 60.0));
assert!(matches!(acts.first(), Some(Action::Begin(_))), "{acts:?}");
assert_eq!(t.drop_press(&c), vec![Action::Cancel]);
assert!(!t.busy());
assert!(t.pointer(&c, &ev(PointerKind::Up, 150.0, 60.0)).is_empty(), "the release places nothing");
}
#[test]
fn a_dropped_placement_press_does_not_commit_the_dimension() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y);
let mut t = DimensionTool::default();
for (x, y) in [(400.0, 400.0), (450.0, 420.0)] {
assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty());
assert!(t.pointer(&c, &ev(PointerKind::Up, x, y)).is_empty());
}
assert!(t.pending.is_some());
assert!(matches!(t.pointer(&c, &ev(PointerKind::Move, 470.0, 450.0)).first(), Some(Action::Begin(_))));
assert!(matches!(t.pointer(&c, &ev(PointerKind::Down, 470.0, 450.0)).last(), Some(Action::Preview(_, _))));
assert_eq!(t.drop_press(&c), vec![Action::Cancel]);
assert!(t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)).is_empty());
assert!(t.pending.is_some(), "the dimension can still be placed");
assert!(matches!(t.pointer(&c, &ev(PointerKind::Down, 480.0, 460.0)).last(), Some(Action::Preview(_, _))));
let dragged = t.pointer(&c, &ev(PointerKind::Drag, 490.0, 470.0));
assert!(matches!(dragged.last(), Some(Action::Preview(_, _))), "{dragged:?}");
let placed = t.pointer(&c, &ev(PointerKind::Up, 490.0, 470.0));
assert!(matches!(placed.last(), Some(Action::Commit)), "{placed:?}");
}
/// A Linear press on an anchor picks that anchor even when the pointer moves a few pixels
/// before the release: it does not drag out the dimension of a segment that ends there. Both
/// picks keep their links to the path, so the dimension measures between the two anchors.
#[test]
fn a_press_on_an_anchor_picks_it_when_the_pointer_moves() {
let (d, id) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let ev = |kind, x: f64, y: f64| PointerEvent::new(kind, x, y);
let mut t = DimensionTool::default();
for ((x, y), (dx, dy)) in [((100.0, 100.0), (3.5, 1.0)), ((200.0, 200.0), (-4.0, -2.0))] {
assert!(t.pointer(&c, &ev(PointerKind::Down, x, y)).is_empty());
assert!(t.pointer(&c, &ev(PointerKind::Drag, x + dx, y + dy)).is_empty(), "no segment dimension is dragged out");
assert!(t.pointer(&c, &ev(PointerKind::Up, x + dx, y + dy)).is_empty(), "nothing is committed");
}
let d = t.pending.as_ref().expect("both anchors were picked");
assert_eq!((d.a, d.b), (Point::new(100.0, 100.0), Point::new(200.0, 200.0)));
let anchor = |anchor| Some(PointRef { node: id, subpath: 0, anchor });
assert_eq!(d.source, Some(DimensionSource::Points { a: anchor(0), b: anchor(2), vertex: None }));
// Dragged past the anchor's reach, the press drags out the dimension of a segment there.
let mut t = DimensionTool::default();
t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0));
assert!(t.pointer(&c, &ev(PointerKind::Drag, 103.0, 106.0)).is_empty());
let acts = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 60.0));
assert!(matches!(acts.first(), Some(Action::Begin(_))), "{acts:?}");
assert!(matches!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 60.0)).last(), Some(Action::Commit)));
}
/// The anchors of a live rectangle are picked the same way.
#[test]
fn a_press_on_a_live_shapes_anchor_picks_it() {
let (d, id) = doc_with_live_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = DimensionTool::default();
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 100.0));
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 197.0, 102.0));
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 197.0, 102.0));
let [(at, Some(r))] = t.points.as_slice() else { panic!("{:?}", t.points) };
assert_eq!((*at, r.node), (Point::new(200.0, 100.0), id));
}
/// An anchor of a compound path inside nested groups, lying just inside another object's
/// edge, is picked and linked, though that edge is nearer the pointer than any of the
/// compound path's own segments. A press there that moves a few pixels picks it too.
#[test]
fn an_anchor_under_another_objects_edge_is_picked() {
use std::sync::Arc;
use vectorcraft_doc::{Appearance, NodeKind};
use vectorcraft_geom::Rect;
let (mut d, _) = doc_with_rect();
let l = d.layers[0].id;
let (outer, inner, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let mut path = vectorcraft_geom::shapes::rectangle(Rect::new(20.0, 20.0, 40.0, 40.0));
path.subpaths.extend(vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 103.0, 180.0, 130.0)).subpaths);
let art = Node::path(compound, path, Appearance::default_art());
let inner_group = Node::new(inner, NodeKind::Group { children: vec![Arc::new(art)], clip: false });
let group = Node::new(outer, NodeKind::Group { children: vec![Arc::new(inner_group)], clip: false });
d.insert(Some(l), 0, group).unwrap();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let corner = (Point::new(150.0, 103.0), Some(PointRef { node: compound, subpath: 1, anchor: 0 }));
let mut t = DimensionTool::default();
assert_eq!(t.snap_linear(&c, Point::new(151.0, 101.0)), corner);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 151.0, 101.0));
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 155.0, 100.0)).is_empty(), "no segment dimension is dragged out");
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 155.0, 100.0));
assert_eq!(t.points, vec![corner]);
}
/// Beside the two points the dimension is aligned; past either point it is horizontal when
/// the pointer's quarter holds the straight up or down direction and vertical when it holds
/// the straight left or right direction. Points on one horizontal or vertical line, a pointer
/// on the line through the points, and two equal points give aligned.
#[test]
fn the_pointer_chooses_aligned_horizontal_or_vertical() {
use Orientation::{Aligned, Horizontal, Vertical};
let at = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| orientation_at(Point::new(a.0, a.1), Point::new(b.0, b.1), Point::new(p.0, p.1));
// A line rising to the right (y grows downwards), and the same points in the other order.
for (a, b) in [((100.0, 300.0), (200.0, 200.0)), ((200.0, 200.0), (100.0, 300.0))] {
for (p, want) in [
((120.0, 160.0), Aligned),
((200.0, 300.0), Aligned),
((150.0, 250.0), Aligned),
((200.0, 200.0), Aligned),
((100.0, 300.0), Aligned),
((210.0, 120.0), Horizontal),
((240.0, 150.0), Horizontal),
((280.0, 220.0), Vertical),
((240.0, 170.0), Vertical),
((40.0, 310.0), Vertical),
((90.0, 380.0), Horizontal),
((250.0, 150.0), Aligned),
] {
assert_eq!(at(a, b, p), want, "{a:?} to {b:?}, pointer at {p:?}");
}
}
// A line falling to the right.
let (a, b) = ((100.0, 100.0), (300.0, 200.0));
for (p, want) in
[((200.0, 50.0), Aligned), ((320.0, 300.0), Horizontal), ((400.0, 210.0), Vertical), ((60.0, 20.0), Horizontal), ((20.0, 90.0), Vertical)]
{
assert_eq!(at(a, b, p), want, "pointer at {p:?}");
}
for (a, b) in [((100.0, 100.0), (300.0, 100.0)), ((100.0, 100.0), (100.0, 300.0)), ((100.0, 100.0), (100.0, 100.0))] {
for p in [(50.0, 50.0), (350.0, 50.0), (350.0, 350.0), (50.0, 350.0), (200.0, 60.0), (60.0, 200.0), (400.0, 100.0), (100.0, 400.0)] {
assert_eq!(at(a, b, p), Aligned, "{a:?} to {b:?}, pointer at {p:?}");
}
}
}
#[test]
fn placement_press_previews_without_a_prior_move() {
let (d, _) = doc_with_rect();
let (s, p) = (Selection::default(), paint());
let c = cx(&d, &s, &p);
let mut t = DimensionTool::default();
for (x, y) in [(400.0, 400.0), (450.0, 420.0)] {
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)).is_empty());
assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, x, y)).is_empty());
}
assert!(t.pending.is_some());
let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 470.0, 450.0));
assert!(matches!(acts.first(), Some(Action::Begin(_))), "{acts:?}");
assert!(matches!(acts.last(), Some(Action::Preview(_, _))), "{acts:?}");
}
}