diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 681c2bcc5..87ec6c8a5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index d814c4f01..3fab5fe93 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1724,14 +1724,51 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { } fn set_bounds(s: &mut Session, p: &Value) -> Result { + 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, Rect), + /// The objects and the transform that gives them the values. + Objects(Vec, 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, least: f64) -> Result { 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 { // 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 { } 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 diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b08053415..4d078301a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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); diff --git a/crates/engine/src/tests_previewbounds.rs b/crates/engine/src/tests_previewbounds.rs index a8ffe4ee0..88b87394d 100644 --- a/crates/engine/src/tests_previewbounds.rs +++ b/crates/engine/src/tests_previewbounds.rs @@ -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 diff --git a/crates/tools/src/bbox.rs b/crates/tools/src/bbox.rs index 2227bef27..37d2dca53 100644 --- a/crates/tools/src/bbox.rs +++ b/crates/tools/src/bbox.rs @@ -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] diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 6a21f9d45..225bf7b8a 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -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);