From be9c9077cd62232f3a6ecac2a6c2d309cfb68545 Mon Sep 17 00:00:00 2001 From: p01ntov <278898977+p01ntov@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:56:35 +0300 Subject: [PATCH 01/10] G2: tessellate viewer path overlays in screen space --- crates/ui-egui/src/panels/viewer_overlays.rs | 152 ++++++++++++++++++- 1 file changed, 146 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer_overlays.rs b/crates/ui-egui/src/panels/viewer_overlays.rs index 9117103d5..d0875941e 100644 --- a/crates/ui-egui/src/panels/viewer_overlays.rs +++ b/crates/ui-egui/src/panels/viewer_overlays.rs @@ -132,17 +132,52 @@ pub(crate) fn path_of(ectx: &EvalCtx, layer: LayerId, uid: u64) -> Option Vec { - let k = effectcraft_engine::render::kurbo_path(sp); + let mut k = effectcraft_engine::render::kurbo_path(sp); + // A path-space tolerance becomes visible corners when the viewer or a layer/group is + // magnified. Flatten affine paths in screen space so the error stays below half a point. + // Projective transforms must still map the flattened points: projecting Bezier control + // points would change the curve rather than just its tessellation. + let screen_space = m.is_affine(); + if screen_space { + let z = f64::from(map.zoom); + k.apply_affine(kurbo::Affine::new([ + m.0[0][0] * z, + m.0[1][0] * z, + m.0[0][1] * z, + m.0[1][1] * z, + m.0[0][2] * z + f64::from(map.origin.x), + m.0[1][2] * z + f64::from(map.origin.y), + ])); + } + if !k.is_finite() { + return Vec::new(); + } + let bounds = k.control_box(); + if [bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().any(|v| v.abs() > f64::from(f32::MAX)) { + return Vec::new(); + } + // Bound subdivision work even for extreme finite scales from a malformed project. For + // ordinary on-screen paths the tolerance remains half a point. + let tolerance = (bounds.width().max(bounds.height()) / 1_000_000.0).max(0.5); let mut pts: Vec = vec![]; - kurbo::flatten(k.iter(), 0.5, |el| match el { + kurbo::flatten(k.iter(), tolerance, |el| match el { kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => { - let c = m.apply(gv2(q.x, q.y)); - pts.push(map.to_screen([c.x, c.y])); + let p = if screen_space { + egui::pos2(q.x as f32, q.y as f32) + } else { + let c = m.apply(gv2(q.x, q.y)); + map.to_screen([c.x, c.y]) + }; + if p.is_finite() { + pts.push(p); + } } _ => {} }); - if sp.closed && !pts.is_empty() { - pts.push(pts[0]); + if sp.closed + && let Some(first) = pts.first().copied() + { + pts.push(first); } pts } @@ -679,6 +714,111 @@ mod tests { PathInfo { layer: LayerId(3), uid: 7, m: Mat3::IDENTITY, sp, is_mask: true, feather: Vec::new() } } + fn circle() -> ShapePath { + let r = 38.0; + let h = r * 0.552_284_749_830_793_6; + ShapePath { + vertices: vec![[0.0, -r], [r, 0.0], [0.0, r], [-r, 0.0]], + in_tangents: vec![[-h, 0.0], [0.0, -h], [h, 0.0], [0.0, h]], + out_tangents: vec![[h, 0.0], [0.0, h], [-h, 0.0], [0.0, -h]], + closed: true, + feather: Vec::new(), + } + } + + /// Measure the actual screen-space error against dense samples of the original cubics, + /// independently of the tessellator's chosen subdivisions. + fn screen_error(sp: &ShapePath, m: &Mat3, map: &ViewerMap, points: &[Pos2]) -> f32 { + let mut worst = 0.0_f32; + for i in 0..sp.vertices.len() { + let Some(c) = segment(sp, i) else { continue }; + for step in 0..=1024 { + let q = cubic(c[0], c[1], c[2], c[3], f64::from(step) / 1024.0); + let q = m.apply(gv2(q[0], q[1])); + let p = map.to_screen([q.x, q.y]); + let distance = points + .windows(2) + .map(|pair| { + let a = pair[0]; + let v = pair[1] - a; + let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + v * t) + }) + .fold(f32::INFINITY, f32::min); + worst = worst.max(distance); + } + } + worst + } + + #[test] + fn curve_overlay_stays_smooth_at_viewer_magnifications() { + let sp = circle(); + let mut counts = Vec::new(); + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &Mat3::IDENTITY, &map); + let error = screen_error(&sp, &Mat3::IDENTITY, &map, &points); + assert!(error <= 0.51, "{zoom}x: {error} screen points from the curve"); + assert_eq!(points.first(), points.last(), "closed paths keep their closing edge"); + for v in &sp.vertices { + let anchor = map.to_screen(*v); + assert!(points.iter().any(|p| p.distance(anchor) < 0.001), "original vertex missing at {zoom}x"); + } + counts.push(points.len()); + } + assert!(counts[0] < counts[1] && counts[1] < counts[2], "tessellation follows visible curve size: {counts:?}"); + } + + #[test] + fn curve_overlay_accounts_for_nonuniform_layer_and_group_transforms() { + let sp = circle(); + let layer = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let group = Mat3::rotate_deg(-19.0) * Mat3::skew_deg(25.0, 11.0) * Mat3::scale(gv2(0.7, 2.5)); + let m = layer * group; + for zoom in [0.5, 1.0, 5.0] { + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + let error = screen_error(&sp, &m, &map, &points); + assert!(error <= 0.51, "{zoom}x transformed path: {error} screen points from the curve"); + } + } + + #[test] + fn open_overlay_preserves_its_transformed_endpoints() { + let sp = info(vec![[10.0, 20.0], [50.0, 60.0]], false).sp; + let m = Mat3::translate(gv2(70.0, 50.0)) * Mat3::rotate_deg(27.0) * Mat3::scale(gv2(3.2, 0.8)); + let map = ViewerMap { origin: egui::pos2(121.25, 210.5), zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let points = flatten(&sp, &m, &map); + assert_ne!(points.first(), points.last(), "an open path must not gain a closing edge"); + for (point, vertex) in [points.first(), points.last()].into_iter().zip([sp.vertices.first(), sp.vertices.last()]) { + let q = m.apply(gv2(vertex.unwrap()[0], vertex.unwrap()[1])); + assert!(point.unwrap().distance(map.to_screen([q.x, q.y])) < 0.001); + } + } + + #[test] + fn invalid_overlay_coordinates_are_not_tessellated() { + let sp = circle(); + for zoom in [f32::NAN, f32::INFINITY] { + let map = ViewerMap { origin: Pos2::ZERO, zoom, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::IDENTITY, &map).is_empty()); + } + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + assert!(flatten(&sp, &Mat3::scale(gv2(f64::INFINITY, 1.0)), &map).is_empty()); + } + + #[test] + fn extreme_finite_overlay_scales_keep_subdivision_bounded() { + let map = ViewerMap { origin: Pos2::ZERO, zoom: 5.0, comp: [640.0, 400.0], area: Rect::EVERYTHING }; + let sp = circle(); + let points = flatten(&sp, &Mat3::scale(gv2(1e12, 1e12)), &map); + assert!(!points.is_empty()); + assert!(points.len() < 10_000, "extreme scales must not allocate millions of vertices"); + assert!(points.iter().all(|p| p.is_finite())); + assert!(flatten(&sp, &Mat3::scale(gv2(1e300, 1e300)), &map).is_empty()); + } + /// #912: clicking the last vertex of an open path with the Pen resumes that path. #[test] fn pen_resumes_an_open_path_at_its_last_vertex() { From 0ca3f72ac2175521c1a109dabb3f8a2242528709 Mon Sep 17 00:00:00 2001 From: becauseno7 <95076423+becauseno7@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:34:30 +0600 Subject: [PATCH 02/10] CC Bubbles: bubbles refract the layer's edge pixels on padded buffers too (#2024) Refraction samples are clamped to the layer rectangle instead of the buffer, so a sample past the layer's edge in a buffer with transparent padding (a half-resolution preview's, a text or shape layer's) repeats the layer's edge pixels rather than reaching the pale colour that stands in for transparent ones. Transparent pixels inside the layer still show it. Same bounds on the GPU path. Re-pins the four half-resolution CC Bubbles golden hashes (aarch64 macOS); the full-resolution ones are unchanged. Adds a CPU test and a GPU-vs-CPU parity test on padded buffers. --- crates/effects/src/lib.rs | 2 +- crates/effects/src/sim.rs | 2 +- crates/effects/src/sim/tests.rs | 39 +++++++++++++++++++++++++++ crates/effects/src/sim/weather.rs | 14 +++++++++- crates/effects/tests/it/sim_golden.rs | 8 +++--- crates/gpu/src/fx_sim.rs | 1 + crates/gpu/src/shaders/fx_sim.wgsl | 7 +++-- crates/gpu/src/tests/fx_sim.rs | 28 +++++++++++++++++++ 8 files changed, 92 insertions(+), 9 deletions(-) diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 32e662fec..a5534bf86 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -117,7 +117,7 @@ pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). pub use channel3d::{CRYPTO_LAYERS, CryptoAux, aux_point, crypto_aux_image, crypto_aux_image_with, id_color, selection_hashes}; pub use ocio::{ColorOp, Straight, Tf, color_program}; -pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, drizzle_drops, mercury_blobs, sprite_plan}; +pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan}; pub use sim2::{ CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan, wave_world_plan, diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 79a225f75..333549e7d 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -38,7 +38,7 @@ use particle_fx::{PW_RATE, mercury_phys, ps2_phys, pw_phys}; pub use registry::{specs, sprite_plan}; pub use sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint}; pub(crate) use sprites::{combine, raster, splat}; -pub use weather::{Bubble, bubble_list, drizzle_drops}; +pub use weather::{Bubble, bubble_list, bubble_sample_bounds, drizzle_drops}; #[cfg(test)] mod tests; diff --git a/crates/effects/src/sim/tests.rs b/crates/effects/src/sim/tests.rs index d5de4cb74..06ce973b2 100644 --- a/crates/effects/src/sim/tests.rs +++ b/crates/effects/src/sim/tests.rs @@ -112,6 +112,45 @@ fn bubbles_near_edges_show_the_layer() { assert_eq!(pale, [0, 0], "pale bubble pixels (liquid, metal)"); } +/// CC Bubbles on a 40 × 40 layer `img` at `scale`, in a buffer padded by 6 transparent pixels +/// (a half-resolution preview's layout). +fn padded_bubbles(img: Image, scale: f64, reflection: u32) -> Image { + let size = 40.0; + let s = crate::find("ec.sim.ccbubbles").unwrap(); + let mut params = crate::Params { values: s.params.iter().map(|p| (p.id.to_string(), crate::default_value(p, [size; 2]))).collect() }; + for (k, v) in [("bubbleAmount", num(200.0)), ("bubbleSize", num(3.0)), ("reflectionType", Value::Enum(reflection))] { + params.values.insert(k.into(), v); + } + let ctx = EffectCtx { params: ¶ms, time: 0.3, layer_size: [size; 2], seed: 1, adjustment: false, env: EffectEnv::default() }; + let mut b = Buf { img, offset: [0.0; 2], scale }; + b.pad(6); + crate::apply(s, &ctx, b).img +} + +/// #2024: the same when the buffer has transparent padding around the layer: samples past the +/// layer's edges repeat its edge pixels, not the padding, and a clear hole inside the layer is +/// still refracted as the pale colour. +#[test] +fn bubbles_near_padded_edges_show_the_layer() { + let blue = |side| Image::filled(side, side, [0.0, 0.0, 1.0, 1.0]); + let pale = |img: &Image| img.data.iter().filter(|p| (p[2] - p[0] - p[3]).abs() > 1e-4).count(); + for (scale, side) in [(1.0, 40), (0.5, 20)] { + let counts = [0, 1].map(|reflection| { + let a = padded_bubbles(blue(side), scale, reflection); + assert!(a.data.iter().any(|p| p[3] > 0.5), "bubbles drawn"); + pale(&a) + }); + assert_eq!(counts, [0, 0], "pale bubble pixels at scale {scale} (liquid, metal)"); + } + let mut holed = blue(40); + for y in 12..28 { + for x in 12..28 { + holed.set(x, y, [0.0; 4]); + } + } + assert!(pale(&padded_bubbles(holed, 1.0, 0)) > 0, "the hole refracts as the pale colour"); +} + #[test] fn drizzle_ripples() { let a = run("ec.sim.ccdrizzle", &[], ramp(48, 48), 0.6); diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs index b58b39b5e..347ab4939 100644 --- a/crates/effects/src/sim/weather.rs +++ b/crates/effects/src/sim/weather.rs @@ -1,6 +1,7 @@ use super::common::{h, hs}; use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster}; use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul}; +use crate::util::layer_rect_at; // ------------------------------------------------------------------ CC Rainfall / CC Snowfall @@ -160,11 +161,21 @@ pub fn bubble_list(ctx: &EffectCtx, b: &Buf) -> Vec { .collect() } +/// CC Bubbles' refraction sample bounds in the pixels of a buffer with `offset` and `scale`: the +/// layer rectangle's first and last pixel centres (x0, y0, x1, y1). Samples clamped to them repeat +/// the layer's edge pixels instead of reaching transparent padding around it (#2024). +pub fn bubble_sample_bounds(ctx: &EffectCtx, offset: [f64; 2], scale: f64) -> [f32; 4] { + let (lx, ly, lw, lh) = layer_rect_at(ctx, offset, scale); + let (x0, y0) = (lx + 0.5, ly + 0.5); + [x0 as f32, y0 as f32, (lx + lw - 0.5).max(x0) as f32, (ly + lh - 0.5).max(y0) as f32] +} + pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; let metal = pr.e("reflectionType") == 1; let shading = pr.e("shadingType"); let list = bubble_list(ctx, &b); + let [x0, y0, x1, y1] = bubble_sample_bounds(ctx, b.offset, b.scale); let src = b.img.clone(); let mut out = Image::new(src.width, src.height); raster( @@ -182,7 +193,8 @@ pub(super) fn bubbles(ctx: &EffectCtx, mut b: Buf) -> Buf { // Liquid refracts (inverted, magnified), metal reflects (wide-angle mirror). let k = if metal { 1.6 } else { -0.7 }; let nz = (1.0 - d2).max(0.0).sqrt(); - let p = src.sample_bilinear_clamped((q.sx + dx * q.r * k) as f64, (q.sy + dy * q.r * k) as f64); + let (sx, sy) = ((q.sx + dx * q.r * k).max(x0).min(x1), (q.sy + dy * q.r * k).max(y0).min(y1)); + let p = src.sample_bilinear_clamped(sx as f64, sy as f64); let (c, sa) = unpremul(p); let mut c = if sa <= 0.0 { [0.6, 0.7, 0.8] } else { c }; let mut a = cov; diff --git a/crates/effects/tests/it/sim_golden.rs b/crates/effects/tests/it/sim_golden.rs index 3932418ad..388790901 100644 --- a/crates/effects/tests/it/sim_golden.rs +++ b/crates/effects/tests/it/sim_golden.rs @@ -441,10 +441,10 @@ const GOLDEN: &[(&str, u64)] = &[ ("ec.sim.ccbubbles/0/32bpc/t1.5", 0xd09565e124d8f5db), ("ec.sim.ccbubbles/0/32bpc/t0.8", 0x91242f8e1fd0ad84), ("ec.sim.ccbubbles/0/32bpc/t2.2", 0x1f9782131e62149d), - ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x931b4ca707604220), - ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x21c25d4555abebe9), - ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0x7e05972683359632), - ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0xadc4f7219dab4921), + ("ec.sim.ccbubbles/0/8bpc/half/t0.5", 0x020755e48bdf8f43), + ("ec.sim.ccbubbles/0/8bpc/half/t1.5", 0x4aac0f1332e6001e), + ("ec.sim.ccbubbles/0/8bpc/half/t0.8", 0xdc2ab5e04c4a95a2), + ("ec.sim.ccbubbles/0/8bpc/half/t2.2", 0x30f78adc5d1dd715), ("ec.sim.ccbubbles/1/8bpc/t0.5", 0x2f5415c679ff0980), ("ec.sim.ccbubbles/1/8bpc/t1.5", 0xfcfc354dcd22e6d0), ("ec.sim.ccbubbles/1/8bpc/t0.8", 0xdf90fd57c217019c), diff --git a/crates/gpu/src/fx_sim.rs b/crates/gpu/src/fx_sim.rs index a2d2c24dd..944ca6c07 100644 --- a/crates/gpu/src/fx_sim.rs +++ b/crates/gpu/src/fx_sim.rs @@ -253,6 +253,7 @@ fn bubbles(e: &mut Enc, ctx: &EffectCtx, b: GBuf, geo: &effectcraft_effects::Buf } let mut p = Params::default(); p.u[1] = [0, (ctx.params.e("reflectionType") == 1) as u32, ctx.params.e("shadingType"), 0]; + p.f[0] = effectcraft_effects::bubble_sample_bounds(ctx, b.offset, b.scale); let img = raster(e, items, 1, false, &b.img, None, p, w, h)?; Some(GBuf { img, ..b }) } diff --git a/crates/gpu/src/shaders/fx_sim.wgsl b/crates/gpu/src/shaders/fx_sim.wgsl index 8ef3035fe..0c6023f32 100644 --- a/crates/gpu/src/shaders/fx_sim.wgsl +++ b/crates/gpu/src/shaders/fx_sim.wgsl @@ -165,7 +165,8 @@ fn fxm_sprite(at: u32, x: f32, y: f32) -> FxmShade { return FxmShade(true, vec4(rgb * a, a)); } -// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type. +// CC Bubbles' bubble (sim::bubbles). u[1].y = metal, u[1].z = shading type, f[0] = the layer +// rectangle's first and last pixel centres (x0, y0, x1, y1), which refraction samples stay within. fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { let qx = data[at + 1u]; let qy = data[at + 2u]; @@ -180,7 +181,9 @@ fn fxm_bubble(at: u32, x: f32, y: f32) -> FxmShade { } let k = select(-0.7, 1.6, P.u[1].y != 0u); let nz = sqrt(max(1.0 - d2, 0.0)); - let s = fxm_unpremul(sample_bilinear_clamped(src, data[at + 4u] + dx * qr * k, data[at + 5u] + dy * qr * k)); + let sx = min(max(data[at + 4u] + dx * qr * k, P.f[0].x), P.f[0].z); + let sy = min(max(data[at + 5u] + dy * qr * k, P.f[0].y), P.f[0].w); + let s = fxm_unpremul(sample_bilinear_clamped(src, sx, sy)); var c = s.xyz; if (s.w <= 0.0) { c = vec3(0.6, 0.7, 0.8); diff --git a/crates/gpu/src/tests/fx_sim.rs b/crates/gpu/src/tests/fx_sim.rs index dc2510bb3..4f54e07b6 100644 --- a/crates/gpu/src/tests/fx_sim.rs +++ b/crates/gpu/src/tests/fx_sim.rs @@ -105,6 +105,34 @@ fn bubbles_drizzle_mercury() { case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0); } +/// CC Bubbles on a buffer with transparent padding around the layer: both sides clamp the +/// refraction samples to the layer rectangle, not to the buffer (#2024). +#[test] +fn bubbles_on_padded_buffers() { + let Some(g) = gpu() else { return }; + let spec = effectcraft_effects::find("ec.sim.ccbubbles").unwrap(); + let size = [70.0, 44.0]; + for reflection in [0, 1] { + let mut params = + effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in [("bubbleAmount", n(80.0)), ("bubbleSize", n(4.0)), ("reflectionType", e(reflection))] { + params.values.insert(k.to_string(), v); + } + for scale in [1.0, 0.5] { + let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32); + let mut buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale }; + buf.pad(6); + let ctx = || effectcraft_effects::EffectCtx { params: ¶ms, time: 0.5, layer_size: size, seed: 11, adjustment: false, env: Default::default() }; + let cpu = (spec.render)(&ctx(), buf.clone()); + let out = + effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect"); + assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "geometry"); + let d = diff(&cpu.img, &out.img, 1e-3); + assert!(d.over == 0, "reflection {reflection} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", d.over, d.total, d.max, d.worst); + } + } +} + #[test] fn hair_wave_world_foam() { case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0); From 63ec5f2aad6d865cd3cb75428a1f222e32753252 Mon Sep 17 00:00:00 2001 From: erythrofr <20369086+erythrofr@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:39:56 +0100 Subject: [PATCH 03/10] Export: apply crop once when calculating resize dimensions Calculate frame_size from the original rendered dimensions and effective render scale before cropping, so locked-aspect resize does not apply the crop margins a second time in its dimension calculation. Add dimension and decoded-PNG regressions for crop, resize, padding, scaled ROI, both interpolation qualities, and zero or extreme requested sizes. AI assistance: OpenAI Codex (GPT-6). --- crates/export/src/pipeline.rs | 84 ++++++++++++++++++++++- crates/export/tests/it/render_settings.rs | 23 +++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index c639ebad1..7b9e4111c 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -431,12 +431,13 @@ impl<'a> Cx<'a> { fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); + // frame_size applies crop itself: use the original frame and the same scale as edges. + let (w, h) = om.frame_size(img.width, img.height, scale); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { return img; } - let (w, h) = om.frame_size(img.width, img.height, 1.0); if (w, h) == (img.width, img.height) { return img; } @@ -1062,6 +1063,87 @@ mod tests { } } + #[test] + fn crop_resize_applies_margins_once() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let output = OutputModule { + crop: Crop { enabled: true, left: 480, right: 480, ..Default::default() }, + resize: Resize { enabled: true, width: 1280, lock_aspect: true, quality: ResizeQuality::Low, ..Default::default() }, + ..Default::default() + }; + assert_eq!(output.frame_size(1920, 1080, 1.0), (1280, 1440)); + let img = run_crop_resize(Image::new(1920, 1080), &output, 1.0); + assert_eq!((img.width, img.height), (1280, 1440)); + assert_eq!(img.data.len(), 1280 * 1440); + } + + fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image { + use effectcraft_project::render_queue::RenderSettings; + let project = Project::default(); + let settings = RenderSettings { resolution: scale, ..Default::default() }; + let job = Job { + project: &project, + footage: &effectcraft_render::NoFootage, + expr: None, + accel: None, + comp: effectcraft_project::ItemId(0), + settings: &settings, + output, + path: "unused.png", + sink: None, + nested_switches: true, + options: Default::default(), + }; + Cx::new(&job).crop_resize(img) + } + + #[test] + fn crop_resize_dimensions_match_the_output_module() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + let edges = Crop { enabled: true, left: 2, right: 2, ..Default::default() }; + let padding = Crop { enabled: true, left: -2, bottom: -2, ..Default::default() }; + let roi = |rect| Crop { enabled: true, use_roi: true, roi: Some(rect), ..Default::default() }; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + let resize = |width| Resize { enabled: true, width, height: 1, lock_aspect: true, quality }; + for (crop, resize, scale, expected) in [ + (Crop::default(), Resize::default(), 1.0, (8, 6)), + (edges, Resize::default(), 1.0, (4, 6)), + (Crop::default(), resize(4), 1.0, (4, 3)), + (edges, resize(8), 1.0, (8, 12)), + (padding, resize(5), 1.0, (5, 4)), + (roi([4.0, 2.0, 8.0, 6.0]), resize(8), 0.5, (8, 6)), + (roi([1.0, 0.5, 2.0, 1.5]), resize(8), 2.0, (8, 6)), + (edges, Resize { lock_aspect: false, height: 2, ..resize(5) }, 1.0, (5, 2)), + ] { + let output = OutputModule { crop, resize, ..Default::default() }; + assert_eq!(output.frame_size(8, 6, scale), expected); + let img = run_crop_resize(Image::filled(8, 6, [0.25, 0.0, 0.0, 0.5]), &output, scale); + assert_eq!((img.width, img.height), expected, "{crop:?}, {resize:?}, scale {scale}"); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + } + } + } + + #[test] + fn crop_resize_zero_and_extreme_sizes_stay_bounded() { + use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; + for quality in [ResizeQuality::Low, ResizeQuality::High] { + for (size, crop, width, height, expected) in [ + ((8, 6), Crop::default(), 0, 0, (1, 1)), + ((8, 1), Crop::default(), u32::MAX, 1, (30_000, 1)), + ((1, 8), Crop::default(), 1, u32::MAX, (1, 30_000)), + ((8, 6), Crop { enabled: true, left: 20_000, right: 20_000, ..Default::default() }, 2, 3, (2, 3)), + ((0, 0), Crop { enabled: true, left: 1, top: 1, ..Default::default() }, 2, 3, (2, 3)), + ] { + let output = OutputModule { crop, resize: Resize { enabled: true, width, height, lock_aspect: false, quality }, ..Default::default() }; + let img = run_crop_resize(Image::new(size.0, size.1), &output, 1.0); + assert_eq!((img.width, img.height), expected); + assert_eq!(img.data.len(), expected.0 as usize * expected.1 as usize); + assert!(img.data.iter().all(|p| *p == [0.0; 4])); + } + } + } + #[test] fn crop_and_pad() { let mut img = Image::new(4, 3); diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 465d8e5fe..c2d7dcbfa 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -174,6 +174,29 @@ fn crop_roi_and_resize() { assert_eq!((z.width(), z.height()), (32, 24)); } +#[test] +fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() { + let (p, cid) = project(FrameRate::new(25, 1), |_, _| {}); + for (crop, scale, expected) in [ + (Crop { enabled: true, left: 16, right: 16, ..Default::default() }, 1.0, (32, 48)), + (Crop { enabled: true, left: -4, top: -2, ..Default::default() }, 1.0, (68, 50)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 0.5, (16, 12)), + (Crop { enabled: true, use_roi: true, roi: Some([0.0, 24.0, 32.0, 24.0]), ..Default::default() }, 2.0, (64, 48)), + ] { + let settings = RenderSettings { resolution: scale, ..custom(0.4, 0.44) }; + let mut output = OutputModule { crop, ..OutputModule::for_format(OutputFormat::PngSequence) }; + let reference = png(&run(&p, cid, &settings, &output, "/c/reference_[#####].png"), 0); + assert_eq!(reference.dimensions(), expected); + assert!(reference.pixels().any(|p| p.0[0] > 150), "the reference contains visible content"); + for quality in [ResizeQuality::Low, ResizeQuality::High] { + output.resize = Resize { enabled: true, width: expected.0, height: 1, lock_aspect: true, quality }; + let actual = png(&run(&p, cid, &settings, &output, "/c/resized_[#####].png"), 0); + assert_eq!(actual.dimensions(), expected); + assert!(actual == reference, "crop {crop:?}, scale {scale}, quality {quality:?}: pixels changed"); + } + } +} + #[test] fn alpha_only_and_premultiplied() { let (p, cid) = project(FrameRate::new(25, 1), |p, comp| { From 3bc1caaedbf53dac566ecd677b84d9400bae18ae Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 19:54:11 +0200 Subject: [PATCH 04/10] Shapes: per-corner roundness for Rectangle Path (#1896) Add Per-Corner Roundness to Rectangle Path: a checkbox (off by default) and four Pixels properties (top-left, top-right, bottom-right, bottom-left). New rectangles initialise the four values from their Roundness, so ticking the box keeps the shape; the radii are animatable and expression-driven.\n\nrect_radii() builds the path clockwise from the top-left with kappa quarter-circle arcs. Overlapping radii scale down like CSS border-radius; negative and non-finite radii are square; rect() delegates to it, so uniform roundness clamps to half the shorter side as before.\n\nLottie rc has one r: four equal static radii export as that value; mixed or animated radii export roundness 0 with a warning. Import puts r into the four per-corner values so ticking the box on an imported rectangle keeps the shape.\n\nTests: path geometry and clamping, rendered per-corner pixels, Lottie equal/mixed/checkbox-off export. --- crates/engine/src/cmd/paths.rs | 65 +++++++++++----- crates/engine/src/tests/stubs.rs | 64 +++++++++++++++ crates/lottie/src/shapes.rs | 22 +++++- crates/lottie/src/tests.rs | 36 +++++++++ crates/path/src/lib.rs | 77 +++++++++++++++++++ crates/project/src/build.rs | 7 ++ crates/render/src/shapes.rs | 15 +++- crates/render/src/shapes/tests.rs | 30 ++++++++ crates/ui-egui/src/panels/timeline/tests.rs | 5 ++ crates/ui-egui/tests/it/ui_timeline_shapes.rs | 5 ++ 10 files changed, 304 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 0bb2eaae2..7cd457640 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -5,6 +5,7 @@ use effectcraft_keyframe::{ShapePath, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{FeatherFalloff, GroupKind, ItemId, LayerId, LayerSource, MaskMotionBlur, Node, PropGroup, Uid}; +use effectcraft_render::EvalCtx; use serde_json::{Value, json}; use super::layer_menu::target_masks; @@ -111,18 +112,30 @@ fn roto_bezier(s: &mut Session, p: &Value) -> Result { /// The parametric shape paths that Convert To Bezier Path replaces with an editable path. const PARAMETRIC: [&str; 3] = ["rect", "ellipse", "star"]; -/// The path a parametric shape group draws, in the group's own space, read at `lt`. This is the -/// geometry the renderer builds from the same values (`render::shapes::collect`), so the converted -/// path is the shape that was on screen rather than an approximation of it; the defaults match -/// that code for a property a group doesn't carry (a polygon has no inner radius or roundness). -fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option { - let f = |id: &str, d: f64| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_f64()); - let v2 = |id: &str, d: [f64; 2]| g.prop(id).map_or(d, |pr| pr.value_at(lt).as_vec2()); +/// The path a parametric shape group draws, in the group's own space. This is the geometry the +/// renderer builds from the same values (`render::shapes::collect`), expressions included, so the +/// converted path is the shape that was on screen rather than an approximation of it; the defaults +/// match that code for a property a group doesn't carry (a polygon has no inner radius or +/// roundness). +fn parametric_path(ctx: &EvalCtx, layer: &effectcraft_project::Layer, g: &PropGroup) -> Option { + let f = |id: &str, d: f64| ctx.f(layer, g, id, d); + let v2 = |id: &str, d: [f64; 2]| ctx.v2(layer, g, id, d); let path = match g.match_id.as_str() { - "rect" => effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)), + "rect" => { + let (size, pos) = (v2("size", [100.0; 2]), v2("position", [0.0; 2])); + if ctx.b(layer, g, "perCorner") { + effectcraft_path::rect_radii( + size, + pos, + [f("topLeftRoundness", 0.0), f("topRightRoundness", 0.0), f("bottomRightRoundness", 0.0), f("bottomLeftRoundness", 0.0)], + ) + } else { + effectcraft_path::rect(size, pos, f("roundness", 0.0)) + } + } "ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])), "star" => effectcraft_path::polystar(effectcraft_path::Polystar { - star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, + star: ctx.e(layer, g, "type") == 0, points: f("points", 5.0), pos: v2("position", [0.0; 2]), rotation: f("rotation", 0.0), @@ -160,28 +173,40 @@ fn convert_to_bezier(s: &mut Session, p: &Value) -> Result { } let t = s.time(); let n = shapes.len(); + // Read the geometry before editing, with the same expression-aware context the renderer + // uses: an expression-driven corner radius, size or position must convert to what is on + // screen, not to the property's underlying value. + let expr = s.expr.clone(); + let prepared = { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; + let mut prepared = Vec::new(); + for (lid, uid) in &shapes { + let Some(l) = comp.layer(*lid) else { continue }; + let Some(old) = l.props.sub("contents").and_then(|c| c.find_group(*uid)) else { continue }; + let Some(path) = parametric_path(&ctx, l, old) else { continue }; + // Path Direction is the shape's own, not a default: reversing is a separate command. + prepared.push((*lid, *uid, path, ctx.e(l, old, "direction"), old.enabled)); + } + prepared + }; let made = s.edit("Convert To Bezier Path", None, |proj, st| { let mut next = proj.next_id; let mut made = Vec::new(); - for (lid, uid) in &shapes { - let l = layer_mut(proj, cid, *lid)?; - let lt = l.layer_time(t); + for (lid, uid, path, direction, enabled) in prepared { + let l = layer_mut(proj, cid, lid)?; let contents = l.props.sub_mut("contents").ok_or_else(|| bad("path.convertToBezier", "not a shape layer"))?; - let Some(old) = contents.find_group(*uid) else { continue }; - let Some(path) = parametric_path(old, lt) else { continue }; - // Path Direction is the shape's own, not a default: reversing is a separate command. - let direction = old.prop("direction").map_or(0, |pr| pr.value_at(lt).as_enum()); - let enabled = old.enabled; let mut g = build::shape_path(&mut Ids(&mut next), path); if let Some(pr) = g.prop_mut("direction") { pr.value = KV::Enum(direction); } g.enabled = enabled; let new_uid = g.uid; - let parent = contents.parent_of_mut(*uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?; - let Some(at) = parent.children.iter().position(|c| c.uid() == *uid) else { continue }; + let parent = contents.parent_of_mut(uid).ok_or_else(|| bad("path.convertToBezier", "no such shape item"))?; + let Some(at) = parent.children.iter().position(|c| c.uid() == uid) else { continue }; parent.children[at] = Node::Group(g); - made.push((*lid, new_uid)); + made.push((lid, new_uid)); } proj.next_id = next; // The new paths are what is selected, so the vertices can be edited straight away. diff --git a/crates/engine/src/tests/stubs.rs b/crates/engine/src/tests/stubs.rs index 9735f2c08..087a4ae1a 100644 --- a/crates/engine/src/tests/stubs.rs +++ b/crates/engine/src/tests/stubs.rs @@ -585,6 +585,70 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() { assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys)); } +/// #1896: Convert To Bezier Path reads Per-Corner Roundness, not just the uniform Roundness, so +/// the editable path is the shape that was on screen. +#[test] +fn convert_to_bezier_honours_per_corner_roundness() { + let mut s = comp(); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap(); + let (uid, _) = first_shape(&s, l); + let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid; + let per_corner = uid_of(&s, "perCorner"); + let top_left = uid_of(&s, "topLeftRoundness"); + let bottom_right = uid_of(&s, "bottomRightRoundness"); + for (u, v) in [(per_corner, json!(true)), (top_left, json!(20.0)), (bottom_right, json!(10.0))] { + s.execute("prop.set", json!({"layer": l, "path": format!("@{u}"), "value": v})).unwrap(); + } + + s.state.selected_props = vec![(LayerId(l), uid)]; + s.execute("path.convertToBezier", json!({})).unwrap(); + let (new_uid, now) = first_shape(&s, l); + assert_eq!(now, "path"); + let path = shape_path_of(&s, l, new_uid); + let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 10.0, 0.0])).into_iter().next().unwrap(); + assert_eq!(path, expected, "the converted path has the per-corner geometry"); +} + +/// #1896: Convert To Bezier Path evaluates the shape's expressions, so an expression-driven +/// Per-Corner checkbox and radius convert to the geometry on screen. +#[test] +fn convert_to_bezier_evaluates_corner_expressions() { + struct Corners; + impl effectcraft_render::ExprHost for Corners { + fn eval( + &self, + _: &effectcraft_render::EvalCtx, + _: &effectcraft_project::Layer, + prop: &effectcraft_project::Property, + value: &KV, + ) -> std::result::Result { + Ok(match prop.match_id.as_str() { + "perCorner" => KV::Bool(true), + "topLeftRoundness" => KV::Scalar(20.0), + _ => value.clone(), + }) + } + } + + let mut s = comp(); + s.expr = Some(Arc::new(Corners)); + let l = s.execute("layer.newShape", json!({"kind": "rect", "size": [120, 80]})).unwrap()["layer"].as_u64().unwrap(); + let (uid, _) = first_shape(&s, l); + let uid_of = |s: &Session, id: &str| layer(s, l).props.sub("contents").unwrap().find_group(uid).unwrap().get(id).unwrap().uid; + for (id, expression) in [("perCorner", "true"), ("topLeftRoundness", "20")] { + let u = uid_of(&s, id); + s.execute("prop.setExpression", json!({"layer": l, "path": format!("@{u}"), "expression": expression})).unwrap(); + } + + s.state.selected_props = vec![(LayerId(l), uid)]; + s.execute("path.convertToBezier", json!({})).unwrap(); + let (new_uid, now) = first_shape(&s, l); + assert_eq!(now, "path"); + let path = shape_path_of(&s, l, new_uid); + let expected = effectcraft_path::from_kurbo(&effectcraft_path::rect_radii([120.0, 80.0], [0.0, 0.0], [20.0, 0.0, 0.0, 0.0])).into_iter().next().unwrap(); + assert_eq!(path, expected, "the expression-evaluated corner is converted"); +} + /// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a /// shared merge key; one undo must revert the whole drag, not the last frame. #[test] diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index 990a0f28b..fc823b775 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -113,6 +113,9 @@ impl Out<'_, '_> { fn f(&self, m: &str) -> f64 { self.g.get(m).map(|p| p.value.as_f64()).unwrap_or(0.0) } + fn flag(&self, m: &str) -> bool { + self.g.get(m).map(|p| p.value.as_bool()).unwrap_or(false) + } fn animated(&self, m: &str) -> bool { self.g.get(m).is_some_and(|p| p.is_animated() || p.has_expression()) } @@ -232,7 +235,19 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve dir(&mut o); o.put("s", "size", &anim::arr2); o.put("p", "position", &anim::arr2); - o.put("r", "roundness", &anim::num); + if o.flag("perCorner") { + let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"]; + let radii = names.map(|m| o.f(m)); + // Lottie `rc` has one `r`: four equal static radii export as that value. + if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 { + o.raw("r", json!(r(radii[0]))); + } else { + o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label)); + o.raw("r", json!(0)); + } + } else { + o.put("r", "roundness", &anim::num); + } } "el" => { dir(&mut o); @@ -447,6 +462,11 @@ pub(crate) fn import_items(im: &mut Im, ids: &mut Ids, items: &[Json]) -> Vec { diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 203f04c04..24b4690eb 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -896,3 +896,39 @@ fn muted_keys_are_omitted_from_the_export() { assert!((opacity_at(&p, cid, 1.0) - 150.0).abs() < 1e-6); assert!((opacity_at(&q, nc, 1.0) - 150.0).abs() < 1e-6); } + +#[test] +fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { + let shape = |radii: [f64; 4], roundness: f64, per_corner: bool| { + let (mut p, cid) = setup(); + let l = shape_layer(&mut p, cid, move |ids| { + let mut g = build::shape_rect(ids, [100.0, 50.0], [0.0, 0.0], roundness); + g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner); + for (m, v) in + [("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])] + { + g.prop_mut(m).unwrap().value = Value::Scalar(v); + } + let fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); + vec![build::shape_group(ids, "Rectangle 1", vec![g, fill])] + }); + push(&mut p, cid, l); + (p, cid) + }; + let rc = |json: &Json| -> Json { json["layers"][0]["shapes"][0]["it"].as_array().unwrap().iter().find(|i| i["ty"] == "rc").cloned().unwrap() }; + // Four equal static radii: `rc.r` carries the value, no warning. + let (p, cid) = shape([12.0; 4], 5.0, true); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"], json!(12.0)); + assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); + // Mixed radii: not representable, roundness 0 exported with a warning. + let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, true); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"], json!(0)); + assert!(warnings.iter().any(|w| w.contains("per-corner roundness")), "{warnings:?}"); + // The checkbox off exports the single Roundness and ignores the four values. + let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, false); + let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); + assert_eq!(rc(&json)["r"]["k"], json!(5.0)); + assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); +} diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 83e2102dc..61fd0e6c3 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -164,6 +164,52 @@ pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath { p } +/// Rectangle path centred at `pos` with independent corner radii `[top-left, top-right, +/// bottom-right, bottom-left]`, clockwise in screen coordinates. Negative and non-finite radii +/// are 0; radii that would overlap are scaled down as CSS `border-radius` does. +pub fn rect_radii(size: [f64; 2], pos: [f64; 2], radii: [f64; 4]) -> BezPath { + // Start at the end of the top-right arc on the right edge, like `rect`, and walk the corners + // clockwise: Trim Paths measures from the first point, so ticking per-corner roundness must + // not make a trim jump (#1896). + let w = if size[0].is_finite() { size[0].abs() } else { 0.0 }; + let h = if size[1].is_finite() { size[1].abs() } else { 0.0 }; + let (x0, y0) = (pos[0] - w / 2.0, pos[1] - h / 2.0); + let (x1, y1) = (pos[0] + w / 2.0, pos[1] + h / 2.0); + let mut r = radii.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // CSS border-radius: when two radii on an edge overlap, scale every radius by the smallest + // edge ratio (a uniform radius then clamps to half the shorter side, as before). + let ratio = |a: f64, b: f64, edge: f64| if a + b > edge && a + b > 0.0 { (edge / (a + b)).min(1.0) } else { 1.0 }; + let f = ratio(r[0], r[1], w).min(ratio(r[3], r[2], w)).min(ratio(r[0], r[3], h)).min(ratio(r[1], r[2], h)); + if f < 1.0 { + for v in &mut r { + *v *= f; + } + } + let [tl, tr, br, bl] = r; + // Quarter-circle Bezier control distance. + const K: f64 = 0.552_284_749_830_793_6; + let mut p = BezPath::new(); + p.move_to((x1, y0 + tr)); + p.line_to((x1, y1 - br)); + if br > 0.0 { + p.curve_to((x1, y1 - br + K * br), (x1 - br + K * br, y1), (x1 - br, y1)); + } + p.line_to((x0 + bl, y1)); + if bl > 0.0 { + p.curve_to((x0 + bl - K * bl, y1), (x0, y1 - bl + K * bl), (x0, y1 - bl)); + } + p.line_to((x0, y0 + tl)); + if tl > 0.0 { + p.curve_to((x0, y0 + tl - K * tl), (x0 + tl - K * tl, y0), (x0 + tl, y0)); + } + p.line_to((x1 - tr, y0)); + if tr > 0.0 { + p.curve_to((x1 - tr + K * tr, y0), (x1, y0 + tr - K * tr), (x1, y0 + tr)); + } + p.close_path(); + p +} + pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath { to_kurbo(&ShapePath::ellipse(pos, size[0], size[1])) } @@ -419,6 +465,11 @@ mod tests { 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:?}"); + // Per-corner roundness starts and runs in the same order: ticking the checkbox must not + // move a Trim Paths contour. + let corners = ops::trim(&[rect_radii([400.0, 300.0], [0.0, 0.0], [40.0; 4])], 0.0, 25.0, 0.0); + let (a, b) = ends(&corners[0]); + assert!(near(a, (200.0, -110.0)) && near(b, (110.0, 150.0)), "per-corner: {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]); @@ -456,6 +507,32 @@ mod tests { assert!((area - 200.0).abs() < 1.0, "{area}"); } + #[test] + fn rect_radii_uniform_matches_a_rounded_rect() { + for r in [0.0, 5.0, 25.0, 100.0] { + let p = rect_radii([100.0, 50.0], [10.0, 20.0], [r; 4]); + let clamped = r.min(25.0); + let expect = 100.0 * 50.0 - (4.0 - std::f64::consts::PI) * clamped * clamped; + assert!((p.area().abs() - expect).abs() < 1.0, "{r}: {} != {expect}", p.area()); + assert!((p.area() - rect([100.0, 50.0], [10.0, 20.0], r).area()).abs() < 1e-6); + } + } + + #[test] + fn rect_radii_mixes_corners_and_scales_overlaps() { + // Only the top-left and bottom-right corners round. + let p = rect_radii([100.0, 50.0], [0.0, 0.0], [10.0, 0.0, 20.0, 0.0]); + let expect = 100.0 * 50.0 - (1.0 - std::f64::consts::PI / 4.0) * (10.0 * 10.0 + 20.0 * 20.0); + assert!((p.area().abs() - expect).abs() < 1.0, "{}", p.area()); + // Radii wider than the top edge scale down together, like CSS border-radius. + let p = rect_radii([50.0, 100.0], [0.0, 0.0], [40.0, 40.0, 0.0, 0.0]); + let scaled = rect_radii([50.0, 100.0], [0.0, 0.0], [25.0, 25.0, 0.0, 0.0]); + assert!((p.area() - scaled.area()).abs() < 1e-9); + // Negative and non-finite radii are square corners. + let p = rect_radii([50.0, 50.0], [0.0, 0.0], [-5.0, f64::NAN, f64::INFINITY, 0.0]); + assert!((p.area().abs() - 2500.0).abs() < 1e-9); + } + #[test] fn stroke_has_area() { let r = rect([40.0, 40.0], [50.0, 50.0], 0.0); diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 3ff61ba7d..1aa8d7c90 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -385,11 +385,18 @@ pub fn shape_group(ids: &mut Ids, name: &str, items: Vec) -> PropGrou } pub fn shape_rect(ids: &mut Ids, size: [f64; 2], position: [f64; 2], roundness: f64) -> PropGroup { + // Per-corner radii start from the uniform Roundness, so ticking the box keeps the shape. + let r = if roundness.is_finite() { roundness.max(0.0) } else { 0.0 }; indexed(ids.group("rect", "Rectangle Path 1")) .with(ids.prop("direction", "Path Direction", Value::Enum(0)).with_ui(ParamUi::Hidden)) .with(ids.prop("size", "Size", Value::Vec2(size)).with_ui(ParamUi::Pixels)) .with(ids.prop("position", "Position", Value::Vec2(position)).with_ui(ParamUi::Point)) .with(ids.prop("roundness", "Roundness", Value::Scalar(roundness)).with_ui(ParamUi::Pixels)) + .with(ids.prop("perCorner", "Per-Corner Roundness", Value::Bool(false)).with_ui(ParamUi::Checkbox)) + .with(ids.prop("topLeftRoundness", "Top Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("topRightRoundness", "Top Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("bottomRightRoundness", "Bottom Right Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) + .with(ids.prop("bottomLeftRoundness", "Bottom Left Roundness", Value::Scalar(r)).with_ui(ParamUi::Pixels)) } pub fn shape_ellipse(ids: &mut Ids, size: [f64; 2], position: [f64; 2]) -> PropGroup { diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index bbc4283ff..2497ea807 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -269,7 +269,20 @@ fn collect_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Ve "rect" => { let size = ctx.v2(layer, g, "size", [100.0; 2]); let pos = ctx.v2(layer, g, "position", [0.0; 2]); - let p = effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)); + let p = if ctx.b(layer, g, "perCorner") { + effectcraft_path::rect_radii( + size, + pos, + [ + ctx.f(layer, g, "topLeftRoundness", 0.0), + ctx.f(layer, g, "topRightRoundness", 0.0), + ctx.f(layer, g, "bottomRightRoundness", 0.0), + ctx.f(layer, g, "bottomLeftRoundness", 0.0), + ], + ) + } else { + effectcraft_path::rect(size, pos, ctx.f(layer, g, "roundness", 0.0)) + }; push_shape(ctx, layer, g, p, arena, &mut live); } "ellipse" => { diff --git a/crates/render/src/shapes/tests.rs b/crates/render/src/shapes/tests.rs index e0c9086a7..ba7028a25 100644 --- a/crates/render/src/shapes/tests.rs +++ b/crates/render/src/shapes/tests.rs @@ -715,3 +715,33 @@ fn miter_corner_still_fits_its_buffer() { let b = shape_buf(&p, cid); assert!(b.img.height as f64 >= 50.0 + 20.0 * 4.0 * 0.5, "{}", b.img.height); } + +#[test] +fn rect_per_corner_roundness_rounds_the_named_corners() { + let render = |per_corner: bool, radii: [f64; 4], roundness: f64| { + let (p, cid) = shape_comp_sized(200, 100, move |ids| { + let mut g = build::shape_rect(ids, [100.0, 100.0], [0.0, 0.0], roundness); + g.prop_mut("perCorner").unwrap().value = Value::Bool(per_corner); + for (m, v) in + [("topLeftRoundness", radii[0]), ("topRightRoundness", radii[1]), ("bottomRightRoundness", radii[2]), ("bottomLeftRoundness", radii[3])] + { + g.prop_mut(m).unwrap().value = Value::Scalar(v); + } + vec![g, build::shape_fill(ids, RED)] + }); + frame(&p, cid) + }; + // Only the top-left corner rounds: its corner pixels fade, the other three stay filled. + let img = render(true, [30.0, 0.0, 0.0, 0.0], 0.0); + assert!(alpha(&img, 52, 2) < 0.1, "top-left rounds: {}", alpha(&img, 52, 2)); + for (x, y) in [(148, 2), (148, 98), (52, 98)] { + assert!(alpha(&img, x, y) > 0.9, "({x},{y}) stays square: {}", alpha(&img, x, y)); + } + // The checkbox off ignores the four values (the single Roundness still rules). + let off = render(false, [30.0; 4], 0.0); + assert!((coverage(&off) - 100.0 * 100.0).abs() < 20.0, "{}", coverage(&off)); + // Four equal per-corner radii render like the same Roundness. + let per = render(true, [25.0; 4], 0.0); + let uni = render(false, [0.0; 4], 25.0); + assert!((coverage(&per) - coverage(&uni)).abs() < 5.0, "{} vs {}", coverage(&per), coverage(&uni)); +} diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 12259abf7..9a5c89f7d 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -195,6 +195,11 @@ fn shape_group_inner_contents_is_elided() { " Size", " Position", " Roundness", + " Per-Corner Roundness", + " Top Left Roundness", + " Top Right Roundness", + " Bottom Right Roundness", + " Bottom Left Roundness", " >Fill 1", " Composite", " Fill Rule", diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index 73686ee99..cd56db825 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -340,6 +340,11 @@ fn timeline_colour_eyedropper_samples_the_comp() { let fill = fill.expect("a fill colour"); fn groups(g: &effectcraft_engine::project::PropGroup, out: &mut Vec) { for c in g.groups() { + // The rectangle's path rows would push Fill below the rows the Timeline fits; the + // eyedropper only needs Fill, so leave that group collapsed. + if c.name == "Rectangle Path 1" { + continue; + } out.push(c.uid); groups(c, out); } From d249f255ae7852e23f285a473640b9720cf1819e Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 21:08:54 +0200 Subject: [PATCH 05/10] Shapes: export per-corner `rc.r` as a Lottie property The equal-radii and zero fallback branches wrote a bare number where the uniform branch (and every other Value field) writes `{a, k}`; external players may reject that form even though our importer reads it. Write `{"a": 0, "k": value}` in both branches and update the export assertions. --- crates/lottie/src/shapes.rs | 5 +++-- crates/lottie/src/tests.rs | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/lottie/src/shapes.rs b/crates/lottie/src/shapes.rs index fc823b775..5b9d29970 100644 --- a/crates/lottie/src/shapes.rs +++ b/crates/lottie/src/shapes.rs @@ -239,11 +239,12 @@ pub(crate) fn export_items(ex: &mut Ex, contents: &PropGroup, label: &str) -> Ve let names = ["topLeftRoundness", "topRightRoundness", "bottomRightRoundness", "bottomLeftRoundness"]; let radii = names.map(|m| o.f(m)); // Lottie `rc` has one `r`: four equal static radii export as that value. + // `r` is a property (`{a, k}`) like the uniform branch writes, not a bare number. if radii.iter().all(|v| (v - radii[0]).abs() < 1e-6) && !names.iter().any(|m| o.animated(m)) && radii[0] >= 0.0 { - o.raw("r", json!(r(radii[0]))); + o.raw("r", json!({"a": 0, "k": r(radii[0])})); } else { o.ex.warn(format!("{}: per-corner roundness has no Lottie rectangle equivalent (roundness 0 exported)", o.label)); - o.raw("r", json!(0)); + o.raw("r", json!({"a": 0, "k": 0})); } } else { o.put("r", "roundness", &anim::num); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 24b4690eb..83c81e242 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -919,12 +919,12 @@ fn per_corner_roundness_exports_equal_radii_and_warns_when_mixed() { // Four equal static radii: `rc.r` carries the value, no warning. let (p, cid) = shape([12.0; 4], 5.0, true); let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); - assert_eq!(rc(&json)["r"], json!(12.0)); + assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 12.0})); assert!(!warnings.iter().any(|w| w.contains("per-corner")), "{warnings:?}"); // Mixed radii: not representable, roundness 0 exported with a warning. let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, true); let (_, _, warnings, json) = roundtrip(&p, cid, &ExportOptions::default()); - assert_eq!(rc(&json)["r"], json!(0)); + assert_eq!(rc(&json)["r"], json!({"a": 0, "k": 0})); assert!(warnings.iter().any(|w| w.contains("per-corner roundness")), "{warnings:?}"); // The checkbox off exports the single Roundness and ignores the four values. let (p, cid) = shape([10.0, 20.0, 10.0, 20.0], 5.0, false); From 1c1faaa758793129571d3aaa4b244c1e0f85d70b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:26:44 +0700 Subject: [PATCH 06/10] Timeline: Shift-click adds effects and other groups to the selection (#2552) Clicking an effect, Layer Style, mask or shape item row in the Timeline always replaced the selection, so one animation preset couldn't hold several effects and styles. Shift or Cmd/Ctrl now adds the group to the selection, or takes a selected one out, as property rows and Effect Controls headers already do. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 10 ++++- crates/ui-egui/tests/it/ui_timeline_shapes.rs | 39 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index e8e771d21..8971ba5c5 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3591,7 +3591,15 @@ fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: } app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name); if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 { - actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false}))); + // Shift or Cmd/Ctrl adds the group to the selection, or takes a selected one out, as + // property rows and Effect Controls headers do (#2552: several effects and styles for + // one animation preset). + let add = ui.input(|i| i.modifiers.shift || i.modifiers.command); + if add && app.session.state.selected_props.contains(&(layer.id, *uid)) { + actions.push(("prop.deselect".into(), json!({"layer": layer.id.0, "prop": uid}))); + } else { + actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false, "add": add}))); + } } if gr.double_clicked() { if fx.is_some() { diff --git a/crates/ui-egui/tests/it/ui_timeline_shapes.rs b/crates/ui-egui/tests/it/ui_timeline_shapes.rs index 73686ee99..c9b2245f8 100644 --- a/crates/ui-egui/tests/it/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/it/ui_timeline_shapes.rs @@ -448,3 +448,42 @@ fn effects_drag_to_a_new_place_in_the_timeline() { h.state_mut().session.execute("edit.undo", json!({})).unwrap(); assert_eq!(names(&h), ["Tint", "Gaussian Blur", "Invert"], "one undo step per drop"); } + +/// #2552: Shift-clicking effect rows in the Timeline adds them to the selection (and a second +/// Shift-click takes one out), so one animation preset can hold several effects. +#[test] +fn shift_click_selects_several_effects_for_a_preset() { + let mut s = session(); + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + for fx in ["Gaussian Blur", "Invert"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let effects = s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("effects").unwrap().clone(); + let uids: Vec = effects.groups().map(|g| g.uid).collect(); + let mut h = harness(s); + h.state_mut().ui.timeline.open_groups.insert(effects.uid); + open_layer(&mut h, l); + let shift_click = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| { + let p = center(h, &format!("timeline.group.{uid}.name")); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::SHIFT)); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }); + h.step(); + } + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + }; + let selected = |h: &Harness<'_, EffectcraftApp>| h.state().session.state.selected_props.iter().map(|(_, u)| *u).collect::>(); + click(&mut h, &format!("timeline.group.{}.name", uids[0])); + assert_eq!(selected(&h), [uids[0]]); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0], uids[1]], "Shift adds the second effect"); + let path = std::env::temp_dir().join(format!("ec-2552-{}.ffx", std::process::id())); + let r = h.state_mut().session.execute("anim.savePreset", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["effects"], 2, "{r}"); + let _ = std::fs::remove_file(&path); + shift_click(&mut h, uids[1]); + assert_eq!(selected(&h), [uids[0]], "a second Shift-click takes it out"); +} From 5bb66bad745fcec1f68e0f6a28a7697ba596aec1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:37:19 +0700 Subject: [PATCH 07/10] ScriptUI: a docked panel gets its dock's size and onResize (#2585) A docked ScriptUI panel only ever saw its preferredSize: the host drew it in a scroll area at max(dock, preferred) and the script's window never learned the dock. scriptui.resize now gives a window a size: the script's window takes it, lays itself out again and runs onResizing / onResize. The dock host sends it whenever the panel's dock size changes, so window.size is the dock's, as in After Effects. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/scripts.rs | 9 ++++++ crates/engine/src/scriptui.rs | 11 ++++++++ crates/script/src/scriptui.js | 12 ++++++++ crates/ui-egui/src/panels/scriptui_view.rs | 11 ++++++++ crates/ui-egui/tests/it/ui_scriptui.rs | 32 ++++++++++++++++++++++ 5 files changed, 75 insertions(+) diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index a523770e3..a92d484a7 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -467,6 +467,15 @@ pub fn specs() -> Vec { always, scriptui::mouse ), + cmd!( + "scriptui.resize", + "Resize Script Window", + [], + None, + "{window?, size: [width, height] (pixels)} → the window takes the size, lays itself out again and its onResizing / onResize run (a docked ScriptUI panel gets its dock's size)", + always, + scriptui::resize + ), cmd!( "scriptui.close", "Close Script Window", diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index af89932fa..d8f9f1d1e 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -765,6 +765,17 @@ pub(crate) fn mouse(s: &mut Session, p: &Value) -> Result { dispatch(s, ScriptUiEvent { window, widget, kind: (*ty).to_string(), value }) } +/// `scriptui.resize`: the host gave the window a new size (a docked panel's dock, #2585): the +/// script's window takes it, lays itself out again and gets `onResizing` / `onResize`. +pub(crate) fn resize(s: &mut Session, p: &Value) -> Result { + let c = "scriptui.resize"; + let window = window_p(s, p, c)?; + let size = p.get("size").and_then(Value::as_array).ok_or_else(|| crate::commands::bad(c, "missing `size` [width, height]"))?; + let side = |i: usize| size.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.round().clamp(1.0, 16384.0)); + let (Some(w), Some(h)) = (side(0), side(1)) else { return Err(crate::commands::bad(c, "`size` must be [width, height] in pixels")) }; + dispatch(s, ScriptUiEvent { window, widget: 0, kind: "resize".into(), value: json!([w, h]) }) +} + /// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). pub(crate) fn close(s: &mut Session, p: &Value) -> Result { let window = window_p(s, p, "scriptui.close")?; diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 178bd8be1..ddfbc1f3e 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -912,6 +912,18 @@ function __uiDispatch(winId, id, kind, valueJson) { w.close(v === null ? 2 : v); return; } + // The host gave the window a new size (a docked panel's dock): take it, lay out, tell the script. + if (kind === "resize") { + var nw = Number(v && v[0]), nh = Number(v && v[1]); + var b = w.__ub || w.__lb; + if (nw > 0 && nh > 0 && (b[2] - b[0] !== nw || b[3] - b[1] !== nh)) { + w.__ub = [b[0], b[1], b[0] + nw, b[1] + nh]; + __uiLayout(w); + w.__fire("onResizing"); + w.__fire("onResize"); + } + return; + } var c = __uiFind(w, id); if (!c || c.enabled === false) return; var radio = function () { diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs index 14460ed28..753b2884e 100644 --- a/crates/ui-egui/src/panels/scriptui_view.rs +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -26,6 +26,8 @@ enum Act { Close(u32), /// A mouse event (`scriptui.mouse` parameters without window and widget). Mouse(u32, u32, Value), + /// A docked panel's new size (`scriptui.resize`). + Resize(u32, [f32; 2]), } fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect { @@ -500,6 +502,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), Act::Changing(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v, "changing": true})), Act::Close(w) => ("scriptui.close", json!({"window": w})), + Act::Resize(w, [x, y]) => ("scriptui.resize", json!({"window": w, "size": [x, y]})), Act::Mouse(w, id, mut v) => { if let Some(o) = v.as_object_mut() { o.insert("window".into(), json!(w)); @@ -582,6 +585,14 @@ pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { // A modal script dialog blocks the panels too. let modal = app.session.script_ui.windows.iter().any(|w| w.modal); let mut acts = vec![]; + // The script learns its dock's size when it changes (#2585), as After Effects tells a + // docked panel: its window takes the size and onResize runs. + let dock = [rect.width().round(), rect.height().round()]; + let told = egui::Id::unique(("scriptui-dock-size", id)); + if ui.data(|d| d.get_temp::<[f32; 2]>(told)) != Some(dock) && dock[0] >= 1.0 && dock[1] >= 1.0 { + ui.data_mut(|d| d.insert_temp(told, dock)); + acts.push(Act::Resize(id, dock)); + } let content = rect; egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover()); diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index 2669a2631..8a248155b 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -305,6 +305,38 @@ fn a_drag_keeps_its_control_past_the_window_edge() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } +/// #2585: a window the host resizes takes the size, lays out again and runs onResize; a docked +/// ScriptUI panel gets its dock's size that way. +#[test] +fn script_windows_learn_the_size_they_are_given() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Sized"); + var log = w.add("statictext", undefined, "", { name: "log" }); + log.preferredSize = [200, 20]; + w.onResize = function () { log.text = "resized " + w.size.width + "x" + w.size.height; }; + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "sized.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(2); + h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": [300, 180]})).unwrap(); + let win = h.state().session.script_ui.windows[0].clone(); + assert_eq!(win.root.lookup(&json!("log")).unwrap().text, "resized 300x180"); + let list = h.state_mut().session.execute("scriptui.list", json!({})).unwrap(); + assert_eq!(list[0]["size"], json!([300.0, 180.0]), "{list}"); + assert!(h.state_mut().session.execute("scriptui.resize", json!({"window": "Sized", "size": ["wide", 1]})).is_err()); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); + // A docked panel: its window is its dock's size, not its preferred size. + h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap(); + h.run_steps(4); + let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().clone(); + let dock = rect(&h, &format!("scriptui.{}", panel.id)); + assert_eq!([panel.root.bounds[2].round(), panel.root.bounds[3].round()], [dock.width().round() as f64, dock.height().round() as f64]); + h.state_mut().close_panel(PanelKind::ScriptPanel(panel.id)); + h.run_steps(2); +} + fn click_at(h: &mut Harness<'_, EffectcraftApp>, c: egui::Pos2) { h.input_mut().events.push(Event::PointerMoved(c)); h.run_steps(1); From df21aff0d80ab36583cb3cae3a5cf447902cb95d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:46:50 +0700 Subject: [PATCH 08/10] Docs: ROADMAP progress log and ScriptUI parity for 30 October's features Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 11 ++++++++++- docs/scripting-parity.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f3e6750ed..740ec6010 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~30 points of "ready for real work" and ~350–620 h away -> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (localization: 303 more panel/dialog rows in Japanese and the Chinese catalogs; recounted after #1774: Japanese 27% → 46%, Simplified Chinese 29% → 46%, Traditional Chinese 29% → 38%, overall 18% → 24%) · **Target:** Adobe After Effects 2026 (26.5.0.89) +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-30 · **Change:** minor (features of 30 October in the progress log: text Line Join, Timeline drag-reorder and multi-select of effects, viewer edge pan, ScriptUI drag capture and dock size; previously: localization recount after #1774) · **Target:** Adobe After Effects 2026 (26.5.0.89) EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written from scratch in Rust. This page is the one-page summary; the evidence is in @@ -117,6 +117,14 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). ## Progress log +- 2026-10-30: Text strokes take a Line Join (Miter, Round or Bevel) in the Character panel, + Miter for new text as in After Effects (#2077). In the Timeline, effects, masks and shape items + drag to a new place in their stack (#2210) and Shift-click adds them to the selection, so one + animation preset can hold several (#2552). The Composition panel pans while a mask vertex is + held at its edge (#999). Effect buffers that pad a small layer stay within a memory budget + (#2261); an MP4 lasts as long as its edit list shows (#2592); the Render Queue's times tick + between frames (#2593). ScriptUI drags keep their control past the window edge with + `MouseEvent.buttons` (#2584), and docked panels get their dock's size and `onResize` (#2585). - 2026-10-28: A shape layer's Rectangle and Ellipse Size get the Constrain Proportions chain that Scale has, so a circle stays a circle while its size is typed, scrubbed or keyed; it starts off, so existing shapes behave as before (#2489). @@ -169,6 +177,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Progress log: text Line Join, Timeline drag-reorder and Shift multi-select of effects, viewer edge pan, effect padding budget, MP4 edit-list duration, Render Queue clock, ScriptUI drag capture and docked-panel size | | 2026-10-22 | minor | Localization: 300 more panel/dialog rows in Japanese and the Chinese catalogs, plus the review's three missing rows (Basic, Advanced, Play/Stop) — panel tabs, form and dialog labels, context menus, tracker/graph/text-panel controls, File-panel footers, credits; language shares recounted after #1774 (Japanese 27% → 46% with 297 effect names, Simplified Chinese 29% → 46% with 307 effect names, Traditional Chinese 29% → 38%, overall 18% → 24%) | | 2026-10-12 | minor | Open-issue count (134 to 216), crash list and localization share (12% to 18%) updated to match docs/gaps.md | | 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges | diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 8d2934518..96e298ece 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -20,7 +20,7 @@ our own scripts; no real-world script library has been run against both apps. | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging | -| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` | +| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch; a docked panel gets its dock's size and `onResize` (`scriptui.resize`, #2585); a drag keeps reaching the control it started on past the window edge, and `MouseEvent.buttons` reports the held buttons (#2584) | good | `ui_scriptui`, `tests_ui*` | | `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay | | Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) | | Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) | From 1158022585666a11d046351cc5ca448509d83c72 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 07:53:12 +0700 Subject: [PATCH 09/10] Settings: a dragged UI Scale applies on release (#2582) Dragging an integer setting applied every step, so UI Scale resized the interface under the pointer mid-drag. A dragged value now shows in its field and applies when the button is released (a typed one already waited for Enter). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/settings.rs | 13 ++++++++---- crates/ui-egui/tests/it/ui_panels.rs | 30 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index f4dd89bfc..562a90132 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -117,15 +117,20 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value ui.add_sized(vec2(label_w, 18.0), egui::Label::new(format!("{}:", tr(label))).truncate()); let r = match kind { Kind::Int(lo, hi, unit) => { - let mut x = v.as_i64().unwrap_or(0); - // A typed number applies once entered: typing 85 into UI Scale - // doesn't resize the interface to 75 % at its first digit. + // A dragged value applies on release, and a typed one once + // entered: UI Scale doesn't resize the interface under the + // pointer mid-drag (#2582) or at a typed number's first digit. + let held = egui::Id::unique(("settings-drag", key)); + let mut x = ui.data(|d| d.get_temp::(held)).unwrap_or_else(|| v.as_i64().unwrap_or(0)); let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false); let r = ui.add(crate::widgets::drag_value(drag)); if !unit.is_empty() { ui.label(RichText::new(tr(unit)).color(t.text_dim)); } - if r.changed() { + if r.dragged() { + ui.data_mut(|d| d.insert_temp(held, x)); + } else if r.changed() || r.drag_stopped() { + ui.data_mut(|d| d.remove::(held)); acts.push(Act::Set(key.into(), json!(x))); } r diff --git a/crates/ui-egui/tests/it/ui_panels.rs b/crates/ui-egui/tests/it/ui_panels.rs index db9ef7373..6d06c6bf7 100644 --- a/crates/ui-egui/tests/it/ui_panels.rs +++ b/crates/ui-egui/tests/it/ui_panels.rs @@ -664,3 +664,33 @@ fn transition_presets_apply_from_effects_and_presets() { assert_eq!(fx.name, "Radial Wipe"); assert_eq!(fx.prop("completion").unwrap().keys.len(), 2); } + +/// #2582: dragging Settings' UI Scale doesn't resize the interface under the pointer; the value +/// applies once the button is released. +#[test] +fn ui_scale_applies_when_the_drag_ends() { + let mut h = harness(); + open_settings(&mut h); + let e = h.state().auto.find("settings.appearance.uiScale").expect("the UI Scale field"); + let from = pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + egui::vec2(10.0 * k as f32, 0.0))); + h.step(); + assert_eq!(scale(&h), (100, 1.0), "nothing applies mid-drag"); + } + h.input_mut().events.push(Event::PointerButton { + pos: from + egui::vec2(80.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(4); + let (pct, zoom) = scale(&h); + assert!(pct > 100 && (zoom - pct as f32 / 100.0).abs() < 1e-6, "applied on release: {pct} {zoom}"); + click(&mut h, "settings.cancel"); + h.run_steps(2); + assert_eq!(scale(&h), (100, 1.0)); +} From f6222ff5d5c53dc2847c47e61774b10ce4567533 Mon Sep 17 00:00:00 2001 From: erythrofr <20369086+erythrofr@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:47:31 +0100 Subject: [PATCH 10/10] Tests: document crop/resize regressions Describe the dimension, pixel-preservation and extreme-input scenarios and their synthetic-frame helper to address the review's documentation warning. Test logic and the crop/resize implementation are unchanged. AI assistance: OpenAI Codex (GPT-6). --- crates/export/src/pipeline.rs | 4 ++++ crates/export/tests/it/render_settings.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 7b9e4111c..8e8c90561 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -1063,6 +1063,7 @@ mod tests { } } + /// Regress the locked-aspect example: cropping 1920x1080 must resize to 1280x1440. #[test] fn crop_resize_applies_margins_once() { use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; @@ -1077,6 +1078,7 @@ mod tests { assert_eq!(img.data.len(), 1280 * 1440); } + /// Apply output-module geometry to a synthetic frame without rendering or writing files. fn run_crop_resize(img: Image, output: &effectcraft_project::render_queue::OutputModule, scale: f64) -> Image { use effectcraft_project::render_queue::RenderSettings; let project = Project::default(); @@ -1097,6 +1099,7 @@ mod tests { Cx::new(&job).crop_resize(img) } + /// Match predicted dimensions across crop, padding, ROI scale, aspect locks and resize qualities. #[test] fn crop_resize_dimensions_match_the_output_module() { use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; @@ -1124,6 +1127,7 @@ mod tests { } } + /// Exercise zero/empty inputs, collapsed crops and existing caps on extreme single-axis resizes. #[test] fn crop_resize_zero_and_extreme_sizes_stay_bounded() { use effectcraft_project::render_queue::{Crop, OutputModule, Resize}; diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index c2d7dcbfa..0fd6d471b 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -174,6 +174,8 @@ fn crop_roi_and_resize() { assert_eq!((z.width(), z.height()), (32, 24)); } +/// Resizing to the cropped frame's own dimensions must preserve every decoded PNG pixel. +/// Compare against crop-only output for padding and scaled ROIs with both resize qualities. #[test] fn crop_resize_preserves_pixels_when_the_cropped_size_is_requested() { let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});