M4: vector edit transform box and point snapping (#64)

- Transform box around 2+ selected points (Move tool): corners/edges scale
  them (Shift keeps proportions, Alt from the centre), just outside a corner
  rotates them (Shift: 15° steps, the box turns with them), dragging inside
  moves them; one undo step each. Corners win over points, points over edges,
  edges over segments.
- Commands: vector.edit.bounds, vector.edit.resize, vector.edit.rotate,
  vector.edit.transform; object.flipH/V and the 90°/180° rotations act on the
  selected points while editing (unless ids are given).
- Design panel in edit mode: W/H for the selected points next to X/Y (the
  layer's dimensions are hidden while points are selected).
- Point snapping for dragged points and handles and both pens: points and
  segment midpoints of this and other vectors, the usual layer targets and
  the pixel grid, with guides and a marker on the point landed on; Ctrl/Cmd
  turns object snapping off. Snap code generalised in snap.rs (Targets spans).
- Fix: handle/bend drags read positions in the gesture snapshot's coordinates,
  so handles no longer lag when the preview re-fits the vector.
- Shared resize math (tools::resized_box) and handle hit-testing for layer
  and point boxes; canvas draws both boxes through one helper.
- Tests in vectoredit/transform_tests.rs (incl. junk sweep for the
  edit-aware commands).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 10:44:12 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 5f54fecca2
commit 8dff0089e5
16 changed files with 1245 additions and 238 deletions
+21 -5
View File
@@ -27,11 +27,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("arrange.forward", "Bring forward", "{ids?}", true, |s, p| zorder(s, p, Z::Forward)),
spec!("arrange.backward", "Send backward", "{ids?}", true, |s, p| zorder(s, p, Z::Backward)),
spec!("arrange.back", "Send to back", "{ids?}", true, |s, p| zorder(s, p, Z::Back)),
spec!("object.flipH", "Flip horizontal", "{ids?}", true, |s, p| flip(s, p, true)),
spec!("object.flipV", "Flip vertical", "{ids?}", true, |s, p| flip(s, p, false)),
spec!("object.rotate180", "Rotate 180°", "{ids?}", true, |s, p| rotate_by(s, p, 180.0)),
spec!("object.rotateLeft", "Rotate 90° left", "{ids?}", true, |s, p| rotate_by(s, p, 90.0)),
spec!("object.rotateRight", "Rotate 90° right", "{ids?}", true, |s, p| rotate_by(s, p, -90.0)),
spec!("object.flipH", "Flip horizontal", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| flip(s, p, true)),
spec!("object.flipV", "Flip vertical", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| flip(s, p, false)),
spec!("object.rotate180", "Rotate 180°", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| rotate_by(
s, p, 180.0
)),
spec!("object.rotateLeft", "Rotate 90° left", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| rotate_by(
s, p, 90.0
)),
spec!("object.rotateRight", "Rotate 90° right", "{ids?} (in vector edit mode without ids: the selected points)", true, |s, p| {
rotate_by(s, p, -90.0)
}),
spec!("transform.move", "Move", "{ids?, dx, dy} (page units)", true, |s, p| {
let (dx, dy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0));
let ids = req_targets("transform.move", s, p)?;
@@ -290,6 +296,13 @@ fn zorder(s: &mut Session, p: &Value, z: Z) -> Result<Value> {
}
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
// Editing a vector with points selected: flip them in their box.
let points = crate::vectoredit::transform::transform_in_edit_mode(s, p, |b| {
if horizontal { b.onto(b.width, 0.0, 0.0, b.height) } else { b.onto(0.0, b.height, b.width, 0.0) }
});
if let Some(r) = points {
return r;
}
let ids = req_targets("flip", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
@@ -304,6 +317,9 @@ fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
}
fn rotate_by(s: &mut Session, p: &Value, deg: f64) -> Result<Value> {
if let Some(r) = crate::vectoredit::transform::transform_in_edit_mode(s, p, |b| figcraft_geom::rotate_about(Affine::IDENTITY, b.center(), deg)) {
return r;
}
let ids = req_targets("rotate", s, p)?;
let doc = s.doc_mut()?;
let ids = roots(doc, &ids);
+82 -55
View File
@@ -1,9 +1,10 @@
//! Snapping and smart guides: while moving or resizing, edges and centres snap to nearby layers'
//! edges/centres, the parent frame, layout grid columns and page guides; the matches come back as
//! guide lines (with spacing labels) for the canvas to draw in red.
//! guide lines (with spacing labels) for the canvas to draw in red. Vector points also snap to
//! other points (see `vectoredit::snap`); landing on one shows a marker (a zero-length guide).
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::Rect;
use figcraft_geom::{Point, Rect, Vec2};
use serde::Serialize;
/// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label
@@ -17,15 +18,21 @@ pub struct Guide {
pub label: Option<String>,
}
/// A line to snap: its position on one axis and the span it covers on the other (x, y0, y1 for a
/// vertical line; y, x0, x1 for a horizontal one).
pub type Span = (f64, f64, f64);
/// Snap candidates gathered once at the start of a gesture.
#[derive(Clone, Debug, Default)]
pub struct Targets {
/// Vertical lines (x) with the y-span of the rect they came from.
pub xs: Vec<(f64, f64, f64)>,
pub xs: Vec<Span>,
/// Horizontal lines (y) with the x-span.
pub ys: Vec<(f64, f64, f64)>,
pub ys: Vec<Span>,
/// Sibling boxes (for spacing labels).
pub boxes: Vec<Rect>,
/// Points to land on exactly (see [`Targets::add_point`]).
pub points: Vec<Point>,
}
impl Targets {
@@ -84,14 +91,52 @@ impl Targets {
t
}
/// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes,
/// and a marker when a moving point lands on it.
pub fn add_point(&mut self, p: Point) {
self.xs.push((p.x, p.y, p.y));
self.ys.push((p.y, p.x, p.x));
self.points.push(p);
}
/// Snap a moving box: returns the (dx, dy) correction and guides to draw.
pub fn snap_rect(&self, r: Rect, threshold: f64) -> (f64, f64, Vec<Guide>) {
let xs = [r.x0, r.center().x, r.x1];
let ys = [r.y0, r.center().y, r.y1];
let best = |cands: &[(f64, f64, f64)], vals: &[f64]| -> Option<f64> {
let (xs, ys) = rect_spans(r);
let (dx, dy, guides) = self.snap_spans(&xs, &ys, threshold);
(dx.unwrap_or(0.0), dy.unwrap_or(0.0), guides)
}
/// Snap a single point (resize handles, drawing).
pub fn snap_point(&self, x: f64, y: f64, threshold: f64) -> (f64, f64, Vec<Guide>) {
let (dx, dy, guides) = self.snap_spans(&[(x, y, y)], &[(y, x, x)], threshold);
(dx.unwrap_or(0.0), dy.unwrap_or(0.0), guides)
}
/// Snap several moving points together (vector points): the correction on each axis (`None`
/// where nothing was in reach), the guides, and a marker (a zero-length guide) on every target
/// point a moving point lands on.
pub fn snap_points(&self, pts: &[Point], threshold: f64) -> (Option<f64>, Option<f64>, Vec<Guide>) {
let xs: Vec<Span> = pts.iter().map(|p| (p.x, p.y, p.y)).collect();
let ys: Vec<Span> = pts.iter().map(|p| (p.y, p.x, p.x)).collect();
let (dx, dy, mut guides) = self.snap_spans(&xs, &ys, threshold);
let d = Vec2::new(dx.unwrap_or(0.0), dy.unwrap_or(0.0));
for p in pts.iter().map(|p| *p + d) {
let marker = Guide { x0: p.x, y0: p.y, x1: p.x, y1: p.y, label: None };
if self.points.iter().any(|t| (*t - p).hypot() < 1e-6) && !guides.contains(&marker) {
guides.push(marker);
}
}
(dx, dy, guides)
}
/// Snap moving lines: `xs` are vertical ones (x with the y-span they cover), `ys` horizontal
/// ones. Returns the nearest correction on each axis within `threshold` and the guides for the
/// lines that coincide with targets afterwards.
pub fn snap_spans(&self, xs: &[Span], ys: &[Span], threshold: f64) -> (Option<f64>, Option<f64>, Vec<Guide>) {
let best = |cands: &[Span], vals: &[Span]| -> Option<f64> {
let mut best: Option<f64> = None;
for (line, _, _) in cands {
for v in vals {
for (v, _, _) in vals {
let d = line - v;
if d.abs() <= threshold && best.is_none_or(|b| d.abs() < b.abs()) {
best = Some(d);
@@ -100,65 +145,47 @@ impl Targets {
}
best
};
let dx = best(&self.xs, &xs).unwrap_or(0.0);
let dy = best(&self.ys, &ys).unwrap_or(0.0);
let snapped = Rect::new(r.x0 + dx, r.y0 + dy, r.x1 + dx, r.y1 + dy);
let guides = self.guides_for(snapped);
(dx, dy, guides)
}
/// Snap a single point (resize handles, drawing).
pub fn snap_point(&self, x: f64, y: f64, threshold: f64) -> (f64, f64, Vec<Guide>) {
let mut bx: Option<f64> = None;
for (line, _, _) in &self.xs {
let d = line - x;
if d.abs() <= threshold && bx.is_none_or(|b| d.abs() < b.abs()) {
bx = Some(d);
}
}
let mut by: Option<f64> = None;
for (line, _, _) in &self.ys {
let d = line - y;
if d.abs() <= threshold && by.is_none_or(|b| d.abs() < b.abs()) {
by = Some(d);
}
}
let (nx, ny) = (x + bx.unwrap_or(0.0), y + by.unwrap_or(0.0));
let mut g = Vec::new();
if bx.is_some() {
let span = self.xs.iter().filter(|(l, _, _)| (l - nx).abs() < 1e-6).fold((ny, ny), |a, (_, y0, y1)| (a.0.min(*y0), a.1.max(*y1)));
g.push(Guide { x0: nx, y0: span.0, x1: nx, y1: span.1, label: None });
}
if by.is_some() {
let span = self.ys.iter().filter(|(l, _, _)| (l - ny).abs() < 1e-6).fold((nx, nx), |a, (_, x0, x1)| (a.0.min(*x0), a.1.max(*x1)));
g.push(Guide { x0: span.0, y0: ny, x1: span.1, y1: ny, label: None });
}
(bx.unwrap_or(0.0), by.unwrap_or(0.0), g)
let (dx, dy) = (best(&self.xs, xs), best(&self.ys, ys));
let (ox, oy) = (dx.unwrap_or(0.0), dy.unwrap_or(0.0));
let xs: Vec<Span> = xs.iter().map(|(x, a, b)| (x + ox, a + oy, b + oy)).collect();
let ys: Vec<Span> = ys.iter().map(|(y, a, b)| (y + oy, a + ox, b + ox)).collect();
(dx, dy, self.guides_spans(&xs, &ys))
}
/// Guide lines for edges of `r` that coincide with targets.
pub fn guides_for(&self, r: Rect) -> Vec<Guide> {
let (xs, ys) = rect_spans(r);
self.guides_spans(&xs, &ys)
}
/// Guides for moving lines (as in [`Self::snap_spans`]) that coincide with targets, each
/// spanning the line and the targets on it.
fn guides_spans(&self, xs: &[Span], ys: &[Span]) -> Vec<Guide> {
let span = |cands: &[Span], (v, a, b): Span| -> Option<(f64, f64)> {
let hits: Vec<&Span> = cands.iter().filter(|(l, _, _)| (l - v).abs() < 1e-6).collect();
(!hits.is_empty()).then(|| (hits.iter().map(|h| h.1).fold(a, f64::min).max(-1e6), hits.iter().map(|h| h.2).fold(b, f64::max).min(1e6)))
};
let mut out = Vec::new();
for x in [r.x0, r.center().x, r.x1] {
let hits: Vec<&(f64, f64, f64)> = self.xs.iter().filter(|(l, _, _)| (l - x).abs() < 1e-6).collect();
if !hits.is_empty() {
let y0 = hits.iter().map(|h| h.1).fold(r.y0, f64::min).max(-1e6);
let y1 = hits.iter().map(|h| h.2).fold(r.y1, f64::max).min(1e6);
out.push(Guide { x0: x, y0, x1: x, y1, label: None });
for x in xs {
if let Some((y0, y1)) = span(&self.xs, *x) {
out.push(Guide { x0: x.0, y0, x1: x.0, y1, label: None });
}
}
for y in [r.y0, r.center().y, r.y1] {
let hits: Vec<&(f64, f64, f64)> = self.ys.iter().filter(|(l, _, _)| (l - y).abs() < 1e-6).collect();
if !hits.is_empty() {
let x0 = hits.iter().map(|h| h.1).fold(r.x0, f64::min).max(-1e6);
let x1 = hits.iter().map(|h| h.2).fold(r.x1, f64::max).min(1e6);
out.push(Guide { x0, y0: y, x1, y1: y, label: None });
for y in ys {
if let Some((x0, x1)) = span(&self.ys, *y) {
out.push(Guide { x0, y0: y.0, x1, y1: y.0, label: None });
}
}
out
}
}
/// A box's edges and centre lines as moving lines (see [`Targets::snap_spans`]).
fn rect_spans(r: Rect) -> ([Span; 3], [Span; 3]) {
let c = r.center();
([(r.x0, r.y0, r.y1), (c.x, r.y0, r.y1), (r.x1, r.y0, r.y1)], [(r.y0, r.x0, r.x1), (c.y, r.x0, r.x1), (r.y1, r.x0, r.x1)])
}
/// Distance lines from `r` to the nearest box on each side (Alt-hover measuring), or to `other`.
pub fn measure(r: Rect, other: Rect) -> Vec<Guide> {
let mut out = Vec::new();
@@ -166,7 +193,7 @@ pub fn measure(r: Rect, other: Rect) -> Vec<Guide> {
let v = (d * 100.0).round() / 100.0;
if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
};
if other.contains(r.origin()) && other.contains(figcraft_geom::Point::new(r.x1, r.y1)) {
if other.contains(r.origin()) && other.contains(Point::new(r.x1, r.y1)) {
// Inside: distances to each edge of the container.
let cy = r.center().y;
let cx = r.center().x;
+144 -110
View File
@@ -338,6 +338,8 @@ pub struct ToolState {
pub builder_remove: bool,
/// Show distances from the selection on hover without holding Alt (Dev mode inspection).
pub redlines: bool,
/// Pen snap targets, kept while hovering (see `vectoredit::point_snap`).
pub(crate) pen_targets: Option<crate::vectoredit::point_snap::PenTargets>,
}
impl ToolState {
@@ -348,7 +350,8 @@ impl ToolState {
const HANDLE_PX: f64 = 5.0;
const ROTATE_PX: f64 = 18.0;
const SNAP_PX: f64 = 5.0;
/// Snap distance in screen pixels.
pub(crate) const SNAP_PX: f64 = 5.0;
/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl).
pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> {
@@ -402,9 +405,15 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<
return None;
}
let corners = selection_box(doc, sel)?;
let tol = HANDLE_PX / zoom.max(1e-6);
// Lines (zero height) only get end handles.
let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line));
handle_at_corners(&corners, p, zoom, is_line)
}
/// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`;
/// `is_line` keeps only the two end handles.
pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option<Handle> {
let tol = HANDLE_PX / zoom.max(1e-6);
for (i, c) in corners.iter().enumerate() {
if is_line && (i == 3 || i == 2) {
continue;
@@ -438,7 +447,11 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<
}
fn resize_cursor(doc: &Document, sel: &[NodeId], h: Handle) -> Cursor {
let Some(c) = selection_box(doc, sel) else { return Cursor::Default };
selection_box(doc, sel).map_or(Cursor::Default, |c| handle_cursor(&c, h))
}
/// The cursor over handle `h` of the box with corners `c`.
pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor {
let angle = |a: Point, b: Point| (b - a).atan2().to_degrees();
match h {
Handle::Corner(i) => {
@@ -455,6 +468,112 @@ fn resize_cursor(doc: &Document, sel: &[NodeId], h: Handle) -> Cursor {
}
}
/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in
/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre.
/// Edges that cross over come back reversed (x1 < x0 or y1 < y0: a flip).
pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) {
let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0);
match handle {
Handle::Corner(0) => {
x0 = q.x;
y0 = q.y;
}
Handle::Corner(1) => {
x1 = q.x;
y0 = q.y;
}
Handle::Corner(2) => {
x1 = q.x;
y1 = q.y;
}
Handle::Corner(_) => {
x0 = q.x;
y1 = q.y;
}
Handle::Edge(0) => y0 = q.y,
Handle::Edge(1) => x1 = q.x,
Handle::Edge(2) => y1 = q.y,
Handle::Edge(_) => x0 = q.x,
Handle::Rotate(_) => {}
}
if keep && w0 > 1e-9 && h0 > 1e-9 {
let (sx, sy) = ((x1 - x0) / w0, (y1 - y0) / h0);
let k = match handle {
Handle::Edge(0) | Handle::Edge(2) => sy,
Handle::Edge(_) => sx,
_ => {
if sx.abs() > sy.abs() {
sx
} else {
sy
}
}
};
let (nw, nh) = (w0 * k, h0 * k);
match handle {
Handle::Corner(0) => {
x0 = w0 - nw;
y0 = h0 - nh;
}
Handle::Corner(1) => {
x1 = nw;
y0 = h0 - nh;
}
Handle::Corner(2) => {
x1 = nw;
y1 = nh;
}
Handle::Corner(_) => {
x0 = w0 - nw;
y1 = nh;
}
Handle::Edge(0) | Handle::Edge(2) => {
let c = w0 / 2.0;
x0 = c - nw / 2.0;
x1 = c + nw / 2.0;
if matches!(handle, Handle::Edge(0)) {
y0 = h0 - nh;
} else {
y1 = nh;
}
}
Handle::Rotate(_) => {}
Handle::Edge(_) => {
let c = h0 / 2.0;
y0 = c - nh / 2.0;
y1 = c + nh / 2.0;
if matches!(handle, Handle::Edge(3)) {
x0 = w0 - nw;
} else {
x1 = nw;
}
}
}
}
if from_center {
let (cx, cy) = (w0 / 2.0, h0 / 2.0);
let hw = ((x1 - x0) / 2.0).abs().max((cx - x0).abs().max((x1 - cx).abs()));
let hh = ((y1 - y0) / 2.0).abs().max((cy - y0).abs().max((y1 - cy).abs()));
match handle {
Handle::Edge(0) | Handle::Edge(2) => {
y0 = cy - hh;
y1 = cy + hh;
}
Handle::Edge(_) => {
x0 = cx - hw;
x1 = cx + hw;
}
_ => {
x0 = cx - hw;
x1 = cx + hw;
y0 = cy - hh;
y1 = cy + hh;
}
}
}
(x0, y0, x1, y1)
}
impl Session {
pub fn set_tool(&mut self, t: ToolId) {
if self.tool.tool == ToolId::Pen && t != ToolId::Pen {
@@ -558,13 +677,15 @@ impl Session {
}
}
self.tool.overlay.cursor = cursor;
self.tool.overlay.guides = guides;
if tool == ToolId::Pen
&& let Some(pen) = &self.tool.pen
&& let Some((last, _, _)) = pen.points.last()
{
self.tool.overlay.rubber = Some([last.x, last.y, ev.x, ev.y]);
if tool == ToolId::Pen {
// Where a click would land (snapped), with its guides.
let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? };
guides = g;
if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]);
}
}
self.tool.overlay.guides = guides;
let ds = self.ds_mut()?;
if ds.hover != hover {
ds.hover = hover;
@@ -868,30 +989,6 @@ impl Session {
// In the handle box's own axes (one layer: its local space).
let (frame, w0, h0) = r.frame;
let q = frame.inverse() * p;
let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0);
match r.handle {
Handle::Corner(0) => {
x0 = q.x;
y0 = q.y;
}
Handle::Corner(1) => {
x1 = q.x;
y0 = q.y;
}
Handle::Corner(2) => {
x1 = q.x;
y1 = q.y;
}
Handle::Corner(_) => {
x0 = q.x;
y1 = q.y;
}
Handle::Edge(0) => y0 = q.y,
Handle::Edge(1) => x1 = q.x,
Handle::Edge(2) => y1 = q.y,
Handle::Edge(_) => x0 = q.x,
Handle::Rotate(_) => {}
}
let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false);
if is_line {
// Line end handles: move the end point freely (length + angle).
@@ -919,82 +1016,7 @@ impl Session {
let keep = ev.shift
|| r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false)
|| scale_tool;
if keep && w0 > 1e-9 && h0 > 1e-9 {
let (sx, sy) = ((x1 - x0) / w0, (y1 - y0) / h0);
let k = match r.handle {
Handle::Edge(0) | Handle::Edge(2) => sy,
Handle::Edge(_) => sx,
_ => {
if sx.abs() > sy.abs() {
sx
} else {
sy
}
}
};
let (nw, nh) = (w0 * k, h0 * k);
match r.handle {
Handle::Corner(0) => {
x0 = w0 - nw;
y0 = h0 - nh;
}
Handle::Corner(1) => {
x1 = nw;
y0 = h0 - nh;
}
Handle::Corner(2) => {
x1 = nw;
y1 = nh;
}
Handle::Corner(_) => {
x0 = w0 - nw;
y1 = nh;
}
Handle::Edge(0) | Handle::Edge(2) => {
let c = w0 / 2.0;
x0 = c - nw / 2.0;
x1 = c + nw / 2.0;
if matches!(r.handle, Handle::Edge(0)) {
y0 = h0 - nh;
} else {
y1 = nh;
}
}
Handle::Rotate(_) => {}
Handle::Edge(_) => {
let c = h0 / 2.0;
y0 = c - nh / 2.0;
y1 = c + nh / 2.0;
if matches!(r.handle, Handle::Edge(3)) {
x0 = w0 - nw;
} else {
x1 = nw;
}
}
}
}
if ev.alt {
// Resize from the centre.
let (cx, cy) = (w0 / 2.0, h0 / 2.0);
let hw = ((x1 - x0) / 2.0).abs().max((cx - x0).abs().max((x1 - cx).abs()));
let hh = ((y1 - y0) / 2.0).abs().max((cy - y0).abs().max((y1 - cy).abs()));
match r.handle {
Handle::Edge(0) | Handle::Edge(2) => {
y0 = cy - hh;
y1 = cy + hh;
}
Handle::Edge(_) => {
x0 = cx - hw;
x1 = cx + hw;
}
_ => {
x0 = cx - hw;
x1 = cx + hw;
y0 = cy - hh;
y1 = cy + hh;
}
}
}
let (x0, y0, x1, y1) = resized_box(r.handle, q, w0, h0, keep, ev.alt);
let (flip_x, flip_y) = (x1 < x0, y1 < y0);
let (nx0, ny0) = (x0.min(x1), y0.min(y1));
let (nw, nh) = ((x1 - x0).abs(), (y1 - y0).abs());
@@ -1363,8 +1385,20 @@ impl Session {
Ok(())
}
/// The pen tool's snapped position for `ev`: its path's vector (or, before it has one, its first
/// point) counts among the targets.
fn snap_pen_tool(&mut self, ev: &PointerEvent) -> Result<(Point, Vec<Guide>)> {
let (id, extra) = match &self.tool.pen {
Some(pen) if pen.id.is_some() => (pen.id, Vec::new()),
Some(pen) => (None, pen.points.iter().map(|(p, _, _)| *p).collect()),
None => (None, Vec::new()),
};
self.snap_pen(ev, id, &extra)
}
fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> {
let p = ev.p();
self.tool.overlay.guides.clear();
let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 };
let (doc, page) = {
let ds = self.ds()?;
(ds.doc.clone(), ds.page)
+7 -1
View File
@@ -3,12 +3,17 @@
//!
//! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent).
//! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the
//! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`.
//! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`,
//! the transform box around several points in [`transform`] and point snapping in `point_snap.rs`.
pub mod ops;
pub(crate) mod point_snap;
mod pointer;
#[cfg(test)]
mod tests;
pub mod transform;
#[cfg(test)]
mod transform_tests;
mod width;
#[cfg(test)]
mod width_tests;
@@ -798,6 +803,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
.into_iter()
.chain(transform::specs())
.chain(width::specs())
.collect()
}
+38 -1
View File
@@ -4,7 +4,7 @@
use figcraft_doc::*;
use figcraft_geom::kurbo::{CubicBez, ParamCurve};
use figcraft_geom::{Point, Vec2};
use figcraft_geom::{Affine, Point, Vec2};
fn tvec(t: (f64, f64)) -> Vec2 {
Vec2::new(t.0, t.1)
@@ -30,6 +30,43 @@ pub fn move_vertices(net: &mut VectorNetwork, idx: &[usize], d: Vec2) {
}
}
/// Map vertices `idx` through `m` (scale, rotate, flip…); the handles at them follow its linear
/// part. Nothing changes when any result wouldn't be finite.
pub fn transform_vertices(net: &mut VectorNetwork, idx: &[usize], m: Affine) {
let set: std::collections::BTreeSet<usize> = idx.iter().copied().filter(|i| *i < net.vertices.len()).collect();
let [a, b, c, d, _, _] = m.as_coeffs();
let map_t = |t: (f64, f64)| (a * t.0 + c * t.1, b * t.0 + d * t.1);
let points: Vec<(usize, Point)> = set.iter().filter_map(|i| Some((*i, m * net.vertices.get(*i)?.point()))).collect();
// (segment, at its start, the mapped handle).
let handles: Vec<(usize, bool, (f64, f64))> = net
.segments
.iter()
.enumerate()
.flat_map(|(si, s)| [(si, true, s.a, s.ta), (si, false, s.b, s.tb)])
.filter(|(_, _, v, _)| set.contains(v))
.map(|(si, start, _, t)| (si, start, map_t(t)))
.collect();
let ok = |x: f64, y: f64| x.is_finite() && y.is_finite();
if !points.iter().all(|(_, p)| ok(p.x, p.y)) || !handles.iter().all(|(_, _, t)| ok(t.0, t.1)) {
return;
}
for (i, p) in points {
if let Some(v) = net.vertices.get_mut(i) {
v.x = p.x;
v.y = p.y;
}
}
for (si, start, t) in handles {
if let Some(s) = net.segments.get_mut(si) {
if start {
s.ta = t;
} else {
s.tb = t;
}
}
}
}
/// Set a handle to `rel` (relative to its vertex). The vertex's other handle follows its mirroring
/// (none, angle, or angle and length); `independent` (Alt-drag) breaks the mirroring.
pub fn set_handle(net: &mut VectorNetwork, seg: usize, start: bool, rel: (f64, f64), independent: bool) {
+163
View File
@@ -0,0 +1,163 @@
//! Point snapping: dragged points and handles, and pen clicks, snap to the points and segment
//! midpoints of the vector being edited and of the other vectors on the page, to the usual layer
//! targets (edges and centres of the layers around it, the parent frame, layout grids, guides) and
//! to the pixel grid. Holding Ctrl/Cmd suppresses the object snapping, as when moving layers; the
//! guides (and a marker on a point landed on) go to the overlay like smart guides.
use std::collections::BTreeSet;
use std::sync::Arc;
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::kurbo::ParamCurve;
use figcraft_geom::{Point, Vec2};
use crate::snap::{Guide, Targets};
use crate::tools::{PointerEvent, SNAP_PX};
use crate::{Result, Session};
/// Most points gathered as targets (keeps huge pages responsive).
const MAX_POINTS: usize = 20_000;
/// Most layers visited looking for vectors.
const MAX_NODES: usize = 200_000;
/// Most moving points tested against the targets (the rest move along).
const MAX_MOVING: usize = 256;
/// Snap targets for points of vector `edited` (if any): its points except `moving` (the ones being
/// dragged; their segments' midpoints move too), the points and segment midpoints of the other
/// visible vectors on `page`, the layer targets around it and `extra` page points.
pub fn targets(doc: &Document, page: NodeId, edited: Option<NodeId>, moving: &[usize], extra: &[Point]) -> Targets {
let mut t = Targets::gather(doc, page, edited.as_slice());
let mut budget = MAX_POINTS;
for p in extra.iter().take(budget) {
t.add_point(*p);
}
budget = budget.saturating_sub(extra.len());
if let Some(id) = edited {
add_vector(doc, id, &moving.iter().copied().collect(), &mut t, &mut budget);
}
let none = BTreeSet::new();
for id in visible_vectors(doc, page).into_iter().filter(|v| Some(*v) != edited) {
add_vector(doc, id, &none, &mut t, &mut budget);
}
t
}
/// The visible vectors on `page`, front to back.
fn visible_vectors(doc: &Document, page: NodeId) -> Vec<NodeId> {
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(page).map(|n| n.children.clone()).unwrap_or_default();
let mut visited = 0;
while let Some(id) = stack.pop() {
visited += 1;
if visited > MAX_NODES {
break;
}
let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
if matches!(n.kind, NodeKind::Vector { .. }) {
out.push(id);
}
stack.extend(n.children.iter().copied());
}
out
}
/// Vector `id`'s points (but `skip`) and the midpoints of segments clear of them, in page space.
fn add_vector(doc: &Document, id: NodeId, skip: &BTreeSet<usize>, t: &mut Targets, budget: &mut usize) {
let Some(net) = super::network(doc, id) else { return };
let w = doc.world_transform(id);
let points = net.vertices.iter().enumerate().filter(|(i, _)| !skip.contains(i)).map(|(_, v)| w * v.point());
let mids = net
.segments
.iter()
.enumerate()
.filter(|(_, s)| !skip.contains(&s.a) && !skip.contains(&s.b))
.filter_map(|(si, _)| Some(w * net.cubic(si)?.eval(0.5)));
for p in points.chain(mids).filter(|p| p.x.is_finite() && p.y.is_finite()) {
if *budget == 0 {
return;
}
t.add_point(p);
*budget -= 1;
}
}
/// The snapping that applies to a pointer event: (objects and points, the pixel grid).
pub(crate) fn modes(s: &Session, ev: &PointerEvent) -> (bool, bool) {
let (objects, pixel) = s.ds().map(|d| (d.view.snap_objects, d.view.snap_pixel)).unwrap_or((true, true));
(objects && !ev.cmd, pixel)
}
/// The correction that snaps page points `pts` (already moved by the pointer) together, and the
/// guides at the result. Object snapping comes first; the axes it leaves free put `grab` (the point
/// under the pointer) on whole pixels. `lock`: the points only move horizontally (`Some(true)`) or
/// vertically (`Some(false)`), so the other axis keeps still.
pub(crate) fn snap(
t: &Targets,
pts: &[Point],
grab: Option<Point>,
zoom: f64,
(objects, pixel): (bool, bool),
lock: Option<bool>,
) -> (Vec2, Vec<Guide>) {
let pts = pts.get(..MAX_MOVING).unwrap_or(pts);
let (dx, dy, _) = if objects { t.snap_points(pts, SNAP_PX / zoom.max(1e-6)) } else { (None, None, Vec::new()) };
let mut d = Vec2::new(dx.unwrap_or(0.0), dy.unwrap_or(0.0));
if pixel && let Some(g) = grab.filter(|g| g.x.is_finite() && g.y.is_finite()) {
if dx.is_none() {
d.x = g.x.round() - g.x;
}
if dy.is_none() {
d.y = g.y.round() - g.y;
}
}
match lock {
Some(true) => d.y = 0.0,
Some(false) => d.x = 0.0,
None => {}
}
// The guides show what the points sit on where they end up.
let guides = if objects {
let at: Vec<Point> = pts.iter().map(|p| *p + d).collect();
t.snap_points(&at, 0.0).2
} else {
Vec::new()
};
(d, guides)
}
/// Targets for a pen (nothing moves), kept while the document, the page, the vector and `extra`
/// stay the same, so hovering doesn't gather them again.
#[derive(Clone, Debug)]
pub struct PenTargets {
doc: Arc<Document>,
page: NodeId,
edited: Option<NodeId>,
extra: Vec<Point>,
targets: Arc<Targets>,
}
impl Session {
/// A pen position: `ev`'s point snapped to the targets around vector `edited` (or the page's,
/// plus `extra` points: the pen path's own), with the guides to show.
pub(crate) fn snap_pen(&mut self, ev: &PointerEvent, edited: Option<NodeId>, extra: &[Point]) -> Result<(Point, Vec<Guide>)> {
let modes = modes(self, ev);
if modes == (false, false) {
return Ok((ev.p(), Vec::new()));
}
let (doc, page) = {
let ds = self.ds()?;
(ds.doc.clone(), ds.page)
};
let cached = self.tool.pen_targets.as_ref().filter(|c| Arc::ptr_eq(&c.doc, &doc) && c.page == page && c.edited == edited && c.extra == extra);
let targets = match cached {
Some(c) => c.targets.clone(),
None => {
let targets = Arc::new(targets(&doc, page, edited, &[], extra));
self.tool.pen_targets = Some(PenTargets { doc, page, edited, extra: extra.to_vec(), targets: targets.clone() });
targets
}
};
let (d, guides) = snap(&targets, &[ev.p()], Some(ev.p()), ev.zoom, modes, None);
Ok((ev.p() + d, guides))
}
}
+186 -23
View File
@@ -1,27 +1,51 @@
//! Pointer gestures in vector edit mode, one per edit tool. Each gesture is one undo step: it
//! starts an interaction and previews its edit against the snapshot until the pointer goes up.
//! Positions are read in the snapshot's coordinates (the preview re-fits the vector, which moves
//! its origin). Dragged points and handles, and pen clicks, snap (see `point_snap.rs`).
use std::sync::Arc;
use figcraft_doc::*;
use figcraft_geom::{Point, Rect, Vec2};
use figcraft_geom::{Affine, Point, Rect, Vec2};
use super::point_snap;
use super::transform::{self, BoxHit, PointBox};
use super::{VHit, VTool, VectorEdit, edit, editing, hit, network, ops, set_state};
use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind};
use crate::snap::Targets;
use crate::tools::{Cursor, Gesture, Handle, PointerEvent, PointerKind};
use crate::{Result, Session};
/// An in-progress edit-mode gesture.
/// An in-progress edit-mode gesture (cheap to clone: snap targets are shared).
#[derive(Clone, Debug)]
pub(crate) enum VGesture {
/// Drag points (also a segment's ends). `split` re-adds a point on a segment first.
/// Drag points (also a segment's ends). `split` re-adds a point on a segment first; a click
/// without a drag clears the point selection when `click_clears` (inside the transform box).
Points {
start: Point,
verts: Vec<usize>,
split: Option<(usize, f64)>,
moved: bool,
click_clears: bool,
snap: Arc<PointSnap>,
},
Handle {
seg: usize,
start: bool,
independent: bool,
targets: Arc<Targets>,
},
/// The transform box: a corner or edge scales the points in it.
BoxScale {
b: PointBox,
handle: Handle,
targets: Arc<Targets>,
},
/// The transform box: outside a corner rotates the points about its centre (`angle` so far,
/// degrees clockwise on screen).
BoxRotate {
b: PointBox,
start_angle: f64,
angle: f64,
},
BendSegment {
seg: usize,
@@ -56,6 +80,15 @@ pub(crate) enum VGesture {
Width(super::width::WidthGesture),
}
/// Snapping for dragged points: their page positions at the start, the one under the pointer (kept
/// on whole pixels) and the targets.
#[derive(Clone, Debug)]
pub(crate) struct PointSnap {
from: Vec<Point>,
grab: Option<Point>,
targets: Targets,
}
/// Eraser brush radius in screen pixels.
const ERASER_PX: f64 = 8.0;
@@ -117,6 +150,17 @@ fn apply_pen(net: &mut VectorNetwork, plan: PenPlan, out: Option<(f64, f64)>, dr
}
impl Session {
/// The transform box to draw around the selected points (page corners tl, tr, br, bl): turned
/// with them while a rotation is dragged.
pub fn vector_edit_box(&self) -> Option<[Point; 4]> {
let ds = self.ds().ok()?;
if let Gesture::Vector(VGesture::BoxRotate { b, angle, .. }) = &self.tool.gesture {
let m = figcraft_geom::rotate_about(Affine::IDENTITY, b.center(), -angle);
return Some(b.corners().map(|c| m * c));
}
transform::shown_box(&ds.doc, ds.vector_edit.as_ref()?).map(|b| b.corners())
}
/// Feed a pointer event to vector edit mode (the canvas is in edit mode and the main tool is
/// Move). Returns false when edit mode ended and the event should go to the normal tool.
pub(crate) fn vector_pointer(&mut self, ev: &PointerEvent) -> Result<bool> {
@@ -143,16 +187,71 @@ impl Session {
self.tool.gesture = Gesture::Vector(g);
}
/// The document the gesture started from (its preview is applied to it); the current one
/// outside gestures.
fn gesture_doc(&self) -> Result<Arc<Document>> {
let ds = self.ds()?;
Ok(ds.interaction.as_ref().map_or_else(|| ds.doc.clone(), |it| it.doc.clone()))
}
/// A gesture dragging points `verts` of vector `id` from `start` (positions and snap targets
/// taken from the document as it is now).
fn points_gesture(&self, id: NodeId, start: Point, verts: Vec<usize>, split: Option<(usize, f64)>, click_clears: bool) -> Result<VGesture> {
let ds = self.ds()?;
let w = ds.doc.world_transform(id);
let from: Vec<Point> =
network(&ds.doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default();
let grab = from.iter().copied().min_by(|a, b| (*a - start).hypot().total_cmp(&(*b - start).hypot()));
let targets = point_snap::targets(&ds.doc, ds.page, Some(id), &verts, &[]);
Ok(VGesture::Points { start, verts, split, moved: false, click_clears, snap: Arc::new(PointSnap { from, grab, targets }) })
}
/// A gesture dragging handle `start` of segment `seg` (snapping clear of its own point).
fn handle_gesture(&self, id: NodeId, seg: usize, start: bool, independent: bool) -> Result<VGesture> {
let ds = self.ds()?;
let own: Vec<usize> = network(&ds.doc, id).and_then(|n| n.segments.get(seg)).map(|s| vec![if start { s.a } else { s.b }]).unwrap_or_default();
let targets = Arc::new(point_snap::targets(&ds.doc, ds.page, Some(id), &own, &[]));
Ok(VGesture::Handle { seg, start, independent, targets })
}
/// Start a transform box gesture.
fn box_down(&mut self, ve: &VectorEdit, bh: BoxHit, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let (doc, page) = (ds.doc.clone(), ds.page);
let Some(b) = transform::shown_box(&doc, ve) else { return Ok(()) };
match bh {
BoxHit::Inside => {
let g = self.points_gesture(ve.id, ev.p(), b.points, None, true)?;
self.begin_interaction("Move points")?;
self.vgesture(g);
}
BoxHit::Handle(Handle::Rotate(_)) => {
let start_angle = (ev.p() - b.center()).atan2();
self.begin_interaction("Rotate points")?;
self.vgesture(VGesture::BoxRotate { b, start_angle, angle: 0.0 });
}
BoxHit::Handle(handle) => {
let targets = Arc::new(point_snap::targets(&doc, page, Some(ve.id), &b.points, &[]));
self.begin_interaction("Scale points")?;
self.vgesture(VGesture::BoxScale { b, handle, targets });
}
}
Ok(())
}
fn vector_hover(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<()> {
let doc = self.doc()?.clone();
let tol = 6.0 / ev.zoom.max(1e-6);
let width_tool = ve.tool == VTool::Width;
let h = if width_tool { None } else { hit(&doc, ve, ev.p(), tol) };
// A pen click on empty canvas lands where it snaps.
let pen_at = if ve.tool == VTool::Pen && h.is_none() && !ev.shift { Some(self.snap_pen(ev, Some(ve.id), &[])?) } else { None };
let ov = &mut self.tool.overlay;
ov.vector_hover = h;
ov.width_hover = None;
ov.face.clear();
ov.rubber = None;
ov.guides.clear();
ov.brush = (ve.tool == VTool::Eraser).then(|| [ev.x, ev.y, ERASER_PX / ev.zoom.max(1e-6)]);
ov.cursor = match ve.tool {
VTool::Move if ev.cmd => Cursor::Crosshair,
@@ -162,11 +261,22 @@ impl Session {
};
let w = doc.world_transform(ve.id);
match ve.tool {
VTool::Move if !ev.cmd => {
// Over the transform box's handles: their cursor, and no point highlighted.
if let Some(BoxHit::Handle(handle)) = transform::box_hit(&doc, ve, h, ev.p(), ev.zoom)
&& let Some(b) = transform::shown_box(&doc, ve)
{
self.tool.overlay.cursor = crate::tools::handle_cursor(&b.corners(), handle);
self.tool.overlay.vector_hover = None;
}
}
VTool::Pen => {
let (to, guides) = pen_at.unwrap_or((ev.p(), Vec::new()));
if let Some(a) = ve.anchor.and_then(|a| network(&doc, ve.id)?.vertices.get(a).copied()) {
let ap = w * a.point();
self.tool.overlay.rubber = Some([ap.x, ap.y, ev.x, ev.y]);
self.tool.overlay.rubber = Some([ap.x, ap.y, to.x, to.y]);
}
self.tool.overlay.guides = guides;
}
VTool::Paint => {
// Highlight the closed area the bucket would fill (skipped on huge networks).
@@ -207,14 +317,23 @@ impl Session {
let h = hit(&doc, ve, ev.p(), tol);
self.tool.overlay.vector_hover = None;
self.tool.overlay.face.clear();
self.tool.overlay.guides.clear();
let tool = if ev.cmd && ve.tool == VTool::Move { VTool::Bend } else { ve.tool };
let id = ve.id;
let w = doc.world_transform(id);
// The transform box (Shift inside it adds to the selection instead).
if tool == VTool::Move
&& let Some(bh) = transform::box_hit(&doc, ve, h, ev.p(), ev.zoom).filter(|bh| !(ev.shift && *bh == BoxHit::Inside))
{
self.box_down(ve, bh, ev)?;
return Ok(true);
}
match tool {
VTool::Move => match h {
Some(VHit::Handle { seg, start }) => {
let g = self.handle_gesture(id, seg, start, ev.alt)?;
self.begin_interaction("Edit handle")?;
self.vgesture(VGesture::Handle { seg, start, independent: ev.alt });
self.vgesture(g);
}
Some(VHit::Vertex { index }) => {
let verts = if ev.shift {
@@ -245,8 +364,9 @@ impl Session {
)
})
.unwrap_or_default();
let g = self.points_gesture(id, ev.p(), moving, None, false)?;
self.begin_interaction("Move points")?;
self.vgesture(VGesture::Points { start: ev.p(), verts: moving, split: None, moved: false });
self.vgesture(g);
}
Some(VHit::Segment { seg, t }) => {
if ev.alt {
@@ -262,7 +382,8 @@ impl Session {
e.vertices = vec![v];
e.segments.clear();
})?;
self.vgesture(VGesture::Points { start: ev.p(), verts: vec![v], split: Some((seg, t)), moved: false });
let g = self.points_gesture(id, ev.p(), vec![v], Some((seg, t)), false)?;
self.vgesture(g);
} else if ev.shift {
set_state(self, |e| {
if let Some(i) = e.segments.iter().position(|x| *x == seg) {
@@ -277,8 +398,9 @@ impl Session {
e.vertices.clear();
e.segments = vec![seg];
})?;
let g = self.points_gesture(id, ev.p(), ends, None, false)?;
self.begin_interaction("Move segment")?;
self.vgesture(VGesture::Points { start: ev.p(), verts: ends, split: None, moved: false });
self.vgesture(g);
}
}
None => {
@@ -300,8 +422,9 @@ impl Session {
},
VTool::Bend => match h {
Some(VHit::Handle { seg, start }) => {
let g = self.handle_gesture(id, seg, start, true)?;
self.begin_interaction("Edit handle")?;
self.vgesture(VGesture::Handle { seg, start, independent: true });
self.vgesture(g);
}
Some(VHit::Vertex { index }) => {
let smooth =
@@ -324,7 +447,8 @@ impl Session {
}
},
VTool::Pen => {
let Some(plan) = self.pen_plan(ve, h, w.inverse() * ev.p(), ev.shift) else { return Ok(true) };
let at = if h.is_none() && !ev.shift { self.snap_pen(ev, Some(id), &[])?.0 } else { ev.p() };
let Some(plan) = self.pen_plan(ve, h, w.inverse() * at, ev.shift) else { return Ok(true) };
self.begin_interaction("Pen")?;
let out = ve.pen_out;
let mut v = None;
@@ -471,23 +595,28 @@ impl Session {
fn vector_drag(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<()> {
let Gesture::Vector(g) = self.tool.gesture.clone() else { return Ok(()) };
let id = ve.id;
let w = self.doc()?.world_transform(id);
let w = self.gesture_doc()?.world_transform(id);
let modes = point_snap::modes(self, ev);
let inv = w.inverse();
let lp = inv * ev.p();
let to_local = |d: Vec2| inv * (Point::ZERO + d) - inv * Point::ZERO;
match g {
VGesture::Points { start, verts, split, .. } => {
VGesture::Points { start, verts, split, click_clears, snap, .. } => {
let mut d = ev.p() - start;
if d.hypot() * ev.zoom < 1.0 {
return Ok(());
}
if ev.shift {
if d.x.abs() > d.y.abs() {
d.y = 0.0;
} else {
d.x = 0.0;
}
// Shift: along the axis moved most.
let lock = ev.shift.then_some(d.x.abs() > d.y.abs());
match lock {
Some(true) => d.y = 0.0,
Some(false) => d.x = 0.0,
None => {}
}
let moved: Vec<Point> = snap.from.iter().map(|p| *p + d).collect();
let (c, guides) = point_snap::snap(&snap.targets, &moved, snap.grab.map(|g| g + d), ev.zoom, modes, lock);
d += c;
self.tool.overlay.guides = guides;
let ld = to_local(d);
self.preview(|doc, _| {
edit(doc, id, |net| {
@@ -497,10 +626,13 @@ impl Session {
ops::move_vertices(net, &verts, ld);
})
})?;
self.vgesture(VGesture::Points { start, verts, split, moved: true });
self.vgesture(VGesture::Points { start, verts, split, moved: true, click_clears, snap });
}
VGesture::Handle { seg, start, independent } => {
VGesture::Handle { seg, start, independent, targets } => {
let alt = independent || ev.alt;
let (c, guides) = point_snap::snap(&targets, &[ev.p()], Some(ev.p()), ev.zoom, modes, None);
self.tool.overlay.guides = guides;
let lp = inv * (ev.p() + c);
self.preview(|doc, _| {
edit(doc, id, |net| {
let Some(s) = net.segments.get(seg).copied() else { return };
@@ -509,6 +641,26 @@ impl Session {
})
})?;
}
VGesture::BoxScale { b, handle, targets } => {
// The dragged corner or edge snaps like a resize handle.
let (c, guides) = point_snap::snap(&targets, &[ev.p()], None, ev.zoom, (modes.0, false), None);
self.tool.overlay.guides = guides;
let q = b.frame.inverse() * (ev.p() + c);
let (x0, y0, x1, y1) = crate::tools::resized_box(handle, q, b.width, b.height, ev.shift, ev.alt);
let m = b.onto(x0, y0, x1, y1);
self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?;
}
VGesture::BoxRotate { b, start_angle, .. } => {
let c = b.center();
let mut angle = ((ev.p() - c).atan2() - start_angle).to_degrees();
if ev.shift {
angle = (angle / 15.0).round() * 15.0;
}
let m = figcraft_geom::rotate_about(Affine::IDENTITY, c, -angle);
self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?;
self.tool.overlay.cursor = Cursor::Rotate;
self.vgesture(VGesture::BoxRotate { b, start_angle, angle });
}
VGesture::BendSegment { seg, t } => {
self.preview(|doc, _| edit(doc, id, |net| ops::bend(net, seg, t, lp)))?;
}
@@ -587,13 +739,20 @@ impl Session {
let g = std::mem::take(&mut self.tool.gesture);
self.tool.overlay.marquee = None;
self.tool.overlay.lasso.clear();
self.tool.overlay.guides.clear();
let Gesture::Vector(g) = g else { return Ok(()) };
match g {
VGesture::Points { moved, split, .. } => {
VGesture::Points { moved, split, click_clears, .. } => {
if moved || split.is_some() {
self.commit_interaction()?;
} else {
self.cancel_interaction()?;
if click_clears {
set_state(self, |e| {
e.vertices.clear();
e.segments.clear();
})?;
}
}
}
VGesture::BendVertex { v, moved, smooth, .. } => {
@@ -607,7 +766,11 @@ impl Session {
self.cancel_interaction()?;
}
}
VGesture::Handle { .. } | VGesture::BendSegment { .. } | VGesture::Pen { .. } => self.commit_interaction()?,
VGesture::Handle { .. }
| VGesture::BendSegment { .. }
| VGesture::Pen { .. }
| VGesture::BoxScale { .. }
| VGesture::BoxRotate { .. } => self.commit_interaction()?,
VGesture::Marquee { .. } | VGesture::Lasso { .. } => {}
VGesture::Erase { .. } => self.commit_interaction()?,
VGesture::Width(g) => self.width_up(g)?,
+9 -9
View File
@@ -6,24 +6,24 @@ use super::*;
use crate::EngineError;
use crate::tools::{PointerEvent, PointerKind};
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
pub(super) fn ex(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
fn net(s: &Session, id: NodeId) -> VectorNetwork {
pub(super) fn net(s: &Session, id: NodeId) -> VectorNetwork {
network(s.doc().unwrap(), id).unwrap().clone()
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
pub(super) fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::at(kind, x, y, 1.0)
}
fn click(s: &mut Session, x: f64, y: f64) {
pub(super) fn click(s: &mut Session, x: f64, y: f64) {
s.pointer(&ev(PointerKind::Down, x, y)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y)).unwrap();
}
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
pub(super) fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
s.pointer(&ev(PointerKind::Down, from.0, from.1)).unwrap();
s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0)).unwrap();
s.pointer(&ev(PointerKind::Move, to.0, to.1)).unwrap();
@@ -31,22 +31,22 @@ fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
}
/// A 100×100 rectangle at the origin, in edit mode.
fn rect(s: &mut Session) -> NodeId {
pub(super) fn rect(s: &mut Session) -> NodeId {
let r = ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(s, "vector.edit.begin", json!({"id": id}));
id
}
fn ve(s: &Session) -> VectorEdit {
pub(super) fn ve(s: &Session) -> VectorEdit {
s.ds().unwrap().vector_edit.clone().unwrap()
}
fn vertex_at(n: &VectorNetwork, x: f64, y: f64) -> usize {
pub(super) fn vertex_at(n: &VectorNetwork, x: f64, y: f64) -> usize {
n.vertices.iter().position(|v| (v.x - x).abs() < 1e-6 && (v.y - y).abs() < 1e-6).unwrap()
}
fn pt(x: f64, y: f64) -> Point {
pub(super) fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
+213
View File
@@ -0,0 +1,213 @@
//! The transform box of vector edit mode. With two or more points selected (Move tool) a box in the
//! vector's own axes wraps them: its corners and edges scale them (Shift keeps proportions, Alt
//! scales from the centre), dragging just outside a corner rotates them about its centre (Shift:
//! 15° steps) and dragging inside it moves them; each drag is one undo step (the gestures live in
//! `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP,
//! and while points are selected the flip and 90°/180° rotation commands act on them.
use figcraft_doc::Document;
use figcraft_geom::{Affine, Point};
use serde_json::{Value, json};
use super::{VHit, VTool, VectorEdit, default_points, edit, editing, indices, network, ops, selected_points, selection_position};
use crate::cmd::CommandSpec;
use crate::params::*;
use crate::tools::{Handle, handle_at_corners};
use crate::{Result, Session, bad, spec};
/// Box sizes at or below this have no extent on that axis (nothing to scale).
const EPS: f64 = 1e-9;
/// The selected points' box: the page transform of its top-left corner (axes along the vector's
/// own), its size in those axes and the points in it.
#[derive(Clone, Debug, PartialEq)]
pub struct PointBox {
pub frame: Affine,
pub width: f64,
pub height: f64,
pub points: Vec<usize>,
}
impl PointBox {
/// Corners in page coordinates (tl, tr, br, bl).
pub fn corners(&self) -> [Point; 4] {
let (f, w, h) = (self.frame, self.width, self.height);
[f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)]
}
pub fn center(&self) -> Point {
self.frame * Point::new(self.width / 2.0, self.height / 2.0)
}
/// The page transform mapping this box onto (x0, y0)–(x1, y1) in its own axes (a reversed
/// pair flips). An axis without extent keeps its points where they are.
pub fn onto(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Affine {
let (x0, sx) = if self.width > EPS { (x0, (x1 - x0) / self.width) } else { (0.0, 1.0) };
let (y0, sy) = if self.height > EPS { (y0, (y1 - y0) / self.height) } else { (0.0, 1.0) };
self.frame * Affine::new([sx, 0.0, 0.0, sy, x0, y0]) * self.frame.inverse()
}
}
/// The box of the selected points (and selected segments' ends); one point makes an empty box.
pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
let net = network(doc, ve.id)?;
let points = selected_points(net, ve);
let (x0, y0, x1, y1) = points
.iter()
.filter_map(|i| net.vertices.get(*i))
.fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b, c, d), v| {
(a.min(v.x), b.min(v.y), c.max(v.x), d.max(v.y))
});
let (width, height) = (x1 - x0, y1 - y0);
if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) {
return None;
}
Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points })
}
/// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more
/// points that aren't all in one place.
pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option<PointBox> {
if ve.tool != VTool::Move || ve.anchor.is_some() {
return None;
}
point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS))
}
/// What the transform box does under the pointer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum BoxHit {
/// A corner or edge (scale) or a rotation zone.
Handle(Handle),
/// Inside the box, clear of the network: drag to move the points.
Inside,
}
/// The box's part under page point `p` when it wins over `h`, the network's part there: Bezier
/// handles come first, then the box's corners, points, the box's edges, segments, the rotation
/// zones, and the inside of the box last. Edges along an axis without extent are left out.
pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option<VHit>, p: Point, zoom: f64) -> Option<BoxHit> {
let b = shown_box(doc, ve)?;
match handle_at_corners(&b.corners(), p, zoom, false) {
Some(handle) => {
let wins = match (handle, h) {
(_, Some(VHit::Handle { .. })) => false,
(Handle::Corner(_), _) => true,
(Handle::Edge(e), None | Some(VHit::Segment { .. })) => {
if e % 2 == 0 {
b.height > EPS
} else {
b.width > EPS
}
}
(Handle::Rotate(_), None) => true,
_ => false,
};
wins.then_some(BoxHit::Handle(handle))
}
None => {
let q = b.frame.inverse() * p;
(h.is_none() && (0.0..=b.width).contains(&q.x) && (0.0..=b.height).contains(&q.y)).then_some(BoxHit::Inside)
}
}
}
/// Map points `verts` of vector `id` (and the handles at them) through page transform `m`, then
/// re-fit the vector.
pub fn transform_points(doc: &mut Document, id: figcraft_doc::NodeId, verts: &[usize], m: Affine) -> Result<()> {
let w = doc.world_transform(id);
let local = w.inverse() * m * w;
edit(doc, id, |net| ops::transform_vertices(net, verts, local))
}
/// Transform the selected points by `f(box)` (page space) when editing with points selected and
/// the command names no layers (`p` has no `ids` or `id`); `None` otherwise (it then acts on
/// layers).
pub fn transform_in_edit_mode(s: &mut Session, p: &Value, f: impl FnOnce(&PointBox) -> Affine) -> Option<Result<Value>> {
if get(p, "ids").is_some() || get(p, "id").is_some() {
return None;
}
let ve = editing(s).ok()?;
let b = point_box(s.doc().ok()?, &ve).filter(|b| !b.points.is_empty())?;
let m = f(&b);
Some(s.doc_mut().and_then(|doc| transform_points(doc, ve.id, &b.points, m)).map(|()| json!({"ok": true})))
}
/// The selected points' box, or an error naming `cmd`.
fn selected_box(s: &Session, cmd: &str) -> Result<(VectorEdit, PointBox)> {
let ve = editing(s)?;
let b = point_box(s.doc()?, &ve).filter(|b| !b.points.is_empty()).ok_or_else(|| bad(cmd, "select points"))?;
Ok((ve, b))
}
/// `[a, b, c, d, e, f]` from params.
fn matrix(cmd: &str, p: &Value) -> Result<Affine> {
let c: Vec<f64> = match get(p, "matrix") {
Some(Value::Array(a)) => a.iter().filter_map(f64_of).collect(),
_ => vec![],
};
match c.as_slice() {
[a, b, c, d, e, f] => Ok(Affine::new([*a, *b, *c, *d, *e, *f])),
_ => Err(bad(cmd, "`matrix` must be [a, b, c, d, e, f]")),
}
}
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"vector.edit.bounds",
"Selected points' bounds",
"{} → {x, y (top-left in the parent's coordinates), width, height (in the vector's axes), corners (page), points}",
false,
|s, _| {
let (ve, b) = selected_box(s, "vector.edit.bounds")?;
let pos = selection_position(s.doc()?, &ve);
let corners: Vec<[f64; 2]> = b.corners().iter().map(|c| [c.x, c.y]).collect();
Ok(
json!({"x": pos.map(|p| p.x), "y": pos.map(|p| p.y), "width": b.width, "height": b.height, "corners": corners, "points": b.points}),
)
}
),
spec!(
"vector.edit.resize",
"Resize points",
"{width?, height?, center? (about the centre; default keeps the top-left)} — scales the selected points' box in the vector's axes",
true,
|s, p| {
let (ve, b) = selected_box(s, "vector.edit.resize")?;
let w = num(p, "width").map_or(b.width, |v| v.clamp(0.0, 1e7));
let h = num(p, "height").map_or(b.height, |v| v.clamp(0.0, 1e7));
let (x0, y0) = if boolean(p, "center").unwrap_or(false) { ((b.width - w) / 2.0, (b.height - h) / 2.0) } else { (0.0, 0.0) };
transform_points(s.doc_mut()?, ve.id, &b.points, b.onto(x0, y0, x0 + w, y0 + h))?;
Ok(json!({"ok": true}))
}
),
spec!(
"vector.edit.rotate",
"Rotate points",
"{angle (degrees, counter-clockwise), cx?, cy? (page pivot; default the box centre)}",
true,
|s, p| {
let (ve, b) = selected_box(s, "vector.edit.rotate")?;
let angle = req_num("vector.edit.rotate", p, "angle")?;
let c = b.center();
let pivot = Point::new(num_or(p, "cx", c.x), num_or(p, "cy", c.y));
transform_points(s.doc_mut()?, ve.id, &b.points, figcraft_geom::rotate_about(Affine::IDENTITY, pivot, angle))?;
Ok(json!({"ok": true}))
}
),
spec!(
"vector.edit.transform",
"Transform points",
"{matrix: [a, b, c, d, e, f] (page space), vertices? (default the selected points)}",
true,
|s, p| {
let ve = editing(s)?;
let m = matrix("vector.edit.transform", p)?;
let verts = indices(p, "vertices").unwrap_or_else(|| default_points(s, &ve));
transform_points(s.doc_mut()?, ve.id, &verts, m)?;
Ok(json!({"ok": true}))
}
),
]
}
@@ -0,0 +1,309 @@
//! The transform box around several points and point snapping, through the session (pointer
//! gestures, commands and undo).
use serde_json::json;
use super::tests::{click, drag, ev, ex, net, pt, rect, ve, vertex_at};
use super::transform::{BoxHit, box_hit, shown_box};
use super::*;
use crate::EngineError;
use crate::tools::{Handle, PointerKind, ToolId};
/// Page position of point `v` of vector `id`.
fn page_pt(s: &Session, id: NodeId, v: usize) -> Point {
s.doc().unwrap().world_transform(id) * net(s, id).vertices[v].point()
}
fn near(a: Point, b: Point) -> bool {
(a - b).hypot() < 1e-6
}
fn select_all(s: &mut Session) {
ex(s, "vector.edit.selectAll", json!({}));
}
/// A drag with modifiers held from the start.
fn drag_with(s: &mut Session, from: (f64, f64), to: (f64, f64), shift: bool, alt: bool) {
let with = |kind, x, y| {
let mut e = ev(kind, x, y);
e.shift = shift;
e.alt = alt;
e
};
s.pointer(&with(PointerKind::Down, from.0, from.1)).unwrap();
s.pointer(&with(PointerKind::Move, to.0, to.1)).unwrap();
s.pointer(&with(PointerKind::Up, to.0, to.1)).unwrap();
}
#[test]
fn the_box_shows_for_two_or_more_points_with_the_move_tool() {
let mut s = Session::new();
let id = rect(&mut s);
let doc = s.doc().unwrap().clone();
assert!(shown_box(&doc, &ve(&s)).is_none(), "nothing selected");
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
assert!(shown_box(s.doc().unwrap(), &ve(&s)).is_none(), "one point has no box");
select_all(&mut s);
let b = shown_box(s.doc().unwrap(), &ve(&s)).unwrap();
assert_eq!((b.width, b.height, b.points.len()), (100.0, 100.0, 4));
assert!(near(b.corners()[2], pt(100.0, 100.0)));
// Corners win over the points under them, points over edges, edges over segments.
let doc = s.doc().unwrap().clone();
let at = |x, y| {
let h = hit(&doc, &ve(&s), pt(x, y), 6.0);
box_hit(&doc, &ve(&s), h, pt(x, y), 1.0)
};
assert_eq!(at(100.0, 100.0), Some(BoxHit::Handle(Handle::Corner(2))));
assert_eq!(at(100.0, 50.0), Some(BoxHit::Handle(Handle::Edge(1))));
assert_eq!(at(110.0, -10.0), Some(BoxHit::Handle(Handle::Rotate(1))));
assert_eq!(at(40.0, 60.0), Some(BoxHit::Inside));
// Other edit tools don't show it.
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
assert!(shown_box(s.doc().unwrap(), &ve(&s)).is_none());
let _ = id;
}
#[test]
fn corners_scale_points_in_one_undo_step() {
let mut s = Session::new();
let id = rect(&mut s);
select_all(&mut s);
drag(&mut s, (100.0, 100.0), (150.0, 120.0));
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 150.0, 120.0));
assert_eq!(ve(&s).vertices.len(), 4, "the points stay selected");
s.undo().unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.width(), b.height()), (100.0, 100.0), "one undo step");
// Shift keeps proportions; Alt scales from the centre.
select_all(&mut s);
drag_with(&mut s, (100.0, 100.0), (150.0, 110.0), true, false);
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 150.0, 150.0));
s.undo().unwrap();
select_all(&mut s);
drag_with(&mut s, (100.0, 100.0), (110.0, 120.0), false, true);
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.x0, b.y0, b.x1, b.y1), (-10.0, -20.0, 110.0, 120.0));
}
#[test]
fn edges_scale_one_axis_and_handles_follow() {
let mut s = Session::new();
let id = rect(&mut s);
// A curved handle on the right side scales with its point.
let v = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [v]}));
ex(&mut s, "vector.edit.smooth", json!({}));
let before: Vec<(f64, f64)> = net(&s, id).incident(v).iter().map(|(sg, st)| net(&s, id).handle(*sg, *st).unwrap()).collect();
select_all(&mut s);
drag(&mut s, (100.0, 50.0), (200.0, 50.0));
let n = net(&s, id);
let after: Vec<(f64, f64)> = n.incident(v).iter().map(|(sg, st)| n.handle(*sg, *st).unwrap()).collect();
for (a, b) in before.iter().zip(&after) {
assert!((b.0 - a.0 * 2.0).abs() < 1e-6 && (b.1 - a.1).abs() < 1e-6, "{a:?} → {b:?}");
}
assert!(near(page_pt(&s, id, v), pt(200.0, 0.0)), "{:?}", page_pt(&s, id, v));
assert!((0..4).any(|i| near(page_pt(&s, id, i), pt(0.0, 100.0))), "the left side stays");
}
#[test]
fn outside_a_corner_rotates_and_inside_moves() {
let mut s = Session::new();
let id = rect(&mut s);
select_all(&mut s);
let v = vertex_at(&net(&s, id), 100.0, 0.0);
// From just outside the top-right corner, a quarter turn clockwise about the centre.
s.pointer(&ev(PointerKind::Down, 110.0, -10.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 110.0, 110.0)).unwrap();
let c = s.vector_edit_box().unwrap();
assert!(near(c[0], pt(100.0, 0.0)), "the box turns with the points: {c:?}");
s.pointer(&ev(PointerKind::Up, 110.0, 110.0)).unwrap();
assert!(near(page_pt(&s, id, v), pt(100.0, 100.0)), "{:?}", page_pt(&s, id, v));
s.undo().unwrap();
assert!(near(page_pt(&s, id, v), pt(100.0, 0.0)));
// Inside the box (clear of the network) the selected points move together.
let n = net(&s, id);
let (a, w) = (vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 100.0));
ex(&mut s, "vector.edit.select", json!({"vertices": [a, w]}));
drag(&mut s, (60.0, 40.0), (80.0, 50.0));
assert!(near(page_pt(&s, id, w), pt(120.0, 110.0)) && near(page_pt(&s, id, a), pt(20.0, 10.0)));
assert!(near(page_pt(&s, id, vertex_at(&net(&s, id), 0.0, 100.0)), pt(0.0, 100.0)), "unselected points stay");
// A click inside without a drag clears the point selection; Shift-drag marquees instead.
click(&mut s, 60.0, 50.0);
assert!(ve(&s).vertices.is_empty());
}
#[test]
fn box_commands_resize_rotate_and_transform() {
let mut s = Session::new();
let id = rect(&mut s);
assert!(s.execute("vector.edit.bounds", &json!({})).is_err(), "nothing selected");
select_all(&mut s);
let b = ex(&mut s, "vector.edit.bounds", json!({}));
assert_eq!((b["x"].as_f64(), b["width"].as_f64(), b["height"].as_f64()), (Some(0.0), Some(100.0), Some(100.0)));
ex(&mut s, "vector.edit.resize", json!({"width": 50}));
let r = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((r.x0, r.x1, r.height()), (0.0, 50.0, 100.0), "the top-left stays");
ex(&mut s, "vector.edit.resize", json!({"height": 200, "center": true}));
let r = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((r.y0, r.y1), (-50.0, 150.0), "about the centre");
// Rotation is counter-clockwise like a layer's.
s.undo().unwrap();
s.undo().unwrap();
select_all(&mut s);
let v = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.rotate", json!({"angle": 90}));
assert!(near(page_pt(&s, id, v), pt(0.0, 0.0)), "{:?}", page_pt(&s, id, v));
s.undo().unwrap();
select_all(&mut s);
ex(&mut s, "vector.edit.transform", json!({"matrix": [2, 0, 0, 1, 10, 0], "vertices": [v]}));
assert!(near(page_pt(&s, id, v), pt(210.0, 0.0)));
assert!(s.execute("vector.edit.transform", &json!({"matrix": [1, 0]})).is_err());
// A singular or overflowing matrix changes nothing.
let before = net(&s, id);
ex(&mut s, "vector.edit.transform", json!({"matrix": [1e308, 0, 0, 1e308, 1e308, 0]}));
assert_eq!(net(&s, id), before);
}
#[test]
fn flip_and_rotate_act_on_selected_points_while_editing() {
let mut s = Session::new();
let id = rect(&mut s);
let n = net(&s, id);
let (a, b) = (vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 100.0));
ex(&mut s, "vector.edit.select", json!({"vertices": [a, b]}));
ex(&mut s, "object.flipH", json!({}));
assert!(near(page_pt(&s, id, a), pt(100.0, 0.0)) && near(page_pt(&s, id, b), pt(0.0, 100.0)));
assert!(s.ds().unwrap().vector_edit.is_some(), "still editing");
ex(&mut s, "object.rotate180", json!({}));
assert!(near(page_pt(&s, id, a), pt(0.0, 100.0)));
// Without points selected the layer flips, as outside edit mode.
ex(&mut s, "vector.edit.select", json!({}));
let t = s.doc().unwrap().node(id).unwrap().transform;
ex(&mut s, "object.flipV", json!({"ids": [id]}));
assert_ne!(s.doc().unwrap().node(id).unwrap().transform, t);
}
#[test]
fn dragged_points_snap_to_other_vectors_points_and_midpoints() {
let mut s = Session::new();
// Another vector with a point clear of its box's edge and centre lines.
ex(&mut s, "vector.create", json!({"points": [[300, 0], [400, 100], [320, 60]], "closed": true}));
let id = rect(&mut s);
let v = vertex_at(&net(&s, id), 100.0, 100.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [v]}));
s.pointer(&ev(PointerKind::Down, 100.0, 100.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 318.0, 62.0)).unwrap();
let marker = s.tool.overlay.guides.iter().any(|g| g.x0 == g.x1 && g.y0 == g.y1 && near(pt(g.x0, g.y0), pt(320.0, 60.0)));
assert!(marker, "a marker on the point landed on: {:?}", s.tool.overlay.guides);
s.pointer(&ev(PointerKind::Up, 318.0, 62.0)).unwrap();
assert!(near(page_pt(&s, id, v), pt(320.0, 60.0)), "{:?}", page_pt(&s, id, v));
assert!(s.tool.overlay.guides.is_empty(), "guides go with the gesture");
s.undo().unwrap();
// Segment midpoints of the vector itself (the top edge's middle).
ex(&mut s, "vector.edit.select", json!({"vertices": [v]}));
drag(&mut s, (100.0, 100.0), (52.0, 3.0));
assert!(near(page_pt(&s, id, v), pt(50.0, 0.0)), "{:?}", page_pt(&s, id, v));
}
#[test]
fn ctrl_suppresses_snapping_and_the_pixel_grid_rounds() {
let mut s = Session::new();
ex(&mut s, "vector.create", json!({"points": [[300, 0], [400, 100], [320, 60]], "closed": true}));
let id = rect(&mut s);
let v = vertex_at(&net(&s, id), 100.0, 100.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [v]}));
let cmd = |kind, x, y| {
let mut e = ev(kind, x, y);
e.cmd = true;
e
};
// ⌘ held after the press (pressing with it bends instead).
s.pointer(&ev(PointerKind::Down, 100.0, 100.0)).unwrap();
s.pointer(&cmd(PointerKind::Move, 318.4, 62.3)).unwrap();
s.pointer(&cmd(PointerKind::Up, 318.4, 62.3)).unwrap();
assert!(near(page_pt(&s, id, v), pt(318.0, 62.0)), "no point snapping, whole pixels: {:?}", page_pt(&s, id, v));
s.undo().unwrap();
ex(&mut s, "view.snapPixel", json!({"on": false}));
ex(&mut s, "vector.edit.select", json!({"vertices": [v]}));
s.pointer(&ev(PointerKind::Down, 100.0, 100.0)).unwrap();
s.pointer(&cmd(PointerKind::Move, 318.4, 62.3)).unwrap();
s.pointer(&cmd(PointerKind::Up, 318.4, 62.3)).unwrap();
assert!(near(page_pt(&s, id, v), pt(318.4, 62.3)), "{:?}", page_pt(&s, id, v));
}
#[test]
fn handles_follow_the_pointer_while_the_vector_refits() {
// The preview re-fits the vector (its origin moves as the curve grows up and left); the handle
// must still land under the pointer.
let mut s = Session::new();
let id = rect(&mut s);
let a = vertex_at(&net(&s, id), 0.0, 0.0);
let (seg, start) = net(&s, id)
.incident(a)
.into_iter()
.find(|(sg, st)| {
let n = net(&s, id);
let x = n.segments[*sg];
n.vertices[if *st { x.b } else { x.a }].y == 0.0
})
.unwrap();
ex(&mut s, "vector.edit.setHandle", json!({"segment": seg, "start": start, "x": 30, "y": 0, "independent": true}));
ex(&mut s, "vector.edit.select", json!({"vertices": [a]}));
s.pointer(&ev(PointerKind::Down, 30.0, 0.0)).unwrap();
for (x, y) in [(-10.0, -40.0), (-30.0, -60.0), (-50.0, -80.0)] {
s.pointer(&ev(PointerKind::Move, x, y)).unwrap();
}
s.pointer(&ev(PointerKind::Up, -50.0, -80.0)).unwrap();
let n = net(&s, id);
let h = n.handle(seg, start).unwrap();
let p = page_pt(&s, id, a) + Vec2::new(h.0, h.1);
assert!(near(p, pt(-50.0, -80.0)), "handle at {p:?}");
}
#[test]
fn pens_snap_their_clicks() {
let mut s = Session::new();
ex(&mut s, "vector.create", json!({"points": [[300, 0], [400, 100], [320, 60]], "closed": true}));
let id = rect(&mut s);
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
s.pointer(&ev(PointerKind::Hover, 322.0, 58.0)).unwrap();
assert!(!s.tool.overlay.guides.is_empty(), "hovering shows where the click lands");
click(&mut s, 322.0, 58.0);
let n = net(&s, id);
assert!(near(page_pt(&s, id, n.vertices.len() - 1), pt(320.0, 60.0)));
ex(&mut s, "vector.edit.end", json!({}));
// The pen tool: the second click lines up with the first.
s.set_tool(ToolId::Pen);
click(&mut s, 0.0, 300.0);
click(&mut s, 80.0, 303.0);
s.pen_finish().unwrap();
let pen = s.selection()[0];
let b = s.doc().unwrap().world_bounds(pen).unwrap();
assert_eq!((b.y0, b.y1, b.x1), (300.0, 300.0, 80.0), "a level line");
}
#[test]
fn edit_mode_transform_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!({"width": -1e308, "height": "x", "center": "maybe", "angle": 1e308, "cx": "NaN", "matrix": [1, 2, 3, "a", 5, 6]}),
json!({"matrix": [0, 0, 0, 0, 0, 0], "vertices": [1e12, -1], "width": 1e308, "height": 0}),
];
for c in
["object.flipH", "object.flipV", "object.rotate180", "object.rotateLeft", "object.rotateRight", "vector.edit.resize", "vector.edit.rotate"]
{
for j in &junk {
let mut s = Session::new();
let id = rect(&mut s);
select_all(&mut s);
if let Err(EngineError::Internal { cmd, msg }) = s.execute(c, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
let n = net(&s, id);
assert!(n.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()), "{c} broke the points on {j}");
}
}
}