Skip to content
Merged
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
88 changes: 88 additions & 0 deletions crates/rustmotion-core/src/css/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1596,6 +1596,18 @@ impl Material {
}
}

fn default_clip_path_blob_lobes() -> u32 {
8
}

fn default_clip_path_blob_wobble() -> f32 {
0.15
}

fn default_clip_path_blob_seed() -> u32 {
11
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ClipPath {
Expand Down Expand Up @@ -1625,6 +1637,30 @@ pub enum ClipPath {
Path {
d: String,
},
/// A procedurally generated organic silhouette — the same wobbling-lobe
/// shape a `blob` scene `transition` grows across a whole cut, scoped
/// here to one element's own clip instead. `lobes` control points sit
/// around a circle of `radius`, each nudged radially by a deterministic
/// noise scaled by `wobble` and picked by `seed`; the same three values
/// reproduce the same silhouette, byte for byte. `wobble: 0` degenerates
/// to a plain circle, the same way `radius` alone does for `circle`.
Blob {
radius: LengthPercentage,
#[serde(default)]
origin: Option<TransformOrigin>,
/// How many lobes the silhouette grows. Same field as
/// `transition.lobes`.
#[serde(default = "default_clip_path_blob_lobes")]
lobes: u32,
/// How far each lobe's radius wanders from a perfect circle, as a
/// fraction of it. Same field as `transition.wobble`.
#[serde(default = "default_clip_path_blob_wobble")]
wobble: f32,
/// Stable selector for the lobe noise, same selector as
/// `pixel_dissolve.seed` and `transition.seed`.
#[serde(default = "default_clip_path_blob_seed")]
seed: u32,
},
/// Clip to another node's own path geometry, by id, instead of a literal
/// `d` frozen at author time. The motivating shape: two copies of the
/// same gem, each clipped by the same animated "crack" node, so the two
Expand Down Expand Up @@ -1724,6 +1760,58 @@ mod tests {
assert!(matches!(s2.color, Some(Color::Rgba { r: 255, .. })));
}

#[test]
fn deserialize_clip_path_blob_falls_back_to_the_transition_defaults() {
let json = r#"{ "clip-path": { "kind": "blob", "radius": 120 } }"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
match s.clip_path {
Some(ClipPath::Blob {
radius,
origin,
lobes,
wobble,
seed,
}) => {
assert_eq!(radius, LengthPercentage::Px(120.0));
assert!(origin.is_none());
assert_eq!(
lobes, 8,
"must default to the same lobes as transition: blob"
);
assert_eq!(
wobble, 0.15,
"must default to the same wobble as transition: blob"
);
assert_eq!(
seed, 11,
"must default to the same seed as transition: blob"
);
}
other => panic!("expected ClipPath::Blob, got {other:?}"),
}
}

#[test]
fn deserialize_clip_path_blob_with_explicit_lobes_wobble_seed() {
let json = r#"{
"clip-path": { "kind": "blob", "radius": 90, "lobes": 5, "wobble": 0.4, "seed": 3 }
}"#;
let s: CssStyle = serde_json::from_str(json).unwrap();
match s.clip_path {
Some(ClipPath::Blob {
lobes,
wobble,
seed,
..
}) => {
assert_eq!(lobes, 5);
assert_eq!(wobble, 0.4);
assert_eq!(seed, 3);
}
other => panic!("expected ClipPath::Blob, got {other:?}"),
}
}

#[test]
fn deserialize_transform_list() {
let json = r#"{ "transform": [
Expand Down
80 changes: 80 additions & 0 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use crate::css::units::{
use crate::engine::animator::{burst_progress, burst_stroke_span};
use crate::engine::box_tree::{BoxKind, BoxNode, NodeId};
use crate::engine::layout_pass::{BoxLayout, LayoutResult};
use crate::engine::transition::{blob_local_path, scaled_mask_path};
use crate::schema::{AnimationEffect as SchemaAnimationEffect, BurstConfig};

#[derive(Debug, Clone, Copy)]
Expand Down Expand Up @@ -2514,6 +2515,27 @@ fn clip_path_to_skia(
Some(parsed.with_offset((layout.x, layout.y)))
}

ClipPath::Blob {
radius,
origin,
lobes,
wobble,
seed,
} => {
let (cx, cy, _) = resolve_origin(origin.as_ref(), layout, ctx);
let reference = LengthContext {
parent_size: (layout.width.powi(2) + layout.height.powi(2)).sqrt()
/ std::f32::consts::SQRT_2,
..*ctx
};
let r = radius.resolve(&reference);
if r <= 0.0 {
return Some(PathBuilder::new().detach());
}
let local = blob_local_path(*lobes, *wobble, *seed);
Some(scaled_mask_path(&local, r, (cx, cy)))
}

ClipPath::NodePath { id } => {
eprintln!(
"rustmotion: clip-path {{ kind: node-path, id: \"{id}\" }} is not implemented \
Expand Down Expand Up @@ -2544,6 +2566,7 @@ fn clip_path_kind_name(clip: &ClipPath) -> &'static str {
ClipPath::Ellipse { .. } => "ellipse",
ClipPath::Polygon { .. } => "polygon",
ClipPath::Path { .. } => "path",
ClipPath::Blob { .. } => "blob",
ClipPath::NodePath { .. } => "node-path",
ClipPath::Morph { .. } => "morph",
}
Expand Down Expand Up @@ -4207,6 +4230,63 @@ mod paint_order_tests {
);
}

#[test]
fn clip_path_blob_keeps_the_centre_and_drops_the_far_corner() {
let blob = ClipPath::Blob {
radius: CLP::Px(150.0),
origin: None,
lobes: 8,
wobble: 0.15,
seed: 11,
};
assert!(
is_red_at(Some(blob.clone()), 200, 200),
"the centre is inside a centred organic blob of radius 150"
);
assert!(
!is_red_at(Some(blob), 20, 20),
"the far corner, well beyond the blob's radius, is outside it"
);
}

#[test]
fn clip_path_blob_reuses_the_wobbling_lobe_generator_instead_of_drawing_a_plain_circle() {
let radius = 150.0;
let circle = ClipPath::Circle {
radius: CLP::Px(radius),
origin: None,
};
let blob = ClipPath::Blob {
radius: CLP::Px(radius),
origin: None,
lobes: 8,
wobble: 0.45,
seed: 11,
};
let mut saw_a_difference = false;
for deg in (0..360).step_by(3) {
let rad = (deg as f32).to_radians();
let probe_r = radius * 0.98;
let x = 200.0 + probe_r * rad.cos();
let y = 200.0 + probe_r * rad.sin();
if !(0.0..400.0).contains(&x) || !(0.0..400.0).contains(&y) {
continue;
}
let (xi, yi) = (x.round() as u32, y.round() as u32);
if is_red_at(Some(circle.clone()), xi, yi) != is_red_at(Some(blob.clone()), xi, yi) {
saw_a_difference = true;
break;
}
}
assert!(
saw_a_difference,
"a wobble of 0.45 must make the blob's edge diverge from a perfect circle \
somewhere around a ring just inside the nominal radius — if every sample agrees \
with the circle, clip-path blob is not reusing the transition's wobbling-lobe \
generator, just drawing a circle under a different name"
);
}

#[test]
fn clip_path_none_paints_exactly_as_no_clip_path_at_all() {
for (x, y) in [(20, 20), (200, 200), (380, 380)] {
Expand Down
130 changes: 125 additions & 5 deletions crates/rustmotion-core/src/engine/transition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,29 @@ fn wipe_reveal_rect(direction: &Direction, w: f32, h: f32, progress: f32) -> Rec
}
}

const WIPE_FIXED_EDGE_MARGIN_FEATHER_FACTOR: f32 = 6.0;
const WIPE_FIXED_EDGE_MARGIN_FLOOR: f32 = 16.0;

fn wipe_fixed_edge_margin(feather: f32) -> f32 {
feather.max(0.0) * WIPE_FIXED_EDGE_MARGIN_FEATHER_FACTOR + WIPE_FIXED_EDGE_MARGIN_FLOOR
}

fn wipe_feathered_reveal_rect(
direction: &Direction,
w: f32,
h: f32,
progress: f32,
feather: f32,
) -> Rect {
let margin = wipe_fixed_edge_margin(feather);
match direction {
Direction::Left => Rect::from_ltrb(-margin, -margin, w * progress, h + margin),
Direction::Right => Rect::from_ltrb(w * (1.0 - progress), -margin, w + margin, h + margin),
Direction::Up => Rect::from_ltrb(-margin, -margin, w + margin, h * progress),
Direction::Down => Rect::from_ltrb(-margin, h * (1.0 - progress), w + margin, h + margin),
}
}

fn wipe(
frame_a: &[u8],
frame_b: &[u8],
Expand Down Expand Up @@ -461,8 +484,9 @@ fn wipe(
return surface_to_pixels(surface, width, height);
}

let feathered_reveal = wipe_feathered_reveal_rect(&direction, w, h, progress, feather);
let mut builder = PathBuilder::new();
builder.add_rect(reveal, None, None);
builder.add_rect(feathered_reveal, None, None);
let path = builder.detach();
composite_through_mask(
frame_a,
Expand Down Expand Up @@ -652,7 +676,7 @@ fn clock_wipe(frame_a: &[u8], frame_b: &[u8], width: u32, height: u32, progress:
}

const IRIS_PILL_OVERSHOOT: f32 = 1.45;
const IRIS_PILL_CORNER_FRACTION: f32 = 0.2;
const IRIS_PILL_CORNER_FRACTION: f32 = 1.0;

fn iris_max_radius(
origin: (f32, f32),
Expand Down Expand Up @@ -824,7 +848,7 @@ fn iris_transition(
blend_fade(&filled, frame_b, t)
}

fn mask_shape_to_local_path(shape: &MaskShape) -> Option<skia_safe::Path> {
pub fn mask_shape_to_local_path(shape: &MaskShape) -> Option<skia_safe::Path> {
match shape {
MaskShape::Polygon { points } => {
if points.len() < 3 {
Expand All @@ -845,7 +869,11 @@ fn mask_shape_to_local_path(shape: &MaskShape) -> Option<skia_safe::Path> {
}
}

fn scaled_mask_path(local: &skia_safe::Path, scale: f32, origin: (f32, f32)) -> skia_safe::Path {
pub(crate) fn scaled_mask_path(
local: &skia_safe::Path,
scale: f32,
origin: (f32, f32),
) -> skia_safe::Path {
let bounds = *local.bounds();
let cx = (bounds.left + bounds.right) / 2.0;
let cy = (bounds.top + bounds.bottom) / 2.0;
Expand Down Expand Up @@ -1010,7 +1038,7 @@ fn mask_transition(
)
}

fn blob_local_path(lobes: u32, wobble: f32, seed: u32) -> skia_safe::Path {
pub(crate) fn blob_local_path(lobes: u32, wobble: f32, seed: u32) -> skia_safe::Path {
let n = lobes.max(3);
let wobble = wobble.clamp(0.0, 0.95);
let points: Vec<(f32, f32)> = (0..n)
Expand Down Expand Up @@ -1782,3 +1810,95 @@ mod pixel_dissolve_tests {
}
}
}

#[cfg(test)]
mod wipe_fixed_edge_tests {
use super::*;

const W: u32 = 1280;
const H: u32 = 720;

fn frames() -> (Vec<u8>, Vec<u8>) {
let a: Vec<u8> = (0..W * H).flat_map(|_| [200u8, 200, 200, 255]).collect();
let b: Vec<u8> = (0..W * H).flat_map(|_| [40u8, 40, 40, 255]).collect();
(a, b)
}

fn pixel(buf: &[u8], x: u32, y: u32) -> [u8; 4] {
let base = ((y * W + x) * 4) as usize;
[buf[base], buf[base + 1], buf[base + 2], buf[base + 3]]
}

#[test]
fn a_wide_feather_and_band_color_do_not_leak_onto_the_three_fixed_edges_of_a_left_wipe() {
let (a, b) = frames();
let opts = TransitionOptions {
feather: 320.0,
band_color: Some("#FF8CC6".to_string()),
..TransitionOptions::default()
};
let out = apply_transition(&a, &b, W, H, 0.8, &TransitionType::WipeLeft, &opts);

assert_eq!(
pixel(&out, 640, 3),
[40, 40, 40, 255],
"the top edge never moves for a left wipe — a pixel well inside the revealed area \
but close to y=0 must be the exact incoming colour, not softened or tinted pink \
just because it happens to sit near the frame's own fixed top border"
);
assert_eq!(
pixel(&out, 640, H - 4),
[40, 40, 40, 255],
"the bottom edge never moves for a left wipe either — same leak, opposite border"
);
assert_eq!(
pixel(&out, 2, 360),
[40, 40, 40, 255],
"the left edge is the wipe's own starting edge, always flush with the frame's left \
border — it must never show a blended or tinted pixel, only the moving right edge \
may"
);
}

#[test]
fn the_moving_edge_still_feathers_normally_once_the_fixed_edges_are_excluded() {
let (a, b) = frames();
let opts = TransitionOptions {
feather: 320.0,
..TransitionOptions::default()
};
let out = apply_transition(&a, &b, W, H, 0.8, &TransitionType::WipeLeft, &opts);
let moving_edge_x = (W as f32 * 0.8) as u32;
let blended = pixel(&out, moving_edge_x, 360);
assert!(
blended[0] != 200 && blended[0] != 40,
"the wipe's own moving reveal boundary must still show a blended pixel from a wide \
feather, got {blended:?} — the fixed-edge fix must not have flattened the real \
moving edge along with the fake ones"
);
}
}

#[cfg(test)]
mod iris_pill_shape_tests {
use super::*;

#[test]
fn a_pill_with_aspect_one_degenerates_into_a_true_circle_not_a_barely_rounded_square() {
let origin = (100.0, 100.0);
let radius = 50.0;
let path = iris_mask_path(origin, IrisShape::Pill, 1.0, radius);
let beyond_the_radius_on_the_diagonal = radius * 1.2 / std::f32::consts::SQRT_2;
let corner_point = (
origin.0 + beyond_the_radius_on_the_diagonal,
origin.1 + beyond_the_radius_on_the_diagonal,
);
assert!(
!path.contains(corner_point),
"a pill with aspect 1.0 must degenerate into a circle — a point just beyond the \
radius on the diagonal must fall outside it; the old 0.2 corner fraction left a \
barely-rounded square whose corner reached out to radius*sqrt(2), well past this \
point"
);
}
}
Loading
Loading