Snap: dimensions on resize and create

This commit is contained in:
Cameron Gilroy authored and Brandon Thomas committed 2026-10-06 09:05:59 -04:00
1 parent b0797f2b47
commit 40f3b6454a
6 files changed
+746 -73

No files matched your search

+22
View File
@@ -106,6 +106,28 @@ fn cmd_move_does_not_snap() {
assert!(b.x0 > 80.0);
}
#[test]
fn cmd_resize_still_snaps_to_a_guide() {
use designcraft_tools::{PointerEvent, PointerKind};
let mut s = session();
s.execute("frame.create", &json!({"rect": [100.0, 100.0, 200.0, 180.0], "content": "unassigned"})).unwrap();
s.execute("guide.add", &json!({"orientation": "vertical", "position": 250.0, "page": 0})).unwrap();
s.set_tool("selection");
let v = ViewInfo::at_zoom(1.0);
let b0 = s.doc().unwrap().doc.spreads[0].items[0].bounds();
let right = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(b0.x1, b0.center().y));
let cmd = designcraft_tools::Mods { cmd: true, ..Default::default() };
s.pointer(&PointerEvent::new(PointerKind::Down, right.x, right.y).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, right.x + 47.0, right.y).with_mods(cmd), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, right.x + 47.0, right.y).with_mods(cmd), v).unwrap();
let it = &s.doc().unwrap().doc.spreads[0].items[0];
let b = it.bounds();
assert!((b.x1 - 250.0).abs() < 1e-6, "cmd resize did not snap the moving edge: {b:?}");
assert!((b.x0 - b0.x0).abs() < 1e-6 && (b.y0 - b0.y0).abs() < 1e-6 && (b.y1 - b0.y1).abs() < 1e-6, "{b:?}");
let expect = (b.x1 - b.x0).abs() / b0.width() * (b.y1 - b.y0).abs() / b0.height();
assert!((it.stroke.weight - expect.sqrt()).abs() < 1e-6, "content scale weight {} want {}", it.stroke.weight, expect.sqrt());
}
#[test]
fn shift_move_on_a_turned_spread_stays_on_the_line() {
use designcraft_tools::{PointerEvent, PointerKind};
+111 -11
View File
@@ -4,7 +4,7 @@
use designcraft_geom::{Point, Rect};
use serde_json::json;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, rect_json, spread_json};
use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext, rect_json, spread_json};
pub struct FrameTool {
id: &'static str,
@@ -63,6 +63,52 @@ pub fn drag_rect(a: Point, b: Point, m: Mods) -> Rect {
}
}
/// Edges the drag is setting. Alt (drawn from the centre) snaps the pointer side.
fn moving_edges(anchor: Point, end: Point, rect: Rect, from_center: bool) -> ([bool; 3], [bool; 3]) {
if from_center {
let x = if (end.x - anchor.x).abs() <= 1e-9 {
[false, false, false]
} else if end.x >= anchor.x {
[false, false, true]
} else {
[true, false, false]
};
let y = if (end.y - anchor.y).abs() <= 1e-9 {
[false, false, false]
} else if end.y >= anchor.y {
[false, false, true]
} else {
[true, false, false]
};
return (x, y);
}
(anchored_edge(anchor.x, rect.x0, rect.x1), anchored_edge(anchor.y, rect.y0, rect.y1))
}
fn anchored_edge(anchor: f64, a0: f64, a1: f64) -> [bool; 3] {
let on0 = (a0 - anchor).abs() <= 1e-4;
let on1 = (a1 - anchor).abs() <= 1e-4;
if on0 && !on1 {
[false, false, true]
} else if on1 && !on0 {
[true, false, false]
} else if on0 && on1 {
[false, false, false]
} else if (a0 - anchor).abs() <= (a1 - anchor).abs() {
[false, false, true]
} else {
[true, false, false]
}
}
fn length_on(edges: [bool; 3], size: f64) -> Option<f64> {
if edges == [false, false, false] {
return None;
}
let len = size.abs();
len.is_finite().then_some(len)
}
impl Tool for FrameTool {
fn id(&self) -> &'static str {
self.id
@@ -90,14 +136,7 @@ impl Tool for FrameTool {
return vec![];
}
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
let mut ev = *ev;
self.guides.clear();
if cx.snap.any() && !ev.mods.shift {
let (p, g) = crate::snap::snap_point(cx, sr, cx.layout.to_spread(sr, ev.pos));
ev.pos = cx.layout.to_canvas(sr, p);
self.guides = g;
}
let ev = &ev;
let mut out = vec![];
if !self.active {
self.active = true;
@@ -107,15 +146,76 @@ impl Tool for FrameTool {
)));
}
if shape == "line" {
// Shift constrains the angle first. The free endpoint still snaps.
let mut b = cx.layout.to_spread(sr, ev.pos);
if ev.mods.shift {
let v = b - sa;
let v = designcraft_geom::constrain_angle(v, 45.0);
b = sa + v;
b = sa + designcraft_geom::constrain_angle(v, 45.0);
}
if cx.snap.any() {
let len = (b - sa).hypot();
let hit = crate::snap::snap(
cx,
SnapRequest {
spread: sr,
gesture: Gesture::Create,
rect: Rect::from_points(b, b),
x_edges: [true, true, true],
y_edges: [true, true, true],
exclude: &[],
copying: false,
lengths: [len.is_finite().then_some(len), None],
angle: None,
radius: 0.0,
pointer: b,
},
);
if hit.length_delta[1].is_none() {
b.y += hit.delta.y;
}
if hit.length_delta[0].is_none() {
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);
}
}
}
self.guides = hit.guides;
}
out.push(Action::Preview("line.create".into(), json!({"spread": spread_json(sr), "a": [sa.x, sa.y], "b": [b.x, b.y]})));
} else {
let r = drag_rect(sa, cx.layout.to_spread(sr, ev.pos), ev.mods);
// Shift constrains the shape first. The free edges still snap.
let end = cx.layout.to_spread(sr, ev.pos);
let mut r = drag_rect(sa, end, ev.mods);
if cx.snap.any() {
let (x_edges, y_edges) = moving_edges(sa, end, r, ev.mods.alt);
let hit = crate::snap::snap(
cx,
SnapRequest {
spread: sr,
gesture: Gesture::Create,
rect: r,
x_edges,
y_edges,
exclude: &[],
copying: false,
lengths: [length_on(x_edges, r.width()), length_on(y_edges, r.height())],
angle: None,
radius: 0.0,
pointer: end,
},
);
r = crate::snap::nudge_edges(r, x_edges, y_edges, hit.delta);
self.guides = hit.guides;
}
self.last = Some((spread_json(sr), r));
out.push(self.preview(spread_json(sr), r));
}
+3
View File
@@ -302,6 +302,9 @@ pub struct Snap {
pub delta: Vec2,
pub angle: Option<f64>,
pub guides: Vec<Overlay>,
/// Set when the dimensions pass won that axis. The value is the matched length minus the
/// proposed length (positive grows the size). `delta` on that axis is the moving edge's shift.
pub length_delta: [Option<f64>; 2],
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+162 -44
View File
@@ -102,6 +102,11 @@ fn rotate_zone(cx: &ToolContext, p: Point) -> Option<Point> {
/// New rect when dragging `handle` of `from` to `p`.
pub fn resize_rect(from: Rect, handle: usize, p: Point, m: Mods) -> Rect {
finish_resize(from, handle, resize_edges_rect(from, handle, p), m)
}
/// Edges the pointer sets, before Shift or Alt.
fn resize_edges_rect(from: Rect, handle: usize, p: Point) -> Rect {
let (mut x0, mut y0, mut x1, mut y1) = (from.x0, from.y0, from.x1, from.y1);
match handle {
0 => {
@@ -125,53 +130,125 @@ pub fn resize_rect(from: Rect, handle: usize, p: Point, m: Mods) -> Rect {
}
_ => x0 = p.x,
}
if m.shift && from.width() > 1e-9 && from.height() > 1e-9 {
let sx = (x1 - x0) / from.width();
let sy = (y1 - y0) / from.height();
let s = if matches!(handle, 1 | 5) {
sy
} else if matches!(handle, 3 | 7) || sx.abs() > sy.abs() {
sx
} else {
sy
};
let (w, h) = (from.width() * s, from.height() * s);
match handle {
0 => {
x0 = x1 - w;
y0 = y1 - h;
}
2 => {
x1 = x0 + w;
y0 = y1 - h;
}
3..=5 => {
x1 = x0 + w;
y1 = y0 + h;
}
6 | 7 => {
x0 = x1 - w;
y1 = y0 + h;
}
_ => {
x0 = x1 - w;
y0 = y1 - h;
}
Rect::new(x0, y0, x1, y1)
}
fn finish_resize(from: Rect, handle: usize, rect: Rect, m: Mods) -> Rect {
let rect = if m.shift && from.width() > 1e-9 && from.height() > 1e-9 {
apply_aspect(from, handle, rect, aspect_scale(from, handle, rect))
} else {
rect
};
if m.alt { apply_alt(from, handle, rect) } else { rect }
}
fn aspect_scale(from: Rect, handle: usize, rect: Rect) -> f64 {
let sx = (rect.x1 - rect.x0) / from.width();
let sy = (rect.y1 - rect.y0) / from.height();
if matches!(handle, 1 | 5) {
sy
} else if matches!(handle, 3 | 7) || sx.abs() > sy.abs() {
sx
} else {
sy
}
}
/// True when the width, not the height, drives a Shift constraint.
fn drives_x(handle: usize, from: Rect, rect: Rect) -> bool {
if matches!(handle, 1 | 5) {
return false;
}
if matches!(handle, 3 | 7) {
return true;
}
if from.width() <= 1e-9 || from.height() <= 1e-9 {
return true;
}
let sx = ((rect.x1 - rect.x0) / from.width()).abs();
let sy = ((rect.y1 - rect.y0) / from.height()).abs();
sx > sy
}
fn apply_aspect(from: Rect, handle: usize, rect: Rect, s: f64) -> Rect {
let (mut x0, mut y0, mut x1, mut y1) = (rect.x0, rect.y0, rect.x1, rect.y1);
let (w, h) = (from.width() * s, from.height() * s);
match handle {
0 => {
x0 = x1 - w;
y0 = y1 - h;
}
2 => {
x1 = x0 + w;
y0 = y1 - h;
}
3..=5 => {
x1 = x0 + w;
y1 = y0 + h;
}
6 | 7 => {
x0 = x1 - w;
y1 = y0 + h;
}
_ => {
x0 = x1 - w;
y0 = y1 - h;
}
}
if m.alt {
let c = from.center();
let (hw, hh) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0);
let (hw, hh) = match handle {
1 | 5 => (from.width() / 2.0, (if handle == 1 { c.y - y0 } else { y1 - c.y })),
3 | 7 => ((if handle == 7 { c.x - x0 } else { x1 - c.x }), from.height() / 2.0),
_ => (hw.abs().max((c.x - x0).abs()).max((x1 - c.x).abs()), hh.abs().max((c.y - y0).abs()).max((y1 - c.y).abs())),
};
return Rect::new(c.x - hw, c.y - hh, c.x + hw, c.y + hh);
}
Rect::new(x0, y0, x1, y1)
}
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();
let (hw, hh) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0);
let (hw, hh) = match handle {
1 | 5 => (from.width() / 2.0, (if handle == 1 { c.y - y0 } else { y1 - c.y })),
3 | 7 => ((if handle == 7 { c.x - x0 } else { x1 - c.x }), from.height() / 2.0),
_ => (hw.abs().max((c.x - x0).abs()).max((x1 - c.x).abs()), hh.abs().max((c.y - y0).abs()).max((y1 - c.y).abs())),
};
Rect::new(c.x - hw, c.y - hh, c.x + hw, c.y + hh)
}
/// Left/center/right and top/center/bottom for a handle. Centers stay off.
fn resize_edge_flags(handle: usize) -> ([bool; 3], [bool; 3]) {
let x = match handle {
0 | 6 | 7 => [true, false, false],
2..=4 => [false, false, true],
_ => [false, false, false],
};
let y = match handle {
0..=2 => [true, false, false],
4..=6 => [false, false, true],
_ => [false, false, false],
};
(x, y)
}
fn axis_length(edges: [bool; 3], size: f64, pad: f64) -> Option<f64> {
if edges == [false, false, false] {
return None;
}
let len = size.abs() + pad;
len.is_finite().then_some(len)
}
/// Visible size minus path size, for one selected item. Centered stroke then matches the outer edge.
fn single_stroke_pad(cx: &ToolContext) -> (f64, f64) {
if cx.selection.items.len() != 1 {
return (0.0, 0.0);
}
let Some(id) = cx.selection.items.first() else { return (0.0, 0.0) };
let Some(it) = cx.doc.item(*id) else { return (0.0, 0.0) };
let path = it.bounds();
let vis = it.visible_bounds();
(positive_pad(vis.width() - path.width()), positive_pad(vis.height() - path.height()))
}
fn positive_pad(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v } else { 0.0 }
}
impl Tool for SelectionTool {
fn id(&self) -> &'static str {
if self.direct { "directSelection" } else { "selection" }
@@ -327,10 +404,51 @@ impl Tool for SelectionTool {
vec![Action::Preview("transform.move".into(), params)]
}
Drag::Resize { handle, start, from, spread } => {
let to = resize_rect(from, handle, cx.layout.to_spread(spread, p), ev.mods);
let _ = start;
let pointer = cx.layout.to_spread(spread, p);
let raw = resize_edges_rect(from, handle, pointer);
let mut to = resize_rect(from, handle, pointer, ev.mods);
// Space while dragging: Live Distribute (several objects keep their size).
let distribute = ev.mods.space && cx.selection.items.len() > 1;
self.guides.clear();
// Command still scales content. It does not skip the snap.
if !distribute && cx.snap.any() {
let (mut x_edges, mut y_edges) = resize_edge_flags(handle);
let driver_x = drives_x(handle, from, raw);
if ev.mods.shift {
if driver_x {
y_edges = [false, false, false];
} else {
x_edges = [false, false, false];
}
}
let exclude = cx.selection.items.clone();
let (pad_x, pad_y) = single_stroke_pad(cx);
let hit = crate::snap::snap(
cx,
SnapRequest {
spread,
gesture: Gesture::Resize,
rect: to,
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)],
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);
}
}
self.guides = hit.guides;
}
vec![Action::Preview(
"transform.resize".into(),
json!({"from": rect_json(from), "to": rect_json(to), "content": ev.mods.cmd, "distribute": distribute}),
@@ -423,7 +541,7 @@ impl Tool for SelectionTool {
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match &self.drag {
Drag::Marquee { start, cur } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
Drag::Move { .. } => self.guides.clone(),
Drag::Move { .. } | Drag::Resize { .. } => self.guides.clone(),
_ => vec![],
}
}
+319 -9
View File
@@ -6,9 +6,9 @@
use std::collections::HashSet;
use designcraft_doc::{Item, ItemId, Orientation, PageSide, Selection, SpreadId, SpreadRef};
use designcraft_doc::{Item, ItemId, Orientation, PageSide, Selection, SpreadId, SpreadRef, StrokeAlign};
use designcraft_geom::snap::snap_to_grid;
use designcraft_geom::{Point, Rect};
use designcraft_geom::{Point, Rect, Vec2, format_measure};
pub use crate::{Gesture, Snap, SnapRequest, SnapView};
@@ -149,33 +149,71 @@ pub fn snap(cx: &ToolContext, req: SnapRequest<'_>) -> Snap {
let mut out = Snap::default();
if let Some(hit) = &x_hit {
out.delta.x = hit.delta;
out.length_delta[0] = hit.length_delta;
}
if let Some(hit) = &y_hit {
out.delta.y = hit.delta;
out.length_delta[1] = hit.length_delta;
}
let placed = Rect::new(req.rect.x0 + out.delta.x, req.rect.y0 + out.delta.y, req.rect.x1 + out.delta.x, req.rect.y1 + out.delta.y);
if let Some(hit) = &x_hit {
push_alignment_guide(&mut out.guides, cx, req.spread, Axis::X, hit, placed);
push_hit_guides(&mut out.guides, cx, &req, Axis::X, hit, placed);
}
if let Some(hit) = &y_hit {
push_alignment_guide(&mut out.guides, cx, req.spread, Axis::Y, hit, placed);
push_hit_guides(&mut out.guides, cx, &req, Axis::Y, hit, placed);
}
out
}
/// Move the flagged edges by `delta`.
///
/// One edge takes the whole delta. Both edges of an axis split it, low edge one way and high
/// edge the other, so a length match keeps the centre. A non-finite component is ignored.
pub(crate) fn nudge_edges(mut rect: Rect, x_edges: [bool; 3], y_edges: [bool; 3], delta: Vec2) -> Rect {
nudge_axis(&mut rect.x0, &mut rect.x1, x_edges, delta.x);
nudge_axis(&mut rect.y0, &mut rect.y1, y_edges, delta.y);
rect
}
fn nudge_axis(lo: &mut f64, hi: &mut f64, flags: [bool; 3], delta: f64) {
if !delta.is_finite() {
return;
}
let [low_edge, _, high_edge] = flags;
if low_edge && high_edge {
*lo -= delta;
*hi += delta;
} else if low_edge {
*lo += delta;
} else if high_edge {
*hi += delta;
}
}
#[derive(Clone, Copy)]
enum Axis {
X,
Y,
}
#[derive(Clone, Copy)]
#[derive(Clone)]
struct AxisHit {
delta: f64,
/// Position of the winning line on this axis.
at: f64,
/// Target box for an alignment guide. None when the hit draws nothing.
span: Option<Rect>,
/// Matched length minus the proposed length, when this hit is a dimension match.
length_delta: Option<f64>,
/// Spread-space segment along the matched side, plus the measure text.
dimension: Option<DimMark>,
}
#[derive(Clone)]
struct DimMark {
a: Point,
b: Point,
text: String,
}
struct Best {
@@ -343,8 +381,232 @@ fn pass_spacing(_cx: &ToolContext, _req: &SnapRequest<'_>, _axis: Axis, _tol: f6
None
}
fn pass_dimensions(_cx: &ToolContext, _req: &SnapRequest<'_>, _axis: Axis, _tol: f64) -> Option<AxisHit> {
None
/// 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.
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;
}
let proposed = match axis {
Axis::X => req.lengths[0],
Axis::Y => req.lengths[1],
}?;
if !proposed.is_finite() {
return None;
}
let mut best: Option<(f64, f64)> = None;
for len in dimension_lengths(cx, req) {
if !len.is_finite() || len < 0.0 {
continue;
}
let dist = (len - proposed).abs();
if dist <= tol && best.is_none_or(|(have, _)| dist < have) {
best = Some((dist, len));
}
}
let (_, length) = best?;
let diff = length - proposed;
if !diff.is_finite() {
return None;
}
let flags = axis_flags(req, axis);
let text = format_measure(length, cx.unit);
Some(AxisHit {
delta: length_edge_delta(flags, diff),
at: 0.0,
span: None,
length_delta: Some(diff),
dimension: Some(dimension_mark(req, axis, diff, text)),
})
}
/// Edge shift so adding it to the moving edge grows the length by `diff`.
fn length_edge_delta(flags: [bool; 3], diff: f64) -> f64 {
let [low_edge, mid, high_edge] = flags;
if low_edge && high_edge && !mid {
diff / 2.0
} else if high_edge && !low_edge {
diff
} else if low_edge && !high_edge {
-diff
} else {
diff
}
}
/// 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) = match axis {
Axis::X => {
let (x0, x1) = grown_span(rect.x0, rect.x1, flags, diff);
let y = rect.y0.max(rect.y1);
(Point::new(x0, y), Point::new(x1, y))
}
Axis::Y => {
let (y0, y1) = grown_span(rect.y0, rect.y1, flags, diff);
let x = rect.x0.max(rect.x1);
(Point::new(x, y0), Point::new(x, y1))
}
};
DimMark { a, b, text }
}
fn grown_span(a0: f64, a1: f64, flags: [bool; 3], diff: f64) -> (f64, f64) {
let (lo, hi) = if a0 <= a1 { (a0, a1) } else { (a1, a0) };
let [low_edge, mid, high_edge] = flags;
if low_edge && high_edge && !mid {
let mid_v = (lo + hi) / 2.0;
let half = (hi - lo) / 2.0 + diff / 2.0;
(mid_v - half, mid_v + half)
} else if low_edge && !high_edge {
(lo - diff, hi)
} else {
(lo, hi + diff)
}
}
fn dimension_lengths(cx: &ToolContext, req: &SnapRequest<'_>) -> Vec<f64> {
let mut out = Vec::new();
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);
}
}
if !layout_edits_parents(cx) {
push_parent_lengths(&mut out, cx, req, &mut seen);
}
out
}
fn push_own_lengths(
out: &mut Vec<f64>,
selection: &Selection,
req: &SnapRequest<'_>,
item: &Item,
ancestor_sx: f64,
ancestor_sy: f64,
skipped: bool,
depth: usize,
seen: &mut HashSet<ItemId>,
) {
if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) || item.hidden {
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);
}
return;
}
if skip {
return;
}
let Some((w, h)) = leaf_own_size(item, ancestor_sx, ancestor_sy) 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)> {
let local = item.path.bounds()?;
if !rect_finite(local) {
return None;
}
let outset = stroke_outset(item);
if !outset.is_finite() {
return None;
}
let local = if outset > 0.0 { local.inflate(outset, outset) } else { local };
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();
if !sx.is_finite() || !sy.is_finite() {
return None;
}
let w = (local.x1 - local.x0).abs() * sx;
let h = (local.y1 - local.y0).abs() * sy;
(w.is_finite() && h.is_finite()).then_some((w, h))
}
/// Same outset as [`Item::visible_bounds`]: half the weight when centered, the whole weight when
/// outside, nothing when inside or when the stroke is none. Open paths also count arrowheads.
fn stroke_outset(item: &Item) -> f64 {
if item.stroke.is_none() {
return 0.0;
}
let w = match item.stroke.align {
StrokeAlign::Center => item.stroke.weight / 2.0,
StrokeAlign::Inside => 0.0,
StrokeAlign::Outside => item.stroke.weight,
};
if item.path.is_closed() { w } else { w.max(item.stroke.extent() - item.stroke.weight) }
}
fn push_parent_lengths(out: &mut Vec<f64>, cx: &ToolContext, req: &SnapRequest<'_>, seen_items: &mut HashSet<ItemId>) {
let SpreadRef::Doc(si) = req.spread else { return };
let page_count = cx.doc.spreads.get(si).map(|sp| sp.pages.len()).unwrap_or(0);
let first = cx.doc.first_page_of_spread(si);
for pi in 0..page_count {
let Some(abs) = first.checked_add(pi) else { continue };
let Some(page) = cx.doc.page(abs) else { continue };
if !page.show_parent_items {
continue;
}
let doc_side = page.side;
let overridden = page.overridden.clone();
let Some((ppi, _)) = cx.doc.parent_page_for(abs) else { continue };
let mut seen_spreads = HashSet::new();
push_parent_length_chain(out, cx, req, doc_side, &overridden, ppi, seen_items, &mut seen_spreads);
}
}
fn push_parent_length_chain(
out: &mut Vec<f64>,
cx: &ToolContext,
req: &SnapRequest<'_>,
doc_side: PageSide,
overridden: &[ItemId],
index: usize,
seen_items: &mut HashSet<ItemId>,
seen_spreads: &mut HashSet<SpreadId>,
) {
let Some(parent) = cx.doc.parents.get(index) else { return };
if !seen_spreads.insert(parent.id) {
return;
}
let next = parent.parent.as_ref().and_then(|info| info.based_on);
if let Some(page_idx) = shown_parent_page_index(parent.pages.len(), doc_side) {
for item in &parent.items {
if overridden.contains(&item.id) {
continue;
}
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);
}
}
let Some(next_id) = next else { return };
let Some(next_index) = cx.doc.parent_index(next_id) else { return };
push_parent_length_chain(out, cx, req, doc_side, overridden, next_index, seen_items, seen_spreads);
}
/// Ruler guides, margins, and column sides. No overlay.
@@ -626,7 +888,20 @@ fn offer(best: &mut Option<Best>, moving: f64, target: f64, tol: f64, span: Opti
let delta = target - moving;
let dist = delta.abs();
if dist <= tol && best.as_ref().is_none_or(|b| dist < b.dist) {
*best = Some(Best { dist, hit: AxisHit { delta, at: target, span } });
*best = Some(Best { dist, hit: AxisHit { delta, at: target, span, length_delta: None, dimension: None } });
}
}
fn push_hit_guides(out: &mut Vec<Overlay>, cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, hit: &AxisHit, moving: Rect) {
push_alignment_guide(out, cx, req.spread, axis, hit, moving);
let Some(mark) = &hit.dimension else { return };
let xf = cx.layout.xf(req.spread);
if mark.a.x.is_finite() && mark.a.y.is_finite() && mark.b.x.is_finite() && mark.b.y.is_finite() {
out.push(Overlay::Guide { a: xf * mark.a, b: xf * mark.b });
}
let p = xf * req.pointer;
if p.x.is_finite() && p.y.is_finite() {
out.push(Overlay::Measure { p, text: mark.text.clone() });
}
}
@@ -659,8 +934,8 @@ mod tests {
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Guide, Item, Margins, Selection, Shape, Stroke};
use designcraft_geom::Unit;
use designcraft_geom::shapes;
use designcraft_geom::{Affine, Unit};
use crate::layout::CanvasLayout;
@@ -968,4 +1243,39 @@ mod tests {
let hit = snap(&cx, y_req(Rect::new(100.0, 49.0, 140.0, 80.0)));
assert_eq!(hit.delta.y, 0.0);
}
#[test]
fn rotated_item_matches_its_own_side() {
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let id = ItemId(doc.alloc());
let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 40.0)));
item.xf = Affine::rotate((-30.0_f64).to_radians());
doc.insert_item(SpreadRef::Doc(0), item, 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;
// Width 193.2 is the axis-aligned box. 200 is the side. Zone 4 reaches 200 from 197, not from 193.
let hit = snap(
&cx,
SnapRequest {
spread: SpreadRef::Doc(0),
gesture: Gesture::Resize,
rect: Rect::new(80.0, 200.0, 277.0, 280.0),
x_edges: [false, false, true],
y_edges: [false, false, false],
exclude: &[],
copying: false,
lengths: [Some(197.0), None],
angle: None,
radius: 0.0,
pointer: Point::new(277.0, 240.0),
},
);
assert!((hit.delta.x - 3.0).abs() < 1e-6);
assert!(matches!(hit.guides.last(), Some(Overlay::Measure { text, .. }) if text.contains("200")));
}
}
+129 -9
View File
@@ -2,22 +2,55 @@
//! 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 designcraft_doc::SpreadRef;
use designcraft_geom::{Point, Rect};
use serde_json::json;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
use crate::{Action, Cursor, Gesture, Mods, Overlay, PointerEvent, PointerKind, SnapRequest, Tool, ToolContext};
pub struct XformTool {
id: &'static str,
drag: Option<(Point, Point)>,
guides: Vec<Overlay>,
}
impl XformTool {
pub fn new(id: &'static str) -> Self {
Self { id, drag: None }
Self { id, drag: None, guides: Vec::new() }
}
}
/// Top-level selection on one spread, in that spread's coordinates.
fn selection_spread_bounds(cx: &ToolContext) -> Option<(SpreadRef, Rect)> {
let mut spread = None;
let mut rect: Option<Rect> = None;
for id in &cx.selection.items {
let loc = cx.doc.find(*id)?;
if loc.path.len() != 1 {
return None;
}
if let Some(have) = spread
&& have != loc.spread
{
return None;
}
spread = Some(loc.spread);
let b = cx.doc.item(*id)?.bounds();
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
return None;
}
rect = Some(match rect {
Some(r) => r.union(b),
None => b,
});
}
Some((spread?, rect?))
}
fn edge_toward(pointer: f64, center: f64) -> [bool; 3] {
if pointer >= center { [false, false, true] } else { [true, false, false] }
}
impl Tool for XformTool {
fn id(&self) -> &'static str {
self.id
@@ -25,6 +58,7 @@ impl Tool for XformTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
self.guides.clear();
let Some(b) = cx.selection_bounds() else { return vec![] };
self.drag = Some((b.center(), ev.pos));
vec![Action::Begin(match self.id {
@@ -36,6 +70,7 @@ impl Tool for XformTool {
PointerKind::Drag => {
let Some((c, start)) = self.drag else { return vec![] };
let (v0, v1) = (start - c, ev.pos - c);
self.guides.clear();
match self.id {
"rotate" => {
let mut a = (v1.atan2() - v0.atan2()).to_degrees();
@@ -45,12 +80,74 @@ impl Tool for XformTool {
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());
let (sx0, sy0) = (v1.x / v0.x.abs().max(1e-6) * v0.x.signum(), v1.y / v0.y.abs().max(1e-6) * v0.y.signum());
let x_drives = !ev.mods.shift || sx0.abs() >= sy0.abs();
let (mut sx, mut sy) = (sx0, sy0);
if ev.mods.shift {
let s = sx.abs().max(sy.abs());
sx = s;
sy = s;
}
if cx.snap.any()
&& sx.is_finite()
&& sy.is_finite()
&& let Some((spread, from)) = selection_spread_bounds(cx)
{
let (sign_x, sign_y) = (if sx < 0.0 { -1.0 } else { 1.0 }, if sy < 0.0 { -1.0 } else { 1.0 });
let (w0, h0) = (from.width().abs(), from.height().abs());
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 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(
cx,
SnapRequest {
spread,
gesture: Gesture::Resize,
rect: to,
x_edges,
y_edges,
exclude: &exclude,
copying: false,
lengths: [length_on(x_edges, w, pad_x), length_on(y_edges, h, pad_y)],
angle: None,
radius: 0.0,
pointer,
},
);
if let Some(diff) = hit.length_delta[0] {
w += diff;
} else if x_edges[2] {
w += 2.0 * hit.delta.x;
} else if x_edges[0] {
w -= 2.0 * hit.delta.x;
}
if let Some(diff) = hit.length_delta[1] {
h += diff;
} else if y_edges[2] {
h += 2.0 * hit.delta.y;
} else if y_edges[0] {
h -= 2.0 * hit.delta.y;
}
if ev.mods.shift {
if x_drives {
h = h0 * (w / w0);
} else {
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;
}
self.guides = hit.guides;
}
}
vec![Action::Preview("transform.scale".into(), json!({"sx": sx, "sy": sy}))]
}
_ => {
@@ -60,15 +157,15 @@ impl Tool for XformTool {
}
}
PointerKind::Up => {
if self.drag.take().is_some() {
vec![Action::Commit]
} else {
vec![]
}
self.guides.clear();
if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] }
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
self.guides.clone()
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
if self.id == "rotate" { Cursor::Rotate } else { Cursor::Crosshair }
}
@@ -77,6 +174,29 @@ impl Tool for XformTool {
}
}
fn length_on(edges: [bool; 3], size: f64, pad: f64) -> Option<f64> {
if edges == [false, false, false] {
return None;
}
let len = size.abs() + pad;
len.is_finite().then_some(len)
}
fn scale_stroke_pad(cx: &ToolContext) -> (f64, f64) {
if cx.selection.items.len() != 1 {
return (0.0, 0.0);
}
let Some(id) = cx.selection.items.first() else { return (0.0, 0.0) };
let Some(it) = cx.doc.item(*id) else { return (0.0, 0.0) };
let path = it.bounds();
let vis = it.visible_bounds();
(positive_pad(vis.width() - path.width()), positive_pad(vis.height() - path.height()))
}
fn positive_pad(v: f64) -> f64 {
if v.is_finite() && v > 0.0 { v } else { 0.0 }
}
#[derive(Default)]
pub struct EyedropperTool;