diff --git a/ROADMAP.md b/ROADMAP.md index aac08e5..26e8843 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cb11048..f27cb1f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/rotated_select_tests.rs b/crates/engine/src/rotated_select_tests.rs new file mode 100644 index 0000000..000acd0 --- /dev/null +++ b/crates/engine/src/rotated_select_tests.rs @@ -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 { + 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 = 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)); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 67f579b..0141cc6 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -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 { + 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 { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index dd25ee1..689a4e0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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::() / 4.0;