M1: rulers and guides on rotated frames (#87)

A rotated frame's guides run along its own axes and span only the frame
(drawn, hit-tested and dragged along them, with a cursor that follows the
line); a guide dropped from a ruler onto a turned frame converts the page
point into the frame's coordinates, along its axis nearer the ruler's.
Layers inside a rotated frame snap in its space: to its guides, edges,
layout grids and siblings along its axes, keeping whole pixels there.
New engine module `guides` (segment, offset_at, frame_axis, line_in);
measuring finds quarter-turned frames' guides too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 17:31:37 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 70b2666c90
commit 61c2b8d0f0
11 changed files with 436 additions and 111 deletions
+1 -1
View File
@@ -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 |
+2 -9
View File
@@ -146,7 +146,7 @@ pub fn to_guide(a: Rect, vertical: bool, at: f64) -> Vec<Guide> {
}
/// 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()
+69
View File
@@ -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<f64> {
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)
}
+3
View File
@@ -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;
+173
View File
@@ -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);
}
+106 -29
View File
@@ -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<Rect>,
/// Points to land on exactly (see [`Targets::add_point`]).
pub points: Vec<Point>,
/// 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<Rect>,
}
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<Rect>, 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<Guide>) {
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<Guide>) {
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<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)
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<Guide>) -> Vec<Guide> {
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();
+6 -11
View File
@@ -281,7 +281,6 @@ pub(crate) enum Gesture {
Move {
start: Point,
ids: Vec<NodeId>,
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;
+2 -1
View File
@@ -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<NodeId>, 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);
+6 -1
View File
@@ -975,8 +975,13 @@ pub fn grid_lines(g: &LayoutGrid, w: f64, h: f64) -> Vec<Rect> {
/// Snap targets of a frame's visible layout grids (page coordinates): column/row edges.
pub fn grid_snap_lines(doc: &Document, frame: NodeId) -> (Vec<f64>, Vec<f64>) {
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<f64>, Vec<f64>) {
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) {
+64 -58
View File
@@ -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<Placed> {
.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<Placed> {
/// 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<Placed> {
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<Placed> {
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<Placed> {
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<Placed> {
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<NodeId>, 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<Placed> {
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::<Placed>(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::<GuideDrag>(drag_id());
d.remove::<Placed>(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);
+4 -1
View File
@@ -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