M1: rotated handle box for layers that share a rotation (#30)

Selecting several layers with the same rotation now shows the handle box
turned with them (the size label reads the box's own width and height),
and resizing scales them along the box's axes instead of the page's.
Different or right-angle rotations keep the page-aligned box.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 04:57:31 +07:00 committed by GitHub
parent 3767b74312
commit b572363ac8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 145 additions and 38 deletions

View File

@ -136,7 +136,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Area | Weight | Score | Remaining work | Hours |
|---|---|---|---|---|
| Canvas & navigation | 6 | 65% | pixel preview, frame guides, perf at scale | 6–10 |
| Selection & transform | 8 | 60% | rotated multi-select handles, scale tool polish, measure hover polish | 10–15 |
| Selection & transform | 8 | 64% | scale tool polish, measure hover polish | 8–12 |
| Layers panel | 5 | 70% | drag polish (auto-scroll while dragging, drop into collapsed groups), keyboard row navigation | 4–6 |
| Shapes & vector | 8 | 60% | variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 12–18 |
| Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 |

View File

@ -760,6 +760,8 @@ mod mask_tests;
#[cfg(test)]
mod proto_multi_tests;
#[cfg(test)]
mod rotated_select_tests;
#[cfg(test)]
mod section_tests;
#[cfg(test)]
mod style_tests;

View File

@ -0,0 +1,82 @@
//! Handle boxes for several layers that share a rotation.
use serde_json::json;
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind, selection_box, selection_frame};
use figcraft_doc::NodeId;
use figcraft_geom::{Affine, Point};
fn rect(s: &mut Session, x: f64, y: f64, rotation: f64) -> NodeId {
let id = id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 100, "height": 50})).unwrap()["id"]).unwrap();
s.execute("node.set", &json!({"id": id, "props": {"rotation": rotation}})).unwrap();
id
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
/// The layer's corners in the box's axes.
fn corners_in(s: &Session, frame: Affine, id: NodeId) -> Vec<Point> {
let doc = s.doc().unwrap();
let n = doc.node(id).unwrap();
let m = frame.inverse() * doc.world_transform(id);
[(0.0, 0.0), (n.width, 0.0), (n.width, n.height), (0.0, n.height)].iter().map(|(x, y)| m * Point::new(*x, *y)).collect()
}
#[test]
fn shared_rotation_turns_the_box_with_the_layers() {
let mut s = Session::new();
let ids = [rect(&mut s, 0.0, 0.0, 30.0), rect(&mut s, 40.0, 120.0, 30.0)];
let (frame, w, h) = selection_frame(s.doc().unwrap(), &ids).unwrap();
// The box's axes follow the layers': each sits upright in it, and the box hugs them.
let pts: Vec<Point> = ids.iter().flat_map(|i| corners_in(&s, frame, *i)).collect();
let fold = |f: fn(f64, f64) -> f64, init: f64, k: fn(&Point) -> f64| pts.iter().map(k).fold(init, f);
assert!(near(fold(f64::min, f64::INFINITY, |p| p.x), 0.0) && near(fold(f64::min, f64::INFINITY, |p| p.y), 0.0));
assert!(near(fold(f64::max, f64::NEG_INFINITY, |p| p.x), w) && near(fold(f64::max, f64::NEG_INFINITY, |p| p.y), h));
let c = (frame.inverse() * s.doc().unwrap().world_transform(ids[0])).as_coeffs();
assert!(near(c[0], 1.0) && near(c[1], 0.0) && near(c[2], 0.0) && near(c[3], 1.0), "{c:?}");
// Tighter than the page-aligned bounds.
let b = s.doc().unwrap().bounds_of(&ids).unwrap();
assert!(w * h < b.width() * b.height() - 1.0);
}
#[test]
fn different_or_right_angle_rotations_keep_the_page_aligned_box() {
let mut s = Session::new();
for (r0, r1) in [(30.0, 45.0), (90.0, 90.0), (0.0, 180.0)] {
let ids = [rect(&mut s, 0.0, 0.0, r0), rect(&mut s, 200.0, 50.0, r1)];
let doc = s.doc().unwrap();
let b = doc.bounds_of(&ids).unwrap();
let q = selection_box(doc, &ids).unwrap();
assert_eq!(q, [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)], "{r0} {r1}");
}
}
#[test]
fn resizing_a_rotated_multi_selection_scales_along_its_axes() {
let mut s = Session::new();
let ids = [rect(&mut s, 0.0, 0.0, 30.0), rect(&mut s, 40.0, 120.0, 30.0)];
s.execute("select.set", &json!({"ids": ids})).unwrap();
let (frame, w, h) = selection_frame(s.doc().unwrap(), &ids).unwrap();
// Drag the bottom-right handle to make the box 1.5× wider and 2× taller.
let (from, to) = (frame * Point::new(w, h), frame * Point::new(w * 1.5, h * 2.0));
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();
let doc = s.doc().unwrap();
let (f2, w2, h2) = selection_frame(doc, &ids).unwrap();
assert!(near(w2, w * 1.5) && near(h2, h * 2.0), "{w2} {h2}");
// The opposite corner stays put and the layers keep their rotation and grow with the box.
assert!((f2 * Point::ZERO - frame * Point::ZERO).hypot() < 1e-6);
for i in ids {
let n = doc.node(i).unwrap();
assert!(near(n.width, 150.0) && near(n.height, 100.0), "{} {}", n.width, n.height);
assert!(near(figcraft_geom::rotation_degrees(n.transform), 30.0));
}
// One undo step restores both.
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(s.doc().unwrap().node(ids[0]).unwrap().width, 100.0));
}

View File

@ -233,9 +233,10 @@ pub struct Overlay {
pub(crate) struct ResizeStart {
/// Node id → (world transform, w, h) at the start.
nodes: Vec<(NodeId, Affine, f64, f64)>,
/// Single node: its world transform and size; else the AABB.
single: Option<(Affine, f64, f64)>,
bounds: Rect,
/// The handle box: page transform of its top-left corner (axes along the box) and its size.
frame: (Affine, f64, f64),
/// One layer (resized in place) rather than several (mapped from the old box to the new).
single: bool,
handle: Handle,
}
@ -327,16 +328,48 @@ const SNAP_PX: f64 = 5.0;
/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl).
pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> {
let (f, w, h) = selection_frame(doc, sel)?;
Some([f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)])
}
/// The selection's handle box: the page transform of its top-left corner (axes along the box) and
/// its size. One layer: its own box; several sharing a rotation: a box turned with them;
/// otherwise the page-aligned bounds.
pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f64)> {
let ids = roots(doc, sel);
if ids.len() == 1 {
let id = ids.first().copied()?;
let n = doc.node(id)?;
let w = doc.world_transform(id);
let (x0, y0, x1, y1) = (0.0, 0.0, n.width, n.height);
return Some([w * Point::new(x0, y0), w * Point::new(x1, y0), w * Point::new(x1, y1), w * Point::new(x0, y1)]);
if let [id] = ids.as_slice() {
let n = doc.node(*id)?;
return Some((doc.world_transform(*id), n.width, n.height));
}
let b = doc.bounds_of(&ids)?;
Some([Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)])
let Some(angle) = shared_rotation(doc, &ids) else {
let b = doc.bounds_of(&ids)?;
return Some((Affine::translate((b.x0, b.y0)), b.width(), b.height()));
};
let rot = Affine::rotate(angle);
let inv = rot.inverse();
let b = ids
.iter()
.filter_map(|i| Some(figcraft_geom::transform_rect_bbox(inv * doc.world_transform(*i), doc.node(*i)?.local_rect())))
.reduce(|a, b| a.union(b))?;
Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height()))
}
/// 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> {
use std::f64::consts::{FRAC_PI_2, PI};
const EPS: f64 = 1e-4;
let mut angles = ids.iter().map(|i| {
let c = doc.world_transform(*i).as_coeffs();
c[1].atan2(c[0])
});
let a = angles.next()?;
let same = |b: f64| {
let d = (b - a).rem_euclid(PI);
d.min(PI - d) < EPS
};
let axis_aligned = (a - (a / FRAC_PI_2).round() * FRAC_PI_2).abs() < EPS;
(a.is_finite() && !axis_aligned && angles.all(same)).then_some(a)
}
/// Which handle (if any) is under `p`.
@ -530,12 +563,12 @@ impl Session {
}
let nodes: Vec<(NodeId, Affine, f64, f64)> =
ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect();
let single = if ids.len() == 1 { nodes.first().map(|(_, w, a, b)| (*w, *a, *b)) } else { None };
let bounds = doc.bounds_of(&ids).unwrap_or_default();
let Some(frame) = selection_frame(&doc, &ids) else { return Ok(()) };
let single = ids.len() == 1;
self.begin_interaction(if self.tool.tool == ToolId::Scale { "Scale" } else { "Resize" })?;
self.tool.gesture = Gesture::Resize {
start: ev.p(),
r: Box::new(ResizeStart { nodes, single, bounds, handle: h }),
r: Box::new(ResizeStart { nodes, frame, single, handle: h }),
scale_tool: self.tool.tool == ToolId::Scale,
};
return Ok(());
@ -784,11 +817,8 @@ impl Session {
guides = g;
}
let _ = start;
// In the reference frame: single node → its local space; several → page space AABB.
let (frame, w0, h0) = match r.single {
Some((w, a, b)) => (w, a, b),
None => (Affine::translate((r.bounds.x0, r.bounds.y0)), r.bounds.width(), r.bounds.height()),
};
// In the handle box's own axes (one layer: its local space).
let (frame, w0, h0) = r.frame;
let q = frame.inverse() * p;
let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0);
match r.handle {
@ -814,8 +844,7 @@ impl Session {
Handle::Edge(_) => x0 = q.x,
Handle::Rotate(_) => {}
}
let is_line =
r.single.is_some() && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false);
let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false);
if is_line {
// Line end handles: move the end point freely (length + angle).
let (a, b) = match r.handle {
@ -840,7 +869,7 @@ impl Session {
}
// Constrain proportions (Shift, or the node's lock).
let keep = ev.shift
|| r.single.is_some() && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false)
|| r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false)
|| scale_tool;
if keep && w0 > 1e-9 && h0 > 1e-9 {
let (sx, sy) = ((x1 - x0) / w0, (y1 - y0) / h0);
@ -922,7 +951,7 @@ impl Session {
let (nx0, ny0) = (x0.min(x1), y0.min(y1));
let (nw, nh) = ((x1 - x0).abs(), (y1 - y0).abs());
let nodes = r.nodes.clone();
let single = r.single.is_some();
let single = r.single;
let handle = r.handle;
self.preview(|doc, _| {
if single {
@ -957,15 +986,14 @@ impl Session {
}
return Ok(());
}
// Several layers: map the old AABB onto the new one.
// Several layers: map the old box onto the new one, in the box's axes.
let (sx, sy) = (if w0 > 1e-9 { nw / w0 } else { 1.0 }, if h0 > 1e-9 { nh / h0 } else { 1.0 });
let origin = Point::new(r.bounds.x0, r.bounds.y0);
let new_origin = Point::new(r.bounds.x0 + nx0, r.bounds.y0 + ny0);
let inv = frame.inverse();
for (id, world, w, h) in &nodes {
let o = *world * Point::ZERO;
let no = new_origin + Vec2::new((o.x - origin.x) * sx, (o.y - origin.y) * sy);
let c = world.as_coeffs();
doc.set_world_transform(*id, Affine::new([c[0], c[1], c[2], c[3], no.x, no.y]))?;
let local = inv * *world;
let o = local * Point::ZERO;
let c = local.as_coeffs();
doc.set_world_transform(*id, frame * Affine::new([c[0], c[1], c[2], c[3], nx0 + o.x * sx, ny0 + o.y * sy]))?;
if scale_tool {
crate::cmd::arrange::scale_node(doc, *id, sx.min(sy))?;
} else {

View File

@ -5,7 +5,7 @@ use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_engine::Preedit;
use figcraft_engine::tools::{Cursor, PointerEvent, PointerKind, ToolId, selection_box};
use figcraft_engine::tools::{Cursor, PointerEvent, PointerKind, ToolId, selection_box, selection_frame};
use figcraft_geom::Point;
use serde_json::json;
@ -608,13 +608,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) {
}
// Size label under the box.
if app.session.prefs.show_dimensions
&& let Some(bb) = doc.bounds_of(&sel)
&& let Some((_, w, h)) = selection_frame(&doc, &sel)
{
let (w, h) = if sel.len() == 1 {
sel.first().and_then(|i| doc.node(*i)).map(|n| (n.width, n.height)).unwrap_or((bb.width(), bb.height()))
} else {
(bb.width(), bb.height())
};
let label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) };
let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max);
let cx = q.iter().map(|p| p.x).sum::<f32>() / 4.0;