From a3a72d48aafc4fea018485edf2c254688b5575b0 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:22:57 +0100 Subject: [PATCH 1/3] Transform: Fit to Comp sizes shape and text layers by their painted bounds (#2198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit layer.transform's fit / fitWidth / fitHeight ops took the layer's size from render::source_size, which returns (0, 0) for layers without a source item — shapes and text — so the ops matched nothing and silently did nothing, for the shortcut, the menu item and the command alike. When the source size is empty and a fit op runs, the size falls back to the layer's painted content bounds at the current time, and the anchor goes to the bounds' centre: content bounds live in layer space, where a shape is centred on the origin and text sits above its baseline, so (w/2, h/2) would fill the frame but land off-centre. Reset and the other ops keep the source size, so their defaults are unchanged. The regression test asserts what the viewer shows — the painted bounds mapped to comp space span [0, 0, 640, 360] after fit, and fitWidth spans the width centred vertically — for a rect shape and a text layer. Co-Authored-By: GLM-5.3-Flash --- crates/engine/src/cmd/layer/transform.rs | 32 ++++++++++++--- crates/engine/src/tests/menu_cmds.rs | 51 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 8b75dcbc8..78bd0247f 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -74,14 +74,36 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { let op = str_p(p, "op").unwrap_or("reset").to_string(); let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let t = s.time(); - let sizes: Vec<(LayerId, (u32, u32))> = - comp.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.id, effectcraft_render::source_size(&s.project, l))).collect(); + // A shape or text layer has no source item, so source_size is (0, 0): the fit ops size it by + // its painted bounds at the current time instead, and anchor at the bounds' centre — content + // bounds live in layer space, where a shape is centred on the origin and text sits above its + // baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the + // source size. + let ectx = + effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight"); + let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp + .layers + .iter() + .filter(|l| ids.contains(&l.id)) + .map(|l| { + let (w, h) = effectcraft_render::source_size(&s.project, l); + if fitting && (w == 0 || h == 0) { + match effectcraft_render::content_bounds(&ectx, l).filter(|b| b[2] > b[0] && b[3] > b[1]) { + Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]), + None => (l.id, [0.0; 2], [0.0; 2]), + } + } else { + (l.id, [w as f64, h as f64], [w as f64 / 2.0, h as f64 / 2.0]) + } + }) + .collect(); s.edit("Transform", None, |proj, _| { - for (lid, (w, h)) in &sizes { + for (lid, size, anchor) in &sizes { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); let (cw, ch) = (comp.width as f64, comp.height as f64); - let (w, h) = (*w as f64, *h as f64); + let (w, h) = (size[0], size[1]); let Some(tr) = l.transform_mut() else { continue }; let set = |tr: &mut PropGroup, m: &str, v: KV| { if let Some(pr) = tr.get_mut(m) { @@ -109,7 +131,7 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]); - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, az])); + set(tr, "anchor", KV::Vec3([anchor[0], anchor[1], az])); } "flipH" | "flipV" => { let cur = tr.get("scale").map(|p| p.value_at(lt).as_vec3()).unwrap_or([100.0; 3]); diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index eb99d809f..214c3c2b9 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1103,6 +1103,57 @@ fn transform_commands_keep_depth_and_follow_separated_dimensions() { assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre"); } +/// The layer's painted bounds mapped to comp space — what the viewer shows. +fn seen_bounds(s: &Session, lid: u64) -> [f64; 4] { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap().clone(); + let layer = comp.layer(effectcraft_project::LayerId(lid)).unwrap(); + let ectx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time()); + let b = effectcraft_render::content_bounds(&ectx, layer).expect("painted bounds"); + let m = ectx.layer_to_comp(layer).0; + let corners = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]; + let pts: Vec<[f64; 2]> = corners + .iter() + .map(|p| { + let q = m.apply(effectcraft_geom::vec2(p[0], p[1])); + [q.x, q.y] + }) + .collect(); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in &pts { + x0 = x0.min(p[0]); + y0 = y0.min(p[1]); + x1 = x1.max(p[0]); + y1 = y1.max(p[1]); + } + [x0, y0, x1, y1] +} + +#[test] +fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() { + let mut s = comp(); + let close = |x: f64, y: f64| (x - y).abs() < 1e-6; + // A shape or text layer has no source item: fit sizes it by its painted bounds, anchored at + // the bounds' centre, so what the viewer shows fills the frame (#2198). + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, a); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the rect's painted bounds fill the comp: {seen:?}"); + + let t = s.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.transform", json!({"layers": [t], "op": "fit"})).unwrap(); + let seen = seen_bounds(&s, t); + assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the text's painted bounds fill the comp: {seen:?}"); + + // fitWidth spans the comp's width and centres vertically. + for lid in [a, t] { + s.execute("layer.transform", json!({"layers": [lid], "op": "fitWidth"})).unwrap(); + let seen = seen_bounds(&s, lid); + assert!(close(seen[0], 0.0) && close(seen[2], 640.0), "fitWidth spans the comp width: {seen:?}"); + assert!(close((seen[1] + seen[3]) / 2.0, 180.0), "fitWidth centres vertically: {seen:?}"); + } +} + #[test] fn duplicated_and_pasted_layers_keep_their_links() { let mut s = comp(); From 5224414d28328c11b69d02c0529db8aa6d4ecc16 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:41:00 +0100 Subject: [PATCH 2/3] Transform: Fit to Comp refuses a scale that overflows (#2198) A shape's painted bounds can be tiny enough (a 1e-305 px rectangle) that the fit scale overflows to infinity. Non-finite bounds are skipped and a non-finite scale returns an error before anything is written, instead of storing infinity in the document. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layer/transform.rs | 6 +++++- crates/engine/src/tests/menu_cmds.rs | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 78bd0247f..ad4643ae1 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -89,7 +89,7 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { .map(|l| { let (w, h) = effectcraft_render::source_size(&s.project, l); if fitting && (w == 0 || h == 0) { - match effectcraft_render::content_bounds(&ectx, l).filter(|b| b[2] > b[0] && b[3] > b[1]) { + match effectcraft_render::content_bounds(&ectx, l).filter(|b| b.iter().all(|v| v.is_finite()) && b[2] > b[0] && b[3] > b[1]) { Some(b) => (l.id, [b[2] - b[0], b[3] - b[1]], [(b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0]), None => (l.id, [0.0; 2], [0.0; 2]), } @@ -127,6 +127,10 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { "fitHeight" => (ch / h, ch / h), _ => (cw / w, ch / h), }; + // A tiny shape's bounds can overflow the scale; refuse rather than write infinity. + if !(sx * 100.0).is_finite() || !(sy * 100.0).is_finite() { + return Err(EngineError::Other(format!("layer {} is too small to fit to the composition", lid.0))); + } let z = |m: &str, d: f64| tr.get(m).map(|p| p.value_at(lt).as_vec3()[2]).unwrap_or(d); let (sz, az) = (z("scale", 100.0), z("anchor", 0.0)); set(tr, "scale", KV::Vec3([sx * 100.0, sy * 100.0, sz])); diff --git a/crates/engine/src/tests/menu_cmds.rs b/crates/engine/src/tests/menu_cmds.rs index 214c3c2b9..93f81736f 100644 --- a/crates/engine/src/tests/menu_cmds.rs +++ b/crates/engine/src/tests/menu_cmds.rs @@ -1154,6 +1154,15 @@ fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() { } } +#[test] +fn fit_to_comp_refuses_a_scale_that_overflows() { + let mut s = comp(); + let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [1e-305, 80]})).unwrap()["layer"].as_u64().unwrap(); + let before = s.project.clone(); + assert!(s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).is_err(), "an infinite scale is refused"); + assert_eq!(s.project, before, "nothing was written"); +} + #[test] fn duplicated_and_pasted_layers_keep_their_links() { let mut s = comp(); From ee01d7d4f60d4963acd347990f4432bcb202416b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 05:10:42 +0700 Subject: [PATCH 3/3] Merge main; keep the batch-tested versions of crates/engine/src/cmd/layer/transform.rs --- crates/engine/src/cmd/layer/transform.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index ad4643ae1..eb0f610f9 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -79,8 +79,11 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { // bounds live in layer space, where a shape is centred on the origin and text sits above its // baseline, so (w/2, h/2) would push it off-centre (#2198). Reset and the other ops keep the // source size. - let ectx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + ..effectcraft_render::EvalCtx::new(&s.project, cid, &comp, t) + }; let fitting = matches!(op.as_str(), "fit" | "fitWidth" | "fitHeight"); let sizes: Vec<(LayerId, [f64; 2], [f64; 2])> = comp .layers