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:
parent
9b7f8cf005
commit
86d47dc3fe
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue