From faffad3a6e56b00b73559524e54c86d1e83be9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:40:50 +0000 Subject: [PATCH 1/6] Media: a multi-layer EXR's picture and its EXtractoR / Cryptomatte channels share one decode (#474) A multi-layer OpenEXR file without an unnamed RGB layer (Blender's compositor File Output) was decoded twice per frame: `layered_image` decoded every channel to find the picture, then `MediaPool::aux` decoded the same bytes again into the aux-channel cache for EXtractoR and Cryptomatte. The first EXtractoR layer doubled the frame time. The footage path now reads the picture from the aux-channel cache (`cached_layered_image`), so the decode it makes is the one EXtractoR and Cryptomatte reuse. (Step 1 of the issue; reading only the picture's channels and keeping half channels as f16 are still to do.) Test: after a frame of a layered file is decoded, its aux channels come from the cache without reading the file again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/media/src/exr_channels.rs | 36 ++++++++++++++++++++++++++++++-- crates/media/src/pool.rs | 2 +- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 732cd3c4..6e90a6dd 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -161,9 +161,20 @@ fn picture_layer<'a>(aux: &AuxChannels, layers: &[&'a str]) -> Option<&'a str> { /// that is not a data pass (never Cryptomatte, depth or normals, #412), else the first channel /// as grey. Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR. pub fn layered_image(bytes: &[u8]) -> Option { - let aux = read_exr_channels(bytes)?; + picture_of(&read_exr_channels(bytes)?) +} + +/// [`layered_image`] of the EXR at `path` from the channel cache, so the picture and EXtractoR / +/// Cryptomatte share one decode of the file (#474). +pub(crate) fn cached_layered_image(path: &str, bytes: Arc<[u8]>) -> Option { + let aux = cached(path, || Some(bytes))?; + picture_of(&aux) +} + +/// The picture of decoded channels (see [`layered_image`]). +fn picture_of(aux: &AuxChannels) -> Option { let layers = aux.layers(); - let colour = picture_layer(&aux, &layers)?; + let colour = picture_layer(aux, &layers)?; let names = aux.layer_rgba(colour); // A layer without R, G and B channels shows its first channel as grey. let first = aux.names().into_iter().find(|n| split_channel(n).0 == colour).unwrap_or_default(); @@ -254,6 +265,27 @@ mod tests { /// #295: a multi-layer EXR whose colour is only in named layers (`diffuse.R`, as Blender and /// Nuke write them) failed to import ("does not contain non-deep rgb channels"); it now /// shows its colour layer and keeps every channel for EXtractoR. + /// #474: the picture and EXtractoR / Cryptomatte share one decode of a multi-layer file: + /// after the frame is decoded, the aux channels come from the cache without reading the file. + #[test] + fn picture_and_aux_channels_share_one_decode() { + use exr::prelude::*; + let (w, h) = (2usize, 2usize); + let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let channels = AnyChannels::sort(vec![ch("Image.R", 0.5), ch("Image.G", 0.5), ch("Image.B", 0.5), ch("GlossDir.R", 0.25)].into()); + let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let b: Arc<[u8]> = bytes.into_inner().into(); + let path = "/474-shared-decode.exr"; + let pool = crate::MediaPool::new(); + pool.add_bytes(path, b.clone()); + let f = crate::probe_bytes(path, b).unwrap(); + pool.frame_at(&f, effectcraft_time::Tick::ZERO).unwrap(); + let aux = cached(path, || panic!("the frame's decode is reused, the file is not read again")).unwrap(); + assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25)); + } + #[test] fn layered_exr_without_an_unnamed_rgb_layer_imports() { use exr::prelude::*; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9db2f225..ce980c56 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -741,7 +741,7 @@ impl Inner { let img = match image::load_from_memory(&bytes) { Ok(img) => img, // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::layered_image(&bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, + Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, }; Ok(dynamic_to_image(&img, op)) } From 1c9b5f1828cd9fe9a6faf2b4870a87291c29e952 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:42:34 +0000 Subject: [PATCH 2/6] Graph Editor: the speed graph of a curved motion path is smooth, not jagged (#456) Spatial properties move along their path by arc length: a table of 96 chords per segment, read back by linear interpolation of the curve parameter. Within each cell that mapping is linear, so the speed (the Graph Editor's speed graph, a central difference of the position) jumped at every cell boundary and was off by up to ~2 % on a curved path: a sawtooth over a constant-speed move, and a slight wobble in the motion itself. The table now holds Gauss-Legendre arc lengths, and `at_length` refines the cell's linear guess with Newton steps on the true arc length (|B'(u)| as the derivative), so position and speed follow the path exactly. Test: linear keys along a strongly curved path give a constant speed (within 0.1 %) at every sample; the old mapping was off by 2 %. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/keyframe/src/lib.rs | 62 +++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 80537149..3ef50882 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -233,26 +233,44 @@ struct SpatialSeg { const ARC_N: usize = 96; +/// 5-point Gauss-Legendre nodes and weights on -1..1. +const GAUSS: [(f64, f64); 5] = [ + (0.0, 0.568_888_888_888_888_9), + (-0.538_469_310_105_683_1, 0.478_628_670_499_366_5), + (0.538_469_310_105_683_1, 0.478_628_670_499_366_5), + (-0.906_179_845_938_664, 0.236_926_885_056_189_1), + (0.906_179_845_938_664, 0.236_926_885_056_189_1), +]; + impl SpatialSeg { fn new(p: [[f64; 3]; 4]) -> SpatialSeg { let mut table = Vec::with_capacity(ARC_N + 1); table.push(0.0); - let mut prev = Self::point(&p, 0.0); let mut acc = 0.0; for i in 1..=ARC_N { - let q = Self::point(&p, i as f64 / ARC_N as f64); - acc += ((q[0] - prev[0]).powi(2) + (q[1] - prev[1]).powi(2) + (q[2] - prev[2]).powi(2)).sqrt(); + acc += Self::arc(&p, (i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64); table.push(acc); - prev = q; } SpatialSeg { p, table } } fn point(p: &[[f64; 3]; 4], u: f64) -> [f64; 3] { [bez(p[0][0], p[1][0], p[2][0], p[3][0], u), bez(p[0][1], p[1][1], p[2][1], p[3][1], u), bez(p[0][2], p[1][2], p[2][2], p[3][2], u)] } + /// |dB/du|. + fn tangent_len(p: &[[f64; 3]; 4], u: f64) -> f64 { + (0..3).map(|k| bez_d(p[0][k], p[1][k], p[2][k], p[3][k], u).powi(2)).sum::().sqrt() + } + /// Arc length between `a` and `b` (Gauss-Legendre; exact enough on one table cell). + fn arc(p: &[[f64; 3]; 4], a: f64, b: f64) -> f64 { + let (m, h) = ((a + b) * 0.5, (b - a) * 0.5); + GAUSS.iter().map(|(x, w)| w * Self::tangent_len(p, m + h * x)).sum::() * h + } fn length(&self) -> f64 { *self.table.last().unwrap_or(&0.0) } + /// The point at arc length `s`. The table cell's linear guess is refined with Newton steps + /// on the true arc length, so the position (and the speed graph, its derivative) is smooth + /// along curved paths instead of piecewise linear per cell (#456). fn at_length(&self, s: f64) -> [f64; 3] { let len = self.length(); if len <= 0.0 { @@ -260,9 +278,21 @@ impl SpatialSeg { } let s = s.clamp(0.0, len); let i = self.table.partition_point(|&v| v < s).clamp(1, ARC_N); - let (a, b) = (self.table[i - 1], self.table[i]); - let f = if b > a { (s - a) / (b - a) } else { 0.0 }; - Self::point(&self.p, (i as f64 - 1.0 + f) / ARC_N as f64) + let (Some(&a), Some(&b)) = (self.table.get(i - 1), self.table.get(i)) else { return self.p[0] }; + let (u0, u1) = ((i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64); + let mut u = u0 + if b > a { (s - a) / (b - a) } else { 0.0 } * (u1 - u0); + for _ in 0..4 { + let d = Self::tangent_len(&self.p, u); + if d <= 1e-12 { + break; + } + let e = a + Self::arc(&self.p, u0, u) - s; + u = (u - e / d).clamp(u0, u1); + if e.abs() <= 1e-12 * len.max(1.0) { + break; + } + } + Self::point(&self.p, u) } } @@ -764,6 +794,24 @@ mod tests { assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}"); } + /// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is + /// flat, not a sawtooth from a piecewise-linear arc-length table. + #[test] + fn spatial_curved_path_has_smooth_constant_speed() { + let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])); + let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])); + a.spatial_auto = false; + b.spatial_auto = false; + a.spatial_out = [50.0, -300.0, 0.0]; + b.spatial_in = [80.0, 250.0, 0.0]; + let keys = vec![a, b]; + let len = spatial_segment_length(&keys, 0); + let speeds: Vec = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect(); + for v in &speeds { + assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0); + } + } + #[test] fn spatial_auto_bezier_passes_through_keys() { let keys = vec![ From ecb2cf1d3202cea55535a363845f76b6baf75a2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:51:24 +0000 Subject: [PATCH 3/6] Blend modes: classic modes fade with opacity and HDR modes keep over-range values, as in After Effects (#494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against After Effects at 32 bpc: - Classic Color Dodge, Classic Color Burn and Classic Difference at partial opacity: AE fades the source toward the mode's neutral colour (0 for Dodge and Difference, 1 for Burn) by the opacity and applies the classic formula at full strength (b / (1 − k·o), |b − k·o|, 1 − (1 − b) / (1 − k·(1 − o))). We mixed the full-strength result by opacity: up to 45/255 off on ordinary 0–1 footage. - Overlay, Exclusion, Luminosity, Color Burn and Classic Color Burn keep their plain formula for values above 1 (HDR passes). They were missing from `supports_hdr`, so their inputs were clamped to 0–1 (Exclusion was up to 208/255 off); Classic Color Burn also clamped its result at 0, where AE keeps negative values. The CPU blend (color::blend_pixel) and the GPU kernel (common.wgsl) change together. Tests: the reporter's formulas for each case (blend.rs); the CPU/GPU parity test of every blend mode at every bit depth (over-range values, 70 % opacity) passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/color/src/blend.rs | 73 +++++++++++++++++++++++++----- crates/gpu/src/shaders/common.wgsl | 16 +++++-- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 2305f12a..0fecf4e9 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -159,7 +159,8 @@ impl BlendMode { /// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp /// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects - /// (in 8/16 bpc everything is already in range). + /// (in 8/16 bpc everything is already in range). Overlay, Exclusion, Luminosity and the + /// Color Burns keep their plain formula above 1 in After Effects (#494). pub fn supports_hdr(self) -> bool { matches!( self, @@ -168,6 +169,8 @@ impl BlendMode { | BlendMode::DancingDissolve | BlendMode::Darken | BlendMode::Multiply + | BlendMode::ColorBurn + | BlendMode::ClassicColorBurn | BlendMode::LinearBurn | BlendMode::DarkerColor | BlendMode::Add @@ -177,6 +180,9 @@ impl BlendMode { | BlendMode::LighterColor | BlendMode::Difference | BlendMode::ClassicDifference + | BlendMode::Overlay + | BlendMode::Exclusion + | BlendMode::Luminosity | BlendMode::Subtract | BlendMode::Divide | BlendMode::StencilAlpha @@ -188,6 +194,17 @@ impl BlendMode { ) } + /// The source colour a classic mode leaves the backdrop unchanged with. After Effects fades + /// the source toward it by the layer's opacity before applying the classic formula at full + /// strength, rather than mixing the full-strength result by opacity (#494). + fn classic_neutral(self) -> Option { + match self { + BlendMode::ClassicColorDodge | BlendMode::ClassicDifference => Some(0.0), + BlendMode::ClassicColorBurn => Some(1.0), + _ => None, + } + } + pub fn next(self) -> BlendMode { let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0); BlendMode::ALL[(i + 1) % BlendMode::ALL.len()] @@ -242,11 +259,8 @@ fn separable(mode: BlendMode, cb: f32, cs: f32) -> f32 { BlendMode::Multiply => cb * cs, BlendMode::ColorBurn => burn(cb, cs), BlendMode::ClassicColorBurn => { - if cs <= 0.0 { - 0.0 - } else { - (1.0 - (1.0 - cb) / cs).max(0.0) - } + // (negative results are kept, as in After Effects) + if cs <= 0.0 { 0.0 } else { 1.0 - (1.0 - cb) / cs } } BlendMode::LinearBurn => (cb + cs - 1.0).max(0.0), BlendMode::Add | BlendMode::LinearDodge => cb + cs, @@ -401,11 +415,19 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) -> cs = cs.map(|v| v.clamp(0.0, 1.0)); cb = cb.map(|v| v.clamp(0.0, 1.0)); } - let b = non_separable(mode, cb, cs); let ao = sa + da - sa * da; let mut o = [0.0f32; 4]; - for i in 0..3 { - o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i]; + if let Some(neutral) = mode.classic_neutral() { + // Fade the source toward the neutral colour by its coverage, then apply the formula. + let b = non_separable(mode, cb, cs.map(|v| neutral + sa * (v - neutral))); + for i in 0..3 { + o[i] = (1.0 - da) * src[i] + da * b[i]; + } + } else { + let b = non_separable(mode, cb, cs); + for i in 0..3 { + o[i] = (1.0 - da) * src[i] + (1.0 - sa) * dst[i] + sa * da * b[i]; + } } o[3] = ao; o @@ -469,12 +491,41 @@ mod tests { #[test] fn hdr_modes() { - // Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs. + // Add and Screen keep over-range values (32 bpc); Soft Light clamps its inputs. let d = [2.0, 2.0, 2.0, 1.0]; let s = [0.5, 0.5, 0.5, 1.0]; assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6); assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6); - assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0); + assert!(blend_pixel(BlendMode::SoftLight, d, s, 0.5)[0] <= 1.0); + } + + /// #494: After Effects' 32 bpc formulas, measured by the reporter. b = bottom, o = top, + /// k = opacity (premultiplied into the source, as the compositor passes it). + #[test] + fn after_effects_32bpc_formulas() { + let at = |m, b: f32, o: f32, k: f32| blend_pixel(m, opaque(b), [o * k, o * k, o * k, k], 0.5)[0]; + let close = |a: f32, e: f32| (a - e).abs() < 1e-5; + // Classic modes fade the source toward their neutral colour first. + for (b, o) in [(0.4, 0.5), (0.8, 0.9), (0.1, 0.3)] { + let k = 0.15; + assert!(close(at(BlendMode::ClassicColorDodge, b, o, k), (b / (1.0 - k * o)).min(1.0))); + assert!(close(at(BlendMode::ClassicDifference, b, o, k), (b - k * o).abs())); + assert!(close(at(BlendMode::ClassicColorBurn, b, o, k), 1.0 - (1.0 - b) / (1.0 - k * (1.0 - o)))); + } + // At full opacity the classic formulas are unchanged. + assert!(close(at(BlendMode::ClassicDifference, 0.6, 0.3, 1.0), 0.3)); + // Over-range top layers keep the plain formulas. + let (b, o) = (0.7, 3.0); + assert!(close(at(BlendMode::Exclusion, b, o, 1.0), b + o - 2.0 * b * o)); + let s = 2.0 * b - 1.0; + assert!(close(at(BlendMode::Overlay, b, o, 1.0), o + s - o * s)); + assert!(close(at(BlendMode::ColorBurn, 0.0, 1.35, 0.15), 0.15 * (1.0 - 1.0 / 1.35))); + assert!(close(at(BlendMode::ClassicColorBurn, 0.0, 0.1, 1.0), -9.0), "negative results are kept"); + // Luminosity: SetLum + ClipColor on the unclamped top layer. + let l = blend_pixel(BlendMode::Luminosity, [0.2, 0.4, 0.6, 1.0], [2.0, 2.0, 2.0, 1.0], 0.5); + let e = set_lum([0.2, 0.4, 0.6], 2.0); + assert!((0..3).all(|i| close(l[i], e[i])), "{l:?} {e:?}"); + assert!((l[0] - 1.0).abs() > 0.1, "not the white of a top layer clamped to 1: {l:?}"); } #[test] diff --git a/crates/gpu/src/shaders/common.wgsl b/crates/gpu/src/shaders/common.wgsl index 00ac946d..2c65c9d4 100644 --- a/crates/gpu/src/shaders/common.wgsl +++ b/crates/gpu/src/shaders/common.wgsl @@ -235,7 +235,7 @@ fn separable(mode: u32, cb: f32, cs: f32) -> f32 { if (cs <= 0.0) { return 0.0; } - return max(1.0 - (1.0 - cb) / cs, 0.0); + return 1.0 - (1.0 - cb) / cs; } case 7u: { return max(cb + cs - 1.0, 0.0); } case 9u, 14u: { return cb + cs; } @@ -343,7 +343,7 @@ fn non_separable(mode: u32, cb: vec3, cs: vec3) -> vec3 { fn supports_hdr(mode: u32) -> bool { switch mode { - case 0u, 1u, 2u, 3u, 4u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 23u, 24u, 26u, 27u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; } + case 0u, 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 16u, 23u, 24u, 25u, 26u, 27u, 31u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; } default: { return false; } } } @@ -388,8 +388,18 @@ fn blend_pixel(mode: u32, dst: vec4, src: vec4, noise: f32) -> vec4(0.0), vec3(1.0)); cb = clamp(cb, vec3(0.0), vec3(1.0)); } - let b = non_separable(mode, cb, cs); let ao = sa + da - sa * da; + // Classic Color Dodge / Difference (neutral 0) and Classic Color Burn (neutral 1): the + // source fades toward the neutral colour by its coverage, then the formula applies. + if (mode == 13u || mode == 24u || mode == 6u) { + var neutral = 0.0; + if (mode == 6u) { + neutral = 1.0; + } + let bc = non_separable(mode, cb, neutral + sa * (cs - neutral)); + return vec4((1.0 - da) * src.xyz + da * bc, ao); + } + let b = non_separable(mode, cb, cs); return vec4((1.0 - da) * src.xyz + (1.0 - sa) * dst.xyz + sa * da * b, ao); } From 56fd5d52b13857254f6f6130a871920ea3d2e2da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:22:38 +0000 Subject: [PATCH 4/6] Dock: a panel dropped on a stacked panel becomes a tab in its frame, as in After Effects (#302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stack (the Default workspace's right column) held one panel per frame. Dropping a panel onto the middle or the tab strip of a stacked panel inserted a new stacked frame below it, and dropping on its top or bottom edge split the whole column. A stacked frame now holds tabs (`StackEntry { panels, active, open, height }`): - Center / tab strip drops add the panel as a tab of the anchor's frame and show it. - Top / Bottom drops add a frame of its own above / below the anchor's frame only. (Left / Right still split the column: a frame can't hold a side-by-side split.) - Clicking a frame's shown tab (or its header bar) expands / collapses it; another tab shows that panel. Closing a tab keeps the frame; its last tab removes it. - Saved workspaces from before (`{"panel": …}` per frame) still load. Tests: dock unit tests (Center / Top / Bottom on a stack, insert_tab, close, old saved entries); a kittest dragging Effect Controls onto Properties makes a two-tab frame whose tabs switch the shown panel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/dock.rs | 159 ++++++++++++++++++++----- crates/ui-egui/tests/ui_panel_focus.rs | 2 +- crates/ui-egui/tests/ui_tabs.rs | 24 +++- 3 files changed, 153 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 39be3f59..06b99982 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -168,15 +168,66 @@ pub enum SplitSize { FixedB(f32), } -/// One panel in a [`DockNode::Stack`]. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +/// One frame in a [`DockNode::Stack`]: a header with one tab per panel (a panel dropped onto a +/// stacked panel joins its frame as a tab, as in After Effects). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "StackEntryRepr")] pub struct StackEntry { - pub panel: PanelKind, + pub panels: Vec, + /// The shown tab. + pub active: usize, pub open: bool, /// Content height when open (set by dragging the gaps); `None` = share the remaining height. pub height: Option, } +/// A saved [`StackEntry`]: workspaces saved before stacked frames held tabs name one `panel`. +#[derive(Deserialize)] +struct StackEntryRepr { + #[serde(default)] + panel: Option, + #[serde(default)] + panels: Vec, + #[serde(default)] + active: usize, + open: bool, + #[serde(default)] + height: Option, +} + +impl From for StackEntry { + fn from(r: StackEntryRepr) -> StackEntry { + let mut panels = r.panels; + if let Some(p) = r.panel + && !panels.contains(&p) + { + panels.insert(0, p); + } + let active = r.active.min(panels.len().saturating_sub(1)); + StackEntry { panels, active, open: r.open, height: r.height } + } +} + +impl StackEntry { + /// A frame showing just `panel`. + pub fn new(panel: PanelKind, open: bool, height: Option) -> StackEntry { + StackEntry { panels: vec![panel], active: 0, open, height } + } + pub fn contains(&self, p: PanelKind) -> bool { + self.panels.contains(&p) + } + /// The frame's shown panel. + pub fn shown(&self) -> Option { + self.panels.get(self.active).or(self.panels.first()).copied() + } + /// Show `p` (one of its tabs). + fn show(&mut self, p: PanelKind) { + if let Some(i) = self.panels.iter().position(|x| *x == p) { + self.active = i; + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum DockNode { /// `vertical`: children stacked top/bottom; else side by side. @@ -202,7 +253,7 @@ fn tabs(p: &[PanelKind], active: usize) -> DockNode { DockNode::Tabs { panels: p.to_vec(), active } } fn stack(e: &[(PanelKind, bool, Option)]) -> DockNode { - DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry { panel, open, height }).collect() } + DockNode::Stack { entries: e.iter().map(|&(panel, open, height)| StackEntry::new(panel, open, height)).collect() } } fn hsplit(size: SplitSize, a: DockNode, b: DockNode) -> DockNode { DockNode::Split { vertical: false, size, a: Box::new(a), b: Box::new(b) } @@ -401,7 +452,7 @@ impl DockNode { b.panels(out); } DockNode::Tabs { panels, .. } => out.extend(panels.iter().copied()), - DockNode::Stack { entries } => out.extend(entries.iter().map(|e| e.panel)), + DockNode::Stack { entries } => out.extend(entries.iter().flat_map(|e| e.panels.iter().copied())), } } pub fn contains(&self, p: PanelKind) -> bool { @@ -421,8 +472,9 @@ impl DockNode { false } } - DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) { + DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) { Some(e) => { + e.show(p); e.open = true; true } @@ -430,13 +482,15 @@ impl DockNode { }, } } - /// Expand or collapse a stacked panel. Returns false if `p` is not in a stack. + /// Expand or collapse a stacked panel's frame (showing `p`). Returns false if `p` is not in + /// a stack. pub fn toggle_stacked(&mut self, p: PanelKind) -> bool { match self { DockNode::Split { a, b, .. } => a.toggle_stacked(p) || b.toggle_stacked(p), DockNode::Tabs { .. } => false, - DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.panel == p) { + DockNode::Stack { entries } => match entries.iter_mut().find(|e| e.contains(p)) { Some(e) => { + e.show(p); e.open = !e.open; true } @@ -448,7 +502,7 @@ impl DockNode { match self { DockNode::Split { a, b, .. } => a.is_visible(p) || b.is_visible(p), DockNode::Tabs { panels, active } => panels.get(*active) == Some(&p), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p && e.open), + DockNode::Stack { entries } => entries.iter().any(|e| e.open && e.shown() == Some(p)), } } /// Close a panel (remove its tab; empty groups collapse their split). @@ -471,7 +525,12 @@ impl DockNode { panels.retain(|x| *x != p); *active = shown.and_then(|shown| panels.iter().position(|x| *x == shown)).unwrap_or((*active).min(panels.len().saturating_sub(1))); } else if let DockNode::Stack { entries } = self { - entries.retain(|e| e.panel != p); + for e in entries.iter_mut() { + let shown = e.shown(); + e.panels.retain(|x| *x != p); + e.active = shown.and_then(|shown| e.panels.iter().position(|x| *x == shown)).unwrap_or(e.active.min(e.panels.len().saturating_sub(1))); + } + entries.retain(|e| !e.panels.is_empty()); } } /// Add a panel as a tab next to `near` (or into the first group). @@ -492,9 +551,9 @@ impl DockNode { false } } - DockNode::Stack { entries } => match entries.iter().position(|e| e.panel == near) { + DockNode::Stack { entries } => match entries.iter().position(|e| e.contains(near)) { Some(i) => { - entries.insert(i + 1, StackEntry { panel: p, open: true, height: None }); + entries.insert(i + 1, StackEntry::new(p, true, None)); true } None => false, @@ -510,7 +569,7 @@ impl DockNode { true } DockNode::Stack { entries } if !entries.is_empty() => { - entries.push(StackEntry { panel: p, open: true, height: None }); + entries.push(StackEntry::new(p, true, None)); true } _ => false, @@ -586,8 +645,10 @@ pub struct Floating { impl DockNode { /// Insert panel `p` relative to the group that holds `anchor`: as a tab next to it - /// (Center) or in a new group split off to one side. `p` is removed from its old place - /// first. Returns false (tree unchanged) if `anchor` isn't docked or is `p` itself alone. + /// (Center) or in a new group split off to one side. In a stack, the group is `anchor`'s + /// frame: Center adds a tab to that frame and Top / Bottom add a frame above or below it + /// (#302); Left / Right split the column. `p` is removed from its old place first. Returns + /// false (tree unchanged) if `anchor` isn't docked or is `p` itself alone. pub fn dock_panel(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool { if p == anchor || !self.contains(anchor) { return false; @@ -598,7 +659,7 @@ impl DockNode { let here = match n { DockNode::Split { a, b, .. } => return rec(a, p, anchor, zone) || rec(b, p, anchor, zone), DockNode::Tabs { panels, .. } => panels.contains(&anchor), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == anchor), + DockNode::Stack { entries } => entries.iter().any(|e| e.contains(anchor)), }; if !here { return false; @@ -610,8 +671,16 @@ impl DockNode { *active = i; } (Zone::Center, DockNode::Stack { entries }) => { - let i = entries.iter().position(|e| e.panel == anchor).map_or(entries.len(), |i| i + 1); - entries.insert(i, StackEntry { panel: p, open: true, height: None }); + let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false }; + let i = e.panels.iter().position(|x| *x == anchor).map_or(e.panels.len(), |i| i + 1); + e.panels.insert(i, p); + e.active = i; + e.open = true; + } + (Zone::Top | Zone::Bottom, DockNode::Stack { entries }) => { + let Some(i) = entries.iter().position(|e| e.contains(anchor)) else { return false }; + let at = if zone == Zone::Top { i } else { i + 1 }; + entries.insert(at, StackEntry::new(p, true, None)); } (z, node) => { let new = tabs(&[p], 0); @@ -648,9 +717,13 @@ impl DockNode { *active = i; true } - DockNode::Stack { entries } if entries.iter().any(|e| e.panel == anchor) => { - let i = before.and_then(|b| entries.iter().position(|e| e.panel == b)).unwrap_or(entries.len()); - entries.insert(i, StackEntry { panel: p, open: true, height: None }); + DockNode::Stack { entries } => { + // A tab of the anchor's frame. + let Some(e) = entries.iter_mut().find(|e| e.contains(anchor)) else { return false }; + let i = before.and_then(|b| e.panels.iter().position(|x| *x == b)).unwrap_or(e.panels.len()); + e.panels.insert(i, p); + e.active = i; + e.open = true; true } _ => false, @@ -670,7 +743,7 @@ impl DockNode { match n { DockNode::Split { a, b, .. } => rec(a, p, &format!("{path}a")).or_else(|| rec(b, p, &format!("{path}b"))), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| path.to_string()), - DockNode::Stack { entries } => entries.iter().position(|e| e.panel == p).map(|i| format!("{path}s{i}")), + DockNode::Stack { entries } => entries.iter().position(|e| e.contains(p)).map(|i| format!("{path}s{i}")), } } rec(self, p, "") @@ -886,7 +959,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa drag = Some((above, below, d)); } } - out.push(Group { path: group, rect: r, content, panels: vec![e.panel], active: 0, stacked: Some(e.open) }); + out.push(Group { path: group, rect: r, content, panels: e.panels.clone(), active: e.active, stacked: Some(e.open) }); y = r.max.y + g; } if let Some((above, below, d)) = drag { @@ -1184,7 +1257,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra } } if resp.clicked() { - if g.stacked.is_some() { + // A stacked frame's shown tab expands / collapses it; another tab shows that + // panel (expanding the frame). + if g.stacked.is_some() && (k == ai || entries.len() == 1) { out.actions.push(DockAction::ToggleStacked(*p)); } else { out.actions.push(activate(e)); @@ -1197,9 +1272,9 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra x += w + 8.0; } // A stacked panel's whole header bar expands/collapses it, not just its name (as in After - // Effects). A stack group holds one panel, so the rest of the strip is unambiguous. + // Effects): the frame with its shown tab. if g.stacked.is_some() - && let Some(e) = entries.first() + && let Some(e) = entries.get(ai).or(entries.first()) { let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max); if rest.width() > 0.0 { @@ -1269,7 +1344,7 @@ mod tests { } fn entry(panel: PanelKind, open: bool, height: Option) -> StackEntry { - StackEntry { panel, open, height } + StackEntry::new(panel, open, height) } fn close_to(a: &[f32], b: &[f32]) -> bool { @@ -1471,16 +1546,42 @@ mod tests { assert!(!d.dock_panel(Audio, Audio, Zone::Left)); assert!(!d.dock_panel(Audio, Tracker, Zone::Left)); assert_eq!(d, before); - // Into a stack. + // Into a stack: a tab of the anchor's frame (#302). let mut s = workspace("Default"); + let props = s.path_of(Properties).unwrap(); assert!(s.dock_panel(Tracker, Properties, Zone::Center)); - assert!(s.path_of(Tracker).unwrap().contains('s')); + assert_eq!(s.path_of(Tracker), Some(props.clone())); + assert!(s.is_visible(Tracker) && !s.is_visible(Properties)); + assert!(s.activate(Properties) && s.is_visible(Properties)); + // Top / Bottom: a frame of its own above / below the anchor's, not a split column. + assert!(s.dock_panel(Info, Properties, Zone::Bottom)); + let below = s.path_of(Info).unwrap(); + assert_ne!(below, props); + assert_eq!(s.path_of(Properties), Some(props.clone())); + assert!(s.dock_panel(Info, Properties, Zone::Top)); + assert_eq!(s.path_of(Info), Some(props.clone()), "above Properties: its frame's index"); + // Closing a tab keeps the frame; closing the frame's last tab removes it. + s.close(Tracker); + assert!(s.contains(Properties) && !s.contains(Tracker)); + // A tab dragged onto a stacked frame's tab strip joins that frame. + let mut t = workspace("Default"); + assert!(t.insert_tab(Info, Preview, None)); + assert_eq!(t.path_of(Info), t.path_of(Preview)); // Moving the only panel of a group collapses its split. let mut m = hsplit(SplitSize::Ratio(0.5), tabs(&[Project], 0), tabs(&[Composition], 0)); assert!(m.dock_panel(Project, Composition, Zone::Center)); assert_eq!(m, tabs(&[Composition, Project], 1)); } + /// #302: workspaces saved when each stacked frame held one `panel` still load. + #[test] + fn old_stack_entries_load() { + let e: StackEntry = serde_json::from_str(r#"{"panel":"Preview","open":true,"height":46.0}"#).unwrap(); + assert_eq!(e, StackEntry::new(PanelKind::Preview, true, Some(46.0))); + let s = serde_json::to_string(&workspace("Default")).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), workspace("Default")); + } + #[test] fn floating_layout_round_trips() { use PanelKind::*; diff --git a/crates/ui-egui/tests/ui_panel_focus.rs b/crates/ui-egui/tests/ui_panel_focus.rs index f87c3ddd..5d3302b1 100644 --- a/crates/ui-egui/tests/ui_panel_focus.rs +++ b/crates/ui-egui/tests/ui_panel_focus.rs @@ -47,7 +47,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option> { match n { DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()), } } diff --git a/crates/ui-egui/tests/ui_tabs.rs b/crates/ui-egui/tests/ui_tabs.rs index 8b52a72a..4d6777b6 100644 --- a/crates/ui-egui/tests/ui_tabs.rs +++ b/crates/ui-egui/tests/ui_tabs.rs @@ -64,7 +64,7 @@ fn group_of(n: &DockNode, p: PanelKind) -> Option> { match n { DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)), DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()), - DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.clone()), } } @@ -209,7 +209,7 @@ fn stacked_panel_gaps_resize_the_panels() { match n { DockNode::Split { a, b, .. } => height(a, p).or_else(|| height(b, p)), DockNode::Tabs { .. } => None, - DockNode::Stack { entries } => entries.iter().find(|e| e.panel == p).map(|e| e.height), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.height), } } let (preview, props) = (rect(&h, "panel.Preview"), rect(&h, "panel.Properties")); @@ -345,3 +345,23 @@ fn a_stacked_panels_whole_header_bar_toggles_it() { click_n(&mut h, menu.center(), 1); assert!(open(&h), "the panel menu button doesn't toggle the panel"); } + +/// #302: a panel dropped onto a stacked panel becomes a tab in that panel's frame (it was +/// inserted as a stacked panel of its own below it); its tabs switch the frame's panel. +#[test] +fn a_tab_dropped_on_a_stacked_panel_joins_its_frame() { + let (mut h, _, _) = harness(); + let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone(); + let fx = rect(&h, "panel.tab.EffectControls"); + let props = rect(&h, "panel.Properties"); + drag(&mut h, fx.center(), props.center()); + assert_eq!(group_of(&dock(&h), PanelKind::Properties), Some(vec![PanelKind::Properties, PanelKind::EffectControls])); + assert!(dock(&h).is_visible(PanelKind::EffectControls) && !dock(&h).is_visible(PanelKind::Properties)); + // The frame's other tab shows Properties again, the frame stays open. + let tab = rect(&h, "panel.tab.Properties"); + click_n(&mut h, tab.center(), 1); + assert!(dock(&h).is_visible(PanelKind::Properties) && !dock(&h).is_visible(PanelKind::EffectControls)); + // The layout (saved workspaces) round-trips. + let s = serde_json::to_string(&dock(&h)).unwrap(); + assert_eq!(serde_json::from_str::(&s).unwrap(), dock(&h)); +} From 03598229baab9e56ac62cae0b9d7301db9a47d46 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:23:23 +0000 Subject: [PATCH 5/6] Viewer: 3D layer outlines and the axis gizmo clip at the camera instead of drawing long lines (#572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer projected a 3D layer's box, its axis gizmo and its Extended Viewer outline through the camera with a plain perspective divide. A point at or behind the camera (w ≤ 0) flips through infinity, so a layer turned or moved so that part of it reaches past the camera drew long lines across the viewer, and its handles and hit area flipped too. - `layer_quad` (handles, hit tests, marquee) is None for a layer reaching past the near plane, so nothing is placed or picked on a flipped quad. - `layer_outline` clips the box to the part in front of the near plane (Sutherland-Hodgman in homogeneous coordinates); the selection outline and the Extended Viewer use it. - The 3D axis gizmo's arrows are clipped the same way (`project_segment`). Tests: a box crossing the near plane keeps only its front part, one wholly behind draws nothing; gizmo segments are cut at the plane and never flip. Note: the reporter's exact setup (ellipse, X/Y Rotation, Orientation) wasn't reproduced in a headless probe with the default camera, where the layer stays in front of it; this fixes the projection fault that draws such lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/panels/viewer.rs | 125 ++++++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 36df6174..e4ef5f37 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -258,17 +258,87 @@ pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 { from_effect_space(ctx, layer, l2c(ctx, layer).0) } -/// Layer → comp matrix and its bounds quad (in comp pixels). +/// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1 +/// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the +/// camera would flip through infinity and draw long lines across the viewer (#572). +const NEAR_W: f64 = 0.5; + +/// `p` through the homography `m` before the perspective divide: (x, y, w). +fn homogeneous(m: &Mat3, p: [f64; 2]) -> [f64; 3] { + let r = |i: usize| m.0.get(i).map_or(0.0, |row| row[0] * p[0] + row[1] * p[1] + row[2]); + [r(0), r(1), r(2)] +} + +/// The closed polygon `poly` (layer pixels) through `m`, clipped to the part in front of the +/// camera's near plane (comp pixels; empty when none of it is). +fn project_clipped(m: &Mat3, poly: &[[f64; 2]]) -> Vec<[f64; 2]> { + let h: Vec<[f64; 3]> = poly.iter().map(|p| homogeneous(m, *p)).collect(); + let mut out = vec![]; + for (i, a) in h.iter().enumerate() { + let Some(b) = h.get((i + 1) % h.len()) else { continue }; + let (ina, inb) = (a[2] >= NEAR_W, b[2] >= NEAR_W); + if ina { + out.push(*a); + } + if ina != inb { + let t = (NEAR_W - a[2]) / (b[2] - a[2]); + out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, NEAR_W]); + } + } + out.iter().map(|q| [q[0] / q[2], q[1] / q[2]]).filter(|q| q[0].is_finite() && q[1].is_finite()).collect() +} + +/// The segment a → b (world space) through the projection `pc`, clipped at the near plane. +fn project_segment(pc: &effectcraft_engine::geom::Mat4, a: effectcraft_engine::geom::Vec3, b: effectcraft_engine::geom::Vec3) -> Option<[[f64; 2]; 2]> { + let h = |p: effectcraft_engine::geom::Vec3| { + let r = |i: usize| pc.0.get(i).map_or(0.0, |row| row[0] * p.x + row[1] * p.y + row[2] * p.z + row[3]); + [r(0), r(1), r(3)] + }; + let (mut ha, mut hb) = (h(a), h(b)); + if ha[2] < NEAR_W && hb[2] < NEAR_W { + return None; + } + let cut = |p: [f64; 3], q: [f64; 3]| { + let t = (NEAR_W - p[2]) / (q[2] - p[2]); + [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, NEAR_W] + }; + if ha[2] < NEAR_W { + ha = cut(ha, hb); + } else if hb[2] < NEAR_W { + hb = cut(hb, ha); + } + let d = |q: [f64; 3]| [q[0] / q[2], q[1] / q[2]]; + let s = [d(ha), d(hb)]; + s.iter().flatten().all(|v| v.is_finite()).then_some(s) +} + +/// The content box corners (layer pixels) of `layer`. +fn box_corners(b: [f64; 4]) -> [[f64; 2]; 4] { + [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] +} + +/// Layer → comp matrix and its bounds quad (in comp pixels). None for a 3D layer reaching to or +/// behind the camera (its quad would flip; [`layer_outline`] draws the part in front). pub(crate) fn layer_quad(ctx: &EvalCtx, layer: &Layer) -> Option<(Mat3, [[f64; 2]; 4], [f64; 4])> { let b = effectcraft_engine::render::content_bounds(ctx, layer)?; let (m, _) = l2c(ctx, layer); - let pts = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| { + let corners = box_corners(b); + if corners.iter().any(|p| homogeneous(&m, *p)[2] < NEAR_W) { + return None; + } + let pts = corners.map(|p| { let q = m.apply(gv2(p[0], p[1])); [q.x, q.y] }); Some((m, pts, b)) } +/// The outline of a layer's content box in comp pixels, clipped at the camera's near plane. +pub(crate) fn layer_outline(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> { + let Some(b) = effectcraft_engine::render::content_bounds(ctx, layer) else { return vec![] }; + project_clipped(&l2c(ctx, layer).0, &box_corners(b)) +} + /// Which axes a bounding-box handle scales: corners (0–3) both; the top and bottom edges (4, 6) /// y; the right and left edges (5, 7) x. fn handle_axes(handle: usize) -> [bool; 2] { @@ -836,6 +906,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } let Some((m, q, _)) = layer_quad(&ectx, l) else { + // A 3D layer reaching past the camera: the part of its box in front of it. + let outline = layer_outline(&ectx, l); + if outline.len() > 2 { + painter.add(egui::Shape::closed_line(outline.iter().map(|p| map.to_screen(*p)).collect(), Stroke::new(1.0, col))); + continue; + } // Cameras, lights, empty layers: just the anchor. if let Some(tr) = l.transform() { let pos = ectx.v3(l, tr, "position", [0.0; 3]); @@ -868,15 +944,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let w = ectx.world_matrix(l); let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w; let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]); - let o = pc.apply(a3); let len = 60.0 / zoom as f64; for (axis, col) in [ (effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50)), (effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60)), (effectcraft_engine::geom::vec3(0.0, 0.0, len), Color32::from_rgb(0x50, 0x8c, 0xf0)), ] { - let e = pc.apply(a3 + axis); - painter.arrow(map.to_screen([o.x, o.y]), map.to_screen([e.x, e.y]) - map.to_screen([o.x, o.y]), Stroke::new(2.0, col)); + // (clipped at the camera's near plane, #572) + if let Some([o, e]) = project_segment(&pc, a3, a3 + axis) { + painter.arrow(map.to_screen(o), map.to_screen(e) - map.to_screen(o), Stroke::new(2.0, col)); + } } } } @@ -888,7 +965,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time)); if has_3d && app.session.prefs.three_d.extended_viewer { for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) { - if let Some((_, q, _)) = layer_quad(&ectx, l) { + let q = layer_outline(&ectx, l); + if q.len() > 2 { let sq: Vec = q.iter().map(|p| map.to_screen(*p)).collect(); let outside = sq.iter().any(|p| !comp_rect.contains(*p)); if outside { @@ -2228,3 +2306,38 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: } } } + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_engine::geom::{Mat4, vec3}; + + /// A perspective-like homography: w = 100 + y (the plane crosses the camera at y = −100). + fn tilted() -> Mat3 { + Mat3([[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 1.0, 100.0]]) + } + + /// #572: a box reaching behind the camera is clipped at the near plane instead of flipping + /// through infinity into long lines. + #[test] + fn outlines_clip_at_the_near_plane() { + let m = tilted(); + let front = project_clipped(&m, &box_corners([-50.0, -50.0, 50.0, 50.0])); + assert_eq!(front.len(), 4); + let crossing = project_clipped(&m, &box_corners([-50.0, -150.0, 50.0, 50.0])); + assert_eq!(crossing.len(), 4, "{crossing:?}"); + // Every point is in front: none flipped to the other side (y < 0 here means behind). + assert!(crossing.iter().all(|p| p[0].is_finite() && p[1].abs() < 1e4 && p[0].abs() < 1e4), "{crossing:?}"); + assert!(project_clipped(&m, &box_corners([-50.0, -400.0, 50.0, -200.0])).is_empty(), "wholly behind"); + } + + #[test] + fn gizmo_segments_clip_at_the_near_plane() { + // w = 100 + z. + let pc = Mat4([[100.0, 0.0, 0.0, 0.0], [0.0, 100.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 1.0, 100.0]]); + let s = project_segment(&pc, vec3(10.0, 0.0, 0.0), vec3(10.0, 0.0, -300.0)).unwrap(); + assert_eq!(s[0], [10.0, 0.0]); + assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}"); + assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); + } +} From 657f34d40b6f4eec2e0fc29bf5f3cbca656d3fe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:23:36 +0000 Subject: [PATCH 6/6] Layers: with several layers selected, value edits, stopwatches and viewer Scale / Rotate change them all (#355, #491) With several layers selected, After Effects applies a property edit to the same property of every selected layer; we changed only the layer whose row was edited (the Properties panel showed only the first selected layer), so a stopwatch switched on one layer and later moves of the others had nothing to keyframe (#355), and scaling or rotating a selection changed one layer (#491). - Engine: `prop.set`, `prop.toggleAnimation` and `prop.toggleKey` take `selected: true`: the change also goes to the same property (by match path, `transform/scale`, `effects/#n/`) of every other selected, unlocked layer whose value has the same kind, in one undo step (a scrub keeps folding into it through its `merge` key). With `offset: true` (a scrub) each layer changes by the same amount from its own value; a typed value sets them all. Position with Separate Dimensions is handled per layer. - The Timeline, Properties and Effect Controls panels pass `selected` (and `offset` while a value is dragged). The Properties panel heads "(N) Selected Objects" (zh-CN / zh-TW / ja / uk rows). - Viewer: a bounding-box handle drag scales every selected layer by the same ratio about its own anchor point; the Rotation tool turns them all by the same angle (one undo step). Tests: engine test (stopwatch, scrub offset, typed value, keyframe button, one undo step, unselected layers untouched); kittests: the Properties panel with three layers selected (header, stopwatch on all three undone in one step, a scrub moving all three by the same amount) and a handle drag scaling two selected layers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/prop.rs | 147 ++++++++++++++++++- crates/engine/src/tests_timeline.rs | 48 ++++++ crates/ui-egui/src/i18n/ui.rs | 4 + crates/ui-egui/src/panels/effect_controls.rs | 11 +- crates/ui-egui/src/panels/properties.rs | 22 ++- crates/ui-egui/src/panels/timeline.rs | 12 +- crates/ui-egui/src/panels/viewer.rs | 42 +++++- crates/ui-egui/tests/ui_keyframes.rs | 50 +++++++ crates/ui-egui/tests/ui_viewer.rs | 26 ++++ 9 files changed, 342 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index cb8c3ad2..46804c87 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -165,6 +165,145 @@ fn property_reply(p: &Value, layer: &Layer, pr: &Property, time: Tick, value: Va } } +// ---------------------------------------------------------------- several selected layers + +/// With `selected: true` (the Timeline, Properties and Effect Controls panels pass it): the same +/// property (by match path, `transform/position`, `effects/#n/`) of every other +/// selected, unlocked layer of the comp whose value is of the same kind, as (layer, uid). Empty +/// when `selected` is not set or the edited layer isn't selected. After Effects applies a value +/// edit, the stopwatch and the keyframe button to all selected layers (#355, #491). +fn selected_twins(s: &Session, p: &Value, cmd: &str) -> Result> { + if b_p(p, "selected") != Some(true) { + return Ok(vec![]); + } + let (cid, lid, uid) = prop_ref(s, p, cmd)?; + let Some(comp) = s.project.comp(cid) else { return Ok(vec![]) }; + let Some(layer) = comp.layer(lid) else { return Ok(vec![]) }; + let (Some(path), Some(pr)) = (layer.props.match_path_of(uid), layer.props.find(uid)) else { return Ok(vec![]) }; + if !s.state.selected_layers.contains(&lid) { + return Ok(vec![]); + } + let kind = (pr.value.kind_name(), pr.value.components().len()); + Ok(s.state + .selected_layers + .iter() + .filter(|o| **o != lid) + .filter_map(|o| comp.layer(*o)) + .filter(|l| !l.switches.locked) + .filter_map(|l| l.props.prop(&path).filter(|q| (q.value.kind_name(), q.value.components().len()) == kind).map(|q| (l.id, q.uid))) + .collect()) +} + +/// `p` aimed at property `uid` of layer `lid` instead. +fn twin_params(p: &Value, lid: LayerId, uid: Uid) -> Value { + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.remove("path"); + o.remove("selected"); + o.remove("offset"); + } + q["layer"] = json!(lid.0); + q["prop"] = json!(uid); + q +} + +/// The value of property `uid` of `lid` at the CTI as numbers (Position with Separate +/// Dimensions: its X / Y / Z Position). +fn current_components(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option> { + let l = s.project.comp(cid)?.layer(lid)?; + let lt = l.layer_time(s.time_of(cid)); + if let Some(tr) = l.transform() + && tr.get("position").is_some_and(|q| q.uid == uid) + && tr.get("positionX").is_some() + { + return Some(["positionX", "positionY", "positionZ"].iter().filter_map(|m| tr.get(m)).map(|q| q.value_at(lt).as_f64()).collect()); + } + Some(l.props.find(uid)?.value_at(lt).components()) +} + +/// A JSON number or array of numbers. +fn json_numbers(v: &Value) -> Option> { + match v { + Value::Number(n) => Some(vec![n.as_f64()?]), + Value::Array(a) => a.iter().map(Value::as_f64).collect(), + _ => None, + } +} + +/// `prop.set`; with `selected`, also on the other selected layers: the same value, or with +/// `offset` (a scrub) the same change from each layer's own value. One undo step (with `merge`, +/// one that a continuing scrub keeps folding into). +fn set_selected(s: &mut Session, p: &Value) -> Result { + let twins = selected_twins(s, p, "prop.set")?; + if twins.is_empty() { + return set(s, p); + } + let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; + let new = p.get("value").and_then(json_numbers); + let delta = match (b_p(p, "offset"), &new, current_components(s, cid, lid, uid)) { + (Some(true), Some(n), Some(c)) => Some(n.iter().zip(c.iter().chain(std::iter::repeat(&0.0))).map(|(a, b)| a - b).collect::>()), + _ => None, + }; + let run = |s: &mut Session| -> Result { + let out = set(s, p)?; + for (olid, ouid) in twins { + let mut q = twin_params(p, olid, ouid); + if let Some(d) = &delta + && let Some(c) = current_components(s, cid, olid, ouid) + { + let v: Vec = c.iter().enumerate().map(|(i, x)| x + d.get(i).copied().unwrap_or(0.0)).collect(); + q["value"] = if p.get("value").is_some_and(Value::is_number) { json!(v.first().copied().unwrap_or(0.0)) } else { json!(v) }; + } + // (a layer that can't take it, e.g. a clamped or static property, keeps its value) + let _ = set(s, &q); + } + Ok(out) + }; + if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Change Property", run) } +} + +/// `prop.toggleAnimation`; with `selected`, the other selected layers' same property follows. +fn toggle_anim_selected(s: &mut Session, p: &Value) -> Result { + let twins = selected_twins(s, p, "prop.toggleAnimation")?; + if twins.is_empty() { + return toggle_anim(s, p); + } + let run = |s: &mut Session| -> Result { + let on = toggle_anim(s, p)?; + for (olid, ouid) in twins { + let mut q = twin_params(p, olid, ouid); + q["value"] = on.clone(); + let _ = toggle_anim(s, &q); + } + Ok(on) + }; + // (a stopwatch drag over several rows keeps folding into the step its `merge` names) + if merge_p(p).is_some() { run(s) } else { super::app_more::grouped(s, "Toggle Animation", run) } +} + +/// `prop.toggleKey`; with `selected`, the other selected layers' same property gets (or loses) +/// its keyframe at the current time too. +fn toggle_key_selected(s: &mut Session, p: &Value) -> Result { + let twins = selected_twins(s, p, "prop.toggleKey")?; + if twins.is_empty() { + return toggle_key(s, p); + } + let cid = prop_ref(s, p, "prop.toggleKey")?.0; + super::app_more::grouped(s, "Add/Remove Keyframe", |s| { + let added = toggle_key(s, p)?; + let want = added.as_bool().unwrap_or(true); + for (olid, ouid) in twins { + let _ = with_prop(s, "Add/Remove Keyframe", None, cid, olid, ouid, |pr, lt| { + if key_at(&pr.keys, lt).is_some() != want { + toggle_key_at(pr, lt); + } + Ok(()) + }); + } + Ok(added) + }) +} + fn set(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; // Position with Separate Dimensions: write the X/Y/Z Position properties instead. @@ -1001,13 +1140,13 @@ pub fn specs() -> Vec { "Set Property Value", [], None, - "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?}", + "{layer?, path|prop, value, time? (layer s by default), timeBase?: layer|comp, merge?, selected? (also the other selected layers), offset? (them by the same change)}", has_layers, - set + set_selected ), - cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim), + cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?, selected?}", has_layers, toggle_anim_selected), cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time? (layer s by default), timeBase?: layer|comp, value?}", has_layers, add_key), - cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key), + cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop, selected?}", has_layers, toggle_key_selected), cmd!( "keys.toggleTransform", "Add or Remove Transform Keyframe", diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index a741f5ad..746a8a0b 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -831,3 +831,51 @@ fn toggle_transform_key_covers_selected_layers_separated_position_and_3d_rotatio } assert!(s.execute("keys.toggleTransform", json!({"prop": "skew"})).is_err()); } + +/// #355, #491: with several layers selected, the stopwatch, the keyframe button and value edits +/// (`selected: true`, as the panels send them) apply to every selected layer in one undo step; a +/// scrub (`offset`) moves each layer by the same amount, a typed value sets them all. +#[test] +fn property_edits_apply_to_every_selected_layer() { + let (mut s, a) = setup(); + let b = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let c = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap(); + s.execute("layer.select", json!({"layers": [a, b]})).unwrap(); + let pos = |s: &Session, l: u64| prop(s, l, "transform/position").value_at(effectcraft_time::Tick::ZERO).components(); + let uid = prop(&s, a, "transform/position").uid; + + // Stopwatch on all selected layers, one undo step; the unselected layer is untouched. + let undo = s.history.undo.len(); + s.execute("prop.toggleAnimation", json!({"layer": a, "prop": uid, "selected": true})).unwrap(); + assert!(prop(&s, a, "transform/position").is_animated() && prop(&s, b, "transform/position").is_animated()); + assert!(!prop(&s, c, "transform/position").is_animated()); + assert_eq!(s.history.undo.len(), undo + 1); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!prop(&s, a, "transform/position").is_animated() && !prop(&s, b, "transform/position").is_animated()); + + // A scrub moves both by the same change (merged into one step while it continues). + let (pa, pb) = (pos(&s, a), pos(&s, b)); + for k in 1..=3 { + let v = [pa[0] + 5.0 * k as f64, pa[1]]; + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v, "selected": true, "offset": true, "merge": "scrub-1"})).unwrap(); + } + assert_eq!(pos(&s, a)[..2], [pa[0] + 15.0, pa[1]]); + assert_eq!(pos(&s, b)[..2], [pb[0] + 15.0, pb[1]]); + assert_eq!(s.history.undo.len(), undo + 1, "one undo step for the scrub"); + // A typed value sets them all. + s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 40, "selected": true})).unwrap(); + assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 40.0); + assert_eq!(prop(&s, c, "transform/opacity").value.as_f64(), 100.0); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, a, "transform/opacity").value.as_f64(), 100.0); + assert_eq!(prop(&s, b, "transform/opacity").value.as_f64(), 100.0); + + // The keyframe button adds a key on each selected layer. + s.execute("prop.toggleKey", json!({"layer": a, "path": "transform/scale", "selected": true})).unwrap(); + assert_eq!(prop(&s, a, "transform/scale").keys.len(), 1); + assert_eq!(prop(&s, b, "transform/scale").keys.len(), 1); + // Without `selected` only the given layer changes. + s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap(); + assert_eq!(prop(&s, b, "transform/rotation").value.as_f64(), 0.0); +} diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index ce7c2a99..f7c77905 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -405,6 +405,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "ショートカットを変更するコマンドを選択してください。"), ("Select a layer to see its effects", "レイヤーを選択するとエフェクトが表示されます"), ("Select a layer to see its properties", "レイヤーを選択するとプロパティが表示されます"), + ("({}) Selected Objects", "({}) 個の選択されたオブジェクト"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "マスクまたはシェイプパスのあるレイヤー(またはパスプロパティ)か、パペットピンを選択してください。", @@ -840,6 +841,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "选择一条命令以更改其快捷键。"), ("Select a layer to see its effects", "选择图层以查看其效果"), ("Select a layer to see its properties", "选择图层以查看其属性"), + ("({}) Selected Objects", "({}) 个选定对象"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "请选择带蒙版或形状路径(或路径属性)的图层,或带有人偶定位点的图层。", @@ -1364,6 +1366,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "選取一條命令以更改其快速鍵。"), ("Select a layer to see its effects", "選取圖層以查看其效果"), ("Select a layer to see its properties", "選取圖層以查看其屬性"), + ("({}) Selected Objects", "({}) 個選取的物件"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "請選取帶遮色片或形狀路徑(或路徑屬性)的圖層,或帶有人偶釘選點的圖層。", @@ -1882,6 +1885,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Select a command to change its shortcut.", "Виберіть команду, щоб змінити її клавіатурне скорочення."), ("Select a layer to see its effects", "Виділіть шар, щоб переглянути його ефекти"), ("Select a layer to see its properties", "Виділіть шар, щоб переглянути його властивості"), + ("({}) Selected Objects", "Вибрано об’єктів: ({})"), ( "Select a layer with a mask or shape path (or the path property), or Puppet pins.", "Виділіть шар із маскою або контуром фігури (або властивість контуру) чи опорні точки деформації.", diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 6612187e..d5edc667 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -180,7 +180,7 @@ fn key_navigator( match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), - _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))), } } } @@ -241,7 +241,7 @@ fn prop_row( ); app.auto.add(&format!("effectControls.prop.{uid}.stopwatch"), swr, &prop.name); if resp.clicked() { - actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))); } } if prop.is_animated() { @@ -273,8 +273,11 @@ fn prop_row( ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click()); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); let merge = format!("ec-{uid}"); - let set = - |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))); + // With several layers selected the edit applies to the same effect property of each (#491). + let scrub = ui.ctx().dragged_id().is_some(); + let set = |actions: &mut Actions, v: serde_json::Value| { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + }; let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| { let open = app.ui.fx_slider_open.contains(&uid); let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index dbeeb1a7..c10d29bb 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -72,6 +72,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = top; let mut actions: Actions = vec![]; + // Several layers selected: "(3) Selected Objects", as in After Effects. The rows show the + // first layer; the stopwatch, keyframe button and value edits apply to all of them (#355). + let selected = app.session.state.selected_layers.iter().filter(|id| comp.layer(**id).is_some()).count(); + if selected > 1 { + let n = selected.to_string(); + let label = crate::i18n::tr_args("({}) Selected Objects", &[&n]); + p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, &label, Tokens::semibold(12.0), t.text); + app.auto.add("properties.selectedObjects", Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H)), &label); + y += ROW_H + 4.0; + } + // ---------------------------------------------------------------- Layer Transform if let Some(tr) = layer.transform().cloned() { section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform"); @@ -218,7 +229,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), - _ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": lid, "prop": uid, "selected": true}))), } } } @@ -228,13 +239,18 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim }); app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name); if resp.clicked() { - actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": lid, "prop": uid, "selected": true}))); } } p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text); let vx = r.min.x + (r.width() * 0.5).max(130.0); let merge = format!("props-{uid}"); - let set = |actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge}))); + // With several layers selected the edit applies to all: a scrub by the same change, a typed + // value as it is. + let scrub = ui.ctx().dragged_id().is_some(); + let set = |actions: &mut Actions, v: serde_json::Value| { + actions.push(("prop.set".into(), json!({"layer": lid, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + }; let value = ectx.value(layer, pr); let is_scale = pr.name == "Scale"; let pct = is_scale || pr.name == "Opacity"; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 086e46dc..fb074bcc 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2403,7 +2403,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui_actions.push(UiAct::SetTime(layer.comp_time(k.time))); } if ui.interact(mid, egui::Id::new(("kmid", uid)), Sense::click()).clicked() { - actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid}))); + actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))); } app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe"); } @@ -2426,10 +2426,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid}))); } } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) { - actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge}))); + actions + .push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge, "selected": true}))); } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) { // Keyboard or accessibility activation. - actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid}))); + actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))); } } let name_x = indent + 12.0; @@ -3510,8 +3511,11 @@ fn value_editor( let uid = prop.uid; let mut dims = vec![]; let merge = format!("scrub-{uid}"); + // With several layers selected the edit applies to all of them (#491): a scrub by the same + // change, a typed value as it is. + let scrub = ui.ctx().dragged_id().is_some(); let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))) + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) }; let is_3d = layer.is_3d(); let mut x = at.x; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index e4ef5f37..2c5d47f3 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -84,12 +84,17 @@ enum Gesture { /// Layer pixels → comp as the drag began, and its inverse. l2c: Mat3, inv: Mat3, + /// The other selected layers and their scales as the drag began: they scale by the same + /// ratio about their own anchor points, as in After Effects (#491). + others: Vec<(LayerId, [f64; 3])>, }, Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64, + /// The other selected layers and their rotations: they turn by the same angle. + others: Vec<(LayerId, f64)>, }, Anchor { layer: LayerId, @@ -258,6 +263,16 @@ pub(crate) fn fx2c(ctx: &EvalCtx, layer: &Layer) -> Mat3 { from_effect_space(ctx, layer, l2c(ctx, layer).0) } +/// The selected, unlocked layers of `comp` other than `layer` (when `layer` is one of them): a +/// handle or Rotation tool drag on one changes them all. +fn selected_others<'a>(app: &EffectcraftApp, comp: &'a Comp, layer: LayerId) -> Vec<&'a Layer> { + let sel = &app.session.state.selected_layers; + if !sel.contains(&layer) { + return vec![]; + } + sel.iter().filter(|id| **id != layer).filter_map(|id| comp.layer(*id)).filter(|l| !l.switches.locked && l.transform().is_some()).collect() +} + /// The least homogeneous depth (`w`: the distance in front of a perspective camera in pixels, 1 /// for 2D layers and orthographic views) a viewer outline is drawn at: points at or behind the /// camera would flip through infinity and draw long lines across the viewer (#572). @@ -1232,6 +1247,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay), Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| { + let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect(); let layer = comp.layer(l).cloned(); let center = layer .as_ref() @@ -1239,7 +1255,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .map(|p| map.to_screen([p[0], p[1]])) .unwrap_or(pos); let rot = layer.as_ref().and_then(|l| l.transform().map(|tr| ectx.f(l, tr, "rotation", 0.0))).unwrap_or(0.0); - Gesture::Rotate { layer: l, center, start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), start_rot: rot } + Gesture::Rotate { + layer: l, + center, + start_angle: ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(), + start_rot: rot, + others, + } }), Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| { let layer = comp.layer(l)?; @@ -1349,6 +1371,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { axes: handle_axes(hi), l2c, inv, + others: selected_others(app, &comp, lid) + .into_iter() + .filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3])))) + .collect(), }) }) } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) { @@ -1417,23 +1443,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); } } - Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv } => { + Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv, others } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); let uniform = mods.shift && axes == [true, true]; let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform); // The handle snaps (to the comp's and other layers' edges, corners and centres), // so a layer scales exactly to them, e.g. to the comp's size. let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods)); - let v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]]; - let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge})); + for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) { + let v = [s0[0] * f[0], s0[1] * f[1], s0[2]]; + let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge})); + } } - Gesture::Rotate { layer, center, start_angle, start_rot } => { + Gesture::Rotate { layer, center, start_angle, start_rot, others } => { let ang = ((pos.y - center.y) as f64).atan2((pos.x - center.x) as f64).to_degrees(); let mut r = start_rot + (ang - start_angle); if mods.shift { r = (r / 45.0).round() * 45.0; } let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge})); + for (lid, r0) in others { + let _ = + app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/rotation", "value": r0 + (r - start_rot), "merge": merge})); + } } Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => { // Pan Behind snaps the anchor point (to its own layer's box, other layers' features, diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 3ca85f8f..a7d1d1ae 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -348,3 +348,53 @@ fn graph_editor_buttons_that_do_not_fit_move_to_the_more_menu() { click(&mut h, pos2(item[0] + item[2] / 2.0, item[1] + item[3] / 2.0)); assert_eq!(h.state().ui.timeline.graph_transform_box, !before, "the menu item runs the button"); } + +/// #355, #491: with three layers selected the Properties panel heads "(3) Selected Objects"; its +/// stopwatch animates Position on all three (one undo step) and scrubbing X moves all three by +/// the same amount. +#[test] +fn properties_panel_edits_every_selected_layer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Three", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids = vec![]; + for (i, c) in ["#e04020", "#20e040", "#2040e0"].iter().enumerate() { + let l = s.execute("layer.newSolid", json!({"color": c, "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [60.0 + 80.0 * i as f64, 90.0]})).unwrap(); + ids.push(l); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let first = s.state.selected_layers[0]; + let uid = s.active_comp().unwrap().layer(first).unwrap().props.prop("transform/position").unwrap().uid; + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Properties); + h.run_steps(4); + assert_eq!(h.state().auto.find("properties.selectedObjects").map(|e| e.label.clone()), Some("(3) Selected Objects".into())); + let pos = |h: &Harness<'_, EffectcraftApp>, l: u64| { + h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone() + }; + let at = |h: &Harness<'_, EffectcraftApp>, id: &str| { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) + }; + let sw = at(&h, &format!("properties.prop.{uid}.stopwatch")); + h.input_mut().events.push(Event::PointerMoved(sw)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: sw, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + assert!(ids.iter().all(|l| pos(&h, *l).is_animated()), "every selected layer's stopwatch"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + assert!(ids.iter().all(|l| !pos(&h, *l).is_animated()), "one undo step"); + // Scrub X: each layer moves by the same amount. + let before: Vec = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect(); + let x = at(&h, &format!("properties.prop.{uid}.value.0")); + drag(&mut h, x, x + egui::vec2(30.0, 0.0), Default::default()); + let after: Vec = ids.iter().map(|l| pos(&h, *l).value.components()[0]).collect(); + let d = after[0] - before[0]; + assert!(d.abs() > 1.0, "the scrub moved X: {before:?} → {after:?}"); + for (a, b) in after.iter().zip(&before) { + assert!((a - b - d).abs() < 1e-6, "same change on every layer: {before:?} → {after:?}"); + } +} diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index bada77a7..120c4db0 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1707,6 +1707,32 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() { assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h)); } +/// #491: with several layers selected, a handle drag scales all of them by the same ratio, each +/// about its own anchor point, in one undo step. +#[test] +fn handle_drags_scale_every_selected_layer() { + let mut h = harness(); + let layers = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect::>(); + let (box_id, plate) = (layers[0], layers[1]); + let scale = |h: &Harness<'_, EffectcraftApp>, id: LayerId| { + let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone(); + l.props.prop("transform/scale").unwrap().value.as_vec3() + }; + h.state_mut().session.execute("prop.set", json!({"layer": plate.0, "path": "transform/scale", "value": [50, 50, 100]})).unwrap(); + h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0, plate.0]})).unwrap(); + h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap(); + h.run_steps(2); + let undo0 = h.state().session.history.undo.len(); + // The box's bottom-right corner (360, 220) to (400, 260): twice the box's size. + let corner = rect(&h, &format!("viewer.handle.{}.2", box_id.0)).center(); + let to = screen(&h, [400.0, 260.0]); + drag_path(&mut h, &[corner, to], Default::default()); + let (b, p) = (scale(&h, box_id), scale(&h, plate)); + assert!((b[0] - 200.0).abs() < 1.0 && (b[1] - 200.0).abs() < 1.0, "{b:?}"); + assert!((p[0] - 100.0).abs() < 1.0 && (p[1] - 100.0).abs() < 1.0, "the other layer doubles too: {p:?}"); + assert_eq!(h.state().session.history.undo.len(), undo0 + 1, "one undo step"); +} + /// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other /// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands /// on the comp's size.