From 03b7a958e5c3a59d0a63315c0948f3f69e2f1d50 Mon Sep 17 00:00:00 2001 From: Themolx <183303841+Themolx@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:15:54 +0200 Subject: [PATCH] Solo: exclude other layers even when the soloed layer is outside its time range In After Effects solo is not time-dependent: while any AV layer is soloed, non-soloed layers are excluded at every time. The renderer, nested/collapsed 3D paths, engine viewer helpers and viewer hit tests only counted solo layers active at the current time, so outside the soloed layer's in-out range the whole comp reappeared. Drop the time condition (audio already ignored it). Update the #66 viewer regression test to the AE behaviour and add a render test for it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/viewer.rs | 2 +- crates/render/src/lib.rs | 2 +- crates/render/src/tests.rs | 18 ++++++++++++++++++ crates/render/src/three_d/adv/scene.rs | 2 +- crates/render/src/three_d/compose.rs | 2 +- crates/ui-egui/src/panels/viewer.rs | 5 +++-- crates/ui-egui/tests/ui_viewer.rs | 6 ++++-- docs/gaps.md | 3 ++- 8 files changed, 31 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index cac75024..997c2023 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -362,7 +362,7 @@ const MAX_COLLAPSED_DEPTH: usize = 8; fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterator { let comp = ctx.comp; let time = ctx.time; - let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time)); + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id)) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41036fe6..bf6eebd9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2086,7 +2086,7 @@ impl<'a> Renderer<'a> { /// applied). pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> { let (comp, t) = (ctx.comp, ctx.time); - let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); comp.layers .iter() .rev() diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 39e022b0..a38b580f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -953,3 +953,21 @@ fn shape_content_bounds_are_the_visual_edges() { assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}"); } } + +/// Solo is not time-dependent (as in After Effects): while any layer is soloed, the others stay +/// excluded even when the playhead is outside the soloed layer's in-out range. +#[test] +fn solo_excludes_other_layers_outside_the_soloed_layers_range() { + let (mut p, cid, comp) = setup(); + let a = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let mut b = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100); + b.in_point = Tick::from_seconds_f64(1.0); + b.switches.solo = true; + let c = p.comp_mut(cid).unwrap(); + c.layers.push(b); + c.layers.push(a); + let first = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0); + assert_eq!(first.get(100, 50), [0.0, 0.0, 0.0, 0.0], "neither A nor B before B's in point"); + let second = render_frame(&p, cid, Tick::from_seconds_f64(1.5), 1.0); + assert_eq!(second.get(100, 50), [0.0, 0.0, 1.0, 1.0], "only B inside its range"); +} diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 5b5ae956..34517933 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -873,7 +873,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; - let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); + let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); for l in nctx.comp.layers.iter().rev() { if !l.is_active_at(t) || l.is_light() diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 0d70b32f..831ab3ec 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -497,7 +497,7 @@ fn collapsed_items<'a>( let op = ctx.opacity(layer) as f32 * r.opacity_mul(); let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; let t = nctx.time; - let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); + let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); let nested: Vec<&'a Layer> = nctx .comp .layers diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index f4bb5a88..62aca538 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -2283,9 +2283,10 @@ fn pen_press( } /// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order. -/// Solo scope matches the renderer: only active AV layers activate it, even when hidden or locked. +/// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After +/// Effects), even when hidden, locked or outside its in-out range. pub(crate) fn selectable_layers(comp: &Comp, time: Tick) -> impl Iterator { - let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time)); + let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av()); comp.layers .iter() .filter(move |l| l.is_active_at(time) && l.switches.video && !l.switches.locked && !l.is_camera() && !l.is_light() && (!any_solo || l.switches.solo)) diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index e60226b1..53d6d206 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -121,8 +121,10 @@ fn viewer_click_does_not_pick_through_hidden_or_locked_solo_layers() { } } +/// Solo is not time-dependent (as in After Effects): outside the soloed layer's in-out range the +/// viewer shows nothing, so neither the soloed layer nor the others can be picked. #[test] -fn viewer_click_ignores_inactive_solo_layers() { +fn viewer_click_picks_nothing_while_the_solo_layer_is_inactive() { let mut h = selection_harness(); let comp = h.state().session.active_comp().unwrap(); let (front, back) = (comp.layers[0].id, comp.layers[1].id); @@ -133,7 +135,7 @@ fn viewer_click_ignores_inactive_solo_layers() { h.run_steps(3); let at = screen(&h, [320.0, 180.0]); click(&mut h, at); - assert_eq!(h.state().session.state.selected_layers, vec![back]); + assert!(h.state().session.state.selected_layers.is_empty(), "{back:?} stays excluded by solo"); } /// A selected layer under the pointer takes the press before the layers in front of it, as in diff --git a/docs/gaps.md b/docs/gaps.md index c22a97fb..64f6f413 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -117,7 +117,8 @@ The most important missing piece: it turns every other estimate here into a meas - Regression evidence for [#66](https://github.com/storytold/effectcraft/issues/66): scripted pointer tests in `ui_viewer` and `ui_text_edit`, checked headlessly on Linux, cover viewer clicks, marquee selection and text picking when layers are hidden or excluded by solo. They also check - hidden/locked solo layers and solo layers outside their active time range. + hidden/locked solo layers, and that solo still excludes the other layers while the soloed + layer is outside its active time range (as in After Effects, so nothing is picked there). - Regression evidence for [#67](https://github.com/storytold/effectcraft/issues/67): scripted input tests in `ui_project_delete`, checked headlessly on Linux, cover Delete/Backspace on Project items, multiple selection and undo. They check that an unrelated Timeline layer stays,