mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
While a mask is edited, the Transparency panel and its commands act on the masked object (Release leaves editing first). Mask commands and transparency.set take ids and work without a selection; Make Opacity Mask with one object adds an empty mask and enters editing. Opacity is 0..100 in every command. transparency.info reports the panel's values with null for mixed ones, shown blank. Saves and exports never include the editing layer (Document::without_edit_modes; mask_edit is not serialized). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
13 KiB
Rust
291 lines
13 KiB
Rust
//! Transform & utility tools driven through `Session::pointer`, and their commands.
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_geom::{Point, Rect};
|
||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||
|
||
use super::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
|
||
NodeId(r["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
|
||
}
|
||
|
||
fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) -> Vec<UiRequest> {
|
||
let v = ViewInfo::default();
|
||
s.select_tool(tool, v).unwrap();
|
||
let mut out = vec![];
|
||
for (i, (x, y)) in pts.iter().enumerate() {
|
||
let kind = if i == 0 {
|
||
PointerKind::Down
|
||
} else if i == pts.len() - 1 {
|
||
PointerKind::Up
|
||
} else {
|
||
PointerKind::Drag
|
||
};
|
||
out.extend(s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(mods), v).unwrap());
|
||
}
|
||
out
|
||
}
|
||
|
||
fn close(a: Rect, b: Rect) -> bool {
|
||
(a.x0 - b.x0).abs() < 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && (a.x1 - b.x1).abs() < 1e-6 && (a.y1 - b.y1).abs() < 1e-6
|
||
}
|
||
|
||
fn undo_len(s: &Session) -> usize {
|
||
s.doc().unwrap().history.undo.len()
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_tool_drag_rotates_about_center_one_undo() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||
let n = undo_len(&s);
|
||
gesture(&mut s, "rotate", &[(250.0, 125.0), (200.0, 175.0), (150.0, 225.0), (150.0, 225.0)], Mods::default());
|
||
// 90° about (150,125): 100×50 becomes 50×100.
|
||
assert!(close(bounds(&s, a), Rect::new(125.0, 75.0, 175.0, 175.0)), "{:?}", bounds(&s, a));
|
||
assert_eq!(undo_len(&s), n + 1);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_tool_alt_release_copies() {
|
||
let mut s = session();
|
||
rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||
let alt = Mods { alt: true, ..Default::default() };
|
||
let v = ViewInfo::default();
|
||
s.select_tool("rotate", v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 125.0), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 225.0), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 225.0).with_mods(alt), v).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn rotate_tool_alt_click_requests_dialog() {
|
||
let mut s = session();
|
||
rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||
let alt = Mods { alt: true, ..Default::default() };
|
||
let ui = gesture(&mut s, "rotate", &[(10.0, 20.0), (10.0, 20.0)], alt);
|
||
assert_eq!(ui, vec![UiRequest::Dialog("rotate".into(), json!({"angle": 0, "origin": [10.0, 20.0]}))]);
|
||
}
|
||
|
||
#[test]
|
||
fn scale_tool_click_origin_then_drag() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
gesture(&mut s, "scale", &[(100.0, 100.0), (100.0, 100.0)], Mods::default());
|
||
gesture(&mut s, "scale", &[(200.0, 200.0), (250.0, 250.0), (300.0, 300.0), (300.0, 300.0)], Mods::default());
|
||
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 300.0)), "{:?}", bounds(&s, a));
|
||
}
|
||
|
||
#[test]
|
||
fn reflect_and_shear_tools() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||
// Reflect across a vertical axis through the centre (150,125): bounds unchanged, geometry mirrored.
|
||
gesture(&mut s, "reflect", &[(150.0, 50.0), (150.0, 40.0), (150.0, 20.0), (150.0, 20.0)], Mods::default());
|
||
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0)));
|
||
// Shear horizontally: top edge shifts right by 25 relative to the centre row.
|
||
gesture(&mut s, "shear", &[(150.0, 100.0), (160.0, 100.0), (175.0, 100.0), (175.0, 100.0)], Mods::default());
|
||
let b = bounds(&s, a);
|
||
assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn distort_command_maps_corners() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||
s.execute("object.distort", &json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100]]})).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
|
||
for want in [Point::new(10.0, 0.0), Point::new(90.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] {
|
||
assert!(pts.iter().any(|p| p.distance(want) < 1e-6), "{pts:?} missing {want:?}");
|
||
}
|
||
assert!(s.execute("object.distort", &json!({"corners": [[0, 0]]})).is_err());
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn distort_perspective_midpoint_is_projective() {
|
||
// Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real
|
||
// perspective warp the centre lands where the quad's diagonals cross (y = 100/6), not at y = 50.
|
||
let mut s = session();
|
||
let probe = rect(&mut s, 49.999, 49.999, 0.002, 0.002);
|
||
s.execute("object.distort", &json!({"corners": [[40, 0], [60, 0], [100, 100], [0, 100]], "from": [0, 0, 100, 100], "ids": [probe.0]})).unwrap();
|
||
let c = bounds(&s, probe).center();
|
||
assert!((c.x - 50.0).abs() < 1e-6);
|
||
assert!((c.y - 100.0 / 6.0).abs() < 1e-3, "{c:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn free_transform_distort_mode_via_pointer() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
let v = ViewInfo::default();
|
||
s.select_tool("freeTransform", v).unwrap();
|
||
s.set_tool_option("mode", &json!("distort"));
|
||
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0), v).unwrap();
|
||
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0), v).unwrap();
|
||
let d = &s.doc().unwrap().doc;
|
||
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
|
||
assert!(pts.iter().any(|p| p.distance(Point::new(250.0, 260.0)) < 1e-6), "{pts:?}");
|
||
assert!(pts.iter().any(|p| p.distance(Point::new(100.0, 100.0)) < 1e-6));
|
||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
|
||
}
|
||
|
||
#[test]
|
||
fn free_transform_side_handle_scales() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
gesture(&mut s, "freeTransform", &[(200.0, 150.0), (250.0, 150.0), (300.0, 150.0), (300.0, 150.0)], Mods::default());
|
||
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 200.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn eyedropper_copies_appearance_to_selection_and_defaults() {
|
||
let mut s = session();
|
||
let src = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
|
||
let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||
s.execute("paint.default", &json!({})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
|
||
gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods::default());
|
||
let d = &s.doc().unwrap().doc;
|
||
let n = d.node(dst).unwrap();
|
||
assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap()));
|
||
assert_eq!(n.appearance.stroke_width(), 4.0);
|
||
assert!((n.opacity - 0.5).abs() < 1e-6);
|
||
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#ff0000").unwrap()));
|
||
assert_eq!(s.paint.stroke_width, 4.0);
|
||
let _ = src;
|
||
}
|
||
|
||
#[test]
|
||
fn eyedropper_shift_samples_color_into_active_proxy() {
|
||
let mut s = session();
|
||
rect(&mut s, 300.0, 300.0, 50.0, 50.0);
|
||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||
let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||
s.execute("paint.default", &json!({})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
|
||
s.execute("paint.toggleActive", &json!({})).unwrap(); // stroke active
|
||
gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods { shift: true, ..Default::default() });
|
||
let n = s.doc().unwrap().doc.node(dst).unwrap().clone();
|
||
assert_eq!(n.appearance.stroke_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap()));
|
||
assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::WHITE));
|
||
}
|
||
|
||
#[test]
|
||
fn gradient_tool_sets_vector_on_solid_fill() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
gesture(&mut s, "gradient", &[(100.0, 150.0), (150.0, 150.0), (200.0, 100.0), (200.0, 100.0)], Mods::default());
|
||
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
|
||
let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("expected gradient") };
|
||
let geom = g.geom.unwrap();
|
||
assert_eq!(geom.start, Point::new(100.0, 150.0));
|
||
assert_eq!(geom.end, Point::new(200.0, 100.0));
|
||
assert_eq!(undo_len(&s), 2);
|
||
// Changing an existing gradient keeps its stops.
|
||
s.execute("paint.setGradientGeom", &json!({"start": [0, 0], "end": [10, 0]})).unwrap();
|
||
let Paint::Gradient(g2) = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint() else { panic!() };
|
||
assert_eq!(g2.gradient, g.gradient);
|
||
assert!(s.execute("paint.setGradientGeom", &json!({"start": [0, 0]})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn artboard_tool_moves_with_art_creates_and_deletes() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||
let outside = rect(&mut s, 900.0, 100.0, 50.0, 50.0);
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
gesture(&mut s, "artboard", &[(400.0, 300.0), (420.0, 310.0), (450.0, 330.0), (450.0, 330.0)], Mods::default());
|
||
let d = &s.doc().unwrap().doc;
|
||
assert_eq!(d.artboards[0].rect, Rect::new(50.0, 30.0, 850.0, 630.0));
|
||
assert!(close(bounds(&s, a), Rect::new(150.0, 130.0, 200.0, 180.0)));
|
||
assert!(close(bounds(&s, outside), Rect::new(900.0, 100.0, 950.0, 150.0)));
|
||
// Draw a new artboard on the pasteboard.
|
||
gesture(&mut s, "artboard", &[(1000.0, 0.0), (1100.0, 50.0), (1200.0, 100.0), (1200.0, 100.0)], Mods::default());
|
||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
|
||
assert_eq!(s.tool_options()["active"], 1);
|
||
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(1000.0, 0.0, 1200.0, 100.0));
|
||
// Delete it.
|
||
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
|
||
// The last artboard can't be deleted.
|
||
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn artboard_resize_via_handle_and_move_command() {
|
||
let mut s = session();
|
||
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
|
||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0));
|
||
s.execute("artboard.move", &json!({"index": 0, "dx": -10, "dy": 5})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(-10.0, 5.0, 890.0, 605.0));
|
||
assert!(s.execute("artboard.move", &json!({"index": 7, "dx": 1})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn magic_wand_selects_same_fill() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
|
||
let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0);
|
||
let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0);
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [c.0]})).unwrap();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
gesture(&mut s, "magicWand", &[(20.0, 20.0), (20.0, 20.0)], Mods::default());
|
||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
|
||
gesture(&mut s, "magicWand", &[(100.0, 20.0), (100.0, 20.0)], Mods { shift: true, ..Default::default() });
|
||
assert_eq!(s.doc().unwrap().selection.len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn lasso_selects_anchor_subset() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default());
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(st.selection.objects, vec![a]);
|
||
assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2));
|
||
}
|
||
|
||
#[test]
|
||
fn measure_tool_leaves_document_untouched() {
|
||
let mut s = session();
|
||
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||
let rev = s.doc().unwrap().revision;
|
||
let n = undo_len(&s);
|
||
gesture(&mut s, "measure", &[(0.0, 0.0), (30.0, 40.0), (30.0, 40.0)], Mods::default());
|
||
assert_eq!(s.doc().unwrap().revision, rev);
|
||
assert_eq!(undo_len(&s), n);
|
||
assert!((s.tool_options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9);
|
||
assert!(!s.overlays(ViewInfo::default()).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn copy_from_requires_source() {
|
||
let mut s = session();
|
||
assert!(s.execute("appearance.copyFrom", &json!({})).is_err());
|
||
assert!(s.execute("appearance.copyFrom", &json!({"source": 9999})).is_err());
|
||
}
|