Transform: Fit to Comp sizes shape and text layers by their painted bounds (#2198)

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 <noreply@z.ai>
This commit is contained in:
SusieeTheFemboy
2026-10-29 05:22:57 +01:00
co-authored by GLM-5.3-Flash
parent 990fa668cd
commit a3a72d48aa
2 changed files with 78 additions and 5 deletions
+27 -5
View File
@@ -74,14 +74,36 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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]);
+51
View File
@@ -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();