Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions crates/rustmotion/src/cli/commands/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,16 @@ fn container_clips(c: &Component) -> bool {
/// path, out of scope for this fix. Animated transform-producing presets are
/// folded separately in `walk_anim`; this only handles what a component
/// declares directly in `style.transform`.
///
/// `font_size` is the NODE's own resolved font-size
/// (`css.font_size_px_or(16.0)`), not a hardcoded 16px — an `em` length in
/// `transform` must scale with the element it's declared on, exactly like
/// `paint_pass.rs`'s `length_ctx` does for the same field. Percentage
/// lengths inside `transform` resolve per axis (`ctx_x`/`ctx_y`, mirroring
/// `paint_pass.rs`'s `length_ctx_x`/`length_ctx_y`) rather than against a
/// single `bbox.w.max(bbox.h)` shared by both axes — see
/// `apply_transform_chain`'s doc comment for why a shared context there was
/// wrong for every non-square box.
fn apply_static_node_transform(bbox: &BBox, css: &CssStyle, viewport: (f32, f32)) -> BBox {
let transform = match css.transform.as_deref() {
Some(t) if !t.is_empty() => t,
Expand All @@ -482,9 +492,17 @@ fn apply_static_node_transform(bbox: &BBox, css: &CssStyle, viewport: (f32, f32)
viewport_width: viewport.0,
viewport_height: viewport.1,
parent_size: bbox.w.max(bbox.h),
font_size: 16.0,
font_size: css.font_size_px_or(16.0),
root_font_size: 16.0,
};
let ctx_x = LengthContext {
parent_size: bbox.w,
..ctx
};
let ctx_y = LengthContext {
parent_size: bbox.h,
..ctx
};
let (pivot_x, pivot_y) = resolve_transform_origin_2d(css.transform_origin.as_ref(), bbox, &ctx);
let corners = [
(bbox.x, bbox.y),
Expand All @@ -498,7 +516,7 @@ fn apply_static_node_transform(bbox: &BBox, css: &CssStyle, viewport: (f32, f32)
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
for (cx, cy) in corners {
let (tx, ty) = apply_transform_chain(transform, cx - pivot_x, cy - pivot_y, &ctx);
let (tx, ty) = apply_transform_chain(transform, cx - pivot_x, cy - pivot_y, &ctx_x, &ctx_y);
let (wx, wy) = (pivot_x + tx, pivot_y + ty);
min_x = min_x.min(wx);
max_x = max_x.max(wx);
Expand Down Expand Up @@ -584,15 +602,32 @@ fn resolve_transform_origin_2d(
/// clockwise on a y-down canvas, i.e. `x' = x·cosθ − y·sinθ`,
/// `y' = x·sinθ + y·cosθ`); `Skew`/`SkewX`/`SkewY` match `Canvas::skew`
/// (`x' = x + y·tan(skew_x)`, `y' = y + x·tan(skew_y)`).
fn apply_transform_chain(list: &[TransformFn], x: f32, y: f32, ctx: &LengthContext) -> (f32, f32) {
///
/// `ctx_x`/`ctx_y` are separate contexts differing only in
/// `parent_size` (the box's own width / height respectively), used for
/// `Translate`/`TranslateX`/`TranslateY`/`Translate3d`'s percentage
/// resolution — CSS resolves a translate's x-component percentage against
/// the box's own WIDTH and the y-component against its own HEIGHT, never a
/// single value shared by both axes (that's only correct for square boxes).
/// `Scale`/`Rotate`/`Skew` take unitless factors/degrees and never consult
/// either context.
fn apply_transform_chain(
list: &[TransformFn],
x: f32,
y: f32,
ctx_x: &LengthContext,
ctx_y: &LengthContext,
) -> (f32, f32) {
let (mut x, mut y) = (x, y);
for f in list.iter().rev() {
let (nx, ny) = match f {
TransformFn::Translate { x: tx, y: ty } => (x + tx.resolve(ctx), y + ty.resolve(ctx)),
TransformFn::TranslateX { x: tx } => (x + tx.resolve(ctx), y),
TransformFn::TranslateY { y: ty } => (x, y + ty.resolve(ctx)),
TransformFn::Translate { x: tx, y: ty } => {
(x + tx.resolve(ctx_x), y + ty.resolve(ctx_y))
}
TransformFn::TranslateX { x: tx } => (x + tx.resolve(ctx_x), y),
TransformFn::TranslateY { y: ty } => (x, y + ty.resolve(ctx_y)),
TransformFn::Translate3d { x: tx, y: ty, .. } => {
(x + tx.resolve(ctx), y + ty.resolve(ctx))
(x + tx.resolve(ctx_x), y + ty.resolve(ctx_y))
}
TransformFn::Scale { x: sx, y: sy } => (x * sx, y * sy),
TransformFn::ScaleX { x: sx } => (x * sx, y),
Expand Down
88 changes: 88 additions & 0 deletions crates/rustmotion/tests/audit_ws_b.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,91 @@ fn strict_anim_also_measures_vw_against_the_real_viewport_width() {
"--strict-anim pass (line ~1347) must also be clean: {report_json}"
);
}

// ─── static transform lengths use the node's own font-size and per-axis size ───

/// `translateX(-10em)` on a 96px-font node must resolve against ITS OWN
/// font-size (960px), not the hardcoded 16px `apply_static_node_transform`
/// used to assume (160px). At x=200 with a 100px-wide box, the correct
/// translate pushes the box to x=-760 (fully off-frame, a real violation);
/// the buggy 160px translate only reaches x=40 (still on-frame, silent).
#[test]
fn static_translate_em_resolves_against_the_nodes_own_font_size() {
let scenario = ScratchFile::new("rm15-em-scenario");
let report = ScratchFile::new("rm15-em-report");
let json = r##"{
"video": { "width": 1920, "height": 1080 },
"scenes": [{
"duration": 1.0,
"children": [{
"type": "shape",
"shape": "rect",
"position": "absolute",
"x": 200, "y": 400,
"style": {
"width": "100px", "height": "50px",
"font-size": "96px",
"transform": [{ "fn": "translate-x", "x": "-10em" }]
},
"fill": "#ff0000"
}]
}]
}"##;
std::fs::write(&scenario.0, json).expect("write scenario");

let output = run_validate(&scenario.0, Some(&report.0), false, false);
let report_json = read_report(&report.0);
assert!(
!output.status.success(),
"a -10em translate on a 96px-font node must push the shape off-frame; report={report_json}"
);
let violation = find_kind(&report_json, "viewport_overflow")
.expect("expected a viewport_overflow violation");
let x = violation["bbox"]["x"].as_f64().expect("bbox.x is a number");
assert!(
(x - (-760.0)).abs() < 2.0,
"expected bbox.x ~ -760 (200 - 10*96), got {x}: {report_json}"
);
}

/// `translateY(50%)` on a WIDE, SHORT box (1000×100) must resolve against
/// its OWN height (50px), not `max(width, height)` (500px) — the exact
/// mistake `paint_pass.rs`'s `length_ctx_x`/`length_ctx_y` split exists to
/// avoid. The correct 50px shift keeps the box on-frame; the buggy 500px
/// shift pushes its bottom edge to 1400px, past the 1080px-tall viewport.
#[test]
fn static_translate_percent_resolves_per_axis_not_against_max_of_both() {
let scenario = ScratchFile::new("rm15-percent-scenario");
let report = ScratchFile::new("rm15-percent-report");
let json = r##"{
"video": { "width": 1920, "height": 1080 },
"scenes": [{
"duration": 1.0,
"children": [{
"type": "shape",
"shape": "rect",
"position": "absolute",
"x": 100, "y": 800,
"style": {
"width": "1000px", "height": "100px",
"transform": [{ "fn": "translate-y", "y": "50%" }]
},
"fill": "#ff0000"
}]
}]
}"##;
std::fs::write(&scenario.0, json).expect("write scenario");

let output = run_validate(&scenario.0, Some(&report.0), false, false);
let report_json = read_report(&report.0);
assert!(
output.status.success(),
"a 50% translateY on a 1000x100 box must resolve against its own 100px height \
(50px shift, still on-frame), not max(1000,100); report={report_json}"
);
assert_eq!(
count_kind(&report_json, "viewport_overflow"),
0,
"{report_json}"
);
}
Loading