M8: rotate from selection corners, Rotate/Scale/Shear tools, Eyedropper (match attributes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 11:06:16 -04:00
co-authored by Claude Opus 5.5
parent 984435ed6f
commit c244e32bfe
5 changed files with 197 additions and 1 deletions
+40
View File
@@ -28,6 +28,46 @@ pub fn specs() -> Vec<CommandSpec> {
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) })
+18
View File
@@ -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();
}
+5
View File
@@ -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<dyn Tool> {
"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"))),
+34 -1
View File
@@ -16,6 +16,7 @@ enum Drag {
Pending { start: Point, hit: bool },
Move { start: Point, origin_spread: SpreadRef, bounds0: Option<Rect> },
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<usize> {
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<Point> {
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,
}
}
+100
View File
@@ -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<Action> {
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<Action> {
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
}
}