diff --git a/crates/tools/src/snap.rs b/crates/tools/src/snap.rs index 974081dd5..e79051b11 100644 --- a/crates/tools/src/snap.rs +++ b/crates/tools/src/snap.rs @@ -1368,880 +1368,4 @@ fn push_alignment_guide(out: &mut Vec, cx: &ToolContext, spread: Spread } #[cfg(test)] -mod tests { - use std::sync::Arc; - - use designcraft_compose::Cache; - use designcraft_doc::build::NewDocument; - use designcraft_doc::{Document, Guide, Item, Margins, PageSide, Selection, Shape, Stroke}; - use designcraft_geom::shapes; - use designcraft_geom::{Affine, Unit}; - - use crate::layout::CanvasLayout; - - use super::*; - - /// Snapping offers the parent page the canvas shows: a single-sided page uses a two-page parent's first page. - #[test] - fn shown_parent_page_matches_page_side() { - let two = Document::new(&NewDocument { facing_pages: true, ..Default::default() }); - assert_eq!(two.parents[0].pages.len(), 2); - assert_eq!(two.parents[0].parent_page_for_side(PageSide::Single), Some(0)); - assert_eq!(two.parents[0].parent_page_for_side(PageSide::Left), Some(0)); - assert_eq!(two.parents[0].parent_page_for_side(PageSide::Right), Some(1)); - let mut one = Document::new(&NewDocument { facing_pages: false, ..Default::default() }); - assert_eq!(one.parents[0].pages.len(), 1); - assert_eq!(one.parents[0].parent_page_for_side(PageSide::Single), Some(0)); - Arc::make_mut(&mut one.parents[0]).pages.clear(); - assert_eq!(one.parents[0].parent_page_for_side(PageSide::Single), None); - } - - // The factory flags are const. Pin them anyway. - #[test] - #[allow(clippy::assertions_on_constants)] - fn factory_zone_is_four_pixels() { - assert!(SnapView::FACTORY.snap_to_guides); - assert!(!SnapView::FACTORY.snap_to_document_grid); - assert!(SnapView::FACTORY.smart_guides); - assert_eq!(SnapView::FACTORY.zone_px, 4.0); - assert!(!SnapView::OFF.any()); - assert!(SnapView::FACTORY.any()); - } - - fn doc_with_items(moving: Rect, other: Rect, guide_x: f64) -> (Document, ItemId, ItemId) { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let make = |doc: &mut Document, rect: Rect| { - 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 - }; - let moving_id = make(&mut doc, moving); - let other_id = make(&mut doc, other); - let page = &mut Arc::make_mut(&mut doc.spreads[0]).pages[0]; - page.guides.push(Guide { orientation: Orientation::Vertical, position: guide_x, spread: false, locked: false, layer: None, liquid: false }); - (doc, moving_id, other_id) - } - - fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: &'a CanvasLayout) -> ToolContext<'a> { - ToolContext { - doc, - selection: sel, - cache, - layout, - zoom: 1.0, - layer: doc.default_layer(), - snap: SnapView::FACTORY, - unit: Unit::Points, - stroke_in_front: false, - } - } - - fn y_req(rect: Rect) -> SnapRequest<'static> { - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect, - x_edges: [false, false, false], - y_edges: [true, true, true], - exclude: &[], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - } - } - - fn move_req<'a>(rect: Rect, exclude: &'a [ItemId]) -> SnapRequest<'a> { - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect, - x_edges: [true, true, true], - y_edges: [false, false, false], - exclude, - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - } - } - - /// The bleed guide attracts like a ruler guide: a top edge 2 pt below the top bleed line - /// snaps up to it; with no bleed there is nothing there to snap to. - #[test] - fn bleed_guide_is_a_snap_target() { - let far = Rect::new(300.0, 300.0, 320.0, 320.0); - for (bleed, expected) in [([9.0, 9.0, 9.0, 9.0], -2.0), ([0.0; 4], 0.0)] { - let (mut doc, moving, _other) = doc_with_items(Rect::new(80.0, 100.0, 100.0, 140.0), far, 500.0); - doc.settings.bleed = bleed; - let top = doc.spreads[0].bounds().y0 - 9.0; - let rect = Rect::new(80.0, top + 2.0, 100.0, top + 40.0); - 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.zone_px = 4.0; - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect, - x_edges: [false, false, false], - y_edges: [true, true, true], - exclude: &[moving], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - }, - ); - assert!((hit.delta.y - expected).abs() < 1e-6, "bleed {bleed:?}: {:?}", hit.delta); - } - } - - #[test] - fn guide_beats_a_closer_object_edge() { - // Right edge at 100.5. Object edge at 100 (0.5 away). Guide at 104 (3.5 away). Zone is 4. - let (doc, moving, _other) = doc_with_items(Rect::new(80.0, 100.0, 100.5, 140.0), Rect::new(90.0, 100.0, 100.0, 160.0), 104.0); - 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.zone_px = 4.0; - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect: Rect::new(80.0, 100.0, 100.5, 140.0), - x_edges: [true, true, true], - y_edges: [false, false, false], - exclude: &[moving], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - }, - ); - assert!((hit.delta.x - 3.5).abs() < 1e-6); - assert!(hit.guides.is_empty()); - } - - /// A dragged guide is not its own target: excluded, the guide at 104 doesn't pull a line at 102. - /// Nor does the baseline grid pull it. - #[test] - fn excluded_guide_is_not_a_target() { - let far = Rect::new(400.0, 400.0, 420.0, 420.0); - let (doc, _, _) = doc_with_items(far, far, 104.0); - let cache = Cache::new(); - let layout = CanvasLayout::new(&doc, false); - let sel = Selection::default(); - let cx = ctx_on(&doc, &sel, &cache, &layout); - let line = Rect::new(102.0, 100.0, 102.0, 300.0); - let dragged = [designcraft_doc::GuideSel { spread: SpreadRef::Doc(0), page: 0, index: 0 }]; - let hit = snap(&cx, move_req(line, &[])); - assert!((hit.delta.x - 2.0).abs() < 1e-6, "{:?}", hit.delta); - let hit = snap(&cx, SnapRequest { exclude_guides: &dragged, ..move_req(line, &[]) }); - assert_eq!(hit.delta.x, 0.0); - // A dragged guide passes the baseline grid's lines (36 + 12n), which a moved object meets. - let flat = Rect::new(100.0, 86.0, 300.0, 86.0); - assert!((snap(&cx, y_req(flat)).delta.y - -2.0).abs() < 1e-6); - assert_eq!(snap(&cx, SnapRequest { gesture: Gesture::Guide, ..y_req(flat) }).delta.y, 0.0); - } - - #[test] - fn parent_centered_stroke_attracts_at_the_outer_edge() { - let mut doc = Document::new(&NewDocument::default()); - let pid = doc.add_parent("A", "A-Parent", 1, 12.0, Margins::uniform(36.0)); - doc.apply_parent(&[0], Some(pid)).unwrap(); - let layer = doc.default_layer(); - let id = ItemId(doc.alloc()); - let (ppi, ppg) = doc.parent_page_for(0).unwrap(); - let parent_x = doc.parents[ppi].pages[ppg].x; - let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(parent_x + 72.0, 140.0, parent_x + 252.0, 300.0))); - item.stroke = Stroke::default(); // 1 pt, center - doc.insert_item(SpreadRef::Parent(ppi), 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; - // Moving left edge at 253. Visible parent right edge is 252.5. - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect: Rect::new(253.0, 140.0, 353.0, 240.0), - x_edges: [true, false, false], - y_edges: [false, false, false], - exclude: &[], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - }, - ); - assert!((hit.delta.x - -0.5).abs() < 1e-6, "delta {}", hit.delta.x); - } - - #[test] - fn object_edge_snaps_when_guide_snap_is_off() { - let (doc, moving, _other) = doc_with_items(Rect::new(80.0, 100.0, 100.5, 140.0), Rect::new(90.0, 100.0, 100.0, 160.0), 104.0); - 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; - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect: Rect::new(80.0, 100.0, 100.5, 140.0), - x_edges: [true, true, true], - y_edges: [false, false, false], - exclude: &[moving], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - }, - ); - assert!((hit.delta.x - -0.5).abs() < 1e-6); - assert!(matches!(hit.guides.first(), Some(Overlay::Guide { .. }))); - } - - #[test] - fn hidden_layer_guide_is_not_a_target() { - let (mut doc, moving, _) = doc_with_items(Rect::new(80.0, 100.0, 102.0, 140.0), Rect::new(300.0, 100.0, 340.0, 140.0), 104.0); - let layer = doc.default_layer(); - doc.layers.iter_mut().find(|l| l.id == layer).unwrap().visible = false; - Arc::make_mut(&mut doc.spreads[0]).pages[0].guides[0].layer = Some(layer); - let cache = Cache::new(); - let layout = CanvasLayout::new(&doc, false); - let sel = Selection::default(); - let cx = ctx_on(&doc, &sel, &cache, &layout); - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect: Rect::new(80.0, 100.0, 102.0, 140.0), - x_edges: [false, false, true], - y_edges: [false, false, false], - exclude: &[moving], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - }, - ); - assert_eq!(hit.delta.x, 0.0); - } - - #[test] - fn alt_copy_snaps_to_the_original() { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let id = ItemId(doc.alloc()); - let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 40.0, 160.0, 80.0))); - 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; - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect: Rect::new(162.0, 40.0, 222.0, 80.0), - x_edges: [true, false, false], - y_edges: [false, false, false], - exclude: &[id], - exclude_guides: &[], - copying: true, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - }, - ); - assert!((hit.delta.x - -2.0).abs() < 1e-6); - } - - #[test] - fn alt_copy_does_not_stick_to_the_preview() { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let make = |doc: &mut Document, rect: Rect| { - 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 - }; - let original = make(&mut doc, Rect::new(100.0, 40.0, 160.0, 80.0)); - let ghost = make(&mut doc, Rect::new(161.0, 40.0, 221.0, 80.0)); - let cache = Cache::new(); - let layout = CanvasLayout::new(&doc, false); - let sel = Selection::items(vec![ghost]); - let mut cx = ctx_on(&doc, &sel, &cache, &layout); - cx.snap.snap_to_guides = false; - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect: Rect::new(161.0, 40.0, 221.0, 80.0), - x_edges: [true, false, false], - y_edges: [false, false, false], - exclude: &[original], - exclude_guides: &[], - copying: true, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - }, - ); - assert!((hit.delta.x - -1.0).abs() < 1e-6, "delta.x = {}", hit.delta.x); - } - - #[test] - fn parent_page_guides_snap_on_the_document_page() { - // Facing document: page 1 is a right page at x = 0; its parent page is A's right page at - // x = 612 in the parent spread. - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let id = ItemId(doc.alloc()); - let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(80.0, 100.0, 100.5, 140.0))); - doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); - Arc::make_mut(&mut doc.parents[0]).pages[1].guides.push(Guide { - orientation: Orientation::Vertical, - position: 612.0 + 104.0, - spread: false, - locked: false, - layer: None, - liquid: false, - }); - 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.zone_px = 4.0; - let hit = snap(&cx, move_req(Rect::new(80.0, 100.0, 100.5, 140.0), &[id])); - assert!((hit.delta.x - 3.5).abs() < 1e-6, "snaps to the parent guide at x = 104: {:?}", hit.delta); - } - - #[test] - fn grid_beats_a_closer_guide() { - let mut doc = Document::new(&NewDocument::default()); - doc.settings.grid.horizontal = 72.0; - doc.settings.grid.vertical = 72.0; - doc.settings.grid.subdivisions = 8; // 9 pt - let layer = doc.default_layer(); - let id = ItemId(doc.alloc()); - let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0))); - doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); - // Guide 1 pt away at x = 11. Subdivision line at 9 is 1 pt the other way. Grid must win. - Arc::make_mut(&mut doc.spreads[0]).pages[0].guides.push(Guide { - orientation: Orientation::Vertical, - position: 11.0, - spread: true, - locked: false, - layer: None, - liquid: false, - }); - 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_document_grid = true; - let hit = snap(&cx, move_req(Rect::new(10.0, 10.0, 40.0, 40.0), &[id])); - assert!((hit.delta.x - -1.0).abs() < 1e-6); - assert!(hit.guides.is_empty()); - } - - #[test] - fn hidden_baseline_still_snaps_and_zero_increment_does_not() { - let mut doc = Document::new(&NewDocument::default()); - doc.settings.baseline_grid.start = 36.0; - doc.settings.baseline_grid.increment = 12.0; - let cache = Cache::new(); - let layout = CanvasLayout::new(&doc, false); - let sel = Selection::default(); - let cx = ctx_on(&doc, &sel, &cache, &layout); - // Top at 49 wants the line at 48 (start 36, increment 12). The margin at 36 is 13 pt away. - let hit = snap(&cx, y_req(Rect::new(100.0, 49.0, 140.0, 80.0))); - assert!((hit.delta.y - -1.0).abs() < 1e-6); - let mut dead = doc.clone(); - dead.settings.baseline_grid.increment = 0.0; - let layout = CanvasLayout::new(&dead, false); - let cx = ctx_on(&dead, &sel, &cache, &layout); - 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 baseline_relative_to_the_top_margin_snaps_from_the_margin() { - let mut doc = Document::new(&NewDocument::default()); - std::sync::Arc::make_mut(&mut doc.spreads[0]).pages[0].margins.top = 37.0; - doc.settings.baseline_grid.start = 0.0; - doc.settings.baseline_grid.increment = 10.0; - doc.settings.baseline_grid.relative_to = designcraft_doc::GridRelative::TopMargin; - let cache = Cache::new(); - let layout = CanvasLayout::new(&doc, false); - let sel = Selection::default(); - let cx = ctx_on(&doc, &sel, &cache, &layout); - // Lines at 37 + 10n: a top at 58 goes up to 57, not down to the page-relative 60. - let hit = snap(&cx, y_req(Rect::new(100.0, 58.0, 140.0, 80.0))); - assert!((hit.delta.y - -1.0).abs() < 1e-6, "{}", hit.delta.y); - } - - #[test] - fn nearer_of_margin_and_baseline_wins() { - // Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744. - let doc = Document::new(&NewDocument { - margins: designcraft_doc::Margins { top: 54.0, bottom: 54.0, inside: 54.0, outside: 42.0 }, - ..Default::default() - }); - let cache = Cache::new(); - let layout = CanvasLayout::new(&doc, false); - let sel = Selection::default(); - // At 50% the 4 px zone is 8 pt: both the margin and a baseline line are in reach. - let cx = ToolContext { zoom: 0.5, ..ctx_on(&doc, &sel, &cache, &layout) }; - let bottom = |y1: f64| SnapRequest { y_edges: [false, false, true], gesture: Gesture::Resize, ..y_req(Rect::new(100.0, 600.0, 140.0, y1)) }; - assert!((snap(&cx, bottom(739.5)).delta.y - -1.5).abs() < 1e-6, "the margin is nearer"); - assert!((snap(&cx, bottom(742.5)).delta.y - 1.5).abs() < 1e-6, "the baseline line is nearer"); - assert!((snap(&cx, bottom(741.0)).delta.y - -3.0).abs() < 1e-6, "a tie goes to the margin"); - } - - #[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: &[], - exclude_guides: &[], - 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"))); - } - - #[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: &[], - exclude_guides: &[], - 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); - } - - #[test] - fn move_matches_a_stationary_gap() { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - // Stationary gap of 20 between [0,40] and [60,90]. Moving box at [112,150], 22 away from 90. - for r in [Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(60.0, 0.0, 90.0, 30.0), Rect::new(112.0, 0.0, 150.0, 30.0)] { - let id = ItemId(doc.alloc()); - let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(r)); - doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); - } - let moving = doc.spreads[0].items[2].id; - 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; - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Move, - rect: Rect::new(112.0, 0.0, 150.0, 30.0), - x_edges: [true, true, true], - y_edges: [false, false, false], - exclude: &[moving], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - }, - ); - assert!((hit.delta.x - -2.0).abs() < 1e-6); - assert!(hit.guides.iter().any(|g| matches!(g, Overlay::Gap { .. }))); - } - - fn doc_with_rects(rects: &[Rect]) -> Document { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - for &r in rects { - let id = ItemId(doc.alloc()); - let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(r)); - doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); - } - doc - } - - fn gap_overlays(guides: &[Overlay]) -> usize { - guides.iter().filter(|g| matches!(g, Overlay::Gap { .. })).count() - } - - // #188: a box dragged between two others snaps to the middle and marks both gaps. - #[test] - fn move_centers_between_two_boxes_side_by_side() { - // Neighbours end at 40 and start at 160. The 30 wide mover at [83,113] is 2 left of centre. - let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(160.0, 0.0, 200.0, 30.0)]); - 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; - let hit = snap(&cx, move_req(Rect::new(83.0, 5.0, 113.0, 25.0), &[])); - assert!((hit.delta.x - 2.0).abs() < 1e-6, "{}", hit.delta.x); - assert_eq!(gap_overlays(&hit.guides), 2); - } - - #[test] - fn move_centers_between_two_boxes_stacked() { - let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 30.0, 40.0), Rect::new(0.0, 160.0, 30.0, 200.0)]); - 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; - let hit = snap(&cx, y_req(Rect::new(5.0, 83.0, 25.0, 113.0))); - assert!((hit.delta.y - 2.0).abs() < 1e-6, "{}", hit.delta.y); - assert_eq!(gap_overlays(&hit.guides), 2); - } - - fn with_baseline_grid(mut doc: Document) -> Document { - doc.settings.baseline_grid.start = 36.0; - doc.settings.baseline_grid.increment = 12.0; - doc - } - - // #188: with Snap to Guides on, the hidden baseline grid took vertical moves first. - #[test] - fn vertical_centering_beats_a_closer_baseline() { - // Middle at 200. Mover centre 198 (+2). Its top and bottom are 3 from lines 180 and 216. - let doc = with_baseline_grid(doc_with_rects(&[Rect::new(100.0, 100.0, 130.0, 140.0), Rect::new(100.0, 260.0, 130.0, 300.0)])); - let cache = Cache::new(); - let layout = CanvasLayout::new(&doc, false); - let sel = Selection::default(); - let cx = ctx_on(&doc, &sel, &cache, &layout); - let hit = snap(&cx, y_req(Rect::new(105.0, 183.0, 125.0, 213.0))); - assert!((hit.delta.y - 2.0).abs() < 1e-6, "{}", hit.delta.y); - assert_eq!(gap_overlays(&hit.guides), 2); - } - - #[test] - fn vertical_alignment_beats_a_closer_baseline() { - // Top 107 is 1 from the line at 108 and 2 from the other box's top at 105. - let doc = with_baseline_grid(doc_with_rects(&[Rect::new(300.0, 105.0, 340.0, 135.0)])); - let cache = Cache::new(); - let layout = CanvasLayout::new(&doc, false); - let sel = Selection::default(); - let cx = ctx_on(&doc, &sel, &cache, &layout); - let hit = snap(&cx, y_req(Rect::new(100.0, 107.0, 140.0, 137.0))); - assert!((hit.delta.y - -2.0).abs() < 1e-6, "{}", hit.delta.y); - assert!(hit.guides.iter().any(|g| matches!(g, Overlay::Guide { .. }))); - } - - #[test] - fn move_off_center_outside_the_zone_does_not_center() { - // 10 off centre, zone 4. - let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(160.0, 0.0, 200.0, 30.0)]); - 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; - let hit = snap(&cx, move_req(Rect::new(95.0, 5.0, 125.0, 25.0), &[])); - assert_eq!(hit.delta.x, 0.0); - assert_eq!(gap_overlays(&hit.guides), 0); - } - - #[test] - fn centering_needs_smart_spacing() { - let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(160.0, 0.0, 200.0, 30.0)]); - 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.smart_spacing = false; - let hit = snap(&cx, move_req(Rect::new(83.0, 5.0, 113.0, 25.0), &[])); - assert_eq!(hit.delta.x, 0.0); - } - - #[test] - fn create_does_not_match_spacing() { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - for r in [Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(60.0, 0.0, 90.0, 30.0)] { - let id = ItemId(doc.alloc()); - let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(r)); - 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.smart_dimensions = false; - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Create, - rect: Rect::new(112.0, 0.0, 150.0, 30.0), - x_edges: [true, true, true], - y_edges: [false, false, false], - exclude: &[], - exclude_guides: &[], - copying: false, - lengths: [Some(38.0), None], - angle: None, - radius: 0.0, - pointer: Point::new(0.0, 0.0), - }, - ); - assert!((hit.delta.x).abs() < 1e-6); - } - - #[test] - fn rotation_matches_another_items_angle() { - 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, 80.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; - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Rotate, - rect: Rect::new(100.0, 100.0, 160.0, 140.0), - x_edges: [false, false, false], - y_edges: [false, false, false], - exclude: &[], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: Some(28.0), - radius: 80.0, - pointer: Point::new(180.0, 120.0), - }, - ); - let angle = hit.angle.expect("rotation hit"); - assert!((angle - 30.0).abs() < 1e-6); - assert!(matches!(hit.guides.first(), Some(Overlay::Measure { .. }))); - } - - #[test] - fn parent_item_angle_matches() { - // Document::new already has A-Parent. Apply a second parent and insert on the - // page parent_page_for shows, not Parent(0). A facing right page sits near x 612. - let mut doc = Document::new(&NewDocument::default()); - let pid = doc.add_parent("B", "B-Parent", 1, 12.0, Margins::uniform(36.0)); - doc.apply_parent(&[0], Some(pid)).unwrap(); - std::sync::Arc::make_mut(&mut doc.spreads[0]).pages[0].show_parent_items = true; - let (ppi, ppg) = doc.parent_page_for(0).unwrap(); - let parent_x = doc.parents[ppi].pages[ppg].x; - let rect = Rect::new(parent_x + 72.0, 140.0, parent_x + 152.0, 180.0); - let center = rect.center(); - let layer = doc.default_layer(); - let id = ItemId(doc.alloc()); - let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); - item.xf = Affine::translate((center.x, center.y)) * Affine::rotate((-30.0_f64).to_radians()) * Affine::translate((-center.x, -center.y)); - doc.insert_item(SpreadRef::Parent(ppi), item, None).unwrap(); - assert!(doc.spreads[0].items.iter().all(|it| (designcraft_geom::decompose::decompose(it.xf).rotation - 30.0).abs() > 1.0)); - 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; - let hit = snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Rotate, - rect: Rect::new(100.0, 100.0, 160.0, 140.0), - x_edges: [false, false, false], - y_edges: [false, false, false], - exclude: &[], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: Some(28.0), - radius: 80.0, - pointer: Point::new(180.0, 120.0), - }, - ); - let angle = hit.angle.expect("parent rotation hit"); - assert!((angle - 30.0).abs() < 1e-6); - } - - #[test] - fn rotated_group_uses_the_per_child_union() { - // Two children do not fill their local union. Rotating that union as one rectangle - // swings the empty corners out. The snap target is each child's visible box, transformed, then united. - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let mut child_a = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); - child_a.stroke = Stroke { weight: 2.0, ..Stroke::default() }; - let child_b = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 30.0, 50.0, 40.0))); - let mut group = Item::new(ItemId(doc.alloc()), layer, Shape::Group, designcraft_geom::PathData::default()); - let xf = Affine::translate((300.0, 400.0)) * Affine::rotate((-45.0_f64).to_radians()); - group.xf = xf; - group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child_a), Arc::new(child_b)] }; - doc.insert_item(SpreadRef::Doc(0), group, None).unwrap(); - let a_vis = Rect::new(-1.0, -1.0, 11.0, 11.0); - let b_vis = Rect::new(40.0, 30.0, 50.0, 40.0); - let per_child = xf.transform_rect_bbox(a_vis).union(xf.transform_rect_bbox(b_vis)); - let rotated_union = xf.transform_rect_bbox(a_vis.union(b_vis)); - // At 45 degrees the empty corners move y, not x. The two boxes share x0. - assert!((per_child.y0 - rotated_union.y0).abs() > 1.0, "fixture does not separate the two boxes"); - let gap = 2.0; - // A short rect just above the per-child top. Its other edges stay outside the zone of the rotated union. - let moving = Rect::new(per_child.x0, per_child.y0 - gap - 4.0, per_child.x0 + 20.0, per_child.y0 - gap); - 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; - let hit = snap(&cx, y_req(moving)); - assert!((hit.delta.y - gap).abs() < 1e-4, "delta.y {}, want {gap} onto {}", hit.delta.y, per_child.y0); - let wrong = rotated_union.y0 - moving.y1; - assert!((hit.delta.y - wrong).abs() > 1.0, "delta matched the rotated local union"); - } - - #[test] - fn rotated_group_child_does_not_offer_local_zero() { - // The group is at 30 degrees. Its child stays at local 0, so the absolute angle is 30. - // 2 degrees is inside the zone of 0 and far from 30. Local 0 must not win. - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let child = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0))); - let mut group = Item::new(ItemId(doc.alloc()), layer, Shape::Group, designcraft_geom::PathData::default()); - group.xf = Affine::rotate((-30.0_f64).to_radians()); - group.content = designcraft_doc::Content::Group { items: vec![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; - let ask = |angle: f64| { - snap( - &cx, - SnapRequest { - spread: SpreadRef::Doc(0), - gesture: Gesture::Rotate, - rect: Rect::new(100.0, 100.0, 160.0, 140.0), - x_edges: [false, false, false], - y_edges: [false, false, false], - exclude: &[], - exclude_guides: &[], - copying: false, - lengths: [None, None], - angle: Some(angle), - radius: 80.0, - pointer: Point::new(180.0, 120.0), - }, - ) - }; - let near_zero = ask(2.0); - assert!(near_zero.angle.is_none(), "local 0 was a target: {:?}", near_zero.angle); - let near_group = ask(28.0); - let angle = near_group.angle.expect("absolute 30"); - assert!((angle - 30.0).abs() < 1e-6, "angle {angle}"); - } -} +mod tests; diff --git a/crates/tools/src/snap/tests.rs b/crates/tools/src/snap/tests.rs new file mode 100644 index 000000000..2dfccafae --- /dev/null +++ b/crates/tools/src/snap/tests.rs @@ -0,0 +1,875 @@ +use std::sync::Arc; + +use designcraft_compose::Cache; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Document, Guide, Item, Margins, PageSide, Selection, Shape, Stroke}; +use designcraft_geom::shapes; +use designcraft_geom::{Affine, Unit}; + +use crate::layout::CanvasLayout; + +use super::*; + +/// Snapping offers the parent page the canvas shows: a single-sided page uses a two-page parent's first page. +#[test] +fn shown_parent_page_matches_page_side() { + let two = Document::new(&NewDocument { facing_pages: true, ..Default::default() }); + assert_eq!(two.parents[0].pages.len(), 2); + assert_eq!(two.parents[0].parent_page_for_side(PageSide::Single), Some(0)); + assert_eq!(two.parents[0].parent_page_for_side(PageSide::Left), Some(0)); + assert_eq!(two.parents[0].parent_page_for_side(PageSide::Right), Some(1)); + let mut one = Document::new(&NewDocument { facing_pages: false, ..Default::default() }); + assert_eq!(one.parents[0].pages.len(), 1); + assert_eq!(one.parents[0].parent_page_for_side(PageSide::Single), Some(0)); + Arc::make_mut(&mut one.parents[0]).pages.clear(); + assert_eq!(one.parents[0].parent_page_for_side(PageSide::Single), None); +} + +// The factory flags are const. Pin them anyway. +#[test] +#[allow(clippy::assertions_on_constants)] +fn factory_zone_is_four_pixels() { + assert!(SnapView::FACTORY.snap_to_guides); + assert!(!SnapView::FACTORY.snap_to_document_grid); + assert!(SnapView::FACTORY.smart_guides); + assert_eq!(SnapView::FACTORY.zone_px, 4.0); + assert!(!SnapView::OFF.any()); + assert!(SnapView::FACTORY.any()); +} + +fn doc_with_items(moving: Rect, other: Rect, guide_x: f64) -> (Document, ItemId, ItemId) { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let make = |doc: &mut Document, rect: Rect| { + 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 + }; + let moving_id = make(&mut doc, moving); + let other_id = make(&mut doc, other); + let page = &mut Arc::make_mut(&mut doc.spreads[0]).pages[0]; + page.guides.push(Guide { orientation: Orientation::Vertical, position: guide_x, spread: false, locked: false, layer: None, liquid: false }); + (doc, moving_id, other_id) +} + +fn ctx_on<'a>(doc: &'a Document, sel: &'a Selection, cache: &'a Cache, layout: &'a CanvasLayout) -> ToolContext<'a> { + ToolContext { + doc, + selection: sel, + cache, + layout, + zoom: 1.0, + layer: doc.default_layer(), + snap: SnapView::FACTORY, + unit: Unit::Points, + stroke_in_front: false, + } +} + +fn y_req(rect: Rect) -> SnapRequest<'static> { + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect, + x_edges: [false, false, false], + y_edges: [true, true, true], + exclude: &[], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + } +} + +fn move_req<'a>(rect: Rect, exclude: &'a [ItemId]) -> SnapRequest<'a> { + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect, + x_edges: [true, true, true], + y_edges: [false, false, false], + exclude, + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + } +} + +/// The bleed guide attracts like a ruler guide: a top edge 2 pt below the top bleed line +/// snaps up to it; with no bleed there is nothing there to snap to. +#[test] +fn bleed_guide_is_a_snap_target() { + let far = Rect::new(300.0, 300.0, 320.0, 320.0); + for (bleed, expected) in [([9.0, 9.0, 9.0, 9.0], -2.0), ([0.0; 4], 0.0)] { + let (mut doc, moving, _other) = doc_with_items(Rect::new(80.0, 100.0, 100.0, 140.0), far, 500.0); + doc.settings.bleed = bleed; + let top = doc.spreads[0].bounds().y0 - 9.0; + let rect = Rect::new(80.0, top + 2.0, 100.0, top + 40.0); + 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.zone_px = 4.0; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect, + x_edges: [false, false, false], + y_edges: [true, true, true], + exclude: &[moving], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert!((hit.delta.y - expected).abs() < 1e-6, "bleed {bleed:?}: {:?}", hit.delta); + } +} + +#[test] +fn guide_beats_a_closer_object_edge() { + // Right edge at 100.5. Object edge at 100 (0.5 away). Guide at 104 (3.5 away). Zone is 4. + let (doc, moving, _other) = doc_with_items(Rect::new(80.0, 100.0, 100.5, 140.0), Rect::new(90.0, 100.0, 100.0, 160.0), 104.0); + 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.zone_px = 4.0; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect: Rect::new(80.0, 100.0, 100.5, 140.0), + x_edges: [true, true, true], + y_edges: [false, false, false], + exclude: &[moving], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert!((hit.delta.x - 3.5).abs() < 1e-6); + assert!(hit.guides.is_empty()); +} + +/// A dragged guide is not its own target: excluded, the guide at 104 doesn't pull a line at 102. +/// Nor does the baseline grid pull it. +#[test] +fn excluded_guide_is_not_a_target() { + let far = Rect::new(400.0, 400.0, 420.0, 420.0); + let (doc, _, _) = doc_with_items(far, far, 104.0); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + let line = Rect::new(102.0, 100.0, 102.0, 300.0); + let dragged = [designcraft_doc::GuideSel { spread: SpreadRef::Doc(0), page: 0, index: 0 }]; + let hit = snap(&cx, move_req(line, &[])); + assert!((hit.delta.x - 2.0).abs() < 1e-6, "{:?}", hit.delta); + let hit = snap(&cx, SnapRequest { exclude_guides: &dragged, ..move_req(line, &[]) }); + assert_eq!(hit.delta.x, 0.0); + // A dragged guide passes the baseline grid's lines (36 + 12n), which a moved object meets. + let flat = Rect::new(100.0, 86.0, 300.0, 86.0); + assert!((snap(&cx, y_req(flat)).delta.y - -2.0).abs() < 1e-6); + assert_eq!(snap(&cx, SnapRequest { gesture: Gesture::Guide, ..y_req(flat) }).delta.y, 0.0); +} + +#[test] +fn parent_centered_stroke_attracts_at_the_outer_edge() { + let mut doc = Document::new(&NewDocument::default()); + let pid = doc.add_parent("A", "A-Parent", 1, 12.0, Margins::uniform(36.0)); + doc.apply_parent(&[0], Some(pid)).unwrap(); + let layer = doc.default_layer(); + let id = ItemId(doc.alloc()); + let (ppi, ppg) = doc.parent_page_for(0).unwrap(); + let parent_x = doc.parents[ppi].pages[ppg].x; + let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(parent_x + 72.0, 140.0, parent_x + 252.0, 300.0))); + item.stroke = Stroke::default(); // 1 pt, center + doc.insert_item(SpreadRef::Parent(ppi), 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; + // Moving left edge at 253. Visible parent right edge is 252.5. + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect: Rect::new(253.0, 140.0, 353.0, 240.0), + x_edges: [true, false, false], + y_edges: [false, false, false], + exclude: &[], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert!((hit.delta.x - -0.5).abs() < 1e-6, "delta {}", hit.delta.x); +} + +#[test] +fn object_edge_snaps_when_guide_snap_is_off() { + let (doc, moving, _other) = doc_with_items(Rect::new(80.0, 100.0, 100.5, 140.0), Rect::new(90.0, 100.0, 100.0, 160.0), 104.0); + 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; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect: Rect::new(80.0, 100.0, 100.5, 140.0), + x_edges: [true, true, true], + y_edges: [false, false, false], + exclude: &[moving], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert!((hit.delta.x - -0.5).abs() < 1e-6); + assert!(matches!(hit.guides.first(), Some(Overlay::Guide { .. }))); +} + +#[test] +fn hidden_layer_guide_is_not_a_target() { + let (mut doc, moving, _) = doc_with_items(Rect::new(80.0, 100.0, 102.0, 140.0), Rect::new(300.0, 100.0, 340.0, 140.0), 104.0); + let layer = doc.default_layer(); + doc.layers.iter_mut().find(|l| l.id == layer).unwrap().visible = false; + Arc::make_mut(&mut doc.spreads[0]).pages[0].guides[0].layer = Some(layer); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect: Rect::new(80.0, 100.0, 102.0, 140.0), + x_edges: [false, false, true], + y_edges: [false, false, false], + exclude: &[moving], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert_eq!(hit.delta.x, 0.0); +} + +#[test] +fn alt_copy_snaps_to_the_original() { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let id = ItemId(doc.alloc()); + let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 40.0, 160.0, 80.0))); + 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; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect: Rect::new(162.0, 40.0, 222.0, 80.0), + x_edges: [true, false, false], + y_edges: [false, false, false], + exclude: &[id], + exclude_guides: &[], + copying: true, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert!((hit.delta.x - -2.0).abs() < 1e-6); +} + +#[test] +fn alt_copy_does_not_stick_to_the_preview() { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let make = |doc: &mut Document, rect: Rect| { + 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 + }; + let original = make(&mut doc, Rect::new(100.0, 40.0, 160.0, 80.0)); + let ghost = make(&mut doc, Rect::new(161.0, 40.0, 221.0, 80.0)); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::items(vec![ghost]); + let mut cx = ctx_on(&doc, &sel, &cache, &layout); + cx.snap.snap_to_guides = false; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect: Rect::new(161.0, 40.0, 221.0, 80.0), + x_edges: [true, false, false], + y_edges: [false, false, false], + exclude: &[original], + exclude_guides: &[], + copying: true, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert!((hit.delta.x - -1.0).abs() < 1e-6, "delta.x = {}", hit.delta.x); +} + +#[test] +fn parent_page_guides_snap_on_the_document_page() { + // Facing document: page 1 is a right page at x = 0; its parent page is A's right page at + // x = 612 in the parent spread. + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let id = ItemId(doc.alloc()); + let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(80.0, 100.0, 100.5, 140.0))); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + Arc::make_mut(&mut doc.parents[0]).pages[1].guides.push(Guide { + orientation: Orientation::Vertical, + position: 612.0 + 104.0, + spread: false, + locked: false, + layer: None, + liquid: false, + }); + 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.zone_px = 4.0; + let hit = snap(&cx, move_req(Rect::new(80.0, 100.0, 100.5, 140.0), &[id])); + assert!((hit.delta.x - 3.5).abs() < 1e-6, "snaps to the parent guide at x = 104: {:?}", hit.delta); +} + +#[test] +fn grid_beats_a_closer_guide() { + let mut doc = Document::new(&NewDocument::default()); + doc.settings.grid.horizontal = 72.0; + doc.settings.grid.vertical = 72.0; + doc.settings.grid.subdivisions = 8; // 9 pt + let layer = doc.default_layer(); + let id = ItemId(doc.alloc()); + let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0))); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + // Guide 1 pt away at x = 11. Subdivision line at 9 is 1 pt the other way. Grid must win. + Arc::make_mut(&mut doc.spreads[0]).pages[0].guides.push(Guide { + orientation: Orientation::Vertical, + position: 11.0, + spread: true, + locked: false, + layer: None, + liquid: false, + }); + 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_document_grid = true; + let hit = snap(&cx, move_req(Rect::new(10.0, 10.0, 40.0, 40.0), &[id])); + assert!((hit.delta.x - -1.0).abs() < 1e-6); + assert!(hit.guides.is_empty()); +} + +#[test] +fn hidden_baseline_still_snaps_and_zero_increment_does_not() { + let mut doc = Document::new(&NewDocument::default()); + doc.settings.baseline_grid.start = 36.0; + doc.settings.baseline_grid.increment = 12.0; + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + // Top at 49 wants the line at 48 (start 36, increment 12). The margin at 36 is 13 pt away. + let hit = snap(&cx, y_req(Rect::new(100.0, 49.0, 140.0, 80.0))); + assert!((hit.delta.y - -1.0).abs() < 1e-6); + let mut dead = doc.clone(); + dead.settings.baseline_grid.increment = 0.0; + let layout = CanvasLayout::new(&dead, false); + let cx = ctx_on(&dead, &sel, &cache, &layout); + 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 baseline_relative_to_the_top_margin_snaps_from_the_margin() { + let mut doc = Document::new(&NewDocument::default()); + std::sync::Arc::make_mut(&mut doc.spreads[0]).pages[0].margins.top = 37.0; + doc.settings.baseline_grid.start = 0.0; + doc.settings.baseline_grid.increment = 10.0; + doc.settings.baseline_grid.relative_to = designcraft_doc::GridRelative::TopMargin; + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + // Lines at 37 + 10n: a top at 58 goes up to 57, not down to the page-relative 60. + let hit = snap(&cx, y_req(Rect::new(100.0, 58.0, 140.0, 80.0))); + assert!((hit.delta.y - -1.0).abs() < 1e-6, "{}", hit.delta.y); +} + +#[test] +fn nearer_of_margin_and_baseline_wins() { + // Bottom margin at 792 - 54 = 738, between baseline lines 732 and 744. + let doc = Document::new(&NewDocument { + margins: designcraft_doc::Margins { top: 54.0, bottom: 54.0, inside: 54.0, outside: 42.0 }, + ..Default::default() + }); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + // At 50% the 4 px zone is 8 pt: both the margin and a baseline line are in reach. + let cx = ToolContext { zoom: 0.5, ..ctx_on(&doc, &sel, &cache, &layout) }; + let bottom = |y1: f64| SnapRequest { y_edges: [false, false, true], gesture: Gesture::Resize, ..y_req(Rect::new(100.0, 600.0, 140.0, y1)) }; + assert!((snap(&cx, bottom(739.5)).delta.y - -1.5).abs() < 1e-6, "the margin is nearer"); + assert!((snap(&cx, bottom(742.5)).delta.y - 1.5).abs() < 1e-6, "the baseline line is nearer"); + assert!((snap(&cx, bottom(741.0)).delta.y - -3.0).abs() < 1e-6, "a tie goes to the margin"); +} + +#[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: &[], + exclude_guides: &[], + 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"))); +} + +#[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: &[], + exclude_guides: &[], + 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); +} + +#[test] +fn move_matches_a_stationary_gap() { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + // Stationary gap of 20 between [0,40] and [60,90]. Moving box at [112,150], 22 away from 90. + for r in [Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(60.0, 0.0, 90.0, 30.0), Rect::new(112.0, 0.0, 150.0, 30.0)] { + let id = ItemId(doc.alloc()); + let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(r)); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + let moving = doc.spreads[0].items[2].id; + 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; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Move, + rect: Rect::new(112.0, 0.0, 150.0, 30.0), + x_edges: [true, true, true], + y_edges: [false, false, false], + exclude: &[moving], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert!((hit.delta.x - -2.0).abs() < 1e-6); + assert!(hit.guides.iter().any(|g| matches!(g, Overlay::Gap { .. }))); +} + +fn doc_with_rects(rects: &[Rect]) -> Document { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + for &r in rects { + let id = ItemId(doc.alloc()); + let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(r)); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + doc +} + +fn gap_overlays(guides: &[Overlay]) -> usize { + guides.iter().filter(|g| matches!(g, Overlay::Gap { .. })).count() +} + +// #188: a box dragged between two others snaps to the middle and marks both gaps. +#[test] +fn move_centers_between_two_boxes_side_by_side() { + // Neighbours end at 40 and start at 160. The 30 wide mover at [83,113] is 2 left of centre. + let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(160.0, 0.0, 200.0, 30.0)]); + 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; + let hit = snap(&cx, move_req(Rect::new(83.0, 5.0, 113.0, 25.0), &[])); + assert!((hit.delta.x - 2.0).abs() < 1e-6, "{}", hit.delta.x); + assert_eq!(gap_overlays(&hit.guides), 2); +} + +#[test] +fn move_centers_between_two_boxes_stacked() { + let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 30.0, 40.0), Rect::new(0.0, 160.0, 30.0, 200.0)]); + 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; + let hit = snap(&cx, y_req(Rect::new(5.0, 83.0, 25.0, 113.0))); + assert!((hit.delta.y - 2.0).abs() < 1e-6, "{}", hit.delta.y); + assert_eq!(gap_overlays(&hit.guides), 2); +} + +fn with_baseline_grid(mut doc: Document) -> Document { + doc.settings.baseline_grid.start = 36.0; + doc.settings.baseline_grid.increment = 12.0; + doc +} + +// #188: with Snap to Guides on, the hidden baseline grid took vertical moves first. +#[test] +fn vertical_centering_beats_a_closer_baseline() { + // Middle at 200. Mover centre 198 (+2). Its top and bottom are 3 from lines 180 and 216. + let doc = with_baseline_grid(doc_with_rects(&[Rect::new(100.0, 100.0, 130.0, 140.0), Rect::new(100.0, 260.0, 130.0, 300.0)])); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + let hit = snap(&cx, y_req(Rect::new(105.0, 183.0, 125.0, 213.0))); + assert!((hit.delta.y - 2.0).abs() < 1e-6, "{}", hit.delta.y); + assert_eq!(gap_overlays(&hit.guides), 2); +} + +#[test] +fn vertical_alignment_beats_a_closer_baseline() { + // Top 107 is 1 from the line at 108 and 2 from the other box's top at 105. + let doc = with_baseline_grid(doc_with_rects(&[Rect::new(300.0, 105.0, 340.0, 135.0)])); + let cache = Cache::new(); + let layout = CanvasLayout::new(&doc, false); + let sel = Selection::default(); + let cx = ctx_on(&doc, &sel, &cache, &layout); + let hit = snap(&cx, y_req(Rect::new(100.0, 107.0, 140.0, 137.0))); + assert!((hit.delta.y - -2.0).abs() < 1e-6, "{}", hit.delta.y); + assert!(hit.guides.iter().any(|g| matches!(g, Overlay::Guide { .. }))); +} + +#[test] +fn move_off_center_outside_the_zone_does_not_center() { + // 10 off centre, zone 4. + let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(160.0, 0.0, 200.0, 30.0)]); + 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; + let hit = snap(&cx, move_req(Rect::new(95.0, 5.0, 125.0, 25.0), &[])); + assert_eq!(hit.delta.x, 0.0); + assert_eq!(gap_overlays(&hit.guides), 0); +} + +#[test] +fn centering_needs_smart_spacing() { + let doc = doc_with_rects(&[Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(160.0, 0.0, 200.0, 30.0)]); + 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.smart_spacing = false; + let hit = snap(&cx, move_req(Rect::new(83.0, 5.0, 113.0, 25.0), &[])); + assert_eq!(hit.delta.x, 0.0); +} + +#[test] +fn create_does_not_match_spacing() { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + for r in [Rect::new(0.0, 0.0, 40.0, 30.0), Rect::new(60.0, 0.0, 90.0, 30.0)] { + let id = ItemId(doc.alloc()); + let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(r)); + 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.smart_dimensions = false; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Create, + rect: Rect::new(112.0, 0.0, 150.0, 30.0), + x_edges: [true, true, true], + y_edges: [false, false, false], + exclude: &[], + exclude_guides: &[], + copying: false, + lengths: [Some(38.0), None], + angle: None, + radius: 0.0, + pointer: Point::new(0.0, 0.0), + }, + ); + assert!((hit.delta.x).abs() < 1e-6); +} + +#[test] +fn rotation_matches_another_items_angle() { + 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, 80.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; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Rotate, + rect: Rect::new(100.0, 100.0, 160.0, 140.0), + x_edges: [false, false, false], + y_edges: [false, false, false], + exclude: &[], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: Some(28.0), + radius: 80.0, + pointer: Point::new(180.0, 120.0), + }, + ); + let angle = hit.angle.expect("rotation hit"); + assert!((angle - 30.0).abs() < 1e-6); + assert!(matches!(hit.guides.first(), Some(Overlay::Measure { .. }))); +} + +#[test] +fn parent_item_angle_matches() { + // Document::new already has A-Parent. Apply a second parent and insert on the + // page parent_page_for shows, not Parent(0). A facing right page sits near x 612. + let mut doc = Document::new(&NewDocument::default()); + let pid = doc.add_parent("B", "B-Parent", 1, 12.0, Margins::uniform(36.0)); + doc.apply_parent(&[0], Some(pid)).unwrap(); + std::sync::Arc::make_mut(&mut doc.spreads[0]).pages[0].show_parent_items = true; + let (ppi, ppg) = doc.parent_page_for(0).unwrap(); + let parent_x = doc.parents[ppi].pages[ppg].x; + let rect = Rect::new(parent_x + 72.0, 140.0, parent_x + 152.0, 180.0); + let center = rect.center(); + let layer = doc.default_layer(); + let id = ItemId(doc.alloc()); + let mut item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(rect)); + item.xf = Affine::translate((center.x, center.y)) * Affine::rotate((-30.0_f64).to_radians()) * Affine::translate((-center.x, -center.y)); + doc.insert_item(SpreadRef::Parent(ppi), item, None).unwrap(); + assert!(doc.spreads[0].items.iter().all(|it| (designcraft_geom::decompose::decompose(it.xf).rotation - 30.0).abs() > 1.0)); + 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; + let hit = snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Rotate, + rect: Rect::new(100.0, 100.0, 160.0, 140.0), + x_edges: [false, false, false], + y_edges: [false, false, false], + exclude: &[], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: Some(28.0), + radius: 80.0, + pointer: Point::new(180.0, 120.0), + }, + ); + let angle = hit.angle.expect("parent rotation hit"); + assert!((angle - 30.0).abs() < 1e-6); +} + +#[test] +fn rotated_group_uses_the_per_child_union() { + // Two children do not fill their local union. Rotating that union as one rectangle + // swings the empty corners out. The snap target is each child's visible box, transformed, then united. + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let mut child_a = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + child_a.stroke = Stroke { weight: 2.0, ..Stroke::default() }; + let child_b = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 30.0, 50.0, 40.0))); + let mut group = Item::new(ItemId(doc.alloc()), layer, Shape::Group, designcraft_geom::PathData::default()); + let xf = Affine::translate((300.0, 400.0)) * Affine::rotate((-45.0_f64).to_radians()); + group.xf = xf; + group.content = designcraft_doc::Content::Group { items: vec![Arc::new(child_a), Arc::new(child_b)] }; + doc.insert_item(SpreadRef::Doc(0), group, None).unwrap(); + let a_vis = Rect::new(-1.0, -1.0, 11.0, 11.0); + let b_vis = Rect::new(40.0, 30.0, 50.0, 40.0); + let per_child = xf.transform_rect_bbox(a_vis).union(xf.transform_rect_bbox(b_vis)); + let rotated_union = xf.transform_rect_bbox(a_vis.union(b_vis)); + // At 45 degrees the empty corners move y, not x. The two boxes share x0. + assert!((per_child.y0 - rotated_union.y0).abs() > 1.0, "fixture does not separate the two boxes"); + let gap = 2.0; + // A short rect just above the per-child top. Its other edges stay outside the zone of the rotated union. + let moving = Rect::new(per_child.x0, per_child.y0 - gap - 4.0, per_child.x0 + 20.0, per_child.y0 - gap); + 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; + let hit = snap(&cx, y_req(moving)); + assert!((hit.delta.y - gap).abs() < 1e-4, "delta.y {}, want {gap} onto {}", hit.delta.y, per_child.y0); + let wrong = rotated_union.y0 - moving.y1; + assert!((hit.delta.y - wrong).abs() > 1.0, "delta matched the rotated local union"); +} + +#[test] +fn rotated_group_child_does_not_offer_local_zero() { + // The group is at 30 degrees. Its child stays at local 0, so the absolute angle is 30. + // 2 degrees is inside the zone of 0 and far from 30. Local 0 must not win. + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let child = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0))); + let mut group = Item::new(ItemId(doc.alloc()), layer, Shape::Group, designcraft_geom::PathData::default()); + group.xf = Affine::rotate((-30.0_f64).to_radians()); + group.content = designcraft_doc::Content::Group { items: vec![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; + let ask = |angle: f64| { + snap( + &cx, + SnapRequest { + spread: SpreadRef::Doc(0), + gesture: Gesture::Rotate, + rect: Rect::new(100.0, 100.0, 160.0, 140.0), + x_edges: [false, false, false], + y_edges: [false, false, false], + exclude: &[], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: Some(angle), + radius: 80.0, + pointer: Point::new(180.0, 120.0), + }, + ) + }; + let near_zero = ask(2.0); + assert!(near_zero.angle.is_none(), "local 0 was a target: {:?}", near_zero.angle); + let near_group = ask(28.0); + let angle = near_group.angle.expect("absolute 30"); + assert!((angle - 30.0).abs() < 1e-6, "angle {angle}"); +}