mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Use Preview Bounds: a handle drag keeps the opposite visible edge in place
With Use Preview Bounds on, a bounding-box handle drag scaled the art by the ratio of the visual box, so margins that stay (a stroke with Scale Strokes & Effects off, an Offset Path reach) or scale by another factor moved the opposite visible edge: dragging the right edge of a square with Offset Path 10 from 210 to 310 left its left edge at 98.33 instead of 90. The Selection tool now previews object.setBounds with the dragged visual size and the opposite handle (with Alt, the center) as the reference point, so the scale is solved from the geometric box and the margins the visual box adds, for strokes with Scale Strokes & Effects on or off and for geometry effects. Shift and Alt drags follow the same rule; a drag that flips the box, and one with anchors selected, still scale by the matrix. A previewed object.setBounds runs as object.transform with the transform it finds, so Transform Again repeats the drag and the journal holds that transform. The drag transforms a path of a compound path selected on its own, as object.transform does, and leaves the art at least 1e-4 of its width and height.
This commit is contained in:
@@ -486,6 +486,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
}
|
||||
|
||||
pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use object::bounds_rewrite;
|
||||
pub(crate) use vectorcraft_doc::range::parse_range;
|
||||
|
||||
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
|
||||
|
||||
@@ -1724,14 +1724,51 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match bounds_transform(s, p, selected_roots(s)?, 0.0)? {
|
||||
Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b),
|
||||
Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `object.setBounds` does with its values ([`bounds_transform`]).
|
||||
enum Bounds {
|
||||
/// With anchors selected with Direct Selection: the objects and the box of the anchors that
|
||||
/// [`set_anchor_bounds`] moves or scales.
|
||||
Anchors(Vec<NodeId>, Rect),
|
||||
/// The objects and the transform that gives them the values.
|
||||
Objects(Vec<NodeId>, Affine),
|
||||
}
|
||||
|
||||
/// A previewed `object.setBounds` that transforms objects runs as `object.transform` with the
|
||||
/// transform [`bounds_transform`] finds for it, so the interaction commits that transform (Transform
|
||||
/// Again repeats it, and the journal holds it). A bounding-box handle drag with Use Preview Bounds
|
||||
/// previews `object.setBounds`. The objects are those `object.transform` transforms (a path of a
|
||||
/// compound path selected on its own is one), and the art keeps at least 1e-4 of its width and
|
||||
/// height, as with [`vectorcraft_tools::bbox::scale_for_drag`]. `None` with anchors selected with
|
||||
/// Direct Selection and when the values are not valid.
|
||||
pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> {
|
||||
if cmd != "object.setBounds" {
|
||||
return None;
|
||||
}
|
||||
let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4) else { return None };
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
for k in ["x", "y", "width", "height", "reference", "proportional"] {
|
||||
p.remove(k);
|
||||
}
|
||||
p.insert("matrix".into(), json!(xf.as_coeffs()));
|
||||
Some(("object.transform".to_string(), Value::Object(p)))
|
||||
}
|
||||
|
||||
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
|
||||
/// art with a width or height below `least` of the one it has keeps `least` of it.
|
||||
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
|
||||
check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?;
|
||||
let ids = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
if transforms_objects(p)
|
||||
&& !bool_or(p, "copy", false)
|
||||
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
|
||||
{
|
||||
return set_anchor_bounds(s, p, ids, b);
|
||||
return Ok(Bounds::Anchors(ids, b));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
|
||||
@@ -1786,7 +1823,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// leave a rounding error in it on a rotated object or with Use Preview Bounds.
|
||||
let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now);
|
||||
if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) {
|
||||
return apply_transform(s, "Transform", ids, Affine::IDENTITY, p, false);
|
||||
return Ok(Bounds::Objects(ids, Affine::IDENTITY));
|
||||
}
|
||||
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
|
||||
// The scales and the point above can miss the size or the reference point when the margins of
|
||||
@@ -1818,8 +1855,15 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp))
|
||||
};
|
||||
let (scale, new_rp) = solved.map_or((scale, new_rp), |(sx, sy, at)| (about(rp, Affine::scale_non_uniform(sx, sy)), at));
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
|
||||
let (sx, sy, new_rp) = solved.unwrap_or((sx, sy, new_rp));
|
||||
// A scale below `least` of art with a width (height) keeps `least`, and the reference point
|
||||
// moves with the scales as above.
|
||||
let keep = |s: f64, geo: f64| if geo > 1e-9 && (0.0..least).contains(&s) { least } else { s };
|
||||
let (kx, ky) = (keep(sx, g.width()), keep(sy, g.height()));
|
||||
let scale = about(rp, Affine::scale_non_uniform(kx, ky));
|
||||
let new_rp = if (kx, ky) == (sx, sy) { new_rp } else { scale * gp + (sp - gp) * f(kx, ky) + (rp - sp) };
|
||||
let xf = frame.conjugate(Affine::translate(to - new_rp) * scale);
|
||||
Ok(Bounds::Objects(ids, xf))
|
||||
}
|
||||
|
||||
/// The scales along x and y that give the box `measure` returns for them the size `size`, and point
|
||||
|
||||
@@ -1902,7 +1902,7 @@ impl Session {
|
||||
}
|
||||
// The previous preview's paint is undone with it.
|
||||
self.pending_paint = None;
|
||||
let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params);
|
||||
let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params).or_else(|| cmd::bounds_rewrite(self, cmd, params));
|
||||
let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p));
|
||||
let outer = std::mem::replace(&mut self.previewing, true);
|
||||
let r = self.execute(cmd, params);
|
||||
|
||||
@@ -748,6 +748,136 @@ fn the_bounding_box_handles_sit_on_the_offset_shape() {
|
||||
assert_eq!(drag(false).width(), 100.0, "off, the point is no handle");
|
||||
}
|
||||
|
||||
/// The box `id` is drawn in (strokes and geometry effects included) and its geometric box after a
|
||||
/// Selection tool drag from `from` to `to` with `mods`, with Smart Guides off.
|
||||
fn drag_box(s: &mut Session, id: NodeId, from: (f64, f64), to: (f64, f64), mods: Mods) -> (Rect, Rect) {
|
||||
let v = ViewInfo { smart_guides: false, ..ViewInfo::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for (k, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent { mods, ..PointerEvent::new(k, x, y) }, v).unwrap();
|
||||
}
|
||||
let drawn = vectorcraft_render::effects::preview::bounds_of(&s.doc().unwrap().doc, &[id], true).unwrap();
|
||||
(drawn, geometric(s, id))
|
||||
}
|
||||
|
||||
/// Drags of `make`'s object with Use Preview Bounds on, and Scale Strokes & Effects off and on: from
|
||||
/// the handle at the first point to the second point, with the modifiers, giving the visual box.
|
||||
type HandleDrags = [((f64, f64), (f64, f64), Mods, Rect); 4];
|
||||
|
||||
fn assert_handle_drags(make: fn(&mut Session) -> NodeId, drags: HandleDrags) {
|
||||
for strokes in [false, true] {
|
||||
for (from, to, mods, want) in drags {
|
||||
let mut s = session();
|
||||
let id = make(&mut s);
|
||||
preview(&mut s, true);
|
||||
s.prefs.scale_strokes = strokes;
|
||||
let (v, _) = drag_box(&mut s, id, from, to, mods);
|
||||
assert!(near(v, want, 1e-6), "strokes {strokes}, {from:?} to {to:?}, {mods:?}: {v:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false };
|
||||
const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false };
|
||||
|
||||
#[test]
|
||||
fn a_handle_drag_keeps_the_opposite_visual_edge_of_a_stroked_path() {
|
||||
// A 100 x 50 rectangle at (100, 100) with a 20 pt stroke, drawn from (90, 90) to (210, 160): the
|
||||
// opposite edge of the visual box stays and the dragged edge goes to the pointer, with Shift in
|
||||
// proportion, with Alt about the center.
|
||||
let make = |s: &mut Session| rect(s, 100.0, 100.0, 20.0);
|
||||
assert_handle_drags(
|
||||
make,
|
||||
[
|
||||
((210.0, 125.0), (310.0, 125.0), Mods::default(), Rect::new(90.0, 90.0, 310.0, 160.0)),
|
||||
((210.0, 160.0), (260.0, 200.0), Mods::default(), Rect::new(90.0, 90.0, 260.0, 200.0)),
|
||||
((210.0, 160.0), (330.0, 170.0), SHIFT, Rect::new(90.0, 90.0, 330.0, 230.0)),
|
||||
((210.0, 125.0), (260.0, 125.0), ALT, Rect::new(40.0, 90.0, 260.0, 160.0)),
|
||||
],
|
||||
);
|
||||
// Off, the handles sit on the path, and the path scales from its own opposite edge.
|
||||
for strokes in [false, true] {
|
||||
let mut s = session();
|
||||
let id = make(&mut s);
|
||||
s.prefs.scale_strokes = strokes;
|
||||
let (_, g) = drag_box(&mut s, id, (200.0, 125.0), (300.0, 125.0), Mods::default());
|
||||
assert_eq!(g, Rect::new(100.0, 100.0, 300.0, 150.0), "strokes {strokes}");
|
||||
let (_, g) = drag_box(&mut s, id, (300.0, 150.0), (350.0, 200.0), Mods::default());
|
||||
assert_eq!(g, Rect::new(100.0, 100.0, 350.0, 200.0), "strokes {strokes}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_again_repeats_the_transform_of_a_handle_drag_with_preview_bounds() {
|
||||
// The drag takes the visual right edge of the stroked rectangle from 210 to 310, so the path,
|
||||
// with its stroke kept, goes from 100..200 to 100..300: x becomes 2x - 100.
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 20.0);
|
||||
preview(&mut s, true);
|
||||
s.prefs.scale_strokes = false;
|
||||
let (_, g) = drag_box(&mut s, id, (210.0, 125.0), (310.0, 125.0), Mods::default());
|
||||
assert!(near(g, Rect::new(100.0, 100.0, 300.0, 150.0), 1e-6), "{g:?}");
|
||||
run(&mut s, "object.transformAgain", json!({}));
|
||||
assert!(near(geometric(&s, id), Rect::new(100.0, 100.0, 500.0, 150.0), 1e-6), "{:?}", geometric(&s, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handle_drag_with_preview_bounds_scales_a_compound_paths_member_alone() {
|
||||
// A ring: a 100 pt square at (100, 100) with a 20 pt square hole at (140, 140), the hole selected
|
||||
// alone in the isolated compound path.
|
||||
let mut s = session();
|
||||
let square = |s: &mut Session, at: f64, size: f64| {
|
||||
NodeId(run(s, "shape.rectangle", json!({"x": at, "y": at, "width": size, "height": size}))["id"].as_u64().unwrap())
|
||||
};
|
||||
let (outer, hole) = (square(&mut s, 100.0, 100.0), square(&mut s, 140.0, 20.0));
|
||||
run(&mut s, "select.set", json!({"ids": [outer.0, hole.0]}));
|
||||
let c = run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "object.isolate", json!({"id": c}));
|
||||
run(&mut s, "select.set", json!({"ids": [hole.0]}));
|
||||
preview(&mut s, true);
|
||||
let (b, around) = (s.transform_bounds(&[hole]).unwrap(), s.transform_bounds(&[outer]));
|
||||
let (v, _) = drag_box(&mut s, hole, (b.x1, b.center().y), (b.x1 + 10.0, b.center().y), Mods::default());
|
||||
assert_eq!(s.transform_bounds(&[outer]), around, "the square around the hole stays");
|
||||
assert!(near(v, Rect::new(b.x0, b.y0, b.x1 + 10.0, b.y1), 1e-6), "the hole: {v:?} from {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handle_drag_with_preview_bounds_leaves_the_art_some_width() {
|
||||
// Dragging the visual right edge of the rectangle with a kept 20 pt stroke (drawn from 90 to 210)
|
||||
// to 95 asks for less width than the stroke adds: the path keeps 1e-4 of its width.
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0, 100.0, 20.0);
|
||||
preview(&mut s, true);
|
||||
s.prefs.scale_strokes = false;
|
||||
let (_, g) = drag_box(&mut s, id, (210.0, 125.0), (95.0, 125.0), Mods::default());
|
||||
assert!((g.width() - 0.01).abs() < 1e-9, "{g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handle_drag_keeps_the_opposite_edge_of_the_offset_shape() {
|
||||
// A 100 pt square at (100, 100) with Offset Path 10, drawn from (90, 90) to (210, 210).
|
||||
let make = |s: &mut Session| offset_square(s, 100.0);
|
||||
assert_handle_drags(
|
||||
make,
|
||||
[
|
||||
((210.0, 150.0), (310.0, 150.0), Mods::default(), Rect::new(90.0, 90.0, 310.0, 210.0)),
|
||||
((210.0, 210.0), (260.0, 250.0), Mods::default(), Rect::new(90.0, 90.0, 260.0, 250.0)),
|
||||
((210.0, 210.0), (330.0, 220.0), SHIFT, Rect::new(90.0, 90.0, 330.0, 330.0)),
|
||||
((210.0, 150.0), (260.0, 150.0), ALT, Rect::new(40.0, 90.0, 260.0, 210.0)),
|
||||
],
|
||||
);
|
||||
// Off, the handles sit on the square, and the square scales from its own opposite edge.
|
||||
for strokes in [false, true] {
|
||||
let mut s = session();
|
||||
let id = make(&mut s);
|
||||
s.prefs.scale_strokes = strokes;
|
||||
let (_, g) = drag_box(&mut s, id, (200.0, 150.0), (300.0, 150.0), Mods::default());
|
||||
assert_eq!(g, Rect::new(100.0, 100.0, 300.0, 200.0), "strokes {strokes}");
|
||||
let (_, g) = drag_box(&mut s, id, (300.0, 200.0), (350.0, 250.0), Mods::default());
|
||||
assert_eq!(g, Rect::new(100.0, 100.0, 350.0, 250.0), "strokes {strokes}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_over_an_unchanged_document_measures_nothing_again() {
|
||||
// A selected square with Offset Path 10, drawn from 90 to 210, with Use Preview Bounds and Smart
|
||||
|
||||
@@ -46,6 +46,20 @@ impl Handle {
|
||||
_ => (true, true),
|
||||
}
|
||||
}
|
||||
/// The handle's point in the 9-point reference grid (row-major from the top-left, 4 the center)
|
||||
/// that `object.setBounds` takes as `reference`.
|
||||
pub fn reference(self) -> usize {
|
||||
match self {
|
||||
Handle::TopLeft => 0,
|
||||
Handle::Top => 1,
|
||||
Handle::TopRight => 2,
|
||||
Handle::Left => 3,
|
||||
Handle::Right => 5,
|
||||
Handle::BottomLeft => 6,
|
||||
Handle::Bottom => 7,
|
||||
Handle::BottomRight => 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle under `p` (within `tol` document units).
|
||||
@@ -122,6 +136,9 @@ mod tests {
|
||||
assert_eq!(Handle::Top.opposite(), Handle::Bottom);
|
||||
assert_eq!(hit_handle(R, Point::new(99.0, 24.0), 3.0), Some(Handle::Right));
|
||||
assert_eq!(hit_handle(R, Point::new(50.0, 25.0), 3.0), None);
|
||||
for h in Handle::ALL {
|
||||
assert_eq!(vectorcraft_geom::reference_point(R, h.reference()), h.pos(R), "{h:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -716,11 +716,21 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
let nr = a.transform_rect_bbox(bx.rect);
|
||||
self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height())));
|
||||
let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false });
|
||||
// With Use Preview Bounds the box is the visual box, whose margins around the art
|
||||
// (strokes, geometry effects) scale with it or stay. `object.setBounds` then gives the
|
||||
// visual box the dragged size and keeps its opposite handle (with Alt, its center)
|
||||
// where it is. A drag that flips the box scales by the matrix.
|
||||
let [sx, _, _, sy, _, _] = a.as_coeffs();
|
||||
let (cmd, mut params) = if cx.preview_bounds && cx.selection.anchors.is_empty() && sx > 0.0 && sy > 0.0 {
|
||||
let reference = if m.alt { 4 } else { handle.opposite().reference() };
|
||||
("object.setBounds", json!({ "width": nr.width(), "height": nr.height(), "reference": reference, "copy": false }))
|
||||
} else {
|
||||
("object.transform", json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }))
|
||||
};
|
||||
if areas {
|
||||
params["typeAreas"] = json!(true);
|
||||
}
|
||||
vec![Action::Preview("object.transform".into(), params)]
|
||||
vec![Action::Preview(cmd.into(), params)]
|
||||
}
|
||||
(PointerKind::Drag, State::Rotating { center, start, .. }) => {
|
||||
let (a, deg) = rotate_for_drag(center, start, p, m.shift);
|
||||
|
||||
Reference in New Issue
Block a user