Snap: parent items use the stroked edge

This commit is contained in:
Cameron Gilroy authored and Brandon Thomas committed 2026-10-06 09:05:59 -04:00
1 parent 7032587238
commit b0797f2b47
1 file changed
+177 -14
+177 -14
View File
@@ -4,7 +4,9 @@
//! Ruler guides, margins, and columns draw nothing. Alignment draws [`Overlay::Guide`] in
//! canvas coordinates. [`snap_rect`] is the older closest-target helper.
use designcraft_doc::{ItemId, Orientation, SpreadRef};
use std::collections::HashSet;
use designcraft_doc::{Item, ItemId, Orientation, PageSide, Selection, SpreadId, SpreadRef};
use designcraft_geom::snap::snap_to_grid;
use designcraft_geom::{Point, Rect};
@@ -389,35 +391,159 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
best.map(|b| b.hit)
}
/// Page and top-level item edges and centers. Draws one [`Overlay::Guide`] per winning axis.
/// How deep a group may nest before alignment stops walking it.
const ALIGN_GROUP_DEPTH: usize = 32;
/// Page and item edges and centers. Draws one [`Overlay::Guide`] per winning axis.
///
/// Edges match edges only while `align_edges` is on. Centers match centers only while
/// `align_centers` is on, and only when the request's middle flag is set. Hidden items are
/// skipped. Without copying, excluded items are skipped. With copying, that list is the
/// drag-start set and stays a target. Live selection items outside that set are preview
/// copies and are skipped. Parent items and group children are not targets.
/// copies and are skipped. A group with children uses the union of those children's visible
/// bounds in group space, then the group's transform. It does not use the group's own stroke.
/// Parent items are added for a document page that shows them, in document spread space.
/// A layout built for parent editing already has those items on the spread, so they are not
/// added again.
fn pass_align(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !cx.snap.smart_guides || (!cx.snap.align_edges && !cx.snap.align_centers) {
return None;
}
let sp = cx.doc.spread(req.spread)?;
let flags = axis_flags(req, axis);
let mut best: Option<Best> = None;
for page in &sp.pages {
offer_box(&mut best, req.rect, flags, axis, page.bounds(), tol, cx.snap.align_edges, cx.snap.align_centers);
}
for item in &sp.items {
if item.hidden || align_skips_item(cx, req, item.id) {
continue;
if let Some(sp) = cx.doc.spread(req.spread) {
for page in &sp.pages {
offer_box(&mut best, req.rect, flags, axis, page.bounds(), tol, cx.snap.align_edges, cx.snap.align_centers);
}
offer_box(&mut best, req.rect, flags, axis, item.visible_bounds(), tol, cx.snap.align_edges, cx.snap.align_centers);
for item in &sp.items {
let Some(rect) = item_target_rect(cx.selection, req, item) else { continue };
offer_box(&mut best, req.rect, flags, axis, rect, tol, cx.snap.align_edges, cx.snap.align_centers);
}
}
if !layout_edits_parents(cx) {
offer_parent_items(&mut best, cx, req, flags, axis, tol);
}
best.map(|b| b.hit)
}
/// Drag-start ids stay targets while copying. Preview copies are the live selection minus that set.
fn align_skips_item(cx: &ToolContext, req: &SnapRequest<'_>, id: ItemId) -> bool {
if req.copying { cx.selection.items.contains(&id) && !req.exclude.contains(&id) } else { req.exclude.contains(&id) }
fn align_skips_item(selection: &Selection, req: &SnapRequest<'_>, id: ItemId) -> bool {
if req.copying { selection.items.contains(&id) && !req.exclude.contains(&id) } else { req.exclude.contains(&id) }
}
/// Parent spreads are the canvas while a parent is being edited. Their items are ordinary targets.
fn layout_edits_parents(cx: &ToolContext) -> bool {
cx.layout.slots.iter().any(|slot| matches!(slot.spread, SpreadRef::Parent(_)))
}
/// Visible box used for alignment. Groups with children contribute the union of the children.
fn item_target_rect(selection: &Selection, req: &SnapRequest<'_>, item: &Item) -> Option<Rect> {
if item.hidden || align_skips_item(selection, req, item.id) {
return None;
}
if item.is_group() && !item.children().is_empty() {
let mut seen = HashSet::new();
return group_target_rect(selection, req, item, &mut seen, 0);
}
let bounds = item.visible_bounds();
rect_finite(bounds).then_some(bounds)
}
/// Union of children's visible bounds in this group's space, then `item.xf`.
fn group_target_rect(selection: &Selection, req: &SnapRequest<'_>, item: &Item, seen: &mut HashSet<ItemId>, depth: usize) -> Option<Rect> {
if depth >= ALIGN_GROUP_DEPTH || !seen.insert(item.id) {
return None;
}
let mut acc: Option<Rect> = None;
for child in item.children() {
if child.hidden || align_skips_item(selection, req, child.id) {
continue;
}
let local = if child.is_group() && !child.children().is_empty() {
group_target_rect(selection, req, child, seen, depth + 1)
} else {
let bounds = child.visible_bounds();
rect_finite(bounds).then_some(bounds)
};
let Some(local) = local else { continue };
if !rect_finite(local) {
continue;
}
acc = Some(acc.map_or(local, |have| have.union(local)));
}
acc.map(|r| item.xf.transform_rect_bbox(r)).filter(|r| rect_finite(*r))
}
/// Parent items shown on the document spread, shifted into that spread's space.
fn offer_parent_items(best: &mut Option<Best>, cx: &ToolContext, req: &SnapRequest<'_>, flags: [bool; 3], axis: Axis, tol: f64) {
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_x = page.x;
let doc_side = page.side;
let overridden = page.overridden.clone();
let Some((ppi, _)) = cx.doc.parent_page_for(abs) else { continue };
let mut seen = HashSet::new();
offer_parent_chain(best, cx, req, flags, axis, tol, doc_x, doc_side, &overridden, ppi, &mut seen);
}
}
/// Walk `based_on`. A repeated spread id ends the walk.
fn offer_parent_chain(
best: &mut Option<Best>,
cx: &ToolContext,
req: &SnapRequest<'_>,
flags: [bool; 3],
axis: Axis,
tol: f64,
doc_x: f64,
doc_side: PageSide,
overridden: &[ItemId],
index: usize,
seen: &mut HashSet<SpreadId>,
) {
let Some(parent) = cx.doc.parents.get(index) else { return };
if !seen.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)
&& let Some(parent_page) = parent.pages.get(page_idx)
{
let dx = doc_x - parent_page.x;
if dx.is_finite() {
for item in &parent.items {
if overridden.contains(&item.id) {
continue;
}
// Same other-page skip as the renderer: a facing parent only shows items
// whose center sits on the parent page this document page uses.
if parent.pages.len() > 1 && parent.page_at_x(item.bounds().center().x) != Some(page_idx) {
continue;
}
let Some(rect) = item_target_rect(cx.selection, req, item) else { continue };
offer_box(best, req.rect, flags, axis, shift_x(rect, dx), tol, cx.snap.align_edges, cx.snap.align_centers);
}
}
}
let Some(next_id) = next else { return };
let Some(next_index) = cx.doc.parent_index(next_id) else { return };
offer_parent_chain(best, cx, req, flags, axis, tol, doc_x, doc_side, overridden, next_index, seen);
}
/// Left page of a facing parent, otherwise the last page. Same choice as `Document::parent_page_for`.
fn shown_parent_page_index(page_count: usize, side: PageSide) -> Option<usize> {
if page_count >= 2 && side == PageSide::Left { Some(0) } else { page_count.checked_sub(1) }
}
fn shift_x(r: Rect, dx: f64) -> Rect {
Rect::new(r.x0 + dx, r.y0, r.x1 + dx, r.y1)
}
fn axis_flags(req: &SnapRequest<'_>, axis: Axis) -> [bool; 3] {
@@ -532,7 +658,7 @@ mod tests {
use designcraft_compose::Cache;
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, Guide, Item, Selection, Shape};
use designcraft_doc::{Document, Guide, Item, Margins, Selection, Shape, Stroke};
use designcraft_geom::Unit;
use designcraft_geom::shapes;
@@ -633,6 +759,43 @@ mod tests {
assert!(hit.guides.is_empty());
}
#[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: &[],
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);