diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index caf5c6435..daf1c45cb 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -28,6 +28,46 @@ pub fn specs() -> Vec { cmd!("transform.move", "Move", ["Object", "Transform"], None, "{dx, dy, copy?: bool, ids?, toSpread?}", has_selection, transform_move), cmd!("transform.resize", "Resize", [], None, "{from: rect, to: rect, content?: bool (scale content), ids?}", has_selection, transform_resize), cmd!("transform.rotate", "Rotate", ["Object", "Transform"], None, "{angle (degrees, CCW), ids?}", has_selection, transform_rotate), + cmd!("transform.scale", "Scale", ["Object", "Transform"], None, "{sx, sy, ids?} (about the selection centre)", has_selection, |s, p| { + let sx = f64_or(p, "sx", 1.0); + let sy = f64_or(p, "sy", sx); + if sx.abs() < 1e-4 || sy.abs() < 1e-4 { + return Err(bad("transform.scale", "scale too small")); + } + apply_about_center(s, p, Affine::scale_non_uniform(sx, sy)) + }), + cmd!("transform.shear", "Shear", ["Object", "Transform"], None, "{angle (degrees), ids?}", has_selection, |s, p| { + let a = f64_or(p, "angle", 0.0).clamp(-85.0, 85.0).to_radians().tan(); + apply_about_center(s, p, Affine::new([1.0, 0.0, a, 1.0, 0.0, 0.0])) + }), + cmd!( + "object.matchAttributes", + "Apply Attributes (Eyedropper)", + [], + None, + "{from: id, ids?} — copy fill, stroke, corners, opacity, effects, wrap", + has_selection, + |s, p| { + let from = super::id_param(p, "from").ok_or_else(|| bad("object.matchAttributes", "missing from"))?; + let src = s.doc()?.doc.item(from).cloned().ok_or(designcraft_doc::DocError::NoItem(from))?; + set_flag( + s, + p, + move |i| { + if i.id == src.id { + return; + } + i.fill = src.fill.clone(); + i.stroke = src.stroke.clone(); + i.corners = src.corners; + i.opacity = src.opacity; + i.blend = src.blend; + i.effects = src.effects.clone(); + }, + false, + ) + } + ), cmd!("transform.flip", "Flip", ["Object", "Transform"], None, "{axis: horizontal|vertical, ids?}", has_selection, |s, p| { let h = str_param(p, "axis") != Some("vertical"); apply_about_center(s, p, if h { Affine::scale_non_uniform(-1.0, 1.0) } else { Affine::scale_non_uniform(1.0, -1.0) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3aabba3fd..c0147d277 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -162,3 +162,21 @@ fn pen_draws_and_direct_selection_edits() { let a = s.doc().unwrap().doc.item(id).unwrap().path.subpaths[0].anchors[0].p; assert_eq!(a, designcraft_geom::Point::new(90.0, 80.0)); } + +#[test] +fn rotate_scale_shear_and_eyedropper() { + let mut s = session(); + let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [300, 100, 340, 140], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("transform.rotate", &json!({"ids": [a], "angle": 90})).unwrap(); + let r = s.doc().unwrap().doc.item(designcraft_doc::ItemId(a)).unwrap().bounds(); + assert!((r.width() - 50.0).abs() < 1e-6 && (r.height() - 100.0).abs() < 1e-6, "{r:?}"); + s.execute("transform.scale", &json!({"ids": [b], "sx": 2.0})).unwrap(); + let r = s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().bounds(); + assert!((r.width() - 80.0).abs() < 1e-6, "{r:?}"); + s.execute("selection.set", &json!({"ids": [b]})).unwrap(); + s.execute("object.matchAttributes", &json!({"from": a})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().fill.swatch, "C=100 M=0 Y=0 K=0"); + s.execute("transform.shear", &json!({"ids": [b], "angle": 20})).unwrap(); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 61b2ce390..c602f0608 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -16,6 +16,7 @@ mod pen; pub mod select; pub mod snap; mod text; +mod xform; use designcraft_compose::Cache; use designcraft_doc::{Document, Item, ItemId, Selection, SpreadRef}; @@ -237,6 +238,10 @@ pub fn create(id: &str) -> Box { "rectangleFrame" | "ellipseFrame" | "polygonFrame" | "rectangle" | "ellipse" | "polygon" | "line" => Box::new(frame::FrameTool::new(id)), "type" => Box::new(text::TypeTool::default()), "pen" => Box::new(pen::PenTool::default()), + "rotate" => Box::new(xform::XformTool::new("rotate")), + "scale" => Box::new(xform::XformTool::new("scale")), + "shear" => Box::new(xform::XformTool::new("shear")), + "eyedropper" => Box::new(xform::EyedropperTool), "hand" => Box::new(nav::HandTool::default()), "zoom" => Box::new(nav::ZoomTool), other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))), diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1c9e1bb4e..6d07908fc 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -16,6 +16,7 @@ enum Drag { Pending { start: Point, hit: bool }, Move { start: Point, origin_spread: SpreadRef, bounds0: Option }, Resize { handle: usize, start: Point, from: Rect, spread: SpreadRef }, + Rotate { center: Point, start_angle: f64 }, Marquee { start: Point, cur: Point }, Anchor { id: u64, si: usize, ai: usize, handle: Option<&'static str>, start: Point }, } @@ -77,6 +78,21 @@ fn handle_at(cx: &ToolContext, p: Point) -> Option { handles(b).iter().position(|h| (h.x - p.x).abs() <= tol && (h.y - p.y).abs() <= tol) } +/// Just outside a corner handle of the selection → rotate. +fn rotate_zone(cx: &ToolContext, p: Point) -> Option { + let b = cx.selection_bounds()?; + let inner = cx.tol(5.0); + let outer = cx.tol(18.0); + for c in [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 d = (c - p).hypot(); + let outside = p.x < b.x0 || p.x > b.x1 || p.y < b.y0 || p.y > b.y1; + if d > inner && d <= outer && outside { + return Some(b.center()); + } + } + None +} + /// New rect when dragging `handle` of `from` to `p`. pub fn resize_rect(from: Rect, handle: usize, p: Point, m: Mods) -> Rect { let (mut x0, mut y0, mut x1, mut y1) = (from.x0, from.y0, from.x1, from.y1); @@ -168,6 +184,13 @@ impl Tool for SelectionTool { self.drag = Drag::Anchor { id, si, ai, handle, start: p }; return vec![Action::Begin(if handle.is_some() { "Move Direction Handle".into() } else { "Move Anchor".into() })]; } + if !self.direct + && handle_at(cx, p).is_none() + && let Some(center) = rotate_zone(cx, p) + { + self.drag = Drag::Rotate { center, start_angle: (p - center).atan2() }; + return vec![Action::Begin("Rotate".into())]; + } if let Some(h) = handle_at(cx, p).filter(|_| !self.direct) && let (Some(b), Some(first)) = (cx.selection_bounds(), cx.selection.items.first()) && let Some(loc) = cx.doc.find(*first) @@ -249,6 +272,14 @@ impl Tool for SelectionTool { self.drag = Drag::Marquee { start, cur: p }; vec![] } + Drag::Rotate { center, start_angle } => { + let mut a = ((p - center).atan2() - start_angle).to_degrees(); + if ev.mods.shift { + a = (a / 45.0).round() * 45.0; + } + // Screen y points down: a positive screen angle is clockwise. + vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))] + } Drag::Anchor { id, si, ai, handle, start } => { let d = p - start; let mut params = json!({"id": id, "anchors": [[si, ai]], "dx": d.x, "dy": d.y}); @@ -263,7 +294,7 @@ impl Tool for SelectionTool { self.guides.clear(); let d = std::mem::replace(&mut self.drag, Drag::None); match d { - Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } => vec![Action::Commit], + Drag::Move { .. } | Drag::Resize { .. } | Drag::Anchor { .. } | Drag::Rotate { .. } => vec![Action::Commit], Drag::Marquee { start, cur } => { let r = Rect::from_points(start, cur); // Items whose bounds intersect the marquee. @@ -331,10 +362,12 @@ impl Tool for SelectionTool { match self.drag { Drag::Move { .. } => return Cursor::Move, Drag::Resize { handle, .. } => return handle_cursor(handle), + Drag::Rotate { .. } => return Cursor::Rotate, _ => {} } match handle_at(cx, p) { Some(h) => handle_cursor(h), + None if rotate_zone(cx, p).is_some() => Cursor::Rotate, None => Cursor::Arrow, } } diff --git a/crates/tools/src/xform.rs b/crates/tools/src/xform.rs new file mode 100644 index 000000000..603018062 --- /dev/null +++ b/crates/tools/src/xform.rs @@ -0,0 +1,100 @@ +//! Rotate (R), Scale (S) and Shear (O) tools: drag around the selection centre (Alt-click sets +//! the reference point elsewhere in a later iteration); Eyedropper (I): click an object to apply +//! its attributes to the selection. + +use designcraft_geom::Point; +use serde_json::json; + +use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; + +pub struct XformTool { + id: &'static str, + drag: Option<(Point, Point)>, +} + +impl XformTool { + pub fn new(id: &'static str) -> Self { + Self { id, drag: None } + } +} + +impl Tool for XformTool { + fn id(&self) -> &'static str { + self.id + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + let Some(b) = cx.selection_bounds() else { return vec![] }; + self.drag = Some((b.center(), ev.pos)); + vec![Action::Begin(match self.id { + "rotate" => "Rotate".into(), + "scale" => "Scale".into(), + _ => "Shear".into(), + })] + } + PointerKind::Drag => { + let Some((c, start)) = self.drag else { return vec![] }; + let (v0, v1) = (start - c, ev.pos - c); + match self.id { + "rotate" => { + let mut a = (v1.atan2() - v0.atan2()).to_degrees(); + if ev.mods.shift { + a = (a / 45.0).round() * 45.0; + } + vec![Action::Preview("transform.rotate".into(), json!({"angle": -a}))] + } + "scale" => { + let (mut sx, mut sy) = (v1.x / v0.x.abs().max(1e-6) * v0.x.signum(), v1.y / v0.y.abs().max(1e-6) * v0.y.signum()); + if ev.mods.shift { + let s = sx.abs().max(sy.abs()); + sx = s; + sy = s; + } + vec![Action::Preview("transform.scale".into(), json!({"sx": sx, "sy": sy}))] + } + _ => { + let a = ((v1.x - v0.x) / v0.y.abs().max(20.0)).atan().to_degrees(); + vec![Action::Preview("transform.shear".into(), json!({"angle": a}))] + } + } + } + PointerKind::Up => { + if self.drag.take().is_some() { + vec![Action::Commit] + } else { + vec![] + } + } + _ => vec![], + } + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + if self.id == "rotate" { Cursor::Rotate } else { Cursor::Crosshair } + } + fn busy(&self) -> bool { + self.drag.is_some() + } +} + +#[derive(Default)] +pub struct EyedropperTool; + +impl Tool for EyedropperTool { + fn id(&self) -> &'static str { + "eyedropper" + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + if ev.kind != PointerKind::Down { + return vec![]; + } + let Some((_, id)) = cx.hit(ev.pos) else { return vec![] }; + if cx.selection.items.is_empty() && cx.selection.text.is_none() { + return vec![Action::Exec("selection.set".into(), json!({"ids": [id.0]}))]; + } + vec![Action::Exec("object.matchAttributes".into(), json!({"from": id.0}))] + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Eyedropper + } +}