mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 16:34:55 +00:00
Merge main into #2147
This commit is contained in:
@@ -32,7 +32,7 @@ use handles::DRAG_SLOP_PX;
|
||||
pub(crate) use handles::scale_factor;
|
||||
pub use handles::{
|
||||
PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point,
|
||||
selection_box, selection_frame,
|
||||
selection_box, selection_frame, shared_rotation_degrees,
|
||||
};
|
||||
pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box};
|
||||
#[cfg(test)]
|
||||
@@ -454,6 +454,8 @@ pub(crate) enum Gesture {
|
||||
Rotate {
|
||||
center: Point,
|
||||
start_angle: f64,
|
||||
/// The rotation (Design panel convention) the layers share when the drag began, else 0.
|
||||
base: f64,
|
||||
ids: Vec<NodeId>,
|
||||
},
|
||||
Draw {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::handles::{
|
||||
SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame,
|
||||
shape_hint,
|
||||
shape_hint, shared_rotation_degrees,
|
||||
};
|
||||
use super::{
|
||||
Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point,
|
||||
@@ -58,7 +58,8 @@ impl Session {
|
||||
.moved_origin()
|
||||
.unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::<f64>() / 4.0, b.iter().map(|c| c.y).sum::<f64>() / 4.0));
|
||||
self.begin_interaction("Rotate")?;
|
||||
self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids };
|
||||
self.tool.gesture =
|
||||
Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), base: shared_rotation_degrees(&doc, &ids), ids };
|
||||
return Ok(());
|
||||
}
|
||||
let nodes: Vec<(NodeId, Affine, f64, f64)> =
|
||||
@@ -362,10 +363,11 @@ impl Session {
|
||||
}
|
||||
return res;
|
||||
}
|
||||
Gesture::Rotate { center, start_angle, ids } => {
|
||||
Gesture::Rotate { center, start_angle, base, ids } => {
|
||||
let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees();
|
||||
if ev.shift {
|
||||
a = (a / 15.0).round() * 15.0;
|
||||
// The panel rotation is base - a; Shift lands it on a multiple of 15 degrees.
|
||||
a = base - ((base - a) / 15.0).round() * 15.0;
|
||||
}
|
||||
let (c, ids) = (*center, ids.clone());
|
||||
self.preview(|doc, _| {
|
||||
|
||||
@@ -132,6 +132,14 @@ pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f
|
||||
Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height()))
|
||||
}
|
||||
|
||||
/// The rotation `ids` share on the page (degrees, counter-clockwise positive), else 0 (the handle
|
||||
/// box is page-aligned then). Figma's rotation field shows this page rotation, also for a layer
|
||||
/// inside a rotated frame or group, and Shift lands it on multiples of 15 degrees. Axis-aligned
|
||||
/// layers give 0, which is the same as 90, 180 and 270 for stepping in 15 degrees.
|
||||
pub fn shared_rotation_degrees(doc: &Document, ids: &[NodeId]) -> f64 {
|
||||
shared_rotation(doc, ids).map_or(0.0, |a| -a.to_degrees())
|
||||
}
|
||||
|
||||
/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the
|
||||
/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction.
|
||||
fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option<f64> {
|
||||
|
||||
@@ -369,6 +369,73 @@ fn resizing_and_flipping_one_layer() {
|
||||
assert!(near(rotation(&s, t).abs(), 30.0), "Shift: the nearest 15° step: {}", rotation(&s, t));
|
||||
}
|
||||
|
||||
/// A rotate-handle drag on a layer at `start` degrees, turning it by `turn` degrees in the Design
|
||||
/// panel's convention (positive is counter-clockwise on screen); returns the rotation it ends at.
|
||||
fn rotate_drag_from(start: f64, turn: f64, m: Mods) -> f64 {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 200.0, 100.0, 100.0, None);
|
||||
set(&mut s, &[r], json!({"rotation": start}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
// The handle sits just outside the top-left corner of the turned box; the pointer then circles
|
||||
// the centre by `turn` (the page angle runs the other way round, y being down).
|
||||
let world = s.doc().unwrap().world_transform(r);
|
||||
let (c, corner) = (world * Point::new(50.0, 50.0), world * Point::ZERO);
|
||||
let from = corner + (corner - c) * 0.15;
|
||||
let a = (from - c).atan2() - turn.to_radians();
|
||||
let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0;
|
||||
drag(&mut s, from, to, m);
|
||||
rotation(&s, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start() {
|
||||
// Measured in Figma: a layer at 7° (or -4.27°) Shift-rotated by its corner ends at 15°, so the
|
||||
// steps are absolute multiples of 15°, not 15° steps from where the drag began.
|
||||
let got = rotate_drag_from(7.0, 37.0, SHIFT);
|
||||
assert!(near(got, 45.0), "7° turned by 37° is 44°, nearest multiple of 15 is 45°: {got}");
|
||||
// Without Shift the turn is free.
|
||||
let got = rotate_drag_from(7.0, 37.0, NONE);
|
||||
assert!(near(got, 44.0), "a free turn: {got}");
|
||||
// A small turn: 7° + 3° = 10°, nearest multiple of 15 is 15° (not 7° or 10°).
|
||||
let got = rotate_drag_from(7.0, 3.0, SHIFT);
|
||||
assert!(near(got, 15.0), "10° rounds to 15°: {got}");
|
||||
let got = rotate_drag_from(-4.27, 2.0, SHIFT);
|
||||
assert!(near(got, 0.0), "-2.27° rounds to 0°: {got}");
|
||||
}
|
||||
|
||||
/// Rotates a 120 × 80 layer inside a frame turned `frame_rot`, the layer at `start` on the page,
|
||||
/// by `turn` from its corner, and returns its rotation on the page.
|
||||
fn rotate_drag_in_frame(frame_rot: f64, start: f64, turn: f64, m: Mods) -> f64 {
|
||||
let mut s = session();
|
||||
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 400, "height": 400})));
|
||||
set(&mut s, &[f], json!({"rotation": frame_rot}));
|
||||
let r = rect(&mut s, 100.0, 100.0, 120.0, 80.0, Some(f));
|
||||
set(&mut s, &[r], json!({"rotation": start - frame_rot}));
|
||||
let page = |s: &Session| rotation_degrees(s.doc().unwrap().world_transform(r));
|
||||
assert!(near(page(&s), start), "the layer starts at {start}° on the page: {}", page(&s));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let world = s.doc().unwrap().world_transform(r);
|
||||
let (c, corner) = (world * Point::new(60.0, 40.0), world * Point::ZERO);
|
||||
let from = corner + (corner - c) * 0.15;
|
||||
let a = (from - c).atan2() - turn.to_radians();
|
||||
let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0;
|
||||
drag(&mut s, from, to, m);
|
||||
page(&s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_rotating_inside_a_rotated_frame_lands_the_page_rotation_on_multiples_of_15() {
|
||||
// Measured in Figma (2026-11-10): the rotation field of a layer inside a frame turned 10° shows
|
||||
// its rotation on the page, and Shift lands that on multiples of 15°: from 7°, turns of 40°,
|
||||
// 6° and -10° end at 45°, 15° and 0°; without Shift 6° ends at 13°. Groups behave the same.
|
||||
for (turn, want) in [(40.0, 45.0), (6.0, 15.0), (-10.0, 0.0)] {
|
||||
let got = rotate_drag_in_frame(10.0, 7.0, turn, SHIFT);
|
||||
assert!(near(got, want), "7° on the page turned by {turn}° with Shift: {got}, want {want}");
|
||||
}
|
||||
let got = rotate_drag_in_frame(10.0, 7.0, 6.0, NONE);
|
||||
assert!(near(got, 13.0), "a free turn: {got}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_scale_children_keeping_their_rotation_and_flip() {
|
||||
let mut s = session();
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user