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:
parent
5f54fecca2
commit
8dff0089e5
10
ROADMAP.md
10
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<f32>() / 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::<f32>() / 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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<f64>| -> Option<f64> {
|
||||
let mut it = pts.iter().map(|v| f(*v));
|
||||
let first = it.next().flatten()?;
|
||||
|
|
|
|||
10
docs/mcp.md
10
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
|
||||
|
|
|
|||
|
|
@ -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%)**
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue