mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
Found while testing crates/tools/src/xform/measure.rs:20 (shown at :66, reported at :80) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
132 lines
4.8 KiB
Rust
132 lines
4.8 KiB
Rust
//! Measure tool: drag between two points to read distance, angle, dX and dY (Shift constrains to
|
|
//! 45°). The result stays on screen (and in the tool options, for agents) until the next drag; the
|
|
//! document is never changed.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_geom::{Point, Vec2};
|
|
|
|
use super::CYAN;
|
|
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
|
|
|
#[derive(Default)]
|
|
pub struct MeasureTool {
|
|
line: Option<(Point, Point)>,
|
|
dragging: bool,
|
|
}
|
|
|
|
/// (distance, angle in degrees counter-clockwise, dx, dy)
|
|
pub fn measure(a: Point, b: Point) -> (f64, f64, f64, f64) {
|
|
let d = b - a;
|
|
(d.hypot(), (-d.y).atan2(d.x).to_degrees() + 0.0, d.x, d.y)
|
|
}
|
|
|
|
impl Tool for MeasureTool {
|
|
fn id(&self) -> &'static str {
|
|
"measure"
|
|
}
|
|
|
|
fn busy(&self) -> bool {
|
|
self.dragging
|
|
}
|
|
|
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
|
match ev.kind {
|
|
PointerKind::Down => {
|
|
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
|
|
self.line = Some((p, p));
|
|
self.dragging = true;
|
|
}
|
|
PointerKind::Drag | PointerKind::Up if self.dragging => {
|
|
if let Some((a, _)) = self.line {
|
|
let b = if ev.mods.shift {
|
|
a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0)
|
|
} else {
|
|
crate::guides::snap_draw(cx, ev.pos, &[]).0
|
|
};
|
|
self.line = Some((a, b));
|
|
}
|
|
self.dragging = ev.kind == PointerKind::Drag;
|
|
}
|
|
_ => {}
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
|
let Some((a, b)) = self.line else { return vec![] };
|
|
if a == b {
|
|
return vec![];
|
|
}
|
|
let mut out = vec![Overlay::Line { a, b, color: CYAN, dashed: false }];
|
|
// Measurement Labels decides the readout; the line the tool draws stays.
|
|
if cx.measurement_labels {
|
|
let (dist, ang, dx, dy) = measure(a, b);
|
|
out.push(Overlay::Measure {
|
|
p: b + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
|
|
text: format!("D: {}\nAngle: {ang:.1}°\n{}", cx.len(dist), cx.offset_label(dx, dy)),
|
|
});
|
|
}
|
|
out
|
|
}
|
|
|
|
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
|
Cursor::Measure
|
|
}
|
|
|
|
fn options(&self) -> Value {
|
|
match self.line {
|
|
Some((a, b)) => {
|
|
let (d, ang, dx, dy) = measure(a, b);
|
|
json!({ "x": a.x, "y": a.y, "distance": d, "angle": ang, "dx": dx, "dy": dy })
|
|
}
|
|
None => Value::Null,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testutil::*;
|
|
use vectorcraft_doc::Selection;
|
|
|
|
#[test]
|
|
fn horizontal_drag_reports_positive_zero_angle() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let mut cx = cx(&d, &s, &p);
|
|
cx.smart_guides = false;
|
|
let mut t = MeasureTool::default();
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, 10.0));
|
|
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, 10.0));
|
|
let ang = t.options()["angle"].as_f64().unwrap();
|
|
assert!(ang == 0.0 && ang.is_sign_positive(), "angle was {ang:?}");
|
|
let ov = t.overlays(&cx);
|
|
assert!(matches!(&ov[1], Overlay::Measure { text, .. } if text.contains("Angle: 0.0°")));
|
|
}
|
|
|
|
#[test]
|
|
fn drag_reports_distance_and_angle() {
|
|
let (d, _) = doc_with_rect();
|
|
let s = Selection::default();
|
|
let p = paint();
|
|
let mut cx = cx(&d, &s, &p);
|
|
cx.smart_guides = false;
|
|
let mut t = MeasureTool::default();
|
|
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)).is_empty());
|
|
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -30.0)).is_empty());
|
|
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, -30.0)).is_empty());
|
|
let o = t.options();
|
|
assert!((o["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9);
|
|
assert!((o["angle"].as_f64().unwrap() - 53.130102).abs() < 1e-4);
|
|
let ov = t.overlays(&cx);
|
|
assert!(matches!(&ov[1], Overlay::Measure { text, .. } if text.starts_with("D: 50.00 pt")));
|
|
let quiet = ToolContext { measurement_labels: false, ..cx };
|
|
let ov = t.overlays(&quiet);
|
|
assert!(!ov.iter().any(|o| matches!(o, Overlay::Measure { .. })), "Measurement Labels off hides the readout");
|
|
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { .. })), "the measured line stays");
|
|
}
|
|
}
|