Merge main into #2191

This commit is contained in:
bflatastic
2026-11-12 12:26:47 +07:00
5 changed files with 87 additions and 7 deletions
+3 -1
View File
@@ -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 {
+6 -4
View File
@@ -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
View File
File diff suppressed because one or more lines are too long