mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 12:46:53 -06:00
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:
co-authored by
Claude Opus 5.5
parent
984435ed6f
commit
c244e32bfe
@@ -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) })
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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"))),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user