mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
# Conflicts: # ROADMAP.md
This commit is contained in:
+6
-6
File diff suppressed because one or more lines are too long
@@ -634,7 +634,7 @@ impl Default for Prefs {
|
||||
object_highlighting: true,
|
||||
transform_tools_guides: true,
|
||||
construction_guides: true,
|
||||
construction_angles: s("90° & 45° Angles"),
|
||||
construction_angles: s(vectorcraft_tools::guides::DEFAULT_CONSTRUCTION_ANGLES),
|
||||
anchor_path_labels: true,
|
||||
measurement_labels: true,
|
||||
spacing_guides: true,
|
||||
@@ -1488,6 +1488,8 @@ mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_smartguides;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegradient;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Smart Guides for the drawing tools, driven through the session (#506): where the Rectangle
|
||||
//! tool's corners and the Pen's anchors land, and the guides they show.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_tools::{Mods, Overlay, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
/// An 800 × 600 document with a 100 × 100 square at (100, 100), nothing selected.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap();
|
||||
}
|
||||
|
||||
fn labels(s: &mut Session) -> Vec<(String, Point)> {
|
||||
s.overlays(ViewInfo::default()).into_iter().filter_map(|o| if let Overlay::Label { text, p, .. } = o { Some((text, p)) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The bounds of the object the last gesture made (it is selected).
|
||||
fn made(s: &Session) -> Rect {
|
||||
let d = s.doc().unwrap();
|
||||
let id: NodeId = d.selection.objects[0];
|
||||
d.doc.node(id).unwrap().geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_corner_lands_on_another_objects_anchor() {
|
||||
let mut s = session();
|
||||
s.select_tool("rectangle", ViewInfo::default()).unwrap();
|
||||
// Hovering near the square's corner already says where the press goes.
|
||||
pointer(&mut s, PointerKind::Move, 302.0, 297.0);
|
||||
pointer(&mut s, PointerKind::Move, 202.0, 203.0);
|
||||
assert_eq!(labels(&mut s), [("anchor".to_string(), Point::new(200.0, 200.0))]);
|
||||
pointer(&mut s, PointerKind::Down, 302.0, 297.0);
|
||||
pointer(&mut s, PointerKind::Drag, 250.0, 250.0);
|
||||
pointer(&mut s, PointerKind::Drag, 202.0, 203.0);
|
||||
assert_eq!(labels(&mut s), [("anchor".to_string(), Point::new(200.0, 200.0))]);
|
||||
let measure = s.overlays(ViewInfo::default()).into_iter().find_map(|o| if let Overlay::Measure { text, .. } = o { Some(text) } else { None });
|
||||
assert!(measure.as_deref().is_some_and(|t| t.starts_with("W: 102") && t.contains("H: 100")), "{measure:?}");
|
||||
pointer(&mut s, PointerKind::Up, 202.0, 203.0);
|
||||
// The start lined up with the artboard's centre (y = 300), the corner on the anchor.
|
||||
assert_eq!(made(&s), Rect::new(200.0, 200.0, 302.0, 300.0));
|
||||
}
|
||||
|
||||
/// The corner being dragged never snaps to the rectangle's own preview: dragged slowly, it follows
|
||||
/// the pointer instead of sticking where the last step left it.
|
||||
#[test]
|
||||
fn a_rectangle_does_not_snap_to_itself_while_drawn() {
|
||||
let mut s = session();
|
||||
s.select_tool("rectangle", ViewInfo::default()).unwrap();
|
||||
pointer(&mut s, PointerKind::Down, 320.0, 420.0);
|
||||
for x in 370..=380 {
|
||||
pointer(&mut s, PointerKind::Drag, f64::from(x), 470.0 + f64::from(x - 370));
|
||||
}
|
||||
pointer(&mut s, PointerKind::Up, 380.0, 480.0);
|
||||
assert_eq!(made(&s), Rect::new(320.0, 420.0, 380.0, 480.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pen_anchor_lines_up_with_another_objects_centre() {
|
||||
let mut s = session();
|
||||
s.select_tool("pen", ViewInfo::default()).unwrap();
|
||||
pointer(&mut s, PointerKind::Down, 300.0, 420.0);
|
||||
pointer(&mut s, PointerKind::Up, 300.0, 420.0);
|
||||
// Hovering: in line with the square's centre (150, 150), the guide running from it.
|
||||
pointer(&mut s, PointerKind::Move, 151.5, 431.0);
|
||||
let ov = s.overlays(ViewInfo::default());
|
||||
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if *a == Point::new(150.0, 150.0) && *b == Point::new(150.0, 431.0))), "{ov:?}");
|
||||
assert_eq!(labels(&mut s), [("align".to_string(), Point::new(150.0, 431.0))]);
|
||||
pointer(&mut s, PointerKind::Down, 151.5, 431.0);
|
||||
pointer(&mut s, PointerKind::Up, 151.5, 431.0);
|
||||
// Shift: along the 45° step from it, slid into line with the square's left side (x = 100).
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 103.0, 386.0).with_mods(Mods { shift: true, ..Mods::default() }), ViewInfo::default()).unwrap();
|
||||
pointer(&mut s, PointerKind::Up, 103.0, 386.0);
|
||||
let d = s.doc().unwrap();
|
||||
let path = d.doc.node(d.selection.objects[0]).unwrap().path_data().unwrap();
|
||||
let pts: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| a.p).collect();
|
||||
assert_eq!(pts[..2], [Point::new(300.0, 420.0), Point::new(150.0, 431.0)]);
|
||||
assert!((pts[2] - Point::new(100.0, 381.0)).hypot() < 1e-9, "{pts:?}");
|
||||
}
|
||||
|
||||
/// Preferences › Smart Guides › Construction Guides: on (the default), a Pen anchor near the
|
||||
/// 45° guide through the last one lands on it; off, it stays where it was clicked.
|
||||
#[test]
|
||||
fn construction_guides_follow_the_preference() {
|
||||
for on in [true, false] {
|
||||
let mut s = session();
|
||||
s.execute("prefs.set", &json!({"key": "constructionGuides", "value": on})).unwrap();
|
||||
s.select_tool("pen", ViewInfo::default()).unwrap();
|
||||
pointer(&mut s, PointerKind::Down, 500.0, 500.0);
|
||||
pointer(&mut s, PointerKind::Up, 500.0, 500.0);
|
||||
pointer(&mut s, PointerKind::Down, 561.0, 438.0);
|
||||
pointer(&mut s, PointerKind::Up, 561.0, 438.0);
|
||||
let d = s.doc().unwrap();
|
||||
let last = d.doc.node(d.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[1].p;
|
||||
let want = if on { Point::new(561.5, 438.5) } else { Point::new(561.0, 438.0) };
|
||||
assert!((last - want).hypot() < 1e-9, "{on}: {last:?}");
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,7 @@ impl Session {
|
||||
let (unit, stroke_unit) = (self.general_unit(), self.stroke_unit());
|
||||
let cx = ToolContext {
|
||||
doc: &st.doc,
|
||||
revision: (st.uid, st.revision),
|
||||
selection: &st.selection,
|
||||
zoom: view.zoom,
|
||||
isolation: st.isolation,
|
||||
@@ -222,6 +223,11 @@ impl Session {
|
||||
measurement_labels: self.prefs.measurement_labels,
|
||||
transform_tools_guides: self.prefs.transform_tools_guides,
|
||||
snapping_tolerance: self.prefs.snapping_tolerance,
|
||||
construction_angles: if self.prefs.construction_guides {
|
||||
vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles)
|
||||
} else {
|
||||
&[]
|
||||
},
|
||||
screen: view.screen,
|
||||
plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position),
|
||||
};
|
||||
|
||||
@@ -588,3 +588,30 @@ fn type_preferences_apply_to_agents() {
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
assert_eq!(style(&mut s, 6), (size + 4.0, tracking + 50.0));
|
||||
}
|
||||
|
||||
/// The drawing tools snap to Smart Guides over MCP as with the mouse (#506): a Rectangle drawn
|
||||
/// from near another object's corner starts on it, and a Pen anchor placed beside that object's
|
||||
/// centre lines up with it.
|
||||
#[test]
|
||||
fn drawing_gestures_snap_to_smart_guides() {
|
||||
let mut s = server();
|
||||
let r = call(&mut s, 1, "run_command", json!({"command": "file.new", "params": {"width": 800, "height": 600}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 100, "height": 100}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let bounds = |s: &mut Server, n: u64, events: Value, tool: &str| {
|
||||
let r = call(s, n, "pointer_gesture", json!({"tool": tool, "events": events}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].clone();
|
||||
let doc: Value = serde_json::from_str(&text_of(&call(s, n + 1, "inspect_document", json!({})))).unwrap();
|
||||
let made = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).expect("drawn").clone();
|
||||
let b = &made["bounds"];
|
||||
[&b["x"], &b["y"], &b["width"], &b["height"]].map(|v| v.as_f64().unwrap())
|
||||
};
|
||||
let events = json!([{"kind": "move", "x": 202, "y": 203}, {"kind": "down", "x": 202, "y": 203}, {"kind": "drag", "x": 330, "y": 341}, {"kind": "up", "x": 330, "y": 341}]);
|
||||
assert_eq!(bounds(&mut s, 3, events, "rectangle"), [200.0, 200.0, 130.0, 141.0]);
|
||||
let r = call(&mut s, 5, "run_command", json!({"command": "select.none", "params": {}}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let events = json!([{"kind": "down", "x": 300, "y": 420}, {"kind": "up", "x": 300, "y": 420}, {"kind": "move", "x": 151.5, "y": 431}, {"kind": "down", "x": 151.5, "y": 431}, {"kind": "up", "x": 151.5, "y": 431}]);
|
||||
assert_eq!(bounds(&mut s, 6, events, "pen"), [150.0, 420.0, 150.0, 11.0]);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
//! Each click adds a point and the path curves smoothly through all points. Alt-click or
|
||||
//! double-click a point toggles it between smooth and corner; drag a point to move it; click the
|
||||
//! first point to close; Backspace/Delete removes the last touched point; Esc/Enter ends the path.
|
||||
//! A rubber band shows the curve to the cursor (Enable Rubber Band for Curvature Tool).
|
||||
//! A rubber band shows the curve to the cursor (Enable Rubber Band for Curvature Tool). Each point
|
||||
//! placed or dragged snaps to Smart Guides ([`DrawSnap`]), as the Pen's anchors do.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_doc::{NodeId, Selection};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::{FEEDBACK, catmull_rom};
|
||||
use crate::guides::{DrawSnap, Leave, Targets};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -22,6 +24,7 @@ pub struct CurvatureTool {
|
||||
/// Last touched point (Backspace removes it).
|
||||
current: Option<usize>,
|
||||
hover: Option<Point>,
|
||||
snap: DrawSnap,
|
||||
}
|
||||
|
||||
impl CurvatureTool {
|
||||
@@ -55,6 +58,12 @@ impl CurvatureTool {
|
||||
self.id = None;
|
||||
self.drag = None;
|
||||
self.current = None;
|
||||
self.snap.clear();
|
||||
}
|
||||
|
||||
/// The segment to the next point leaves the last one.
|
||||
fn leave(&self, cx: &ToolContext) -> Option<Leave> {
|
||||
self.pts.last().map(|(p, _)| Leave::segment(cx, *p, false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,11 +82,12 @@ impl Tool for CurvatureTool {
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.hover = Some(p);
|
||||
let from = active.and_then(|_| self.leave(cx));
|
||||
self.hover = Some(self.snap.hover(cx, p, active.as_slice(), from.as_ref()));
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
if active.is_some() {
|
||||
if let Some(id) = active {
|
||||
if let Some(i) = self.hit_point(cx, p) {
|
||||
self.current = Some(i);
|
||||
if ev.mods.alt {
|
||||
@@ -88,10 +98,15 @@ impl Tool for CurvatureTool {
|
||||
self.closed = true;
|
||||
return vec![Action::Exec("path.curvature".into(), self.params())];
|
||||
}
|
||||
// The point dragged and the curve through it move: the rest pull.
|
||||
let sel = Selection { objects: vec![id], anchors: [(id, [(0, i)].into())].into(), ..Selection::default() };
|
||||
self.snap.hold(cx, || Targets::for_anchor_drag(cx.doc, &sel));
|
||||
self.drag = Some((i, p, false));
|
||||
return vec![];
|
||||
}
|
||||
if !self.closed {
|
||||
let from = self.leave(cx);
|
||||
let p = self.snap.press(cx, p, &[id], from.as_ref());
|
||||
self.pts.push((p, ev.mods.alt));
|
||||
self.current = Some(self.pts.len() - 1);
|
||||
return vec![Action::Exec("path.curvature".into(), self.params())];
|
||||
@@ -99,6 +114,7 @@ impl Tool for CurvatureTool {
|
||||
}
|
||||
// Start a new path.
|
||||
self.reset();
|
||||
let p = self.snap.press(cx, p, &[], None);
|
||||
self.pts.push((p, false));
|
||||
self.current = Some(0);
|
||||
vec![Action::Exec("path.curvature".into(), self.params()), Action::Notify("created".into())]
|
||||
@@ -113,16 +129,20 @@ impl Tool for CurvatureTool {
|
||||
out.push(Action::Begin("Curvature".into()));
|
||||
self.drag = Some((i, start, true));
|
||||
}
|
||||
let p = self.snap.drag(cx, p, None);
|
||||
if let Some(pt) = self.pts.get_mut(i) {
|
||||
pt.0 = p;
|
||||
}
|
||||
out.push(Action::Preview("path.curvature".into(), self.params()));
|
||||
out
|
||||
}
|
||||
PointerKind::Up => match self.drag.take() {
|
||||
Some((_, _, true)) => vec![Action::Commit],
|
||||
_ => vec![],
|
||||
},
|
||||
PointerKind::Up => {
|
||||
self.snap.clear();
|
||||
match self.drag.take() {
|
||||
Some((_, _, true)) => vec![Action::Commit],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
PointerKind::DoubleClick => {
|
||||
if active.is_none() {
|
||||
return vec![];
|
||||
@@ -163,10 +183,10 @@ impl Tool for CurvatureTool {
|
||||
if dragging { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = self.snap.guides().to_vec();
|
||||
if self.active(cx).is_none() {
|
||||
return vec![];
|
||||
return o;
|
||||
}
|
||||
let mut o = vec![];
|
||||
if cx.curvature_rubber_band
|
||||
&& !self.closed
|
||||
&& self.drag.is_none()
|
||||
@@ -217,4 +237,19 @@ mod tests {
|
||||
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 400.0));
|
||||
assert!(matches!(&a[0], Action::Exec(_, v) if v["id"] == id.0 && v["points"].as_array().unwrap().len() == 5));
|
||||
}
|
||||
|
||||
/// Points snap to Smart Guides (#506): the first lands on another object's anchor and says so.
|
||||
#[test]
|
||||
fn points_snap_to_smart_guides() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = CurvatureTool::default();
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 202.0, 99.0));
|
||||
assert!(matches!(&a[0], Action::Exec(_, v) if v["points"][0]["x"] == 200.0 && v["points"][0]["y"] == 100.0), "{a:?}");
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor")));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 202.0, 99.0));
|
||||
assert!(t.overlays(&cx).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! Line Segment family drag tools: Arc, Spiral, Rectangular Grid and Polar Grid.
|
||||
//!
|
||||
//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids, Space held
|
||||
//! moves the shape being drawn); a click without dragging asks the UI for the options dialog.
|
||||
//! moves the shape being drawn); a click without dragging asks the UI for the options dialog. The
|
||||
//! start point and the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering too.
|
||||
//! While dragging, ↑/↓ change the spiral's segments, the grid rows or the concentric dividers; ←/→
|
||||
//! change grid columns / radial dividers.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::guides::{DrawSnap, Leave, square};
|
||||
use crate::shape::drag_rect;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
pub struct FamilyTool {
|
||||
@@ -24,6 +27,7 @@ pub struct FamilyTool {
|
||||
pub rows: u32,
|
||||
/// Columns / radial dividers.
|
||||
pub columns: u32,
|
||||
snap: DrawSnap,
|
||||
}
|
||||
|
||||
impl FamilyTool {
|
||||
@@ -46,6 +50,7 @@ impl FamilyTool {
|
||||
clockwise: true,
|
||||
rows: 5,
|
||||
columns: 5,
|
||||
snap: DrawSnap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,13 +63,10 @@ impl FamilyTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(start: Point, p: Point, m: Mods) -> Rect {
|
||||
let mut d = p - start;
|
||||
if m.shift {
|
||||
let s = d.x.abs().max(d.y.abs());
|
||||
d = Vec2::new(s * d.x.signum(), s * d.y.signum());
|
||||
}
|
||||
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
|
||||
/// How the dragged point keeps to the start: a spiral's radius goes anywhere, the arc's and
|
||||
/// the grids' corner keeps to a diagonal with Shift.
|
||||
fn leave(&self, start: Point, m: Mods) -> Option<Leave> {
|
||||
(self.id != "spiral").then(|| Leave::diagonal(start, m.shift))
|
||||
}
|
||||
|
||||
/// The command for a drag from `start` to `p`.
|
||||
@@ -78,25 +80,21 @@ impl FamilyTool {
|
||||
)
|
||||
}
|
||||
"rectangularGrid" => {
|
||||
let r = Self::rect(start, p, m);
|
||||
let r = drag_rect(start, p, m);
|
||||
(
|
||||
"shape.rectangularGrid".into(),
|
||||
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "rows": self.rows, "columns": self.columns}),
|
||||
)
|
||||
}
|
||||
"polarGrid" => {
|
||||
let r = Self::rect(start, p, m);
|
||||
let r = drag_rect(start, p, m);
|
||||
(
|
||||
"shape.polarGrid".into(),
|
||||
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "concentric": self.rows, "radial": self.columns}),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
let mut d = p - start;
|
||||
if m.shift {
|
||||
let s = d.x.abs().max(d.y.abs());
|
||||
d = Vec2::new(s * d.x.signum(), s * d.y.signum());
|
||||
}
|
||||
let d = if m.shift { square(p - start) } else { p - start };
|
||||
let (a, b) = if m.alt { (start - d, start + d) } else { (start, start + d) };
|
||||
("shape.arc".into(), json!({"x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y, "closed": self.closed}))
|
||||
}
|
||||
@@ -113,8 +111,12 @@ impl Tool for FamilyTool {
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.snap.hover(cx, ev.pos, &[], None);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
|
||||
let p = self.snap.press(cx, ev.pos, &[], None);
|
||||
self.start = Some(p);
|
||||
self.last = p;
|
||||
self.began = false;
|
||||
@@ -122,6 +124,8 @@ impl Tool for FamilyTool {
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some(mut s) = self.start else { return vec![] };
|
||||
let pos = self.snap.drag(cx, ev.pos, self.leave(s, ev.mods).as_ref());
|
||||
let ev = &PointerEvent { pos, ..*ev };
|
||||
crate::shape::space_moves(&mut s, &mut self.last, ev, self.began);
|
||||
self.start = Some(s);
|
||||
self.mods = ev.mods;
|
||||
@@ -139,6 +143,7 @@ impl Tool for FamilyTool {
|
||||
}
|
||||
PointerKind::Up => {
|
||||
let Some(s) = self.start.take() else { return vec![] };
|
||||
self.snap.clear();
|
||||
if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] }
|
||||
}
|
||||
_ => vec![],
|
||||
@@ -179,19 +184,21 @@ impl Tool for FamilyTool {
|
||||
} else if key == ToolKey::Escape {
|
||||
self.start = None;
|
||||
self.began = false;
|
||||
self.snap.clear();
|
||||
vec![Action::Cancel]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
match self.start {
|
||||
Some(s) if self.began && cx.measurement_labels => {
|
||||
let d = self.last - s;
|
||||
vec![Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) }]
|
||||
}
|
||||
_ => vec![],
|
||||
let mut o = self.snap.guides().to_vec();
|
||||
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
|
||||
// The size as drawn (from the centre with Alt, square with Shift).
|
||||
let r = drag_rect(s, self.last, self.mods);
|
||||
let (w, h) = if self.id == "spiral" { ((self.last - s).x.abs(), (self.last - s).y.abs()) } else { (r.width(), r.height()) };
|
||||
o.push(Overlay::Measure { p: self.last, text: cx.size_label(w, h) });
|
||||
}
|
||||
o
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
@@ -267,4 +274,23 @@ mod tests {
|
||||
assert_eq!(c, "shape.arc");
|
||||
assert_eq!(v, json!({"x1": 0.0, "y1": 0.0, "x2": 10.0, "y2": 10.0, "closed": false}));
|
||||
}
|
||||
|
||||
/// The start and the dragged corner snap to Smart Guides (#506), Shift sliding the corner of
|
||||
/// a square grid along its diagonal into line.
|
||||
#[test]
|
||||
fn corners_snap_to_smart_guides() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = FamilyTool::new("rectangularGrid");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 202.0, 99.0));
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 255.0, 147.0).with_mods(shift));
|
||||
assert!(
|
||||
matches!(&a[..], [Action::Begin(_), Action::Preview(_, v)] if v["x"] == 200.0 && v["y"] == 100.0 && v["width"] == 50.0 && v["height"] == 50.0),
|
||||
"{a:?}"
|
||||
);
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
//!
|
||||
//! Pencil and Paintbrush preview the fitted path live (`path.freehand` re-applied on the snapshot)
|
||||
//! and commit on release; Alt on release (or ending near the start) closes the path, and starting
|
||||
//! near an end of a selected open path continues it. The other tools collect the drag polyline
|
||||
//! (shown as an overlay) and run one command on release.
|
||||
//! near an end of a selected open path continues it. The Pencil's new path starts and ends on
|
||||
//! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points
|
||||
//! between follow the hand. The other tools collect the drag polyline (shown as an overlay) and run
|
||||
//! one command on release.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use super::{FEEDBACK, points_json, polyline};
|
||||
use crate::guides::DrawSnap;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
pub struct GestureTool {
|
||||
@@ -30,6 +33,8 @@ pub struct GestureTool {
|
||||
pub edit_within: f64,
|
||||
/// "Close paths when ends are within N pixels".
|
||||
pub close_within: f64,
|
||||
/// Smart Guides for the Pencil's ends.
|
||||
snap: DrawSnap,
|
||||
}
|
||||
|
||||
impl GestureTool {
|
||||
@@ -56,9 +61,15 @@ impl GestureTool {
|
||||
fill: false,
|
||||
edit_within: 12.0,
|
||||
close_within: 15.0,
|
||||
snap: DrawSnap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Do the ends of its strokes snap (the Pencil)?
|
||||
fn snaps(&self) -> bool {
|
||||
self.id == "pencil"
|
||||
}
|
||||
|
||||
fn draws_path(&self) -> bool {
|
||||
matches!(self.id, "pencil" | "paintbrush")
|
||||
}
|
||||
@@ -147,17 +158,27 @@ impl Tool for GestureTool {
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Move if self.snaps() => {
|
||||
self.snap.hover(cx, p, &[], None);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
self.points = vec![p];
|
||||
self.active = true;
|
||||
self.began = false;
|
||||
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
|
||||
// A path continued starts at its end; a new one where it snaps.
|
||||
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
|
||||
self.points = vec![start];
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
if !self.active {
|
||||
return vec![];
|
||||
}
|
||||
if self.snaps() {
|
||||
// Where the stroke would end: shown, taken on release.
|
||||
self.snap.drag(cx, p, None);
|
||||
}
|
||||
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
|
||||
return vec![];
|
||||
}
|
||||
@@ -177,8 +198,16 @@ impl Tool for GestureTool {
|
||||
if !self.active {
|
||||
return vec![];
|
||||
}
|
||||
let end = if self.snaps() { self.snap.drag(cx, p, None) } else { p };
|
||||
self.snap.clear();
|
||||
if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
|
||||
self.points.push(p);
|
||||
self.points.push(end);
|
||||
} else if self.snaps()
|
||||
&& self.points.len() > 1
|
||||
&& let Some(last) = self.points.last_mut()
|
||||
{
|
||||
// The last step was where the button went up: it moves where that snaps.
|
||||
*last = end;
|
||||
}
|
||||
let out = self.finish(cx, ev.mods);
|
||||
self.active = false;
|
||||
@@ -194,6 +223,7 @@ impl Tool for GestureTool {
|
||||
match key {
|
||||
ToolKey::Escape if self.active => {
|
||||
let began = self.began;
|
||||
self.snap.clear();
|
||||
self.active = false;
|
||||
self.began = false;
|
||||
self.points.clear();
|
||||
@@ -212,21 +242,24 @@ impl Tool for GestureTool {
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
let began = self.began;
|
||||
self.snap.clear();
|
||||
self.active = false;
|
||||
self.began = false;
|
||||
self.points.clear();
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = self.snap.guides().to_vec();
|
||||
if !self.active || self.points.len() < 2 || self.began {
|
||||
return vec![];
|
||||
return o;
|
||||
}
|
||||
let width = match self.id {
|
||||
"blobBrush" | "eraser" => (self.size * cx.zoom) as f32,
|
||||
_ => 1.0,
|
||||
};
|
||||
let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK };
|
||||
vec![Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }]
|
||||
o.push(Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" });
|
||||
o
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
@@ -269,6 +302,25 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// The Pencil's new path starts and ends on Smart Guides (#506), the points between where
|
||||
/// the hand went.
|
||||
#[test]
|
||||
fn pencil_ends_snap_to_smart_guides() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = GestureTool::new("pencil");
|
||||
let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0), (250.0, 320.0), (198.0, 203.0)], Mods::default());
|
||||
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
|
||||
assert_eq!(v["points"], json!([[100.0, 100.0], [151.0, 300.0], [250.0, 320.0], [200.0, 200.0]]));
|
||||
// The Paintbrush follows the hand all the way.
|
||||
let mut t = GestureTool::new("paintbrush");
|
||||
let acts = drag(&mut t, &cx, &[(102.0, 98.0), (151.0, 300.0)], Mods::default());
|
||||
let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") };
|
||||
assert_eq!(v["points"][0], json!([102.0, 98.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pencil_previews_and_commits() {
|
||||
let (d, _) = doc_with_rect();
|
||||
|
||||
+539
-51
@@ -1,15 +1,23 @@
|
||||
//! Smart Guides: snapping to anchors, path segments (for dragged anchors), object bounds
|
||||
//! (edges/centres), artboards, with the magenta construction lines and labels Illustrator users
|
||||
//! expect. Preferences › Smart Guides sets their colour, which of them show ([`GuideStyle`]) and
|
||||
//! how near a target pulls (`ToolContext::snapping_tolerance`).
|
||||
//! Smart Guides: snapping to anchors, path segments (for dragged anchors and drawn points), object
|
||||
//! bounds (edges/centres), artboards, with the magenta construction lines and labels Illustrator
|
||||
//! users expect. Preferences › Smart Guides sets their colour, which of them show ([`GuideStyle`]),
|
||||
//! how near a target pulls (`ToolContext::snapping_tolerance`) and the angles of the construction
|
||||
//! guides drawn points land on (`ToolContext::construction_angles`).
|
||||
//!
|
||||
//! The drawing tools (Pen, Curvature, Pencil, the shape tools, Type, Artboard) all snap the points
|
||||
//! they place through one [`DrawSnap`]: their targets are gathered once per document state, sorted,
|
||||
//! and each pointer move looks up the few near the pointer by bisection.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::hit::{HitOptions, hit_test};
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind, OrientedBox, Selection};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, OrientedBox, Selection};
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, SubPath, Vec2, constrain_angle_from};
|
||||
|
||||
use crate::bbox::Handle;
|
||||
use crate::{Overlay, ToolContext};
|
||||
use crate::{Overlay, ScreenFrame, ToolContext};
|
||||
|
||||
pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc];
|
||||
|
||||
@@ -102,9 +110,93 @@ impl Ruler {
|
||||
/// How many anchors, and how many segments, the targets gather at most.
|
||||
const BUDGET: usize = 20_000;
|
||||
|
||||
/// What [`Targets::collect_inner`] gathers from each path besides its anchors and bounds.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Gather {
|
||||
/// Nothing more: a moved or resized selection lines up with bounds.
|
||||
Bounds,
|
||||
/// Its segments, which a dragged anchor lands on.
|
||||
Segments,
|
||||
/// Its segments, and its anchors as lines too: a drawn point lines up with them.
|
||||
Drawing,
|
||||
}
|
||||
|
||||
/// The entries of `sorted` (ascending by `key`) whose key is within `tol` of `v`, found by
|
||||
/// bisection.
|
||||
fn within<T>(sorted: &[T], key: impl Fn(&T) -> f64, v: f64, tol: f64) -> &[T] {
|
||||
let lo = sorted.partition_point(|e| key(e) < v - tol);
|
||||
let hi = sorted.partition_point(|e| key(e) <= v + tol);
|
||||
sorted.get(lo..hi).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Visit `nodes` and everything inside them, but the nodes in `skip` and everything inside those:
|
||||
/// one pass, however many are skipped.
|
||||
fn walk_but<'a>(nodes: &'a [Arc<Node>], skip: &HashSet<NodeId>, f: &mut impl FnMut(&'a Node)) {
|
||||
for n in nodes.iter().filter(|n| !skip.contains(&n.id)) {
|
||||
f(n);
|
||||
if let Some(ch) = n.children() {
|
||||
walk_but(ch, skip, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines of `lines` (sorted by where they run) within `tol` of `v`.
|
||||
fn lines_near(lines: &[(f64, Point, Kind)], v: f64, tol: f64) -> &[(f64, Point, Kind)] {
|
||||
within(lines, |l| l.0, v, tol)
|
||||
}
|
||||
|
||||
impl Targets {
|
||||
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
|
||||
Self::collect_inner(doc, exclude, None, visible, false)
|
||||
Self::collect_inner(doc, exclude, None, visible, Gather::Bounds)
|
||||
}
|
||||
|
||||
/// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its
|
||||
/// anchors, centres, edges and path segments, its anchors in line too. The anchors and
|
||||
/// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets.
|
||||
pub fn for_drawing(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
|
||||
let mut t = Self::collect_inner(doc, exclude, None, visible, Gather::Drawing);
|
||||
t.push_paths(doc, exclude, true);
|
||||
t.index()
|
||||
}
|
||||
|
||||
/// Targets for a dragged direction handle of path `id`: the other art, and the anchors of `id`
|
||||
/// (its bounds and the segments through the handle move with it, so they would chase it).
|
||||
pub fn for_handle(doc: &Document, id: NodeId) -> Self {
|
||||
let mut t = Self::collect_inner(doc, &[id], None, None, Gather::Bounds);
|
||||
t.push_paths(doc, &[id], false);
|
||||
t.index()
|
||||
}
|
||||
|
||||
/// The anchors of the paths `ids` as targets, on them and in line, and their segments too
|
||||
/// when `segments`: at most [`BUDGET`] of each.
|
||||
fn push_paths(&mut self, doc: &Document, ids: &[NodeId], segments: bool) {
|
||||
let (mut anchors, mut segment_budget) = (BUDGET, if segments { BUDGET } else { 0 });
|
||||
for path in ids.iter().filter_map(|id| doc.node(*id)?.path_data()) {
|
||||
for sp in &path.subpaths {
|
||||
for a in sp.anchors.iter().take(anchors) {
|
||||
self.push_anchor(a.p);
|
||||
anchors -= 1;
|
||||
}
|
||||
self.push_segments(sp, |_| false, &mut segment_budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort the targets so that [`within`] finds those near a point by bisection: the anchors
|
||||
/// and centres by x, the lines by where they run. Every constructor ends with it (the sort
|
||||
/// is stable: of targets alike, the first gathered still wins).
|
||||
fn index(mut self) -> Self {
|
||||
self.points.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
|
||||
self.xs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
self.ys.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
self
|
||||
}
|
||||
|
||||
/// Anchor `a` as a target: a point lands on it and lines up with it.
|
||||
fn push_anchor(&mut self, a: Point) {
|
||||
self.points.push((a, Kind::Anchor));
|
||||
self.xs.push((a.x, a, Kind::Anchor));
|
||||
self.ys.push((a.y, a, Kind::Anchor));
|
||||
}
|
||||
|
||||
/// The same targets drawing their guides as the Smart Guides preferences in `cx` ask
|
||||
@@ -127,29 +219,27 @@ impl Targets {
|
||||
/// Targets for dragging artboard `index`: everything except that artboard and `exclude` (the
|
||||
/// art that moves along with it).
|
||||
pub fn for_artboard(doc: &Document, index: usize, exclude: &[NodeId]) -> Self {
|
||||
Self::collect_inner(doc, exclude, Some(index), None, false)
|
||||
Self::collect_inner(doc, exclude, Some(index), None, Gather::Bounds)
|
||||
}
|
||||
|
||||
/// Targets for dragging the direct-selected anchors of `sel` (and its objects selected as a
|
||||
/// whole): the other art, its path segments too, and the anchors and segments that stay put on
|
||||
/// the paths being reshaped (their bounds move along, so they are no targets).
|
||||
pub fn for_anchor_drag(doc: &Document, sel: &Selection) -> Self {
|
||||
let mut t = Self::collect_inner(doc, &sel.objects, None, None, true);
|
||||
let mut t = Self::collect_inner(doc, &sel.objects, None, None, Gather::Segments);
|
||||
let (mut anchors, mut segments) = (BUDGET, BUDGET);
|
||||
for (id, set) in &sel.anchors {
|
||||
let Some(path) = doc.node(*id).and_then(|n| n.path_data()) else { continue };
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let moving = |ai: usize| set.contains(&(si, ai));
|
||||
for a in sp.anchors.iter().enumerate().filter(|(ai, _)| !moving(*ai)).map(|(_, a)| a.p).take(anchors) {
|
||||
t.points.push((a, Kind::Anchor));
|
||||
t.xs.push((a.x, a, Kind::Anchor));
|
||||
t.ys.push((a.y, a, Kind::Anchor));
|
||||
t.push_anchor(a);
|
||||
anchors -= 1;
|
||||
}
|
||||
t.push_segments(sp, moving, &mut segments);
|
||||
}
|
||||
}
|
||||
t
|
||||
t.index()
|
||||
}
|
||||
|
||||
/// The segments of `sp` that stay put while the anchors `moving` accepts move, as targets, at
|
||||
@@ -171,9 +261,8 @@ impl Targets {
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>, segments: bool) -> Self {
|
||||
fn collect_inner(doc: &Document, exclude: &[NodeId], skip_artboard: Option<usize>, visible: Option<Rect>, gather: Gather) -> Self {
|
||||
let mut t = Targets::default();
|
||||
let excluded = |id: NodeId| exclude.iter().any(|e| doc.ancestry(id).is_some_and(|a| a.contains(e)));
|
||||
let add_rect = |t: &mut Targets, r: Rect, kind: Kind| {
|
||||
let c = r.center();
|
||||
for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] {
|
||||
@@ -194,8 +283,8 @@ impl Targets {
|
||||
}
|
||||
}
|
||||
let (mut budget, mut segment_budget) = (BUDGET, BUDGET);
|
||||
doc.walk(|n| {
|
||||
if budget == 0 || n.is_container() || !n.visible || excluded(n.id) {
|
||||
walk_but(&doc.layers, &exclude.iter().copied().collect(), &mut |n| {
|
||||
if budget == 0 || n.is_container() || !n.visible {
|
||||
return;
|
||||
}
|
||||
let Some(b) = n.geometric_bounds() else { return };
|
||||
@@ -207,10 +296,14 @@ impl Targets {
|
||||
}
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
for (_, _, a) in path.anchors() {
|
||||
t.points.push((a.p, Kind::Anchor));
|
||||
if gather == Gather::Drawing {
|
||||
t.push_anchor(a.p);
|
||||
} else {
|
||||
t.points.push((a.p, Kind::Anchor));
|
||||
}
|
||||
budget = budget.saturating_sub(1);
|
||||
}
|
||||
if segments {
|
||||
if gather != Gather::Bounds {
|
||||
for sp in &path.subpaths {
|
||||
t.push_segments(sp, |_| false, &mut segment_budget);
|
||||
}
|
||||
@@ -218,7 +311,7 @@ impl Targets {
|
||||
}
|
||||
add_rect(&mut t, b, Kind::Edge);
|
||||
});
|
||||
t
|
||||
t.index()
|
||||
}
|
||||
|
||||
/// Only these targets' anchors pull (View → Snap to Point).
|
||||
@@ -265,25 +358,55 @@ impl Targets {
|
||||
t.points.push((b.center(), Kind::Center));
|
||||
});
|
||||
}
|
||||
t
|
||||
t.index()
|
||||
}
|
||||
|
||||
/// Snap a single point: onto the nearest anchor or centre, else onto the nearest segment, else
|
||||
/// into line with targets. Returns the snapped point and guide overlays.
|
||||
pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec<Overlay>) {
|
||||
self.snap_from(p, None, tol)
|
||||
}
|
||||
|
||||
/// Snap a drawn point `p` as [`Self::snap_point`] does, and when it leaves `from` (the anchor
|
||||
/// before it, a shape's start): with Shift held it keeps to its angles round it, sliding along
|
||||
/// that way into line with the nearest target; else, before lining up, it lands on a
|
||||
/// construction guide through it, where a target's line crosses the guide if one does nearby.
|
||||
pub fn snap_from(&self, p: Point, from: Option<&Leave>, tol: f64) -> (Point, Vec<Overlay>) {
|
||||
if let Some(f) = from
|
||||
&& let Some(q) = f.constrained(p)
|
||||
{
|
||||
return match self.slide(f.at, q - f.at, q, tol, true) {
|
||||
Some((q, target)) => (q, self.lined_up(q, target)),
|
||||
None => (q, vec![]),
|
||||
};
|
||||
}
|
||||
if let Some((q, k)) = self.nearest_point(p, tol) {
|
||||
return (q, self.label(q, k.label()).into_iter().collect());
|
||||
}
|
||||
if let Some(q) = self.on_segment(p, tol) {
|
||||
return (q, self.label(q, Kind::Edge.label()).into_iter().collect());
|
||||
}
|
||||
if let Some(f) = from
|
||||
&& let Some((dir, q)) = f.construction(p, tol)
|
||||
{
|
||||
let construction = |q: Point| Overlay::Line { a: f.at, b: q, color: self.style.color, dashed: false };
|
||||
return match self.slide(f.at, dir, q, tol, false) {
|
||||
Some((q, target)) => (q, std::iter::once(construction(q)).chain(self.lined_up(q, target)).collect()),
|
||||
None => (q, vec![construction(q)]),
|
||||
};
|
||||
}
|
||||
let mut out = p;
|
||||
let mut ov = vec![];
|
||||
// The targets' lines along one axis and the ruler guides running past `p`, the nearest
|
||||
// within reach of `v`.
|
||||
let nearest = |lines: &[(f64, Point, Kind)], vertical: bool, v: f64| {
|
||||
let rulers = self.rulers.iter().filter(|r| r.vertical == vertical).filter_map(|r| r.line_at(p, tol));
|
||||
lines.iter().copied().chain(rulers).filter(|(t, ..)| (t - v).abs() <= tol).min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs()))
|
||||
lines_near(lines, v, tol)
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(rulers)
|
||||
.filter(|(t, ..)| (t - v).abs() <= tol)
|
||||
.min_by(|a, b| (a.0 - v).abs().total_cmp(&(b.0 - v).abs()))
|
||||
};
|
||||
let mut aligned = false;
|
||||
if let Some((x, from, _)) = nearest(&self.xs, true, p.x) {
|
||||
@@ -304,7 +427,47 @@ impl Targets {
|
||||
|
||||
/// The anchor or centre nearest `p`, within `tol`.
|
||||
fn nearest_point(&self, p: Point, tol: f64) -> Option<(Point, Kind)> {
|
||||
self.points.iter().copied().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p)))
|
||||
within(&self.points, |q| q.0.x, p.x, tol)
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(q, _)| q.distance(p) <= tol)
|
||||
.min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p)))
|
||||
}
|
||||
|
||||
/// Slide `q`, on the line through `at` along `dir` (beyond `at` only, for a `ray`), to where
|
||||
/// the nearest target line crosses it, at most twice `tol` away: where it lands and the
|
||||
/// target's own point (where its line starts).
|
||||
fn slide(&self, at: Point, dir: Vec2, q: Point, tol: f64, ray: bool) -> Option<(Point, Point)> {
|
||||
if !dir.hypot2().is_finite() || dir.hypot2() <= 1e-18 {
|
||||
return None;
|
||||
}
|
||||
let reach = 2.0 * tol;
|
||||
let mut best: Option<(f64, Point, Point)> = None;
|
||||
for (vertical, lines, d, a, v) in [(true, &self.xs, dir.x, at.x, q.x), (false, &self.ys, dir.y, at.y, q.y)] {
|
||||
if d.abs() <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
// A line that far across the way crosses it at least that far along.
|
||||
for (t, from, _) in lines_near(lines, v, reach) {
|
||||
let s = (t - a) / d;
|
||||
// Exactly on the line, whatever the rounding along the way.
|
||||
let hit = if vertical { Point::new(*t, at.y + dir.y * s) } else { Point::new(at.x + dir.x * s, *t) };
|
||||
let moved = hit.distance(q);
|
||||
if moved <= reach && (!ray || s > 0.0) && best.is_none_or(|b| moved < b.0) {
|
||||
best = Some((moved, hit, *from));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, hit, from)| (hit, from))
|
||||
}
|
||||
|
||||
/// The guides of a point `q` slid into line with the target at `from`: the line from it and
|
||||
/// "align", or the target's own label when `q` sits right on it.
|
||||
fn lined_up(&self, q: Point, from: Point) -> Vec<Overlay> {
|
||||
if let Some((on, k)) = self.nearest_point(q, 1e-9) {
|
||||
return self.label(on, k.label()).into_iter().collect();
|
||||
}
|
||||
self.line(from, q).into_iter().chain(self.label(q, "align")).collect()
|
||||
}
|
||||
|
||||
/// The nearest point within `tol` of `p` on a target segment.
|
||||
@@ -323,7 +486,7 @@ impl Targets {
|
||||
pub fn snap_guide(&self, vertical: bool, v: f64, tol: f64) -> (f64, Vec<Overlay>) {
|
||||
let along = |q: &Point| if vertical { q.x } else { q.y };
|
||||
let lines = if vertical { &self.xs } else { &self.ys };
|
||||
let best = lines
|
||||
let best = lines_near(lines, v, tol)
|
||||
.iter()
|
||||
.map(|(t, from, k)| (*t, *from, *k))
|
||||
.chain(self.points.iter().map(|(q, k)| (along(q), *q, *k)))
|
||||
@@ -370,7 +533,7 @@ impl Targets {
|
||||
rects
|
||||
.iter()
|
||||
.flat_map(|r| along(r).into_iter().map(move |v| (v, *r)))
|
||||
.flat_map(|(v, r)| targets.iter().map(move |(t, from, _)| (t - v, *from, v, r)))
|
||||
.flat_map(|(v, r)| lines_near(targets, v, tol).iter().map(move |(t, from, _)| (t - v, *from, v, r)))
|
||||
.filter(|(d, ..)| d.abs() <= tol)
|
||||
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()))
|
||||
};
|
||||
@@ -410,7 +573,11 @@ impl Targets {
|
||||
// A scale that collapses or flips the box is no snap.
|
||||
let valid = |n: f64, s: f64| n.is_finite() && n * s > 0.0;
|
||||
let nearest = |v: f64, ts: &[(f64, Point, Kind)]| -> Hit {
|
||||
ts.iter().map(|(t, from, _)| (*t, *from)).filter(|(t, _)| (t - v).abs() <= tol).min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
|
||||
lines_near(ts, v, tol)
|
||||
.iter()
|
||||
.map(|(t, from, _)| (*t, *from))
|
||||
.filter(|(t, _)| (t - v).abs() <= tol)
|
||||
.min_by(|p, q| (p.0 - v).abs().total_cmp(&(q.0 - v).abs()))
|
||||
};
|
||||
let (mut nx, mut ny) = (sx, sy);
|
||||
let mut snap: Snap = (None, None, None);
|
||||
@@ -540,24 +707,225 @@ impl PointSnap {
|
||||
|
||||
/// Where `p` goes, and the guides showing why.
|
||||
pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
||||
self.snap_from(cx, p, None)
|
||||
}
|
||||
|
||||
/// Where a drawn point `p` leaving `from` goes ([`Targets::snap_from`]), and the guides
|
||||
/// showing why. Pixels and the grid round it before Shift turns it onto its angle.
|
||||
pub fn snap_from(&self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> (Point, Vec<Overlay>) {
|
||||
let keep = |q: Point| from.and_then(|f| f.constrained(q)).unwrap_or(q);
|
||||
if cx.snap_to_pixel {
|
||||
return (Point::new(p.x.round(), p.y.round()), vec![]);
|
||||
return (keep(Point::new(p.x.round(), p.y.round())), vec![]);
|
||||
}
|
||||
if cx.snap_to_grid {
|
||||
return (vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step()), vec![]);
|
||||
return (keep(vectorcraft_geom::snap::snap_point_to_grid(p, cx.grid_step())), vec![]);
|
||||
}
|
||||
match &self.targets {
|
||||
Some((t, tol)) => t.snap_point(p, cx.tol(*tol)),
|
||||
None => (p, vec![]),
|
||||
Some((t, tol)) => t.snap_from(p, from, cx.tol(*tol)),
|
||||
None => (keep(p), vec![]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapping for a dragged direction handle: Shift keeps it at 45° steps round its anchor, else it
|
||||
/// snaps as a drawn point does. The targets are gathered on the first move that needs them and
|
||||
/// kept for the rest of the drag (gathering them takes as long as the document is big).
|
||||
/// The Construction Guides angle set new preferences start with.
|
||||
pub const DEFAULT_CONSTRUCTION_ANGLES: &str = "90° & 45° Angles";
|
||||
|
||||
/// The angles (degrees) of the construction guides of a Smart Guides › Angles set: "90°" gives the
|
||||
/// horizontal and the vertical, "45°" the diagonals, "60°" and "30°" their multiples between those.
|
||||
pub fn construction_angles(set: &str) -> &'static [f64] {
|
||||
match set {
|
||||
"30° Angles" => &[30.0, 60.0, 120.0, 150.0],
|
||||
"45° Angles" => &[45.0, 135.0],
|
||||
"60° Angles" => &[60.0, 120.0],
|
||||
"90° Angles" => &[0.0, 90.0],
|
||||
"90° & 45° & 30° Angles" => &[0.0, 30.0, 45.0, 60.0, 90.0, 120.0, 135.0, 150.0],
|
||||
_ => &[0.0, 45.0, 90.0, 135.0],
|
||||
}
|
||||
}
|
||||
|
||||
/// How Shift keeps a drawn point to the point it leaves from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Keep {
|
||||
/// At steps of `step` degrees counted from `base` (a segment).
|
||||
Angle { step: f64, base: f64 },
|
||||
/// On a diagonal, as far along as the pointer's farther axis (a square's corner).
|
||||
Square,
|
||||
}
|
||||
|
||||
/// The point a drawn point leaves from (the anchor before it, a shape's start) and how it keeps
|
||||
/// to it: with Shift held at fixed angles round it, and onto the construction guides through it
|
||||
/// (Smart Guides › Construction Guides).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Leave {
|
||||
pub at: Point,
|
||||
/// Shift held: how the point keeps to `at`.
|
||||
shift: Option<Keep>,
|
||||
/// The angles of the construction guides through `at` (none with them off).
|
||||
angles: &'static [f64],
|
||||
}
|
||||
|
||||
impl Leave {
|
||||
/// A segment from `at` (Pen, Curvature, Line Segment): Shift keeps it at 45° steps from
|
||||
/// General › Constrain Angle; with Smart Guides on it lands on the construction guides.
|
||||
pub fn segment(cx: &ToolContext, at: Point, shift: bool) -> Self {
|
||||
Self {
|
||||
at,
|
||||
shift: shift.then_some(Keep::Angle { step: 45.0, base: cx.constrain_angle }),
|
||||
angles: if cx.smart_guides { cx.construction_angles } else { &[] },
|
||||
}
|
||||
}
|
||||
|
||||
/// A box dragged from its corner or centre `at` (Rectangle, Ellipse, Arc, the grids,
|
||||
/// Artboard): Shift keeps the dragged corner on a diagonal, the box a square.
|
||||
pub fn diagonal(at: Point, shift: bool) -> Self {
|
||||
Self { at, shift: shift.then_some(Keep::Square), angles: &[] }
|
||||
}
|
||||
|
||||
/// Where Shift puts `p`: none without it.
|
||||
fn constrained(&self, p: Point) -> Option<Point> {
|
||||
let v = p - self.at;
|
||||
Some(match self.shift? {
|
||||
Keep::Angle { step, base } => self.at + constrain_angle_from(v, step, base),
|
||||
Keep::Square => self.at + square(v),
|
||||
})
|
||||
}
|
||||
|
||||
/// The construction guide through `at` nearest `p`, within `tol` of it (and `p` farther than
|
||||
/// that from `at`, where every guide meets): its direction and where `p` lands on it.
|
||||
fn construction(&self, p: Point, tol: f64) -> Option<(Vec2, Point)> {
|
||||
let v = p - self.at;
|
||||
if v.hypot() <= tol {
|
||||
return None;
|
||||
}
|
||||
self.angles
|
||||
.iter()
|
||||
// Counter-clockwise as seen on the y-down page.
|
||||
.map(|a| Vec2::new(a.to_radians().cos(), -a.to_radians().sin()))
|
||||
.map(|d| (d, self.at + d * v.dot(d)))
|
||||
.map(|(d, q)| (d, q, q.distance(p)))
|
||||
.filter(|(.., dist)| *dist <= tol)
|
||||
.min_by(|a, b| a.2.total_cmp(&b.2))
|
||||
.map(|(d, q, _)| (d, q))
|
||||
}
|
||||
}
|
||||
|
||||
/// `d` made square: as long on both axes as on its longer one, each keeping its sign.
|
||||
pub fn square(d: Vec2) -> Vec2 {
|
||||
let s = d.x.abs().max(d.y.abs());
|
||||
Vec2::new(s * d.x.signum(), s * d.y.signum())
|
||||
}
|
||||
|
||||
/// Smart Guides for the points a drawing tool places: the Pen's and Curvature tool's anchors,
|
||||
/// the Pencil's ends, the shape tools' start and dragged corner, the Type tool's click and the
|
||||
/// Artboard tool's new artboard. Each such tool keeps one and asks it for every point, hovering
|
||||
/// too, so they all snap alike, and shows its [`Self::guides`].
|
||||
///
|
||||
/// The targets ([`Targets::for_drawing`], the art in the window) are gathered once per document
|
||||
/// state: hovering gathers them again only after the document, the selection, what is left out,
|
||||
/// the window or the snapping options changed; a press always gathers them afresh, and the drag
|
||||
/// it starts keeps them, the document then showing the drag's own previews.
|
||||
#[derive(Default)]
|
||||
pub struct HandleSnap(Option<PointSnap>);
|
||||
pub struct DrawSnap {
|
||||
/// The targets, and what they were gathered for (none: for one drag only).
|
||||
targets: Option<(Option<SnapKey>, PointSnap)>,
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
/// What a [`DrawSnap`]'s targets were gathered for: the document state, the art left out, the
|
||||
/// window and every option [`PointSnap::new`] reads.
|
||||
#[derive(PartialEq)]
|
||||
struct SnapKey {
|
||||
revision: (u64, u64),
|
||||
exclude: Vec<NodeId>,
|
||||
screen: Option<ScreenFrame>,
|
||||
view: [bool; 5],
|
||||
style: GuideStyle,
|
||||
tolerances: [f64; 2],
|
||||
}
|
||||
|
||||
impl SnapKey {
|
||||
fn of(cx: &ToolContext, exclude: &[NodeId]) -> Self {
|
||||
Self {
|
||||
revision: cx.revision,
|
||||
exclude: exclude.to_vec(),
|
||||
screen: cx.screen,
|
||||
view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides],
|
||||
style: GuideStyle::of(cx),
|
||||
tolerances: [cx.snapping_tolerance, cx.snap_tolerance],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of the document the window shows (none headless: all of it).
|
||||
fn visible(cx: &ToolContext) -> Option<Rect> {
|
||||
let s = cx.screen?;
|
||||
let (w, h) = s.size;
|
||||
Some(Rect::from_points(s.at(0.0, 0.0), s.at(w, h)).union_pt(s.at(w, 0.0)).union_pt(s.at(0.0, h)))
|
||||
}
|
||||
|
||||
impl DrawSnap {
|
||||
/// Snap the pointer hovering at `p` (the art but `exclude` as targets, the anchors of
|
||||
/// `exclude` still pulling), reusing the targets while nothing they depend on changed.
|
||||
pub fn hover(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point {
|
||||
let key = SnapKey::of(cx, exclude);
|
||||
if self.targets.as_ref().is_none_or(|(k, _)| k.as_ref() != Some(&key)) {
|
||||
self.gather(cx, key);
|
||||
}
|
||||
self.snap(cx, p, from)
|
||||
}
|
||||
|
||||
/// Snap the point pressed at `p`, the targets gathered afresh: the drag that follows keeps
|
||||
/// them ([`Self::drag`]).
|
||||
pub fn press(&mut self, cx: &ToolContext, p: Point, exclude: &[NodeId], from: Option<&Leave>) -> Point {
|
||||
self.gather(cx, SnapKey::of(cx, exclude));
|
||||
self.snap(cx, p, from)
|
||||
}
|
||||
|
||||
/// Gather `targets` other than the art for the drag a press starts (a point being dragged
|
||||
/// leaves itself out); the next hover gathers its own again.
|
||||
pub fn hold(&mut self, cx: &ToolContext, targets: impl FnOnce() -> Targets) {
|
||||
self.targets = Some((None, PointSnap::new(cx, targets)));
|
||||
self.guides.clear();
|
||||
}
|
||||
|
||||
/// Snap `p`, dragged since the press, with the targets the press gathered.
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point {
|
||||
if self.targets.is_none() {
|
||||
self.gather(cx, SnapKey::of(cx, &[]));
|
||||
}
|
||||
self.snap(cx, p, from)
|
||||
}
|
||||
|
||||
/// The guides of the last snap.
|
||||
pub fn guides(&self) -> &[Overlay] {
|
||||
&self.guides
|
||||
}
|
||||
|
||||
/// Hide the guides (the gesture ended).
|
||||
pub fn clear(&mut self) {
|
||||
self.guides.clear();
|
||||
}
|
||||
|
||||
fn gather(&mut self, cx: &ToolContext, key: SnapKey) {
|
||||
let snap = PointSnap::new(cx, || Targets::for_drawing(cx.doc, &key.exclude, visible(cx)));
|
||||
self.targets = Some((Some(key), snap));
|
||||
}
|
||||
|
||||
fn snap(&mut self, cx: &ToolContext, p: Point, from: Option<&Leave>) -> Point {
|
||||
let (q, guides) = match &self.targets {
|
||||
Some((_, t)) => t.snap_from(cx, p, from),
|
||||
None => (p, vec![]),
|
||||
};
|
||||
self.guides = guides;
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapping for a dragged direction handle: Shift keeps it at 45° steps round its anchor, else it
|
||||
/// snaps as a drawn point does ([`DrawSnap`]). The targets ([`Targets::for_handle`]) are gathered
|
||||
/// on the first move that needs them and kept for the rest of the drag.
|
||||
#[derive(Default)]
|
||||
pub struct HandleSnap(DrawSnap);
|
||||
|
||||
impl HandleSnap {
|
||||
/// Where the handle of anchor `ai` of subpath `si` of path `id` goes with the pointer at `p`,
|
||||
@@ -565,22 +933,13 @@ impl HandleSnap {
|
||||
pub fn snap(&mut self, cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
|
||||
let path = cx.doc.node(id).and_then(|n| n.path_data());
|
||||
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
|
||||
return (a.p + vectorcraft_geom::constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
|
||||
return (a.p + constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
|
||||
}
|
||||
let snap = self.0.get_or_insert_with(|| {
|
||||
PointSnap::new(cx, || {
|
||||
// The path's bounds move with the handle, so they would chase it: its anchors
|
||||
// (which stay put) are the targets instead, the handle lining up with them too.
|
||||
let mut t = Targets::collect(cx.doc, &[id], None);
|
||||
for (_, _, a) in path.into_iter().flat_map(|pd| pd.anchors()).take(20_000) {
|
||||
t.points.push((a.p, Kind::Anchor));
|
||||
t.xs.push((a.p.x, a.p, Kind::Anchor));
|
||||
t.ys.push((a.p.y, a.p, Kind::Anchor));
|
||||
}
|
||||
t
|
||||
})
|
||||
});
|
||||
snap.snap(cx, p)
|
||||
if self.0.targets.is_none() {
|
||||
self.0.hold(cx, || Targets::for_handle(cx.doc, id));
|
||||
}
|
||||
let q = self.0.drag(cx, p, None);
|
||||
(q, self.0.guides().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,9 +1117,138 @@ mod tests {
|
||||
s.anchors.insert(id, [(0, 0)].into_iter().collect());
|
||||
let t = Targets::for_anchor_drag(&d, &s);
|
||||
let anchors: Vec<Point> = t.points.iter().filter(|(_, k)| *k == Kind::Anchor).map(|(p, _)| *p).collect();
|
||||
assert_eq!(anchors, [Point::new(200.0, 100.0), Point::new(200.0, 200.0), Point::new(100.0, 200.0)]);
|
||||
assert_eq!(anchors, [Point::new(100.0, 200.0), Point::new(200.0, 100.0), Point::new(200.0, 200.0)], "by x");
|
||||
assert_eq!(t.segments.len(), 2);
|
||||
assert!(!t.points.iter().any(|(p, k)| *k == Kind::Center && *p == Point::new(150.0, 150.0)), "the rect's centre moves");
|
||||
assert_eq!(t.snap_point(Point::new(101.0, 99.0), 4.0).0, Point::new(100.0, 100.0), "in line with its neighbours, not on itself");
|
||||
}
|
||||
|
||||
fn labels(ov: &[Overlay]) -> Vec<&str> {
|
||||
ov.iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text.as_str()) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn lines(ov: &[Overlay]) -> Vec<(Point, Point)> {
|
||||
ov.iter().filter_map(|o| if let Overlay::Line { a, b, .. } = o { Some((*a, *b)) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// A drawn point (#506): on another object's anchors, in line with them too (not only with its
|
||||
/// bounds), onto its paths; Shift slides it along its 45° step into line; near a construction
|
||||
/// guide through the point it leaves from it lands on the guide, where a target's line crosses
|
||||
/// it if one does nearby.
|
||||
#[test]
|
||||
fn drawn_points_land_on_anchors_paths_alignments_and_construction_guides() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &s, &p);
|
||||
let t = Targets::for_drawing(&d, &[], None).styled(&c);
|
||||
// The rect is 100..200: its corner, a point on its side, and in line with an anchor.
|
||||
let (q, ov) = t.snap_from(Point::new(202.0, 198.0), None, 4.0);
|
||||
assert_eq!((q, labels(&ov)), (Point::new(200.0, 200.0), vec!["anchor"]));
|
||||
let (q, ov) = t.snap_from(Point::new(202.0, 160.0), None, 4.0);
|
||||
assert_eq!((q, labels(&ov)), (Point::new(200.0, 160.0), vec!["path"]));
|
||||
let (q, ov) = t.snap_from(Point::new(330.0, 102.0), None, 4.0);
|
||||
assert_eq!((q, labels(&ov)), (Point::new(330.0, 100.0), vec!["align"]));
|
||||
// Shift from (300, 300): the pointer at 2° keeps to 0°, then slides onto x = 200.
|
||||
let shift = Leave::segment(&c, Point::new(300.0, 300.0), true);
|
||||
let (q, ov) = t.snap_from(Point::new(203.0, 303.0), Some(&shift), 4.0);
|
||||
assert!((q - Point::new(200.0, 300.0)).hypot() < 1e-9, "{q:?}");
|
||||
assert_eq!(labels(&ov), ["align"]);
|
||||
assert_eq!(lines(&ov).len(), 1, "{ov:?}");
|
||||
// Nothing in reach: on its angle, as far as the pointer.
|
||||
let (q, ov) = t.snap_from(Point::new(300.0, 380.0), Some(&shift), 4.0);
|
||||
assert_eq!((q, ov.len()), (Point::new(300.0, 380.0), 0));
|
||||
// Construction guides through (300, 320): onto the 135° one…
|
||||
let from = Leave::segment(&c, Point::new(300.0, 320.0), false);
|
||||
let (q, ov) = t.snap_from(Point::new(352.0, 373.0), Some(&from), 4.0);
|
||||
assert!((q - Point::new(352.5, 372.5)).hypot() < 1e-9, "{q:?}");
|
||||
assert_eq!(lines(&ov), vec![(Point::new(300.0, 320.0), q)]);
|
||||
// …where x = 200 crosses it.
|
||||
let (q, ov) = t.snap_from(Point::new(203.0, 222.5), Some(&from), 4.0);
|
||||
assert!((q - Point::new(200.0, 220.0)).hypot() < 1e-9, "{q:?}");
|
||||
assert_eq!((lines(&ov).len(), labels(&ov)), (2, vec!["align"]));
|
||||
// Construction Guides off (or Smart Guides off): in line, not on a guide.
|
||||
let off = ToolContext { construction_angles: &[], ..cx(&d, &s, &p) };
|
||||
let (q, _) = t.snap_from(Point::new(352.0, 373.0), Some(&Leave::segment(&off, Point::new(300.0, 320.0), false)), 4.0);
|
||||
assert_eq!(q, Point::new(352.0, 373.0));
|
||||
// A box's corner keeps to a diagonal with Shift, the farther axis giving its size.
|
||||
let (q, _) = t.snap_from(Point::new(340.0, 310.0), Some(&Leave::diagonal(Point::new(300.0, 300.0), true)), 4.0);
|
||||
assert_eq!(q, Point::new(340.0, 340.0));
|
||||
}
|
||||
|
||||
/// The angle sets of Preferences › Smart Guides › Construction Guides.
|
||||
#[test]
|
||||
fn construction_angle_sets() {
|
||||
assert_eq!(construction_angles(DEFAULT_CONSTRUCTION_ANGLES), [0.0, 45.0, 90.0, 135.0]);
|
||||
assert_eq!(construction_angles("90° Angles"), [0.0, 90.0]);
|
||||
assert_eq!(construction_angles("30° Angles"), [30.0, 60.0, 120.0, 150.0]);
|
||||
assert_eq!(construction_angles("90° & 45° & 30° Angles").len(), 8);
|
||||
assert_eq!(construction_angles("nonsense"), construction_angles(DEFAULT_CONSTRUCTION_ANGLES));
|
||||
}
|
||||
|
||||
/// The sorted targets find by bisection just what a scan of all of them finds, whatever
|
||||
/// gathered them.
|
||||
#[test]
|
||||
fn bisection_finds_what_a_scan_finds() {
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let l = d.layers[0].id;
|
||||
for i in 0..60 {
|
||||
let (x, y) = ((i * 37 % 450) as f64, (i * 53 % 430) as f64);
|
||||
let tri = vectorcraft_geom::PathData::single(SubPath::polyline(
|
||||
&[Point::new(x, y), Point::new(x + 13.0, y + 3.0), Point::new(x + 5.0, y + 17.0)],
|
||||
true,
|
||||
));
|
||||
let n = d.alloc_id();
|
||||
d.insert(Some(l), 1, Node::path(n, tri, Appearance::default_art())).unwrap();
|
||||
}
|
||||
let mut s = Selection::default();
|
||||
s.set([id]);
|
||||
s.anchors.insert(id, [(0, 1)].into_iter().collect());
|
||||
for t in [Targets::collect(&d, &[], None), Targets::for_drawing(&d, &[id], None), Targets::for_anchor_drag(&d, &s)] {
|
||||
for q in (0..40).flat_map(|i| (0..40).map(move |j| Point::new(i as f64 * 12.7, j as f64 * 11.3))) {
|
||||
let scan = t.points.iter().copied().filter(|(a, _)| a.distance(q) <= 6.0).min_by(|a, b| a.0.distance(q).total_cmp(&b.0.distance(q)));
|
||||
assert_eq!(t.nearest_point(q, 6.0).map(|h| h.0.distance(q)), scan.map(|h| h.0.distance(q)), "{q:?}");
|
||||
for (lines, v) in [(&t.xs, q.x), (&t.ys, q.y)] {
|
||||
let mut near: Vec<f64> = lines_near(lines, v, 6.0).iter().map(|l| l.0).collect();
|
||||
let mut all: Vec<f64> = lines.iter().map(|l| l.0).filter(|w| (w - v).abs() <= 6.0).collect();
|
||||
near.sort_by(f64::total_cmp);
|
||||
all.sort_by(f64::total_cmp);
|
||||
assert_eq!(near, all);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing tool's snapper reuses its targets while hovering over the same document state, and
|
||||
/// gathers them again when it changes, and on every press.
|
||||
#[test]
|
||||
fn draw_snap_gathers_once_per_document_state() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let empty = vectorcraft_doc::Document::new(500.0, 500.0);
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let at = Point::new(202.0, 198.0);
|
||||
let mut snap = DrawSnap::default();
|
||||
let c = ToolContext { revision: (1, 1), ..cx(&d, &s, &p) };
|
||||
assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0));
|
||||
assert_eq!(labels(snap.guides()), ["anchor"]);
|
||||
// The same revision: the targets are the ones gathered (the document isn't read again).
|
||||
let c = ToolContext { revision: (1, 1), ..cx(&empty, &s, &p) };
|
||||
assert_eq!(snap.hover(&c, at, &[], None), Point::new(200.0, 200.0));
|
||||
// A new revision, or a press, reads it.
|
||||
let c = ToolContext { revision: (1, 2), ..cx(&empty, &s, &p) };
|
||||
assert_eq!(snap.hover(&c, at, &[], None), at);
|
||||
assert!(snap.guides().is_empty());
|
||||
let c = ToolContext { revision: (1, 2), ..cx(&d, &s, &p) };
|
||||
assert_eq!(snap.press(&c, at, &[], None), Point::new(200.0, 200.0));
|
||||
// The drag keeps what the press gathered, though the document now shows its preview.
|
||||
let c = ToolContext { revision: (1, 3), ..cx(&empty, &s, &p) };
|
||||
assert_eq!(snap.drag(&c, at, None), Point::new(200.0, 200.0));
|
||||
snap.clear();
|
||||
assert!(snap.guides().is_empty());
|
||||
// Snap to Grid wins over Smart Guides, Shift still keeping the angle.
|
||||
let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) };
|
||||
let from = Leave::segment(&c, Point::ZERO, true);
|
||||
let q = snap.press(&c, Point::new(31.0, 2.0), &[], Some(&from));
|
||||
assert!((q.y - 0.0).abs() < 1e-9, "{q:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,10 @@ impl ScreenFrame {
|
||||
/// Read-only context a tool sees.
|
||||
pub struct ToolContext<'a> {
|
||||
pub doc: &'a Document,
|
||||
/// Which open document `doc` is (its process-unique id) and its revision, which moves with
|
||||
/// every change of the document or the selection: what a tool keeps between pointer events
|
||||
/// for the document as it is (the snap targets of hovering) is keyed on it.
|
||||
pub revision: (u64, u64),
|
||||
pub selection: &'a Selection,
|
||||
/// Screen pixels per document point.
|
||||
pub zoom: f64,
|
||||
@@ -319,6 +323,10 @@ pub struct ToolContext<'a> {
|
||||
/// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the
|
||||
/// pointer, a dragged edge or a drawn point.
|
||||
pub snapping_tolerance: f64,
|
||||
/// Smart Guides → Construction Guides: the angles (degrees, counter-clockwise from the x axis
|
||||
/// as seen on the page) of the lines through the point a drawn point leaves from, onto which a
|
||||
/// point near one lands ([`guides::construction_angles`]); none with the option off.
|
||||
pub construction_angles: &'static [f64],
|
||||
/// The document window (none headless): screen-fixed widgets sit in it.
|
||||
pub screen: Option<ScreenFrame>,
|
||||
/// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden.
|
||||
@@ -631,6 +639,7 @@ pub(crate) mod testutil {
|
||||
pub fn cx<'a>(d: &'a Document, s: &'a Selection, p: &'a PaintDefaults) -> ToolContext<'a> {
|
||||
ToolContext {
|
||||
doc: d,
|
||||
revision: (0, 0),
|
||||
selection: s,
|
||||
zoom: 1.0,
|
||||
isolation: None,
|
||||
@@ -675,6 +684,7 @@ pub(crate) mod testutil {
|
||||
measurement_labels: true,
|
||||
transform_tools_guides: true,
|
||||
snapping_tolerance: 4.0,
|
||||
construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES),
|
||||
screen: None,
|
||||
plane_widget: Some(Default::default()),
|
||||
}
|
||||
|
||||
+59
-17
@@ -16,6 +16,10 @@
|
||||
//! segment of a selected path adds an anchor there and a click on one of its anchors deletes it
|
||||
//! (Shift held or General → Disable Auto Add/Delete starts a new path instead). On a selected
|
||||
//! blend's spine a click adds a point (on a point no key object sits on: deletes it).
|
||||
//! Each anchor placed snaps to Smart Guides ([`DrawSnap`]): onto the anchors, centres and paths of
|
||||
//! the other art and of the path being drawn, into line with them, onto the construction guides
|
||||
//! through the last anchor; with Shift held it slides along its 45° step into line. Hovering shows
|
||||
//! where the next one would go.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
@@ -23,6 +27,7 @@ use vectorcraft_geom::{BezPath, Point};
|
||||
|
||||
use crate::direct::hit_handle;
|
||||
use crate::draw2::AnchorTool;
|
||||
use crate::guides::{DrawSnap, Leave};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -43,6 +48,8 @@ pub struct PenTool {
|
||||
handle: Option<(NodeId, usize, usize, Point)>,
|
||||
/// The Anchor Point tool, while an Alt gesture on a selected path's handle or anchor lasts.
|
||||
convert: Option<AnchorTool>,
|
||||
/// Smart Guides for the anchors placed (and the pointer hovering).
|
||||
snap: DrawSnap,
|
||||
}
|
||||
|
||||
/// An anchor being placed, as the press decided: where it is, the path it goes on (none: the
|
||||
@@ -116,8 +123,6 @@ impl Tool for PenTool {
|
||||
self.drag.is_some() || self.handle.is_some() || self.convert.is_some()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let exclude: Vec<vectorcraft_doc::NodeId> = if self.drawing { cx.selection.objects.clone() } else { vec![] };
|
||||
let (mut p, _) = if matches!(ev.kind, PointerKind::Down) { crate::guides::snap_draw(cx, ev.pos, &exclude) } else { (ev.pos, vec![]) };
|
||||
let tol = cx.tol(5.0);
|
||||
// The path a press began is the selected one by the next event.
|
||||
if self.drawing && self.path.is_none() {
|
||||
@@ -127,6 +132,14 @@ impl Tool for PenTool {
|
||||
if self.drawing && active.is_none() && ev.kind == PointerKind::Down {
|
||||
self.stop();
|
||||
}
|
||||
// The path being drawn moves its bounds as it grows: only its anchors and segments pull.
|
||||
let drawn = active.map(|a| a.0);
|
||||
let from = active.map(|(_, _, last, _)| Leave::segment(cx, last, ev.mods.shift));
|
||||
let p = match ev.kind {
|
||||
PointerKind::Move => self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref()),
|
||||
PointerKind::Down => self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()),
|
||||
_ => ev.pos,
|
||||
};
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.hover = Some(p);
|
||||
@@ -135,9 +148,6 @@ impl Tool for PenTool {
|
||||
PointerKind::Down => {
|
||||
self.last = ev.pos;
|
||||
if let Some((id, first, last, _)) = active {
|
||||
if ev.mods.shift {
|
||||
p = last + vectorcraft_geom::constrain_angle(p - last, 45.0);
|
||||
}
|
||||
let end = last_anchor(cx, id);
|
||||
// A one-anchor path doesn't close on itself: its anchor is the last one too.
|
||||
if p.distance(first) <= tol && end.is_some_and(|(_, ai)| ai > 0) {
|
||||
@@ -230,6 +240,7 @@ impl Tool for PenTool {
|
||||
)]
|
||||
}
|
||||
PointerKind::Up => {
|
||||
self.snap.clear();
|
||||
if let Some(mut t) = self.convert.take() {
|
||||
return t.pointer(cx, ev);
|
||||
}
|
||||
@@ -251,6 +262,7 @@ impl Tool for PenTool {
|
||||
self.stop();
|
||||
self.drag = None;
|
||||
self.handle = None;
|
||||
self.snap.clear();
|
||||
// An Alt gesture under way ends as it stands.
|
||||
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
|
||||
}
|
||||
@@ -259,25 +271,30 @@ impl Tool for PenTool {
|
||||
}
|
||||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
self.stop();
|
||||
self.snap.clear();
|
||||
self.convert.take().map(|mut t| t.deactivate(cx)).unwrap_or_default()
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if let Some(t) = &self.convert {
|
||||
return t.overlays(cx);
|
||||
}
|
||||
if !cx.pen_rubber_band || self.drag.is_some() || self.handle.is_some() {
|
||||
return vec![];
|
||||
let mut o = vec![];
|
||||
if cx.pen_rubber_band
|
||||
&& self.drag.is_none()
|
||||
&& self.handle.is_none()
|
||||
&& let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover)
|
||||
{
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(last);
|
||||
if out.distance(last) > 1e-9 {
|
||||
bp.quad_to(out, h);
|
||||
} else {
|
||||
bp.line_to(h);
|
||||
}
|
||||
o.push(Overlay::Path { path: bp, color: cx.doc.layer_color(id), width: 1.0, dashed: false });
|
||||
}
|
||||
let (Some((id, _, last, out)), Some(h)) = (self.active(cx), self.hover) else { return vec![] };
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(last);
|
||||
if out.distance(last) > 1e-9 {
|
||||
bp.quad_to(out, h);
|
||||
} else {
|
||||
bp.line_to(h);
|
||||
}
|
||||
let c = cx.doc.layer_color(id);
|
||||
vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }]
|
||||
o.extend_from_slice(self.snap.guides());
|
||||
o
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
|
||||
let tol = cx.tol(5.0);
|
||||
@@ -614,6 +631,31 @@ mod tests {
|
||||
assert!(matches!(&a[..], [Action::Begin(_), Action::Preview(c, _)] if c == "path.create"), "{a:?}");
|
||||
}
|
||||
|
||||
/// Each anchor snaps to Smart Guides (#506): hovering shows where it goes (the rubber band ends
|
||||
/// there), in line with another object's centre; Shift slides it along its 45° step into line.
|
||||
#[test]
|
||||
fn anchors_snap_to_smart_guides() {
|
||||
let (d, id, s) = drawing(vectorcraft_geom::SubPath::polyline(&[Point::new(10.0, 300.0), Point::new(60.0, 300.0)], false));
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = PenTool { drawing: true, ..PenTool::default() };
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 152.0, 318.0));
|
||||
let o = t.overlays(&cx);
|
||||
assert!(o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 318.0))), "{o:?}");
|
||||
assert!(o.iter().any(
|
||||
|o| matches!(o, Overlay::Path { path, .. } if path.elements().last().and_then(|e| e.end_point()) == Some(Point::new(150.0, 318.0)))
|
||||
));
|
||||
let a = click(&mut t, &cx, 152.0, 318.0, Mods::default());
|
||||
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 318.0})));
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let a = click(&mut t, &cx, 152.0, 310.0, shift);
|
||||
assert_eq!(a[1], Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": 150.0, "y": 300.0})));
|
||||
// The first anchor of a new path lands on the rect's corner.
|
||||
let mut t = PenTool::default();
|
||||
let a = click(&mut t, &cx, 102.0, 99.0, Mods::default());
|
||||
assert_eq!(a[1], Action::Preview("path.create".into(), json!({"anchors": [{"x": 100.0, "y": 100.0}]})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_over_a_shown_handle_or_an_anchor_converts() {
|
||||
// The middle anchor is smooth: handles at (150, 100) and (250, 100) round (200, 100).
|
||||
|
||||
+69
-23
@@ -2,11 +2,13 @@
|
||||
//!
|
||||
//! Drag draws (Shift constrains to square/circle/45°, Alt draws from the centre, Space held moves
|
||||
//! the shape being drawn); a click without a drag asks the UI for the size dialog (like
|
||||
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count.
|
||||
//! Illustrator). ↑/↓ during a polygon/star drag change the side/point count. The start point and
|
||||
//! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::guides::{DrawSnap, Leave, square};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
pub struct ShapeTool {
|
||||
@@ -20,7 +22,7 @@ pub struct ShapeTool {
|
||||
pub corner_radius: f64,
|
||||
/// Star inner radius as a fraction of the outer radius.
|
||||
pub star_ratio: f64,
|
||||
guides: Vec<Overlay>,
|
||||
snap: DrawSnap,
|
||||
}
|
||||
|
||||
impl ShapeTool {
|
||||
@@ -43,7 +45,7 @@ impl ShapeTool {
|
||||
points: 5,
|
||||
corner_radius: 12.0,
|
||||
star_ratio: 0.5,
|
||||
guides: vec![],
|
||||
snap: DrawSnap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +60,18 @@ impl ShapeTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The command for the current drag.
|
||||
/// How the dragged point keeps to the start: a polygon's or star's radius goes anywhere, a
|
||||
/// line is a segment (Shift: 45° steps), the rest are boxes (Shift: a square).
|
||||
fn leave(&self, cx: &ToolContext, start: Point, m: Mods) -> Option<Leave> {
|
||||
match self.id {
|
||||
"polygon" | "star" => None,
|
||||
"lineSegment" => Some(Leave::segment(cx, start, m.shift)),
|
||||
_ => Some(Leave::diagonal(start, m.shift)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The command for a drag from `start` to `p` (snapped: a line's end already keeps Shift's
|
||||
/// angle).
|
||||
pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) {
|
||||
match self.id {
|
||||
"polygon" | "star" => {
|
||||
@@ -76,7 +89,6 @@ impl ShapeTool {
|
||||
}
|
||||
"lineSegment" => {
|
||||
let (a, b) = if m.alt { (start - (p - start), p) } else { (start, p) };
|
||||
let b = if m.shift { a + vectorcraft_geom::constrain_angle(b - a, 45.0) } else { b };
|
||||
("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))
|
||||
}
|
||||
_ => {
|
||||
@@ -104,11 +116,7 @@ pub(crate) fn space_moves(start: &mut Point, last: &mut Point, ev: &PointerEvent
|
||||
/// The rectangle a drag from `start` to `p` draws: Shift makes it a square, Alt draws it from its
|
||||
/// centre.
|
||||
pub(crate) fn drag_rect(start: Point, p: Point, m: Mods) -> Rect {
|
||||
let mut d = p - start;
|
||||
if m.shift {
|
||||
let s = d.x.abs().max(d.y.abs());
|
||||
d = vectorcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum());
|
||||
}
|
||||
let d = if m.shift { square(p - start) } else { p - start };
|
||||
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
|
||||
}
|
||||
|
||||
@@ -121,8 +129,12 @@ impl Tool for ShapeTool {
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
self.snap.hover(cx, ev.pos, &[], None);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
|
||||
let p = self.snap.press(cx, ev.pos, &[], None);
|
||||
self.start = Some(p);
|
||||
self.last = ev.pos;
|
||||
self.began = false;
|
||||
@@ -130,8 +142,7 @@ impl Tool for ShapeTool {
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some(mut s) = self.start else { return vec![] };
|
||||
let (pos, g) = crate::guides::snap_draw(cx, ev.pos, &[]);
|
||||
self.guides = g;
|
||||
let pos = self.snap.drag(cx, ev.pos, self.leave(cx, s, ev.mods).as_ref());
|
||||
let ev = &PointerEvent { pos, ..*ev };
|
||||
space_moves(&mut s, &mut self.last, ev, self.began);
|
||||
self.start = Some(s);
|
||||
@@ -150,7 +161,7 @@ impl Tool for ShapeTool {
|
||||
}
|
||||
PointerKind::Up => {
|
||||
let Some(s) = self.start.take() else { return vec![] };
|
||||
self.guides.clear();
|
||||
self.snap.clear();
|
||||
if self.began {
|
||||
self.began = false;
|
||||
vec![Action::Commit]
|
||||
@@ -196,23 +207,26 @@ impl Tool for ShapeTool {
|
||||
} else if key == ToolKey::Escape {
|
||||
self.start = None;
|
||||
self.began = false;
|
||||
self.snap.clear();
|
||||
vec![Action::Cancel]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
match self.start {
|
||||
Some(s) if self.began => {
|
||||
let d = self.last - s;
|
||||
let mut o = self.guides.clone();
|
||||
if cx.measurement_labels {
|
||||
o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) });
|
||||
let mut o = self.snap.guides().to_vec();
|
||||
if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) {
|
||||
// A box's size as drawn (from its centre with Alt, square with Shift).
|
||||
let (w, h) = match self.id {
|
||||
"polygon" | "star" | "lineSegment" => ((self.last - s).x.abs(), (self.last - s).y.abs()),
|
||||
_ => {
|
||||
let r = drag_rect(s, self.last, self.mods);
|
||||
(r.width(), r.height())
|
||||
}
|
||||
o
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
o.push(Overlay::Measure { p: self.last, text: cx.size_label(w, h) });
|
||||
}
|
||||
o
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Crosshair
|
||||
@@ -310,4 +324,36 @@ mod tests {
|
||||
let a = t.key(&cx, ToolKey::Up, Mods::default());
|
||||
assert!(matches!(&a[0], Action::Preview(_, v) if v["sides"] == 7));
|
||||
}
|
||||
|
||||
/// The start and the dragged corner land on another object's anchors (#506), hovering shows
|
||||
/// it before the press, and the measurement label gives the size drawn.
|
||||
#[test]
|
||||
fn corners_snap_to_smart_guides() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut t = ShapeTool::new("rectangle");
|
||||
let anchor = |o: &[Overlay], at: Point| o.iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == at));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Move, 98.0, 102.0));
|
||||
assert!(anchor(&t.overlays(&cx), Point::new(100.0, 100.0)));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 98.0, 102.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0));
|
||||
assert_eq!(a[1], Action::Preview("shape.rectangle".into(), json!({"x": 100.0, "y": 100.0, "width": 100.0, "height": 100.0})));
|
||||
let o = t.overlays(&cx);
|
||||
assert!(anchor(&o, Point::new(200.0, 200.0)), "{o:?}");
|
||||
assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(100.0, 100.0))));
|
||||
// Alt draws from the centre: the label gives the whole size.
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 198.0, 202.0).with_mods(alt));
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(200.0, 200.0))));
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 198.0, 202.0));
|
||||
assert!(t.overlays(&cx).is_empty());
|
||||
// A line with Shift keeps to 45° steps, sliding into line with the rect's right side.
|
||||
let mut t = ShapeTool::new("lineSegment");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0));
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 203.0, 302.0).with_mods(shift));
|
||||
assert!(matches!(&a[..], [_, Action::Preview(_, v)] if v["x2"] == 200.0 && (v["y2"].as_f64().unwrap() - 300.0).abs() < 1e-9), "{a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+24
-12
@@ -3,7 +3,8 @@
|
||||
//! Type: click places point type, drag draws an area-type frame, clicking into existing text
|
||||
//! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a
|
||||
//! baseline (`text.createInPath`). Point type placed by a click and left empty is discarded when
|
||||
//! editing ends (`text.discardEmpty`); frames keep their shape.
|
||||
//! editing ends (`text.discardEmpty`); frames keep their shape. Where new type goes (the click, the
|
||||
//! frame's corners) snaps to Smart Guides ([`DrawSnap`]), hovering too.
|
||||
//!
|
||||
//! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends
|
||||
//! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a
|
||||
@@ -27,6 +28,7 @@ use vectorcraft_doc::{NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::{BezPath, Point, Rect, Shape};
|
||||
use vectorcraft_text::{FontDb, TextLayout, edit};
|
||||
|
||||
use crate::guides::DrawSnap;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// Which of the Type tools this is.
|
||||
@@ -68,8 +70,12 @@ pub struct TypeTool {
|
||||
goal_x: Option<f64>,
|
||||
typing: Option<Typing>,
|
||||
preedit: Option<Preedit>,
|
||||
press: Option<Point>,
|
||||
/// Where the button went down, and where new type goes from there (snapped to Smart Guides).
|
||||
press: Option<(Point, Point)>,
|
||||
/// The other corner of an area dragged out (snapped).
|
||||
drag: Option<Point>,
|
||||
/// Smart Guides for where new type goes.
|
||||
snap: DrawSnap,
|
||||
/// The press landed in the edited text: dragging selects.
|
||||
selecting: bool,
|
||||
/// Click counting for double/triple click (position of the last click, count).
|
||||
@@ -252,7 +258,8 @@ impl TypeTool {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec<Action> {
|
||||
/// The button released after a press at `start` (new type goes at `at`).
|
||||
fn on_up(&mut self, cx: &ToolContext, start: Point, at: Point) -> Vec<Action> {
|
||||
// Click into existing text: place the caret.
|
||||
if let Some(out) = self.edit_at(cx, start) {
|
||||
return out;
|
||||
@@ -272,10 +279,10 @@ impl TypeTool {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
|
||||
let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0));
|
||||
let mut params = match area {
|
||||
Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}),
|
||||
None => json!({"x": start.x, "y": start.y, "text": ""}),
|
||||
None => json!({"x": at.x, "y": at.y, "text": ""}),
|
||||
};
|
||||
params["vertical"] = json!(self.vertical);
|
||||
params["placeholder"] = json!(cx.placeholder_text);
|
||||
@@ -386,7 +393,7 @@ impl Tool for TypeTool {
|
||||
self.selecting = true;
|
||||
return out;
|
||||
}
|
||||
self.press = Some(ev.pos);
|
||||
self.press = Some((ev.pos, self.snap.press(cx, ev.pos, self.editing.as_slice(), None)));
|
||||
self.drag = None;
|
||||
vec![]
|
||||
}
|
||||
@@ -398,7 +405,7 @@ impl Tool for TypeTool {
|
||||
self.clicks = (None, 0);
|
||||
}
|
||||
} else if self.press.is_some() {
|
||||
self.drag = Some(ev.pos);
|
||||
self.drag = Some(self.snap.drag(cx, ev.pos, None));
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
@@ -407,8 +414,9 @@ impl Tool for TypeTool {
|
||||
self.selecting = false;
|
||||
return vec![];
|
||||
}
|
||||
let Some(start) = self.press.take() else { return vec![] };
|
||||
self.on_up(cx, start)
|
||||
self.snap.clear();
|
||||
let Some((start, at)) = self.press.take() else { return vec![] };
|
||||
self.on_up(cx, start, at)
|
||||
}
|
||||
PointerKind::DoubleClick => {
|
||||
if self.editing.is_some() && self.hit_edited(cx, ev.pos).is_some() {
|
||||
@@ -417,7 +425,11 @@ impl Tool for TypeTool {
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Move => vec![],
|
||||
PointerKind::Move => {
|
||||
// The type being edited is no target: its guides would only point at itself.
|
||||
self.snap.hover(cx, ev.pos, self.editing.as_slice(), None);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
fn text_input(&mut self, cx: &ToolContext, s: &str) -> Vec<Action> {
|
||||
@@ -700,8 +712,8 @@ impl Tool for TypeTool {
|
||||
}
|
||||
}
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = vec![];
|
||||
if let (Some(s), Some(d)) = (self.press, self.drag) {
|
||||
let mut o = self.snap.guides().to_vec();
|
||||
if let (Some((_, s)), Some(d)) = (self.press, self.drag) {
|
||||
o.push(Overlay::Marquee(Rect::from_points(s, d)));
|
||||
}
|
||||
// Overflow markers on selected text.
|
||||
|
||||
@@ -335,3 +335,24 @@ fn hebrew_typing_and_visual_arrows_keep_logical_text() {
|
||||
let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() };
|
||||
assert_eq!(t.plain_text(), "שלום");
|
||||
}
|
||||
|
||||
/// Where new type goes snaps to Smart Guides (#506): a click beside a rect lines up with its
|
||||
/// centre, and hovering shows it first; an area's corners land on its anchors.
|
||||
#[test]
|
||||
fn new_type_snaps_to_smart_guides() {
|
||||
let (d, _) = crate::testutil::doc_with_rect();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let c = cx(&d, &sel, &p);
|
||||
let mut tool = TypeTool::new("type");
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Move, 151.0, 330.0));
|
||||
assert!(tool.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "align" && *p == Point::new(150.0, 330.0))));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 151.0, 330.0));
|
||||
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 151.0, 330.0));
|
||||
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 150.0 && v["y"] == 330.0)), "{acts:?}");
|
||||
let mut tool = TypeTool::new("type");
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Down, 102.0, 98.0));
|
||||
tool.pointer(&c, &PointerEvent::new(PointerKind::Drag, 199.0, 202.0));
|
||||
let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 199.0, 202.0));
|
||||
let area = json!({"width": 100.0, "height": 100.0});
|
||||
assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 100.0 && v["area"] == area)), "{acts:?}");
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
//! the Selection tool. Moving and resizing snap like drawing
|
||||
//! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and
|
||||
//! objects (never to the dragged artboard or the art moving with it); the artboard's own bleed edges
|
||||
//! snap too.
|
||||
//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
|
||||
use super::{BLUE, polygon, rect_corners};
|
||||
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
|
||||
use crate::guides::Targets;
|
||||
use crate::guides::{DrawSnap, Leave, Targets};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -57,11 +57,13 @@ pub struct ArtboardTool {
|
||||
copy_targets: Option<Targets>,
|
||||
/// Smart Guides shown while dragging.
|
||||
guides: Vec<Overlay>,
|
||||
/// Smart Guides for the corners of a new artboard.
|
||||
draw: DrawSnap,
|
||||
}
|
||||
|
||||
impl Default for ArtboardTool {
|
||||
fn default() -> Self {
|
||||
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![] }
|
||||
Self { active: 0, move_art: true, drag: None, preview: None, targets: None, copy_targets: None, guides: vec![], draw: DrawSnap::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +136,7 @@ impl ArtboardTool {
|
||||
self.targets = None;
|
||||
self.copy_targets = None;
|
||||
self.guides.clear();
|
||||
self.draw.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +174,7 @@ impl Tool for ArtboardTool {
|
||||
self.active = i;
|
||||
self.drag = Some(Drag::Move { index: i, start: p, rect: a.rect, began: false, copy: false });
|
||||
} else {
|
||||
let (p, _) = crate::guides::snap_draw(cx, p, &[]);
|
||||
let p = self.draw.press(cx, p, &[], None);
|
||||
self.drag = Some(Drag::Create { start: p, cur: p });
|
||||
}
|
||||
vec![]
|
||||
@@ -213,12 +216,7 @@ impl Tool for ArtboardTool {
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, Some(Drag::Create { start, .. })) => {
|
||||
let (mut cur, _) = crate::guides::snap_draw(cx, p, &[]);
|
||||
if m.shift {
|
||||
let d = cur - start;
|
||||
let s = d.x.abs().max(d.y.abs());
|
||||
cur = start + Vec2::new(s.copysign(d.x), s.copysign(d.y));
|
||||
}
|
||||
let cur = self.draw.drag(cx, p, Some(&Leave::diagonal(start, m.shift)));
|
||||
self.drag = Some(Drag::Create { start, cur });
|
||||
vec![]
|
||||
}
|
||||
@@ -289,6 +287,7 @@ impl Tool for ArtboardTool {
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = vec![];
|
||||
if let Some(Drag::Create { start, cur }) = self.drag {
|
||||
o.extend_from_slice(self.draw.guides());
|
||||
let r = Rect::from_points(start, cur);
|
||||
o.push(Overlay::Marquee(r));
|
||||
if cx.measurement_labels {
|
||||
|
||||
+27
-3
@@ -1226,8 +1226,8 @@ off, an empty window, and the Home button or `app.home` still shows the screen)
|
||||
default: on, a click away from a panel popped out of the icon column puts it away).
|
||||
|
||||
The Smart Guides preferences (Preferences › Smart Guides) apply to `pointer_gesture` with Smart Guides on (the
|
||||
default view) and to the mouse; they change what the tools show and how far a target pulls, never where a snapped
|
||||
point lands:
|
||||
default view) and to the mouse; they change what the tools show and how far a target pulls, and only Construction
|
||||
Guides change where a snapped point lands:
|
||||
|
||||
- `smartGuideColor` (`#ff3dfc` by default): the colour of the smart guides' lines and labels.
|
||||
- `alignmentGuides` (on by default): off, no line is drawn along the edge or centre the art lines up with; the art
|
||||
@@ -1242,6 +1242,11 @@ point lands:
|
||||
- `snappingTolerance` (0–40 px, 4 by default): how near an anchor, edge, centre or artboard edge pulls a drawn point,
|
||||
a dragged selection, a bounding-box handle, a ruler guide or an artboard. With Smart Guides off, Snap to Point uses
|
||||
`snapToPointTolerance` instead.
|
||||
- `constructionGuides` (on by default) and `constructionAngles` (`90° & 45° Angles` by default; also `90° Angles`,
|
||||
`45° Angles`, `60° Angles`, `30° Angles`, `90° & 45° & 30° Angles`): a point drawn near a line at one of those
|
||||
angles through the point it leaves from (the Pen's or Curvature tool's last anchor, a line's start) lands on that
|
||||
line, where a target's alignment line crosses it if one does nearby. "90°" gives the horizontal and the vertical,
|
||||
"45°" the diagonals, "60°" and "30°" the multiples of those angles in between. Off, the point only lines up.
|
||||
|
||||
With Smart Guides on, a bounding-box handle dragged with the Selection or Free Transform tool (`mods.shift`
|
||||
proportional, `mods.alt` from the centre) lands on another object's anchor or centre (labelled "anchor" or
|
||||
@@ -1253,7 +1258,26 @@ snap the pointer as drawing tools do, so dragging a corner onto another object's
|
||||
{"name":"pointer_gesture","arguments":{"tool":"scale","events":[{"kind":"down","x":100,"y":100},{"kind":"up","x":100,"y":100},{"kind":"down","x":199,"y":198},{"kind":"drag","x":302,"y":262},{"kind":"up","x":302,"y":262}]}}
|
||||
```
|
||||
|
||||
Not read yet: Construction Guides and their Angles, and Spacing Guides (the tools draw neither).
|
||||
Every point a drawing tool places snaps the same way, through one snapper (#506): the Pen's and Curvature tool's
|
||||
anchors, the Pencil's first and last point, the shape tools' start and dragged corner or radius (`rectangle`,
|
||||
`roundedRectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, `arc`, `spiral`, `rectangularGrid`, `polarGrid`), the
|
||||
Type tool's click and frame, and a new artboard's corners. The point lands on another object's anchor, centre or
|
||||
path ("anchor", "center", "path"), else lines up with the anchors, edges and centres of the art and the artboards (a
|
||||
line from the target and "align"); the Pen's lands on the path being drawn too, so a click on its first anchor closes
|
||||
it exactly. With `mods.shift` the point keeps to its 45° step from the anchor before it (from `constrainAngle`; a box
|
||||
dragged keeps its corner on a diagonal) and slides along that way into line with the nearest target. A `move` event
|
||||
before the press shows where it would go, as hovering does with the mouse. The targets are the art in the window (all
|
||||
of it headless), gathered once per document state; the shape being drawn is never one of them:
|
||||
|
||||
```json
|
||||
{"name":"pointer_gesture","arguments":{"tool":"rectangle","events":[{"kind":"down","x":202,"y":203},{"kind":"drag","x":330,"y":341},{"kind":"up","x":330,"y":341}]}}
|
||||
{"name":"pointer_gesture","arguments":{"tool":"pen","events":[{"kind":"down","x":300,"y":420},{"kind":"up","x":300,"y":420},{"kind":"down","x":151.5,"y":431},{"kind":"up","x":151.5,"y":431}]}}
|
||||
```
|
||||
|
||||
With a 100 × 100 square at (100, 100), the first starts the rectangle on its corner (200, 200); the second puts the
|
||||
Pen's second anchor at (150, 431), in line with the square's centre.
|
||||
|
||||
Not read yet: Spacing Guides (the tools draw none).
|
||||
|
||||
## Type preferences
|
||||
|
||||
|
||||
Reference in New Issue
Block a user