Shape rectangles start at the top-right, as After Effects, so Trim Paths cut the same segment

After Effects draws a Rectangle Path clockwise from its top-right corner, or
with round corners from the end of the top-right arc on the right edge;
Trim Paths measures from there (AE 2026, case 930: 400 x 300 rectangles,
Trim 0-25 % and 70 / 30). EffectCraft built the path with kurbo, starting at
the top-left corner, so trimmed rectangles drew another segment: case 930
was 86 of 5184 grid points off (max 255), now 5 at stroke edges. Start > End
was already drawn swapped, as in AE. Convert to Bezier now gives AE's vertex
order (top-right first); the engine test follows. Lottie rectangles share
the path and now start where lottie players start them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-19 16:33:44 +02:00
co-authored by Claude Opus 5.5
parent 710ffd9f7e
commit d57b73d4cb
3 changed files with 73 additions and 6 deletions
+3 -2
View File
@@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
};
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
// A 120 x 80 rectangle is its four corners, joined by straight edges.
// A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right
// corner clockwise as After Effects draws it.
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
assert_eq!(rect.vertices, vec![[60.0, -40.0], [60.0, 40.0], [-60.0, 40.0], [-60.0, -40.0]]);
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
// Undo brings the parametric shape back.
s.execute("edit.undo", json!({})).unwrap();
+60 -4
View File
@@ -130,11 +130,36 @@ pub fn from_kurbo(path: &BezPath) -> Vec<ShapePath> {
out
}
/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right).
/// Rectangle path centred at `pos` with corner `roundness`, drawn as After Effects draws it
/// (Trim Paths measures from its first point): clockwise from the top-right corner, or with
/// round corners from the end of the top-right arc on the right edge (After Effects 2026,
/// 400 × 300 rectangles trimmed 0–25 %, case 930).
pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs()));
let rad = roundness.clamp(0.0, size[0].abs().min(size[1].abs()) / 2.0);
if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) }
let (w, h) = (size[0].abs(), size[1].abs());
let (x0, y0, x1, y1) = (pos[0] - w / 2.0, pos[1] - h / 2.0, pos[0] + w / 2.0, pos[1] + h / 2.0);
let rad = if roundness.is_finite() { roundness.clamp(0.0, w.min(h) / 2.0) } else { 0.0 };
let mut p = BezPath::new();
if rad <= 0.0 {
p.move_to((x1, y0));
p.line_to((x1, y1));
p.line_to((x0, y1));
p.line_to((x0, y0));
p.close_path();
return p;
}
// Quarter circles as cubics (handle 0.5523 × radius).
let k = rad * 0.552_284_749_8;
p.move_to((x1, y0 + rad));
p.line_to((x1, y1 - rad));
p.curve_to((x1, y1 - rad + k), (x1 - rad + k, y1), (x1 - rad, y1));
p.line_to((x0 + rad, y1));
p.curve_to((x0 + rad - k, y1), (x0, y1 - rad + k), (x0, y1 - rad));
p.line_to((x0, y0 + rad));
p.curve_to((x0, y0 + rad - k), (x0 + rad - k, y0), (x0 + rad, y0));
p.line_to((x1 - rad, y0));
p.curve_to((x1 - rad + k, y0), (x1, y0 + rad - k), (x1, y0 + rad));
p.close_path();
p
}
pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath {
@@ -354,6 +379,37 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec<BezPath> {
mod tests {
use super::*;
/// After Effects 2026 (case 930): 400 × 300 rectangles at the origin with Trim Paths. Plain:
/// 0–25 % runs from the top-right corner down the right edge and 50 px along the bottom.
/// Round corners 40: from the end of the top-right arc (200, −110) down, round the
/// bottom-right corner, to x 110 on the bottom. Start 70 / End 30 draws 30–70 %.
#[test]
fn rectangles_start_where_after_effects_starts_them() {
let ends = |p: &BezPath| {
let mut first = None;
let mut last = None;
for el in p.elements() {
match *el {
PathEl::MoveTo(q) if first.is_none() => first = Some(q),
PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) | PathEl::QuadTo(_, q) => last = Some(q),
PathEl::ClosePath => {}
}
}
(first.unwrap_or_default(), last.unwrap_or_default())
};
let near = |a: Point, b: (f64, f64)| (a.x - b.0).abs() < 1.0 && (a.y - b.1).abs() < 1.0;
let plain = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 0.0, 25.0, 0.0);
let (a, b) = ends(&plain[0]);
assert!(near(a, (200.0, -150.0)) && near(b, (150.0, 150.0)), "{a:?} {b:?}");
let round = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 40.0)], 0.0, 25.0, 0.0);
let (a, b) = ends(&round[0]);
assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "{a:?} {b:?}");
// Start > End: AE draws 30..70 % (plain: from x 80 on the bottom up to y −130 on the left).
let swapped = ops::trim(&[rect([400.0, 300.0], [0.0, 0.0], 0.0)], 70.0, 30.0, 0.0);
let (a, b) = ends(&swapped[0]);
assert!(near(a, (80.0, 150.0)) && near(b, (-200.0, -130.0)), "{a:?} {b:?}");
}
#[test]
fn roundtrip_vertex_model() {
let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0);
+10
View File
@@ -71,6 +71,16 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two
test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys,
including a timing change. AE's output stays local; only the script and these scores are tracked.
## Rectangle path start point
Measured on 19 October 2026 with After Effects 2026 (26.2) on 400 × 300 shape rectangles with Trim
Paths: a plain rectangle starts at its top-right corner and runs clockwise (0–25 % covers the right
edge and 50 px of the bottom); with Roundness 40 it starts where the top-right arc meets the right
edge. Start 70 / End 30 draws 30–70 %. EffectCraft started at the top-left corner (and the rounded
rectangle elsewhere), so trimmed rectangles drew the wrong segment (86 of 5184 grid points off,
now 5 at stroke edges). `effectcraft_path::rect` now draws AE's order, which also gives Convert to
Bezier AE's vertex order. Regression test: `rectangles_start_where_after_effects_starts_them`.
## Still unmeasured
Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames`