Shapes: Merge Paths keeps paint that belongs to a group above it (#1174)

A Merge Paths item appended to a shape layer's Contents cleared every draw
collected so far, including fills and strokes inside groups above it, so
the layer's shapes vanished from the render. AE's rule is per group: only
paint items in the merge's own group above it are dropped. Draws now
record which contents level created them; the merge drops only its own
group's paint, and the group-bubbled draws keep rendering.

Adds a regression test (Merge Shapes and Add modes) that renders nothing
without the fix.
This commit is contained in:
Vikram Bhaskaran
2026-10-14 00:02:12 +11:00
parent 549e9d8251
commit 48738093b5
2 changed files with 29 additions and 2 deletions
+11 -2
View File
@@ -57,6 +57,10 @@ struct Draw {
blend: BlendMode,
/// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D).
surfaces: SurfaceColors,
/// Paint item of the contents group this draw was collected in (as opposed to one bubbled up
/// from a nested group). Merge Paths drops only these paints above it (#1174); a group's own
/// fill or stroke keeps rendering inside the group.
local: bool,
}
/// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`]
@@ -234,6 +238,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
local: true,
});
}
"stroke" => {
@@ -247,6 +252,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
local: true,
});
}
"gfill" | "gstroke" => {
@@ -270,6 +276,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
local: true,
});
}
"trim" => {
@@ -328,8 +335,9 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
arena.push(ops::merge(&input, mode));
live = vec![arena.len() - 1];
// As in After Effects, fills and strokes above a Merge Paths in its group are not
// rendered: the merged path is painted by the paint items below it.
draws.clear();
// rendered: the merged path is painted by the paint items below it. Paint inside
// groups above the merge still renders — it belongs to those groups (#1174).
draws.retain(|d| !d.local);
}
"group" => {
let Some(inner) = g.sub("contents") else { continue };
@@ -341,6 +349,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
for mut d in cd {
d.xf = m * d.xf;
d.opacity *= op;
d.local = false;
draws.push(d);
}
live.extend(cl);
+18
View File
@@ -76,6 +76,24 @@ fn merge_add_feeds_fill_below_and_drops_paint_above() {
assert!((coverage(&img) - 80.0 * 40.0).abs() < 20.0, "{}", coverage(&img));
}
/// #1174: Adding Merge Paths to a shape layer's Contents, after the group that holds the
/// shape and its paint, erased the render. The merge drops paints above it *in its group* —
/// not paint that belongs to a group above it.
#[test]
fn merge_paths_below_a_group_keeps_the_group_paint() {
for mode in [0u32, 1] {
let (p, cid) = shape_comp(|ids| {
let rect = build::shape_rect(ids, [40.0, 40.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(ids, RED);
vec![build::shape_group(ids, "Group 1", vec![rect, fill]), op(ids, "merge", &[("mode", Value::Enum(mode))])]
});
let img = frame(&p, cid);
let c = img.get(100, 50);
assert!(c[0] > 0.99 && c[1] < 0.01 && c[3] > 0.99, "mode {mode}: group rectangle must still render, got {c:?}");
assert!((coverage(&img) - 40.0 * 40.0).abs() < 20.0, "mode {mode}: {}", coverage(&img));
}
}
#[test]
fn merge_subtract_and_intersect() {
let sub = |mode: u32| {