diff --git a/crates/engine/src/commands/align.rs b/crates/engine/src/commands/align.rs index c225b1e6..2c2b3458 100644 --- a/crates/engine/src/commands/align.rs +++ b/crates/engine/src/commands/align.rs @@ -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(); diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index 6bd8686b..e3cbca4a 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -517,6 +517,20 @@ fn draw_bounds(d: &Draw, paths: &[BezPath]) -> Option { 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 { + 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 (!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 { let mut arena = Vec::new(); let (draws, _) = collect(ctx, layer, contents, &mut arena); draws .iter() .filter_map(|d| { - let paths: Vec = d.slots.iter().map(|&i| arena[i].clone()).filter(|p| !p.elements().is_empty()).collect(); - draw_bounds(d, &paths) + let paths: Vec = 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)) } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 730c5b89..7e065391 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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:?}"); + } +}