use std::cell::RefCell;
use skia_safe::gradient::{self, Colors as GradientColors, Gradient};
use skia_safe::{
canvas::SaveLayerRec, Canvas, ClipOp, Color as SColor, Color4f, Paint, PaintStyle, PathBuilder,
Point, RRect, Rect, M44, V3,
};
use crate::css::style::{
Background, BackgroundLayer, BorderEdges, BorderRadius, BorderStyle, BoxShadow, ClipPath,
Color, CssStyle, Edges, LayoutSurface, Material, MaterialPreset, Overflow, TransformFn,
TransformOrigin,
};
use crate::css::units::{
parse_origin_component, Length, LengthContext, LengthPercentage, ParsedLength,
};
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)]
pub struct PaintFrame {
pub light: LightDirection,
pub time: f64,
pub scenario_time: f64,
pub frame_index: u32,
pub fps: u32,
pub video_width: u32,
pub video_height: u32,
pub scene_duration: f64,
pub camera: Option<PlaneCamera>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlaneCamera {
pub pan_x: f32,
pub pan_y: f32,
pub zoom: f32,
pub rotation: f32,
pub origin_x: f32,
pub origin_y: f32,
pub focus: f32,
pub aperture: f32,
pub rotate_x: f32,
pub rotate_y: f32,
pub perspective: f32,
}
fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: (f32, f32)) {
let zoom = 1.0 + (cam.zoom - 1.0) * depth;
let rotation = cam.rotation * depth;
let pan_x = cam.pan_x * depth;
let pan_y = cam.pan_y * depth;
let tilt_x = cam.rotate_x * depth;
let tilt_y = cam.rotate_y * depth;
canvas.translate(Point::new(cam.origin_x, cam.origin_y));
if rotation.abs() > 0.001 {
canvas.rotate(rotation, None);
}
if (zoom - 1.0).abs() > 0.001 {
canvas.scale((zoom, zoom));
}
if tilt_x.abs() > 0.001 || tilt_y.abs() > 0.001 {
let mut shot = M44::new_identity();
if cam.perspective > 0.0 {
shot.pre_concat(&css_perspective_m44(cam.perspective));
}
shot.pre_concat(&M44::rotate(V3::new(1.0, 0.0, 0.0), tilt_x.to_radians()));
shot.pre_concat(&M44::rotate(V3::new(0.0, 1.0, 0.0), tilt_y.to_radians()));
canvas.concat_44(&shot);
}
canvas.translate(Point::new(-cam.origin_x - pan_x, -cam.origin_y - pan_y));
canvas.clip_rect(
Rect::from_wh(viewport.0, viewport.1),
ClipOp::Intersect,
true,
);
}
#[derive(Debug, Clone, Copy)]
enum SurfaceKind {
Cylinder,
Sphere,
}
struct LayoutSurfaceFrame {
kind: SurfaceKind,
radius: f32,
arc_x_deg: f32,
arc_y_deg: f32,
pivot: (f32, f32),
half_w: f32,
half_h: f32,
perspective: Option<f32>,
rotate_x_deg: f32,
rotate_y_deg: f32,
}
impl LayoutSurfaceFrame {
fn new(surface: &LayoutSurface, container: &BoxLayout, frame: &PaintFrame) -> Self {
let (cx, cy, cw, ch) = container.content_box();
let rotate_x_deg = surface
.rotate_x()
.map(|a| a.value_at(frame.time, frame.scene_duration))
.unwrap_or(0.0);
let rotate_y_deg = surface
.rotate_y()
.map(|a| a.value_at(frame.time, frame.scene_duration))
.unwrap_or(0.0);
Self {
kind: if surface.is_sphere() {
SurfaceKind::Sphere
} else {
SurfaceKind::Cylinder
},
radius: surface.radius(),
arc_x_deg: surface.arc_x(),
arc_y_deg: surface.arc_y(),
pivot: (cx + cw / 2.0, cy + ch / 2.0),
half_w: (cw / 2.0).max(0.001),
half_h: (ch / 2.0).max(0.001),
perspective: surface.perspective(),
rotate_x_deg,
rotate_y_deg,
}
}
fn child_delta(&self, nu: f32, nv: f32) -> (f32, f32, f32) {
layout_surface_child_delta(
self.kind,
self.radius,
self.arc_x_deg,
self.arc_y_deg,
nu,
nv,
self.half_w,
self.half_h,
)
}
fn child_facing(&self, nu: f32, nv: f32) -> (f32, f32) {
layout_surface_child_angles(self.kind, self.arc_x_deg, self.arc_y_deg, nu, nv)
}
fn apply(&self, canvas: &Canvas, child: &BoxLayout) {
let nu = ((child.cx() - self.pivot.0) / self.half_w).clamp(-1.0, 1.0);
let nv = ((child.cy() - self.pivot.1) / self.half_h).clamp(-1.0, 1.0);
let (dx, dy, dz) = self.child_delta(nu, nv);
let (theta_x, theta_y) = self.child_facing(nu, nv);
let flat_x = nu * self.half_w;
let flat_y = nv * self.half_h;
let target_x = flat_x + dx;
let target_y = flat_y + dy;
let target_z = dz;
let mut m = M44::new_identity();
m.pre_concat(&M44::translate(self.pivot.0, self.pivot.1, 0.0));
if let Some(d) = self.perspective {
m.pre_concat(&css_perspective_m44(d.max(1.0)));
}
if self.rotate_y_deg.abs() > 0.001 {
m.pre_concat(&M44::rotate(
V3::new(0.0, 1.0, 0.0),
self.rotate_y_deg.to_radians(),
));
}
if self.rotate_x_deg.abs() > 0.001 {
m.pre_concat(&M44::rotate(
V3::new(1.0, 0.0, 0.0),
self.rotate_x_deg.to_radians(),
));
}
m.pre_concat(&M44::translate(target_x, target_y, target_z));
if theta_x.abs() > 0.0001 {
m.pre_concat(&M44::rotate(V3::new(0.0, 1.0, 0.0), theta_x));
}
if theta_y.abs() > 0.0001 {
m.pre_concat(&M44::rotate(V3::new(1.0, 0.0, 0.0), theta_y));
}
m.pre_concat(&M44::translate(-flat_x, -flat_y, 0.0));
m.pre_concat(&M44::translate(-self.pivot.0, -self.pivot.1, 0.0));
canvas.concat_44(&m);
}
}
fn layout_surface_child_angles(
kind: SurfaceKind,
arc_x_deg: f32,
arc_y_deg: f32,
nu: f32,
nv: f32,
) -> (f32, f32) {
let nu = nu.clamp(-1.0, 1.0);
let nv = nv.clamp(-1.0, 1.0);
let theta_x = nu * arc_x_deg.to_radians() / 2.0;
let theta_y = match kind {
SurfaceKind::Sphere => nv * arc_y_deg.to_radians() / 2.0,
SurfaceKind::Cylinder => 0.0,
};
(theta_x, theta_y)
}
#[allow(clippy::too_many_arguments)]
fn layout_surface_child_delta(
kind: SurfaceKind,
radius: f32,
arc_x_deg: f32,
arc_y_deg: f32,
nu: f32,
nv: f32,
half_w: f32,
half_h: f32,
) -> (f32, f32, f32) {
let nu = nu.clamp(-1.0, 1.0);
let nv = nv.clamp(-1.0, 1.0);
let (theta_x, theta_y) = layout_surface_child_angles(kind, arc_x_deg, arc_y_deg, nu, nv);
let flat_x = nu * half_w;
match kind {
SurfaceKind::Sphere => {
let flat_y = nv * half_h;
let target_x = radius * theta_x.sin() * theta_y.cos();
let target_y = radius * theta_y.sin();
let target_z = radius * (theta_x.cos() * theta_y.cos() - 1.0);
(target_x - flat_x, target_y - flat_y, target_z)
}
SurfaceKind::Cylinder => {
let target_x = radius * theta_x.sin();
let target_z = radius * (theta_x.cos() - 1.0);
(target_x - flat_x, 0.0, target_z)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HitRect {
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HitNode {
pub node_id: NodeId,
pub rect: HitRect,
}
pub type HitMap = Vec<HitNode>;
#[derive(Debug, Clone, PartialEq)]
pub struct EnrichedHit {
pub node_id: NodeId,
pub kind: String,
pub rect: HitRect,
pub pointer: Option<String>,
}
pub trait PaintDispatcher {
fn dispatch(
&self,
canvas: &Canvas,
payload: &(dyn std::any::Any + Send + Sync),
css: &CssStyle,
layout: &BoxLayout,
frame: &PaintFrame,
);
}
pub struct NoopDispatcher;
impl PaintDispatcher for NoopDispatcher {
fn dispatch(
&self,
_canvas: &Canvas,
_payload: &(dyn std::any::Any + Send + Sync),
_css: &CssStyle,
_layout: &BoxLayout,
_frame: &PaintFrame,
) {
}
}
pub fn paint_tree(
canvas: &Canvas,
root: &BoxNode,
layout: &LayoutResult,
frame: &PaintFrame,
dispatcher: &dyn PaintDispatcher,
) {
let ctx = PaintContext {
layout,
frame,
dispatcher,
viewport_size: (frame.video_width as f32, frame.video_height as f32),
hits: None,
};
paint_node(canvas, root, &ctx, 0);
}
pub fn paint_tree_with_hits(
canvas: &Canvas,
root: &BoxNode,
layout: &LayoutResult,
frame: &PaintFrame,
dispatcher: &dyn PaintDispatcher,
) -> HitMap {
let hits = RefCell::new(Vec::new());
let ctx = PaintContext {
layout,
frame,
dispatcher,
viewport_size: (frame.video_width as f32, frame.video_height as f32),
hits: Some(&hits),
};
paint_node(canvas, root, &ctx, 0);
hits.into_inner()
}
struct PaintContext<'a> {
layout: &'a LayoutResult,
frame: &'a PaintFrame,
dispatcher: &'a dyn PaintDispatcher,
viewport_size: (f32, f32),
hits: Option<&'a RefCell<HitMap>>,
}
fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: usize) {
if let Some(window) = &node.window {
if !window.contains(ctx.frame.time) {
return;
}
}
let Some(box_layout) = ctx.layout.get(node.id) else {
return;
};
if box_layout.width <= 0.0 || box_layout.height <= 0.0 {
return;
}
let length_ctx = LengthContext {
viewport_width: ctx.viewport_size.0,
viewport_height: ctx.viewport_size.1,
parent_size: box_layout.width.max(box_layout.height),
font_size: node.css.font_size_px_or(16.0),
root_font_size: 16.0,
};
let length_ctx_x = LengthContext {
parent_size: box_layout.width,
..length_ctx
};
let length_ctx_y = LengthContext {
parent_size: box_layout.height,
..length_ctx
};
canvas.save();
if tree_depth == 1 {
if let Some(cam) = &ctx.frame.camera {
let depth = node.css.depth.unwrap_or(1.0);
apply_plane_camera(canvas, cam, depth, ctx.viewport_size);
}
}
if node.css.transform.is_some() || node.css.perspective.is_some() {
let (tx, ty, _tz) =
resolve_origin(node.css.transform_origin.as_ref(), box_layout, &length_ctx);
let transform_pivot = (tx, ty);
let perspective_pivot = if node.css.perspective_origin.is_some() {
let (px, py, _pz) = resolve_origin(
node.css.perspective_origin.as_ref(),
box_layout,
&length_ctx,
);
(px, py)
} else {
transform_pivot
};
let transform_list = node.css.transform.as_deref().unwrap_or(&[]);
let perspective_d = node
.css
.perspective
.as_ref()
.map(|l| l.resolve(&length_ctx).max(1.0));
let axes = TransformAxes {
x: length_ctx_x,
y: length_ctx_y,
general: length_ctx,
};
apply_transform(
canvas,
transform_list,
perspective_d,
transform_pivot,
perspective_pivot,
&axes,
);
}
if let (Some(hits), BoxKind::Component(_)) = (ctx.hits, &node.kind) {
let local = Rect::from_xywh(
box_layout.x,
box_layout.y,
box_layout.width,
box_layout.height,
);
let dev = canvas.local_to_device_as_3x3().map_rect(local).0;
hits.borrow_mut().push(HitNode {
node_id: node.id,
rect: HitRect {
x: dev.left,
y: dev.top,
w: dev.width(),
h: dev.height(),
},
});
}
if let Some(filters) = node.css.backdrop_filter.as_deref() {
if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx, box_layout) {
let radius = node
.css
.border_radius
.as_ref()
.map(|r| resolve_border_radius(r, box_layout, &length_ctx))
.unwrap_or([0.0; 4]);
canvas.save();
canvas.clip_rrect(border_rrect(box_layout, radius), ClipOp::Intersect, true);
let rec = SaveLayerRec::default().backdrop(&backdrop);
canvas.save_layer(&rec);
canvas.restore();
canvas.restore();
}
}
match active_shatter(&node.css, ctx.frame.time) {
Some((cfg, progress)) => {
paint_shattered_node(
canvas, node, box_layout, length_ctx, ctx, tree_depth, cfg, progress,
);
}
None => {
paint_node_visual(canvas, node, box_layout, length_ctx, ctx, tree_depth);
}
}
if let Some((cfg, progress)) = active_burst(&node.css, ctx.frame.time) {
paint_burst(canvas, box_layout, cfg, progress);
}
canvas.restore();
}
fn paint_node_visual(
canvas: &Canvas,
node: &BoxNode,
box_layout: &BoxLayout,
length_ctx: LengthContext,
ctx: &PaintContext,
tree_depth: usize,
) {
let overflow = node.css.overflow.unwrap_or(Overflow::Visible);
let opacity = node.css.opacity.unwrap_or(1.0).clamp(0.0, 1.0);
let content_filter = node
.css
.filter
.as_deref()
.and_then(|list| filters_to_image_filter(list, &length_ctx, box_layout));
let aberration_shift = active_chromatic_aberration(&node.css, ctx.frame.time)
.map(|(cfg, progress)| crate::engine::animator::chromatic_aberration_shift(cfg, progress));
let aberration_filter = aberration_shift.and_then(chromatic_aberration_image_filter);
let defocus_sigma = match (tree_depth, ctx.frame.camera.as_ref()) {
(1, Some(cam)) => depth_of_field_sigma(cam, node.css.depth.unwrap_or(1.0)),
_ => 0.0,
};
let defocus_filter = depth_of_field_image_filter(defocus_sigma);
let combined_filter = {
use skia_safe::image_filters;
let node_filter = match (content_filter, aberration_filter) {
(Some(cf), Some(af)) => image_filters::compose(af, cf),
(Some(cf), None) => Some(cf),
(None, Some(af)) => Some(af),
(None, None) => None,
};
match (node_filter, defocus_filter) {
(Some(nf), Some(df)) => image_filters::compose(df, nf),
(Some(nf), None) => Some(nf),
(None, Some(df)) => Some(df),
(None, None) => None,
}
};
let opened_opacity_layer = if opacity < 1.0 || combined_filter.is_some() {
let mut paint = Paint::default();
if opacity < 1.0 {
paint.set_alpha((opacity * 255.0) as u8);
}
if let Some(filter) = combined_filter {
paint.set_image_filter(filter);
}
let filter_bleed_px = node
.css
.filter
.as_deref()
.map(|list| filter_bleed(list, &length_ctx))
.unwrap_or(0.0);
let shadow_bleed_px = node
.css
.box_shadow
.as_deref()
.map(|shadows| box_shadow_bleed(shadows, &length_ctx))
.unwrap_or(0.0);
let aberration_bleed_px = aberration_shift.map(|s| s.abs().ceil()).unwrap_or(0.0);
let defocus_bleed_px = (defocus_sigma * 3.0).ceil();
let bleed = filter_bleed_px
.max(shadow_bleed_px)
.max(aberration_bleed_px)
.max(defocus_bleed_px);
let mut bounds = Rect::from_xywh(
box_layout.x - bleed,
box_layout.y - bleed,
box_layout.width + bleed * 2.0,
box_layout.height + bleed * 2.0,
);
if overflow == Overflow::Visible {
if let Some(descendants) = subtree_layout_bounds(node, ctx.layout) {
bounds = Rect::join2(bounds, descendants);
}
}
let rec = SaveLayerRec::default().paint(&paint).bounds(&bounds);
canvas.save_layer(&rec);
true
} else {
false
};
let opened_clip_path = match node.css.clip_path.as_ref() {
Some(clip) => match clip_path_to_skia(clip, box_layout, &length_ctx) {
Some(path) => {
canvas.save();
canvas.clip_path(&path, ClipOp::Intersect, true);
true
}
None => false,
},
None => false,
};
if let Some(shadows) = node.css.box_shadow.as_ref() {
for shadow in shadows {
if shadow.inset.unwrap_or(false) {
continue;
}
paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, false);
}
}
if let Some(bg) = node.css.background.as_ref() {
paint_background(canvas, box_layout, &node.css, bg, &length_ctx);
}
if let Some(material) = node.css.material.as_ref() {
paint_material(
canvas,
box_layout,
&node.css,
material,
&ctx.frame.light,
&length_ctx,
);
}
if let Some(gb) = node.css.gradient_border.as_ref() {
paint_gradient_border(canvas, box_layout, &node.css, gb, &length_ctx);
} else if let Some(border) = node.css.border.as_ref() {
paint_border(canvas, box_layout, &node.css, border, &length_ctx);
}
let shimmer = active_shimmer(&node.css, ctx.frame.time);
let opened_shimmer_layer = if shimmer.is_some() {
let bounds = Rect::from_xywh(
box_layout.x,
box_layout.y,
box_layout.width,
box_layout.height,
);
let rec = SaveLayerRec::default().bounds(&bounds);
canvas.save_layer(&rec);
true
} else {
false
};
let opened_overflow_clip = if matches!(
overflow,
Overflow::Hidden | Overflow::Clip | Overflow::Scroll | Overflow::Auto
) {
let radius = node
.css
.border_radius
.as_ref()
.map(|r| resolve_border_radius(r, box_layout, &length_ctx))
.unwrap_or([0.0; 4]);
let rrect = padding_rrect(box_layout, radius);
canvas.save();
canvas.clip_rrect(rrect, ClipOp::Intersect, true);
true
} else {
false
};
let payload_opt = match &node.kind {
BoxKind::Component(p) | BoxKind::Ghost(p) => Some(p),
BoxKind::Container => None,
};
if let Some(payload) = payload_opt {
ctx.dispatcher
.dispatch(canvas, payload.as_ref(), &node.css, box_layout, ctx.frame);
}
let mut indices: Vec<usize> = (0..node.children.len()).collect();
indices.sort_by_key(|&i| node.children[i].css.z_index.unwrap_or(0));
match node.css.layout_surface.as_ref() {
Some(surface) => {
let surface_frame = LayoutSurfaceFrame::new(surface, box_layout, ctx.frame);
for &i in &indices {
let child = &node.children[i];
canvas.save();
if let Some(child_layout) = ctx.layout.get(child.id) {
surface_frame.apply(canvas, child_layout);
}
paint_node(canvas, child, ctx, tree_depth + 1);
canvas.restore();
}
}
None => {
for &i in &indices {
paint_node(canvas, &node.children[i], ctx, tree_depth + 1);
}
}
}
if opened_overflow_clip {
canvas.restore();
}
if let Some(shadows) = node.css.box_shadow.as_ref() {
for shadow in shadows {
if shadow.inset.unwrap_or(false) {
paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, true);
}
}
}
if let Some((cfg, progress)) = shimmer {
paint_shimmer_band(canvas, box_layout, cfg, progress);
}
if opened_shimmer_layer {
canvas.restore();
}
if opened_clip_path {
canvas.restore();
}
if opened_opacity_layer {
canvas.restore();
}
}
const MAX_SHATTER_PIECES: u32 = 64;
fn active_shatter(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShatterConfig, f32)> {
let cfg = css.animation.iter().find_map(|e| match e {
crate::schema::AnimationEffect::Shatter(c) => Some(c),
_ => None,
})?;
crate::engine::animator::shatter_progress(cfg, time).map(|progress| (cfg, progress))
}
fn shatter_hash(seed: u32, index: u32, salt: u32) -> f32 {
let mut h = (index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ (seed as u64).wrapping_mul(0xBF58_476D_1CE4_E5B9)
^ ((salt as u64) << 32).wrapping_mul(0x94D0_49BB_1331_11EB);
h ^= h >> 30;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
h ^= h >> 27;
h = h.wrapping_mul(0x94D0_49BB_1331_11EB);
h ^= h >> 31;
((h >> 11) as f64 / (1u64 << 53) as f64) as f32
}
fn shatter_seed_points(width: f32, height: f32, pieces: u32, seed: u32) -> Vec<(f32, f32)> {
let cols = (pieces as f32).sqrt().ceil().max(1.0) as u32;
let rows = pieces.div_ceil(cols).max(1);
let cell_w = width / cols as f32;
let cell_h = height / rows as f32;
(0..pieces)
.map(|i| {
let col = i % cols;
let row = i / cols;
let jitter_x = (shatter_hash(seed, i, 1) - 0.5) * cell_w * 0.7;
let jitter_y = (shatter_hash(seed, i, 2) - 0.5) * cell_h * 0.7;
let x = ((col as f32 + 0.5) * cell_w + jitter_x).clamp(0.0, width);
let y = ((row as f32 + 0.5) * cell_h + jitter_y).clamp(0.0, height);
(x, y)
})
.collect()
}
fn shatter_polygon_side(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
(b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0)
}
fn shatter_segment_intersection(
p0: (f32, f32),
p1: (f32, f32),
a: (f32, f32),
b: (f32, f32),
) -> (f32, f32) {
let (x1, y1) = p0;
let (x2, y2) = p1;
let (x3, y3) = a;
let (x4, y4) = b;
let denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if denom.abs() < 1e-6 {
return p1;
}
let t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
(x1 + t * (x2 - x1), y1 + t * (y2 - y1))
}
fn shatter_clip_half_plane(poly: Vec<(f32, f32)>, a: (f32, f32), b: (f32, f32)) -> Vec<(f32, f32)> {
if poly.is_empty() {
return poly;
}
let n = poly.len();
let mut out = Vec::with_capacity(n + 1);
for idx in 0..n {
let cur = poly[idx];
let prev = poly[(idx + n - 1) % n];
let cur_in = shatter_polygon_side(cur, a, b) >= 0.0;
let prev_in = shatter_polygon_side(prev, a, b) >= 0.0;
if cur_in != prev_in {
out.push(shatter_segment_intersection(prev, cur, a, b));
}
if cur_in {
out.push(cur);
}
}
out
}
fn shatter_clip_by_bisector(
poly: Vec<(f32, f32)>,
keep: (f32, f32),
other: (f32, f32),
reach: f32,
) -> Vec<(f32, f32)> {
let mid = ((keep.0 + other.0) * 0.5, (keep.1 + other.1) * 0.5);
let dir = (other.0 - keep.0, other.1 - keep.1);
let len = (dir.0 * dir.0 + dir.1 * dir.1).sqrt().max(1e-6);
let perp = (-dir.1 / len, dir.0 / len);
let mut a = (mid.0 + perp.0 * reach, mid.1 + perp.1 * reach);
let mut b = (mid.0 - perp.0 * reach, mid.1 - perp.1 * reach);
if shatter_polygon_side(keep, a, b) < 0.0 {
std::mem::swap(&mut a, &mut b);
}
shatter_clip_half_plane(poly, a, b)
}
fn shatter_cells(width: f32, height: f32, pieces: u32, seed: u32) -> Vec<Vec<(f32, f32)>> {
if width <= 0.0 || height <= 0.0 {
return Vec::new();
}
let pieces = pieces.clamp(1, MAX_SHATTER_PIECES);
let points = shatter_seed_points(width, height, pieces, seed);
let reach = (width + height) * 4.0 + 1000.0;
(0..points.len())
.map(|i| {
let mut poly = vec![(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)];
for (j, &other) in points.iter().enumerate() {
if j == i || poly.is_empty() {
continue;
}
poly = shatter_clip_by_bisector(poly, points[i], other, reach);
}
poly
})
.collect()
}
fn shatter_polygon_centroid(points: &[(f32, f32)]) -> (f32, f32) {
let n = points.len();
let mut area = 0.0_f32;
let mut cx = 0.0_f32;
let mut cy = 0.0_f32;
for i in 0..n {
let (x0, y0) = points[i];
let (x1, y1) = points[(i + 1) % n];
let cross = x0 * y1 - x1 * y0;
area += cross;
cx += (x0 + x1) * cross;
cy += (y0 + y1) * cross;
}
area *= 0.5;
if area.abs() < 1e-6 {
let sum = points
.iter()
.fold((0.0, 0.0), |acc, p| (acc.0 + p.0, acc.1 + p.1));
return (sum.0 / n as f32, sum.1 / n as f32);
}
(cx / (6.0 * area), cy / (6.0 * area))
}
fn paint_shattered_node(
canvas: &Canvas,
node: &BoxNode,
box_layout: &BoxLayout,
length_ctx: LengthContext,
ctx: &PaintContext,
tree_depth: usize,
cfg: &crate::schema::ShatterConfig,
progress: f32,
) {
let width = box_layout.width.max(1.0).ceil() as i32;
let height = box_layout.height.max(1.0).ceil() as i32;
let info = skia_safe::ImageInfo::new(
(width, height),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Premul,
None,
);
let Some(mut surface) = skia_safe::surfaces::raster(&info, None, None) else {
paint_node_visual(canvas, node, box_layout, length_ctx, ctx, tree_depth);
return;
};
let offscreen = surface.canvas();
offscreen.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
offscreen.translate((-box_layout.x, -box_layout.y));
let capture_ctx = PaintContext {
layout: ctx.layout,
frame: ctx.frame,
dispatcher: ctx.dispatcher,
viewport_size: ctx.viewport_size,
hits: None,
};
paint_node_visual(
offscreen,
node,
box_layout,
length_ctx,
&capture_ctx,
tree_depth,
);
let image = surface.image_snapshot();
let pieces = cfg.pieces.clamp(1, MAX_SHATTER_PIECES);
let cells = shatter_cells(box_layout.width, box_layout.height, pieces, cfg.seed);
let origin_x = cfg.origin.x.clamp(0.0, 1.0) * box_layout.width;
let origin_y = cfg.origin.y.clamp(0.0, 1.0) * box_layout.height;
let diagonal = (box_layout.width.powi(2) + box_layout.height.powi(2)).sqrt();
for (i, cell) in cells.iter().enumerate() {
if cell.len() < 3 {
continue;
}
let index = i as u32;
let centroid = shatter_polygon_centroid(cell);
let (mut dx, mut dy) = (centroid.0 - origin_x, centroid.1 - origin_y);
let dist = (dx * dx + dy * dy).sqrt();
if dist < 0.001 {
let angle = shatter_hash(cfg.seed, index, 7) * std::f32::consts::TAU;
dx = angle.cos();
dy = angle.sin();
} else {
dx /= dist;
dy /= dist;
}
let travel_jitter = 0.6 + shatter_hash(cfg.seed, index, 3) * 0.8;
let travel = cfg.spread.max(0.0) * diagonal * 0.5 * travel_jitter * progress;
let spin_sign = if shatter_hash(cfg.seed, index, 5) < 0.5 {
-1.0
} else {
1.0
};
let spin_jitter = 0.5 + shatter_hash(cfg.seed, index, 6) * 0.5;
let rotation = cfg.spin * spin_sign * spin_jitter * progress;
let depth_value = (shatter_hash(cfg.seed, index, 4) * 2.0 - 1.0) * cfg.depth;
let scale = (1.0 + depth_value * progress).max(0.05);
let alpha = if cfg.fade {
((1.0 - progress).clamp(0.0, 1.0) * 255.0).round() as u8
} else {
255
};
if alpha == 0 {
continue;
}
let mut clip_builder = PathBuilder::new();
for (idx, &(px, py)) in cell.iter().enumerate() {
let p = (box_layout.x + px, box_layout.y + py);
if idx == 0 {
clip_builder.move_to(p);
} else {
clip_builder.line_to(p);
}
}
clip_builder.close();
let clip_path = clip_builder.detach();
let anchor = (box_layout.x + centroid.0, box_layout.y + centroid.1);
canvas.save();
canvas.translate((anchor.0 + dx * travel, anchor.1 + dy * travel));
if rotation.abs() > 0.001 {
canvas.rotate(rotation, None);
}
if (scale - 1.0).abs() > 0.001 {
canvas.scale((scale, scale));
}
canvas.translate((-anchor.0, -anchor.1));
canvas.clip_path(&clip_path, ClipOp::Intersect, true);
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_alpha(alpha);
canvas.draw_image(&image, (box_layout.x, box_layout.y), Some(&paint));
canvas.restore();
}
}
const MAX_BURST_COUNT: u32 = 64;
fn active_burst(css: &CssStyle, time: f64) -> Option<(&BurstConfig, f32)> {
let cfg = css.animation.iter().find_map(|e| match e {
SchemaAnimationEffect::Burst(c) => Some(c),
_ => None,
})?;
burst_progress(cfg, time).map(|progress| (cfg, progress))
}
fn burst_track_start(half_width: f32, half_height: f32, dir: (f32, f32)) -> f32 {
let to_vertical = if dir.0.abs() < 1e-6 {
f32::INFINITY
} else {
half_width / dir.0.abs()
};
let to_horizontal = if dir.1.abs() < 1e-6 {
f32::INFINITY
} else {
half_height / dir.1.abs()
};
let reach = to_vertical.min(to_horizontal);
if reach.is_finite() {
reach
} else {
half_width.max(half_height)
}
}
fn paint_burst(canvas: &Canvas, box_layout: &BoxLayout, cfg: &BurstConfig, progress: f32) {
let count = cfg.count.clamp(1, MAX_BURST_COUNT);
let length = cfg.length.max(0.0);
let width = cfg.width.max(0.0);
if length <= 0.0 || width <= 0.0 {
return;
}
let jitter = cfg.jitter.clamp(0.0, 1.0);
let centre = (
box_layout.x + box_layout.width * 0.5,
box_layout.y + box_layout.height * 0.5,
);
let half_width = box_layout.width * 0.5;
let half_height = box_layout.height * 0.5;
let spacing = std::f32::consts::TAU / count as f32;
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(PaintStyle::Stroke);
paint.set_stroke_width(width);
paint.set_stroke_cap(skia_safe::PaintCap::Round);
paint.set_color(parse_color_string(&cfg.color).unwrap_or_else(|| unresolved_color(&cfg.color)));
for i in 0..count {
let angle = i as f32 * spacing + (shatter_hash(cfg.seed, i, 11) - 0.5) * jitter * spacing;
let dir = (angle.cos(), angle.sin());
let stroke_length = length * (1.0 + (shatter_hash(cfg.seed, i, 12) - 0.5) * jitter);
let phase = shatter_hash(cfg.seed, i, 13) * jitter * 0.5;
let (tail, head) = burst_stroke_span(progress, phase);
if (head - tail) * stroke_length < 0.5 {
continue;
}
let base = burst_track_start(half_width, half_height, dir) + cfg.gap.max(0.0);
let near = base + tail * stroke_length;
let far = base + head * stroke_length;
canvas.draw_line(
(centre.0 + dir.0 * near, centre.1 + dir.1 * near),
(centre.0 + dir.0 * far, centre.1 + dir.1 * far),
&paint,
);
}
}
fn active_shimmer(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShimmerConfig, f32)> {
let cfg = css.animation.iter().find_map(|e| match e {
crate::schema::AnimationEffect::Shimmer(c) => Some(c),
_ => None,
})?;
if cfg.duration <= 0.0 || cfg.intensity <= 0.0 {
return None;
}
let elapsed = time - cfg.delay;
if elapsed < 0.0 {
return None;
}
let progress = if cfg.repeat {
(elapsed / cfg.duration).rem_euclid(1.0)
} else if elapsed > cfg.duration {
return None;
} else {
elapsed / cfg.duration
};
Some((cfg, progress as f32))
}
fn paint_shimmer_band(
canvas: &Canvas,
layout: &BoxLayout,
cfg: &crate::schema::ShimmerConfig,
progress: f32,
) {
if layout.width <= 0.0 || layout.height <= 0.0 {
return;
}
let (r, g, b, a) = crate::engine::renderer::parse_hex_color(&cfg.color);
let peak_alpha = (cfg.intensity.clamp(0.0, 1.0) * a as f32) as u8;
let transparent = SColor::from_argb(0, r, g, b);
let highlight = SColor::from_argb(peak_alpha, r, g, b);
let theta = cfg.angle.to_radians();
let (dx, dy) = (theta.cos(), theta.sin());
let cx = layout.x + layout.width / 2.0;
let cy = layout.y + layout.height / 2.0;
let half_extent = (layout.width * dx).abs() / 2.0 + (layout.height * dy).abs() / 2.0;
let band = (cfg.width.max(0.01) * half_extent * 2.0).max(1.0);
let start = -half_extent - band;
let centre = start + progress * (2.0 * half_extent + 2.0 * band);
let p0 = Point::new(cx + dx * (centre - band), cy + dy * (centre - band));
let p1 = Point::new(cx + dx * (centre + band), cy + dy * (centre + band));
let colors4f = [
Color4f::from(transparent),
Color4f::from(highlight),
Color4f::from(transparent),
];
let gradient_colors = GradientColors::new(&colors4f, None, skia_safe::TileMode::Clamp, None);
let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
let Some(shader) = gradient::shaders::linear_gradient((p0, p1), &grad, None) else {
return;
};
let mut paint = Paint::default();
paint.set_style(PaintStyle::Fill);
paint.set_anti_alias(true);
paint.set_shader(shader);
paint.set_blend_mode(skia_safe::BlendMode::SrcATop);
canvas.draw_rect(
Rect::from_xywh(layout.x, layout.y, layout.width, layout.height),
&paint,
);
}
fn active_chromatic_aberration(
css: &CssStyle,
time: f64,
) -> Option<(&crate::schema::ChromaticAberrationConfig, f32)> {
let cfg = css.animation.iter().find_map(|e| match e {
crate::schema::AnimationEffect::ChromaticAberration(c) => Some(c),
_ => None,
})?;
if cfg.duration <= 0.0 {
return None;
}
let elapsed = time - cfg.delay;
if elapsed < 0.0 || elapsed >= cfg.duration {
return None;
}
Some((cfg, (elapsed / cfg.duration) as f32))
}
fn chromatic_aberration_image_filter(shift: f32) -> Option<skia_safe::ImageFilter> {
if shift.abs() < 0.05 {
return None;
}
use skia_safe::{color_filters, image_filters, BlendMode};
#[rustfmt::skip]
const RED_ONLY: [f32; 20] = [
1.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 1.0, 0.0,
];
#[rustfmt::skip]
const CYAN_ONLY: [f32; 20] = [
0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 0.0, 1.0, 0.0,
];
let red_shifted = image_filters::offset((-shift, 0.0), None, None)?;
let cyan_shifted = image_filters::offset((shift, 0.0), None, None)?;
let red = image_filters::color_filter(
color_filters::matrix_row_major(&RED_ONLY, None),
Some(red_shifted),
None,
)?;
let cyan = image_filters::color_filter(
color_filters::matrix_row_major(&CYAN_ONLY, None),
Some(cyan_shifted),
None,
)?;
image_filters::blend(BlendMode::Plus, Some(red), Some(cyan), None)
}
pub fn depth_of_field_sigma(camera: &PlaneCamera, depth: f32) -> f32 {
if camera.aperture <= 0.0 {
return 0.0;
}
(camera.aperture * (depth - camera.focus).abs()).max(0.0)
}
fn depth_of_field_image_filter(sigma: f32) -> Option<skia_safe::ImageFilter> {
if sigma <= 0.0 {
return None;
}
skia_safe::image_filters::blur((sigma, sigma), skia_safe::TileMode::Decal, None, None)
}
fn resolve_blur_radii(
radius: Option<&Length>,
radius_x: Option<&Length>,
radius_y: Option<&Length>,
ctx: &LengthContext,
) -> (f32, f32) {
let iso = radius.map(|r| r.resolve(ctx).max(0.0));
let rx = radius_x
.map(|r| r.resolve(ctx).max(0.0))
.or(iso)
.unwrap_or(0.0);
let ry = radius_y
.map(|r| r.resolve(ctx).max(0.0))
.or(iso)
.unwrap_or(0.0);
(rx, ry)
}
fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 {
use crate::css::style::FilterFn;
let mut bleed = 0.0f32;
for f in list {
let b = match f {
FilterFn::Blur {
radius,
radius_x,
radius_y,
} => {
let (rx, ry) =
resolve_blur_radii(radius.as_ref(), radius_x.as_ref(), radius_y.as_ref(), ctx);
rx.max(ry) * 1.5
}
FilterFn::DirectionalBlur { radius, .. } => radius.resolve(ctx).max(0.0) * 1.5,
FilterFn::DropShadow {
offset_x,
offset_y,
blur,
..
} => {
let blur_bleed = blur
.as_ref()
.map(|b| b.resolve(ctx).max(0.0) * 1.5)
.unwrap_or(0.0);
offset_x.resolve(ctx).abs().max(offset_y.resolve(ctx).abs()) + blur_bleed
}
_ => 0.0,
};
bleed = bleed.max(b);
}
bleed
}
fn box_shadow_bleed(shadows: &[BoxShadow], ctx: &LengthContext) -> f32 {
let mut bleed = 0.0f32;
for shadow in shadows {
if shadow.inset.unwrap_or(false) {
continue;
}
let offset = shadow
.offset_x
.resolve(ctx)
.abs()
.max(shadow.offset_y.resolve(ctx).abs());
let spread = shadow
.spread
.as_ref()
.map(|s| s.resolve(ctx).max(0.0))
.unwrap_or(0.0);
let blur_bleed = shadow
.blur
.as_ref()
.map(|b| b.resolve(ctx).max(0.0) * 1.5)
.unwrap_or(0.0);
bleed = bleed.max(offset + spread + blur_bleed);
}
bleed
}
fn subtree_layout_bounds(node: &BoxNode, layout: &LayoutResult) -> Option<Rect> {
let mut bounds: Option<Rect> = None;
for child in &node.children {
if let Some(child_layout) = layout.get(child.id) {
let rect = Rect::from_xywh(
child_layout.x,
child_layout.y,
child_layout.width,
child_layout.height,
);
bounds = Some(bounds.map_or(rect, |b| Rect::join2(b, rect)));
}
if let Some(child_bounds) = subtree_layout_bounds(child, layout) {
bounds = Some(bounds.map_or(child_bounds, |b| Rect::join2(b, child_bounds)));
}
}
bounds
}
fn filters_to_image_filter(
list: &[crate::css::style::FilterFn],
ctx: &LengthContext,
layout: &BoxLayout,
) -> Option<skia_safe::ImageFilter> {
use crate::css::style::FilterFn;
use skia_safe::image_filters;
let mut chain: Option<skia_safe::ImageFilter> = None;
for f in list {
chain = match f {
FilterFn::Blur {
radius,
radius_x,
radius_y,
} => {
let (rx, ry) =
resolve_blur_radii(radius.as_ref(), radius_x.as_ref(), radius_y.as_ref(), ctx);
if rx <= 0.0 && ry <= 0.0 {
chain
} else {
image_filters::blur(
(rx / 2.0, ry / 2.0),
skia_safe::TileMode::Clamp,
chain,
None,
)
}
}
FilterFn::DirectionalBlur { angle, radius } => {
let r = radius.resolve(ctx).max(0.0);
if r <= 0.0 {
chain
} else {
directional_blur_image_filter(r, *angle, (layout.cx(), layout.cy()), chain)
}
}
FilterFn::DropShadow {
offset_x,
offset_y,
blur,
color,
} => {
let sigma = blur.as_ref().map(|b| b.resolve(ctx) / 2.0).unwrap_or(0.0);
let c = color.as_ref().map(parse_color).unwrap_or(SColor::BLACK);
image_filters::drop_shadow(
(offset_x.resolve(ctx), offset_y.resolve(ctx)),
(sigma, sigma),
c,
None,
chain,
None,
)
}
FilterFn::Noise { intensity, seed } => match noise_image_filter(*intensity, *seed) {
Some(noise) => {
image_filters::blend(skia_safe::BlendMode::Overlay, chain, Some(noise), None)
}
None => chain,
},
other => color_matrix_for(other)
.map(|m| skia_safe::color_filters::matrix_row_major(&m, None))
.and_then(|cf| image_filters::color_filter(cf, chain, None)),
};
}
chain
}
fn directional_blur_image_filter(
radius: f32,
angle_deg: f32,
pivot: (f32, f32),
input: Option<skia_safe::ImageFilter>,
) -> Option<skia_safe::ImageFilter> {
use skia_safe::{image_filters, Matrix, Point, SamplingOptions, TileMode};
let sigma = radius / 2.0;
if sigma <= 0.0 {
return input;
}
let point = Point::new(pivot.0, pivot.1);
let sampling = SamplingOptions::default();
let to_axis = Matrix::rotate_deg_pivot(-angle_deg, point);
let back_to_angle = Matrix::rotate_deg_pivot(angle_deg, point);
let aligned = image_filters::matrix_transform(&to_axis, sampling, input);
let blurred = image_filters::blur((sigma, 0.0), TileMode::Clamp, aligned, None)?;
image_filters::matrix_transform(&back_to_angle, sampling, blurred)
}
fn noise_image_filter(intensity: f32, seed: u64) -> Option<skia_safe::ImageFilter> {
use skia_safe::image_filters;
let intensity = intensity.clamp(0.0, 1.0);
if intensity <= 0.0 {
return None;
}
let noise = skia_safe::shaders::fractal_noise((0.9, 0.9), 2, seed as f32, None)?;
let (r, g, b) = (0.213, 0.715, 0.072);
#[rustfmt::skip]
let m = [
r, g, b, 0.0, 0.0,
r, g, b, 0.0, 0.0,
r, g, b, 0.0, 0.0,
0.0, 0.0, 0.0, intensity, 0.0,
];
let cf = skia_safe::color_filters::matrix_row_major(&m, None);
let mono = noise.with_color_filter(cf);
image_filters::shader(mono, None)
}
fn color_matrix_for(f: &crate::css::style::FilterFn) -> Option<[f32; 20]> {
use crate::css::style::FilterFn;
#[rustfmt::skip]
fn saturation(s: f32) -> [f32; 20] {
let (r, g, b) = (0.213, 0.715, 0.072);
[
r + (1.0 - r) * s, g * (1.0 - s), b * (1.0 - s), 0.0, 0.0,
r * (1.0 - s), g + (1.0 - g) * s, b * (1.0 - s), 0.0, 0.0,
r * (1.0 - s), g * (1.0 - s), b + (1.0 - b) * s, 0.0, 0.0,
0.0, 0.0, 0.0, 1.0, 0.0,
]
}
match f {
FilterFn::Brightness { value } => {
let v = value.max(0.0);
#[rustfmt::skip]
let m = [
v, 0.0, 0.0, 0.0, 0.0,
0.0, v, 0.0, 0.0, 0.0,
0.0, 0.0, v, 0.0, 0.0,
0.0, 0.0, 0.0, 1.0, 0.0,
];
Some(m)
}
FilterFn::Contrast { value } => {
let v = value.max(0.0);
let t = (1.0 - v) / 2.0;
#[rustfmt::skip]
let m = [
v, 0.0, 0.0, 0.0, t,
0.0, v, 0.0, 0.0, t,
0.0, 0.0, v, 0.0, t,
0.0, 0.0, 0.0, 1.0, 0.0,
];
Some(m)
}
FilterFn::Saturate { value } => Some(saturation(value.max(0.0))),
FilterFn::Grayscale { value } => Some(saturation(1.0 - value.clamp(0.0, 1.0))),
FilterFn::HueRotate { deg } => {
let (sin, cos) = deg.to_radians().sin_cos();
let (r, g, b) = (0.213, 0.715, 0.072);
#[rustfmt::skip]
let m = [
r + cos * (1.0 - r) + sin * (-r), g + cos * (-g) + sin * (-g), b + cos * (-b) + sin * (1.0 - b), 0.0, 0.0,
r + cos * (-r) + sin * 0.143, g + cos * (1.0 - g) + sin * 0.140, b + cos * (-b) + sin * (-0.283), 0.0, 0.0,
r + cos * (-r) + sin * (-(1.0 - r)), g + cos * (-g) + sin * g, b + cos * (1.0 - b) + sin * b, 0.0, 0.0,
0.0, 0.0, 0.0, 1.0, 0.0,
];
Some(m)
}
FilterFn::Invert { value } => {
let v = value.clamp(0.0, 1.0);
let s = 1.0 - 2.0 * v;
let t = v;
#[rustfmt::skip]
let m = [
s, 0.0, 0.0, 0.0, t,
0.0, s, 0.0, 0.0, t,
0.0, 0.0, s, 0.0, t,
0.0, 0.0, 0.0, 1.0, 0.0,
];
Some(m)
}
FilterFn::Sepia { value } => {
let v = value.clamp(0.0, 1.0);
let lerp = |a: f32, b: f32| a + (b - a) * v;
#[rustfmt::skip]
let m = [
lerp(1.0, 0.393), lerp(0.0, 0.769), lerp(0.0, 0.189), 0.0, 0.0,
lerp(0.0, 0.349), lerp(1.0, 0.686), lerp(0.0, 0.168), 0.0, 0.0,
lerp(0.0, 0.272), lerp(0.0, 0.534), lerp(1.0, 0.131), 0.0, 0.0,
0.0, 0.0, 0.0, 1.0, 0.0,
];
Some(m)
}
FilterFn::Opacity { value } => {
let v = value.clamp(0.0, 1.0);
#[rustfmt::skip]
let m = [
1.0, 0.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 0.0, v, 0.0,
];
Some(m)
}
FilterFn::Blur { .. }
| FilterFn::DirectionalBlur { .. }
| FilterFn::DropShadow { .. }
| FilterFn::Noise { .. } => None,
}
}
fn resolve_origin(
origin: Option<&TransformOrigin>,
layout: &BoxLayout,
ctx: &LengthContext,
) -> (f32, f32, f32) {
let Some(o) = origin else {
return (
layout.x + layout.width / 2.0,
layout.y + layout.height / 2.0,
0.0,
);
};
let ox = if let Some(lp) = &o.x {
let parsed = match lp {
LengthPercentage::String(s) => {
parse_origin_component(s).unwrap_or(crate::css::units::ParsedLength::Percent(50.0))
}
LengthPercentage::Px(v) => crate::css::units::ParsedLength::Px(*v),
};
let local_ctx = LengthContext {
parent_size: layout.width,
..*ctx
};
layout.x + parsed.resolve(&local_ctx).unwrap_or(layout.width / 2.0)
} else {
layout.x + layout.width / 2.0
};
let oy = if let Some(lp) = &o.y {
let parsed = match lp {
LengthPercentage::String(s) => {
parse_origin_component(s).unwrap_or(crate::css::units::ParsedLength::Percent(50.0))
}
LengthPercentage::Px(v) => crate::css::units::ParsedLength::Px(*v),
};
let local_ctx = LengthContext {
parent_size: layout.height,
..*ctx
};
layout.y + parsed.resolve(&local_ctx).unwrap_or(layout.height / 2.0)
} else {
layout.y + layout.height / 2.0
};
let oz = o.z.as_ref().map(|l| l.resolve(ctx)).unwrap_or(0.0);
(ox, oy, oz)
}
fn has_3d_transform(list: &[TransformFn]) -> bool {
list.iter().any(|t| {
matches!(
t,
TransformFn::RotateX { .. }
| TransformFn::RotateY { .. }
| TransformFn::Rotate3d { .. }
| TransformFn::Scale3d { .. }
| TransformFn::ScaleZ { .. }
| TransformFn::TranslateZ { .. }
| TransformFn::Perspective { .. }
| TransformFn::Matrix3d { .. }
)
})
}
#[derive(Clone, Copy)]
struct TransformAxes {
x: LengthContext,
y: LengthContext,
general: LengthContext,
}
fn apply_transform(
canvas: &Canvas,
list: &[TransformFn],
perspective_d: Option<f32>,
transform_pivot: (f32, f32),
perspective_pivot: (f32, f32),
axes: &TransformAxes,
) {
let pivots_equal = (transform_pivot.0 - perspective_pivot.0).abs() < 0.001
&& (transform_pivot.1 - perspective_pivot.1).abs() < 0.001;
if perspective_d.is_none() && !has_3d_transform(list) {
let pivot = transform_pivot;
canvas.translate(Point::new(pivot.0, pivot.1));
for tr in list {
match tr {
TransformFn::Translate { x, y } => {
canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y)));
}
TransformFn::TranslateX { x } => {
canvas.translate(Point::new(x.resolve(&axes.x), 0.0));
}
TransformFn::TranslateY { y } => {
canvas.translate(Point::new(0.0, y.resolve(&axes.y)));
}
TransformFn::Translate3d { x, y, .. } => {
canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y)));
}
TransformFn::Scale { x, y } => {
canvas.scale((*x, *y));
}
TransformFn::ScaleX { x } => {
canvas.scale((*x, 1.0));
}
TransformFn::ScaleY { y } => {
canvas.scale((1.0, *y));
}
TransformFn::Rotate { deg } | TransformFn::RotateZ { deg } => {
canvas.rotate(*deg, None);
}
TransformFn::Skew { x, y } => {
canvas.skew((x.to_radians().tan(), y.to_radians().tan()));
}
TransformFn::SkewX { x } => {
canvas.skew((x.to_radians().tan(), 0.0));
}
TransformFn::SkewY { y } => {
canvas.skew((0.0, y.to_radians().tan()));
}
TransformFn::Matrix { values: v } => {
let m = skia_safe::Matrix::new_all(
v[0], v[2], v[4], v[1], v[3], v[5], 0.0, 0.0, 1.0,
);
canvas.concat(&m);
}
_ => {}
}
}
canvas.translate(Point::new(-pivot.0, -pivot.1));
} else if pivots_equal {
let pivot = transform_pivot;
let mut m = M44::new_identity();
m.pre_concat(&M44::translate(pivot.0, pivot.1, 0.0));
if let Some(d) = perspective_d {
m.pre_concat(&css_perspective_m44(d));
}
for tr in list {
m.pre_concat(&transform_to_m44(tr, axes));
}
m.pre_concat(&M44::translate(-pivot.0, -pivot.1, 0.0));
canvas.concat_44(&m);
} else {
let tp = transform_pivot;
let pp = perspective_pivot;
let mut m = M44::new_identity();
m.pre_concat(&M44::translate(pp.0, pp.1, 0.0));
if let Some(d) = perspective_d {
m.pre_concat(&css_perspective_m44(d));
}
m.pre_concat(&M44::translate(-pp.0, -pp.1, 0.0));
m.pre_concat(&M44::translate(tp.0, tp.1, 0.0));
for tr in list {
m.pre_concat(&transform_to_m44(tr, axes));
}
m.pre_concat(&M44::translate(-tp.0, -tp.1, 0.0));
canvas.concat_44(&m);
}
}
pub fn animated_transform(
css: &CssStyle,
layout: &BoxLayout,
viewport: (f32, f32),
) -> (f32, f32, f32, f32, f32) {
let length_ctx = LengthContext {
viewport_width: viewport.0,
viewport_height: viewport.1,
parent_size: layout.width.max(layout.height),
font_size: css.font_size_px_or(16.0),
root_font_size: 16.0,
};
let ctx_x = LengthContext {
parent_size: layout.width,
..length_ctx
};
let ctx_y = LengthContext {
parent_size: layout.height,
..length_ctx
};
let mut tx = 0.0f32;
let mut ty = 0.0f32;
let mut scale_x = 1.0f32;
let mut scale_y = 1.0f32;
let mut rotation = 0.0f32;
for t in css.transform.as_deref().unwrap_or(&[]) {
match t {
TransformFn::Translate { x, y } => {
tx += x.resolve(&ctx_x);
ty += y.resolve(&ctx_y);
}
TransformFn::TranslateX { x } => tx += x.resolve(&ctx_x),
TransformFn::TranslateY { y } => ty += y.resolve(&ctx_y),
TransformFn::Translate3d { x, y, .. } => {
tx += x.resolve(&ctx_x);
ty += y.resolve(&ctx_y);
}
TransformFn::Scale { x, y } => {
scale_x *= x;
scale_y *= y;
}
TransformFn::ScaleX { x } => scale_x *= x,
TransformFn::ScaleY { y } => scale_y *= y,
TransformFn::Scale3d { x, y, .. } => {
scale_x *= x;
scale_y *= y;
}
TransformFn::Rotate { deg } | TransformFn::RotateZ { deg } => rotation += deg,
TransformFn::Rotate3d { deg, .. } => rotation += deg,
_ => {}
}
}
let scale = (scale_x + scale_y) / 2.0;
let opacity = css.opacity.unwrap_or(1.0);
(tx, ty, scale, rotation, opacity)
}
fn css_perspective_m44(d: f32) -> M44 {
M44::row_major(&[
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
-1.0 / d,
1.0,
])
}
fn transform_to_m44(tr: &TransformFn, axes: &TransformAxes) -> M44 {
match tr {
TransformFn::Translate { x, y } => {
M44::translate(x.resolve(&axes.x), y.resolve(&axes.y), 0.0)
}
TransformFn::TranslateX { x } => M44::translate(x.resolve(&axes.x), 0.0, 0.0),
TransformFn::TranslateY { y } => M44::translate(0.0, y.resolve(&axes.y), 0.0),
TransformFn::TranslateZ { z } => M44::translate(0.0, 0.0, z.resolve(&axes.general)),
TransformFn::Translate3d { x, y, z } => M44::translate(
x.resolve(&axes.x),
y.resolve(&axes.y),
z.resolve(&axes.general),
),
TransformFn::Scale { x, y } => M44::scale(*x, *y, 1.0),
TransformFn::ScaleX { x } => M44::scale(*x, 1.0, 1.0),
TransformFn::ScaleY { y } => M44::scale(1.0, *y, 1.0),
TransformFn::ScaleZ { z } => M44::scale(1.0, 1.0, *z),
TransformFn::Scale3d { x, y, z } => M44::scale(*x, *y, *z),
TransformFn::Rotate { deg } | TransformFn::RotateZ { deg } => {
M44::rotate(V3::new(0.0, 0.0, 1.0), deg.to_radians())
}
TransformFn::RotateX { deg } => M44::rotate(V3::new(1.0, 0.0, 0.0), deg.to_radians()),
TransformFn::RotateY { deg } => M44::rotate(V3::new(0.0, 1.0, 0.0), deg.to_radians()),
TransformFn::Rotate3d { x, y, z, deg } => {
M44::rotate(V3::new(*x, *y, *z), deg.to_radians())
}
TransformFn::Skew { x, y } => M44::row_major(&[
1.0,
x.to_radians().tan(),
0.0,
0.0,
y.to_radians().tan(),
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
]),
TransformFn::SkewX { x } => M44::row_major(&[
1.0,
x.to_radians().tan(),
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
]),
TransformFn::SkewY { y } => M44::row_major(&[
1.0,
0.0,
0.0,
0.0,
y.to_radians().tan(),
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
]),
TransformFn::Perspective { length } => {
css_perspective_m44(length.resolve(&axes.general).max(1.0))
}
TransformFn::Matrix { values: v } => M44::row_major(&[
v[0], v[2], 0.0, v[4], v[1], v[3], 0.0, v[5], 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
]),
TransformFn::Matrix3d { values: v } => M44::col_major(v),
}
}
fn paint_background(
canvas: &Canvas,
layout: &BoxLayout,
css: &CssStyle,
bg: &Background,
ctx: &LengthContext,
) {
let radius = css
.border_radius
.as_ref()
.map(|r| resolve_border_radius(r, layout, ctx))
.unwrap_or([0.0; 4]);
let rrect = padding_rrect(layout, radius);
match bg {
Background::Color(c) => {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color(parse_color(c));
canvas.draw_rrect(rrect, &paint);
}
Background::Single(layer) => paint_bg_layer(canvas, &rrect, layer),
Background::Layers(layers) => {
for layer in layers.iter().rev() {
paint_bg_layer(canvas, &rrect, layer);
}
}
}
}
fn paint_bg_layer(canvas: &Canvas, rrect: &RRect, layer: &BackgroundLayer) {
let mut paint = Paint::default();
paint.set_anti_alias(true);
match layer {
BackgroundLayer::Color { color } => {
paint.set_color(parse_color(color));
canvas.draw_rrect(rrect, &paint);
}
BackgroundLayer::LinearGradient { angle, stops } => {
let bounds = rrect.bounds();
let (p0, p1) = gradient_endpoints(*bounds, angle.unwrap_or(180.0));
let (colors, positions) = gradient_stops(stops);
let gradient_colors =
GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
if let Some(shader) = gradient::shaders::linear_gradient((p0, p1), &grad, None) {
paint.set_shader(shader);
canvas.draw_rrect(rrect, &paint);
}
}
BackgroundLayer::RadialGradient { stops, .. } => {
let bounds = rrect.bounds();
let center = Point::new(
bounds.left + bounds.width() / 2.0,
bounds.top + bounds.height() / 2.0,
);
let radius = bounds.width().max(bounds.height()) / 2.0;
let (colors, positions) = gradient_stops(stops);
let gradient_colors =
GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
if let Some(shader) = gradient::shaders::radial_gradient((center, radius), &grad, None)
{
paint.set_shader(shader);
canvas.draw_rrect(rrect, &paint);
}
}
BackgroundLayer::ConicGradient { stops, .. } => {
let bounds = rrect.bounds();
let center = Point::new(
bounds.left + bounds.width() / 2.0,
bounds.top + bounds.height() / 2.0,
);
let (colors, positions) = gradient_stops(stops);
let gradient_colors =
GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
if let Some(shader) =
gradient::shaders::sweep_gradient(center, (0.0, 360.0), &grad, None)
{
paint.set_shader(shader);
canvas.draw_rrect(rrect, &paint);
}
}
BackgroundLayer::Image { .. } => {}
}
}
fn gradient_stops(stops: &[crate::css::style::GradientStop]) -> (Vec<Color4f>, Vec<f32>) {
let mut colors = Vec::with_capacity(stops.len());
let mut positions = Vec::with_capacity(stops.len());
let n = stops.len().max(1);
for (i, s) in stops.iter().enumerate() {
colors.push(Color4f::from(parse_color(&s.color)));
let default_offset = i as f32 / (n.saturating_sub(1).max(1) as f32);
positions.push(s.offset.unwrap_or(default_offset));
}
(colors, positions)
}
fn gradient_endpoints(bounds: Rect, angle_deg: f32) -> (Point, Point) {
let cx = bounds.left + bounds.width() / 2.0;
let cy = bounds.top + bounds.height() / 2.0;
let rad = angle_deg.to_radians();
let (sin_a, cos_a) = (rad.sin(), -rad.cos());
let len = (bounds.width().abs() * sin_a.abs() + bounds.height().abs() * cos_a.abs()) / 2.0;
let p0 = Point::new(cx - sin_a * len, cy - cos_a * len);
let p1 = Point::new(cx + sin_a * len, cy + cos_a * len);
(p0, p1)
}
fn paint_border(
canvas: &Canvas,
layout: &BoxLayout,
css: &CssStyle,
border: &BorderEdges,
ctx: &LengthContext,
) {
let style = border.style.unwrap_or(BorderStyle::Solid);
if matches!(style, BorderStyle::None) {
return;
}
let color = border
.color
.as_ref()
.map(parse_color)
.unwrap_or(SColor::BLACK);
let widths = layout.border;
let max_w = widths
.top
.max(widths.right)
.max(widths.bottom)
.max(widths.left);
if max_w <= 0.0 {
return;
}
let radius = css
.border_radius
.as_ref()
.map(|r| resolve_border_radius(r, layout, ctx))
.unwrap_or([0.0; 4]);
match style {
BorderStyle::None => {}
BorderStyle::Solid => {
let outer = border_rrect(layout, radius);
let inner = inner_rrect(layout, radius);
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(PaintStyle::Fill);
paint.set_color(color);
canvas.draw_drrect(outer, inner, &paint);
}
BorderStyle::Dashed | BorderStyle::Dotted => {
let centerline = border_stroke_rrect(layout, radius, max_w / 2.0);
let mut builder = PathBuilder::new();
builder.add_rrect(centerline, None, None);
let path = builder.detach();
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(PaintStyle::Stroke);
paint.set_color(color);
paint.set_stroke_width(max_w);
let intervals = if matches!(style, BorderStyle::Dotted) {
paint.set_stroke_cap(skia_safe::PaintCap::Round);
[0.001, max_w * 2.0]
} else {
[max_w * 3.0, max_w * 3.0]
};
if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) {
paint.set_path_effect(dash);
}
canvas.draw_path(&path, &paint);
}
BorderStyle::Double => {
let stroke_w = max_w / 3.0;
if stroke_w <= 0.0 {
return;
}
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(PaintStyle::Stroke);
paint.set_color(color);
paint.set_stroke_width(stroke_w);
for inset in [stroke_w / 2.0, max_w - stroke_w / 2.0] {
let centerline = border_stroke_rrect(layout, radius, inset);
let mut builder = PathBuilder::new();
builder.add_rrect(centerline, None, None);
canvas.draw_path(&builder.detach(), &paint);
}
}
}
}
fn border_stroke_rrect(layout: &BoxLayout, radius: [f32; 4], inset: f32) -> RRect {
let rect = Rect::from_xywh(
layout.x + inset,
layout.y + inset,
(layout.width - inset * 2.0).max(0.0),
(layout.height - inset * 2.0).max(0.0),
);
let r = [
(radius[0] - inset).max(0.0),
(radius[1] - inset).max(0.0),
(radius[2] - inset).max(0.0),
(radius[3] - inset).max(0.0),
];
rrect_from_corners(rect, r)
}
fn paint_gradient_border(
canvas: &Canvas,
layout: &BoxLayout,
css: &CssStyle,
gb: &crate::schema::GradientBorder,
ctx: &LengthContext,
) {
if gb.colors.len() < 2 || gb.width <= 0.0 {
return;
}
let width = gb.width.min(layout.width / 2.0).min(layout.height / 2.0);
let radius = css
.border_radius
.as_ref()
.map(|r| resolve_border_radius(r, layout, ctx))
.unwrap_or([0.0; 4]);
let outer = border_rrect(layout, radius);
let inner_rect = Rect::from_xywh(
layout.x + width,
layout.y + width,
(layout.width - width * 2.0).max(0.0),
(layout.height - width * 2.0).max(0.0),
);
let inner_radius = [
(radius[0] - width).max(0.0),
(radius[1] - width).max(0.0),
(radius[2] - width).max(0.0),
(radius[3] - width).max(0.0),
];
let inner = rrect_from_corners(inner_rect, inner_radius);
let colors: Vec<Color4f> = gb
.colors
.iter()
.map(|c| Color4f::from(parse_color_string(c).unwrap_or_else(|| unresolved_color(c))))
.collect();
let n = colors.len();
let positions: Vec<f32> = (0..n)
.map(|i| i as f32 / (n.saturating_sub(1).max(1) as f32))
.collect();
let bounds = outer.bounds();
let (p0, p1) = gradient_endpoints(*bounds, gb.angle);
let gradient_colors =
GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
let Some(shader) = gradient::shaders::linear_gradient((p0, p1), &grad, None) else {
return;
};
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(PaintStyle::Fill);
paint.set_shader(shader);
canvas.draw_drrect(outer, inner, &paint);
}
#[derive(Debug, Clone, Copy)]
pub struct LightDirection {
pub x: f32,
pub y: f32,
pub intensity: f32,
pub color: SColor,
}
impl Default for LightDirection {
fn default() -> Self {
LightDirection {
x: -0.35,
y: -0.8,
intensity: 1.0,
color: SColor::from_argb(255, 255, 255, 255),
}
}
}
impl LightDirection {
fn normalized(&self) -> (f32, f32) {
let length = (self.x * self.x + self.y * self.y).sqrt();
if length <= f32::EPSILON {
return (0.0, -1.0);
}
(self.x / length, self.y / length)
}
}
struct MaterialRecipe {
highlight_alpha: f32,
highlight_spread: f32,
edge_alpha: f32,
shade_alpha: f32,
}
fn material_recipe(preset: MaterialPreset) -> MaterialRecipe {
match preset {
MaterialPreset::Glossy => MaterialRecipe {
highlight_alpha: 0.42,
highlight_spread: 0.85,
edge_alpha: 0.55,
shade_alpha: 0.30,
},
MaterialPreset::Metal => MaterialRecipe {
highlight_alpha: 0.26,
highlight_spread: 0.42,
edge_alpha: 0.78,
shade_alpha: 0.38,
},
MaterialPreset::Matte => MaterialRecipe {
highlight_alpha: 0.0,
highlight_spread: 0.0,
edge_alpha: 0.0,
shade_alpha: 0.22,
},
MaterialPreset::Inflated => MaterialRecipe {
highlight_alpha: 0.0,
highlight_spread: 0.0,
edge_alpha: 0.0,
shade_alpha: 0.0,
},
}
}
fn silhouette_alpha_field(
path: &skia_safe::Path,
layout: &BoxLayout,
bevel: f32,
) -> Option<(Vec<f32>, usize, usize)> {
let width = layout.width.ceil() as i32;
let height = layout.height.ceil() as i32;
if width <= 0 || height <= 0 {
return None;
}
let info = skia_safe::ImageInfo::new(
(width, height),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Premul,
None,
);
let mut surface = skia_safe::surfaces::raster(&info, None, None)?;
let canvas = surface.canvas();
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
canvas.translate((-layout.x, -layout.y));
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color(SColor::from_argb(255, 255, 255, 255));
paint.set_image_filter(skia_safe::image_filters::blur(
(bevel.max(1.0) / 2.0, bevel.max(1.0) / 2.0),
skia_safe::TileMode::Decal,
None,
None,
)?);
canvas.draw_path(path, &paint);
let mut buf = vec![0u8; (width * height * 4) as usize];
surface.read_pixels(&info, &mut buf, (width * 4) as usize, (0, 0));
let field = buf
.as_chunks::<4>()
.0
.iter()
.map(|px| px[3] as f32 / 255.0)
.collect();
Some((field, width as usize, height as usize))
}
const ORDERED_DITHER_4X4: [[f32; 4]; 4] = [
[0.0, 8.0, 2.0, 10.0],
[12.0, 4.0, 14.0, 6.0],
[3.0, 11.0, 1.0, 9.0],
[15.0, 7.0, 13.0, 5.0],
];
fn quantize_with_ordered_dither(value: f32, x: usize, y: usize) -> u8 {
let clamped = value.clamp(0.0, 255.0);
let base = clamped.floor();
let frac = clamped - base;
let threshold = (ORDERED_DITHER_4X4[y % 4][x % 4] + 0.5) / 16.0;
let rounded = if frac > threshold { base + 1.0 } else { base };
rounded.clamp(0.0, 255.0) as u8
}
fn paint_inflated_material(
canvas: &Canvas,
layout: &BoxLayout,
silhouette: &skia_safe::Path,
light: &LightDirection,
strength: f32,
bevel: f32,
softness: f32,
) {
let Some((field, width, height)) = silhouette_alpha_field(silhouette, layout, bevel) else {
return;
};
let (lx, ly) = light.normalized();
let curvature = 1.0 + softness.clamp(0.0, 1.0) * 3.0;
let reach = ((bevel.max(1.0) * 0.25) as usize).clamp(1, 12);
let relief = 6.0 / reach as f32;
let mut lit = vec![0u8; width * height * 4];
for y in 0..height {
for x in 0..width {
let at = |ix: usize, iy: usize| field[iy * width + ix];
let left = at(x.saturating_sub(reach), y);
let right = at((x + reach).min(width - 1), y);
let up = at(x, y.saturating_sub(reach));
let down = at(x, (y + reach).min(height - 1));
let slope_x = (right - left) * relief;
let slope_y = (down - up) * relief;
let lambert = (-(slope_x * lx + slope_y * ly)).clamp(-1.0, 1.0);
let shaped = lambert.abs().powf(1.0 / curvature) * lambert.signum();
let amount = (shaped * strength).clamp(-1.0, 1.0);
let index = (y * width + x) * 4;
let coverage = field[y * width + x];
let raw_alpha = amount.abs() * coverage * 255.0;
let alpha = quantize_with_ordered_dither(raw_alpha, x, y);
let tone = if amount >= 0.0 {
(light.color.r(), light.color.g(), light.color.b())
} else {
(0, 0, 0)
};
lit[index] = (tone.0 as f32 * alpha as f32 / 255.0) as u8;
lit[index + 1] = (tone.1 as f32 * alpha as f32 / 255.0) as u8;
lit[index + 2] = (tone.2 as f32 * alpha as f32 / 255.0) as u8;
lit[index + 3] = alpha;
}
}
let info = skia_safe::ImageInfo::new(
(width as i32, height as i32),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Premul,
None,
);
let data = skia_safe::Data::new_copy(&lit);
let Some(image) = skia_safe::images::raster_from_data(&info, data, width * 4) else {
return;
};
canvas.draw_image(&image, (layout.x, layout.y), None);
}
fn paint_material(
canvas: &Canvas,
layout: &BoxLayout,
css: &CssStyle,
material: &Material,
light: &LightDirection,
ctx: &LengthContext,
) {
let strength = (material.intensity() * light.intensity).clamp(0.0, 4.0);
if strength <= 0.0 {
return;
}
let recipe = material_recipe(material.preset());
let (lx, ly) = light.normalized();
let radius = css
.border_radius
.as_ref()
.map(|r| resolve_border_radius(r, layout, ctx))
.unwrap_or([0.0; 4]);
let rrect = padding_rrect(layout, radius);
let rect = *rrect.rect();
let silhouette = css
.clip_path
.as_ref()
.and_then(|c| clip_path_to_skia(c, layout, ctx))
.unwrap_or_else(|| {
let mut builder = PathBuilder::new();
builder.add_rrect(rrect, None, None);
builder.detach()
});
if material.preset() == MaterialPreset::Inflated {
canvas.save();
canvas.clip_path(&silhouette, ClipOp::Intersect, true);
paint_inflated_material(
canvas,
layout,
&silhouette,
light,
strength,
material.bevel(),
material.softness(),
);
canvas.restore();
return;
}
canvas.save();
canvas.clip_path(&silhouette, ClipOp::Intersect, true);
let tint = light.color;
let half_diagonal = (rect.width().powi(2) + rect.height().powi(2)).sqrt() / 2.0;
let centre = (rect.center_x(), rect.center_y());
let lit_point = (
centre.0 + lx * rect.width() * 0.38,
centre.1 + ly * rect.height() * 0.38,
);
let shaded_point = (
centre.0 - lx * rect.width() * 0.45,
centre.1 - ly * rect.height() * 0.45,
);
if recipe.shade_alpha > 0.0 {
let alpha = (recipe.shade_alpha * strength).clamp(0.0, 1.0);
let stops = [
Color4f::new(0.0, 0.0, 0.0, alpha),
Color4f::new(0.0, 0.0, 0.0, 0.0),
];
let gradient_colors = GradientColors::new(&stops, None, skia_safe::TileMode::Clamp, None);
let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
if let Some(shader) = gradient::shaders::radial_gradient(
(
Point::new(shaded_point.0, shaded_point.1),
half_diagonal * 1.05,
),
&grad,
None,
) {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_shader(shader);
canvas.draw_rect(rect, &paint);
}
}
if recipe.highlight_alpha > 0.0 {
let alpha = (recipe.highlight_alpha * strength).clamp(0.0, 1.0);
let stops = [
Color4f::new(
tint.r() as f32 / 255.0,
tint.g() as f32 / 255.0,
tint.b() as f32 / 255.0,
alpha,
),
Color4f::new(
tint.r() as f32 / 255.0,
tint.g() as f32 / 255.0,
tint.b() as f32 / 255.0,
0.0,
),
];
let gradient_colors = GradientColors::new(&stops, None, skia_safe::TileMode::Clamp, None);
let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
if let Some(shader) = gradient::shaders::radial_gradient(
(
Point::new(lit_point.0, lit_point.1),
(half_diagonal * recipe.highlight_spread).max(1.0),
),
&grad,
None,
) {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_shader(shader);
canvas.draw_rect(rect, &paint);
}
}
if recipe.edge_alpha > 0.0 {
let alpha = (recipe.edge_alpha * strength).clamp(0.0, 1.0);
let width = (rect.width().min(rect.height()) * 0.012).clamp(1.0, 3.0);
let from = (
rect.center_x() + lx * rect.width() / 2.0,
rect.center_y() + ly * rect.height() / 2.0,
);
let to = (
rect.center_x() - lx * rect.width() / 2.0,
rect.center_y() - ly * rect.height() / 2.0,
);
let stops = [
Color4f::new(
tint.r() as f32 / 255.0,
tint.g() as f32 / 255.0,
tint.b() as f32 / 255.0,
alpha,
),
Color4f::new(
tint.r() as f32 / 255.0,
tint.g() as f32 / 255.0,
tint.b() as f32 / 255.0,
0.0,
),
];
let gradient_colors = GradientColors::new(&stops, None, skia_safe::TileMode::Clamp, None);
let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
if let Some(shader) = gradient::shaders::linear_gradient(
(Point::new(from.0, from.1), Point::new(to.0, to.1)),
&grad,
None,
) {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(PaintStyle::Stroke);
paint.set_stroke_width(width);
paint.set_shader(shader);
let inset = skia_safe::RRect::new_rect_radii(
rect.with_inset((width / 2.0, width / 2.0)),
&[
(radius[0], radius[0]).into(),
(radius[1], radius[1]).into(),
(radius[2], radius[2]).into(),
(radius[3], radius[3]).into(),
],
);
canvas.draw_rrect(inset, &paint);
}
}
canvas.restore();
}
fn clip_path_to_skia(
clip: &ClipPath,
layout: &BoxLayout,
ctx: &LengthContext,
) -> Option<skia_safe::Path> {
let ctx_w = LengthContext {
parent_size: layout.width,
..*ctx
};
let ctx_h = LengthContext {
parent_size: layout.height,
..*ctx
};
match clip {
ClipPath::None => None,
ClipPath::Inset {
top,
right,
bottom,
left,
radius,
} => {
let t = top.resolve(&ctx_h);
let r = right.resolve(&ctx_w);
let b = bottom.resolve(&ctx_h);
let l = left.resolve(&ctx_w);
let width = (layout.width - l - r).max(0.0);
let height = (layout.height - t - b).max(0.0);
let rect = Rect::from_xywh(layout.x + l, layout.y + t, width, height);
let corners = radius
.as_ref()
.map(|br| resolve_border_radius(br, layout, ctx))
.unwrap_or([0.0; 4]);
let mut builder = PathBuilder::new();
builder.add_rrect(rrect_from_corners(rect, corners), None, None);
Some(builder.detach())
}
ClipPath::Circle { radius, origin } => {
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 mut builder = PathBuilder::new();
builder.add_circle((cx, cy), r, None);
Some(builder.detach())
}
ClipPath::Ellipse { rx, ry, origin } => {
let (cx, cy, _) = resolve_origin(origin.as_ref(), layout, ctx);
let a = rx.resolve(&ctx_w);
let b = ry.resolve(&ctx_h);
if a <= 0.0 || b <= 0.0 {
return Some(PathBuilder::new().detach());
}
let mut builder = PathBuilder::new();
builder.add_oval(
Rect::from_xywh(cx - a, cy - b, a * 2.0, b * 2.0),
None,
None,
);
Some(builder.detach())
}
ClipPath::Polygon { points } => {
if points.len() < 3 {
return Some(PathBuilder::new().detach());
}
let mut builder = PathBuilder::new();
for (i, (px, py)) in points.iter().enumerate() {
let x = layout.x + px.resolve(&ctx_w);
let y = layout.y + py.resolve(&ctx_h);
if i == 0 {
builder.move_to((x, y));
} else {
builder.line_to((x, y));
}
}
builder.close();
Some(builder.detach())
}
ClipPath::Path { d } => {
let parsed = skia_safe::Path::from_svg(d)?;
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 \
yet — reading another node's geometry needs a resolved-path lookup the paint \
pass does not have. Nothing is clipped. Use kind: path with the same data, or \
follow the tracking issue."
);
None
}
ClipPath::Morph {
from,
to,
via,
progress,
} => {
let (leg_from, leg_to, leg_t) = morph_leg(from, to, via, *progress);
morph_clip_path_to_skia(leg_from, leg_to, leg_t, layout, ctx)
}
}
}
fn clip_path_kind_name(clip: &ClipPath) -> &'static str {
match clip {
ClipPath::None => "none",
ClipPath::Inset { .. } => "inset",
ClipPath::Circle { .. } => "circle",
ClipPath::Ellipse { .. } => "ellipse",
ClipPath::Polygon { .. } => "polygon",
ClipPath::Path { .. } => "path",
ClipPath::Blob { .. } => "blob",
ClipPath::NodePath { .. } => "node-path",
ClipPath::Morph { .. } => "morph",
}
}
fn lerp_f32(a: f32, b: f32, t: f32) -> f32 {
a + (b - a) * t
}
fn morph_leg<'a>(
from: &'a ClipPath,
to: &'a ClipPath,
via: &'a [ClipPath],
progress: f32,
) -> (&'a ClipPath, &'a ClipPath, f32) {
if via.is_empty() {
return (from, to, progress);
}
let legs = via.len() + 1;
let scaled = (progress.clamp(0.0, 1.0) * legs as f32).min(legs as f32 - f32::EPSILON);
let leg = scaled.floor() as usize;
let local = scaled - leg as f32;
let leg_from = if leg == 0 { from } else { &via[leg - 1] };
let leg_to = if leg == via.len() { to } else { &via[leg] };
(leg_from, leg_to, local)
}
fn morph_clip_path_to_skia(
from: &ClipPath,
to: &ClipPath,
progress: f32,
layout: &BoxLayout,
ctx: &LengthContext,
) -> Option<skia_safe::Path> {
let ctx_w = LengthContext {
parent_size: layout.width,
..*ctx
};
let ctx_h = LengthContext {
parent_size: layout.height,
..*ctx
};
let t = progress;
match (from, to) {
(
ClipPath::Inset {
top: t0,
right: r0,
bottom: b0,
left: l0,
radius: rad0,
},
ClipPath::Inset {
top: t1,
right: r1,
bottom: b1,
left: l1,
radius: rad1,
},
) => {
let top = lerp_f32(t0.resolve(&ctx_h), t1.resolve(&ctx_h), t);
let right = lerp_f32(r0.resolve(&ctx_w), r1.resolve(&ctx_w), t);
let bottom = lerp_f32(b0.resolve(&ctx_h), b1.resolve(&ctx_h), t);
let left = lerp_f32(l0.resolve(&ctx_w), l1.resolve(&ctx_w), t);
let width = (layout.width - left - right).max(0.0);
let height = (layout.height - top - bottom).max(0.0);
let rect = Rect::from_xywh(layout.x + left, layout.y + top, width, height);
let corners0 = rad0
.as_ref()
.map(|r| resolve_border_radius(r, layout, ctx))
.unwrap_or([0.0; 4]);
let corners1 = rad1
.as_ref()
.map(|r| resolve_border_radius(r, layout, ctx))
.unwrap_or([0.0; 4]);
let corners = [
lerp_f32(corners0[0], corners1[0], t),
lerp_f32(corners0[1], corners1[1], t),
lerp_f32(corners0[2], corners1[2], t),
lerp_f32(corners0[3], corners1[3], t),
];
let mut builder = PathBuilder::new();
builder.add_rrect(rrect_from_corners(rect, corners), None, None);
Some(builder.detach())
}
(
ClipPath::Circle {
radius: r0,
origin: o0,
},
ClipPath::Circle {
radius: r1,
origin: o1,
},
) => {
let (cx0, cy0, _) = resolve_origin(o0.as_ref(), layout, ctx);
let (cx1, cy1, _) = resolve_origin(o1.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 radius = lerp_f32(r0.resolve(&reference), r1.resolve(&reference), t);
let cx = lerp_f32(cx0, cx1, t);
let cy = lerp_f32(cy0, cy1, t);
if radius <= 0.0 {
return Some(PathBuilder::new().detach());
}
let mut builder = PathBuilder::new();
builder.add_circle((cx, cy), radius, None);
Some(builder.detach())
}
(
ClipPath::Ellipse {
rx: rx0,
ry: ry0,
origin: o0,
},
ClipPath::Ellipse {
rx: rx1,
ry: ry1,
origin: o1,
},
) => {
let (cx0, cy0, _) = resolve_origin(o0.as_ref(), layout, ctx);
let (cx1, cy1, _) = resolve_origin(o1.as_ref(), layout, ctx);
let a = lerp_f32(rx0.resolve(&ctx_w), rx1.resolve(&ctx_w), t);
let b = lerp_f32(ry0.resolve(&ctx_h), ry1.resolve(&ctx_h), t);
let cx = lerp_f32(cx0, cx1, t);
let cy = lerp_f32(cy0, cy1, t);
if a <= 0.0 || b <= 0.0 {
return Some(PathBuilder::new().detach());
}
let mut builder = PathBuilder::new();
builder.add_oval(
Rect::from_xywh(cx - a, cy - b, a * 2.0, b * 2.0),
None,
None,
);
Some(builder.detach())
}
(ClipPath::Polygon { points: p0 }, ClipPath::Polygon { points: p1 }) => {
if p0.len() != p1.len() {
eprintln!(
"rustmotion: clip-path morph between two polygons with different vertex \
counts ({} vs {}) has no point-by-point correspondence to interpolate \
along. Nothing is clipped this frame — give both keyframes the same \
number of points.",
p0.len(),
p1.len()
);
return None;
}
if p0.len() < 3 {
return Some(PathBuilder::new().detach());
}
let mut builder = PathBuilder::new();
for (i, ((x0, y0), (x1, y1))) in p0.iter().zip(p1.iter()).enumerate() {
let x = layout.x + lerp_f32(x0.resolve(&ctx_w), x1.resolve(&ctx_w), t);
let y = layout.y + lerp_f32(y0.resolve(&ctx_h), y1.resolve(&ctx_h), t);
if i == 0 {
builder.move_to((x, y));
} else {
builder.line_to((x, y));
}
}
builder.close();
Some(builder.detach())
}
_ => {
eprintln!(
"rustmotion: clip-path morph needs both keyframes to be the same kind \
(inset/circle/ellipse/polygon, each with itself) — got \"{}\" and \"{}\". \
Nothing is clipped this frame.",
clip_path_kind_name(from),
clip_path_kind_name(to)
);
None
}
}
}
fn border_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect {
let rect = Rect::from_xywh(layout.x, layout.y, layout.width, layout.height);
rrect_from_corners(rect, radius)
}
fn padding_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect {
let (x, y, w, h) = layout.padding_box();
let rect = Rect::from_xywh(x, y, w, h);
let r = [
(radius[0] - layout.border.left.max(layout.border.top)).max(0.0),
(radius[1] - layout.border.right.max(layout.border.top)).max(0.0),
(radius[2] - layout.border.right.max(layout.border.bottom)).max(0.0),
(radius[3] - layout.border.left.max(layout.border.bottom)).max(0.0),
];
rrect_from_corners(rect, r)
}
fn inner_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect {
padding_rrect(layout, radius)
}
fn rrect_from_corners(rect: Rect, radius: [f32; 4]) -> RRect {
let radii = [
Point::new(radius[0], radius[0]),
Point::new(radius[1], radius[1]),
Point::new(radius[2], radius[2]),
Point::new(radius[3], radius[3]),
];
RRect::new_rect_radii(rect, &radii)
}
fn resolve_border_radius(r: &BorderRadius, layout: &BoxLayout, ctx: &LengthContext) -> [f32; 4] {
let mut local_ctx = *ctx;
local_ctx.parent_size = layout.width.min(layout.height);
match r {
BorderRadius::Uniform(v) => {
let p = v.resolve(&local_ctx);
[p, p, p, p]
}
BorderRadius::Corners {
top_left,
top_right,
bottom_right,
bottom_left,
} => [
top_left.resolve(&local_ctx),
top_right.resolve(&local_ctx),
bottom_right.resolve(&local_ctx),
bottom_left.resolve(&local_ctx),
],
}
}
fn paint_box_shadow(
canvas: &Canvas,
layout: &BoxLayout,
css: &CssStyle,
shadow: &BoxShadow,
ctx: &LengthContext,
inset: bool,
) {
let dx = shadow.offset_x.resolve(ctx);
let dy = shadow.offset_y.resolve(ctx);
let blur = shadow.blur.as_ref().map(|b| b.resolve(ctx)).unwrap_or(0.0);
let spread = shadow
.spread
.as_ref()
.map(|b| b.resolve(ctx))
.unwrap_or(0.0);
let color = shadow
.color
.as_ref()
.map(parse_color)
.unwrap_or(SColor::BLACK);
let radius = css
.border_radius
.as_ref()
.map(|r| resolve_border_radius(r, layout, ctx))
.unwrap_or([0.0; 4]);
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color(color);
if blur > 0.0 {
if let Some(filter) =
skia_safe::MaskFilter::blur(skia_safe::BlurStyle::Normal, blur / 2.0, None)
{
paint.set_mask_filter(filter);
}
}
if !inset {
let rect = Rect::from_xywh(
layout.x + dx - spread,
layout.y + dy - spread,
layout.width + spread * 2.0,
layout.height + spread * 2.0,
);
let rrect = rrect_from_corners(rect, radius);
let own_rrect = rrect_from_corners(
Rect::from_xywh(layout.x, layout.y, layout.width, layout.height),
radius,
);
canvas.save();
canvas.clip_rrect(own_rrect, ClipOp::Difference, true);
canvas.draw_rrect(rrect, &paint);
canvas.restore();
} else {
let (px, py, pw, ph) = layout.padding_box();
let outer = rrect_from_corners(Rect::from_xywh(px, py, pw, ph), radius);
canvas.save();
canvas.clip_rrect(outer, ClipOp::Intersect, true);
let inner_rect = Rect::from_xywh(
px + dx + spread,
py + dy + spread,
(pw - spread * 2.0).max(0.0),
(ph - spread * 2.0).max(0.0),
);
let inner = rrect_from_corners(inner_rect, radius);
let mut clear = Paint::default();
clear.set_color(color);
clear.set_anti_alias(true);
if blur > 0.0 {
if let Some(filter) =
skia_safe::MaskFilter::blur(skia_safe::BlurStyle::Normal, blur / 2.0, None)
{
clear.set_mask_filter(filter);
}
}
let mut path = PathBuilder::new();
path.add_rrect(outer, None, None);
path.add_rrect(inner, None, None);
path.set_fill_type(skia_safe::PathFillType::EvenOdd);
canvas.draw_path(&path.detach(), &clear);
canvas.restore();
}
}
pub fn parse_color(c: &Color) -> SColor {
match c {
Color::Rgba { r, g, b, a } => {
let alpha = (a.clamp(0.0, 1.0) * 255.0) as u8;
SColor::from_argb(alpha, *r, *g, *b)
}
Color::String(s) => parse_color_string(s).unwrap_or_else(|| unresolved_color(s)),
}
}
fn parse_color_string(s: &str) -> Option<SColor> {
crate::engine::renderer::parse_css_color(s).map(|(r, g, b, a)| SColor::from_argb(a, r, g, b))
}
fn unresolved_color(original: &str) -> SColor {
eprintln!(
"Warning: unrecognized color '{original}' — rendering as opaque magenta instead of \
silently falling back to black"
);
let (r, g, b, a) = crate::engine::renderer::UNRESOLVED_COLOR;
SColor::from_argb(a, r, g, b)
}
#[allow(dead_code)]
fn _unused_marker(_e: &Edges, _l: &LengthPercentage, _p: &ParsedLength, _f: Color4f) {}
#[cfg(test)]
mod hit_tests {
use super::*;
use std::sync::Arc;
use crate::css::style::{CssStyle, Display, FlexDirection, Position, Size as CSize};
use crate::css::taffy_bridge::ConversionContext;
use crate::css::units::LengthPercentage as CLP;
use crate::engine::box_tree::{BoxKind, BoxNode};
use crate::engine::layout_pass::run_layout;
fn test_frame(w: u32, h: u32) -> PaintFrame {
PaintFrame {
light: Default::default(),
time: 0.0,
scenario_time: 0.0,
frame_index: 0,
fps: 30,
video_width: w,
video_height: h,
scene_duration: 1.0,
camera: None,
}
}
#[test]
fn hitmap_reports_component_rect_for_untransformed_node() {
let leaf = BoxNode {
id: 0,
kind: BoxKind::Component(Arc::new(1u32)),
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(40.0)),
top: Some(CLP::Px(30.0)),
width: Some(CSize::Length(CLP::Px(100.0))),
height: Some(CSize::Length(CLP::Px(80.0))),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Flex),
flex_direction: Some(FlexDirection::Column),
width: Some(CSize::Length(CLP::Px(400.0))),
height: Some(CSize::Length(CLP::Px(400.0))),
..Default::default()
},
children: vec![leaf],
intrinsic: None,
source_path: None,
window: None,
};
root.assign_ids(0);
let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
let hits = paint_tree_with_hits(
surface.canvas(),
&root,
&layout,
&test_frame(400, 400),
&NoopDispatcher,
);
assert_eq!(hits.len(), 1, "expected exactly one component hit");
let h = &hits[0];
assert_eq!(h.node_id, root.children[0].id);
assert!((h.rect.x - 40.0).abs() < 0.5, "x = {}", h.rect.x);
assert!((h.rect.y - 30.0).abs() < 0.5, "y = {}", h.rect.y);
assert!((h.rect.w - 100.0).abs() < 0.5, "w = {}", h.rect.w);
assert!((h.rect.h - 80.0).abs() < 0.5, "h = {}", h.rect.h);
}
#[test]
fn backdrop_filter_blurs_content_behind() {
use crate::css::style::{Background, Color as CssColor, FilterFn};
use crate::css::units::Length;
let black_top = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(0.0)),
top: Some(CLP::Px(0.0)),
width: Some(CSize::Length(CLP::Px(200.0))),
height: Some(CSize::Length(CLP::Px(100.0))),
background: Some(Background::Color(CssColor::String("#000000".into()))),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let panel = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(50.0)),
top: Some(CLP::Px(50.0)),
width: Some(CSize::Length(CLP::Px(100.0))),
height: Some(CSize::Length(CLP::Px(100.0))),
backdrop_filter: Some(vec![FilterFn::Blur {
radius: Some(Length::Px(10.0)),
radius_x: None,
radius_y: None,
}]),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Flex),
width: Some(CSize::Length(CLP::Px(200.0))),
height: Some(CSize::Length(CLP::Px(200.0))),
background: Some(Background::Color(CssColor::String("#ffffff".into()))),
..Default::default()
},
children: vec![black_top, panel],
intrinsic: None,
source_path: None,
window: None,
};
root.assign_ids(0);
let layout = run_layout(&root, (200.0, 200.0), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).unwrap();
paint_tree(
surface.canvas(),
&root,
&layout,
&test_frame(200, 200),
&NoopDispatcher,
);
let info = skia_safe::ImageInfo::new(
(200, 200),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; 200 * 200 * 4];
assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0)));
let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32;
assert!(red(10, 97) < 10, "outside/above must stay black");
assert!(red(10, 103) > 245, "outside/below must stay white");
let above = red(100, 97);
let below = red(100, 103);
assert!(
above > 30,
"backdrop not blurred above boundary (r={above})"
);
assert!(
below < 225,
"backdrop not blurred below boundary (r={below})"
);
}
#[test]
fn backdrop_filter_survives_sibling_opacity_below_one() {
use crate::css::style::{Background, Color as CssColor, FilterFn};
use crate::css::units::Length;
let black_top = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(0.0)),
top: Some(CLP::Px(0.0)),
width: Some(CSize::Length(CLP::Px(200.0))),
height: Some(CSize::Length(CLP::Px(100.0))),
background: Some(Background::Color(CssColor::String("#000000".into()))),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let panel = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(50.0)),
top: Some(CLP::Px(50.0)),
width: Some(CSize::Length(CLP::Px(100.0))),
height: Some(CSize::Length(CLP::Px(100.0))),
backdrop_filter: Some(vec![FilterFn::Blur {
radius: Some(Length::Px(10.0)),
radius_x: None,
radius_y: None,
}]),
opacity: Some(0.99),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Flex),
width: Some(CSize::Length(CLP::Px(200.0))),
height: Some(CSize::Length(CLP::Px(200.0))),
background: Some(Background::Color(CssColor::String("#ffffff".into()))),
..Default::default()
},
children: vec![black_top, panel],
intrinsic: None,
source_path: None,
window: None,
};
root.assign_ids(0);
let layout = run_layout(&root, (200.0, 200.0), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).unwrap();
paint_tree(
surface.canvas(),
&root,
&layout,
&test_frame(200, 200),
&NoopDispatcher,
);
let info = skia_safe::ImageInfo::new(
(200, 200),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; 200 * 200 * 4];
assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0)));
let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32;
assert!(red(10, 97) < 10, "outside/above must stay black");
assert!(red(10, 103) > 245, "outside/below must stay white");
let above = red(100, 97);
let below = red(100, 103);
assert!(
above > 30,
"backdrop not blurred above boundary with opacity:0.99 (r={above})"
);
assert!(
below < 225,
"backdrop not blurred below boundary with opacity:0.99 (r={below})"
);
}
#[test]
fn hitmap_reflects_node_transform() {
use crate::css::style::TransformFn;
let leaf = BoxNode {
id: 0,
kind: BoxKind::Component(Arc::new(1u32)),
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(40.0)),
top: Some(CLP::Px(30.0)),
width: Some(CSize::Length(CLP::Px(100.0))),
height: Some(CSize::Length(CLP::Px(80.0))),
transform: Some(vec![TransformFn::Scale { x: 2.0, y: 2.0 }]),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Flex),
flex_direction: Some(FlexDirection::Column),
width: Some(CSize::Length(CLP::Px(400.0))),
height: Some(CSize::Length(CLP::Px(400.0))),
..Default::default()
},
children: vec![leaf],
intrinsic: None,
source_path: None,
window: None,
};
root.assign_ids(0);
let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
let hits = paint_tree_with_hits(
surface.canvas(),
&root,
&layout,
&test_frame(400, 400),
&NoopDispatcher,
);
assert_eq!(hits.len(), 1);
let h = &hits[0];
assert!((h.rect.w - 200.0).abs() < 1.0, "w = {}", h.rect.w);
assert!((h.rect.h - 160.0).abs() < 1.0, "h = {}", h.rect.h);
}
}
#[cfg(test)]
mod transform_origin_tests {
use super::*;
use crate::css::style::{
Background, Color as CssColor, CssStyle, Display, FlexDirection, Position, Size as CSize,
TransformFn, TransformOrigin,
};
use crate::css::taffy_bridge::ConversionContext;
use crate::css::units::LengthPercentage as CLP;
use crate::engine::box_tree::{BoxKind, BoxNode};
use crate::engine::layout_pass::run_layout;
fn test_frame(w: u32, h: u32) -> PaintFrame {
PaintFrame {
light: Default::default(),
time: 0.0,
scenario_time: 0.0,
frame_index: 0,
fps: 30,
video_width: w,
video_height: h,
scene_duration: 1.0,
camera: None,
}
}
fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec<u8> {
root.assign_ids(0);
let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
paint_tree(
surface.canvas(),
root,
&layout,
&test_frame(w, h),
&NoopDispatcher,
);
let info = skia_safe::ImageInfo::new(
(w as i32, h as i32),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; (w * h * 4) as usize];
surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
buf
}
fn r(buf: &[u8], w: u32, x: u32, y: u32) -> u8 {
buf[((y * w + x) * 4) as usize]
}
fn count_red(buf: &[u8]) -> usize {
buf.chunks(4)
.filter(|px| px[0] > 200 && px[1] < 50 && px[2] < 50)
.count()
}
fn red_centroid_x(buf: &[u8], w: u32, h: u32) -> f32 {
let mut sum_x = 0.0f64;
let mut count = 0.0f64;
for y in 0..h {
for x in 0..w {
if r(buf, w, x, y) > 200
&& buf[((y * w + x) * 4 + 1) as usize] < 50
&& buf[((y * w + x) * 4 + 2) as usize] < 50
{
sum_x += x as f64;
count += 1.0;
}
}
}
if count == 0.0 {
0.0
} else {
(sum_x / count) as f32
}
}
fn red_centroid_y(buf: &[u8], w: u32, h: u32) -> f32 {
let mut sum_y = 0.0f64;
let mut count = 0.0f64;
for y in 0..h {
for x in 0..w {
if r(buf, w, x, y) > 200
&& buf[((y * w + x) * 4 + 1) as usize] < 50
&& buf[((y * w + x) * 4 + 2) as usize] < 50
{
sum_y += y as f64;
count += 1.0;
}
}
}
if count == 0.0 {
0.0
} else {
(sum_y / count) as f32
}
}
fn red_box(position: Position, x: f32, y: f32, w: f32, h: f32) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(position),
left: Some(CLP::Px(x)),
top: Some(CLP::Px(y)),
width: Some(CSize::Length(CLP::Px(w))),
height: Some(CSize::Length(CLP::Px(h))),
background: Some(Background::Color(CssColor::String("#ff0000".into()))),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn root_node(w: f32, h: f32, children: Vec<BoxNode>) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Flex),
flex_direction: Some(FlexDirection::Column),
width: Some(CSize::Length(CLP::Px(w))),
height: Some(CSize::Length(CLP::Px(h))),
..Default::default()
},
children,
intrinsic: None,
source_path: None,
window: None,
}
}
#[test]
fn no_transform_origin_unchanged_non_regression() {
let mut root = root_node(
300.0,
300.0,
vec![{
let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0);
n.css.transform = Some(vec![TransformFn::Scale { x: 1.0, y: 1.0 }]);
n
}],
);
let buf = render_pixels(&mut root, 300, 300);
let cx = red_centroid_x(&buf, 300, 300);
let cy = red_centroid_y(&buf, 300, 300);
assert!((cx - 99.5).abs() < 2.0, "cx={cx}");
assert!((cy - 99.5).abs() < 2.0, "cy={cy}");
}
#[test]
fn transform_origin_50pct_is_center_identity() {
let make_root = |with_origin: bool| -> Vec<u8> {
let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0);
n.css.transform = Some(vec![TransformFn::Rotate { deg: 45.0 }]);
if with_origin {
n.css.transform_origin = Some(TransformOrigin {
x: Some(CLP::String("50%".into())),
y: Some(CLP::String("50%".into())),
z: None,
});
}
let mut root = root_node(300.0, 300.0, vec![n]);
render_pixels(&mut root, 300, 300)
};
let without = make_root(false);
let with_50 = make_root(true);
assert_eq!(
without, with_50,
"transform-origin: 50% 50% must be byte-identical to absent origin"
);
}
#[test]
fn rotate_90_left_top_vs_center_occupy_different_quadrants() {
let make_root_with_origin = |ox: Option<CLP>, oy: Option<CLP>| -> Vec<u8> {
let mut n = red_box(Position::Absolute, 100.0, 100.0, 100.0, 100.0);
n.css.transform = Some(vec![TransformFn::Rotate { deg: 90.0 }]);
if ox.is_some() || oy.is_some() {
n.css.transform_origin = Some(TransformOrigin {
x: ox,
y: oy,
z: None,
});
}
let mut root = root_node(400.0, 400.0, vec![n]);
render_pixels(&mut root, 400, 400)
};
let buf_center = make_root_with_origin(None, None);
let buf_left_top = make_root_with_origin(
Some(CLP::String("0%".into())),
Some(CLP::String("0%".into())),
);
let cx_center = red_centroid_x(&buf_center, 400, 400);
let cy_center = red_centroid_y(&buf_center, 400, 400);
let cx_lt = red_centroid_x(&buf_left_top, 400, 400);
let cy_lt = red_centroid_y(&buf_left_top, 400, 400);
assert!(
(cx_center - 150.0).abs() < 5.0,
"center-pivot cx should be ~150, got {cx_center}"
);
assert!(
(cy_center - 150.0).abs() < 5.0,
"center-pivot cy should be ~150, got {cy_center}"
);
assert!(
cx_lt < cx_center - 50.0,
"left-top pivot cx ({cx_lt}) should be well left of center-pivot cx ({cx_center})"
);
let dist = ((cx_lt - cx_center).powi(2) + (cy_lt - cy_center).powi(2)).sqrt();
assert!(
dist > 50.0,
"pivots should produce clearly distinct positions (dist={dist})"
);
}
#[test]
fn keyword_left_top_equals_zero_percent() {
let make_root = |origin_x: CLP, origin_y: CLP| -> Vec<u8> {
let mut n = red_box(Position::Absolute, 100.0, 100.0, 100.0, 100.0);
n.css.transform = Some(vec![TransformFn::Rotate { deg: 45.0 }]);
n.css.transform_origin = Some(TransformOrigin {
x: Some(origin_x),
y: Some(origin_y),
z: None,
});
let mut root = root_node(400.0, 400.0, vec![n]);
render_pixels(&mut root, 400, 400)
};
let buf_kw = make_root(CLP::String("left".into()), CLP::String("top".into()));
let buf_pct = make_root(CLP::String("0%".into()), CLP::String("0%".into()));
assert_eq!(
buf_kw, buf_pct,
"keyword 'left top' must produce same pixels as '0% 0%'"
);
}
#[test]
fn keyword_right_bottom_equals_100_percent() {
let make_root = |origin_x: CLP, origin_y: CLP| -> Vec<u8> {
let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0);
n.css.transform = Some(vec![TransformFn::Scale { x: 1.5, y: 1.5 }]);
n.css.transform_origin = Some(TransformOrigin {
x: Some(origin_x),
y: Some(origin_y),
z: None,
});
let mut root = root_node(400.0, 400.0, vec![n]);
render_pixels(&mut root, 400, 400)
};
let buf_kw = make_root(CLP::String("right".into()), CLP::String("bottom".into()));
let buf_pct = make_root(CLP::String("100%".into()), CLP::String("100%".into()));
assert_eq!(
buf_kw, buf_pct,
"keyword 'right bottom' must produce same pixels as '100% 100%'"
);
}
#[test]
fn rotate_y_left_origin_left_edge_is_stable() {
let make_root = |origin_x: Option<CLP>| -> Vec<u8> {
let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0);
n.css.transform = Some(vec![TransformFn::RotateY { deg: 45.0 }]);
if let Some(ox) = origin_x {
n.css.transform_origin = Some(TransformOrigin {
x: Some(ox),
y: Some(CLP::String("50%".into())),
z: None,
});
}
let mut root = root_node(500.0, 500.0, vec![n]);
render_pixels(&mut root, 500, 500)
};
let buf_left = make_root(Some(CLP::String("0%".into())));
let buf_center = make_root(None);
fn leftmost_red(buf: &[u8], w: u32, h: u32) -> Option<u32> {
for x in 0..w {
for y in 0..h {
if buf[((y * w + x) * 4) as usize] > 200
&& buf[((y * w + x) * 4 + 1) as usize] < 50
&& buf[((y * w + x) * 4 + 2) as usize] < 50
{
return Some(x);
}
}
}
None
}
let left_edge_left =
leftmost_red(&buf_left, 500, 500).expect("no red pixels in left-origin render") as f32;
let left_edge_center = leftmost_red(&buf_center, 500, 500)
.expect("no red pixels in center-origin render") as f32;
assert!(
left_edge_left > 90.0 && left_edge_left < 115.0,
"left-origin left edge should be near 100, got {left_edge_left}"
);
assert!(
left_edge_center > left_edge_left + 20.0,
"center-origin left edge ({left_edge_center}) should be further right than left-origin ({left_edge_left})"
);
}
#[test]
fn different_perspective_and_transform_origins_no_panic() {
use crate::css::units::Length;
let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0);
n.css.transform = Some(vec![TransformFn::RotateY { deg: 30.0 }]);
n.css.perspective = Some(Length::Px(800.0));
n.css.transform_origin = Some(TransformOrigin {
x: Some(CLP::String("100%".into())),
y: Some(CLP::String("100%".into())),
z: None,
});
n.css.perspective_origin = Some(TransformOrigin {
x: Some(CLP::String("0%".into())),
y: Some(CLP::String("0%".into())),
z: None,
});
let mut root = root_node(500.0, 500.0, vec![n]);
let buf = render_pixels(&mut root, 500, 500);
let red_count = count_red(&buf);
assert!(
red_count > 0,
"expected some red pixels with distinct origins"
);
}
#[test]
fn translate_percent_resolves_against_own_axis_not_max_dimension() {
let mut n = red_box(Position::Absolute, 0.0, 0.0, 200.0, 100.0);
n.css.transform = Some(vec![TransformFn::Translate {
x: CLP::String("50%".into()),
y: CLP::String("50%".into()),
}]);
let mut root = root_node(400.0, 400.0, vec![n]);
let buf = render_pixels(&mut root, 400, 400);
let mut min_x = u32::MAX;
let mut max_x = 0u32;
let mut min_y = u32::MAX;
let mut max_y = 0u32;
for y in 0..400u32 {
for x in 0..400u32 {
let i = ((y * 400 + x) * 4) as usize;
if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 {
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
}
}
}
assert_ne!(min_x, u32::MAX, "expected some red pixels");
assert!((min_x as i32 - 100).abs() <= 2, "min_x={min_x}");
assert!((max_x as i32 - 299).abs() <= 2, "max_x={max_x}");
assert!(
(min_y as i32 - 50).abs() <= 2,
"min_y={min_y} (expected ~50; the max(w,h) bug would give ~100)"
);
assert!(
(max_y as i32 - 149).abs() <= 2,
"max_y={max_y} (expected ~149; the max(w,h) bug would give ~199)"
);
}
}
#[cfg(test)]
mod glassmorphism_tests {
use super::*;
use crate::css::style::{
Background, Color as CssColor, CssStyle, Display, FilterFn, FlexDirection, Position,
Size as CSize,
};
use crate::css::taffy_bridge::ConversionContext;
use crate::css::units::LengthPercentage as CLP;
use crate::engine::box_tree::{BoxKind, BoxNode};
use crate::engine::layout_pass::run_layout;
use crate::schema::GradientBorder;
fn test_frame(w: u32, h: u32) -> PaintFrame {
PaintFrame {
light: Default::default(),
time: 0.0,
scenario_time: 0.0,
frame_index: 0,
fps: 30,
video_width: w,
video_height: h,
scene_duration: 1.0,
camera: None,
}
}
fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec<u8> {
root.assign_ids(0);
let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
paint_tree(
surface.canvas(),
root,
&layout,
&test_frame(w, h),
&NoopDispatcher,
);
let info = skia_safe::ImageInfo::new(
(w as i32, h as i32),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; (w * h * 4) as usize];
surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
buf
}
fn px(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8, u8) {
let i = ((y * w + x) * 4) as usize;
(buf[i], buf[i + 1], buf[i + 2], buf[i + 3])
}
fn unique_colors_in(
buf: &[u8],
w: u32,
x0: u32,
y0: u32,
x1: u32,
y1: u32,
) -> std::collections::HashSet<(u8, u8, u8, u8)> {
let mut set = std::collections::HashSet::new();
for y in y0..y1 {
for x in x0..x1 {
set.insert(px(buf, w, x, y));
}
}
set
}
fn leaf(css: CssStyle) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css,
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn root_node(w: f32, h: f32, background: Option<&str>, children: Vec<BoxNode>) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Flex),
flex_direction: Some(FlexDirection::Column),
width: Some(CSize::Length(CLP::Px(w))),
height: Some(CSize::Length(CLP::Px(h))),
background: background.map(|c| Background::Color(CssColor::String(c.to_string()))),
..Default::default()
},
children,
intrinsic: None,
source_path: None,
window: None,
}
}
fn abs_box(x: f32, y: f32, w: f32, h: f32, css_extra: CssStyle) -> BoxNode {
let mut css = css_extra;
css.position = Some(Position::Absolute);
css.left = Some(CLP::Px(x));
css.top = Some(CLP::Px(y));
css.width = Some(CSize::Length(CLP::Px(w)));
css.height = Some(CSize::Length(CLP::Px(h)));
leaf(css)
}
#[test]
fn gradient_border_paints_both_colors_on_perimeter_center_intact() {
let node = abs_box(
100.0,
100.0,
200.0,
200.0,
CssStyle {
gradient_border: Some(GradientBorder {
colors: vec!["#ff0000".into(), "#0000ff".into()],
width: 12.0,
angle: 90.0,
}),
..Default::default()
},
);
let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]);
let buf = render_pixels(&mut root, 400, 400);
let left = px(&buf, 400, 106, 200);
let right = px(&buf, 400, 294, 200);
let red_side = if left.0 > left.2 { left } else { right };
let blue_side = if left.0 > left.2 { right } else { left };
assert!(
red_side.0 > 150 && red_side.2 < 100,
"expected a red-dominant border edge, got {red_side:?}"
);
assert!(
blue_side.2 > 150 && blue_side.0 < 100,
"expected a blue-dominant border edge, got {blue_side:?}"
);
assert_ne!(
left, right,
"border edges must show different gradient stops"
);
let center = px(&buf, 400, 200, 200);
assert_eq!(
center,
(255, 255, 255, 255),
"box centre must not be painted by the gradient border"
);
let top_mid = px(&buf, 400, 200, 106);
assert_ne!(
top_mid,
(255, 255, 255, 255),
"top border edge must be painted"
);
}
#[test]
fn gradient_border_respects_border_radius() {
use crate::css::style::BorderRadius;
let node = abs_box(
100.0,
100.0,
200.0,
200.0,
CssStyle {
border_radius: Some(BorderRadius::Uniform(CLP::Px(60.0))),
gradient_border: Some(GradientBorder {
colors: vec!["#ff0000".into(), "#0000ff".into()],
width: 10.0,
angle: 90.0,
}),
..Default::default()
},
);
let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]);
let buf = render_pixels(&mut root, 400, 400);
let corner = px(&buf, 400, 104, 104);
assert_eq!(
corner,
(255, 255, 255, 255),
"square corner must stay background with border-radius"
);
let left_mid = px(&buf, 400, 104, 200);
let top_mid = px(&buf, 400, 200, 104);
assert_ne!(left_mid, (255, 255, 255, 255), "left edge must be painted");
assert_ne!(top_mid, (255, 255, 255, 255), "top edge must be painted");
}
#[test]
fn gradient_border_replaces_standard_border() {
use crate::css::style::{BorderEdges, BorderStyle, Edges};
let node = abs_box(
100.0,
100.0,
200.0,
200.0,
CssStyle {
border: Some(BorderEdges {
width: Some(Edges::Uniform(CLP::Px(10.0))),
style: Some(BorderStyle::Solid),
color: Some(CssColor::String("#00ff00".into())),
..Default::default()
}),
gradient_border: Some(GradientBorder {
colors: vec!["#ff0000".into(), "#0000ff".into()],
width: 10.0,
angle: 90.0,
}),
..Default::default()
},
);
let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]);
let buf = render_pixels(&mut root, 400, 400);
let green_pixels = buf
.chunks(4)
.filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60)
.count();
assert_eq!(
green_pixels, 0,
"standard border must not be painted when gradient-border is set"
);
}
fn gray_box_with_filter(filter: Option<Vec<FilterFn>>) -> BoxNode {
abs_box(
50.0,
50.0,
200.0,
200.0,
CssStyle {
background: Some(Background::Color(CssColor::String("#808080".into()))),
filter,
..Default::default()
},
)
}
#[test]
fn noise_filter_explodes_unique_color_count() {
let mut root_plain = root_node(
300.0,
300.0,
Some("#ffffff"),
vec![gray_box_with_filter(None)],
);
let buf_plain = render_pixels(&mut root_plain, 300, 300);
let mut root_noise = root_node(
300.0,
300.0,
Some("#ffffff"),
vec![gray_box_with_filter(Some(vec![FilterFn::Noise {
intensity: 0.5,
seed: 42,
}]))],
);
let buf_noise = render_pixels(&mut root_noise, 300, 300);
let plain = unique_colors_in(&buf_plain, 300, 70, 70, 230, 230);
let noisy = unique_colors_in(&buf_noise, 300, 70, 70, 230, 230);
assert!(
plain.len() <= 4,
"plain box interior should be near-uniform, got {} colors",
plain.len()
);
assert!(
noisy.len() > 50,
"noise filter should explode the unique color count, got {}",
noisy.len()
);
}
#[test]
fn noise_same_seed_is_byte_identical_across_renders() {
let render = || {
let mut root = root_node(
300.0,
300.0,
Some("#ffffff"),
vec![gray_box_with_filter(Some(vec![FilterFn::Noise {
intensity: 0.4,
seed: 7,
}]))],
);
render_pixels(&mut root, 300, 300)
};
let a = render();
let b = render();
assert_eq!(a, b, "same seed must produce byte-identical grain");
}
#[test]
fn noise_different_seeds_differ() {
let render = |seed: u64| {
let mut root = root_node(
300.0,
300.0,
Some("#ffffff"),
vec![gray_box_with_filter(Some(vec![FilterFn::Noise {
intensity: 0.4,
seed,
}]))],
);
render_pixels(&mut root, 300, 300)
};
let a = render(1);
let b = render(2);
assert_ne!(a, b, "different seeds must produce different grain");
}
#[test]
fn backdrop_noise_grains_only_the_panel_region() {
let panel = abs_box(
50.0,
50.0,
100.0,
100.0,
CssStyle {
backdrop_filter: Some(vec![FilterFn::Noise {
intensity: 0.5,
seed: 42,
}]),
..Default::default()
},
);
let mut root = root_node(300.0, 300.0, Some("#808080"), vec![panel]);
let buf = render_pixels(&mut root, 300, 300);
let inside = unique_colors_in(&buf, 300, 60, 60, 140, 140);
let outside = unique_colors_in(&buf, 300, 180, 180, 280, 280);
assert!(
inside.len() > 30,
"panel interior should be grained, got {} colors",
inside.len()
);
assert_eq!(
outside.len(),
1,
"outside the panel must stay uniform, got {} colors",
outside.len()
);
}
#[test]
fn css_gradient_border_and_noise_deserialize() {
let json = r##"{
"gradient-border": { "colors": ["#ff0000", "#0000ff"], "width": 3, "angle": 45 },
"filter": [{ "fn": "noise", "intensity": 0.3, "seed": 9 }],
"backdrop-filter": [{ "fn": "noise" }]
}"##;
let s: CssStyle = serde_json::from_str(json).unwrap();
let gb = s.gradient_border.expect("gradient-border parsed");
assert_eq!(gb.colors.len(), 2);
assert_eq!(gb.width, 3.0);
assert_eq!(gb.angle, 45.0);
assert!(matches!(
s.filter.as_deref(),
Some([FilterFn::Noise {
intensity,
seed: 9
}]) if (intensity - 0.3).abs() < 1e-6
));
assert!(matches!(
s.backdrop_filter.as_deref(),
Some([FilterFn::Noise {
intensity,
seed: 42
}]) if (intensity - 0.15).abs() < 1e-6
));
}
#[test]
fn css_legacy_zombies_accepted() {
let json = r##"{
"backdrop-blur": 20,
"inner-shadow": { "color": "#000000", "offset_x": 0, "offset_y": 2, "blur": 8 }
}"##;
let s: CssStyle = serde_json::from_str(json).unwrap();
assert_eq!(s.backdrop_blur, Some(20.0));
assert!(s.inner_shadow.is_some());
}
}
#[cfg(test)]
mod paint_order_tests {
use super::*;
use crate::css::style::{
Background, BoxShadow, ClipPath, Color as CssColor, CssStyle, Display, FilterFn,
FlexDirection, Overflow, Position, Size as CSize,
};
use crate::css::taffy_bridge::ConversionContext;
use crate::css::units::{Length, LengthPercentage as CLP};
use crate::engine::box_tree::{BoxKind, BoxNode};
use crate::engine::layout_pass::run_layout;
use crate::schema::{
AnimationEffect, BurstConfig, ChromaticAberrationConfig, EasingType, ShatterConfig,
ShatterMode, ShatterOrigin,
};
fn test_frame(w: u32, h: u32) -> PaintFrame {
PaintFrame {
light: Default::default(),
time: 0.0,
scenario_time: 0.0,
frame_index: 0,
fps: 30,
video_width: w,
video_height: h,
scene_duration: 1.0,
camera: None,
}
}
fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec<u8> {
root.assign_ids(0);
let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
paint_tree(
surface.canvas(),
root,
&layout,
&test_frame(w, h),
&NoopDispatcher,
);
let info = skia_safe::ImageInfo::new(
(w as i32, h as i32),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; (w * h * 4) as usize];
surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
buf
}
fn root_node(w: f32, h: f32, background: &str, children: Vec<BoxNode>) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Flex),
flex_direction: Some(FlexDirection::Column),
width: Some(CSize::Length(CLP::Px(w))),
height: Some(CSize::Length(CLP::Px(h))),
background: Some(Background::Color(CssColor::String(background.to_string()))),
..Default::default()
},
children,
intrinsic: None,
source_path: None,
window: None,
}
}
fn count_red_in(buf: &[u8], w: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
let mut n = 0;
for y in y0..y1 {
for x in x0..x1 {
let i = ((y * w + x) * 4) as usize;
if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 {
n += 1;
}
}
}
n
}
fn clipped_square(clip: Option<ClipPath>) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(0.0)),
top: Some(CLP::Px(0.0)),
width: Some(CSize::Length(CLP::Px(400.0))),
height: Some(CSize::Length(CLP::Px(400.0))),
background: Some(Background::Color(CssColor::String("#ff0000".into()))),
clip_path: clip,
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn is_red_at(clip: Option<ClipPath>, x: u32, y: u32) -> bool {
let mut root = root_node(400.0, 400.0, "#000000", vec![clipped_square(clip)]);
let buf = render_pixels(&mut root, 400, 400);
let i = ((y * 400 + x) * 4) as usize;
buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50
}
#[test]
fn clip_path_inset_removes_the_region_outside_it() {
assert!(
is_red_at(None, 350, 200),
"sanity: unclipped, x=350 is inside the square"
);
let inset = ClipPath::Inset {
top: CLP::Px(0.0),
right: CLP::Px(120.0),
bottom: CLP::Px(0.0),
left: CLP::Px(0.0),
radius: None,
};
assert!(
is_red_at(Some(inset.clone()), 200, 200),
"inside the inset box must still paint"
);
assert!(
!is_red_at(Some(inset), 330, 200),
"inset right:120 must remove x=330 — this is the sample from the issue that \
stayed painted while clip-path was read by nothing"
);
}
#[test]
fn clip_path_circle_keeps_the_centre_and_drops_the_corner() {
let circle = ClipPath::Circle {
radius: CLP::Px(100.0),
origin: None,
};
assert!(
is_red_at(Some(circle.clone()), 200, 200),
"the centre is inside a centred r=100 circle"
);
assert!(
!is_red_at(Some(circle), 20, 20),
"the top-left corner is outside a centred r=100 circle"
);
}
#[test]
fn clip_path_ellipse_is_wider_than_it_is_tall() {
let ellipse = ClipPath::Ellipse {
rx: CLP::Px(180.0),
ry: CLP::Px(40.0),
origin: None,
};
assert!(
is_red_at(Some(ellipse.clone()), 360, 200),
"x=360 is within rx=180 of the centre"
);
assert!(
!is_red_at(Some(ellipse), 200, 360),
"y=360 is outside ry=40 of the centre"
);
}
#[test]
fn clip_path_polygon_cuts_a_triangle() {
let triangle = ClipPath::Polygon {
points: vec![
(CLP::Px(200.0), CLP::Px(0.0)),
(CLP::Px(400.0), CLP::Px(400.0)),
(CLP::Px(0.0), CLP::Px(400.0)),
],
};
assert!(
is_red_at(Some(triangle.clone()), 200, 300),
"low centre is inside the triangle"
);
assert!(
!is_red_at(Some(triangle), 20, 20),
"the top-left corner is outside the triangle"
);
}
#[test]
fn clip_path_path_takes_svg_data_relative_to_the_box() {
let left_half = ClipPath::Path {
d: "M0 0 L200 0 L200 400 L0 400 Z".to_string(),
};
assert!(
is_red_at(Some(left_half.clone()), 100, 200),
"the left half stays"
);
assert!(
!is_red_at(Some(left_half), 300, 200),
"the right half is clipped away"
);
}
#[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)] {
assert_eq!(
is_red_at(Some(ClipPath::None), x, y),
is_red_at(None, x, y),
"clip-path: none must be indistinguishable from absent at ({x}, {y})"
);
}
}
#[test]
fn clip_path_clips_the_background_and_the_outset_shadow_too() {
let mut node = clipped_square(Some(ClipPath::Inset {
top: CLP::Px(0.0),
right: CLP::Px(200.0),
bottom: CLP::Px(0.0),
left: CLP::Px(0.0),
radius: None,
}));
node.css.width = Some(CSize::Length(CLP::Px(200.0)));
node.css.height = Some(CSize::Length(CLP::Px(200.0)));
node.css.left = Some(CLP::Px(100.0));
node.css.top = Some(CLP::Px(100.0));
node.css.background = Some(Background::Color(CssColor::String("#ffffff".into())));
node.css.box_shadow = Some(vec![BoxShadow {
offset_x: Length::Px(0.0),
offset_y: Length::Px(0.0),
blur: None,
spread: Some(Length::Px(40.0)),
color: Some(CssColor::String("#ff0000".into())),
inset: None,
}]);
let mut root = root_node(400.0, 400.0, "#000000", vec![node]);
let buf = render_pixels(&mut root, 400, 400);
let right_of_the_cut = count_red_in(&buf, 400, 260, 100, 400, 300);
assert_eq!(
right_of_the_cut, 0,
"clip-path clips the element itself, shadow included — unlike overflow:hidden, \
which clips only the content and deliberately spares the node's own outset shadow"
);
}
#[test]
fn animated_border_radius_reaches_the_paint_pass() {
use crate::css::apply_animated_props;
use crate::engine::animator::AnimatedProperties;
let mut css = CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(100.0)),
top: Some(CLP::Px(50.0)),
width: Some(CSize::Length(CLP::Px(200.0))),
height: Some(CSize::Length(CLP::Px(200.0))),
background: Some(Background::Color(CssColor::String("#000000".into()))),
..Default::default()
};
apply_animated_props(
&mut css,
&AnimatedProperties {
border_radius: 100.0,
..AnimatedProperties::default()
},
);
let node = BoxNode {
id: 0,
kind: BoxKind::Container,
css,
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = root_node(400.0, 300.0, "#ffffff", vec![node]);
let buf = render_pixels(&mut root, 400, 300);
let corner = probe(&buf, 400, 105, 55);
assert_eq!(
corner,
(255, 255, 255),
"a border-radius produced by an animation (not a literal style value) must round \
the square's corner away — got {corner:?}, meaning the animated value never \
reached css.border_radius"
);
}
fn bordered_box(w: f32, h: f32, style: BorderStyle) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(20.0)),
top: Some(CLP::Px(20.0)),
width: Some(CSize::Length(CLP::Px(w))),
height: Some(CSize::Length(CLP::Px(h))),
border: Some(BorderEdges {
width: Some(Edges::Uniform(CLP::Px(8.0))),
style: Some(style),
color: Some(CssColor::String("#000000".into())),
..Default::default()
}),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn count_background_gaps_on_top_edge(buf: &[u8], w: u32, x0: u32, x1: u32, y: u32) -> usize {
let mut gaps = 0;
let mut in_gap = false;
for x in x0..x1 {
let i = ((y * w + x) * 4) as usize;
let is_background = buf[i] > 200 && buf[i + 1] > 200 && buf[i + 2] > 200;
if is_background {
if !in_gap {
gaps += 1;
}
in_gap = true;
} else {
in_gap = false;
}
}
gaps
}
#[test]
fn dashed_border_leaves_background_coloured_gaps_along_the_edge() {
let mut solid_root = root_node(
400.0,
400.0,
"#ffffff",
vec![bordered_box(300.0, 100.0, BorderStyle::Solid)],
);
let solid_buf = render_pixels(&mut solid_root, 400, 400);
let solid_gaps = count_background_gaps_on_top_edge(&solid_buf, 400, 40, 300, 24);
assert_eq!(
solid_gaps, 0,
"a solid border's top edge must be painted over its full length — got {solid_gaps} \
background-coloured gaps"
);
let mut dashed_root = root_node(
400.0,
400.0,
"#ffffff",
vec![bordered_box(300.0, 100.0, BorderStyle::Dashed)],
);
let dashed_buf = render_pixels(&mut dashed_root, 400, 400);
let dashed_gaps = count_background_gaps_on_top_edge(&dashed_buf, 400, 40, 300, 24);
assert!(
dashed_gaps > 0,
"a dashed border must leave background-coloured gaps along its top edge — got \
{dashed_gaps}, meaning border.style was read only to gate BorderStyle::None and \
every other variant painted the same solid ring"
);
}
#[test]
fn dotted_and_double_borders_are_not_painted_as_a_solid_fill() {
for style in [BorderStyle::Dotted, BorderStyle::Double] {
let mut root = root_node(
400.0,
400.0,
"#ffffff",
vec![bordered_box(300.0, 100.0, style)],
);
let buf = render_pixels(&mut root, 400, 400);
let gaps = count_background_gaps_on_top_edge(&buf, 400, 40, 300, 24);
assert!(
gaps > 0,
"{style:?} border must not paint a solid filled ring — got {gaps} \
background-coloured gaps along the top edge"
);
}
}
fn material_tile(material: Option<Material>) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(100.0)),
top: Some(CLP::Px(100.0)),
width: Some(CSize::Length(CLP::Px(200.0))),
height: Some(CSize::Length(CLP::Px(200.0))),
background: Some(Background::Color(CssColor::String("#808080".into()))),
material,
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn render_lit(node: BoxNode, light: Option<LightDirection>) -> Vec<u8> {
let mut root = root_node(400.0, 400.0, "#000000", vec![node]);
root.assign_ids(0);
let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
let mut frame = test_frame(400, 400);
if let Some(l) = light {
frame.light = l;
}
paint_tree(surface.canvas(), &root, &layout, &frame, &NoopDispatcher);
let info = skia_safe::ImageInfo::new(
(400, 400),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; 400 * 400 * 4];
surface.read_pixels(&info, &mut buf, 400 * 4, (0, 0));
buf
}
fn luma_at(buf: &[u8], x: usize, y: usize) -> f32 {
let i = (y * 400 + x) * 4;
0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32
}
fn morph_chain(progress: f32) -> BoxNode {
let square = |inset: f32| ClipPath::Inset {
top: CLP::Px(inset),
right: CLP::Px(inset),
bottom: CLP::Px(inset),
left: CLP::Px(inset),
radius: None,
};
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(0.0)),
top: Some(CLP::Px(0.0)),
width: Some(CSize::Length(CLP::Px(200.0))),
height: Some(CSize::Length(CLP::Px(200.0))),
background: Some(Background::Color(CssColor::String("#ff0000".into()))),
clip_path: Some(ClipPath::Morph {
from: Box::new(square(0.0)),
to: Box::new(square(0.0)),
via: vec![square(80.0)],
progress,
}),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn red_pixels(buf: &[u8]) -> usize {
buf.as_chunks::<4>()
.0
.iter()
.filter(|px| px[0] > 200 && px[1] < 60)
.count()
}
#[test]
fn a_morph_chain_passes_through_its_via_shape_at_the_midpoint() {
let start = red_pixels(&render_lit(morph_chain(0.0), None));
let middle = red_pixels(&render_lit(morph_chain(0.5), None));
let end = red_pixels(&render_lit(morph_chain(1.0), None));
assert!(
middle * 3 < start,
"with one `via` shape, progress 0.5 lands exactly on it — an 80px inset of a \
200px box, so far smaller than either end: start={start}px, middle={middle}px"
);
assert!(
(start as i64 - end as i64).abs() < 40,
"the chain starts and ends on the same shape here, so both ends must match: \
start={start}px, end={end}px"
);
}
#[test]
fn a_morph_chain_keeps_moving_between_its_legs() {
let quarter = red_pixels(&render_lit(morph_chain(0.25), None));
let middle = red_pixels(&render_lit(morph_chain(0.5), None));
let three_quarters = red_pixels(&render_lit(morph_chain(0.75), None));
assert!(
quarter > middle && three_quarters > middle,
"a quarter of the way along each leg is between the endpoints, not at one of \
them: 0.25={quarter}px, 0.5={middle}px, 0.75={three_quarters}px"
);
}
#[test]
fn an_empty_via_leaves_the_two_shape_morph_exactly_as_it_was() {
let a = ClipPath::Circle {
radius: CLP::Px(10.0),
origin: None,
};
let b = ClipPath::Circle {
radius: CLP::Px(90.0),
origin: None,
};
let (from, to, t) = morph_leg(&a, &b, &[], 0.37);
assert_eq!(from, &a);
assert_eq!(to, &b);
assert_eq!(
t, 0.37,
"with no `via`, the leg is the whole morph and progress passes through \
untouched — which is what makes every morph written before this render \
identically"
);
}
#[test]
fn a_via_shape_splits_the_sweep_into_equal_legs() {
let a = ClipPath::Circle {
radius: CLP::Px(10.0),
origin: None,
};
let mid = ClipPath::Circle {
radius: CLP::Px(50.0),
origin: None,
};
let b = ClipPath::Circle {
radius: CLP::Px(90.0),
origin: None,
};
let via = [mid.clone()];
let (f0, t0, p0) = morph_leg(&a, &b, &via, 0.25);
assert_eq!((f0, t0), (&a, &mid), "the first half travels a -> via");
assert!((p0 - 0.5).abs() < 1e-5, "halfway along that leg: {p0}");
let (f1, t1, p1) = morph_leg(&a, &b, &via, 0.75);
assert_eq!((f1, t1), (&mid, &b), "the second half travels via -> b");
assert!((p1 - 0.5).abs() < 1e-5, "halfway along that leg: {p1}");
}
fn star_points() -> Vec<(CLP, CLP)> {
(0..10)
.map(|i| {
let r = if i % 2 == 0 { 90.0f32 } else { 38.0 };
let a = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0;
(CLP::Px(100.0 + r * a.cos()), CLP::Px(100.0 + r * a.sin()))
})
.collect()
}
fn star_tile(material: Material) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(0.0)),
top: Some(CLP::Px(0.0)),
width: Some(CSize::Length(CLP::Px(200.0))),
height: Some(CSize::Length(CLP::Px(200.0))),
background: Some(Background::Color(CssColor::String("#7C3AED".into()))),
clip_path: Some(ClipPath::Polygon {
points: star_points(),
}),
material: Some(material),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn bright_runs(buf: &[u8], y: usize) -> usize {
let mut runs = 0;
let mut inside = false;
for x in 0..400 {
let i = (y * 400 + x) * 4;
let luma =
0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32;
let bright = luma > 150.0;
if bright && !inside {
runs += 1;
}
inside = bright;
}
runs
}
#[test]
fn inflated_lights_each_branch_where_glossy_lights_the_whole_box() {
let glossy = render_lit(star_tile(Material::Preset(MaterialPreset::Glossy)), None);
let inflated = render_lit(
star_tile(Material::Tuned {
preset: MaterialPreset::Inflated,
intensity: 1.0,
bevel: 10.0,
softness: 0.2,
}),
None,
);
let scanline = 78;
let glossy_runs = bright_runs(&glossy, scanline);
let inflated_runs = bright_runs(&inflated, scanline);
assert!(
inflated_runs > glossy_runs,
"across a row cutting two branches, an inflated star must show more separate \
bright runs than a glossy one, which lights the bounding box as a single band: \
glossy={glossy_runs}, inflated={inflated_runs}"
);
}
#[test]
fn inflated_paints_nothing_outside_the_silhouette() {
let buf = render_lit(
star_tile(Material::Tuned {
preset: MaterialPreset::Inflated,
intensity: 1.0,
bevel: 14.0,
softness: 0.5,
}),
None,
);
let corner = luma_at(&buf, 6, 6);
assert_eq!(
corner, 0.0,
"the shading is derived from the silhouette and clipped to it — a star's \
bounding-box corner must stay background"
);
}
#[test]
fn inflated_at_zero_intensity_is_the_bare_shape() {
let flat = render_lit(
star_tile(Material::Tuned {
preset: MaterialPreset::Inflated,
intensity: 0.0,
bevel: 14.0,
softness: 0.5,
}),
None,
);
let none = {
let mut node = star_tile(Material::Preset(MaterialPreset::Matte));
node.css.material = None;
render_lit(node, None)
};
assert_eq!(
flat, none,
"intensity 0 must be byte-identical to declaring no material at all"
);
}
fn without_trailing_plateau(values: &[i32]) -> &[i32] {
let mut end = values.len();
if end == 0 {
return values;
}
let last = values[end - 1];
while end > 0 && values[end - 1] == last {
end -= 1;
}
&values[..end]
}
fn flat_pair_count(values: &[i32]) -> usize {
values.windows(2).filter(|w| w[0] == w[1]).count()
}
#[test]
fn a_wide_inflated_bevel_does_not_posterize_its_ramp_into_solid_flat_plateaus() {
let bevel = 48.0f32;
let mut node = material_tile(Some(Material::Tuned {
preset: MaterialPreset::Inflated,
intensity: 1.0,
bevel,
softness: 0.6,
}));
node.css.border_radius = Some(BorderRadius::Uniform(CLP::Px(100.0)));
let buf = render_lit(node, None);
let y = 200usize;
let x_max = 100 + (bevel * 2.0) as usize;
let lumas: Vec<i32> = (100..x_max).map(|x| luma_at(&buf, x, y) as i32).collect();
let ramp = without_trailing_plateau(&lumas);
let flat_pairs = flat_pair_count(ramp);
assert!(
flat_pairs <= 28,
"a bevel of {bevel} across a 100px radius must not hold {flat_pairs} \
adjacent same-luminance pixel pairs inside its shading ramp (out of {} \
pairs sampled) — quantizing the shading alpha to 8 bits without dithering \
posterizes a shallow gradient into wide flat plateaus, visible as \
stair-stepped bands along the inner bevel",
ramp.len().saturating_sub(1)
);
}
#[test]
fn a_node_without_a_material_is_untouched() {
let plain = render_lit(material_tile(None), None);
let flat = render_lit(
material_tile(Some(Material::Tuned {
preset: MaterialPreset::Glossy,
intensity: 0.0,
bevel: 18.0,
softness: 0.6,
})),
None,
);
assert_eq!(
plain, flat,
"intensity 0 must be byte-identical to declaring no material at all"
);
}
#[test]
fn glossy_is_brighter_on_the_lit_side_than_opposite_it() {
let buf = render_lit(
material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
None,
);
let lit = luma_at(&buf, 140, 140);
let shaded = luma_at(&buf, 260, 260);
assert!(
lit > shaded + 20.0,
"the default light is upper-left, so the upper-left of the tile must be clearly \
brighter than the lower-right: lit={lit:.1}, shaded={shaded:.1}"
);
}
#[test]
fn turning_the_light_around_turns_the_gradient_around() {
let upper_left = render_lit(
material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
None,
);
let lower_right = render_lit(
material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
Some(LightDirection {
x: 0.35,
y: 0.8,
..Default::default()
}),
);
assert!(
luma_at(&upper_left, 140, 140) > luma_at(&upper_left, 260, 260),
"sanity: the default light lights the upper-left"
);
assert!(
luma_at(&lower_right, 260, 260) > luma_at(&lower_right, 140, 140),
"a light declared from the lower-right must light the lower-right — this is what \
makes two materials in one scene agree about where the light is"
);
}
#[test]
fn matte_shades_without_a_highlight() {
let matte = render_lit(
material_tile(Some(Material::Preset(MaterialPreset::Matte))),
None,
);
let plain = render_lit(material_tile(None), None);
let lit_delta = luma_at(&matte, 140, 140) - luma_at(&plain, 140, 140);
let shade_delta = luma_at(&plain, 260, 260) - luma_at(&matte, 260, 260);
assert!(
lit_delta <= 1.0,
"matte returns no specular light, so its lit side must not brighten: +{lit_delta:.1}"
);
assert!(
shade_delta > 10.0,
"matte still takes light, so its far side must darken: -{shade_delta:.1}"
);
}
#[test]
fn the_scene_light_intensity_scales_every_material_at_once() {
let full = render_lit(
material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
None,
);
let dimmed = render_lit(
material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
Some(LightDirection {
intensity: 0.25,
..Default::default()
}),
);
let plain = render_lit(material_tile(None), None);
let full_lift = luma_at(&full, 140, 140) - luma_at(&plain, 140, 140);
let dim_lift = luma_at(&dimmed, 140, 140) - luma_at(&plain, 140, 140);
assert!(
dim_lift > 0.0 && dim_lift < full_lift * 0.6,
"a quarter-intensity light must still lift, but far less: full=+{full_lift:.1}, \
dimmed=+{dim_lift:.1}"
);
}
#[test]
fn a_material_never_paints_outside_its_clip_path() {
let mut node = material_tile(Some(Material::Preset(MaterialPreset::Glossy)));
node.css.background = None;
node.css.clip_path = Some(ClipPath::Inset {
top: CLP::Px(0.0),
right: CLP::Px(100.0),
bottom: CLP::Px(0.0),
left: CLP::Px(0.0),
radius: None,
});
let buf = render_lit(node, None);
assert!(
luma_at(&buf, 120, 200) > 1.0,
"sanity: the material paints inside the clip"
);
assert_eq!(
luma_at(&buf, 260, 200),
0.0,
"the material follows the node's silhouette — clip-path included — and must not \
spill into the region the clip removed"
);
}
fn shot_camera(rotate_y: f32, perspective: f32) -> PlaneCamera {
PlaneCamera {
pan_x: 0.0,
pan_y: 0.0,
zoom: 1.0,
rotation: 0.0,
origin_x: 200.0,
origin_y: 200.0,
focus: 1.0,
aperture: 0.0,
rotate_x: 0.0,
rotate_y,
perspective,
}
}
fn ink_x_extent(buf: &[u8]) -> (usize, usize) {
let mut lo = usize::MAX;
let mut hi = 0usize;
for y in 0..400 {
for x in 0..400 {
let i = (y * 400 + x) * 4;
if buf[i] > 150 && buf[i + 1] < 90 {
lo = lo.min(x);
hi = hi.max(x);
}
}
}
(lo, hi)
}
#[test]
fn a_camera_tilt_of_zero_renders_exactly_as_no_camera() {
let plain = render_with_camera(plane_at_depth(Some(2.0)), None);
let flat = render_with_camera(plane_at_depth(Some(2.0)), Some(shot_camera(0.0, 1400.0)));
assert_eq!(
plain, flat,
"a camera declaring perspective but no tilt must not move a pixel — every \
scenario written before this declares neither"
);
}
#[test]
fn a_camera_tilt_turns_the_whole_shot() {
let flat = render_with_camera(plane_at_depth(Some(1.0)), None);
let tilted = render_with_camera(plane_at_depth(Some(1.0)), Some(shot_camera(24.0, 1400.0)));
assert_ne!(flat, tilted, "a 24-degree tilt must change the frame");
}
#[test]
fn a_deeper_plane_swings_further_than_a_near_one() {
let near = render_with_camera(plane_at_depth(Some(0.5)), Some(shot_camera(24.0, 1400.0)));
let far = render_with_camera(plane_at_depth(Some(3.0)), Some(shot_camera(24.0, 1400.0)));
let resting = ink_x_extent(&render_with_camera(plane_at_depth(Some(1.0)), None));
let near_shift = (ink_x_extent(&near).0 as i64 - resting.0 as i64).abs();
let far_shift = (ink_x_extent(&far).0 as i64 - resting.0 as i64).abs();
assert!(
far_shift > near_shift,
"the tilt is scaled by style.depth, the same rule parallax and focus follow, so a \
plane at depth 3 must swing further than one at 0.5: near={near_shift}px, \
far={far_shift}px"
);
}
#[test]
fn perspective_zero_is_an_orthographic_tilt() {
let ortho = render_with_camera(plane_at_depth(Some(2.0)), Some(shot_camera(24.0, 0.0)));
let projected =
render_with_camera(plane_at_depth(Some(2.0)), Some(shot_camera(24.0, 900.0)));
assert_ne!(
ortho, projected,
"perspective 0 means no vanishing point at all, which is a different image from \
one projected at 900px"
);
}
fn dof_camera(focus: f32, aperture: f32) -> PlaneCamera {
PlaneCamera {
pan_x: 0.0,
pan_y: 0.0,
zoom: 1.0,
rotation: 0.0,
origin_x: 200.0,
origin_y: 200.0,
focus,
aperture,
rotate_x: 0.0,
rotate_y: 0.0,
perspective: 0.0,
}
}
fn plane_at_depth(depth: Option<f32>) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(150.0)),
top: Some(CLP::Px(150.0)),
width: Some(CSize::Length(CLP::Px(100.0))),
height: Some(CSize::Length(CLP::Px(100.0))),
background: Some(Background::Color(CssColor::String("#ff0000".into()))),
depth,
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn render_with_camera(node: BoxNode, camera: Option<PlaneCamera>) -> Vec<u8> {
let mut root = root_node(400.0, 400.0, "#000000", vec![node]);
root.assign_ids(0);
let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
let mut frame = test_frame(400, 400);
frame.camera = camera;
paint_tree(surface.canvas(), &root, &layout, &frame, &NoopDispatcher);
let info = skia_safe::ImageInfo::new(
(400, 400),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; 400 * 400 * 4];
surface.read_pixels(&info, &mut buf, 400 * 4, (0, 0));
buf
}
fn edge_softness(buf: &[u8]) -> usize {
(120..190)
.filter(|y| {
let i = (y * 400 + 200) * 4;
let (r, g) = (buf[i], buf[i + 1]);
r > 12 && r < 240 && g < 60
})
.count()
}
#[test]
fn an_aperture_of_zero_renders_exactly_as_no_camera_at_all() {
let sharp = render_with_camera(plane_at_depth(Some(2.0)), None);
let zero_aperture =
render_with_camera(plane_at_depth(Some(2.0)), Some(dof_camera(1.0, 0.0)));
assert_eq!(
sharp, zero_aperture,
"aperture 0 must be byte-identical to no depth of field: every scenario written \
before this one declares no aperture"
);
}
#[test]
fn a_plane_on_the_focus_distance_stays_sharp() {
let no_dof = render_with_camera(plane_at_depth(Some(2.0)), None);
let focused = render_with_camera(plane_at_depth(Some(2.0)), Some(dof_camera(2.0, 8.0)));
assert_eq!(
no_dof, focused,
"a plane sitting exactly on the focus distance is in focus whatever the aperture"
);
}
#[test]
fn a_plane_away_from_the_focus_distance_is_blurred() {
let sharp_softness = edge_softness(&render_with_camera(plane_at_depth(Some(3.0)), None));
let blurred_softness = edge_softness(&render_with_camera(
plane_at_depth(Some(3.0)),
Some(dof_camera(1.0, 6.0)),
));
assert!(
blurred_softness > sharp_softness + 8,
"a plane two depth units off focus must show a measurably soft edge: \
sharp={sharp_softness}px, defocused={blurred_softness}px"
);
}
#[test]
fn the_further_from_focus_the_softer_the_edge() {
let near = edge_softness(&render_with_camera(
plane_at_depth(Some(2.0)),
Some(dof_camera(1.0, 6.0)),
));
let far = edge_softness(&render_with_camera(
plane_at_depth(Some(4.0)),
Some(dof_camera(1.0, 6.0)),
));
assert!(
far > near,
"softness must grow with distance from the focal plane: \
1 unit off={near}px, 3 units off={far}px"
);
}
#[test]
fn focus_is_symmetric_in_front_of_and_behind_the_focal_plane() {
let in_front = edge_softness(&render_with_camera(
plane_at_depth(Some(1.0)),
Some(dof_camera(3.0, 6.0)),
));
let behind = edge_softness(&render_with_camera(
plane_at_depth(Some(5.0)),
Some(dof_camera(3.0, 6.0)),
));
assert_eq!(
in_front, behind,
"two units in front and two units behind defocus by the same amount"
);
}
#[test]
fn a_node_with_no_declared_depth_sits_on_the_default_focal_plane() {
let no_dof = render_with_camera(plane_at_depth(None), None);
let with_dof = render_with_camera(plane_at_depth(None), Some(dof_camera(1.0, 10.0)));
assert_eq!(
no_dof, with_dof,
"depth defaults to 1.0 and focus defaults to 1.0, so a scenario that declares \
neither is focused on everything it has"
);
}
#[test]
fn depth_of_field_sigma_grows_linearly_with_the_aperture() {
let cam = dof_camera(1.0, 4.0);
assert_eq!(depth_of_field_sigma(&cam, 1.0), 0.0);
assert_eq!(depth_of_field_sigma(&cam, 2.0), 4.0);
assert_eq!(depth_of_field_sigma(&cam, 3.0), 8.0);
assert_eq!(depth_of_field_sigma(&dof_camera(1.0, 0.0), 9.0), 0.0);
}
fn card_with_shadow(overflow_hidden: bool) -> BoxNode {
let mut css = CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(50.0)),
top: Some(CLP::Px(50.0)),
width: Some(CSize::Length(CLP::Px(100.0))),
height: Some(CSize::Length(CLP::Px(100.0))),
background: Some(Background::Color(CssColor::String("#ffffff".into()))),
box_shadow: Some(vec![BoxShadow {
offset_x: Length::Px(0.0),
offset_y: Length::Px(0.0),
blur: None,
spread: Some(Length::Px(20.0)),
color: Some(CssColor::String("#ff0000".into())),
inset: None,
}]),
..Default::default()
};
if overflow_hidden {
css.overflow = Some(Overflow::Hidden);
}
BoxNode {
id: 0,
kind: BoxKind::Container,
css,
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
#[test]
fn overflow_hidden_does_not_clip_own_outset_box_shadow() {
let without = {
let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(false)]);
render_pixels(&mut root, 200, 200)
};
let with_hidden = {
let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(true)]);
render_pixels(&mut root, 200, 200)
};
let probe = |buf: &[u8], x: usize, y: usize| -> (u8, u8, u8) {
let i = (y * 200 + x) * 4;
(buf[i], buf[i + 1], buf[i + 2])
};
let above_plain = probe(&without, 100, 45);
let below_plain = probe(&without, 100, 155);
assert!(
above_plain.0 > 200 && above_plain.1 < 50,
"sanity: shadow halo must be visible without overflow, got {above_plain:?}"
);
assert!(
below_plain.0 > 200 && below_plain.1 < 50,
"sanity: shadow halo must be visible without overflow, got {below_plain:?}"
);
let above_hidden = probe(&with_hidden, 100, 45);
let below_hidden = probe(&with_hidden, 100, 155);
assert!(
above_hidden.0 > 200 && above_hidden.1 < 50,
"overflow:hidden must not erase the node's own outset shadow, got {above_hidden:?}"
);
assert!(
below_hidden.0 > 200 && below_hidden.1 < 50,
"overflow:hidden must not erase the node's own outset shadow, got {below_hidden:?}"
);
let halo_count_plain = count_red_in(&without, 200, 25, 25, 175, 175);
let halo_count_hidden = count_red_in(&with_hidden, 200, 25, 25, 175, 175);
assert_eq!(
halo_count_plain, halo_count_hidden,
"halo pixel count must be identical with/without overflow:hidden \
(plain={halo_count_plain}, hidden={halo_count_hidden})"
);
}
#[test]
fn an_outset_box_shadow_does_not_paint_under_a_transparent_box() {
let transparent_card = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(50.0)),
top: Some(CLP::Px(50.0)),
width: Some(CSize::Length(CLP::Px(100.0))),
height: Some(CSize::Length(CLP::Px(100.0))),
box_shadow: Some(vec![BoxShadow {
offset_x: Length::Px(0.0),
offset_y: Length::Px(0.0),
blur: None,
spread: Some(Length::Px(20.0)),
color: Some(CssColor::String("#ff0000".into())),
inset: None,
}]),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = root_node(200.0, 200.0, "#000000", vec![transparent_card]);
let buf = render_pixels(&mut root, 200, 200);
let probe = |x: usize, y: usize| -> (u8, u8, u8) {
let i = (y * 200 + x) * 4;
(buf[i], buf[i + 1], buf[i + 2])
};
let centre = probe(100, 100);
assert!(
centre.0 < 50 && centre.1 < 50 && centre.2 < 50,
"a box with no background of its own must leave its border-box area showing the \
root's black background, not the shadow bleeding through in red — the shadow is \
clipped to outside the border edge in CSS regardless of what (if anything) the \
box itself paints — got {centre:?}"
);
let halo = probe(100, 45);
assert!(
halo.0 > 200 && halo.1 < 50,
"the shadow halo outside the box's own footprint must still be painted, got \
{halo:?}"
);
}
#[test]
fn filter_layer_bounds_do_not_clip_blur_bleed() {
let n = BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(120.0)),
top: Some(CLP::Px(120.0)),
width: Some(CSize::Length(CLP::Px(60.0))),
height: Some(CSize::Length(CLP::Px(60.0))),
background: Some(Background::Color(CssColor::String("#ff0000".into()))),
opacity: Some(0.999),
filter: Some(vec![FilterFn::Blur {
radius: Some(Length::Px(24.0)),
radius_x: None,
radius_y: None,
}]),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = root_node(300.0, 300.0, "#000000", vec![n]);
let buf = render_pixels(&mut root, 300, 300);
let probe = |x: usize, y: usize| -> u8 {
let i = (y * 300 + x) * 4;
buf[i]
};
let bled = probe(112, 150);
assert!(
bled > 15,
"blur must bleed past the box edge under bounded SaveLayerRec, got r={bled}"
);
let far = probe(20, 20);
assert_eq!(far, 0, "far corner must stay untouched, got r={far}");
}
fn render_pixels_at(root: &mut BoxNode, w: u32, h: u32, time: f64) -> Vec<u8> {
root.assign_ids(0);
let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
let frame = PaintFrame {
light: Default::default(),
time,
..test_frame(w, h)
};
paint_tree(surface.canvas(), root, &layout, &frame, &NoopDispatcher);
let info = skia_safe::ImageInfo::new(
(w as i32, h as i32),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; (w * h * 4) as usize];
surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
buf
}
fn white_square(animation: Vec<AnimationEffect>) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(100.0)),
top: Some(CLP::Px(100.0)),
width: Some(CSize::Length(CLP::Px(120.0))),
height: Some(CSize::Length(CLP::Px(120.0))),
background: Some(Background::Color(CssColor::String("#ffffff".into()))),
animation,
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn probe(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8) {
let i = ((y * w + x) * 4) as usize;
(buf[i], buf[i + 1], buf[i + 2])
}
#[test]
fn chromatic_aberration_shows_a_red_fringe_on_one_edge_and_a_cyan_fringe_on_the_other() {
let cfg = ChromaticAberrationConfig {
delay: 0.0,
duration: 0.6,
amount: 10.0,
easing: EasingType::Linear,
};
let mut root = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::ChromaticAberration(
cfg,
)])],
);
let buf = render_pixels_at(&mut root, 400, 400, 0.3);
let left_edge = probe(&buf, 400, 100, 160);
let right_edge = probe(&buf, 400, 220, 160);
assert!(
left_edge.0 > 200 && left_edge.1 < 50 && left_edge.2 < 50,
"expected a red-leaning fringe on the left edge mid-flight, got {left_edge:?}"
);
assert!(
right_edge.0 < 50 && right_edge.1 > 200 && right_edge.2 > 200,
"expected a cyan-leaning fringe on the right edge mid-flight, got {right_edge:?}"
);
}
#[test]
fn chromatic_aberration_is_gone_by_the_end_of_the_animation() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
let cfg = ChromaticAberrationConfig {
delay: 0.0,
duration: 0.6,
amount: 10.0,
easing: EasingType::Linear,
};
let mut animated = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::ChromaticAberration(
cfg,
)])],
);
let at_end = render_pixels_at(&mut animated, 400, 400, 0.6);
assert_eq!(
baseline, at_end,
"the node must be pixel-identical to one with no effect at all once the \
animation's duration has elapsed — no permanent fringe left behind"
);
}
#[test]
fn a_node_without_the_effect_is_untouched() {
let mut root_a = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let a = render_pixels_at(&mut root_a, 400, 400, 0.0);
let mut root_b = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let b = render_pixels_at(&mut root_b, 400, 400, 5.0);
assert_eq!(
a, b,
"a node with no chromatic_aberration effect must not vary with time"
);
let left_edge = probe(&a, 400, 100, 160);
let right_edge = probe(&a, 400, 219, 160);
assert_eq!(
left_edge,
(255, 255, 255),
"no effect means no fringe on the left edge either, got {left_edge:?}"
);
assert_eq!(
right_edge,
(255, 255, 255),
"no effect means no fringe on the right edge either, got {right_edge:?}"
);
}
fn shatter_cfg(mode: ShatterMode) -> ShatterConfig {
ShatterConfig {
delay: 0.2,
duration: 0.6,
mode,
pieces: 16,
seed: 42,
origin: ShatterOrigin::default(),
spread: 1.0,
spin: 90.0,
depth: 0.4,
fade: true,
}
}
fn any_ink_in_band(buf: &[u8], w: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> bool {
for y in y0..y1 {
for x in x0..x1 {
let i = ((y * w + x) * 4) as usize;
if buf[i] > 20 || buf[i + 1] > 20 || buf[i + 2] > 20 {
return true;
}
}
}
false
}
fn burst_cfg() -> BurstConfig {
BurstConfig {
delay: 0.2,
duration: 0.6,
count: 4,
length: 60.0,
gap: 14.0,
width: 6.0,
color: "#ff0000".to_string(),
seed: 5,
jitter: 0.0,
}
}
fn burst_node(cfg: BurstConfig) -> BoxNode {
white_square(vec![AnimationEffect::Burst(cfg)])
}
#[test]
fn burst_is_pixel_identical_to_no_effect_before_delay_and_at_or_after_the_end() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
let cfg = burst_cfg();
let mut before = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]);
assert_eq!(
baseline,
render_pixels_at(&mut before, 400, 400, 0.0),
"before delay a burst must contribute nothing at all — pixel-identical to a node \
with no burst in its animation list"
);
let mut at_end = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]);
assert_eq!(
baseline,
render_pixels_at(&mut at_end, 400, 400, 0.8),
"at delay + duration the window is already closed — no stroke may survive the last \
frame and bleed into the next scene"
);
let mut after = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg)]);
assert_eq!(
baseline,
render_pixels_at(&mut after, 400, 400, 5.0),
"long after the window the node is plain again"
);
}
#[test]
fn burst_paints_ink_outside_the_nodes_own_box_at_full_extension() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
assert!(
!any_ink_in_band(&baseline, 400, 226, 155, 290, 166),
"the band to the right of the square must be empty without a burst"
);
let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]);
let mid = render_pixels_at(&mut bursting, 400, 400, 0.5);
assert!(
any_ink_in_band(&mid, 400, 226, 155, 290, 166),
"at the halfway point the head is at the far end of its track — a stroke must be \
painted well outside the node's own box"
);
}
#[test]
fn burst_leaves_the_node_itself_untouched() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]);
let mid = render_pixels_at(&mut bursting, 400, 400, 0.5);
for y in 100..220 {
for x in 100..220 {
let i = ((y * 400 + x) * 4) as usize;
assert_eq!(
(baseline[i], baseline[i + 1], baseline[i + 2]),
(mid[i], mid[i + 1], mid[i + 2]),
"gap keeps every stroke off the box — pixel ({x}, {y}) inside the node \
changed"
);
}
}
}
#[test]
fn burst_strokes_sit_on_the_rays_count_asks_for_and_nowhere_else() {
let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]);
let mid = render_pixels_at(&mut bursting, 400, 400, 0.5);
assert!(
any_ink_in_band(&mid, 400, 240, 156, 260, 165),
"count: 4 with no jitter puts a stroke straight to the right of the centre"
);
assert!(
any_ink_in_band(&mid, 400, 156, 240, 165, 260),
"count: 4 with no jitter puts a stroke straight below the centre"
);
assert!(
!any_ink_in_band(&mid, 400, 230, 230, 272, 272),
"count: 4 leaves the diagonals empty — the ring is not a halo"
);
}
#[test]
fn burst_two_renders_of_the_same_instant_are_byte_identical() {
let cfg = BurstConfig {
jitter: 0.9,
count: 24,
..burst_cfg()
};
let mut first = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]);
let mut second = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg)]);
assert_eq!(
render_pixels_at(&mut first, 400, 400, 0.45),
render_pixels_at(&mut second, 400, 400, 0.45),
"every stroke's angle, length and phase comes from (seed, index) alone — the same \
instant must render byte-identically for the parallel renderer"
);
}
#[test]
fn shatter_out_is_pixel_identical_to_no_effect_before_delay_and_after_duration() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
let cfg = shatter_cfg(ShatterMode::Out);
let mut before = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])],
);
let before_delay = render_pixels_at(&mut before, 400, 400, 0.0);
assert_eq!(
baseline, before_delay,
"before delay, mode: out must contribute nothing at all — pixel-identical to a \
node with no shatter effect in its animation list"
);
let mut after = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
);
let after_duration = render_pixels_at(&mut after, 400, 400, 5.0);
assert_eq!(
baseline, after_duration,
"mode: out must short-circuit back to the plain node once delay + duration has \
elapsed — no shards left hanging"
);
}
#[test]
fn shatter_in_is_pixel_identical_to_no_effect_before_delay_and_after_duration() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
let cfg = shatter_cfg(ShatterMode::In);
let mut before = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])],
);
let before_delay = render_pixels_at(&mut before, 400, 400, 0.0);
assert_eq!(
baseline, before_delay,
"mode: in must also render as the plain node before delay — the mirror still \
short-circuits outside its own window"
);
let mut after = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
);
let after_duration = render_pixels_at(&mut after, 400, 400, 5.0);
assert_eq!(
baseline, after_duration,
"mode: in converges back to the plain node by delay + duration"
);
}
#[test]
fn shatter_hold_freezes_past_the_window_instead_of_reverting() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
let cfg = shatter_cfg(ShatterMode::Hold);
let mut held = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
);
let long_after = render_pixels_at(&mut held, 400, 400, 5.0);
assert_ne!(
baseline, long_after,
"mode: hold must never converge back to the plain node, unlike out/in — the \
shards stay frozen in suspension"
);
}
#[test]
fn shatter_two_renders_of_the_same_instant_are_byte_identical() {
let cfg = shatter_cfg(ShatterMode::Out);
let mut root_a = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])],
);
let a = render_pixels_at(&mut root_a, 400, 400, 0.5);
let mut root_b = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
);
let b = render_pixels_at(&mut root_b, 400, 400, 0.5);
assert_eq!(
a, b,
"seed + instant must fully determine every shard — two renders of the same frame \
must be byte-identical"
);
}
#[test]
fn shatter_paints_ink_outside_the_nodes_own_box_where_an_intact_node_does_not() {
let band = (221u32, 100u32, 320u32, 220u32);
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let intact = render_pixels_at(&mut plain, 400, 400, 0.0);
assert!(
!any_ink_in_band(&intact, 400, band.0, band.1, band.2, band.3),
"sanity: an intact node must not paint outside its own box"
);
let cfg = shatter_cfg(ShatterMode::Out);
let mut shattered = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
);
let mid_flight = render_pixels_at(&mut shattered, 400, 400, 0.5);
assert!(
any_ink_in_band(&mid_flight, 400, band.0, band.1, band.2, band.3),
"a shattered node mid-flight must have ink outside its own box — a shard has \
physically left the node's rect"
);
}
#[test]
#[ignore]
fn shatter_per_frame_cost_vs_the_plain_node() {
let frames = 200;
let cfg = shatter_cfg(ShatterMode::Out);
let time_plain = || {
let start = std::time::Instant::now();
for i in 0..frames {
let mut root = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let _ = render_pixels_at(&mut root, 400, 400, i as f64 / 30.0);
}
start.elapsed()
};
let time_shatter = |cfg: &ShatterConfig| {
let start = std::time::Instant::now();
for i in 0..frames {
let mut root = root_node(
400.0,
400.0,
"#000000",
vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])],
);
let _ = render_pixels_at(&mut root, 400, 400, i as f64 / 30.0);
}
start.elapsed()
};
time_plain();
time_shatter(&cfg);
let plain_elapsed = time_plain().min(time_plain());
let shatter_elapsed = time_shatter(&cfg).min(time_shatter(&cfg));
println!(
"plain: {:?}/frame, shatter (16 pieces, 120x120 box): {:?}/frame, ratio {:.1}x",
plain_elapsed / frames,
shatter_elapsed / frames,
shatter_elapsed.as_secs_f64() / plain_elapsed.as_secs_f64().max(1e-9)
);
let heavy_cfg = ShatterConfig { pieces: 64, ..cfg };
let heavy_elapsed = time_shatter(&heavy_cfg).min(time_shatter(&heavy_cfg));
println!(
"shatter (64 pieces, 120x120 box): {:?}/frame, ratio {:.1}x",
heavy_elapsed / frames,
heavy_elapsed.as_secs_f64() / plain_elapsed.as_secs_f64().max(1e-9)
);
}
#[test]
fn clip_path_morph_interpolates_a_circles_radius_between_two_keyframes() {
let morph_at = |progress: f32| ClipPath::Morph {
from: Box::new(ClipPath::Circle {
radius: CLP::Px(50.0),
origin: None,
}),
to: Box::new(ClipPath::Circle {
radius: CLP::Px(150.0),
origin: None,
}),
via: Vec::new(),
progress,
};
assert!(
is_red_at(Some(morph_at(0.0)), 230, 200),
"progress 0: 30px from centre must be inside the `from` circle (r=50)"
);
assert!(
!is_red_at(Some(morph_at(0.0)), 280, 200),
"progress 0 must match the `from` shape exactly (r=50), not something in between"
);
assert!(
is_red_at(Some(morph_at(1.0)), 280, 200),
"progress 1 must match the `to` shape's larger radius (r=150)"
);
assert!(
is_red_at(Some(morph_at(0.5)), 280, 200),
"progress 0.5 interpolates the radius to 100 (halfway between 50 and 150); 80px \
from centre must be inside"
);
assert!(
!is_red_at(Some(morph_at(0.5)), 350, 200),
"150px from centre must be outside the interpolated radius of 100"
);
}
#[test]
fn clip_path_morph_supports_inset_and_ellipse_too() {
let inset_morph_at = |progress: f32| ClipPath::Morph {
from: Box::new(ClipPath::Inset {
top: CLP::Px(0.0),
right: CLP::Px(200.0),
bottom: CLP::Px(0.0),
left: CLP::Px(0.0),
radius: None,
}),
to: Box::new(ClipPath::Inset {
top: CLP::Px(0.0),
right: CLP::Px(0.0),
bottom: CLP::Px(0.0),
left: CLP::Px(0.0),
radius: None,
}),
via: Vec::new(),
progress,
};
assert!(
!is_red_at(Some(inset_morph_at(0.0)), 380, 200),
"an inset morph at progress 0 must match the `from` shape (right:200 cuts x=380)"
);
assert!(
is_red_at(Some(inset_morph_at(1.0)), 380, 200),
"an inset morph at progress 1 must match the `to` shape (the full box, right:0)"
);
let ellipse_morph_at = |progress: f32| ClipPath::Morph {
from: Box::new(ClipPath::Ellipse {
rx: CLP::Px(10.0),
ry: CLP::Px(10.0),
origin: None,
}),
to: Box::new(ClipPath::Ellipse {
rx: CLP::Px(180.0),
ry: CLP::Px(40.0),
origin: None,
}),
via: Vec::new(),
progress,
};
assert!(
!is_red_at(Some(ellipse_morph_at(0.0)), 360, 200),
"an ellipse morph at progress 0 must match the `from` shape (rx=10 excludes x=360)"
);
assert!(
is_red_at(Some(ellipse_morph_at(1.0)), 360, 200),
"an ellipse morph at progress 1 must match the `to` shape's rx=180"
);
}
#[test]
fn clip_path_morph_interpolates_polygon_points_when_vertex_counts_match() {
let big = ClipPath::Polygon {
points: vec![
(CLP::Px(0.0), CLP::Px(0.0)),
(CLP::Px(400.0), CLP::Px(0.0)),
(CLP::Px(400.0), CLP::Px(400.0)),
(CLP::Px(0.0), CLP::Px(400.0)),
],
};
let small = ClipPath::Polygon {
points: vec![
(CLP::Px(150.0), CLP::Px(150.0)),
(CLP::Px(250.0), CLP::Px(150.0)),
(CLP::Px(250.0), CLP::Px(250.0)),
(CLP::Px(150.0), CLP::Px(250.0)),
],
};
let morph = ClipPath::Morph {
from: Box::new(big),
to: Box::new(small),
via: Vec::new(),
progress: 1.0,
};
assert!(
is_red_at(Some(morph.clone()), 200, 200),
"the shrunk square's centre must stay inside at progress 1"
);
assert!(
!is_red_at(Some(morph), 20, 20),
"the corner must be outside the fully-morphed small square at progress 1"
);
}
#[test]
fn clip_path_morph_between_mismatched_kinds_leaves_the_node_unclipped() {
let mismatched = ClipPath::Morph {
from: Box::new(ClipPath::Circle {
radius: CLP::Px(50.0),
origin: None,
}),
to: Box::new(ClipPath::Polygon {
points: vec![
(CLP::Px(0.0), CLP::Px(0.0)),
(CLP::Px(400.0), CLP::Px(0.0)),
(CLP::Px(200.0), CLP::Px(400.0)),
],
}),
via: Vec::new(),
progress: 0.5,
};
for (x, y) in [(20, 20), (200, 200), (380, 380)] {
assert_eq!(
is_red_at(Some(mismatched.clone()), x, y),
is_red_at(None, x, y),
"a clip-path morph between two different kinds cannot be interpolated — it \
must leave the node unclipped rather than silently snap between the two \
shapes, at ({x}, {y})"
);
}
}
#[test]
fn clip_path_morph_between_polygons_with_different_vertex_counts_leaves_the_node_unclipped() {
let triangle = ClipPath::Polygon {
points: vec![
(CLP::Px(200.0), CLP::Px(0.0)),
(CLP::Px(400.0), CLP::Px(400.0)),
(CLP::Px(0.0), CLP::Px(400.0)),
],
};
let square = ClipPath::Polygon {
points: vec![
(CLP::Px(0.0), CLP::Px(0.0)),
(CLP::Px(400.0), CLP::Px(0.0)),
(CLP::Px(400.0), CLP::Px(400.0)),
(CLP::Px(0.0), CLP::Px(400.0)),
],
};
let mismatched = ClipPath::Morph {
from: Box::new(triangle),
to: Box::new(square),
via: Vec::new(),
progress: 0.5,
};
for (x, y) in [(20, 20), (200, 200), (380, 380)] {
assert_eq!(
is_red_at(Some(mismatched.clone()), x, y),
is_red_at(None, x, y),
"a polygon vertex-count mismatch (3 vs 4) cannot be interpolated point-by-point \
— it must leave the node unclipped, at ({x}, {y})"
);
}
}
#[test]
fn animated_clip_path_progress_reaches_the_paint_pass() {
use crate::css::apply_animated_props;
use crate::engine::animator::AnimatedProperties;
let mut css = CssStyle {
position: Some(Position::Absolute),
left: Some(CLP::Px(0.0)),
top: Some(CLP::Px(0.0)),
width: Some(CSize::Length(CLP::Px(400.0))),
height: Some(CSize::Length(CLP::Px(400.0))),
background: Some(Background::Color(CssColor::String("#ff0000".into()))),
clip_path: Some(ClipPath::Morph {
from: Box::new(ClipPath::Circle {
radius: CLP::Px(50.0),
origin: None,
}),
to: Box::new(ClipPath::Circle {
radius: CLP::Px(150.0),
origin: None,
}),
via: Vec::new(),
progress: 0.0,
}),
..Default::default()
};
apply_animated_props(
&mut css,
&AnimatedProperties {
clip_path_progress: 1.0,
..AnimatedProperties::default()
},
);
let node = BoxNode {
id: 0,
kind: BoxKind::Container,
css,
children: vec![],
intrinsic: None,
source_path: None,
window: None,
};
let mut root = root_node(400.0, 400.0, "#000000", vec![node]);
let buf = render_pixels(&mut root, 400, 400);
let inside_the_grown_circle = probe(&buf, 400, 280, 200);
assert_eq!(
inside_the_grown_circle,
(255, 0, 0),
"clip_path_progress written by an animation must reach the morph's `progress` \
field — got {inside_the_grown_circle:?}, expected the fully-swept 150px `to` \
circle to cover this point (80px from centre)"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_hex_3_and_6() {
let c = parse_color_string("#fff").unwrap();
assert_eq!(c, SColor::from_argb(255, 255, 255, 255));
let c = parse_color_string("#102030").unwrap();
assert_eq!(c, SColor::from_argb(255, 0x10, 0x20, 0x30));
}
#[test]
fn parse_hex_with_alpha() {
let c = parse_color_string("#80ffffff").unwrap();
assert_eq!(c.a(), 0xff);
}
#[test]
fn parse_rgb_string() {
let c = parse_color_string("rgb(10, 20, 30)").unwrap();
assert_eq!(c, SColor::from_argb(255, 10, 20, 30));
}
#[test]
fn parse_rgba_string() {
let c = parse_color_string("rgba(10, 20, 30, 0.5)").unwrap();
assert_eq!(c.r(), 10);
assert_eq!(c.a(), 128);
}
#[test]
fn parse_named_colors() {
assert_eq!(parse_color_string("red").unwrap(), SColor::RED);
assert_eq!(
parse_color_string("transparent").unwrap(),
SColor::TRANSPARENT
);
}
#[test]
fn parse_white_forms_all_agree() {
let white = SColor::from_argb(255, 255, 255, 255);
assert_eq!(parse_color_string("#fff").unwrap(), white);
assert_eq!(parse_color_string("#FFF").unwrap(), white);
assert_eq!(parse_color_string("white").unwrap(), white);
assert_eq!(parse_color_string("WHITE").unwrap(), white);
assert_eq!(parse_color_string("rgb(255,255,255)").unwrap(), white);
assert_eq!(parse_color_string("rgb(255, 255, 255)").unwrap(), white);
}
#[test]
fn parse_color_string_supports_extended_named_set() {
assert!(parse_color_string("rebeccapurple").is_some());
assert!(parse_color_string("cornflowerblue").is_some());
assert!(parse_color_string("dodgerblue").is_some());
}
#[test]
fn parse_color_string_supports_hsl() {
assert_eq!(
parse_color_string("hsl(0, 100%, 50%)").unwrap(),
SColor::from_argb(255, 255, 0, 0)
);
}
#[test]
fn parse_color_unresolvable_string_is_not_black() {
let c = parse_color(&Color::String("not-a-color".to_string()));
assert_ne!(c, SColor::BLACK);
assert_eq!(c, SColor::from_argb(255, 255, 0, 255));
}
}
#[cfg(test)]
mod animated_transform_tests {
use super::*;
use crate::css::units::LengthPercentage as CLP;
fn layout(w: f32, h: f32) -> BoxLayout {
BoxLayout {
x: 0.0,
y: 0.0,
width: w,
height: h,
..Default::default()
}
}
#[test]
fn no_transform_is_identity_with_full_opacity() {
let css = CssStyle::default();
let (tx, ty, scale, rotation, opacity) =
animated_transform(&css, &layout(100.0, 100.0), (1920.0, 1080.0));
assert_eq!(
(tx, ty, scale, rotation, opacity),
(0.0, 0.0, 1.0, 0.0, 1.0)
);
}
#[test]
fn single_translate_and_opacity_roundtrip() {
let css = CssStyle {
transform: Some(vec![TransformFn::Translate {
x: CLP::Px(42.0),
y: CLP::Px(-7.0),
}]),
opacity: Some(0.5),
..Default::default()
};
let (tx, ty, _scale, _rotation, opacity) =
animated_transform(&css, &layout(100.0, 100.0), (1920.0, 1080.0));
assert_eq!(tx, 42.0);
assert_eq!(ty, -7.0);
assert_eq!(opacity, 0.5);
}
#[test]
fn uniform_scale_and_rotation() {
let css = CssStyle {
transform: Some(vec![
TransformFn::Scale { x: 2.0, y: 2.0 },
TransformFn::Rotate { deg: 30.0 },
]),
..Default::default()
};
let (_tx, _ty, scale, rotation, _opacity) =
animated_transform(&css, &layout(100.0, 100.0), (1920.0, 1080.0));
assert_eq!(scale, 2.0);
assert_eq!(rotation, 30.0);
}
#[test]
fn an_orbiting_node_at_two_different_frames_reports_two_different_positions() {
let orbit = |angle_deg: f32| {
let mut css = CssStyle::default();
let (s, c) = angle_deg.to_radians().sin_cos();
css.transform = Some(vec![TransformFn::Translate {
x: CLP::Px(c * 100.0),
y: CLP::Px(s * 100.0),
}]);
css
};
let l = layout(10.0, 10.0);
let (tx0, ty0, ..) = animated_transform(&orbit(0.0), &l, (1920.0, 1080.0));
let (tx1, ty1, ..) = animated_transform(&orbit(90.0), &l, (1920.0, 1080.0));
assert!((tx0 - tx1).abs() > 1.0 || (ty0 - ty1).abs() > 1.0);
}
}
#[cfg(test)]
mod layout_surface_tests {
use super::*;
use crate::css::style::{
Background, Color as CssColor, CssStyle, Display, FlexDirection, GridTrack, LayoutSurface,
Size as CSize, SurfaceAngle,
};
use crate::css::taffy_bridge::ConversionContext;
use crate::css::units::LengthPercentage as CLP;
use crate::engine::box_tree::{BoxKind, BoxNode};
use crate::engine::layout_pass::run_layout;
fn test_frame(w: u32, h: u32) -> PaintFrame {
PaintFrame {
light: Default::default(),
time: 0.0,
scenario_time: 0.0,
frame_index: 0,
fps: 30,
video_width: w,
video_height: h,
scene_duration: 1.0,
camera: None,
}
}
fn render_pixels(root: &mut BoxNode, w: u32, h: u32, time: f64) -> Vec<u8> {
root.assign_ids(0);
let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
let mut frame = test_frame(w, h);
frame.time = time;
paint_tree(surface.canvas(), root, &layout, &frame, &NoopDispatcher);
let info = skia_safe::ImageInfo::new(
(w as i32, h as i32),
skia_safe::ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let mut buf = vec![0u8; (w * h * 4) as usize];
surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
buf
}
fn colored_cell(color: &str) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
background: Some(Background::Color(CssColor::String(color.to_string()))),
..Default::default()
},
children: vec![],
intrinsic: None,
source_path: None,
window: None,
}
}
fn three_column_grid(w: f32, h: f32, layout_surface: Option<LayoutSurface>) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Grid),
width: Some(CSize::Length(CLP::Px(w))),
height: Some(CSize::Length(CLP::Px(h))),
grid_template_columns: Some(vec![
GridTrack::Fr(1.0),
GridTrack::Fr(1.0),
GridTrack::Fr(1.0),
]),
layout_surface,
..Default::default()
},
children: vec![
colored_cell("#ff0000"),
colored_cell("#00ff00"),
colored_cell("#0000ff"),
],
intrinsic: None,
source_path: None,
window: None,
}
}
fn n_column_grid(
w: f32,
h: f32,
n: usize,
first_color: &str,
layout_surface: Option<LayoutSurface>,
) -> BoxNode {
let mut children = vec![colored_cell(first_color)];
for _ in 1..n {
children.push(colored_cell("#333333"));
}
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Grid),
width: Some(CSize::Length(CLP::Px(w))),
height: Some(CSize::Length(CLP::Px(h))),
grid_template_columns: Some(vec![GridTrack::Fr(1.0); n]),
layout_surface,
..Default::default()
},
children,
intrinsic: None,
source_path: None,
window: None,
}
}
fn root_with(child: BoxNode, w: f32, h: f32) -> BoxNode {
BoxNode {
id: 0,
kind: BoxKind::Container,
css: CssStyle {
display: Some(Display::Flex),
flex_direction: Some(FlexDirection::Column),
width: Some(CSize::Length(CLP::Px(w))),
height: Some(CSize::Length(CLP::Px(h))),
background: Some(Background::Color(CssColor::String("#000000".into()))),
..Default::default()
},
children: vec![child],
intrinsic: None,
source_path: None,
window: None,
}
}
fn channel_extent(
buf: &[u8],
w: u32,
h: u32,
is_match: impl Fn(u8, u8, u8) -> bool,
) -> Option<(usize, usize)> {
let mut lo = usize::MAX;
let mut hi = 0usize;
let mut found = false;
for y in 0..h {
for x in 0..w {
let i = ((y * w + x) * 4) as usize;
if is_match(buf[i], buf[i + 1], buf[i + 2]) {
lo = lo.min(x as usize);
hi = hi.max(x as usize);
found = true;
}
}
}
found.then_some((lo, hi))
}
fn is_red(r: u8, g: u8, b: u8) -> bool {
r > 150 && g < 80 && b < 80
}
fn is_green(r: u8, g: u8, b: u8) -> bool {
g > 150 && r < 80 && b < 80
}
fn is_blue(r: u8, g: u8, b: u8) -> bool {
b > 150 && r < 80 && g < 80
}
fn sphere(radius: f32, arc_x: f32, arc_y: f32, perspective: Option<f32>) -> LayoutSurface {
LayoutSurface::Sphere {
radius,
arc_x,
arc_y,
perspective,
rotate_x: None,
rotate_y: None,
}
}
#[test]
fn no_layout_surface_leaves_a_flat_grid_byte_identical() {
let mut a = root_with(three_column_grid(900.0, 300.0, None), 900.0, 300.0);
let mut b = root_with(three_column_grid(900.0, 300.0, None), 900.0, 300.0);
let out_a = render_pixels(&mut a, 900, 300, 0.0);
let out_b = render_pixels(&mut b, 900, 300, 0.0);
assert_eq!(
out_a, out_b,
"a container with no layout-surface must render the exact same bytes on every run \
(no feature-flag leakage into the default path)"
);
let widths: Vec<usize> = [is_red, is_green, is_blue]
.iter()
.map(|f| {
let (lo, hi) = channel_extent(&out_a, 900, 300, *f).expect("cell must be visible");
hi - lo + 1
})
.collect();
assert_eq!(
widths[0], widths[1],
"flat grid: every column must be exactly as wide as the others"
);
assert_eq!(
widths[1], widths[2],
"flat grid: every column must be exactly as wide as the others"
);
}
#[test]
fn a_sphere_surface_makes_the_side_columns_narrower_than_the_centre_one() {
let mut flat = root_with(three_column_grid(900.0, 300.0, None), 900.0, 300.0);
let mut curved = root_with(
three_column_grid(900.0, 300.0, Some(sphere(700.0, 140.0, 0.0, Some(1400.0)))),
900.0,
300.0,
);
let flat_out = render_pixels(&mut flat, 900, 300, 0.0);
let curved_out = render_pixels(&mut curved, 900, 300, 0.0);
assert_ne!(
flat_out, curved_out,
"declaring a layout-surface on the container must change the rendered frame"
);
let (red_lo, red_hi) =
channel_extent(&curved_out, 900, 300, is_red).expect("left column must be visible");
let (green_lo, green_hi) =
channel_extent(&curved_out, 900, 300, is_green).expect("centre column must be visible");
let red_w = red_hi - red_lo + 1;
let green_w = green_hi - green_lo + 1;
assert!(
red_w < green_w,
"a column near the arc's edge must foreshorten more than the centre column: \
edge={red_w}px, centre={green_w}px"
);
}
#[test]
fn layout_surface_child_delta_is_zero_at_the_centre_of_the_arc() {
let (dx, dy, dz) = layout_surface_child_delta(
SurfaceKind::Sphere,
1000.0,
120.0,
60.0,
0.0,
0.0,
400.0,
200.0,
);
assert_eq!((dx, dy, dz), (0.0, 0.0, 0.0));
}
#[test]
fn sphere_delta_recedes_in_z_away_from_the_centre() {
let centre = layout_surface_child_delta(
SurfaceKind::Sphere,
1000.0,
140.0,
0.0,
0.0,
0.0,
400.0,
200.0,
);
let mid = layout_surface_child_delta(
SurfaceKind::Sphere,
1000.0,
140.0,
0.0,
0.5,
0.0,
400.0,
200.0,
);
let edge = layout_surface_child_delta(
SurfaceKind::Sphere,
1000.0,
140.0,
0.0,
1.0,
0.0,
400.0,
200.0,
);
assert_eq!(
centre.2, 0.0,
"the centre of the arc sits at the reference depth"
);
assert!(
mid.2 < 0.0 && edge.2 < mid.2,
"depth must recede monotonically away from the centre: mid={:?} edge={:?}",
mid.2,
edge.2
);
}
#[test]
fn adjacent_cell_gaps_shrink_towards_the_edge_of_the_arc() {
let half_w = 450.0;
let x_at = |nu: f32| {
let (dx, _, _) = layout_surface_child_delta(
SurfaceKind::Sphere,
700.0,
150.0,
0.0,
nu,
0.0,
half_w,
1.0,
);
nu * half_w + dx
};
let centre_gap = (x_at(0.1) - x_at(-0.1)).abs();
let edge_gap = (x_at(1.0) - x_at(0.8)).abs();
assert!(
edge_gap < centre_gap,
"a projected grid's cells must not all be the same width — a step near the limb \
must map to a smaller screen gap than the same step at the centre: \
centre_gap={centre_gap:.2}px, edge_gap={edge_gap:.2}px"
);
}
#[test]
fn cylinder_ignores_the_vertical_axis() {
let (_, dy, _) = layout_surface_child_delta(
SurfaceKind::Cylinder,
1000.0,
140.0,
999.0,
0.3,
0.7,
400.0,
200.0,
);
assert_eq!(
dy, 0.0,
"a cylinder only wraps its arc-x — a child's vertical position must be untouched"
);
}
#[test]
fn a_cylinder_edge_column_foreshortens_towards_the_expected_width_with_no_perspective_at_all() {
let mut root = root_with(
n_column_grid(
1800.0,
300.0,
9,
"#ff0000",
Some(LayoutSurface::Cylinder {
radius: 900.0,
arc_x: 120.0,
perspective: None,
rotate_x: None,
rotate_y: None,
}),
),
1800.0,
300.0,
);
let out = render_pixels(&mut root, 1800, 300, 0.0);
let (lo, hi) =
channel_extent(&out, 1800, 300, is_red).expect("the edge column must be visible");
let width = hi - lo + 1;
assert!(
(100..=140).contains(&width),
"a 200px-wide edge column ~53 degrees off-axis on a layout-surface cylinder must \
rotate to face the surface and foreshorten to roughly 120px even with no \
`perspective` set at all — measured {width}px, which is what an unrotated cell \
that is only translated and depth-shifted would still measure (no narrower than \
its flat 200px)"
);
}
#[test]
fn rotate_y_animates_linearly_from_scene_start_to_the_scene_duration() {
let angle = SurfaceAngle::Animated {
from: -20.0,
to: 40.0,
duration: None,
};
assert_eq!(angle.value_at(0.0, 2.0), -20.0);
assert_eq!(angle.value_at(2.0, 2.0), 40.0);
assert!((angle.value_at(1.0, 2.0) - 10.0).abs() < 0.001);
}
}