From 8dff0089e514682fdb67f10fa035e094658b119f Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 10:44:12 +0700 Subject: [PATCH] M4: vector edit transform box and point snapping (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ROADMAP.md | 10 +- crates/engine/src/cmd/arrange.rs | 26 +- crates/engine/src/snap.rs | 137 ++++---- crates/engine/src/tools.rs | 254 +++++++------- crates/engine/src/vectoredit.rs | 8 +- crates/engine/src/vectoredit/ops.rs | 39 ++- crates/engine/src/vectoredit/point_snap.rs | 163 +++++++++ crates/engine/src/vectoredit/pointer.rs | 209 ++++++++++-- crates/engine/src/vectoredit/tests.rs | 18 +- crates/engine/src/vectoredit/transform.rs | 213 ++++++++++++ .../engine/src/vectoredit/transform_tests.rs | 309 ++++++++++++++++++ crates/ui-egui/src/canvas.rs | 61 ++-- crates/ui-egui/src/right.rs | 9 +- crates/ui-egui/src/vector_panel.rs | 15 +- docs/mcp.md | 10 + docs/parity.md | 2 +- 16 files changed, 1245 insertions(+), 238 deletions(-) create mode 100644 crates/engine/src/vectoredit/point_snap.rs create mode 100644 crates/engine/src/vectoredit/transform.rs create mode 100644 crates/engine/src/vectoredit/transform_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index ae80e13..60a3c3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -46,8 +46,14 @@ dimension measured side by side. (⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept), variable width strokes (width profile presets with previews in the stroke settings, the Variable width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete; - smooth interpolation per subpath, dashes, caps and joins, canvas/PNG/SVG/PDF and outline stroke). - *Left:* transform box for several points, point snapping, one-sided widths. 7–11 h. + smooth interpolation per subpath, dashes, caps and joins, canvas/PNG/SVG/PDF and outline stroke), + a transform box around several selected points (corners and edges scale them, Shift keeps + proportions, Alt from the centre; just outside a corner rotates, Shift in 15° steps; drag inside + to move; the Design panel's X/Y/W/H and flip/rotate act on the points), point snapping while + dragging points or handles and with both pens (to the 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 it off). *Left:* arc handles on the canvas, live boolean editing, + one-sided widths. 4–8 h. 2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case, truncation/max lines, font picker with previews and recents, word/line selection, IME commit, inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index d87702e..5109ba9 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -27,11 +27,17 @@ pub fn specs() -> Vec { 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 { } fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result { + // 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 { } fn rotate_by(s: &mut Session, p: &Value, deg: f64) -> Result { + 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); diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 4f99d75..71cb245 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -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, } +/// 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, /// Horizontal lines (y) with the x-span. - pub ys: Vec<(f64, f64, f64)>, + pub ys: Vec, /// Sibling boxes (for spacing labels). pub boxes: Vec, + /// Points to land on exactly (see [`Targets::add_point`]). + pub points: Vec, } 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) { - 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 { + 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) { + 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, Option, Vec) { + let xs: Vec = pts.iter().map(|p| (p.x, p.y, p.y)).collect(); + let ys: Vec = 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, Option, Vec) { + let best = |cands: &[Span], vals: &[Span]| -> Option { let mut best: Option = 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) { - let mut bx: Option = 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 = 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 = xs.iter().map(|(x, a, b)| (x + ox, a + oy, b + oy)).collect(); + let ys: Vec = 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 { + 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 { + 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 { let mut out = Vec::new(); @@ -166,7 +193,7 @@ pub fn measure(r: Rect, other: Rect) -> Vec { 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; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 8c14483..f7b2372 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -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, } 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 { + 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)> { + 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) diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index 84e3bb1..0459c94 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -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 { }), ] .into_iter() + .chain(transform::specs()) .chain(width::specs()) .collect() } diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/vectoredit/ops.rs index 949362a..16fc366 100644 --- a/crates/engine/src/vectoredit/ops.rs +++ b/crates/engine/src/vectoredit/ops.rs @@ -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 = 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) { diff --git a/crates/engine/src/vectoredit/point_snap.rs b/crates/engine/src/vectoredit/point_snap.rs new file mode 100644 index 0000000..a9ade1f --- /dev/null +++ b/crates/engine/src/vectoredit/point_snap.rs @@ -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, 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 { + let mut out = Vec::new(); + let mut stack: Vec = 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, 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, + zoom: f64, + (objects, pixel): (bool, bool), + lock: Option, +) -> (Vec2, Vec) { + 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 = 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, + page: NodeId, + edited: Option, + extra: Vec, + targets: Arc, +} + +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, extra: &[Point]) -> Result<(Point, Vec)> { + 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)) + } +} diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 2c35ff2..c7267f0 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -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, split: Option<(usize, f64)>, moved: bool, + click_clears: bool, + snap: Arc, }, Handle { seg: usize, start: bool, independent: bool, + targets: Arc, + }, + /// The transform box: a corner or edge scales the points in it. + BoxScale { + b: PointBox, + handle: Handle, + targets: Arc, + }, + /// 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, + grab: Option, + 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 { @@ -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> { + 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, split: Option<(usize, f64)>, click_clears: bool) -> Result { + let ds = self.ds()?; + let w = ds.doc.world_transform(id); + let from: Vec = + 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 { + let ds = self.ds()?; + let own: Vec = 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 = 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)?, diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index 243a3a7..ae7ac27 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -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) } diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/vectoredit/transform.rs new file mode 100644 index 0000000..bce8b2c --- /dev/null +++ b/crates/engine/src/vectoredit/transform.rs @@ -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, +} + +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 { + 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 { + 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, p: Point, zoom: f64) -> Option { + 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> { + 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 { + let c: Vec = 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 { + 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})) + } + ), + ] +} diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs new file mode 100644 index 0000000..d69baf5 --- /dev/null +++ b/crates/engine/src/vectoredit/transform_tests.rs @@ -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}"); + } + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d7641d8..aaf8938 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -635,7 +635,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.5), 4.0, 3.0)); } - // Vector edit mode. + // Vector edit mode, with the transform box around several selected points. if let Some(ve) = ds.vector_edit.clone() && let Some(o) = figcraft_engine::vectoredit::overlay(&doc, &ve) { @@ -644,6 +644,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { vector_overlay(app, painter, &o, !width_tool); if width_tool { width_overlay(app, painter, &doc, &ve, zoom); + } else if let Some(c) = app.session.vector_edit_box() { + let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); + transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label); } } // Selection. @@ -670,32 +673,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { // The shape builder reshapes by regions: no transform box. if app.session.tool.tool != ToolId::ShapeBuilder && let Some(b) = selection_box(&doc, &sel) + && let Some((_, w, h)) = selection_frame(&doc, &sel) { - let q = b.map(|p| app.to_screen(p.x, p.y)); - if sel.len() > 1 { - quad(painter, q, stroke(col, 1.0)); - } let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line)); - for (i, c) in q.iter().enumerate() { - if is_line && i >= 2 { - continue; - } - let r = Rect::from_center_size(*c, vec2(8.0, 8.0)); - painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE); - painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside); - } - // Size label under the box. - if app.session.prefs.show_dimensions - && let Some((_, w, h)) = selection_frame(&doc, &sel) - { - let label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) }; - let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max); - let cx = q.iter().map(|p| p.x).sum::() / 4.0; - let galley = painter.layout_no_wrap(label, semibold(11.0), Color32::WHITE); - let r = Rect::from_center_size(pos2(cx, bottom + 8.0 + galley.size().y / 2.0 + 3.0), galley.size() + vec2(8.0, 4.0)); - painter.rect_filled(r, CornerRadius::same(3), col); - painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); - } + let label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) }; + transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, if is_line { 2 } else { 4 }, label); } } if app.session.tool.tool == ToolId::ShapeBuilder @@ -754,9 +736,15 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { painter.line_segment([p, p + vec2(0.0, 14.0 * zoom as f32)], stroke(t.text, 1.0)); } } - // Smart guides and measurements. + // Smart guides and measurements; a zero-length guide marks a point snapped onto. for g in &ov.guides { let (a, b) = (app.to_screen(g.x0, g.y0), app.to_screen(g.x1, g.y1)); + if g.x0 == g.x1 && g.y0 == g.y1 { + for d in [vec2(4.0, 4.0), vec2(4.0, -4.0)] { + painter.line_segment([a - d, a + d], stroke(t.guide, 1.5)); + } + continue; + } painter.line_segment([a, b], stroke(t.guide, 1.0)); if let Some(l) = &g.label { let galley = painter.layout_no_wrap(l.clone(), semibold(10.0), Color32::WHITE); @@ -810,6 +798,27 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { } } +/// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles` +/// corner handles and, when dimensions are shown, the size `label` under it. +fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color32, outline: bool, handles: usize, label: String) { + if outline { + quad(painter, q, stroke(col, 1.0)); + } + for c in q.iter().take(handles) { + let r = Rect::from_center_size(*c, vec2(8.0, 8.0)); + painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE); + painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside); + } + if app.session.prefs.show_dimensions { + let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max); + let cx = q.iter().map(|p| p.x).sum::() / 4.0; + let galley = painter.layout_no_wrap(label, semibold(11.0), Color32::WHITE); + let r = Rect::from_center_size(pos2(cx, bottom + 8.0 + galley.size().y / 2.0 + 3.0), galley.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, CornerRadius::same(3), col); + painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); + } +} + /// Variable width tool: each width point on the path with a bar across the stroke to its two /// width handles (the selected point filled), and a faint preview where a drag would add one. fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &figcraft_engine::vectoredit::VectorEdit, zoom: f64) { diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index a792f0c..83b6d27 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -230,6 +230,9 @@ fn design(app: &mut FigApp, ui: &mut Ui) { } header(app, ui, &doc, &sel); widgets::separator(ui); + // Editing a vector with points selected, the points' fields stand in for the layer's position + // and dimensions. + let points_selected = vector_edit.as_ref().is_some_and(|ve| !ve.vertices.is_empty() || !ve.segments.is_empty()); match vector_edit { Some(ve) => crate::vector_panel::points(app, ui, &doc, &ve), None => { @@ -242,8 +245,10 @@ fn design(app: &mut FigApp, ui: &mut Ui) { } } widgets::separator(ui); - crate::layout_panel::layout_section(app, ui, &doc, &sel); - widgets::separator(ui); + if !points_selected { + crate::layout_panel::layout_section(app, ui, &doc, &sel); + widgets::separator(ui); + } appearance(app, ui, &doc, &sel); widgets::separator(ui); if variable_modes(app, ui, &doc, &sel) { diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 385e319..2614f86 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -1,6 +1,6 @@ -//! Design panel in vector edit mode: the selected points' position, corner radius and handle -//! mirroring, or the selected width point with the Variable width tool (in place of the layer's -//! position section). +//! Design panel in vector edit mode: the selected points' position and dimensions (their +//! transform box), corner radius and handle mirroring, or the selected width point with the +//! Variable width tool (in place of the layer's position and dimensions). use egui::{Id, Ui}; use figcraft_doc::{Document, HandleMirroring}; @@ -83,6 +83,15 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { let e = widgets::number_field(ui, Id::new("vpt_y"), Lead::Text("Y"), pos.map(|p| round(p.y)), "", col, 1.0); point_field(app, "vector.edit.position", e, |v| json!({"y": v})); }); + if let Some(b) = vectoredit::transform::point_box(doc, ve).filter(|b| b.points.len() > 1) { + widgets::sublabels(ui, "Dimensions", None); + row(ui, 32.0, |ui| { + let e = widgets::number_field(ui, Id::new("vpt_w"), Lead::Text("W"), Some(round(b.width)), "", col, 1.0); + point_field(app, "vector.edit.resize", e, |v| json!({"width": v.max(0.0)})); + let e = widgets::number_field(ui, Id::new("vpt_h"), Lead::Text("H"), Some(round(b.height)), "", col, 1.0); + point_field(app, "vector.edit.resize", e, |v| json!({"height": v.max(0.0)})); + }); + } let common = |f: &dyn Fn(usize) -> Option| -> Option { let mut it = pts.iter().map(|v| f(*v)); let first = it.next().flatten()?; diff --git a/docs/mcp.md b/docs/mcp.md index 72e80d0..1d1dc94 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -146,6 +146,16 @@ from the pen's open end; clicking a point connects or branches), `vector.edit.pe `vector.edit.cornerRadius`, `vector.edit.end`. Point indices refer to `network.vertices` in `vector.edit.state`; local coordinates are the layer's own. +The selected points' transform box: `vector.edit.bounds` (`{x, y, width, height, corners, +points}`: the box in the vector's axes, `x`/`y` in the parent's coordinates like the Position +fields), `vector.edit.resize {width?, height?, center?}` (keeps the top-left unless `center`), +`vector.edit.rotate {angle, cx?, cy?}` (degrees counter-clockwise about the box centre or a page +pivot), `vector.edit.transform {matrix: [a, b, c, d, e, f], vertices?}` (any page-space affine). +While points are selected, `object.flipH` / `object.flipV` and the 90°/180° rotations act on them. +On the canvas (`pointer`), dragging points, handles and pen clicks snap to other points and segment +midpoints (this vector's and other vectors'), the usual layer targets and the pixel grid; `cmd` +held while dragging turns the object snapping off. + Variable width strokes: `stroke.widthProfile {ids?, preset?: uniform|wedge|taper|eye|rounded|pinch, points?: [[t, scale], ...]}` (also `node.set {widthProfile}`; `t` runs 0–1 along each subpath by length, `scale` multiplies the stroke weight, widths in between are interpolated smoothly; no diff --git a/docs/parity.md b/docs/parity.md index 11fc885..f0110b2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -1,6 +1,6 @@ # Parity report -Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (389 commands in the engine). +Generated by `cargo xtask parity`: FigCraft commands registered for the incumbent's main menu and toolbar (393 commands in the engine). **197 / 202 menu items (97.5%)**