diff --git a/ROADMAP.md b/ROADMAP.md index 909b5ac9..4774c7d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -273,7 +273,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Area | Weight | Score | Remaining work | Hours | |---|---|---|---|---| -| Canvas & navigation | 6 | 78% | rulers and guides on rotated frames, visual QA at extreme zooms | 1–2 | +| Canvas & navigation | 6 | 80% | visual QA at extreme zooms | 1 | | Selection & transform | 8 | 78% | measuring and transform handles checked side by side, rotated groups and mixed selections QA | 2–4 | | Layers panel | 5 | 88% | visual QA of long lists and deep trees | 0.5–1 | | Shapes & vector | 8 | 76% | live boolean editing, Design panel arc fields, shape builder on strokes and open paths, one-sided widths | 4–8 | diff --git a/crates/engine/src/distance.rs b/crates/engine/src/distance.rs index 47f82e96..585595d5 100644 --- a/crates/engine/src/distance.rs +++ b/crates/engine/src/distance.rs @@ -146,7 +146,7 @@ pub fn to_guide(a: Rect, vertical: bool, at: f64) -> Vec { } /// The ruler guides near page point `p` (within `tol`): the page's and those of the frames -/// around `around` (axis-aligned frames only), as (vertical, page offset). +/// around `around` (those that run along the page's axes), as (vertical, page offset). pub fn guide_at(doc: &Document, page: NodeId, around: &[NodeId], p: Point, tol: f64) -> Option<(bool, f64)> { let mut lines: Vec<(bool, f64)> = Vec::new(); if let Some(n) = doc.node(page) { @@ -163,14 +163,7 @@ pub fn guide_at(doc: &Document, page: NodeId, around: &[NodeId], p: Point, tol: for f in frames { let Some(n) = doc.node(f).filter(|n| !n.guides.is_empty()) else { continue }; let w = doc.world_transform(f); - let c = w.as_coeffs(); - if c[1].abs() > EPS || c[2].abs() > EPS { - continue; - } - for g in &n.guides { - let q = w * Point::new(g.offset, g.offset); - lines.push((g.vertical, if g.vertical { q.x } else { q.y })); - } + lines.extend(n.guides.iter().filter_map(|g| crate::guides::line_in(w, g.vertical, g.offset))); } lines .into_iter() diff --git a/crates/engine/src/guides.rs b/crates/engine/src/guides.rs new file mode 100644 index 00000000..750e78fc --- /dev/null +++ b/crates/engine/src/guides.rs @@ -0,0 +1,69 @@ +//! Ruler guides on the page. A frame keeps its guides in its own coordinates, so on a rotated frame +//! they run along the frame's rotated axes and span only the frame; a page's guides are page lines +//! across the whole canvas. + +use figcraft_doc::{Document, NodeId}; +use figcraft_geom::{Affine, Point}; + +/// Below this, a transform coefficient counts as zero (rotations that keep the axes). +const EPS: f64 = 1e-7; + +/// The page segment guide `offset` of frame `owner` covers: along the frame's own y axis across it +/// for a vertical guide (its x axis for a horizontal one). `None` for a page (its guides cross the +/// whole canvas) or a missing node. +pub fn segment(doc: &Document, owner: NodeId, vertical: bool, offset: f64) -> Option<[Point; 2]> { + let n = doc.node(owner).filter(|n| !n.is_page())?; + let xf = doc.world_transform(owner); + let (a, b) = + if vertical { (Point::new(offset, 0.0), Point::new(offset, n.height)) } else { (Point::new(0.0, offset), Point::new(n.width, offset)) }; + let (a, b) = (xf * a, xf * b); + (a.is_finite() && b.is_finite()).then_some([a, b]) +} + +/// The offset of a guide of `owner` through page point `p`, in the owner's coordinates (its x for a +/// vertical guide). A page's coordinates are the page's. +pub fn offset_at(doc: &Document, owner: NodeId, vertical: bool, p: Point) -> Option { + let n = doc.node(owner)?; + let local = if n.is_page() { p } else { doc.world_transform(owner).inverse() * p }; + let v = if vertical { local.x } else { local.y }; + v.is_finite().then_some(v) +} + +/// Which of frame `owner`'s guides (vertical or not, in its coordinates) a guide dragged from a +/// page ruler as `vertical` becomes: the one whose axis is nearer the page's, so a frame turned +/// past 45° takes the other kind. +pub fn frame_axis(doc: &Document, owner: NodeId, vertical: bool) -> bool { + // The frame's y axis on the page is (c, d): it is nearer the page's y axis when |d| ≥ |c|. + let [_, _, c, d, _, _] = doc.world_transform(owner).as_coeffs(); + if d.abs() >= c.abs() { vertical } else { !vertical } +} + +/// Whether `xf` keeps lines parallel to the axes (no rotation off a multiple of 180°, no skew). +pub fn axis_aligned(xf: Affine) -> bool { + let [_, b, c, _, _, _] = xf.as_coeffs(); + b.abs() < EPS && c.abs() < EPS +} + +/// The line `x = offset` (`vertical`) or `y = offset` mapped by `m`, when it stays parallel to an +/// axis: whether it is vertical then, and its offset. A quarter turn swaps the axes. +pub fn line_in(m: Affine, vertical: bool, offset: f64) -> Option<(bool, f64)> { + let [a, b, c, d, e, f] = m.as_coeffs(); + let line = if vertical { + // (offset, t) ↦ (a·offset + c·t + e, b·offset + d·t + f) + if c.abs() < EPS { + (true, a * offset + e) + } else if d.abs() < EPS { + (false, b * offset + f) + } else { + return None; + } + } else if b.abs() < EPS { + // (t, offset) ↦ (a·t + c·offset + e, b·t + d·offset + f) + (false, d * offset + f) + } else if a.abs() < EPS { + (true, c * offset + e) + } else { + return None; + }; + line.1.is_finite().then_some(line) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a04d9f8c..92474109 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod devinspect; pub mod distance; pub mod drop; pub mod guard; +pub mod guides; pub mod inspect; pub mod library; pub mod measure; @@ -995,6 +996,8 @@ mod proto_responsive_tests; #[cfg(test)] mod proto_run_tests; #[cfg(test)] +mod rotated_guides_tests; +#[cfg(test)] mod rotated_select_tests; #[cfg(test)] mod scale_tool_tests; diff --git a/crates/engine/src/rotated_guides_tests.rs b/crates/engine/src/rotated_guides_tests.rs new file mode 100644 index 00000000..b50fbc65 --- /dev/null +++ b/crates/engine/src/rotated_guides_tests.rs @@ -0,0 +1,173 @@ +//! Guides on rotated frames: they run along the frame's own axes, page points convert into the +//! frame's coordinates, a ruler guide dropped on a turned frame takes its nearer axis, and layers in +//! the frame snap to its guides (and siblings, and edges) along those axes. + +use figcraft_doc::NodeId; +use figcraft_geom::{Affine, Point, Vec2}; +use serde_json::json; + +use crate::Session; +use crate::guides::{frame_axis, offset_at, segment}; +use crate::params::id_of; +use crate::snap::Targets; +use crate::tools::{PointerEvent, PointerKind}; + +fn id(v: serde_json::Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +/// A 300 × 200 frame at (100, 100) turned by `rotation`, with a 20 × 20 rectangle at (10, 10) in it +/// and a vertical guide at 40. +fn setup(rotation: f64) -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 100, "y": 100, "width": 300, "height": 200})).unwrap()); + let rect = id(s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 10, "y": 10, "width": 20, "height": 20})).unwrap()); + s.execute("node.set", &json!({"id": frame, "props": {"rotation": rotation}})).unwrap(); + s.execute("guide.add", &json!({"frame": frame, "vertical": true, "offset": 40})).unwrap(); + (s, frame, rect) +} + +/// Page vector for `v` in `frame`'s coordinates. +fn page_vec(s: &Session, frame: NodeId, v: Vec2) -> Vec2 { + let m = s.doc().unwrap().world_transform(frame); + m * Point::new(v.x, v.y) - m * Point::ZERO +} + +/// Page vector `v` in `frame`'s coordinates. +fn local_vec(s: &Session, frame: NodeId, v: Vec2) -> Vec2 { + let m = s.doc().unwrap().world_transform(frame).inverse(); + m * (Point::ZERO + v) - m * Point::ZERO +} + +/// `id`'s position in its parent's coordinates. +fn local_xy(s: &Session, id: NodeId) -> Point { + s.doc().unwrap().node(id).unwrap().transform.translation().to_point() +} + +#[test] +fn a_rotated_frames_guides_run_along_its_axes_and_span_it() { + let (s, frame, _) = setup(30.0); + let doc = s.doc().unwrap(); + let xf = doc.world_transform(frame); + let [a, b] = segment(doc, frame, true, 40.0).unwrap(); + // From 40 along the frame's top edge, down its y axis, as long as the frame is tall. + let top = xf * Point::new(300.0, 0.0) - xf * Point::ZERO; + assert!(near((a - xf * Point::ZERO).hypot(), 40.0)); + assert!(near((a - xf * Point::ZERO).dot(top) / top.hypot(), 40.0), "on the top edge"); + assert!(near((b - a).hypot(), 200.0)); + assert!(near((b - a).dot(top), 0.0), "across the frame"); + assert!((b.x - a.x).abs() > 50.0, "not a page-vertical line"); + // Page points along it convert back to 40 in the frame; 5 further along the frame's x is 45. + for t in [0.0, 0.3, 1.0] { + assert!(near(offset_at(doc, frame, true, a.lerp(b, t)).unwrap(), 40.0)); + } + let p = a.midpoint(b) + page_vec(&s, frame, Vec2::new(5.0, 0.0)); + assert!(near(offset_at(doc, frame, true, p).unwrap(), 45.0)); + let p = xf * Point::new(12.0, 34.0); + assert!(near(offset_at(doc, frame, false, p).unwrap(), 34.0)); + // A page's guides are page lines across the canvas. + let page = s.page().unwrap(); + assert!(segment(doc, page, true, 40.0).is_none()); + assert_eq!(offset_at(doc, page, true, Point::new(12.5, 7.0)), Some(12.5)); +} + +#[test] +fn a_ruler_guide_dropped_on_a_turned_frame_takes_its_nearer_axis() { + for (rotation, same) in [(0.0, true), (30.0, true), (-40.0, true), (60.0, false), (90.0, false), (150.0, true), (180.0, true)] { + let (s, frame, _) = setup(rotation); + let doc = s.doc().unwrap(); + assert_eq!(frame_axis(doc, frame, true), same, "{rotation}°"); + assert_eq!(frame_axis(doc, frame, false), !same, "{rotation}°"); + } +} + +#[test] +fn layers_in_a_rotated_frame_snap_to_its_guides_along_its_axes() { + let (s, frame, rect) = setup(30.0); + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + let t = Targets::gather(doc, page, &[rect]); + assert_ne!(t.space, Affine::IDENTITY, "snapping runs in the frame's space"); + let m = t.moving.unwrap(); + assert!(near(m.x0, 10.0) && near(m.y0, 10.0) && near(m.x1, 30.0) && near(m.y1, 30.0), "{m:?}"); + // Moved by (29, 3) in the frame: its left edge lands 1 short of the guide at 40 and snaps. + let d = page_vec(&s, frame, Vec2::new(29.0, 3.0)); + let (c, guides) = t.snap_move(d, 4.0); + let moved = local_vec(&s, frame, d + c); + assert!(near(moved.x, 30.0) && near(moved.y, 3.0), "{moved:?}"); + // The guide drawn runs along the frame guide. + assert!( + guides.iter().any(|g| [Point::new(g.x0, g.y0), Point::new(g.x1, g.y1)].iter().all(|p| near(offset_at(doc, frame, true, *p).unwrap(), 40.0))), + "{guides:?}" + ); + // A page guide doesn't run along the frame's axes: no target. + let mut s = s; + s.execute("guide.add", &json!({"vertical": true, "offset": 250})).unwrap(); + let t = Targets::gather(s.doc().unwrap(), page, &[rect]); + assert!(!t.xs.iter().chain(&t.ys).any(|l| l.1 < -1e6), "no page-wide lines"); +} + +#[test] +fn moving_a_layer_in_a_rotated_frame_snaps_and_keeps_whole_pixels_there() { + let (mut s, frame, rect) = setup(30.0); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + let doc = s.doc().unwrap(); + let xf = doc.world_transform(frame); + let from = xf * Point::new(20.0, 20.0); + // Off by fractions in the frame and 1.3 short of the guide. + let to = from + page_vec(&s, frame, Vec2::new(28.7, 5.4)); + s.pointer(&PointerEvent::at(PointerKind::Down, from.x, from.y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Move, to.x, to.y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, to.x, to.y, 1.0)).unwrap(); + assert_eq!(s.doc().unwrap().node(rect).unwrap().parent, Some(frame)); + let p = local_xy(&s, rect); + assert!(near(p.x, 40.0), "snapped to the guide: {p:?}"); + assert!(near(p.y, p.y.round()), "whole pixels in the frame: {p:?}"); + assert!(near(p.y, 15.0), "{p:?}"); +} + +#[test] +fn siblings_and_page_guides_line_up_inside_a_quarter_turned_frame() { + let (mut s, frame, rect) = setup(90.0); + let page = s.page().unwrap(); + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 120, "y": 60, "width": 30, "height": 30})).unwrap(); + // A page-vertical guide runs along the turned frame's x axis. + s.execute("guide.add", &json!({"vertical": true, "offset": 250})).unwrap(); + let doc = s.doc().unwrap(); + let t = Targets::gather(doc, page, &[rect]); + let y = offset_at(doc, frame, false, Point::new(250.0, 0.0)).unwrap(); + assert!(t.ys.iter().any(|l| near(l.0, y) && l.1 < -1e6), "{y} in {:?}", t.ys); + // The sibling's edges are snap lines in the frame's space. + assert!(t.xs.iter().any(|l| near(l.0, 120.0)) && t.ys.iter().any(|l| near(l.0, 60.0))); + // Moved by (109, 49) in the frame: 1 short of the sibling's left and top edges. + let d = page_vec(&s, frame, Vec2::new(109.0, 49.0)); + let (c, _) = t.snap_move(d, 4.0); + let moved = local_vec(&s, frame, d + c); + assert!(near(moved.x, 110.0) && near(moved.y, 50.0), "{moved:?}"); +} + +#[test] +fn measuring_finds_a_quarter_turned_frames_guides() { + let (s, frame, rect) = setup(90.0); + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + let [a, b] = segment(doc, frame, true, 40.0).unwrap(); + // The frame's vertical guide is a page-horizontal line. + assert!(near(a.y, b.y)); + let found = crate::distance::guide_at(doc, page, &[rect], Point::new(a.x, a.y + 2.0), 4.0); + assert_eq!(found.map(|(v, at)| (v, (at * 1e6).round() / 1e6)), Some((false, (a.y * 1e6).round() / 1e6))); +} + +#[test] +fn snapping_outside_rotated_frames_stays_on_the_page() { + let (s, _, rect) = setup(0.0); + let page = s.page().unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(Targets::gather(doc, page, &[rect]).space, Affine::IDENTITY); + let top = doc.node(page).unwrap().children[0]; + assert_eq!(Targets::gather(doc, page, &[top]).space, Affine::IDENTITY); +} diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index c19a57a2..258a03fa 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -3,10 +3,12 @@ //! 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::{Point, Rect, Vec2}; +use figcraft_doc::{Document, NodeId}; +use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox}; use serde::Serialize; +use crate::guides; + /// A guide line to draw: from (x0, y0) to (x1, y1) in page coordinates, with an optional label /// (a distance) at its middle; `dashed` marks a measurement's extension line. #[derive(Clone, Debug, PartialEq, Serialize)] @@ -42,13 +44,32 @@ pub struct Targets { pub boxes: Vec, /// Points to land on exactly (see [`Targets::add_point`]). pub points: Vec, + /// Page ← snap space. The lines above are in snap space: the page's, or the moving layers' + /// parent frame's own when it is rotated, so that its edges, layout grids and guides (and the + /// siblings turned with it) run along the snap axes. + pub space: Affine, + /// The moving layers' box in snap space when gathered. + pub moving: Option, } impl Targets { /// Gather snap lines from the siblings of `moving` (and the parent frame), page guides and - /// layout grids, excluding the moving nodes and their descendants. + /// layout grids, excluding the moving nodes and their descendants. Inside a rotated frame they + /// are gathered in its space. pub fn gather(doc: &Document, page: NodeId, moving: &[NodeId]) -> Targets { - let mut t = Targets::default(); + let parent = parent_of(doc, page, moving); + let xf = doc.world_transform(parent); + let space = if parent != page && !guides::axis_aligned(xf) { xf } else { Affine::IDENTITY }; + Self::gather_in(doc, page, moving, space) + } + + /// [`Self::gather`] in snap space `space` (page ← snap space; the page's is the identity). + /// Lines that don't run along its axes are left out. + pub fn gather_in(doc: &Document, page: NodeId, moving: &[NodeId], space: Affine) -> Targets { + let to = space.inverse(); + // A layer's box in snap space. + let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite()); + let mut t = Targets { space, moving: moving.iter().filter_map(|m| bbox(*m)).reduce(|a, b| a.union(b)), ..Default::default() }; let push = |r: Rect, t: &mut Targets| { for x in [r.x0, r.center().x, r.x1] { t.xs.push((x, r.y0, r.y1)); @@ -58,10 +79,10 @@ impl Targets { } t.boxes.push(r); }; - let parent = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page); + let parent = parent_of(doc, page, moving); // The parent frame's own box (and its layout grids). if parent != page - && let Some(b) = doc.world_bounds(parent) + && let Some(b) = bbox(parent) { for x in [b.x0, b.center().x, b.x1] { t.xs.push((x, b.y0, b.y1)); @@ -69,7 +90,7 @@ impl Targets { for y in [b.y0, b.center().y, b.y1] { t.ys.push((y, b.x0, b.x1)); } - let (gx, gy) = figcraft_layout::grid_snap_lines(doc, parent); + let (gx, gy) = figcraft_layout::grid_snap_lines_in(doc, parent, to * doc.world_transform(parent)); for x in gx { t.xs.push((x, b.y0, b.y1)); } @@ -83,33 +104,33 @@ impl Targets { if moving.contains(c) || !doc.node(*c).is_some_and(|n| n.visible) { continue; } - if let Some(b) = doc.world_bounds(*c) { + if let Some(b) = bbox(*c) { push(b, &mut t); } } } - if let Some(NodeKind::Page { guides, .. }) = doc.node(page).map(|n| &n.kind) { - for g in guides { - if g.vertical { - t.xs.push((g.offset, -1e7, 1e7)); - } else { - t.ys.push((g.offset, -1e7, 1e7)); + // The guides of `owner` mapped into snap space by `m`, across `span` (the owner's box + // there; None: everywhere). + let lines = |owner: NodeId, m: Affine, span: Option, t: &mut Targets| { + for g in doc.node(owner).map(|n| n.guides()).unwrap_or_default() { + match (guides::line_in(m, g.vertical, g.offset), span) { + (Some((true, x)), Some(b)) => t.xs.push((x, b.y0, b.y1)), + (Some((false, y)), Some(b)) => t.ys.push((y, b.x0, b.x1)), + (Some((true, x)), None) => t.xs.push((x, -1e7, 1e7)), + (Some((false, y)), None) => t.ys.push((y, -1e7, 1e7)), + (None, _) => {} } } - } + }; + lines(page, to, None, &mut t); // The guides of the frames the moving layers are in, across each frame. let mut f = parent; for _ in 0..128 { let Some(n) = doc.node(f).filter(|_| f != page) else { break }; - if let Some(b) = doc.world_bounds(f).filter(|_| !n.guides.is_empty()) { - let xf = doc.world_transform(f); - for g in &n.guides { - if g.vertical { - t.xs.push(((xf * Point::new(g.offset, 0.0)).x, b.y0, b.y1)); - } else { - t.ys.push(((xf * Point::new(0.0, g.offset)).y, b.x0, b.x1)); - } - } + if !n.guides.is_empty() + && let Some(b) = bbox(f) + { + lines(f, to * doc.world_transform(f), Some(b), &mut t); } let Some(up) = n.parent else { break }; f = up; @@ -125,17 +146,62 @@ impl Targets { self.points.push(p); } - /// Snap a moving box: returns the (dx, dy) correction and guides to draw. + /// Snap a moving box `r` given in snap space: returns the correction (a page vector) and the + /// guides to draw. pub fn snap_rect(&self, r: Rect, threshold: f64) -> (f64, f64, Vec) { 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) + let d = self.to_page_vec(Vec2::new(dx.unwrap_or(0.0), dy.unwrap_or(0.0))); + (d.x, d.y, self.to_page(guides)) } - /// Snap a single point (resize handles, drawing). + /// Snap the moving layers moved by page vector `d`: the correction to add to it (a page + /// vector) and the guides to draw. + pub fn snap_move(&self, d: Vec2, threshold: f64) -> (Vec2, Vec) { + let Some(b) = self.moving else { return (Vec2::ZERO, Vec::new()) }; + let (dx, dy, guides) = self.snap_rect(b + self.to_space_vec(d), threshold); + (Vec2::new(dx, dy), guides) + } + + /// Page move `d` adjusted so that the moving layers' box keeps its origin on whole pixels of + /// the snap space (a rotated frame's own, inside one). + pub fn round_move(&self, d: Vec2) -> Vec2 { + let Some(b) = self.moving else { return d }; + let l = self.to_space_vec(d); + self.to_page_vec(Vec2::new((b.x0 + l.x).round() - b.x0, (b.y0 + l.y).round() - b.y0)) + } + + /// Snap a single page point (resize handles, drawing): the correction (a page vector) and + /// the guides to draw. 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) + let q = self.space.inverse() * Point::new(x, y); + let (dx, dy, guides) = self.snap_spans(&[(q.x, q.y, q.y)], &[(q.y, q.x, q.x)], threshold); + let d = self.to_page_vec(Vec2::new(dx.unwrap_or(0.0), dy.unwrap_or(0.0))); + (d.x, d.y, self.to_page(guides)) + } + + /// Page vector `v` in snap space. + fn to_space_vec(&self, v: Vec2) -> Vec2 { + linear(self.space.inverse(), v) + } + + /// Snap-space vector `v` on the page. + fn to_page_vec(&self, v: Vec2) -> Vec2 { + linear(self.space, v) + } + + /// Guides found in snap space, on the page. + fn to_page(&self, guides: Vec) -> Vec { + if self.space == Affine::IDENTITY { + return guides; + } + guides + .into_iter() + .map(|g| { + let (a, b) = (self.space * Point::new(g.x0, g.y0), self.space * Point::new(g.x1, g.y1)); + Guide { x0: a.x, y0: a.y, x1: b.x, y1: b.y, ..g } + }) + .collect() } /// Snap several moving points together (vector points): the correction on each axis (`None` @@ -206,6 +272,17 @@ impl Targets { } } +/// The parent of the moving layers (the first one's), or the page. +fn parent_of(doc: &Document, page: NodeId, moving: &[NodeId]) -> NodeId { + moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent).unwrap_or(page) +} + +/// Vector `v` mapped by `m` (without its translation). +fn linear(m: Affine, v: Vec2) -> Vec2 { + let [a, b, c, d, _, _] = m.as_coeffs(); + Vec2::new(a * v.x + c * v.y, b * v.x + d * v.y) +} + /// 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(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 42f5994e..f0faf7e8 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -281,7 +281,6 @@ pub(crate) enum Gesture { Move { start: Point, ids: Vec, - bounds: Rect, targets: Targets, duplicate: bool, moved: bool, @@ -885,10 +884,9 @@ impl Session { return Ok(()); } let ids = roots(&doc, &new_sel); - let bounds = doc.bounds_of(&ids).unwrap_or_default(); let targets = Targets::gather(&doc, page, &ids); self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?; - self.tool.gesture = Gesture::Move { start: ev.p(), ids, bounds, targets, duplicate: ev.alt, moved: false }; + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false }; self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; } None => { @@ -936,7 +934,7 @@ impl Session { self.set_selection(sel); Ok(()) } - Gesture::Move { start, ids, bounds, targets, duplicate, .. } => { + Gesture::Move { start, ids, targets, duplicate, .. } => { let mut d = ev.p() - *start; if ev.shift { if d.x.abs() > d.y.abs() { @@ -948,16 +946,13 @@ impl Session { let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd; let mut guides = Vec::new(); if snap_on { - let moved = Rect::new(bounds.x0 + d.x, bounds.y0 + d.y, bounds.x1 + d.x, bounds.y1 + d.y); - let (sx, sy, g2) = targets.snap_rect(moved, SNAP_PX / ev.zoom.max(1e-6)); - d.x += sx; - d.y += sy; + let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); + d += c; guides = g2; } if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { - // Keep the box's origin on whole pixels. - d.x = (bounds.x0 + d.x).round() - bounds.x0; - d.y = (bounds.y0 + d.y).round() - bounds.y0; + // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). + d = targets.round_move(d); } let ids = ids.clone(); let dup = *duplicate; diff --git a/crates/engine/src/vectoredit/point_snap.rs b/crates/engine/src/vectoredit/point_snap.rs index a9ade1fb..43d2d162 100644 --- a/crates/engine/src/vectoredit/point_snap.rs +++ b/crates/engine/src/vectoredit/point_snap.rs @@ -26,7 +26,8 @@ const MAX_MOVING: usize = 256; /// 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()); + // Points are snapped on the page. + let mut t = Targets::gather_in(doc, page, edited.as_slice(), figcraft_geom::Affine::IDENTITY); let mut budget = MAX_POINTS; for p in extra.iter().take(budget) { t.add_point(*p); diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 798a613a..6b3fb3ce 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -975,8 +975,13 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec { /// Snap targets of a frame's visible layout grids (page coordinates): column/row edges. pub fn grid_snap_lines(doc: &Document, frame: NodeId) -> (Vec, Vec) { + grid_snap_lines_in(doc, frame, doc.world_transform(frame)) +} + +/// [`grid_snap_lines`] mapped from the frame's coordinates by `xf` (the frame's world transform +/// for page lines). +pub fn grid_snap_lines_in(doc: &Document, frame: NodeId, xf: Affine) -> (Vec, Vec) { let Some(f) = doc.node(frame) else { return (vec![], vec![]) }; - let xf = doc.world_transform(frame); let mut xs = Vec::new(); let mut ys = Vec::new(); for g in f.grids.iter().filter(|g| g.visible) { diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index 1f2d1979..924a19aa 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -1,10 +1,11 @@ //! Ruler guides on the canvas: drag out of a ruler to add one, drag a guide to move it, drag it //! back onto its ruler to remove it. A guide dropped on a frame belongs to that frame: it's kept in -//! the frame's coordinates, moves with it and spans only the frame. Guides show while the rulers -//! do; layers snap to them. +//! the frame's coordinates, moves with it and spans only the frame (along the frame's own axes when +//! it's rotated). Guides show while the rulers do; layers snap to them. use egui::{Color32, CornerRadius, Id, Pos2, Rect, Stroke, pos2, vec2}; use figcraft_doc::{Document, NodeId}; +use figcraft_engine::guides; use figcraft_geom::Point; use serde_json::json; @@ -30,12 +31,11 @@ struct Placed { owner: NodeId, index: usize, vertical: bool, - /// Its offset in the owner's coordinates (what its label shows). + /// Its offset in the owner's coordinates (what its label shows; page coordinates for a page). offset: f64, - /// Its page position (x for vertical guides, y for horizontal). - at: f64, - /// A frame guide's extent across it on the page (y span for vertical guides); None: the page. - span: Option<(f64, f64)>, + /// A frame guide's page segment, along the frame's own axis across it; None: a page guide, + /// across the whole canvas. + line: Option<[Point; 2]>, } fn drag_id() -> Id { @@ -57,7 +57,7 @@ fn placed_guides(app: &FigApp) -> Vec { .unwrap_or_default() .iter() .enumerate() - .map(|(index, g)| Placed { owner: ds.page, index, vertical: g.vertical, offset: g.offset, at: g.offset, span: None }) + .map(|(index, g)| Placed { owner: ds.page, index, vertical: g.vertical, offset: g.offset, line: None }) .collect(); let mut stack: Vec<(NodeId, usize)> = doc.node(ds.page).map(|n| n.children.iter().map(|c| (*c, 0)).collect()).unwrap_or_default(); while let Some((id, depth)) = stack.pop() { @@ -76,42 +76,29 @@ fn placed_guides(app: &FigApp) -> Vec { /// Where guide `index` of frame `owner` at `offset` (frame coordinates) lands on the page. fn place(doc: &Document, owner: NodeId, index: usize, vertical: bool, offset: f64) -> Option { - let b = doc.world_bounds(owner)?; - let xf = doc.world_transform(owner); - let (at, span) = if vertical { ((xf * Point::new(offset, 0.0)).x, (b.y0, b.y1)) } else { ((xf * Point::new(0.0, offset)).y, (b.x0, b.x1)) }; - Some(Placed { owner, index, vertical, offset, at, span: Some(span) }) + Some(Placed { owner, index, vertical, offset, line: Some(guides::segment(doc, owner, vertical, offset)?) }) } -/// A guide dropped at page point `p`: on the frame under it (in the frame's coordinates) or on the -/// page. +/// Guide `index` of `owner` (a page or frame) through page point `p`, its offset rounded to whole +/// pixels with `snap` (else to hundredths). +fn through(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, snap: bool) -> Option { + let ds = app.session.ds().ok()?; + let v = guides::offset_at(&ds.doc, owner, vertical, p)?; + let offset = if snap { v.round() } else { (v * 100.0).round() / 100.0 }; + if owner == ds.page { Some(Placed { owner, index, vertical, offset, line: None }) } else { place(&ds.doc, owner, index, vertical, offset) } +} + +/// A guide dragged out of a ruler (`vertical`: the left one) and dropped at page point `p`: on the +/// frame under it, in the frame's coordinates and along its axis nearer the ruler's, or on the page. fn drop_target(app: &FigApp, p: Point, vertical: bool, snap: bool) -> Option { let ds = app.session.ds().ok()?; - let round = |v: f64| if snap { v.round() } else { (v * 100.0).round() / 100.0 }; let frame = figcraft_engine::cmd::create::frame_at(&ds.doc, ds.page, p, &[]).filter(|f| ds.doc.node(*f).is_some_and(|n| n.kind.is_frame_like())); match frame { - Some(f) => { - let local = ds.doc.world_transform(f).inverse() * p; - place(&ds.doc, f, usize::MAX, vertical, round(if vertical { local.x } else { local.y })) - } - None => { - let at = round(if vertical { p.x } else { p.y }); - Some(Placed { owner: ds.page, index: usize::MAX, vertical, offset: at, at, span: None }) - } + Some(f) => through(app, f, usize::MAX, guides::frame_axis(&ds.doc, f, vertical), p, snap), + None => through(app, ds.page, usize::MAX, vertical, p, snap), } } -/// Where the moved guide of `owner` lands with the pointer at page point `p`. -fn moved(app: &FigApp, owner: NodeId, index: usize, vertical: bool, p: Point, snap: bool) -> Option { - let ds = app.session.ds().ok()?; - let round = |v: f64| if snap { v.round() } else { (v * 100.0).round() / 100.0 }; - if owner == ds.page { - let at = round(if vertical { p.x } else { p.y }); - return Some(Placed { owner, index, vertical, offset: at, at, span: None }); - } - let local = ds.doc.world_transform(owner).inverse() * p; - place(&ds.doc, owner, index, vertical, round(if vertical { local.x } else { local.y })) -} - /// A guide command's params: frame guides name their frame, page guides need no owner. fn owned(owner: NodeId, page: Option, mut params: serde_json::Value) -> serde_json::Value { if Some(owner) != page @@ -126,21 +113,19 @@ fn rulers_on(app: &FigApp) -> bool { app.session.ds().is_ok_and(|d| d.view.rulers) } -/// Screen coordinate of a guide (x for vertical guides, y for horizontal). -fn screen_of(app: &FigApp, g: &Placed) -> f32 { - if g.vertical { app.to_screen(g.at, 0.0).x } else { app.to_screen(0.0, g.at).y } -} - -/// The guide's line on screen, clipped to the canvas `r`. +/// The guide's line on screen: a page guide across the canvas `r`, a frame guide across its frame. fn segment(app: &FigApp, g: &Placed, r: Rect) -> [Pos2; 2] { - let s = screen_of(app, g); - let (a, b) = match g.span { - Some((a, b)) if g.vertical => (app.to_screen(0.0, a).y.max(r.min.y), app.to_screen(0.0, b).y.min(r.max.y)), - Some((a, b)) => (app.to_screen(a, 0.0).x.max(r.min.x), app.to_screen(b, 0.0).x.min(r.max.x)), - None if g.vertical => (r.min.y, r.max.y), - None => (r.min.x, r.max.x), - }; - if g.vertical { [pos2(s, a), pos2(s, b)] } else { [pos2(a, s), pos2(b, s)] } + match g.line { + Some([a, b]) => [app.to_screen(a.x, a.y), app.to_screen(b.x, b.y)], + None if g.vertical => { + let x = app.to_screen(g.offset, 0.0).x; + [pos2(x, r.min.y), pos2(x, r.max.y)] + } + None => { + let y = app.to_screen(0.0, g.offset).y; + [pos2(r.min.x, y), pos2(r.max.x, y)] + } + } } /// The guide within a few points of `pos` (frame guides and later ones first). @@ -148,8 +133,9 @@ fn guide_at(app: &FigApp, pos: Pos2) -> Option { let r = app.canvas.rect; placed_guides(app).into_iter().rev().find(|g| { let [a, b] = segment(app, g, r); - let (across, along, lo, hi) = if g.vertical { (pos.x - a.x, pos.y, a.y, b.y) } else { (pos.y - a.y, pos.x, a.x, b.x) }; - across.abs() <= 3.0 && along >= lo - 3.0 && along <= hi + 3.0 + let ab = b - a; + let t = if ab.length_sq() > 1e-6 { ((pos - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + (a + ab * t).distance(pos) <= 3.0 }) } @@ -157,6 +143,21 @@ fn cursor(vertical: bool) -> egui::CursorIcon { if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical } } +/// The resize cursor for dragging guide `g` across itself, whichever way it runs on screen. +fn cursor_across(app: &FigApp, g: &Placed, r: Rect) -> egui::CursorIcon { + let [a, b] = segment(app, g, r); + let d = b - a; + // The line's direction in eighths of a turn, rounded (screen y points down): 0 runs across, + // 2 runs down. + let eighths = (d.y.atan2(d.x).to_degrees().rem_euclid(180.0) + 22.5) / 45.0; + match eighths as u8 % 4 { + 0 => egui::CursorIcon::ResizeVertical, + 1 => egui::CursorIcon::ResizeNeSw, + 2 => egui::CursorIcon::ResizeHorizontal, + _ => egui::CursorIcon::ResizeNwSe, + } +} + /// Pointer handling; returns true when the event belongs to a ruler or guide. pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { if !rulers_on(app) { @@ -173,7 +174,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { let snap = app.session.ds().map(|d| d.view.snap_pixel).unwrap_or(true); let at = match drag { GuideDrag::New { vertical } => drop_target(app, p, vertical, snap), - GuideDrag::Move { owner, index, vertical } => moved(app, owner, index, vertical, p, snap), + GuideDrag::Move { owner, index, vertical } => through(app, owner, index, vertical, p, snap), }; ctx.data_mut(|d| match at { Some(g) => { @@ -181,18 +182,23 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { } None => d.remove::(preview_id()), }); - ctx.set_cursor_icon(cursor(vertical)); + ctx.set_cursor_icon(at.map_or(cursor(vertical), |g| cursor_across(app, &g, r))); if released { ctx.data_mut(|d| { d.remove::(drag_id()); d.remove::(preview_id()); }); - // Released over its own ruler: no guide (a moved one is removed). - let on_ruler = if vertical { pos.x < r.min.x + RULER } else { pos.y < r.min.y + RULER }; + // Released over its own ruler (the left one for a guide running down the screen): no + // guide (a moved one is removed). + let down = at.map_or(vertical, |g| { + let [a, b] = segment(app, &g, r); + (b.y - a.y).abs() >= (b.x - a.x).abs() + }); + let on_ruler = if down { pos.x < r.min.x + RULER } else { pos.y < r.min.y + RULER }; let page = app.session.ds().map(|d| d.page).ok(); match (drag, on_ruler, at) { (GuideDrag::New { .. }, false, Some(g)) => { - app.run("guide.add", owned(g.owner, page, json!({"vertical": vertical, "offset": g.offset}))); + app.run("guide.add", owned(g.owner, page, json!({"vertical": g.vertical, "offset": g.offset}))); } (GuideDrag::Move { owner, index, .. }, true, _) => { app.run("guide.remove", owned(owner, page, json!({"index": index}))); @@ -222,7 +228,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { let idle = app.session.tool.tool == figcraft_engine::ToolId::Move && app.session.ds().is_ok_and(|d| d.text_edit.is_none() && d.vector_edit.is_none()); if idle && let Some(g) = guide_at(app, pos) { - ctx.set_cursor_icon(cursor(g.vertical)); + ctx.set_cursor_icon(cursor_across(app, &g, r)); if pressed { ctx.data_mut(|d| d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical })); } @@ -267,7 +273,7 @@ fn label(app: &FigApp, painter: &egui::Painter, r: Rect, g: &Placed) { let galley = painter.layout_no_wrap(crate::canvas::fmt(g.offset), semibold(10.0), Color32::WHITE); let size = galley.size() + vec2(8.0, 4.0); let [a, _] = segment(app, g, r); - let at = if g.vertical { pos2(a.x + 4.0, a.y.max(r.min.y + RULER) + 4.0) } else { pos2(a.x.max(r.min.x + RULER) + 4.0, a.y + 4.0) }; + let at = pos2(a.x.clamp(r.min.x + RULER, r.max.x) + 4.0, a.y.clamp(r.min.y + RULER, r.max.y) + 4.0); let tag = Rect::from_min_size(at, size); painter.rect_filled(tag, CornerRadius::same(3), GUIDE); painter.galley(tag.min + vec2(4.0, 2.0), galley, Color32::WHITE); diff --git a/docs/mcp.md b/docs/mcp.md index 7b758987..05ebc938 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -498,7 +498,10 @@ first, the last one is on top (the Design panel lists them the other way round, `guide.remove {index, …}` and `guide.clear {…}` edit the guides of the current page, another `page`, or a `frame` (frames, components and instances). A frame's guides are in its own coordinates: they move with the frame, span only it, are copied with it and snap the layers inside -it. In the app, a guide dragged out of a ruler and dropped on a frame becomes that frame's guide. +it. On a rotated frame they run along its own axes, and the layers inside it snap to them (and to +their siblings and the frame's edges) along those axes, keeping whole pixels in the frame's +coordinates. In the app, a guide dragged out of a ruler and dropped on a frame becomes that frame's +guide, along the frame's axis nearer the ruler's. ### Measuring, the Scale tool, arc handles and selecting