Files
vectorcraft/crates/tools/src/dimension.rs
T
dotCooCoo 59791c8c0f Dimension tool: a click on an anchor of any path takes that anchor
With the Linear mode, two problems kept a click on an anchor from
taking it:

- A press on an anchor that moved more than 3 pixels before the release
  dragged out the dimension of the segment under the pointer and
  committed it almost on top of the art. A press on an anchor now picks
  it unless the pointer moves past the anchor's 8-pixel reach; a longer
  drag still dimensions the segment.
- A pick linked only an anchor of the segment nearest the pointer. An
  anchor of another path near that segment, for example one inside a
  group lying just inside another object's edge, was taken at most as a
  Smart Guides point with no link, so the dimension did not follow it
  when the art changed. A pick now links the nearest anchor of any
  visible, unlocked path within reach, in groups and compound paths too.
2026-11-09 23:38:23 -08:00

674 lines
30 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, 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,
}
/// 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, 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);
}
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 };
}
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;
}
/// 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.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]);
}
#[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:?}");
}
}