mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 21:56:50 -06:00
Snap: dimension guides follow the final edge
Shift plus Alt restores the aspect from the original center after the size snaps. A line keeps the runner delta on both axes. Dimension guides are redrawn on the committed side. Grouped own size decomposes the absolute transform once.
This commit is contained in:
committed by
Brandon Thomas
parent
40f3b6454a
commit
8e7cff341f
@@ -170,23 +170,12 @@ impl Tool for FrameTool {
|
||||
pointer: b,
|
||||
},
|
||||
);
|
||||
if hit.length_delta[1].is_none() {
|
||||
b.y += hit.delta.y;
|
||||
}
|
||||
if hit.length_delta[0].is_none() {
|
||||
// Position and length both land on the endpoint. Rescaling the segment would leave the other axis.
|
||||
if hit.delta.x.is_finite() {
|
||||
b.x += hit.delta.x;
|
||||
}
|
||||
if let Some(diff) = hit.length_delta[0]
|
||||
&& diff.is_finite()
|
||||
{
|
||||
let v = b - sa;
|
||||
let cur = v.hypot();
|
||||
if cur > 1e-9 {
|
||||
let next = cur + diff;
|
||||
if next.is_finite() {
|
||||
b = sa + v * (next / cur);
|
||||
}
|
||||
}
|
||||
if hit.delta.y.is_finite() {
|
||||
b.y += hit.delta.y;
|
||||
}
|
||||
self.guides = hit.guides;
|
||||
}
|
||||
|
||||
+77
-10
@@ -198,6 +198,77 @@ fn apply_aspect(from: Rect, handle: usize, rect: Rect, s: f64) -> Rect {
|
||||
Rect::new(x0, y0, x1, y1)
|
||||
}
|
||||
|
||||
/// Snap changes the size. Shift then restores the aspect, and Alt places that size on the original center.
|
||||
///
|
||||
/// Nudging one edge of an already centered rect walks the center. Mirroring that nudge doubles a length match.
|
||||
fn finish_snapped_resize(
|
||||
from: Rect,
|
||||
handle: usize,
|
||||
proposed: Rect,
|
||||
mods: Mods,
|
||||
driver_x: bool,
|
||||
x_edges: [bool; 3],
|
||||
y_edges: [bool; 3],
|
||||
hit: &crate::Snap,
|
||||
) -> Rect {
|
||||
if !mods.alt {
|
||||
let mut to = crate::snap::nudge_edges(proposed, x_edges, y_edges, hit.delta);
|
||||
if mods.shift && from.width() > 1e-9 && from.height() > 1e-9 {
|
||||
let s = if driver_x { (to.x1 - to.x0) / from.width() } else { (to.y1 - to.y0) / from.height() };
|
||||
if s.is_finite() {
|
||||
to = apply_aspect(from, handle, to, s);
|
||||
}
|
||||
}
|
||||
return to;
|
||||
}
|
||||
let mut w = proposed.x1 - proposed.x0;
|
||||
let mut h = proposed.y1 - proposed.y0;
|
||||
w += centered_size_delta(x_edges, hit.delta.x, hit.length_delta[0]);
|
||||
h += centered_size_delta(y_edges, hit.delta.y, hit.length_delta[1]);
|
||||
if mods.shift && from.width() > 1e-9 && from.height() > 1e-9 {
|
||||
if driver_x {
|
||||
let s = w / from.width();
|
||||
if s.is_finite() {
|
||||
h = from.height() * s;
|
||||
}
|
||||
} else {
|
||||
let s = h / from.height();
|
||||
if s.is_finite() {
|
||||
w = from.width() * s;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !(w.is_finite() && h.is_finite()) {
|
||||
return proposed;
|
||||
}
|
||||
let c = from.center();
|
||||
Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0)
|
||||
}
|
||||
|
||||
/// Length match: add the difference once. Position match: the flagged edge moves and the other side mirrors.
|
||||
fn centered_size_delta(edges: [bool; 3], delta: f64, length_delta: Option<f64>) -> f64 {
|
||||
if let Some(diff) = length_delta.filter(|d| d.is_finite()) {
|
||||
return diff;
|
||||
}
|
||||
let edge = axis_size_delta(edges, delta);
|
||||
if edge.is_finite() { edge * 2.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Signed change in `hi - lo` when one flagged edge moves by `delta`.
|
||||
fn axis_size_delta(edges: [bool; 3], delta: f64) -> f64 {
|
||||
if !delta.is_finite() {
|
||||
return 0.0;
|
||||
}
|
||||
let [low_edge, _, high_edge] = edges;
|
||||
if high_edge && !low_edge {
|
||||
delta
|
||||
} else if low_edge && !high_edge {
|
||||
-delta
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_alt(from: Rect, handle: usize, rect: Rect) -> Rect {
|
||||
let (x0, y0, x1, y1) = (rect.x0, rect.y0, rect.x1, rect.y1);
|
||||
let c = from.center();
|
||||
@@ -424,29 +495,25 @@ impl Tool for SelectionTool {
|
||||
}
|
||||
let exclude = cx.selection.items.clone();
|
||||
let (pad_x, pad_y) = single_stroke_pad(cx);
|
||||
let hit = crate::snap::snap(
|
||||
let proposed = to;
|
||||
let mut hit = crate::snap::snap(
|
||||
cx,
|
||||
SnapRequest {
|
||||
spread,
|
||||
gesture: Gesture::Resize,
|
||||
rect: to,
|
||||
rect: proposed,
|
||||
x_edges,
|
||||
y_edges,
|
||||
exclude: &exclude,
|
||||
copying: false,
|
||||
lengths: [axis_length(x_edges, to.width(), pad_x), axis_length(y_edges, to.height(), pad_y)],
|
||||
lengths: [axis_length(x_edges, proposed.width(), pad_x), axis_length(y_edges, proposed.height(), pad_y)],
|
||||
angle: None,
|
||||
radius: 0.0,
|
||||
pointer,
|
||||
},
|
||||
);
|
||||
to = crate::snap::nudge_edges(to, x_edges, y_edges, hit.delta);
|
||||
if ev.mods.shift && !ev.mods.alt && from.width() > 1e-9 && from.height() > 1e-9 {
|
||||
let s = if driver_x { (to.x1 - to.x0) / from.width() } else { (to.y1 - to.y0) / from.height() };
|
||||
if s.is_finite() {
|
||||
to = apply_aspect(from, handle, to, s);
|
||||
}
|
||||
}
|
||||
to = finish_snapped_resize(from, handle, proposed, ev.mods, driver_x, x_edges, y_edges, &hit);
|
||||
crate::snap::lay_dimension_guides(&mut hit.guides, cx.layout.xf(spread), proposed, to, x_edges, y_edges, hit.length_delta);
|
||||
self.guides = hit.guides;
|
||||
}
|
||||
vec![Action::Preview(
|
||||
|
||||
+129
-25
@@ -8,7 +8,7 @@ use std::collections::HashSet;
|
||||
|
||||
use designcraft_doc::{Item, ItemId, Orientation, PageSide, Selection, SpreadId, SpreadRef, StrokeAlign};
|
||||
use designcraft_geom::snap::snap_to_grid;
|
||||
use designcraft_geom::{Point, Rect, Vec2, format_measure};
|
||||
use designcraft_geom::{Affine, Point, Rect, Vec2, format_measure};
|
||||
|
||||
pub use crate::{Gesture, Snap, SnapRequest, SnapView};
|
||||
|
||||
@@ -384,10 +384,11 @@ fn pass_spacing(_cx: &ToolContext, _req: &SnapRequest<'_>, _axis: Axis, _tol: f6
|
||||
/// Other items' own side lengths, on resize and create.
|
||||
///
|
||||
/// Own size is the path box in item space, inflated the way [`Item::visible_bounds`] inflates,
|
||||
/// then multiplied by the absolute scale (ancestors included, rotation dropped). Width and height
|
||||
/// are both candidates for whichever length is set. The closest length inside the zone wins.
|
||||
/// Delta is the length difference applied from the moving edge. A rotated rectangle contributes
|
||||
/// its side lengths, not the axis-aligned box.
|
||||
/// then scaled by the absolute transform. That transform is the parent chain composed with the
|
||||
/// item and decomposed once, so a rotated child inside a non-uniform group keeps the spread-space
|
||||
/// sides. Rotation is dropped. Width and height are both candidates for whichever length is set.
|
||||
/// The closest length inside the zone wins. Delta is the length difference applied from the moving
|
||||
/// edge. A rotated rectangle contributes its side lengths, not the axis-aligned box.
|
||||
fn pass_dimensions(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
|
||||
if !matches!(req.gesture, Gesture::Resize | Gesture::Create) || !cx.snap.smart_guides || !cx.snap.smart_dimensions {
|
||||
return None;
|
||||
@@ -441,8 +442,15 @@ fn length_edge_delta(flags: [bool; 3], diff: f64) -> f64 {
|
||||
|
||||
/// Guide along the side whose length matched, after the edge has moved.
|
||||
fn dimension_mark(req: &SnapRequest<'_>, axis: Axis, diff: f64, text: String) -> DimMark {
|
||||
let rect = req.rect;
|
||||
let flags = axis_flags(req, axis);
|
||||
let (a, b) = dimension_segment(req.rect, axis, axis_flags(req, axis), diff).unwrap_or((Point::ORIGIN, Point::ORIGIN));
|
||||
DimMark { a, b, text }
|
||||
}
|
||||
|
||||
/// Spread-space segment along the matched side. `diff` 0 is the side of `rect` itself.
|
||||
fn dimension_segment(rect: Rect, axis: Axis, flags: [bool; 3], diff: f64) -> Option<(Point, Point)> {
|
||||
if !diff.is_finite() || !rect_finite(rect) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = match axis {
|
||||
Axis::X => {
|
||||
let (x0, x1) = grown_span(rect.x0, rect.x1, flags, diff);
|
||||
@@ -455,7 +463,59 @@ fn dimension_mark(req: &SnapRequest<'_>, axis: Axis, diff: f64, text: String) ->
|
||||
(Point::new(x, y0), Point::new(x, y1))
|
||||
}
|
||||
};
|
||||
DimMark { a, b, text }
|
||||
(point_finite(a) && point_finite(b)).then_some((a, b))
|
||||
}
|
||||
|
||||
fn point_finite(p: Point) -> bool {
|
||||
p.x.is_finite() && p.y.is_finite()
|
||||
}
|
||||
|
||||
/// Move dimension guides onto the rect the gesture commits.
|
||||
///
|
||||
/// Snap draws the guide on `pre` plus the length difference along the flagged edge. Aspect re-lock
|
||||
/// and a centered scale move that side afterwards. The guide then follows the committed side.
|
||||
pub(crate) fn lay_dimension_guides(
|
||||
guides: &mut Vec<Overlay>,
|
||||
xf: Affine,
|
||||
pre: Rect,
|
||||
committed: Rect,
|
||||
x_edges: [bool; 3],
|
||||
y_edges: [bool; 3],
|
||||
length_delta: [Option<f64>; 2],
|
||||
) {
|
||||
if !rect_finite(committed) {
|
||||
return;
|
||||
}
|
||||
let axes = [(Axis::X, x_edges, length_delta[0]), (Axis::Y, y_edges, length_delta[1])];
|
||||
for (axis, edges, diff) in axes {
|
||||
let Some(diff) = diff else { continue };
|
||||
let Some((old_a, old_b)) = dimension_segment(pre, axis, edges, diff) else { continue };
|
||||
let Some((new_a, new_b)) = dimension_segment(committed, axis, edges, 0.0) else { continue };
|
||||
let (oa, ob) = (xf * old_a, xf * old_b);
|
||||
let (na, nb) = (xf * new_a, xf * new_b);
|
||||
if !point_finite(na) || !point_finite(nb) {
|
||||
continue;
|
||||
}
|
||||
let mut found = false;
|
||||
for g in guides.iter_mut() {
|
||||
if let Overlay::Guide { a, b } = g
|
||||
&& segment_same(*a, *b, oa, ob)
|
||||
{
|
||||
*a = na;
|
||||
*b = nb;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
guides.push(Overlay::Guide { a: na, b: nb });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_same(a0: Point, b0: Point, a1: Point, b1: Point) -> bool {
|
||||
let close = |p: Point, q: Point| (p.x - q.x).abs() <= 1e-6 && (p.y - q.y).abs() <= 1e-6;
|
||||
(close(a0, a1) && close(b0, b1)) || (close(a0, b1) && close(b0, a1))
|
||||
}
|
||||
|
||||
fn grown_span(a0: f64, a1: f64, flags: [bool; 3], diff: f64) -> (f64, f64) {
|
||||
@@ -477,7 +537,7 @@ fn dimension_lengths(cx: &ToolContext, req: &SnapRequest<'_>) -> Vec<f64> {
|
||||
let mut seen = HashSet::new();
|
||||
if let Some(sp) = cx.doc.spread(req.spread) {
|
||||
for item in &sp.items {
|
||||
push_own_lengths(&mut out, cx.selection, req, item, 1.0, 1.0, false, 0, &mut seen);
|
||||
push_own_lengths(&mut out, cx.selection, req, item, Affine::IDENTITY, false, 0, &mut seen);
|
||||
}
|
||||
}
|
||||
if !layout_edits_parents(cx) {
|
||||
@@ -491,8 +551,7 @@ fn push_own_lengths(
|
||||
selection: &Selection,
|
||||
req: &SnapRequest<'_>,
|
||||
item: &Item,
|
||||
ancestor_sx: f64,
|
||||
ancestor_sy: f64,
|
||||
ancestor: Affine,
|
||||
skipped: bool,
|
||||
depth: usize,
|
||||
seen: &mut HashSet<ItemId>,
|
||||
@@ -500,29 +559,32 @@ fn push_own_lengths(
|
||||
if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) || item.hidden {
|
||||
return;
|
||||
}
|
||||
let abs = ancestor * item.xf;
|
||||
if !xf_finite(abs) {
|
||||
return;
|
||||
}
|
||||
let skip = skipped || align_skips_item(selection, req, item.id);
|
||||
if item.is_group() && !item.children().is_empty() {
|
||||
let decomposed = designcraft_geom::decompose::decompose(item.xf);
|
||||
let sx = ancestor_sx * decomposed.scale_x.abs();
|
||||
let sy = ancestor_sy * decomposed.scale_y.abs();
|
||||
if !sx.is_finite() || !sy.is_finite() {
|
||||
return;
|
||||
}
|
||||
for child in item.children() {
|
||||
push_own_lengths(out, selection, req, child, sx, sy, skip, depth + 1, seen);
|
||||
push_own_lengths(out, selection, req, child, abs, skip, depth + 1, seen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if skip {
|
||||
return;
|
||||
}
|
||||
let Some((w, h)) = leaf_own_size(item, ancestor_sx, ancestor_sy) else { return };
|
||||
let Some((w, h)) = leaf_own_size(item, abs) else { return };
|
||||
out.push(w);
|
||||
out.push(h);
|
||||
}
|
||||
|
||||
/// Path bounds in item space, stroke outset, then absolute scale. Rotation is not applied.
|
||||
fn leaf_own_size(item: &Item, ancestor_sx: f64, ancestor_sy: f64) -> Option<(f64, f64)> {
|
||||
fn xf_finite(m: Affine) -> bool {
|
||||
m.as_coeffs().iter().all(|c| c.is_finite())
|
||||
}
|
||||
|
||||
/// Path bounds in item space, stroke outset, then one decompose of the absolute transform.
|
||||
/// Rotation is not applied to the side lengths.
|
||||
fn leaf_own_size(item: &Item, abs: Affine) -> Option<(f64, f64)> {
|
||||
let local = item.path.bounds()?;
|
||||
if !rect_finite(local) {
|
||||
return None;
|
||||
@@ -535,9 +597,9 @@ fn leaf_own_size(item: &Item, ancestor_sx: f64, ancestor_sy: f64) -> Option<(f64
|
||||
if !rect_finite(local) {
|
||||
return None;
|
||||
}
|
||||
let decomposed = designcraft_geom::decompose::decompose(item.xf);
|
||||
let sx = ancestor_sx * decomposed.scale_x.abs();
|
||||
let sy = ancestor_sy * decomposed.scale_y.abs();
|
||||
let decomposed = designcraft_geom::decompose::decompose(abs);
|
||||
let sx = decomposed.scale_x.abs();
|
||||
let sy = decomposed.scale_y.abs();
|
||||
if !sx.is_finite() || !sy.is_finite() {
|
||||
return None;
|
||||
}
|
||||
@@ -601,7 +663,7 @@ fn push_parent_length_chain(
|
||||
if parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(page_idx) {
|
||||
continue;
|
||||
}
|
||||
push_own_lengths(out, cx.selection, req, item, 1.0, 1.0, false, 0, seen_items);
|
||||
push_own_lengths(out, cx.selection, req, item, Affine::IDENTITY, false, 0, seen_items);
|
||||
}
|
||||
}
|
||||
let Some(next_id) = next else { return };
|
||||
@@ -1278,4 +1340,46 @@ mod tests {
|
||||
assert!((hit.delta.x - 3.0).abs() < 1e-6);
|
||||
assert!(matches!(hit.guides.last(), Some(Overlay::Measure { text, .. }) if text.contains("200")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_child_matches_spread_side() {
|
||||
// Group scale 2 on X and 1 on Y. Child rotated -90 degrees (decompose reports +90).
|
||||
// Spread-space sides are local width times 1 (100) and local height times 2 (80).
|
||||
// 82 is 2 pt from 80 and far from 200, which is what per-node scales would offer.
|
||||
let mut doc = Document::new(&NewDocument::default());
|
||||
let layer = doc.default_layer();
|
||||
let child_id = ItemId(doc.alloc());
|
||||
let mut child = Item::new(child_id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)));
|
||||
child.xf = Affine::rotate((-90.0_f64).to_radians());
|
||||
let group_id = ItemId(doc.alloc());
|
||||
let mut group = Item::new(group_id, layer, Shape::Group, designcraft_geom::PathData::default());
|
||||
group.xf = Affine::scale_non_uniform(2.0, 1.0);
|
||||
group.content = designcraft_doc::Content::Group { items: vec![std::sync::Arc::new(child)] };
|
||||
doc.insert_item(SpreadRef::Doc(0), group, None).unwrap();
|
||||
let cache = Cache::new();
|
||||
let layout = CanvasLayout::new(&doc, false);
|
||||
let sel = Selection::default();
|
||||
let mut cx = ctx_on(&doc, &sel, &cache, &layout);
|
||||
cx.snap.snap_to_guides = false;
|
||||
cx.snap.snap_to_document_grid = false;
|
||||
cx.snap.align_edges = false;
|
||||
cx.snap.align_centers = false;
|
||||
let hit = snap(
|
||||
&cx,
|
||||
SnapRequest {
|
||||
spread: SpreadRef::Doc(0),
|
||||
gesture: Gesture::Resize,
|
||||
rect: Rect::new(200.0, 200.0, 282.0, 240.0),
|
||||
x_edges: [false, false, true],
|
||||
y_edges: [false, false, false],
|
||||
exclude: &[],
|
||||
copying: false,
|
||||
lengths: [Some(82.0), None],
|
||||
angle: None,
|
||||
radius: 0.0,
|
||||
pointer: Point::new(282.0, 220.0),
|
||||
},
|
||||
);
|
||||
assert!((hit.delta.x - -2.0).abs() < 1e-6, "delta.x = {}, want -2 toward local height times 2", hit.delta.x);
|
||||
}
|
||||
}
|
||||
|
||||
+191
-2
@@ -1,7 +1,8 @@
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Document, ParaFormat, Selection, SpreadRef};
|
||||
use designcraft_geom::{Rect, Unit};
|
||||
use designcraft_doc::{Document, Item, ItemId, ParaFormat, Selection, Shape, SpreadRef};
|
||||
use designcraft_geom::shapes;
|
||||
use designcraft_geom::{Point, Rect, Unit};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -292,3 +293,191 @@ fn turned_spread_maps_points_both_ways() {
|
||||
let dv = slot.delta_to_spread(designcraft_geom::Vec2::new(10.0, 0.0));
|
||||
assert!(dv.x.abs() < 1e-9 && (dv.y + 10.0).abs() < 1e-9 || (dv.y - 10.0).abs() < 1e-9, "{dv:?}");
|
||||
}
|
||||
|
||||
fn add_rect(doc: &mut Document, rect: Rect) -> ItemId {
|
||||
let layer = doc.default_layer();
|
||||
let id = ItemId(doc.alloc());
|
||||
let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect));
|
||||
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Dimensions on, ruler guides and the document grid off. Alignment is optional.
|
||||
fn dims_ctx<'a>(d: &'a Document, s: &'a Selection, c: &'a Cache, l: &'a CanvasLayout, align: bool) -> ToolContext<'a> {
|
||||
let mut cx = ctx(d, s, c, l);
|
||||
cx.snap = SnapView::FACTORY;
|
||||
cx.snap.snap_to_guides = false;
|
||||
cx.snap.snap_to_document_grid = false;
|
||||
cx.snap.align_edges = align;
|
||||
cx.snap.align_centers = align;
|
||||
cx
|
||||
}
|
||||
|
||||
fn spread_pt(l: &CanvasLayout, x: f64, y: f64) -> Point {
|
||||
l.to_canvas(SpreadRef::Doc(0), Point::new(x, y))
|
||||
}
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
(a.x - b.x).abs() < 1e-4 && (a.y - b.y).abs() < 1e-4
|
||||
}
|
||||
|
||||
fn guide_matches(overlays: &[Overlay], a: Point, b: Point) -> bool {
|
||||
overlays.iter().any(|o| match o {
|
||||
Overlay::Guide { a: ga, b: gb } => (near(*ga, a) && near(*gb, b)) || (near(*ga, b) && near(*gb, a)),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn guide_list(overlays: &[Overlay]) -> String {
|
||||
overlays
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
Overlay::Guide { a, b } => Some(format!("({:.4},{:.4})-({:.4},{:.4})", a.x, a.y, b.x, b.y)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_alt_resize_keeps_aspect_and_center() {
|
||||
// 100 by 50, center (150, 125), aspect 2. Corner handle, Shift and Alt.
|
||||
// The centered width is 160. Another item's side is 163, inside the zone.
|
||||
let mut doc = Document::new(&NewDocument::default());
|
||||
let moving = add_rect(&mut doc, Rect::new(100.0, 100.0, 200.0, 150.0));
|
||||
add_rect(&mut doc, Rect::new(400.0, 400.0, 563.0, 430.0));
|
||||
let sel = Selection::items(vec![moving]);
|
||||
let cache = Cache::new();
|
||||
let layout = CanvasLayout::new(&doc, false);
|
||||
let cx = dims_ctx(&doc, &sel, &cache, &layout, false);
|
||||
let mut t = create("selection");
|
||||
let mods = Mods { shift: true, alt: true, ..Mods::default() };
|
||||
let down = spread_pt(&layout, 200.0, 150.0);
|
||||
let drag = spread_pt(&layout, 230.0, 155.0);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y).with_mods(mods));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag.x, drag.y).with_mods(mods));
|
||||
let params = match &a[..] {
|
||||
[Action::Preview(cmd, p)] => {
|
||||
assert_eq!(cmd, "transform.resize");
|
||||
p
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let to = params["to"].as_array().unwrap();
|
||||
let (x0, y0, x1, y1) = (to[0].as_f64().unwrap(), to[1].as_f64().unwrap(), to[2].as_f64().unwrap(), to[3].as_f64().unwrap());
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let (cx0, cy0) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
assert!((cx0 - 150.0).abs() < 1e-4 && (cy0 - 125.0).abs() < 1e-4, "center walked to ({cx0}, {cy0}) rect {to:?}");
|
||||
assert!((w / h - 2.0).abs() < 1e-4, "aspect {} rect {to:?}", w / h);
|
||||
assert!((w - 163.0).abs() < 1e-4, "driving length {w}, want the matched 163, not twice the correction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_length_keeps_the_crossed_guide() {
|
||||
// Diagonal from (100, 100) to (180, 140). Length is about 89.44, near a 92 side.
|
||||
// Endpoint y is 2 pt from the other item's top, so Y is a position snap and X is the length.
|
||||
let mut doc = Document::new(&NewDocument::default());
|
||||
add_rect(&mut doc, Rect::new(400.0, 142.0, 492.0, 180.0));
|
||||
let sel = Selection::default();
|
||||
let cache = Cache::new();
|
||||
let layout = CanvasLayout::new(&doc, false);
|
||||
let cx = dims_ctx(&doc, &sel, &cache, &layout, true);
|
||||
let mut t = create("line");
|
||||
let a = spread_pt(&layout, 100.0, 100.0);
|
||||
let b = spread_pt(&layout, 180.0, 140.0);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, a.x, a.y));
|
||||
let actions = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, b.x, b.y));
|
||||
let params = actions
|
||||
.iter()
|
||||
.find_map(|act| match act {
|
||||
Action::Preview(cmd, p) if cmd == "line.create" => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let end = params["b"].as_array().unwrap();
|
||||
let (bx, by) = (end[0].as_f64().unwrap(), end[1].as_f64().unwrap());
|
||||
let len = 80.0_f64.hypot(40.0);
|
||||
let expect_x = 180.0 + (92.0 - len);
|
||||
assert!((by - 142.0).abs() < 1e-4, "endpoint y {by} left the guide at 142 (x {bx})");
|
||||
assert!((bx - expect_x).abs() < 1e-4, "endpoint x {bx}, want {expect_x} from the length delta on X only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_guide_lies_on_the_committed_side() {
|
||||
let mut problems = Vec::new();
|
||||
|
||||
// Centered scale. Width 120 grows to the matched 123, so each side moves 1.5, not 3.
|
||||
let mut doc = Document::new(&NewDocument::default());
|
||||
let moving = add_rect(&mut doc, Rect::new(100.0, 100.0, 200.0, 160.0));
|
||||
add_rect(&mut doc, Rect::new(400.0, 400.0, 523.0, 440.0));
|
||||
let sel = Selection::items(vec![moving]);
|
||||
let cache = Cache::new();
|
||||
let layout = CanvasLayout::new(&doc, false);
|
||||
let cx = dims_ctx(&doc, &sel, &cache, &layout, false);
|
||||
let mut t = create("scale");
|
||||
let center = spread_pt(&layout, 150.0, 130.0);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, center.x + 50.0, center.y + 30.0));
|
||||
let drag = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, center.x + 60.0, center.y + 40.0));
|
||||
let (sx, sy) = match &drag[..] {
|
||||
[Action::Preview(cmd, p)] => {
|
||||
assert_eq!(cmd, "transform.scale");
|
||||
(p["sx"].as_f64().unwrap(), p["sy"].as_f64().unwrap())
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let committed = Rect::new(150.0 - 50.0 * sx.abs(), 130.0 - 30.0 * sy.abs(), 150.0 + 50.0 * sx.abs(), 130.0 + 30.0 * sy.abs());
|
||||
let xf = layout.xf(SpreadRef::Doc(0));
|
||||
let bottom = (xf * Point::new(committed.x0, committed.y1), xf * Point::new(committed.x1, committed.y1));
|
||||
let overlays = t.overlays(&cx);
|
||||
let scale_guides = overlays.iter().filter(|o| matches!(o, Overlay::Guide { .. })).count();
|
||||
if scale_guides != 1 || !guide_matches(&overlays, bottom.0, bottom.1) {
|
||||
problems.push(format!(
|
||||
"scale guide is not on the committed bottom ({:.4},{:.4})-({:.4},{:.4}), sx {sx} sy {sy}, {scale_guides} guides [{}]",
|
||||
bottom.0.x,
|
||||
bottom.0.y,
|
||||
bottom.1.x,
|
||||
bottom.1.y,
|
||||
guide_list(&overlays)
|
||||
));
|
||||
}
|
||||
|
||||
// Shift resize. Width snaps 130 to 133, then the height follows the aspect and the bottom moves.
|
||||
let mut doc = Document::new(&NewDocument::default());
|
||||
let moving = add_rect(&mut doc, Rect::new(100.0, 100.0, 200.0, 150.0));
|
||||
add_rect(&mut doc, Rect::new(400.0, 400.0, 533.0, 430.0));
|
||||
let sel = Selection::items(vec![moving]);
|
||||
let cache = Cache::new();
|
||||
let layout = CanvasLayout::new(&doc, false);
|
||||
let cx = dims_ctx(&doc, &sel, &cache, &layout, false);
|
||||
let mut t = create("selection");
|
||||
let mods = Mods { shift: true, ..Mods::default() };
|
||||
let down = spread_pt(&layout, 200.0, 150.0);
|
||||
let drag_at = spread_pt(&layout, 230.0, 155.0);
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, down.x, down.y));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, drag_at.x, drag_at.y).with_mods(mods));
|
||||
let to = match &a[..] {
|
||||
[Action::Preview(cmd, p)] => {
|
||||
assert_eq!(cmd, "transform.resize");
|
||||
p["to"].as_array().unwrap()
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let rect = Rect::new(to[0].as_f64().unwrap(), to[1].as_f64().unwrap(), to[2].as_f64().unwrap(), to[3].as_f64().unwrap());
|
||||
let xf = layout.xf(SpreadRef::Doc(0));
|
||||
let y = rect.y0.max(rect.y1);
|
||||
let (x0, x1) = (rect.x0.min(rect.x1), rect.x0.max(rect.x1));
|
||||
let side = (xf * Point::new(x0, y), xf * Point::new(x1, y));
|
||||
let overlays = t.overlays(&cx);
|
||||
let shift_guides = overlays.iter().filter(|o| matches!(o, Overlay::Guide { .. })).count();
|
||||
if shift_guides != 1 || !guide_matches(&overlays, side.0, side.1) {
|
||||
problems.push(format!(
|
||||
"shift guide is not on the committed side ({:.4},{:.4})-({:.4},{:.4}), rect {to:?}, {shift_guides} guides [{}]",
|
||||
side.0.x,
|
||||
side.0.y,
|
||||
side.1.x,
|
||||
side.1.y,
|
||||
guide_list(&overlays)
|
||||
));
|
||||
}
|
||||
assert!(problems.is_empty(), "{}", problems.join(" | "));
|
||||
}
|
||||
|
||||
@@ -98,18 +98,18 @@ impl Tool for XformTool {
|
||||
if w0 > 1e-9 && h0 > 1e-9 {
|
||||
let center = from.center();
|
||||
let (mut w, mut h) = (w0 * sx.abs(), h0 * sy.abs());
|
||||
let to = Rect::new(center.x - w / 2.0, center.y - h / 2.0, center.x + w / 2.0, center.y + h / 2.0);
|
||||
let pre = Rect::new(center.x - w / 2.0, center.y - h / 2.0, center.x + w / 2.0, center.y + h / 2.0);
|
||||
let pointer = cx.layout.to_spread(spread, ev.pos);
|
||||
let x_edges = if x_drives { edge_toward(pointer.x, center.x) } else { [false, false, false] };
|
||||
let y_edges = if x_drives && ev.mods.shift { [false, false, false] } else { edge_toward(pointer.y, center.y) };
|
||||
let exclude = cx.selection.items.clone();
|
||||
let (pad_x, pad_y) = scale_stroke_pad(cx);
|
||||
let hit = crate::snap::snap(
|
||||
let mut hit = crate::snap::snap(
|
||||
cx,
|
||||
SnapRequest {
|
||||
spread,
|
||||
gesture: Gesture::Resize,
|
||||
rect: to,
|
||||
rect: pre,
|
||||
x_edges,
|
||||
y_edges,
|
||||
exclude: &exclude,
|
||||
@@ -141,9 +141,21 @@ impl Tool for XformTool {
|
||||
w = w0 * (h / h0);
|
||||
}
|
||||
}
|
||||
if w.is_finite() && h.is_finite() && w.abs() > 1e-9 && h.abs() > 1e-9 {
|
||||
sx = (w / w0) * sign_x;
|
||||
sy = (h / h0) * sign_y;
|
||||
if w.is_finite() && h.is_finite() {
|
||||
let committed = Rect::new(center.x - w / 2.0, center.y - h / 2.0, center.x + w / 2.0, center.y + h / 2.0);
|
||||
crate::snap::lay_dimension_guides(
|
||||
&mut hit.guides,
|
||||
cx.layout.xf(spread),
|
||||
pre,
|
||||
committed,
|
||||
x_edges,
|
||||
y_edges,
|
||||
hit.length_delta,
|
||||
);
|
||||
if w.abs() > 1e-9 && h.abs() > 1e-9 {
|
||||
sx = (w / w0) * sign_x;
|
||||
sy = (h / h0) * sign_y;
|
||||
}
|
||||
}
|
||||
self.guides = hit.guides;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user