Snapping and Align: shape layers meet at their visual edges, with no gap (#352)

Cause: render::content_bounds (used by snapping's targets and source, Align,
Distribute, the viewer's bounding box and handles) took shape layers' bounds
from draw_bounds, the rasteriser's safety rectangle: 1 px of anti-aliasing
padding around fills and width x miter limit / 2 + 1 around strokes. Two
squares snapped edge to edge therefore stayed 2 px apart, or much more with
strokes.

Fix: shapes::content_bounds uses visual bounds, as After Effects' layer bounds
do: the path bounds plus half the stroke width (through the draw's group
scale), with no anti-aliasing or miter padding. Rendering keeps draw_bounds.

Tests: render shape_content_bounds_are_the_visual_edges (a 100 x 100
rectangle is exactly +-50, +-55 with a 10 px stroke); engine
shape_layers_align_by_their_visual_edges (widths 100 / 110, Align Left puts
both edges exactly at 0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit bfd0abd7437ad1d38652e3aef51f9ba5dea9100b)
This commit is contained in:
Claude 2026-10-10 18:31:40 +00:00
parent 9b7f8cf005
commit 86d47dc3fe
No known key found for this signature in database
3 changed files with 62 additions and 4 deletions

View File

@ -265,6 +265,27 @@ mod tests {
assert!(s.execute("layer.align", json!({"edge": "diagonal", "layers": [a]})).is_err());
}
/// #352: shape layers align and snap by their visual edges (half the stroke, no
/// anti-aliasing padding), so two rectangles lined up edge to edge touch with no gap.
#[test]
fn shape_layers_align_by_their_visual_edges() {
let mut s = session();
let shape = |s: &mut Session, extra: Value| {
let mut p = json!({"kind": "rect", "size": [100, 100], "fill": "#ff0000"});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
s.execute("layer.newShape", p).unwrap()["layer"].as_u64().unwrap()
};
let a = shape(&mut s, json!({}));
let b = shape(&mut s, json!({"stroke": "#ffffff", "strokeWidth": 10}));
let width = |s: &Session, id| {
let r = bounds(s, id);
r[2] - r[0]
};
assert!((width(&s, a) - 100.0).abs() < 1e-6 && (width(&s, b) - 110.0).abs() < 1e-6, "{} {}", width(&s, a), width(&s, b));
s.execute("layer.align", json!({"edge": "left", "layers": [a, b]})).unwrap();
assert!(bounds(&s, a)[0].abs() < 1e-6 && bounds(&s, b)[0].abs() < 1e-6);
}
#[test]
fn align_honours_parent_scale_and_rotation() {
let mut s = session();

View File

@ -517,6 +517,20 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
Some(b.inflate(grow, grow))
}
/// Local-space visual bounds of a draw, as After Effects' layer bounds (`sourceRectAtTime` with
/// extents): the path bounds, plus half the stroke width for strokes. No anti-aliasing or miter
/// padding, so snapping and Align put edges exactly together (#352).
fn visual_bounds(d: &Draw, paths: &[BezPath]) -> Option<kurbo::Rect> {
let b = effectcraft_path::bounds(paths)?;
let stroke = match &d.paint {
Paint::Stroke { style, .. } => Some(style),
Paint::Gradient { stroke: Some(style), .. } => Some(style),
_ => None,
};
let grow = stroke.map_or(0.0, |style| style.width * 0.5 * d.xf.mean_scale());
Some(if grow.is_finite() && grow > 0.0 { b.inflate(grow, grow) } else { b })
}
/// Maximum number of coverage pixels rasterised concurrently (bounds peak memory).
const BATCH_PIXELS: usize = 8 << 20;
@ -639,16 +653,16 @@ pub fn stroke_region(paths: &[BezPath], style: &StrokeStyle) -> Option<BezPath>
(!r.elements().is_empty()).then_some(r)
}
/// Layer-space bounds of a shape layer's painted contents (strokes included), without
/// rasterising.
/// Layer-space visual bounds of a shape layer's painted contents (half the stroke width
/// included, no anti-aliasing padding), without rasterising.
pub fn content_bounds(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Option<kurbo::Rect> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
.iter()
.filter_map(|d| {
let paths: Vec<BezPath> = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect();
draw_bounds(d, &paths)
let paths: Vec<BezPath> = d.slots.iter().filter_map(|&i| arena.get(i).cloned()).filter(|p| !p.elements().is_empty()).collect();
visual_bounds(d, &paths)
})
.reduce(|a, b| a.union(b))
}

View File

@ -808,3 +808,26 @@ fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() {
assert_same(&render_cached(&p, cid, Tick::ZERO, Some(&cache)), &fresh, "precomp source edit");
assert_ne!(before.get(50, 50), fresh.get(50, 50));
}
/// #352: a shape layer's bounds (snapping, Align) are its visual edges: a 100×100 rectangle is
/// exactly ±50, and a 10 px stroke adds half its width, with no anti-aliasing padding.
#[test]
fn shape_content_bounds_are_the_visual_edges() {
for (stroke, half) in [(None, 50.0), (Some(10.0), 55.0)] {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let mut items = vec![build::shape_rect(&mut ids, [100.0, 100.0], [0.0, 0.0], 0.0), build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0])];
if let Some(w) = stroke {
items.push(build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], w));
}
let g = build::shape_group(&mut ids, "Rectangle 1", items);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l.clone());
let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO);
let b = crate::content_bounds(&ctx, &l).unwrap();
assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}");
}
}