mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
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:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user