Skip to main content

rustmotion_core/engine/
paint_pass.rs

1use std::cell::RefCell;
2
3use skia_safe::gradient::{self, Colors as GradientColors, Gradient};
4use skia_safe::{
5    canvas::SaveLayerRec, Canvas, ClipOp, Color as SColor, Color4f, Paint, PaintStyle, PathBuilder,
6    Point, RRect, Rect, M44, V3,
7};
8
9use crate::css::style::{
10    Background, BackgroundLayer, BorderEdges, BorderRadius, BorderStyle, BoxShadow, ClipPath,
11    Color, CssStyle, Edges, LayoutSurface, Material, MaterialPreset, Overflow, TransformFn,
12    TransformOrigin,
13};
14use crate::css::units::{
15    parse_origin_component, Length, LengthContext, LengthPercentage, ParsedLength,
16};
17use crate::engine::animator::{burst_progress, burst_stroke_span};
18use crate::engine::box_tree::{BoxKind, BoxNode, NodeId};
19use crate::engine::layout_pass::{BoxLayout, LayoutResult};
20use crate::engine::transition::{blob_local_path, scaled_mask_path};
21use crate::schema::{AnimationEffect as SchemaAnimationEffect, BurstConfig};
22
23#[derive(Debug, Clone, Copy)]
24pub struct PaintFrame {
25    pub light: LightDirection,
26    pub time: f64,
27    pub scenario_time: f64,
28    pub frame_index: u32,
29    pub fps: u32,
30    pub video_width: u32,
31    pub video_height: u32,
32    pub scene_duration: f64,
33    pub camera: Option<PlaneCamera>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct PlaneCamera {
38    pub pan_x: f32,
39    pub pan_y: f32,
40    pub zoom: f32,
41    pub rotation: f32,
42    pub origin_x: f32,
43    pub origin_y: f32,
44    pub focus: f32,
45    pub aperture: f32,
46    pub rotate_x: f32,
47    pub rotate_y: f32,
48    pub perspective: f32,
49}
50
51fn apply_plane_camera(canvas: &Canvas, cam: &PlaneCamera, depth: f32, viewport: (f32, f32)) {
52    let zoom = 1.0 + (cam.zoom - 1.0) * depth;
53    let rotation = cam.rotation * depth;
54    let pan_x = cam.pan_x * depth;
55    let pan_y = cam.pan_y * depth;
56
57    let tilt_x = cam.rotate_x * depth;
58    let tilt_y = cam.rotate_y * depth;
59
60    canvas.translate(Point::new(cam.origin_x, cam.origin_y));
61    if rotation.abs() > 0.001 {
62        canvas.rotate(rotation, None);
63    }
64    if (zoom - 1.0).abs() > 0.001 {
65        canvas.scale((zoom, zoom));
66    }
67    if tilt_x.abs() > 0.001 || tilt_y.abs() > 0.001 {
68        let mut shot = M44::new_identity();
69        if cam.perspective > 0.0 {
70            shot.pre_concat(&css_perspective_m44(cam.perspective));
71        }
72        shot.pre_concat(&M44::rotate(V3::new(1.0, 0.0, 0.0), tilt_x.to_radians()));
73        shot.pre_concat(&M44::rotate(V3::new(0.0, 1.0, 0.0), tilt_y.to_radians()));
74        canvas.concat_44(&shot);
75    }
76    canvas.translate(Point::new(-cam.origin_x - pan_x, -cam.origin_y - pan_y));
77    canvas.clip_rect(
78        Rect::from_wh(viewport.0, viewport.1),
79        ClipOp::Intersect,
80        true,
81    );
82}
83
84#[derive(Debug, Clone, Copy)]
85enum SurfaceKind {
86    Cylinder,
87    Sphere,
88}
89
90struct LayoutSurfaceFrame {
91    kind: SurfaceKind,
92    radius: f32,
93    arc_x_deg: f32,
94    arc_y_deg: f32,
95    pivot: (f32, f32),
96    half_w: f32,
97    half_h: f32,
98    perspective: Option<f32>,
99    rotate_x_deg: f32,
100    rotate_y_deg: f32,
101}
102
103impl LayoutSurfaceFrame {
104    fn new(surface: &LayoutSurface, container: &BoxLayout, frame: &PaintFrame) -> Self {
105        let (cx, cy, cw, ch) = container.content_box();
106        let rotate_x_deg = surface
107            .rotate_x()
108            .map(|a| a.value_at(frame.time, frame.scene_duration))
109            .unwrap_or(0.0);
110        let rotate_y_deg = surface
111            .rotate_y()
112            .map(|a| a.value_at(frame.time, frame.scene_duration))
113            .unwrap_or(0.0);
114        Self {
115            kind: if surface.is_sphere() {
116                SurfaceKind::Sphere
117            } else {
118                SurfaceKind::Cylinder
119            },
120            radius: surface.radius(),
121            arc_x_deg: surface.arc_x(),
122            arc_y_deg: surface.arc_y(),
123            pivot: (cx + cw / 2.0, cy + ch / 2.0),
124            half_w: (cw / 2.0).max(0.001),
125            half_h: (ch / 2.0).max(0.001),
126            perspective: surface.perspective(),
127            rotate_x_deg,
128            rotate_y_deg,
129        }
130    }
131
132    fn child_delta(&self, nu: f32, nv: f32) -> (f32, f32, f32) {
133        layout_surface_child_delta(
134            self.kind,
135            self.radius,
136            self.arc_x_deg,
137            self.arc_y_deg,
138            nu,
139            nv,
140            self.half_w,
141            self.half_h,
142        )
143    }
144
145    fn child_facing(&self, nu: f32, nv: f32) -> (f32, f32) {
146        layout_surface_child_angles(self.kind, self.arc_x_deg, self.arc_y_deg, nu, nv)
147    }
148
149    fn apply(&self, canvas: &Canvas, child: &BoxLayout) {
150        let nu = ((child.cx() - self.pivot.0) / self.half_w).clamp(-1.0, 1.0);
151        let nv = ((child.cy() - self.pivot.1) / self.half_h).clamp(-1.0, 1.0);
152        let (dx, dy, dz) = self.child_delta(nu, nv);
153        let (theta_x, theta_y) = self.child_facing(nu, nv);
154        let flat_x = nu * self.half_w;
155        let flat_y = nv * self.half_h;
156        let target_x = flat_x + dx;
157        let target_y = flat_y + dy;
158        let target_z = dz;
159
160        let mut m = M44::new_identity();
161        m.pre_concat(&M44::translate(self.pivot.0, self.pivot.1, 0.0));
162        if let Some(d) = self.perspective {
163            m.pre_concat(&css_perspective_m44(d.max(1.0)));
164        }
165        if self.rotate_y_deg.abs() > 0.001 {
166            m.pre_concat(&M44::rotate(
167                V3::new(0.0, 1.0, 0.0),
168                self.rotate_y_deg.to_radians(),
169            ));
170        }
171        if self.rotate_x_deg.abs() > 0.001 {
172            m.pre_concat(&M44::rotate(
173                V3::new(1.0, 0.0, 0.0),
174                self.rotate_x_deg.to_radians(),
175            ));
176        }
177        m.pre_concat(&M44::translate(target_x, target_y, target_z));
178        if theta_x.abs() > 0.0001 {
179            m.pre_concat(&M44::rotate(V3::new(0.0, 1.0, 0.0), theta_x));
180        }
181        if theta_y.abs() > 0.0001 {
182            m.pre_concat(&M44::rotate(V3::new(1.0, 0.0, 0.0), theta_y));
183        }
184        m.pre_concat(&M44::translate(-flat_x, -flat_y, 0.0));
185        m.pre_concat(&M44::translate(-self.pivot.0, -self.pivot.1, 0.0));
186        canvas.concat_44(&m);
187    }
188}
189
190fn layout_surface_child_angles(
191    kind: SurfaceKind,
192    arc_x_deg: f32,
193    arc_y_deg: f32,
194    nu: f32,
195    nv: f32,
196) -> (f32, f32) {
197    let nu = nu.clamp(-1.0, 1.0);
198    let nv = nv.clamp(-1.0, 1.0);
199    let theta_x = nu * arc_x_deg.to_radians() / 2.0;
200    let theta_y = match kind {
201        SurfaceKind::Sphere => nv * arc_y_deg.to_radians() / 2.0,
202        SurfaceKind::Cylinder => 0.0,
203    };
204    (theta_x, theta_y)
205}
206
207#[allow(clippy::too_many_arguments)]
208fn layout_surface_child_delta(
209    kind: SurfaceKind,
210    radius: f32,
211    arc_x_deg: f32,
212    arc_y_deg: f32,
213    nu: f32,
214    nv: f32,
215    half_w: f32,
216    half_h: f32,
217) -> (f32, f32, f32) {
218    let nu = nu.clamp(-1.0, 1.0);
219    let nv = nv.clamp(-1.0, 1.0);
220    let (theta_x, theta_y) = layout_surface_child_angles(kind, arc_x_deg, arc_y_deg, nu, nv);
221    let flat_x = nu * half_w;
222    match kind {
223        SurfaceKind::Sphere => {
224            let flat_y = nv * half_h;
225            let target_x = radius * theta_x.sin() * theta_y.cos();
226            let target_y = radius * theta_y.sin();
227            let target_z = radius * (theta_x.cos() * theta_y.cos() - 1.0);
228            (target_x - flat_x, target_y - flat_y, target_z)
229        }
230        SurfaceKind::Cylinder => {
231            let target_x = radius * theta_x.sin();
232            let target_z = radius * (theta_x.cos() - 1.0);
233            (target_x - flat_x, 0.0, target_z)
234        }
235    }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq)]
239pub struct HitRect {
240    pub x: f32,
241    pub y: f32,
242    pub w: f32,
243    pub h: f32,
244}
245
246#[derive(Debug, Clone, Copy, PartialEq)]
247pub struct HitNode {
248    pub node_id: NodeId,
249    pub rect: HitRect,
250}
251
252pub type HitMap = Vec<HitNode>;
253
254#[derive(Debug, Clone, PartialEq)]
255pub struct EnrichedHit {
256    pub node_id: NodeId,
257    pub kind: String,
258    pub rect: HitRect,
259    pub pointer: Option<String>,
260}
261
262pub trait PaintDispatcher {
263    fn dispatch(
264        &self,
265        canvas: &Canvas,
266        payload: &(dyn std::any::Any + Send + Sync),
267        css: &CssStyle,
268        layout: &BoxLayout,
269        frame: &PaintFrame,
270    );
271}
272
273pub struct NoopDispatcher;
274
275impl PaintDispatcher for NoopDispatcher {
276    fn dispatch(
277        &self,
278        _canvas: &Canvas,
279        _payload: &(dyn std::any::Any + Send + Sync),
280        _css: &CssStyle,
281        _layout: &BoxLayout,
282        _frame: &PaintFrame,
283    ) {
284    }
285}
286
287pub fn paint_tree(
288    canvas: &Canvas,
289    root: &BoxNode,
290    layout: &LayoutResult,
291    frame: &PaintFrame,
292    dispatcher: &dyn PaintDispatcher,
293) {
294    let ctx = PaintContext {
295        layout,
296        frame,
297        dispatcher,
298        viewport_size: (frame.video_width as f32, frame.video_height as f32),
299        hits: None,
300    };
301    paint_node(canvas, root, &ctx, 0);
302}
303
304pub fn paint_tree_with_hits(
305    canvas: &Canvas,
306    root: &BoxNode,
307    layout: &LayoutResult,
308    frame: &PaintFrame,
309    dispatcher: &dyn PaintDispatcher,
310) -> HitMap {
311    let hits = RefCell::new(Vec::new());
312    let ctx = PaintContext {
313        layout,
314        frame,
315        dispatcher,
316        viewport_size: (frame.video_width as f32, frame.video_height as f32),
317        hits: Some(&hits),
318    };
319    paint_node(canvas, root, &ctx, 0);
320    hits.into_inner()
321}
322
323struct PaintContext<'a> {
324    layout: &'a LayoutResult,
325    frame: &'a PaintFrame,
326    dispatcher: &'a dyn PaintDispatcher,
327    viewport_size: (f32, f32),
328    hits: Option<&'a RefCell<HitMap>>,
329}
330
331fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: usize) {
332    if let Some(window) = &node.window {
333        if !window.contains(ctx.frame.time) {
334            return;
335        }
336    }
337    let Some(box_layout) = ctx.layout.get(node.id) else {
338        return;
339    };
340    if box_layout.width <= 0.0 || box_layout.height <= 0.0 {
341        return;
342    }
343
344    let length_ctx = LengthContext {
345        viewport_width: ctx.viewport_size.0,
346        viewport_height: ctx.viewport_size.1,
347        parent_size: box_layout.width.max(box_layout.height),
348        font_size: node.css.font_size_px_or(16.0),
349        root_font_size: 16.0,
350    };
351    let length_ctx_x = LengthContext {
352        parent_size: box_layout.width,
353        ..length_ctx
354    };
355    let length_ctx_y = LengthContext {
356        parent_size: box_layout.height,
357        ..length_ctx
358    };
359
360    canvas.save();
361
362    if tree_depth == 1 {
363        if let Some(cam) = &ctx.frame.camera {
364            let depth = node.css.depth.unwrap_or(1.0);
365            apply_plane_camera(canvas, cam, depth, ctx.viewport_size);
366        }
367    }
368
369    if node.css.transform.is_some() || node.css.perspective.is_some() {
370        let (tx, ty, _tz) =
371            resolve_origin(node.css.transform_origin.as_ref(), box_layout, &length_ctx);
372        let transform_pivot = (tx, ty);
373
374        let perspective_pivot = if node.css.perspective_origin.is_some() {
375            let (px, py, _pz) = resolve_origin(
376                node.css.perspective_origin.as_ref(),
377                box_layout,
378                &length_ctx,
379            );
380            (px, py)
381        } else {
382            transform_pivot
383        };
384
385        let transform_list = node.css.transform.as_deref().unwrap_or(&[]);
386        let perspective_d = node
387            .css
388            .perspective
389            .as_ref()
390            .map(|l| l.resolve(&length_ctx).max(1.0));
391        let axes = TransformAxes {
392            x: length_ctx_x,
393            y: length_ctx_y,
394            general: length_ctx,
395        };
396        apply_transform(
397            canvas,
398            transform_list,
399            perspective_d,
400            transform_pivot,
401            perspective_pivot,
402            &axes,
403        );
404    }
405
406    if let (Some(hits), BoxKind::Component(_)) = (ctx.hits, &node.kind) {
407        let local = Rect::from_xywh(
408            box_layout.x,
409            box_layout.y,
410            box_layout.width,
411            box_layout.height,
412        );
413        let dev = canvas.local_to_device_as_3x3().map_rect(local).0;
414        hits.borrow_mut().push(HitNode {
415            node_id: node.id,
416            rect: HitRect {
417                x: dev.left,
418                y: dev.top,
419                w: dev.width(),
420                h: dev.height(),
421            },
422        });
423    }
424
425    if let Some(filters) = node.css.backdrop_filter.as_deref() {
426        if let Some(backdrop) = filters_to_image_filter(filters, &length_ctx, box_layout) {
427            let radius = node
428                .css
429                .border_radius
430                .as_ref()
431                .map(|r| resolve_border_radius(r, box_layout, &length_ctx))
432                .unwrap_or([0.0; 4]);
433            canvas.save();
434            canvas.clip_rrect(border_rrect(box_layout, radius), ClipOp::Intersect, true);
435            let rec = SaveLayerRec::default().backdrop(&backdrop);
436            canvas.save_layer(&rec);
437            canvas.restore();
438            canvas.restore();
439        }
440    }
441
442    match active_shatter(&node.css, ctx.frame.time) {
443        Some((cfg, progress)) => {
444            paint_shattered_node(
445                canvas, node, box_layout, length_ctx, ctx, tree_depth, cfg, progress,
446            );
447        }
448        None => {
449            paint_node_visual(canvas, node, box_layout, length_ctx, ctx, tree_depth);
450        }
451    }
452
453    if let Some((cfg, progress)) = active_burst(&node.css, ctx.frame.time) {
454        paint_burst(canvas, box_layout, cfg, progress);
455    }
456
457    canvas.restore();
458}
459
460fn paint_node_visual(
461    canvas: &Canvas,
462    node: &BoxNode,
463    box_layout: &BoxLayout,
464    length_ctx: LengthContext,
465    ctx: &PaintContext,
466    tree_depth: usize,
467) {
468    let overflow = node.css.overflow.unwrap_or(Overflow::Visible);
469
470    let opacity = node.css.opacity.unwrap_or(1.0).clamp(0.0, 1.0);
471    let content_filter = node
472        .css
473        .filter
474        .as_deref()
475        .and_then(|list| filters_to_image_filter(list, &length_ctx, box_layout));
476    let aberration_shift = active_chromatic_aberration(&node.css, ctx.frame.time)
477        .map(|(cfg, progress)| crate::engine::animator::chromatic_aberration_shift(cfg, progress));
478    let aberration_filter = aberration_shift.and_then(chromatic_aberration_image_filter);
479    let defocus_sigma = match (tree_depth, ctx.frame.camera.as_ref()) {
480        (1, Some(cam)) => depth_of_field_sigma(cam, node.css.depth.unwrap_or(1.0)),
481        _ => 0.0,
482    };
483    let defocus_filter = depth_of_field_image_filter(defocus_sigma);
484    let combined_filter = {
485        use skia_safe::image_filters;
486        let node_filter = match (content_filter, aberration_filter) {
487            (Some(cf), Some(af)) => image_filters::compose(af, cf),
488            (Some(cf), None) => Some(cf),
489            (None, Some(af)) => Some(af),
490            (None, None) => None,
491        };
492        match (node_filter, defocus_filter) {
493            (Some(nf), Some(df)) => image_filters::compose(df, nf),
494            (Some(nf), None) => Some(nf),
495            (None, Some(df)) => Some(df),
496            (None, None) => None,
497        }
498    };
499    let opened_opacity_layer = if opacity < 1.0 || combined_filter.is_some() {
500        let mut paint = Paint::default();
501        if opacity < 1.0 {
502            paint.set_alpha((opacity * 255.0) as u8);
503        }
504        if let Some(filter) = combined_filter {
505            paint.set_image_filter(filter);
506        }
507        let filter_bleed_px = node
508            .css
509            .filter
510            .as_deref()
511            .map(|list| filter_bleed(list, &length_ctx))
512            .unwrap_or(0.0);
513        let shadow_bleed_px = node
514            .css
515            .box_shadow
516            .as_deref()
517            .map(|shadows| box_shadow_bleed(shadows, &length_ctx))
518            .unwrap_or(0.0);
519        let aberration_bleed_px = aberration_shift.map(|s| s.abs().ceil()).unwrap_or(0.0);
520        let defocus_bleed_px = (defocus_sigma * 3.0).ceil();
521        let bleed = filter_bleed_px
522            .max(shadow_bleed_px)
523            .max(aberration_bleed_px)
524            .max(defocus_bleed_px);
525        let mut bounds = Rect::from_xywh(
526            box_layout.x - bleed,
527            box_layout.y - bleed,
528            box_layout.width + bleed * 2.0,
529            box_layout.height + bleed * 2.0,
530        );
531        if overflow == Overflow::Visible {
532            if let Some(descendants) = subtree_layout_bounds(node, ctx.layout) {
533                bounds = Rect::join2(bounds, descendants);
534            }
535        }
536        let rec = SaveLayerRec::default().paint(&paint).bounds(&bounds);
537        canvas.save_layer(&rec);
538        true
539    } else {
540        false
541    };
542
543    let opened_clip_path = match node.css.clip_path.as_ref() {
544        Some(clip) => match clip_path_to_skia(clip, box_layout, &length_ctx) {
545            Some(path) => {
546                canvas.save();
547                canvas.clip_path(&path, ClipOp::Intersect, true);
548                true
549            }
550            None => false,
551        },
552        None => false,
553    };
554
555    if let Some(shadows) = node.css.box_shadow.as_ref() {
556        for shadow in shadows {
557            if shadow.inset.unwrap_or(false) {
558                continue;
559            }
560            paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, false);
561        }
562    }
563    if let Some(bg) = node.css.background.as_ref() {
564        paint_background(canvas, box_layout, &node.css, bg, &length_ctx);
565    }
566    if let Some(material) = node.css.material.as_ref() {
567        paint_material(
568            canvas,
569            box_layout,
570            &node.css,
571            material,
572            &ctx.frame.light,
573            &length_ctx,
574        );
575    }
576    if let Some(gb) = node.css.gradient_border.as_ref() {
577        paint_gradient_border(canvas, box_layout, &node.css, gb, &length_ctx);
578    } else if let Some(border) = node.css.border.as_ref() {
579        paint_border(canvas, box_layout, &node.css, border, &length_ctx);
580    }
581
582    let shimmer = active_shimmer(&node.css, ctx.frame.time);
583    let opened_shimmer_layer = if shimmer.is_some() {
584        let bounds = Rect::from_xywh(
585            box_layout.x,
586            box_layout.y,
587            box_layout.width,
588            box_layout.height,
589        );
590        let rec = SaveLayerRec::default().bounds(&bounds);
591        canvas.save_layer(&rec);
592        true
593    } else {
594        false
595    };
596
597    let opened_overflow_clip = if matches!(
598        overflow,
599        Overflow::Hidden | Overflow::Clip | Overflow::Scroll | Overflow::Auto
600    ) {
601        let radius = node
602            .css
603            .border_radius
604            .as_ref()
605            .map(|r| resolve_border_radius(r, box_layout, &length_ctx))
606            .unwrap_or([0.0; 4]);
607        let rrect = padding_rrect(box_layout, radius);
608        canvas.save();
609        canvas.clip_rrect(rrect, ClipOp::Intersect, true);
610        true
611    } else {
612        false
613    };
614
615    let payload_opt = match &node.kind {
616        BoxKind::Component(p) | BoxKind::Ghost(p) => Some(p),
617        BoxKind::Container => None,
618    };
619    if let Some(payload) = payload_opt {
620        ctx.dispatcher
621            .dispatch(canvas, payload.as_ref(), &node.css, box_layout, ctx.frame);
622    }
623
624    let mut indices: Vec<usize> = (0..node.children.len()).collect();
625    indices.sort_by_key(|&i| node.children[i].css.z_index.unwrap_or(0));
626    match node.css.layout_surface.as_ref() {
627        Some(surface) => {
628            let surface_frame = LayoutSurfaceFrame::new(surface, box_layout, ctx.frame);
629            for &i in &indices {
630                let child = &node.children[i];
631                canvas.save();
632                if let Some(child_layout) = ctx.layout.get(child.id) {
633                    surface_frame.apply(canvas, child_layout);
634                }
635                paint_node(canvas, child, ctx, tree_depth + 1);
636                canvas.restore();
637            }
638        }
639        None => {
640            for &i in &indices {
641                paint_node(canvas, &node.children[i], ctx, tree_depth + 1);
642            }
643        }
644    }
645
646    if opened_overflow_clip {
647        canvas.restore();
648    }
649
650    if let Some(shadows) = node.css.box_shadow.as_ref() {
651        for shadow in shadows {
652            if shadow.inset.unwrap_or(false) {
653                paint_box_shadow(canvas, box_layout, &node.css, shadow, &length_ctx, true);
654            }
655        }
656    }
657
658    if let Some((cfg, progress)) = shimmer {
659        paint_shimmer_band(canvas, box_layout, cfg, progress);
660    }
661    if opened_shimmer_layer {
662        canvas.restore();
663    }
664    if opened_clip_path {
665        canvas.restore();
666    }
667
668    if opened_opacity_layer {
669        canvas.restore();
670    }
671}
672
673const MAX_SHATTER_PIECES: u32 = 64;
674
675fn active_shatter(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShatterConfig, f32)> {
676    let cfg = css.animation.iter().find_map(|e| match e {
677        crate::schema::AnimationEffect::Shatter(c) => Some(c),
678        _ => None,
679    })?;
680    crate::engine::animator::shatter_progress(cfg, time).map(|progress| (cfg, progress))
681}
682
683fn shatter_hash(seed: u32, index: u32, salt: u32) -> f32 {
684    let mut h = (index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
685        ^ (seed as u64).wrapping_mul(0xBF58_476D_1CE4_E5B9)
686        ^ ((salt as u64) << 32).wrapping_mul(0x94D0_49BB_1331_11EB);
687    h ^= h >> 30;
688    h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
689    h ^= h >> 27;
690    h = h.wrapping_mul(0x94D0_49BB_1331_11EB);
691    h ^= h >> 31;
692    ((h >> 11) as f64 / (1u64 << 53) as f64) as f32
693}
694
695fn shatter_seed_points(width: f32, height: f32, pieces: u32, seed: u32) -> Vec<(f32, f32)> {
696    let cols = (pieces as f32).sqrt().ceil().max(1.0) as u32;
697    let rows = pieces.div_ceil(cols).max(1);
698    let cell_w = width / cols as f32;
699    let cell_h = height / rows as f32;
700    (0..pieces)
701        .map(|i| {
702            let col = i % cols;
703            let row = i / cols;
704            let jitter_x = (shatter_hash(seed, i, 1) - 0.5) * cell_w * 0.7;
705            let jitter_y = (shatter_hash(seed, i, 2) - 0.5) * cell_h * 0.7;
706            let x = ((col as f32 + 0.5) * cell_w + jitter_x).clamp(0.0, width);
707            let y = ((row as f32 + 0.5) * cell_h + jitter_y).clamp(0.0, height);
708            (x, y)
709        })
710        .collect()
711}
712
713fn shatter_polygon_side(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
714    (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0)
715}
716
717fn shatter_segment_intersection(
718    p0: (f32, f32),
719    p1: (f32, f32),
720    a: (f32, f32),
721    b: (f32, f32),
722) -> (f32, f32) {
723    let (x1, y1) = p0;
724    let (x2, y2) = p1;
725    let (x3, y3) = a;
726    let (x4, y4) = b;
727    let denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
728    if denom.abs() < 1e-6 {
729        return p1;
730    }
731    let t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
732    (x1 + t * (x2 - x1), y1 + t * (y2 - y1))
733}
734
735fn shatter_clip_half_plane(poly: Vec<(f32, f32)>, a: (f32, f32), b: (f32, f32)) -> Vec<(f32, f32)> {
736    if poly.is_empty() {
737        return poly;
738    }
739    let n = poly.len();
740    let mut out = Vec::with_capacity(n + 1);
741    for idx in 0..n {
742        let cur = poly[idx];
743        let prev = poly[(idx + n - 1) % n];
744        let cur_in = shatter_polygon_side(cur, a, b) >= 0.0;
745        let prev_in = shatter_polygon_side(prev, a, b) >= 0.0;
746        if cur_in != prev_in {
747            out.push(shatter_segment_intersection(prev, cur, a, b));
748        }
749        if cur_in {
750            out.push(cur);
751        }
752    }
753    out
754}
755
756fn shatter_clip_by_bisector(
757    poly: Vec<(f32, f32)>,
758    keep: (f32, f32),
759    other: (f32, f32),
760    reach: f32,
761) -> Vec<(f32, f32)> {
762    let mid = ((keep.0 + other.0) * 0.5, (keep.1 + other.1) * 0.5);
763    let dir = (other.0 - keep.0, other.1 - keep.1);
764    let len = (dir.0 * dir.0 + dir.1 * dir.1).sqrt().max(1e-6);
765    let perp = (-dir.1 / len, dir.0 / len);
766    let mut a = (mid.0 + perp.0 * reach, mid.1 + perp.1 * reach);
767    let mut b = (mid.0 - perp.0 * reach, mid.1 - perp.1 * reach);
768    if shatter_polygon_side(keep, a, b) < 0.0 {
769        std::mem::swap(&mut a, &mut b);
770    }
771    shatter_clip_half_plane(poly, a, b)
772}
773
774fn shatter_cells(width: f32, height: f32, pieces: u32, seed: u32) -> Vec<Vec<(f32, f32)>> {
775    if width <= 0.0 || height <= 0.0 {
776        return Vec::new();
777    }
778    let pieces = pieces.clamp(1, MAX_SHATTER_PIECES);
779    let points = shatter_seed_points(width, height, pieces, seed);
780    let reach = (width + height) * 4.0 + 1000.0;
781    (0..points.len())
782        .map(|i| {
783            let mut poly = vec![(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)];
784            for (j, &other) in points.iter().enumerate() {
785                if j == i || poly.is_empty() {
786                    continue;
787                }
788                poly = shatter_clip_by_bisector(poly, points[i], other, reach);
789            }
790            poly
791        })
792        .collect()
793}
794
795fn shatter_polygon_centroid(points: &[(f32, f32)]) -> (f32, f32) {
796    let n = points.len();
797    let mut area = 0.0_f32;
798    let mut cx = 0.0_f32;
799    let mut cy = 0.0_f32;
800    for i in 0..n {
801        let (x0, y0) = points[i];
802        let (x1, y1) = points[(i + 1) % n];
803        let cross = x0 * y1 - x1 * y0;
804        area += cross;
805        cx += (x0 + x1) * cross;
806        cy += (y0 + y1) * cross;
807    }
808    area *= 0.5;
809    if area.abs() < 1e-6 {
810        let sum = points
811            .iter()
812            .fold((0.0, 0.0), |acc, p| (acc.0 + p.0, acc.1 + p.1));
813        return (sum.0 / n as f32, sum.1 / n as f32);
814    }
815    (cx / (6.0 * area), cy / (6.0 * area))
816}
817
818fn paint_shattered_node(
819    canvas: &Canvas,
820    node: &BoxNode,
821    box_layout: &BoxLayout,
822    length_ctx: LengthContext,
823    ctx: &PaintContext,
824    tree_depth: usize,
825    cfg: &crate::schema::ShatterConfig,
826    progress: f32,
827) {
828    let width = box_layout.width.max(1.0).ceil() as i32;
829    let height = box_layout.height.max(1.0).ceil() as i32;
830    let info = skia_safe::ImageInfo::new(
831        (width, height),
832        skia_safe::ColorType::RGBA8888,
833        skia_safe::AlphaType::Premul,
834        None,
835    );
836    let Some(mut surface) = skia_safe::surfaces::raster(&info, None, None) else {
837        paint_node_visual(canvas, node, box_layout, length_ctx, ctx, tree_depth);
838        return;
839    };
840    let offscreen = surface.canvas();
841    offscreen.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
842    offscreen.translate((-box_layout.x, -box_layout.y));
843    let capture_ctx = PaintContext {
844        layout: ctx.layout,
845        frame: ctx.frame,
846        dispatcher: ctx.dispatcher,
847        viewport_size: ctx.viewport_size,
848        hits: None,
849    };
850    paint_node_visual(
851        offscreen,
852        node,
853        box_layout,
854        length_ctx,
855        &capture_ctx,
856        tree_depth,
857    );
858    let image = surface.image_snapshot();
859
860    let pieces = cfg.pieces.clamp(1, MAX_SHATTER_PIECES);
861    let cells = shatter_cells(box_layout.width, box_layout.height, pieces, cfg.seed);
862    let origin_x = cfg.origin.x.clamp(0.0, 1.0) * box_layout.width;
863    let origin_y = cfg.origin.y.clamp(0.0, 1.0) * box_layout.height;
864    let diagonal = (box_layout.width.powi(2) + box_layout.height.powi(2)).sqrt();
865
866    for (i, cell) in cells.iter().enumerate() {
867        if cell.len() < 3 {
868            continue;
869        }
870        let index = i as u32;
871        let centroid = shatter_polygon_centroid(cell);
872        let (mut dx, mut dy) = (centroid.0 - origin_x, centroid.1 - origin_y);
873        let dist = (dx * dx + dy * dy).sqrt();
874        if dist < 0.001 {
875            let angle = shatter_hash(cfg.seed, index, 7) * std::f32::consts::TAU;
876            dx = angle.cos();
877            dy = angle.sin();
878        } else {
879            dx /= dist;
880            dy /= dist;
881        }
882
883        let travel_jitter = 0.6 + shatter_hash(cfg.seed, index, 3) * 0.8;
884        let travel = cfg.spread.max(0.0) * diagonal * 0.5 * travel_jitter * progress;
885
886        let spin_sign = if shatter_hash(cfg.seed, index, 5) < 0.5 {
887            -1.0
888        } else {
889            1.0
890        };
891        let spin_jitter = 0.5 + shatter_hash(cfg.seed, index, 6) * 0.5;
892        let rotation = cfg.spin * spin_sign * spin_jitter * progress;
893
894        let depth_value = (shatter_hash(cfg.seed, index, 4) * 2.0 - 1.0) * cfg.depth;
895        let scale = (1.0 + depth_value * progress).max(0.05);
896
897        let alpha = if cfg.fade {
898            ((1.0 - progress).clamp(0.0, 1.0) * 255.0).round() as u8
899        } else {
900            255
901        };
902        if alpha == 0 {
903            continue;
904        }
905
906        let mut clip_builder = PathBuilder::new();
907        for (idx, &(px, py)) in cell.iter().enumerate() {
908            let p = (box_layout.x + px, box_layout.y + py);
909            if idx == 0 {
910                clip_builder.move_to(p);
911            } else {
912                clip_builder.line_to(p);
913            }
914        }
915        clip_builder.close();
916        let clip_path = clip_builder.detach();
917
918        let anchor = (box_layout.x + centroid.0, box_layout.y + centroid.1);
919
920        canvas.save();
921        canvas.translate((anchor.0 + dx * travel, anchor.1 + dy * travel));
922        if rotation.abs() > 0.001 {
923            canvas.rotate(rotation, None);
924        }
925        if (scale - 1.0).abs() > 0.001 {
926            canvas.scale((scale, scale));
927        }
928        canvas.translate((-anchor.0, -anchor.1));
929        canvas.clip_path(&clip_path, ClipOp::Intersect, true);
930
931        let mut paint = Paint::default();
932        paint.set_anti_alias(true);
933        paint.set_alpha(alpha);
934        canvas.draw_image(&image, (box_layout.x, box_layout.y), Some(&paint));
935        canvas.restore();
936    }
937}
938
939const MAX_BURST_COUNT: u32 = 64;
940
941fn active_burst(css: &CssStyle, time: f64) -> Option<(&BurstConfig, f32)> {
942    let cfg = css.animation.iter().find_map(|e| match e {
943        SchemaAnimationEffect::Burst(c) => Some(c),
944        _ => None,
945    })?;
946    burst_progress(cfg, time).map(|progress| (cfg, progress))
947}
948
949fn burst_track_start(half_width: f32, half_height: f32, dir: (f32, f32)) -> f32 {
950    let to_vertical = if dir.0.abs() < 1e-6 {
951        f32::INFINITY
952    } else {
953        half_width / dir.0.abs()
954    };
955    let to_horizontal = if dir.1.abs() < 1e-6 {
956        f32::INFINITY
957    } else {
958        half_height / dir.1.abs()
959    };
960    let reach = to_vertical.min(to_horizontal);
961    if reach.is_finite() {
962        reach
963    } else {
964        half_width.max(half_height)
965    }
966}
967
968fn paint_burst(canvas: &Canvas, box_layout: &BoxLayout, cfg: &BurstConfig, progress: f32) {
969    let count = cfg.count.clamp(1, MAX_BURST_COUNT);
970    let length = cfg.length.max(0.0);
971    let width = cfg.width.max(0.0);
972    if length <= 0.0 || width <= 0.0 {
973        return;
974    }
975    let jitter = cfg.jitter.clamp(0.0, 1.0);
976    let centre = (
977        box_layout.x + box_layout.width * 0.5,
978        box_layout.y + box_layout.height * 0.5,
979    );
980    let half_width = box_layout.width * 0.5;
981    let half_height = box_layout.height * 0.5;
982    let spacing = std::f32::consts::TAU / count as f32;
983
984    let mut paint = Paint::default();
985    paint.set_anti_alias(true);
986    paint.set_style(PaintStyle::Stroke);
987    paint.set_stroke_width(width);
988    paint.set_stroke_cap(skia_safe::PaintCap::Round);
989    paint.set_color(parse_color_string(&cfg.color).unwrap_or_else(|| unresolved_color(&cfg.color)));
990
991    for i in 0..count {
992        let angle = i as f32 * spacing + (shatter_hash(cfg.seed, i, 11) - 0.5) * jitter * spacing;
993        let dir = (angle.cos(), angle.sin());
994        let stroke_length = length * (1.0 + (shatter_hash(cfg.seed, i, 12) - 0.5) * jitter);
995        let phase = shatter_hash(cfg.seed, i, 13) * jitter * 0.5;
996        let (tail, head) = burst_stroke_span(progress, phase);
997        if (head - tail) * stroke_length < 0.5 {
998            continue;
999        }
1000        let base = burst_track_start(half_width, half_height, dir) + cfg.gap.max(0.0);
1001        let near = base + tail * stroke_length;
1002        let far = base + head * stroke_length;
1003        canvas.draw_line(
1004            (centre.0 + dir.0 * near, centre.1 + dir.1 * near),
1005            (centre.0 + dir.0 * far, centre.1 + dir.1 * far),
1006            &paint,
1007        );
1008    }
1009}
1010
1011fn active_shimmer(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShimmerConfig, f32)> {
1012    let cfg = css.animation.iter().find_map(|e| match e {
1013        crate::schema::AnimationEffect::Shimmer(c) => Some(c),
1014        _ => None,
1015    })?;
1016    if cfg.duration <= 0.0 || cfg.intensity <= 0.0 {
1017        return None;
1018    }
1019    let elapsed = time - cfg.delay;
1020    if elapsed < 0.0 {
1021        return None;
1022    }
1023    let progress = if cfg.repeat {
1024        (elapsed / cfg.duration).rem_euclid(1.0)
1025    } else if elapsed > cfg.duration {
1026        return None;
1027    } else {
1028        elapsed / cfg.duration
1029    };
1030    Some((cfg, progress as f32))
1031}
1032
1033fn paint_shimmer_band(
1034    canvas: &Canvas,
1035    layout: &BoxLayout,
1036    cfg: &crate::schema::ShimmerConfig,
1037    progress: f32,
1038) {
1039    if layout.width <= 0.0 || layout.height <= 0.0 {
1040        return;
1041    }
1042    let (r, g, b, a) = crate::engine::renderer::parse_hex_color(&cfg.color);
1043    let peak_alpha = (cfg.intensity.clamp(0.0, 1.0) * a as f32) as u8;
1044    let transparent = SColor::from_argb(0, r, g, b);
1045    let highlight = SColor::from_argb(peak_alpha, r, g, b);
1046
1047    let theta = cfg.angle.to_radians();
1048    let (dx, dy) = (theta.cos(), theta.sin());
1049    let cx = layout.x + layout.width / 2.0;
1050    let cy = layout.y + layout.height / 2.0;
1051    let half_extent = (layout.width * dx).abs() / 2.0 + (layout.height * dy).abs() / 2.0;
1052
1053    let band = (cfg.width.max(0.01) * half_extent * 2.0).max(1.0);
1054    let start = -half_extent - band;
1055    let centre = start + progress * (2.0 * half_extent + 2.0 * band);
1056
1057    let p0 = Point::new(cx + dx * (centre - band), cy + dy * (centre - band));
1058    let p1 = Point::new(cx + dx * (centre + band), cy + dy * (centre + band));
1059
1060    let colors4f = [
1061        Color4f::from(transparent),
1062        Color4f::from(highlight),
1063        Color4f::from(transparent),
1064    ];
1065    let gradient_colors = GradientColors::new(&colors4f, None, skia_safe::TileMode::Clamp, None);
1066    let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
1067    let Some(shader) = gradient::shaders::linear_gradient((p0, p1), &grad, None) else {
1068        return;
1069    };
1070
1071    let mut paint = Paint::default();
1072    paint.set_style(PaintStyle::Fill);
1073    paint.set_anti_alias(true);
1074    paint.set_shader(shader);
1075    paint.set_blend_mode(skia_safe::BlendMode::SrcATop);
1076    canvas.draw_rect(
1077        Rect::from_xywh(layout.x, layout.y, layout.width, layout.height),
1078        &paint,
1079    );
1080}
1081
1082fn active_chromatic_aberration(
1083    css: &CssStyle,
1084    time: f64,
1085) -> Option<(&crate::schema::ChromaticAberrationConfig, f32)> {
1086    let cfg = css.animation.iter().find_map(|e| match e {
1087        crate::schema::AnimationEffect::ChromaticAberration(c) => Some(c),
1088        _ => None,
1089    })?;
1090    if cfg.duration <= 0.0 {
1091        return None;
1092    }
1093    let elapsed = time - cfg.delay;
1094    if elapsed < 0.0 || elapsed >= cfg.duration {
1095        return None;
1096    }
1097    Some((cfg, (elapsed / cfg.duration) as f32))
1098}
1099
1100fn chromatic_aberration_image_filter(shift: f32) -> Option<skia_safe::ImageFilter> {
1101    if shift.abs() < 0.05 {
1102        return None;
1103    }
1104    use skia_safe::{color_filters, image_filters, BlendMode};
1105
1106    #[rustfmt::skip]
1107    const RED_ONLY: [f32; 20] = [
1108        1.0, 0.0, 0.0, 0.0, 0.0,
1109        0.0, 0.0, 0.0, 0.0, 0.0,
1110        0.0, 0.0, 0.0, 0.0, 0.0,
1111        0.0, 0.0, 0.0, 1.0, 0.0,
1112    ];
1113    #[rustfmt::skip]
1114    const CYAN_ONLY: [f32; 20] = [
1115        0.0, 0.0, 0.0, 0.0, 0.0,
1116        0.0, 1.0, 0.0, 0.0, 0.0,
1117        0.0, 0.0, 1.0, 0.0, 0.0,
1118        0.0, 0.0, 0.0, 1.0, 0.0,
1119    ];
1120
1121    let red_shifted = image_filters::offset((-shift, 0.0), None, None)?;
1122    let cyan_shifted = image_filters::offset((shift, 0.0), None, None)?;
1123    let red = image_filters::color_filter(
1124        color_filters::matrix_row_major(&RED_ONLY, None),
1125        Some(red_shifted),
1126        None,
1127    )?;
1128    let cyan = image_filters::color_filter(
1129        color_filters::matrix_row_major(&CYAN_ONLY, None),
1130        Some(cyan_shifted),
1131        None,
1132    )?;
1133    image_filters::blend(BlendMode::Plus, Some(red), Some(cyan), None)
1134}
1135
1136pub fn depth_of_field_sigma(camera: &PlaneCamera, depth: f32) -> f32 {
1137    if camera.aperture <= 0.0 {
1138        return 0.0;
1139    }
1140    (camera.aperture * (depth - camera.focus).abs()).max(0.0)
1141}
1142
1143fn depth_of_field_image_filter(sigma: f32) -> Option<skia_safe::ImageFilter> {
1144    if sigma <= 0.0 {
1145        return None;
1146    }
1147    skia_safe::image_filters::blur((sigma, sigma), skia_safe::TileMode::Decal, None, None)
1148}
1149
1150fn resolve_blur_radii(
1151    radius: Option<&Length>,
1152    radius_x: Option<&Length>,
1153    radius_y: Option<&Length>,
1154    ctx: &LengthContext,
1155) -> (f32, f32) {
1156    let iso = radius.map(|r| r.resolve(ctx).max(0.0));
1157    let rx = radius_x
1158        .map(|r| r.resolve(ctx).max(0.0))
1159        .or(iso)
1160        .unwrap_or(0.0);
1161    let ry = radius_y
1162        .map(|r| r.resolve(ctx).max(0.0))
1163        .or(iso)
1164        .unwrap_or(0.0);
1165    (rx, ry)
1166}
1167
1168fn filter_bleed(list: &[crate::css::style::FilterFn], ctx: &LengthContext) -> f32 {
1169    use crate::css::style::FilterFn;
1170    let mut bleed = 0.0f32;
1171    for f in list {
1172        let b = match f {
1173            FilterFn::Blur {
1174                radius,
1175                radius_x,
1176                radius_y,
1177            } => {
1178                let (rx, ry) =
1179                    resolve_blur_radii(radius.as_ref(), radius_x.as_ref(), radius_y.as_ref(), ctx);
1180                rx.max(ry) * 1.5
1181            }
1182            FilterFn::DirectionalBlur { radius, .. } => radius.resolve(ctx).max(0.0) * 1.5,
1183            FilterFn::DropShadow {
1184                offset_x,
1185                offset_y,
1186                blur,
1187                ..
1188            } => {
1189                let blur_bleed = blur
1190                    .as_ref()
1191                    .map(|b| b.resolve(ctx).max(0.0) * 1.5)
1192                    .unwrap_or(0.0);
1193                offset_x.resolve(ctx).abs().max(offset_y.resolve(ctx).abs()) + blur_bleed
1194            }
1195            _ => 0.0,
1196        };
1197        bleed = bleed.max(b);
1198    }
1199    bleed
1200}
1201
1202fn box_shadow_bleed(shadows: &[BoxShadow], ctx: &LengthContext) -> f32 {
1203    let mut bleed = 0.0f32;
1204    for shadow in shadows {
1205        if shadow.inset.unwrap_or(false) {
1206            continue;
1207        }
1208        let offset = shadow
1209            .offset_x
1210            .resolve(ctx)
1211            .abs()
1212            .max(shadow.offset_y.resolve(ctx).abs());
1213        let spread = shadow
1214            .spread
1215            .as_ref()
1216            .map(|s| s.resolve(ctx).max(0.0))
1217            .unwrap_or(0.0);
1218        let blur_bleed = shadow
1219            .blur
1220            .as_ref()
1221            .map(|b| b.resolve(ctx).max(0.0) * 1.5)
1222            .unwrap_or(0.0);
1223        bleed = bleed.max(offset + spread + blur_bleed);
1224    }
1225    bleed
1226}
1227
1228fn subtree_layout_bounds(node: &BoxNode, layout: &LayoutResult) -> Option<Rect> {
1229    let mut bounds: Option<Rect> = None;
1230    for child in &node.children {
1231        if let Some(child_layout) = layout.get(child.id) {
1232            let rect = Rect::from_xywh(
1233                child_layout.x,
1234                child_layout.y,
1235                child_layout.width,
1236                child_layout.height,
1237            );
1238            bounds = Some(bounds.map_or(rect, |b| Rect::join2(b, rect)));
1239        }
1240        if let Some(child_bounds) = subtree_layout_bounds(child, layout) {
1241            bounds = Some(bounds.map_or(child_bounds, |b| Rect::join2(b, child_bounds)));
1242        }
1243    }
1244    bounds
1245}
1246
1247fn filters_to_image_filter(
1248    list: &[crate::css::style::FilterFn],
1249    ctx: &LengthContext,
1250    layout: &BoxLayout,
1251) -> Option<skia_safe::ImageFilter> {
1252    use crate::css::style::FilterFn;
1253    use skia_safe::image_filters;
1254
1255    let mut chain: Option<skia_safe::ImageFilter> = None;
1256    for f in list {
1257        chain = match f {
1258            FilterFn::Blur {
1259                radius,
1260                radius_x,
1261                radius_y,
1262            } => {
1263                let (rx, ry) =
1264                    resolve_blur_radii(radius.as_ref(), radius_x.as_ref(), radius_y.as_ref(), ctx);
1265                if rx <= 0.0 && ry <= 0.0 {
1266                    chain
1267                } else {
1268                    image_filters::blur(
1269                        (rx / 2.0, ry / 2.0),
1270                        skia_safe::TileMode::Clamp,
1271                        chain,
1272                        None,
1273                    )
1274                }
1275            }
1276            FilterFn::DirectionalBlur { angle, radius } => {
1277                let r = radius.resolve(ctx).max(0.0);
1278                if r <= 0.0 {
1279                    chain
1280                } else {
1281                    directional_blur_image_filter(r, *angle, (layout.cx(), layout.cy()), chain)
1282                }
1283            }
1284            FilterFn::DropShadow {
1285                offset_x,
1286                offset_y,
1287                blur,
1288                color,
1289            } => {
1290                let sigma = blur.as_ref().map(|b| b.resolve(ctx) / 2.0).unwrap_or(0.0);
1291                let c = color.as_ref().map(parse_color).unwrap_or(SColor::BLACK);
1292                image_filters::drop_shadow(
1293                    (offset_x.resolve(ctx), offset_y.resolve(ctx)),
1294                    (sigma, sigma),
1295                    c,
1296                    None,
1297                    chain,
1298                    None,
1299                )
1300            }
1301            FilterFn::Noise { intensity, seed } => match noise_image_filter(*intensity, *seed) {
1302                Some(noise) => {
1303                    image_filters::blend(skia_safe::BlendMode::Overlay, chain, Some(noise), None)
1304                }
1305                None => chain,
1306            },
1307            other => color_matrix_for(other)
1308                .map(|m| skia_safe::color_filters::matrix_row_major(&m, None))
1309                .and_then(|cf| image_filters::color_filter(cf, chain, None)),
1310        };
1311    }
1312    chain
1313}
1314
1315fn directional_blur_image_filter(
1316    radius: f32,
1317    angle_deg: f32,
1318    pivot: (f32, f32),
1319    input: Option<skia_safe::ImageFilter>,
1320) -> Option<skia_safe::ImageFilter> {
1321    use skia_safe::{image_filters, Matrix, Point, SamplingOptions, TileMode};
1322
1323    let sigma = radius / 2.0;
1324    if sigma <= 0.0 {
1325        return input;
1326    }
1327    let point = Point::new(pivot.0, pivot.1);
1328    let sampling = SamplingOptions::default();
1329    let to_axis = Matrix::rotate_deg_pivot(-angle_deg, point);
1330    let back_to_angle = Matrix::rotate_deg_pivot(angle_deg, point);
1331    let aligned = image_filters::matrix_transform(&to_axis, sampling, input);
1332    let blurred = image_filters::blur((sigma, 0.0), TileMode::Clamp, aligned, None)?;
1333    image_filters::matrix_transform(&back_to_angle, sampling, blurred)
1334}
1335
1336fn noise_image_filter(intensity: f32, seed: u64) -> Option<skia_safe::ImageFilter> {
1337    use skia_safe::image_filters;
1338
1339    let intensity = intensity.clamp(0.0, 1.0);
1340    if intensity <= 0.0 {
1341        return None;
1342    }
1343    let noise = skia_safe::shaders::fractal_noise((0.9, 0.9), 2, seed as f32, None)?;
1344    let (r, g, b) = (0.213, 0.715, 0.072);
1345    #[rustfmt::skip]
1346    let m = [
1347        r,   g,   b,   0.0,       0.0,
1348        r,   g,   b,   0.0,       0.0,
1349        r,   g,   b,   0.0,       0.0,
1350        0.0, 0.0, 0.0, intensity, 0.0,
1351    ];
1352    let cf = skia_safe::color_filters::matrix_row_major(&m, None);
1353    let mono = noise.with_color_filter(cf);
1354    image_filters::shader(mono, None)
1355}
1356
1357fn color_matrix_for(f: &crate::css::style::FilterFn) -> Option<[f32; 20]> {
1358    use crate::css::style::FilterFn;
1359    #[rustfmt::skip]
1360    fn saturation(s: f32) -> [f32; 20] {
1361        let (r, g, b) = (0.213, 0.715, 0.072);
1362        [
1363            r + (1.0 - r) * s, g * (1.0 - s),       b * (1.0 - s),       0.0, 0.0,
1364            r * (1.0 - s),     g + (1.0 - g) * s,   b * (1.0 - s),       0.0, 0.0,
1365            r * (1.0 - s),     g * (1.0 - s),       b + (1.0 - b) * s,   0.0, 0.0,
1366            0.0,               0.0,                 0.0,                 1.0, 0.0,
1367        ]
1368    }
1369    match f {
1370        FilterFn::Brightness { value } => {
1371            let v = value.max(0.0);
1372            #[rustfmt::skip]
1373            let m = [
1374                v, 0.0, 0.0, 0.0, 0.0,
1375                0.0, v, 0.0, 0.0, 0.0,
1376                0.0, 0.0, v, 0.0, 0.0,
1377                0.0, 0.0, 0.0, 1.0, 0.0,
1378            ];
1379            Some(m)
1380        }
1381        FilterFn::Contrast { value } => {
1382            let v = value.max(0.0);
1383            let t = (1.0 - v) / 2.0;
1384            #[rustfmt::skip]
1385            let m = [
1386                v, 0.0, 0.0, 0.0, t,
1387                0.0, v, 0.0, 0.0, t,
1388                0.0, 0.0, v, 0.0, t,
1389                0.0, 0.0, 0.0, 1.0, 0.0,
1390            ];
1391            Some(m)
1392        }
1393        FilterFn::Saturate { value } => Some(saturation(value.max(0.0))),
1394        FilterFn::Grayscale { value } => Some(saturation(1.0 - value.clamp(0.0, 1.0))),
1395        FilterFn::HueRotate { deg } => {
1396            let (sin, cos) = deg.to_radians().sin_cos();
1397            let (r, g, b) = (0.213, 0.715, 0.072);
1398            #[rustfmt::skip]
1399            let m = [
1400                r + cos * (1.0 - r) + sin * (-r),      g + cos * (-g) + sin * (-g),       b + cos * (-b) + sin * (1.0 - b), 0.0, 0.0,
1401                r + cos * (-r) + sin * 0.143,          g + cos * (1.0 - g) + sin * 0.140, b + cos * (-b) + sin * (-0.283),  0.0, 0.0,
1402                r + cos * (-r) + sin * (-(1.0 - r)),   g + cos * (-g) + sin * g,          b + cos * (1.0 - b) + sin * b,    0.0, 0.0,
1403                0.0,                                   0.0,                               0.0,                              1.0, 0.0,
1404            ];
1405            Some(m)
1406        }
1407        FilterFn::Invert { value } => {
1408            let v = value.clamp(0.0, 1.0);
1409            let s = 1.0 - 2.0 * v;
1410            let t = v;
1411            #[rustfmt::skip]
1412            let m = [
1413                s, 0.0, 0.0, 0.0, t,
1414                0.0, s, 0.0, 0.0, t,
1415                0.0, 0.0, s, 0.0, t,
1416                0.0, 0.0, 0.0, 1.0, 0.0,
1417            ];
1418            Some(m)
1419        }
1420        FilterFn::Sepia { value } => {
1421            let v = value.clamp(0.0, 1.0);
1422            let lerp = |a: f32, b: f32| a + (b - a) * v;
1423            #[rustfmt::skip]
1424            let m = [
1425                lerp(1.0, 0.393), lerp(0.0, 0.769), lerp(0.0, 0.189), 0.0, 0.0,
1426                lerp(0.0, 0.349), lerp(1.0, 0.686), lerp(0.0, 0.168), 0.0, 0.0,
1427                lerp(0.0, 0.272), lerp(0.0, 0.534), lerp(1.0, 0.131), 0.0, 0.0,
1428                0.0,              0.0,              0.0,              1.0, 0.0,
1429            ];
1430            Some(m)
1431        }
1432        FilterFn::Opacity { value } => {
1433            let v = value.clamp(0.0, 1.0);
1434            #[rustfmt::skip]
1435            let m = [
1436                1.0, 0.0, 0.0, 0.0, 0.0,
1437                0.0, 1.0, 0.0, 0.0, 0.0,
1438                0.0, 0.0, 1.0, 0.0, 0.0,
1439                0.0, 0.0, 0.0, v, 0.0,
1440            ];
1441            Some(m)
1442        }
1443        FilterFn::Blur { .. }
1444        | FilterFn::DirectionalBlur { .. }
1445        | FilterFn::DropShadow { .. }
1446        | FilterFn::Noise { .. } => None,
1447    }
1448}
1449
1450fn resolve_origin(
1451    origin: Option<&TransformOrigin>,
1452    layout: &BoxLayout,
1453    ctx: &LengthContext,
1454) -> (f32, f32, f32) {
1455    let Some(o) = origin else {
1456        return (
1457            layout.x + layout.width / 2.0,
1458            layout.y + layout.height / 2.0,
1459            0.0,
1460        );
1461    };
1462
1463    let ox = if let Some(lp) = &o.x {
1464        let parsed = match lp {
1465            LengthPercentage::String(s) => {
1466                parse_origin_component(s).unwrap_or(crate::css::units::ParsedLength::Percent(50.0))
1467            }
1468            LengthPercentage::Px(v) => crate::css::units::ParsedLength::Px(*v),
1469        };
1470        let local_ctx = LengthContext {
1471            parent_size: layout.width,
1472            ..*ctx
1473        };
1474        layout.x + parsed.resolve(&local_ctx).unwrap_or(layout.width / 2.0)
1475    } else {
1476        layout.x + layout.width / 2.0
1477    };
1478
1479    let oy = if let Some(lp) = &o.y {
1480        let parsed = match lp {
1481            LengthPercentage::String(s) => {
1482                parse_origin_component(s).unwrap_or(crate::css::units::ParsedLength::Percent(50.0))
1483            }
1484            LengthPercentage::Px(v) => crate::css::units::ParsedLength::Px(*v),
1485        };
1486        let local_ctx = LengthContext {
1487            parent_size: layout.height,
1488            ..*ctx
1489        };
1490        layout.y + parsed.resolve(&local_ctx).unwrap_or(layout.height / 2.0)
1491    } else {
1492        layout.y + layout.height / 2.0
1493    };
1494
1495    let oz = o.z.as_ref().map(|l| l.resolve(ctx)).unwrap_or(0.0);
1496
1497    (ox, oy, oz)
1498}
1499
1500fn has_3d_transform(list: &[TransformFn]) -> bool {
1501    list.iter().any(|t| {
1502        matches!(
1503            t,
1504            TransformFn::RotateX { .. }
1505                | TransformFn::RotateY { .. }
1506                | TransformFn::Rotate3d { .. }
1507                | TransformFn::Scale3d { .. }
1508                | TransformFn::ScaleZ { .. }
1509                | TransformFn::TranslateZ { .. }
1510                | TransformFn::Perspective { .. }
1511                | TransformFn::Matrix3d { .. }
1512        )
1513    })
1514}
1515
1516#[derive(Clone, Copy)]
1517struct TransformAxes {
1518    x: LengthContext,
1519    y: LengthContext,
1520    general: LengthContext,
1521}
1522
1523fn apply_transform(
1524    canvas: &Canvas,
1525    list: &[TransformFn],
1526    perspective_d: Option<f32>,
1527    transform_pivot: (f32, f32),
1528    perspective_pivot: (f32, f32),
1529    axes: &TransformAxes,
1530) {
1531    let pivots_equal = (transform_pivot.0 - perspective_pivot.0).abs() < 0.001
1532        && (transform_pivot.1 - perspective_pivot.1).abs() < 0.001;
1533
1534    if perspective_d.is_none() && !has_3d_transform(list) {
1535        let pivot = transform_pivot;
1536        canvas.translate(Point::new(pivot.0, pivot.1));
1537        for tr in list {
1538            match tr {
1539                TransformFn::Translate { x, y } => {
1540                    canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y)));
1541                }
1542                TransformFn::TranslateX { x } => {
1543                    canvas.translate(Point::new(x.resolve(&axes.x), 0.0));
1544                }
1545                TransformFn::TranslateY { y } => {
1546                    canvas.translate(Point::new(0.0, y.resolve(&axes.y)));
1547                }
1548                TransformFn::Translate3d { x, y, .. } => {
1549                    canvas.translate(Point::new(x.resolve(&axes.x), y.resolve(&axes.y)));
1550                }
1551                TransformFn::Scale { x, y } => {
1552                    canvas.scale((*x, *y));
1553                }
1554                TransformFn::ScaleX { x } => {
1555                    canvas.scale((*x, 1.0));
1556                }
1557                TransformFn::ScaleY { y } => {
1558                    canvas.scale((1.0, *y));
1559                }
1560                TransformFn::Rotate { deg } | TransformFn::RotateZ { deg } => {
1561                    canvas.rotate(*deg, None);
1562                }
1563                TransformFn::Skew { x, y } => {
1564                    canvas.skew((x.to_radians().tan(), y.to_radians().tan()));
1565                }
1566                TransformFn::SkewX { x } => {
1567                    canvas.skew((x.to_radians().tan(), 0.0));
1568                }
1569                TransformFn::SkewY { y } => {
1570                    canvas.skew((0.0, y.to_radians().tan()));
1571                }
1572                TransformFn::Matrix { values: v } => {
1573                    let m = skia_safe::Matrix::new_all(
1574                        v[0], v[2], v[4], v[1], v[3], v[5], 0.0, 0.0, 1.0,
1575                    );
1576                    canvas.concat(&m);
1577                }
1578                _ => {}
1579            }
1580        }
1581        canvas.translate(Point::new(-pivot.0, -pivot.1));
1582    } else if pivots_equal {
1583        let pivot = transform_pivot;
1584        let mut m = M44::new_identity();
1585        m.pre_concat(&M44::translate(pivot.0, pivot.1, 0.0));
1586        if let Some(d) = perspective_d {
1587            m.pre_concat(&css_perspective_m44(d));
1588        }
1589        for tr in list {
1590            m.pre_concat(&transform_to_m44(tr, axes));
1591        }
1592        m.pre_concat(&M44::translate(-pivot.0, -pivot.1, 0.0));
1593        canvas.concat_44(&m);
1594    } else {
1595        let tp = transform_pivot;
1596        let pp = perspective_pivot;
1597        let mut m = M44::new_identity();
1598
1599        m.pre_concat(&M44::translate(pp.0, pp.1, 0.0));
1600        if let Some(d) = perspective_d {
1601            m.pre_concat(&css_perspective_m44(d));
1602        }
1603        m.pre_concat(&M44::translate(-pp.0, -pp.1, 0.0));
1604
1605        m.pre_concat(&M44::translate(tp.0, tp.1, 0.0));
1606        for tr in list {
1607            m.pre_concat(&transform_to_m44(tr, axes));
1608        }
1609        m.pre_concat(&M44::translate(-tp.0, -tp.1, 0.0));
1610
1611        canvas.concat_44(&m);
1612    }
1613}
1614
1615pub fn animated_transform(
1616    css: &CssStyle,
1617    layout: &BoxLayout,
1618    viewport: (f32, f32),
1619) -> (f32, f32, f32, f32, f32) {
1620    let length_ctx = LengthContext {
1621        viewport_width: viewport.0,
1622        viewport_height: viewport.1,
1623        parent_size: layout.width.max(layout.height),
1624        font_size: css.font_size_px_or(16.0),
1625        root_font_size: 16.0,
1626    };
1627    let ctx_x = LengthContext {
1628        parent_size: layout.width,
1629        ..length_ctx
1630    };
1631    let ctx_y = LengthContext {
1632        parent_size: layout.height,
1633        ..length_ctx
1634    };
1635
1636    let mut tx = 0.0f32;
1637    let mut ty = 0.0f32;
1638    let mut scale_x = 1.0f32;
1639    let mut scale_y = 1.0f32;
1640    let mut rotation = 0.0f32;
1641    for t in css.transform.as_deref().unwrap_or(&[]) {
1642        match t {
1643            TransformFn::Translate { x, y } => {
1644                tx += x.resolve(&ctx_x);
1645                ty += y.resolve(&ctx_y);
1646            }
1647            TransformFn::TranslateX { x } => tx += x.resolve(&ctx_x),
1648            TransformFn::TranslateY { y } => ty += y.resolve(&ctx_y),
1649            TransformFn::Translate3d { x, y, .. } => {
1650                tx += x.resolve(&ctx_x);
1651                ty += y.resolve(&ctx_y);
1652            }
1653            TransformFn::Scale { x, y } => {
1654                scale_x *= x;
1655                scale_y *= y;
1656            }
1657            TransformFn::ScaleX { x } => scale_x *= x,
1658            TransformFn::ScaleY { y } => scale_y *= y,
1659            TransformFn::Scale3d { x, y, .. } => {
1660                scale_x *= x;
1661                scale_y *= y;
1662            }
1663            TransformFn::Rotate { deg } | TransformFn::RotateZ { deg } => rotation += deg,
1664            TransformFn::Rotate3d { deg, .. } => rotation += deg,
1665            _ => {}
1666        }
1667    }
1668    let scale = (scale_x + scale_y) / 2.0;
1669    let opacity = css.opacity.unwrap_or(1.0);
1670    (tx, ty, scale, rotation, opacity)
1671}
1672
1673fn css_perspective_m44(d: f32) -> M44 {
1674    M44::row_major(&[
1675        1.0,
1676        0.0,
1677        0.0,
1678        0.0,
1679        0.0,
1680        1.0,
1681        0.0,
1682        0.0,
1683        0.0,
1684        0.0,
1685        1.0,
1686        0.0,
1687        0.0,
1688        0.0,
1689        -1.0 / d,
1690        1.0,
1691    ])
1692}
1693
1694fn transform_to_m44(tr: &TransformFn, axes: &TransformAxes) -> M44 {
1695    match tr {
1696        TransformFn::Translate { x, y } => {
1697            M44::translate(x.resolve(&axes.x), y.resolve(&axes.y), 0.0)
1698        }
1699        TransformFn::TranslateX { x } => M44::translate(x.resolve(&axes.x), 0.0, 0.0),
1700        TransformFn::TranslateY { y } => M44::translate(0.0, y.resolve(&axes.y), 0.0),
1701        TransformFn::TranslateZ { z } => M44::translate(0.0, 0.0, z.resolve(&axes.general)),
1702        TransformFn::Translate3d { x, y, z } => M44::translate(
1703            x.resolve(&axes.x),
1704            y.resolve(&axes.y),
1705            z.resolve(&axes.general),
1706        ),
1707        TransformFn::Scale { x, y } => M44::scale(*x, *y, 1.0),
1708        TransformFn::ScaleX { x } => M44::scale(*x, 1.0, 1.0),
1709        TransformFn::ScaleY { y } => M44::scale(1.0, *y, 1.0),
1710        TransformFn::ScaleZ { z } => M44::scale(1.0, 1.0, *z),
1711        TransformFn::Scale3d { x, y, z } => M44::scale(*x, *y, *z),
1712        TransformFn::Rotate { deg } | TransformFn::RotateZ { deg } => {
1713            M44::rotate(V3::new(0.0, 0.0, 1.0), deg.to_radians())
1714        }
1715        TransformFn::RotateX { deg } => M44::rotate(V3::new(1.0, 0.0, 0.0), deg.to_radians()),
1716        TransformFn::RotateY { deg } => M44::rotate(V3::new(0.0, 1.0, 0.0), deg.to_radians()),
1717        TransformFn::Rotate3d { x, y, z, deg } => {
1718            M44::rotate(V3::new(*x, *y, *z), deg.to_radians())
1719        }
1720        TransformFn::Skew { x, y } => M44::row_major(&[
1721            1.0,
1722            x.to_radians().tan(),
1723            0.0,
1724            0.0,
1725            y.to_radians().tan(),
1726            1.0,
1727            0.0,
1728            0.0,
1729            0.0,
1730            0.0,
1731            1.0,
1732            0.0,
1733            0.0,
1734            0.0,
1735            0.0,
1736            1.0,
1737        ]),
1738        TransformFn::SkewX { x } => M44::row_major(&[
1739            1.0,
1740            x.to_radians().tan(),
1741            0.0,
1742            0.0,
1743            0.0,
1744            1.0,
1745            0.0,
1746            0.0,
1747            0.0,
1748            0.0,
1749            1.0,
1750            0.0,
1751            0.0,
1752            0.0,
1753            0.0,
1754            1.0,
1755        ]),
1756        TransformFn::SkewY { y } => M44::row_major(&[
1757            1.0,
1758            0.0,
1759            0.0,
1760            0.0,
1761            y.to_radians().tan(),
1762            1.0,
1763            0.0,
1764            0.0,
1765            0.0,
1766            0.0,
1767            1.0,
1768            0.0,
1769            0.0,
1770            0.0,
1771            0.0,
1772            1.0,
1773        ]),
1774        TransformFn::Perspective { length } => {
1775            css_perspective_m44(length.resolve(&axes.general).max(1.0))
1776        }
1777        TransformFn::Matrix { values: v } => M44::row_major(&[
1778            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,
1779        ]),
1780        TransformFn::Matrix3d { values: v } => M44::col_major(v),
1781    }
1782}
1783
1784fn paint_background(
1785    canvas: &Canvas,
1786    layout: &BoxLayout,
1787    css: &CssStyle,
1788    bg: &Background,
1789    ctx: &LengthContext,
1790) {
1791    let radius = css
1792        .border_radius
1793        .as_ref()
1794        .map(|r| resolve_border_radius(r, layout, ctx))
1795        .unwrap_or([0.0; 4]);
1796    let rrect = padding_rrect(layout, radius);
1797
1798    match bg {
1799        Background::Color(c) => {
1800            let mut paint = Paint::default();
1801            paint.set_anti_alias(true);
1802            paint.set_color(parse_color(c));
1803            canvas.draw_rrect(rrect, &paint);
1804        }
1805        Background::Single(layer) => paint_bg_layer(canvas, &rrect, layer),
1806        Background::Layers(layers) => {
1807            for layer in layers.iter().rev() {
1808                paint_bg_layer(canvas, &rrect, layer);
1809            }
1810        }
1811    }
1812}
1813
1814fn paint_bg_layer(canvas: &Canvas, rrect: &RRect, layer: &BackgroundLayer) {
1815    let mut paint = Paint::default();
1816    paint.set_anti_alias(true);
1817    match layer {
1818        BackgroundLayer::Color { color } => {
1819            paint.set_color(parse_color(color));
1820            canvas.draw_rrect(rrect, &paint);
1821        }
1822        BackgroundLayer::LinearGradient { angle, stops } => {
1823            let bounds = rrect.bounds();
1824            let (p0, p1) = gradient_endpoints(*bounds, angle.unwrap_or(180.0));
1825            let (colors, positions) = gradient_stops(stops);
1826            let gradient_colors =
1827                GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
1828            let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
1829            if let Some(shader) = gradient::shaders::linear_gradient((p0, p1), &grad, None) {
1830                paint.set_shader(shader);
1831                canvas.draw_rrect(rrect, &paint);
1832            }
1833        }
1834        BackgroundLayer::RadialGradient { stops, .. } => {
1835            let bounds = rrect.bounds();
1836            let center = Point::new(
1837                bounds.left + bounds.width() / 2.0,
1838                bounds.top + bounds.height() / 2.0,
1839            );
1840            let radius = bounds.width().max(bounds.height()) / 2.0;
1841            let (colors, positions) = gradient_stops(stops);
1842            let gradient_colors =
1843                GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
1844            let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
1845            if let Some(shader) = gradient::shaders::radial_gradient((center, radius), &grad, None)
1846            {
1847                paint.set_shader(shader);
1848                canvas.draw_rrect(rrect, &paint);
1849            }
1850        }
1851        BackgroundLayer::ConicGradient { stops, .. } => {
1852            let bounds = rrect.bounds();
1853            let center = Point::new(
1854                bounds.left + bounds.width() / 2.0,
1855                bounds.top + bounds.height() / 2.0,
1856            );
1857            let (colors, positions) = gradient_stops(stops);
1858            let gradient_colors =
1859                GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
1860            let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
1861            if let Some(shader) =
1862                gradient::shaders::sweep_gradient(center, (0.0, 360.0), &grad, None)
1863            {
1864                paint.set_shader(shader);
1865                canvas.draw_rrect(rrect, &paint);
1866            }
1867        }
1868        BackgroundLayer::Image { .. } => {}
1869    }
1870}
1871
1872fn gradient_stops(stops: &[crate::css::style::GradientStop]) -> (Vec<Color4f>, Vec<f32>) {
1873    let mut colors = Vec::with_capacity(stops.len());
1874    let mut positions = Vec::with_capacity(stops.len());
1875    let n = stops.len().max(1);
1876    for (i, s) in stops.iter().enumerate() {
1877        colors.push(Color4f::from(parse_color(&s.color)));
1878        let default_offset = i as f32 / (n.saturating_sub(1).max(1) as f32);
1879        positions.push(s.offset.unwrap_or(default_offset));
1880    }
1881    (colors, positions)
1882}
1883
1884fn gradient_endpoints(bounds: Rect, angle_deg: f32) -> (Point, Point) {
1885    let cx = bounds.left + bounds.width() / 2.0;
1886    let cy = bounds.top + bounds.height() / 2.0;
1887    let rad = angle_deg.to_radians();
1888    let (sin_a, cos_a) = (rad.sin(), -rad.cos());
1889    let len = (bounds.width().abs() * sin_a.abs() + bounds.height().abs() * cos_a.abs()) / 2.0;
1890    let p0 = Point::new(cx - sin_a * len, cy - cos_a * len);
1891    let p1 = Point::new(cx + sin_a * len, cy + cos_a * len);
1892    (p0, p1)
1893}
1894
1895fn paint_border(
1896    canvas: &Canvas,
1897    layout: &BoxLayout,
1898    css: &CssStyle,
1899    border: &BorderEdges,
1900    ctx: &LengthContext,
1901) {
1902    let style = border.style.unwrap_or(BorderStyle::Solid);
1903    if matches!(style, BorderStyle::None) {
1904        return;
1905    }
1906    let color = border
1907        .color
1908        .as_ref()
1909        .map(parse_color)
1910        .unwrap_or(SColor::BLACK);
1911
1912    let widths = layout.border;
1913    let max_w = widths
1914        .top
1915        .max(widths.right)
1916        .max(widths.bottom)
1917        .max(widths.left);
1918    if max_w <= 0.0 {
1919        return;
1920    }
1921
1922    let radius = css
1923        .border_radius
1924        .as_ref()
1925        .map(|r| resolve_border_radius(r, layout, ctx))
1926        .unwrap_or([0.0; 4]);
1927
1928    match style {
1929        BorderStyle::None => {}
1930        BorderStyle::Solid => {
1931            let outer = border_rrect(layout, radius);
1932            let inner = inner_rrect(layout, radius);
1933
1934            let mut paint = Paint::default();
1935            paint.set_anti_alias(true);
1936            paint.set_style(PaintStyle::Fill);
1937            paint.set_color(color);
1938
1939            canvas.draw_drrect(outer, inner, &paint);
1940        }
1941        BorderStyle::Dashed | BorderStyle::Dotted => {
1942            let centerline = border_stroke_rrect(layout, radius, max_w / 2.0);
1943            let mut builder = PathBuilder::new();
1944            builder.add_rrect(centerline, None, None);
1945            let path = builder.detach();
1946
1947            let mut paint = Paint::default();
1948            paint.set_anti_alias(true);
1949            paint.set_style(PaintStyle::Stroke);
1950            paint.set_color(color);
1951            paint.set_stroke_width(max_w);
1952
1953            let intervals = if matches!(style, BorderStyle::Dotted) {
1954                paint.set_stroke_cap(skia_safe::PaintCap::Round);
1955                [0.001, max_w * 2.0]
1956            } else {
1957                [max_w * 3.0, max_w * 3.0]
1958            };
1959            if let Some(dash) = skia_safe::PathEffect::dash(&intervals, 0.0) {
1960                paint.set_path_effect(dash);
1961            }
1962
1963            canvas.draw_path(&path, &paint);
1964        }
1965        BorderStyle::Double => {
1966            let stroke_w = max_w / 3.0;
1967            if stroke_w <= 0.0 {
1968                return;
1969            }
1970
1971            let mut paint = Paint::default();
1972            paint.set_anti_alias(true);
1973            paint.set_style(PaintStyle::Stroke);
1974            paint.set_color(color);
1975            paint.set_stroke_width(stroke_w);
1976
1977            for inset in [stroke_w / 2.0, max_w - stroke_w / 2.0] {
1978                let centerline = border_stroke_rrect(layout, radius, inset);
1979                let mut builder = PathBuilder::new();
1980                builder.add_rrect(centerline, None, None);
1981                canvas.draw_path(&builder.detach(), &paint);
1982            }
1983        }
1984    }
1985}
1986
1987fn border_stroke_rrect(layout: &BoxLayout, radius: [f32; 4], inset: f32) -> RRect {
1988    let rect = Rect::from_xywh(
1989        layout.x + inset,
1990        layout.y + inset,
1991        (layout.width - inset * 2.0).max(0.0),
1992        (layout.height - inset * 2.0).max(0.0),
1993    );
1994    let r = [
1995        (radius[0] - inset).max(0.0),
1996        (radius[1] - inset).max(0.0),
1997        (radius[2] - inset).max(0.0),
1998        (radius[3] - inset).max(0.0),
1999    ];
2000    rrect_from_corners(rect, r)
2001}
2002
2003fn paint_gradient_border(
2004    canvas: &Canvas,
2005    layout: &BoxLayout,
2006    css: &CssStyle,
2007    gb: &crate::schema::GradientBorder,
2008    ctx: &LengthContext,
2009) {
2010    if gb.colors.len() < 2 || gb.width <= 0.0 {
2011        return;
2012    }
2013    let width = gb.width.min(layout.width / 2.0).min(layout.height / 2.0);
2014
2015    let radius = css
2016        .border_radius
2017        .as_ref()
2018        .map(|r| resolve_border_radius(r, layout, ctx))
2019        .unwrap_or([0.0; 4]);
2020
2021    let outer = border_rrect(layout, radius);
2022    let inner_rect = Rect::from_xywh(
2023        layout.x + width,
2024        layout.y + width,
2025        (layout.width - width * 2.0).max(0.0),
2026        (layout.height - width * 2.0).max(0.0),
2027    );
2028    let inner_radius = [
2029        (radius[0] - width).max(0.0),
2030        (radius[1] - width).max(0.0),
2031        (radius[2] - width).max(0.0),
2032        (radius[3] - width).max(0.0),
2033    ];
2034    let inner = rrect_from_corners(inner_rect, inner_radius);
2035
2036    let colors: Vec<Color4f> = gb
2037        .colors
2038        .iter()
2039        .map(|c| Color4f::from(parse_color_string(c).unwrap_or_else(|| unresolved_color(c))))
2040        .collect();
2041    let n = colors.len();
2042    let positions: Vec<f32> = (0..n)
2043        .map(|i| i as f32 / (n.saturating_sub(1).max(1) as f32))
2044        .collect();
2045
2046    let bounds = outer.bounds();
2047    let (p0, p1) = gradient_endpoints(*bounds, gb.angle);
2048    let gradient_colors =
2049        GradientColors::new(&colors, Some(&positions), skia_safe::TileMode::Clamp, None);
2050    let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
2051    let Some(shader) = gradient::shaders::linear_gradient((p0, p1), &grad, None) else {
2052        return;
2053    };
2054
2055    let mut paint = Paint::default();
2056    paint.set_anti_alias(true);
2057    paint.set_style(PaintStyle::Fill);
2058    paint.set_shader(shader);
2059    canvas.draw_drrect(outer, inner, &paint);
2060}
2061
2062#[derive(Debug, Clone, Copy)]
2063pub struct LightDirection {
2064    pub x: f32,
2065    pub y: f32,
2066    pub intensity: f32,
2067    pub color: SColor,
2068}
2069
2070impl Default for LightDirection {
2071    fn default() -> Self {
2072        LightDirection {
2073            x: -0.35,
2074            y: -0.8,
2075            intensity: 1.0,
2076            color: SColor::from_argb(255, 255, 255, 255),
2077        }
2078    }
2079}
2080
2081impl LightDirection {
2082    fn normalized(&self) -> (f32, f32) {
2083        let length = (self.x * self.x + self.y * self.y).sqrt();
2084        if length <= f32::EPSILON {
2085            return (0.0, -1.0);
2086        }
2087        (self.x / length, self.y / length)
2088    }
2089}
2090
2091struct MaterialRecipe {
2092    highlight_alpha: f32,
2093    highlight_spread: f32,
2094    edge_alpha: f32,
2095    shade_alpha: f32,
2096}
2097
2098fn material_recipe(preset: MaterialPreset) -> MaterialRecipe {
2099    match preset {
2100        MaterialPreset::Glossy => MaterialRecipe {
2101            highlight_alpha: 0.42,
2102            highlight_spread: 0.85,
2103            edge_alpha: 0.55,
2104            shade_alpha: 0.30,
2105        },
2106        MaterialPreset::Metal => MaterialRecipe {
2107            highlight_alpha: 0.26,
2108            highlight_spread: 0.42,
2109            edge_alpha: 0.78,
2110            shade_alpha: 0.38,
2111        },
2112        MaterialPreset::Matte => MaterialRecipe {
2113            highlight_alpha: 0.0,
2114            highlight_spread: 0.0,
2115            edge_alpha: 0.0,
2116            shade_alpha: 0.22,
2117        },
2118        MaterialPreset::Inflated => MaterialRecipe {
2119            highlight_alpha: 0.0,
2120            highlight_spread: 0.0,
2121            edge_alpha: 0.0,
2122            shade_alpha: 0.0,
2123        },
2124    }
2125}
2126
2127fn silhouette_alpha_field(
2128    path: &skia_safe::Path,
2129    layout: &BoxLayout,
2130    bevel: f32,
2131) -> Option<(Vec<f32>, usize, usize)> {
2132    let width = layout.width.ceil() as i32;
2133    let height = layout.height.ceil() as i32;
2134    if width <= 0 || height <= 0 {
2135        return None;
2136    }
2137
2138    let info = skia_safe::ImageInfo::new(
2139        (width, height),
2140        skia_safe::ColorType::RGBA8888,
2141        skia_safe::AlphaType::Premul,
2142        None,
2143    );
2144    let mut surface = skia_safe::surfaces::raster(&info, None, None)?;
2145    let canvas = surface.canvas();
2146    canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
2147    canvas.translate((-layout.x, -layout.y));
2148
2149    let mut paint = Paint::default();
2150    paint.set_anti_alias(true);
2151    paint.set_color(SColor::from_argb(255, 255, 255, 255));
2152    paint.set_image_filter(skia_safe::image_filters::blur(
2153        (bevel.max(1.0) / 2.0, bevel.max(1.0) / 2.0),
2154        skia_safe::TileMode::Decal,
2155        None,
2156        None,
2157    )?);
2158    canvas.draw_path(path, &paint);
2159
2160    let mut buf = vec![0u8; (width * height * 4) as usize];
2161    surface.read_pixels(&info, &mut buf, (width * 4) as usize, (0, 0));
2162
2163    let field = buf
2164        .as_chunks::<4>()
2165        .0
2166        .iter()
2167        .map(|px| px[3] as f32 / 255.0)
2168        .collect();
2169    Some((field, width as usize, height as usize))
2170}
2171
2172const ORDERED_DITHER_4X4: [[f32; 4]; 4] = [
2173    [0.0, 8.0, 2.0, 10.0],
2174    [12.0, 4.0, 14.0, 6.0],
2175    [3.0, 11.0, 1.0, 9.0],
2176    [15.0, 7.0, 13.0, 5.0],
2177];
2178
2179fn quantize_with_ordered_dither(value: f32, x: usize, y: usize) -> u8 {
2180    let clamped = value.clamp(0.0, 255.0);
2181    let base = clamped.floor();
2182    let frac = clamped - base;
2183    let threshold = (ORDERED_DITHER_4X4[y % 4][x % 4] + 0.5) / 16.0;
2184    let rounded = if frac > threshold { base + 1.0 } else { base };
2185    rounded.clamp(0.0, 255.0) as u8
2186}
2187
2188fn paint_inflated_material(
2189    canvas: &Canvas,
2190    layout: &BoxLayout,
2191    silhouette: &skia_safe::Path,
2192    light: &LightDirection,
2193    strength: f32,
2194    bevel: f32,
2195    softness: f32,
2196) {
2197    let Some((field, width, height)) = silhouette_alpha_field(silhouette, layout, bevel) else {
2198        return;
2199    };
2200    let (lx, ly) = light.normalized();
2201    let curvature = 1.0 + softness.clamp(0.0, 1.0) * 3.0;
2202
2203    let reach = ((bevel.max(1.0) * 0.25) as usize).clamp(1, 12);
2204    let relief = 6.0 / reach as f32;
2205
2206    let mut lit = vec![0u8; width * height * 4];
2207    for y in 0..height {
2208        for x in 0..width {
2209            let at = |ix: usize, iy: usize| field[iy * width + ix];
2210            let left = at(x.saturating_sub(reach), y);
2211            let right = at((x + reach).min(width - 1), y);
2212            let up = at(x, y.saturating_sub(reach));
2213            let down = at(x, (y + reach).min(height - 1));
2214
2215            let slope_x = (right - left) * relief;
2216            let slope_y = (down - up) * relief;
2217            let lambert = (-(slope_x * lx + slope_y * ly)).clamp(-1.0, 1.0);
2218            let shaped = lambert.abs().powf(1.0 / curvature) * lambert.signum();
2219            let amount = (shaped * strength).clamp(-1.0, 1.0);
2220
2221            let index = (y * width + x) * 4;
2222            let coverage = field[y * width + x];
2223            let raw_alpha = amount.abs() * coverage * 255.0;
2224            let alpha = quantize_with_ordered_dither(raw_alpha, x, y);
2225            let tone = if amount >= 0.0 {
2226                (light.color.r(), light.color.g(), light.color.b())
2227            } else {
2228                (0, 0, 0)
2229            };
2230            lit[index] = (tone.0 as f32 * alpha as f32 / 255.0) as u8;
2231            lit[index + 1] = (tone.1 as f32 * alpha as f32 / 255.0) as u8;
2232            lit[index + 2] = (tone.2 as f32 * alpha as f32 / 255.0) as u8;
2233            lit[index + 3] = alpha;
2234        }
2235    }
2236
2237    let info = skia_safe::ImageInfo::new(
2238        (width as i32, height as i32),
2239        skia_safe::ColorType::RGBA8888,
2240        skia_safe::AlphaType::Premul,
2241        None,
2242    );
2243    let data = skia_safe::Data::new_copy(&lit);
2244    let Some(image) = skia_safe::images::raster_from_data(&info, data, width * 4) else {
2245        return;
2246    };
2247    canvas.draw_image(&image, (layout.x, layout.y), None);
2248}
2249
2250fn paint_material(
2251    canvas: &Canvas,
2252    layout: &BoxLayout,
2253    css: &CssStyle,
2254    material: &Material,
2255    light: &LightDirection,
2256    ctx: &LengthContext,
2257) {
2258    let strength = (material.intensity() * light.intensity).clamp(0.0, 4.0);
2259    if strength <= 0.0 {
2260        return;
2261    }
2262    let recipe = material_recipe(material.preset());
2263    let (lx, ly) = light.normalized();
2264
2265    let radius = css
2266        .border_radius
2267        .as_ref()
2268        .map(|r| resolve_border_radius(r, layout, ctx))
2269        .unwrap_or([0.0; 4]);
2270    let rrect = padding_rrect(layout, radius);
2271    let rect = *rrect.rect();
2272
2273    let silhouette = css
2274        .clip_path
2275        .as_ref()
2276        .and_then(|c| clip_path_to_skia(c, layout, ctx))
2277        .unwrap_or_else(|| {
2278            let mut builder = PathBuilder::new();
2279            builder.add_rrect(rrect, None, None);
2280            builder.detach()
2281        });
2282
2283    if material.preset() == MaterialPreset::Inflated {
2284        canvas.save();
2285        canvas.clip_path(&silhouette, ClipOp::Intersect, true);
2286        paint_inflated_material(
2287            canvas,
2288            layout,
2289            &silhouette,
2290            light,
2291            strength,
2292            material.bevel(),
2293            material.softness(),
2294        );
2295        canvas.restore();
2296        return;
2297    }
2298
2299    canvas.save();
2300    canvas.clip_path(&silhouette, ClipOp::Intersect, true);
2301
2302    let tint = light.color;
2303    let half_diagonal = (rect.width().powi(2) + rect.height().powi(2)).sqrt() / 2.0;
2304    let centre = (rect.center_x(), rect.center_y());
2305    let lit_point = (
2306        centre.0 + lx * rect.width() * 0.38,
2307        centre.1 + ly * rect.height() * 0.38,
2308    );
2309    let shaded_point = (
2310        centre.0 - lx * rect.width() * 0.45,
2311        centre.1 - ly * rect.height() * 0.45,
2312    );
2313
2314    if recipe.shade_alpha > 0.0 {
2315        let alpha = (recipe.shade_alpha * strength).clamp(0.0, 1.0);
2316        let stops = [
2317            Color4f::new(0.0, 0.0, 0.0, alpha),
2318            Color4f::new(0.0, 0.0, 0.0, 0.0),
2319        ];
2320        let gradient_colors = GradientColors::new(&stops, None, skia_safe::TileMode::Clamp, None);
2321        let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
2322        if let Some(shader) = gradient::shaders::radial_gradient(
2323            (
2324                Point::new(shaded_point.0, shaded_point.1),
2325                half_diagonal * 1.05,
2326            ),
2327            &grad,
2328            None,
2329        ) {
2330            let mut paint = Paint::default();
2331            paint.set_anti_alias(true);
2332            paint.set_shader(shader);
2333            canvas.draw_rect(rect, &paint);
2334        }
2335    }
2336
2337    if recipe.highlight_alpha > 0.0 {
2338        let alpha = (recipe.highlight_alpha * strength).clamp(0.0, 1.0);
2339        let stops = [
2340            Color4f::new(
2341                tint.r() as f32 / 255.0,
2342                tint.g() as f32 / 255.0,
2343                tint.b() as f32 / 255.0,
2344                alpha,
2345            ),
2346            Color4f::new(
2347                tint.r() as f32 / 255.0,
2348                tint.g() as f32 / 255.0,
2349                tint.b() as f32 / 255.0,
2350                0.0,
2351            ),
2352        ];
2353        let gradient_colors = GradientColors::new(&stops, None, skia_safe::TileMode::Clamp, None);
2354        let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
2355        if let Some(shader) = gradient::shaders::radial_gradient(
2356            (
2357                Point::new(lit_point.0, lit_point.1),
2358                (half_diagonal * recipe.highlight_spread).max(1.0),
2359            ),
2360            &grad,
2361            None,
2362        ) {
2363            let mut paint = Paint::default();
2364            paint.set_anti_alias(true);
2365            paint.set_shader(shader);
2366            canvas.draw_rect(rect, &paint);
2367        }
2368    }
2369
2370    if recipe.edge_alpha > 0.0 {
2371        let alpha = (recipe.edge_alpha * strength).clamp(0.0, 1.0);
2372        let width = (rect.width().min(rect.height()) * 0.012).clamp(1.0, 3.0);
2373        let from = (
2374            rect.center_x() + lx * rect.width() / 2.0,
2375            rect.center_y() + ly * rect.height() / 2.0,
2376        );
2377        let to = (
2378            rect.center_x() - lx * rect.width() / 2.0,
2379            rect.center_y() - ly * rect.height() / 2.0,
2380        );
2381        let stops = [
2382            Color4f::new(
2383                tint.r() as f32 / 255.0,
2384                tint.g() as f32 / 255.0,
2385                tint.b() as f32 / 255.0,
2386                alpha,
2387            ),
2388            Color4f::new(
2389                tint.r() as f32 / 255.0,
2390                tint.g() as f32 / 255.0,
2391                tint.b() as f32 / 255.0,
2392                0.0,
2393            ),
2394        ];
2395        let gradient_colors = GradientColors::new(&stops, None, skia_safe::TileMode::Clamp, None);
2396        let grad = Gradient::new(gradient_colors, gradient::Interpolation::default());
2397        if let Some(shader) = gradient::shaders::linear_gradient(
2398            (Point::new(from.0, from.1), Point::new(to.0, to.1)),
2399            &grad,
2400            None,
2401        ) {
2402            let mut paint = Paint::default();
2403            paint.set_anti_alias(true);
2404            paint.set_style(PaintStyle::Stroke);
2405            paint.set_stroke_width(width);
2406            paint.set_shader(shader);
2407            let inset = skia_safe::RRect::new_rect_radii(
2408                rect.with_inset((width / 2.0, width / 2.0)),
2409                &[
2410                    (radius[0], radius[0]).into(),
2411                    (radius[1], radius[1]).into(),
2412                    (radius[2], radius[2]).into(),
2413                    (radius[3], radius[3]).into(),
2414                ],
2415            );
2416            canvas.draw_rrect(inset, &paint);
2417        }
2418    }
2419
2420    canvas.restore();
2421}
2422
2423fn clip_path_to_skia(
2424    clip: &ClipPath,
2425    layout: &BoxLayout,
2426    ctx: &LengthContext,
2427) -> Option<skia_safe::Path> {
2428    let ctx_w = LengthContext {
2429        parent_size: layout.width,
2430        ..*ctx
2431    };
2432    let ctx_h = LengthContext {
2433        parent_size: layout.height,
2434        ..*ctx
2435    };
2436
2437    match clip {
2438        ClipPath::None => None,
2439
2440        ClipPath::Inset {
2441            top,
2442            right,
2443            bottom,
2444            left,
2445            radius,
2446        } => {
2447            let t = top.resolve(&ctx_h);
2448            let r = right.resolve(&ctx_w);
2449            let b = bottom.resolve(&ctx_h);
2450            let l = left.resolve(&ctx_w);
2451            let width = (layout.width - l - r).max(0.0);
2452            let height = (layout.height - t - b).max(0.0);
2453            let rect = Rect::from_xywh(layout.x + l, layout.y + t, width, height);
2454            let corners = radius
2455                .as_ref()
2456                .map(|br| resolve_border_radius(br, layout, ctx))
2457                .unwrap_or([0.0; 4]);
2458            let mut builder = PathBuilder::new();
2459            builder.add_rrect(rrect_from_corners(rect, corners), None, None);
2460            Some(builder.detach())
2461        }
2462
2463        ClipPath::Circle { radius, origin } => {
2464            let (cx, cy, _) = resolve_origin(origin.as_ref(), layout, ctx);
2465            let reference = LengthContext {
2466                parent_size: (layout.width.powi(2) + layout.height.powi(2)).sqrt()
2467                    / std::f32::consts::SQRT_2,
2468                ..*ctx
2469            };
2470            let r = radius.resolve(&reference);
2471            if r <= 0.0 {
2472                return Some(PathBuilder::new().detach());
2473            }
2474            let mut builder = PathBuilder::new();
2475            builder.add_circle((cx, cy), r, None);
2476            Some(builder.detach())
2477        }
2478
2479        ClipPath::Ellipse { rx, ry, origin } => {
2480            let (cx, cy, _) = resolve_origin(origin.as_ref(), layout, ctx);
2481            let a = rx.resolve(&ctx_w);
2482            let b = ry.resolve(&ctx_h);
2483            if a <= 0.0 || b <= 0.0 {
2484                return Some(PathBuilder::new().detach());
2485            }
2486            let mut builder = PathBuilder::new();
2487            builder.add_oval(
2488                Rect::from_xywh(cx - a, cy - b, a * 2.0, b * 2.0),
2489                None,
2490                None,
2491            );
2492            Some(builder.detach())
2493        }
2494
2495        ClipPath::Polygon { points } => {
2496            if points.len() < 3 {
2497                return Some(PathBuilder::new().detach());
2498            }
2499            let mut builder = PathBuilder::new();
2500            for (i, (px, py)) in points.iter().enumerate() {
2501                let x = layout.x + px.resolve(&ctx_w);
2502                let y = layout.y + py.resolve(&ctx_h);
2503                if i == 0 {
2504                    builder.move_to((x, y));
2505                } else {
2506                    builder.line_to((x, y));
2507                }
2508            }
2509            builder.close();
2510            Some(builder.detach())
2511        }
2512
2513        ClipPath::Path { d } => {
2514            let parsed = skia_safe::Path::from_svg(d)?;
2515            Some(parsed.with_offset((layout.x, layout.y)))
2516        }
2517
2518        ClipPath::Blob {
2519            radius,
2520            origin,
2521            lobes,
2522            wobble,
2523            seed,
2524        } => {
2525            let (cx, cy, _) = resolve_origin(origin.as_ref(), layout, ctx);
2526            let reference = LengthContext {
2527                parent_size: (layout.width.powi(2) + layout.height.powi(2)).sqrt()
2528                    / std::f32::consts::SQRT_2,
2529                ..*ctx
2530            };
2531            let r = radius.resolve(&reference);
2532            if r <= 0.0 {
2533                return Some(PathBuilder::new().detach());
2534            }
2535            let local = blob_local_path(*lobes, *wobble, *seed);
2536            Some(scaled_mask_path(&local, r, (cx, cy)))
2537        }
2538
2539        ClipPath::NodePath { id } => {
2540            eprintln!(
2541                "rustmotion: clip-path {{ kind: node-path, id: \"{id}\" }} is not implemented \
2542                 yet — reading another node's geometry needs a resolved-path lookup the paint \
2543                 pass does not have. Nothing is clipped. Use kind: path with the same data, or \
2544                 follow the tracking issue."
2545            );
2546            None
2547        }
2548
2549        ClipPath::Morph {
2550            from,
2551            to,
2552            via,
2553            progress,
2554        } => {
2555            let (leg_from, leg_to, leg_t) = morph_leg(from, to, via, *progress);
2556            morph_clip_path_to_skia(leg_from, leg_to, leg_t, layout, ctx)
2557        }
2558    }
2559}
2560
2561fn clip_path_kind_name(clip: &ClipPath) -> &'static str {
2562    match clip {
2563        ClipPath::None => "none",
2564        ClipPath::Inset { .. } => "inset",
2565        ClipPath::Circle { .. } => "circle",
2566        ClipPath::Ellipse { .. } => "ellipse",
2567        ClipPath::Polygon { .. } => "polygon",
2568        ClipPath::Path { .. } => "path",
2569        ClipPath::Blob { .. } => "blob",
2570        ClipPath::NodePath { .. } => "node-path",
2571        ClipPath::Morph { .. } => "morph",
2572    }
2573}
2574
2575fn lerp_f32(a: f32, b: f32, t: f32) -> f32 {
2576    a + (b - a) * t
2577}
2578
2579fn morph_leg<'a>(
2580    from: &'a ClipPath,
2581    to: &'a ClipPath,
2582    via: &'a [ClipPath],
2583    progress: f32,
2584) -> (&'a ClipPath, &'a ClipPath, f32) {
2585    if via.is_empty() {
2586        return (from, to, progress);
2587    }
2588    let legs = via.len() + 1;
2589    let scaled = (progress.clamp(0.0, 1.0) * legs as f32).min(legs as f32 - f32::EPSILON);
2590    let leg = scaled.floor() as usize;
2591    let local = scaled - leg as f32;
2592    let leg_from = if leg == 0 { from } else { &via[leg - 1] };
2593    let leg_to = if leg == via.len() { to } else { &via[leg] };
2594    (leg_from, leg_to, local)
2595}
2596
2597fn morph_clip_path_to_skia(
2598    from: &ClipPath,
2599    to: &ClipPath,
2600    progress: f32,
2601    layout: &BoxLayout,
2602    ctx: &LengthContext,
2603) -> Option<skia_safe::Path> {
2604    let ctx_w = LengthContext {
2605        parent_size: layout.width,
2606        ..*ctx
2607    };
2608    let ctx_h = LengthContext {
2609        parent_size: layout.height,
2610        ..*ctx
2611    };
2612    let t = progress;
2613
2614    match (from, to) {
2615        (
2616            ClipPath::Inset {
2617                top: t0,
2618                right: r0,
2619                bottom: b0,
2620                left: l0,
2621                radius: rad0,
2622            },
2623            ClipPath::Inset {
2624                top: t1,
2625                right: r1,
2626                bottom: b1,
2627                left: l1,
2628                radius: rad1,
2629            },
2630        ) => {
2631            let top = lerp_f32(t0.resolve(&ctx_h), t1.resolve(&ctx_h), t);
2632            let right = lerp_f32(r0.resolve(&ctx_w), r1.resolve(&ctx_w), t);
2633            let bottom = lerp_f32(b0.resolve(&ctx_h), b1.resolve(&ctx_h), t);
2634            let left = lerp_f32(l0.resolve(&ctx_w), l1.resolve(&ctx_w), t);
2635            let width = (layout.width - left - right).max(0.0);
2636            let height = (layout.height - top - bottom).max(0.0);
2637            let rect = Rect::from_xywh(layout.x + left, layout.y + top, width, height);
2638            let corners0 = rad0
2639                .as_ref()
2640                .map(|r| resolve_border_radius(r, layout, ctx))
2641                .unwrap_or([0.0; 4]);
2642            let corners1 = rad1
2643                .as_ref()
2644                .map(|r| resolve_border_radius(r, layout, ctx))
2645                .unwrap_or([0.0; 4]);
2646            let corners = [
2647                lerp_f32(corners0[0], corners1[0], t),
2648                lerp_f32(corners0[1], corners1[1], t),
2649                lerp_f32(corners0[2], corners1[2], t),
2650                lerp_f32(corners0[3], corners1[3], t),
2651            ];
2652            let mut builder = PathBuilder::new();
2653            builder.add_rrect(rrect_from_corners(rect, corners), None, None);
2654            Some(builder.detach())
2655        }
2656
2657        (
2658            ClipPath::Circle {
2659                radius: r0,
2660                origin: o0,
2661            },
2662            ClipPath::Circle {
2663                radius: r1,
2664                origin: o1,
2665            },
2666        ) => {
2667            let (cx0, cy0, _) = resolve_origin(o0.as_ref(), layout, ctx);
2668            let (cx1, cy1, _) = resolve_origin(o1.as_ref(), layout, ctx);
2669            let reference = LengthContext {
2670                parent_size: (layout.width.powi(2) + layout.height.powi(2)).sqrt()
2671                    / std::f32::consts::SQRT_2,
2672                ..*ctx
2673            };
2674            let radius = lerp_f32(r0.resolve(&reference), r1.resolve(&reference), t);
2675            let cx = lerp_f32(cx0, cx1, t);
2676            let cy = lerp_f32(cy0, cy1, t);
2677            if radius <= 0.0 {
2678                return Some(PathBuilder::new().detach());
2679            }
2680            let mut builder = PathBuilder::new();
2681            builder.add_circle((cx, cy), radius, None);
2682            Some(builder.detach())
2683        }
2684
2685        (
2686            ClipPath::Ellipse {
2687                rx: rx0,
2688                ry: ry0,
2689                origin: o0,
2690            },
2691            ClipPath::Ellipse {
2692                rx: rx1,
2693                ry: ry1,
2694                origin: o1,
2695            },
2696        ) => {
2697            let (cx0, cy0, _) = resolve_origin(o0.as_ref(), layout, ctx);
2698            let (cx1, cy1, _) = resolve_origin(o1.as_ref(), layout, ctx);
2699            let a = lerp_f32(rx0.resolve(&ctx_w), rx1.resolve(&ctx_w), t);
2700            let b = lerp_f32(ry0.resolve(&ctx_h), ry1.resolve(&ctx_h), t);
2701            let cx = lerp_f32(cx0, cx1, t);
2702            let cy = lerp_f32(cy0, cy1, t);
2703            if a <= 0.0 || b <= 0.0 {
2704                return Some(PathBuilder::new().detach());
2705            }
2706            let mut builder = PathBuilder::new();
2707            builder.add_oval(
2708                Rect::from_xywh(cx - a, cy - b, a * 2.0, b * 2.0),
2709                None,
2710                None,
2711            );
2712            Some(builder.detach())
2713        }
2714
2715        (ClipPath::Polygon { points: p0 }, ClipPath::Polygon { points: p1 }) => {
2716            if p0.len() != p1.len() {
2717                eprintln!(
2718                    "rustmotion: clip-path morph between two polygons with different vertex \
2719                     counts ({} vs {}) has no point-by-point correspondence to interpolate \
2720                     along. Nothing is clipped this frame — give both keyframes the same \
2721                     number of points.",
2722                    p0.len(),
2723                    p1.len()
2724                );
2725                return None;
2726            }
2727            if p0.len() < 3 {
2728                return Some(PathBuilder::new().detach());
2729            }
2730            let mut builder = PathBuilder::new();
2731            for (i, ((x0, y0), (x1, y1))) in p0.iter().zip(p1.iter()).enumerate() {
2732                let x = layout.x + lerp_f32(x0.resolve(&ctx_w), x1.resolve(&ctx_w), t);
2733                let y = layout.y + lerp_f32(y0.resolve(&ctx_h), y1.resolve(&ctx_h), t);
2734                if i == 0 {
2735                    builder.move_to((x, y));
2736                } else {
2737                    builder.line_to((x, y));
2738                }
2739            }
2740            builder.close();
2741            Some(builder.detach())
2742        }
2743
2744        _ => {
2745            eprintln!(
2746                "rustmotion: clip-path morph needs both keyframes to be the same kind \
2747                 (inset/circle/ellipse/polygon, each with itself) — got \"{}\" and \"{}\". \
2748                 Nothing is clipped this frame.",
2749                clip_path_kind_name(from),
2750                clip_path_kind_name(to)
2751            );
2752            None
2753        }
2754    }
2755}
2756
2757fn border_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect {
2758    let rect = Rect::from_xywh(layout.x, layout.y, layout.width, layout.height);
2759    rrect_from_corners(rect, radius)
2760}
2761
2762fn padding_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect {
2763    let (x, y, w, h) = layout.padding_box();
2764    let rect = Rect::from_xywh(x, y, w, h);
2765    let r = [
2766        (radius[0] - layout.border.left.max(layout.border.top)).max(0.0),
2767        (radius[1] - layout.border.right.max(layout.border.top)).max(0.0),
2768        (radius[2] - layout.border.right.max(layout.border.bottom)).max(0.0),
2769        (radius[3] - layout.border.left.max(layout.border.bottom)).max(0.0),
2770    ];
2771    rrect_from_corners(rect, r)
2772}
2773
2774fn inner_rrect(layout: &BoxLayout, radius: [f32; 4]) -> RRect {
2775    padding_rrect(layout, radius)
2776}
2777
2778fn rrect_from_corners(rect: Rect, radius: [f32; 4]) -> RRect {
2779    let radii = [
2780        Point::new(radius[0], radius[0]),
2781        Point::new(radius[1], radius[1]),
2782        Point::new(radius[2], radius[2]),
2783        Point::new(radius[3], radius[3]),
2784    ];
2785    RRect::new_rect_radii(rect, &radii)
2786}
2787
2788fn resolve_border_radius(r: &BorderRadius, layout: &BoxLayout, ctx: &LengthContext) -> [f32; 4] {
2789    let mut local_ctx = *ctx;
2790    local_ctx.parent_size = layout.width.min(layout.height);
2791    match r {
2792        BorderRadius::Uniform(v) => {
2793            let p = v.resolve(&local_ctx);
2794            [p, p, p, p]
2795        }
2796        BorderRadius::Corners {
2797            top_left,
2798            top_right,
2799            bottom_right,
2800            bottom_left,
2801        } => [
2802            top_left.resolve(&local_ctx),
2803            top_right.resolve(&local_ctx),
2804            bottom_right.resolve(&local_ctx),
2805            bottom_left.resolve(&local_ctx),
2806        ],
2807    }
2808}
2809
2810fn paint_box_shadow(
2811    canvas: &Canvas,
2812    layout: &BoxLayout,
2813    css: &CssStyle,
2814    shadow: &BoxShadow,
2815    ctx: &LengthContext,
2816    inset: bool,
2817) {
2818    let dx = shadow.offset_x.resolve(ctx);
2819    let dy = shadow.offset_y.resolve(ctx);
2820    let blur = shadow.blur.as_ref().map(|b| b.resolve(ctx)).unwrap_or(0.0);
2821    let spread = shadow
2822        .spread
2823        .as_ref()
2824        .map(|b| b.resolve(ctx))
2825        .unwrap_or(0.0);
2826    let color = shadow
2827        .color
2828        .as_ref()
2829        .map(parse_color)
2830        .unwrap_or(SColor::BLACK);
2831
2832    let radius = css
2833        .border_radius
2834        .as_ref()
2835        .map(|r| resolve_border_radius(r, layout, ctx))
2836        .unwrap_or([0.0; 4]);
2837
2838    let mut paint = Paint::default();
2839    paint.set_anti_alias(true);
2840    paint.set_color(color);
2841    if blur > 0.0 {
2842        if let Some(filter) =
2843            skia_safe::MaskFilter::blur(skia_safe::BlurStyle::Normal, blur / 2.0, None)
2844        {
2845            paint.set_mask_filter(filter);
2846        }
2847    }
2848
2849    if !inset {
2850        let rect = Rect::from_xywh(
2851            layout.x + dx - spread,
2852            layout.y + dy - spread,
2853            layout.width + spread * 2.0,
2854            layout.height + spread * 2.0,
2855        );
2856        let rrect = rrect_from_corners(rect, radius);
2857        let own_rrect = rrect_from_corners(
2858            Rect::from_xywh(layout.x, layout.y, layout.width, layout.height),
2859            radius,
2860        );
2861        canvas.save();
2862        canvas.clip_rrect(own_rrect, ClipOp::Difference, true);
2863        canvas.draw_rrect(rrect, &paint);
2864        canvas.restore();
2865    } else {
2866        let (px, py, pw, ph) = layout.padding_box();
2867        let outer = rrect_from_corners(Rect::from_xywh(px, py, pw, ph), radius);
2868        canvas.save();
2869        canvas.clip_rrect(outer, ClipOp::Intersect, true);
2870        let inner_rect = Rect::from_xywh(
2871            px + dx + spread,
2872            py + dy + spread,
2873            (pw - spread * 2.0).max(0.0),
2874            (ph - spread * 2.0).max(0.0),
2875        );
2876        let inner = rrect_from_corners(inner_rect, radius);
2877        let mut clear = Paint::default();
2878        clear.set_color(color);
2879        clear.set_anti_alias(true);
2880        if blur > 0.0 {
2881            if let Some(filter) =
2882                skia_safe::MaskFilter::blur(skia_safe::BlurStyle::Normal, blur / 2.0, None)
2883            {
2884                clear.set_mask_filter(filter);
2885            }
2886        }
2887        let mut path = PathBuilder::new();
2888        path.add_rrect(outer, None, None);
2889        path.add_rrect(inner, None, None);
2890        path.set_fill_type(skia_safe::PathFillType::EvenOdd);
2891        canvas.draw_path(&path.detach(), &clear);
2892        canvas.restore();
2893    }
2894}
2895
2896pub fn parse_color(c: &Color) -> SColor {
2897    match c {
2898        Color::Rgba { r, g, b, a } => {
2899            let alpha = (a.clamp(0.0, 1.0) * 255.0) as u8;
2900            SColor::from_argb(alpha, *r, *g, *b)
2901        }
2902        Color::String(s) => parse_color_string(s).unwrap_or_else(|| unresolved_color(s)),
2903    }
2904}
2905
2906fn parse_color_string(s: &str) -> Option<SColor> {
2907    crate::engine::renderer::parse_css_color(s).map(|(r, g, b, a)| SColor::from_argb(a, r, g, b))
2908}
2909
2910fn unresolved_color(original: &str) -> SColor {
2911    eprintln!(
2912        "Warning: unrecognized color '{original}' — rendering as opaque magenta instead of \
2913         silently falling back to black"
2914    );
2915    let (r, g, b, a) = crate::engine::renderer::UNRESOLVED_COLOR;
2916    SColor::from_argb(a, r, g, b)
2917}
2918
2919#[allow(dead_code)]
2920fn _unused_marker(_e: &Edges, _l: &LengthPercentage, _p: &ParsedLength, _f: Color4f) {}
2921
2922#[cfg(test)]
2923mod hit_tests {
2924    use super::*;
2925    use std::sync::Arc;
2926
2927    use crate::css::style::{CssStyle, Display, FlexDirection, Position, Size as CSize};
2928    use crate::css::taffy_bridge::ConversionContext;
2929    use crate::css::units::LengthPercentage as CLP;
2930    use crate::engine::box_tree::{BoxKind, BoxNode};
2931    use crate::engine::layout_pass::run_layout;
2932
2933    fn test_frame(w: u32, h: u32) -> PaintFrame {
2934        PaintFrame {
2935            light: Default::default(),
2936            time: 0.0,
2937            scenario_time: 0.0,
2938            frame_index: 0,
2939            fps: 30,
2940            video_width: w,
2941            video_height: h,
2942            scene_duration: 1.0,
2943            camera: None,
2944        }
2945    }
2946
2947    #[test]
2948    fn hitmap_reports_component_rect_for_untransformed_node() {
2949        let leaf = BoxNode {
2950            id: 0,
2951            kind: BoxKind::Component(Arc::new(1u32)),
2952            css: CssStyle {
2953                position: Some(Position::Absolute),
2954                left: Some(CLP::Px(40.0)),
2955                top: Some(CLP::Px(30.0)),
2956                width: Some(CSize::Length(CLP::Px(100.0))),
2957                height: Some(CSize::Length(CLP::Px(80.0))),
2958                ..Default::default()
2959            },
2960            children: vec![],
2961            intrinsic: None,
2962            source_path: None,
2963            window: None,
2964        };
2965        let mut root = BoxNode {
2966            id: 0,
2967            kind: BoxKind::Container,
2968            css: CssStyle {
2969                display: Some(Display::Flex),
2970                flex_direction: Some(FlexDirection::Column),
2971                width: Some(CSize::Length(CLP::Px(400.0))),
2972                height: Some(CSize::Length(CLP::Px(400.0))),
2973                ..Default::default()
2974            },
2975            children: vec![leaf],
2976            intrinsic: None,
2977            source_path: None,
2978            window: None,
2979        };
2980        root.assign_ids(0);
2981
2982        let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
2983        let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
2984        let hits = paint_tree_with_hits(
2985            surface.canvas(),
2986            &root,
2987            &layout,
2988            &test_frame(400, 400),
2989            &NoopDispatcher,
2990        );
2991
2992        assert_eq!(hits.len(), 1, "expected exactly one component hit");
2993        let h = &hits[0];
2994        assert_eq!(h.node_id, root.children[0].id);
2995        assert!((h.rect.x - 40.0).abs() < 0.5, "x = {}", h.rect.x);
2996        assert!((h.rect.y - 30.0).abs() < 0.5, "y = {}", h.rect.y);
2997        assert!((h.rect.w - 100.0).abs() < 0.5, "w = {}", h.rect.w);
2998        assert!((h.rect.h - 80.0).abs() < 0.5, "h = {}", h.rect.h);
2999    }
3000
3001    #[test]
3002    fn backdrop_filter_blurs_content_behind() {
3003        use crate::css::style::{Background, Color as CssColor, FilterFn};
3004        use crate::css::units::Length;
3005
3006        let black_top = BoxNode {
3007            id: 0,
3008            kind: BoxKind::Container,
3009            css: CssStyle {
3010                position: Some(Position::Absolute),
3011                left: Some(CLP::Px(0.0)),
3012                top: Some(CLP::Px(0.0)),
3013                width: Some(CSize::Length(CLP::Px(200.0))),
3014                height: Some(CSize::Length(CLP::Px(100.0))),
3015                background: Some(Background::Color(CssColor::String("#000000".into()))),
3016                ..Default::default()
3017            },
3018            children: vec![],
3019            intrinsic: None,
3020            source_path: None,
3021            window: None,
3022        };
3023        let panel = BoxNode {
3024            id: 0,
3025            kind: BoxKind::Container,
3026            css: CssStyle {
3027                position: Some(Position::Absolute),
3028                left: Some(CLP::Px(50.0)),
3029                top: Some(CLP::Px(50.0)),
3030                width: Some(CSize::Length(CLP::Px(100.0))),
3031                height: Some(CSize::Length(CLP::Px(100.0))),
3032                backdrop_filter: Some(vec![FilterFn::Blur {
3033                    radius: Some(Length::Px(10.0)),
3034                    radius_x: None,
3035                    radius_y: None,
3036                }]),
3037                ..Default::default()
3038            },
3039            children: vec![],
3040            intrinsic: None,
3041            source_path: None,
3042            window: None,
3043        };
3044        let mut root = BoxNode {
3045            id: 0,
3046            kind: BoxKind::Container,
3047            css: CssStyle {
3048                display: Some(Display::Flex),
3049                width: Some(CSize::Length(CLP::Px(200.0))),
3050                height: Some(CSize::Length(CLP::Px(200.0))),
3051                background: Some(Background::Color(CssColor::String("#ffffff".into()))),
3052                ..Default::default()
3053            },
3054            children: vec![black_top, panel],
3055            intrinsic: None,
3056            source_path: None,
3057            window: None,
3058        };
3059        root.assign_ids(0);
3060
3061        let layout = run_layout(&root, (200.0, 200.0), &ConversionContext::default());
3062        let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).unwrap();
3063        paint_tree(
3064            surface.canvas(),
3065            &root,
3066            &layout,
3067            &test_frame(200, 200),
3068            &NoopDispatcher,
3069        );
3070
3071        let info = skia_safe::ImageInfo::new(
3072            (200, 200),
3073            skia_safe::ColorType::RGBA8888,
3074            skia_safe::AlphaType::Unpremul,
3075            None,
3076        );
3077        let mut buf = vec![0u8; 200 * 200 * 4];
3078        assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0)));
3079        let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32;
3080
3081        assert!(red(10, 97) < 10, "outside/above must stay black");
3082        assert!(red(10, 103) > 245, "outside/below must stay white");
3083        let above = red(100, 97);
3084        let below = red(100, 103);
3085        assert!(
3086            above > 30,
3087            "backdrop not blurred above boundary (r={above})"
3088        );
3089        assert!(
3090            below < 225,
3091            "backdrop not blurred below boundary (r={below})"
3092        );
3093    }
3094
3095    #[test]
3096    fn backdrop_filter_survives_sibling_opacity_below_one() {
3097        use crate::css::style::{Background, Color as CssColor, FilterFn};
3098        use crate::css::units::Length;
3099
3100        let black_top = BoxNode {
3101            id: 0,
3102            kind: BoxKind::Container,
3103            css: CssStyle {
3104                position: Some(Position::Absolute),
3105                left: Some(CLP::Px(0.0)),
3106                top: Some(CLP::Px(0.0)),
3107                width: Some(CSize::Length(CLP::Px(200.0))),
3108                height: Some(CSize::Length(CLP::Px(100.0))),
3109                background: Some(Background::Color(CssColor::String("#000000".into()))),
3110                ..Default::default()
3111            },
3112            children: vec![],
3113            intrinsic: None,
3114            source_path: None,
3115            window: None,
3116        };
3117        let panel = BoxNode {
3118            id: 0,
3119            kind: BoxKind::Container,
3120            css: CssStyle {
3121                position: Some(Position::Absolute),
3122                left: Some(CLP::Px(50.0)),
3123                top: Some(CLP::Px(50.0)),
3124                width: Some(CSize::Length(CLP::Px(100.0))),
3125                height: Some(CSize::Length(CLP::Px(100.0))),
3126                backdrop_filter: Some(vec![FilterFn::Blur {
3127                    radius: Some(Length::Px(10.0)),
3128                    radius_x: None,
3129                    radius_y: None,
3130                }]),
3131                opacity: Some(0.99),
3132                ..Default::default()
3133            },
3134            children: vec![],
3135            intrinsic: None,
3136            source_path: None,
3137            window: None,
3138        };
3139        let mut root = BoxNode {
3140            id: 0,
3141            kind: BoxKind::Container,
3142            css: CssStyle {
3143                display: Some(Display::Flex),
3144                width: Some(CSize::Length(CLP::Px(200.0))),
3145                height: Some(CSize::Length(CLP::Px(200.0))),
3146                background: Some(Background::Color(CssColor::String("#ffffff".into()))),
3147                ..Default::default()
3148            },
3149            children: vec![black_top, panel],
3150            intrinsic: None,
3151            source_path: None,
3152            window: None,
3153        };
3154        root.assign_ids(0);
3155
3156        let layout = run_layout(&root, (200.0, 200.0), &ConversionContext::default());
3157        let mut surface = skia_safe::surfaces::raster_n32_premul((200, 200)).unwrap();
3158        paint_tree(
3159            surface.canvas(),
3160            &root,
3161            &layout,
3162            &test_frame(200, 200),
3163            &NoopDispatcher,
3164        );
3165
3166        let info = skia_safe::ImageInfo::new(
3167            (200, 200),
3168            skia_safe::ColorType::RGBA8888,
3169            skia_safe::AlphaType::Unpremul,
3170            None,
3171        );
3172        let mut buf = vec![0u8; 200 * 200 * 4];
3173        assert!(surface.read_pixels(&info, &mut buf, 200 * 4, (0, 0)));
3174        let red = |x: usize, y: usize| buf[(y * 200 + x) * 4] as i32;
3175
3176        assert!(red(10, 97) < 10, "outside/above must stay black");
3177        assert!(red(10, 103) > 245, "outside/below must stay white");
3178        let above = red(100, 97);
3179        let below = red(100, 103);
3180        assert!(
3181            above > 30,
3182            "backdrop not blurred above boundary with opacity:0.99 (r={above})"
3183        );
3184        assert!(
3185            below < 225,
3186            "backdrop not blurred below boundary with opacity:0.99 (r={below})"
3187        );
3188    }
3189
3190    #[test]
3191    fn hitmap_reflects_node_transform() {
3192        use crate::css::style::TransformFn;
3193
3194        let leaf = BoxNode {
3195            id: 0,
3196            kind: BoxKind::Component(Arc::new(1u32)),
3197            css: CssStyle {
3198                position: Some(Position::Absolute),
3199                left: Some(CLP::Px(40.0)),
3200                top: Some(CLP::Px(30.0)),
3201                width: Some(CSize::Length(CLP::Px(100.0))),
3202                height: Some(CSize::Length(CLP::Px(80.0))),
3203                transform: Some(vec![TransformFn::Scale { x: 2.0, y: 2.0 }]),
3204                ..Default::default()
3205            },
3206            children: vec![],
3207            intrinsic: None,
3208            source_path: None,
3209            window: None,
3210        };
3211        let mut root = BoxNode {
3212            id: 0,
3213            kind: BoxKind::Container,
3214            css: CssStyle {
3215                display: Some(Display::Flex),
3216                flex_direction: Some(FlexDirection::Column),
3217                width: Some(CSize::Length(CLP::Px(400.0))),
3218                height: Some(CSize::Length(CLP::Px(400.0))),
3219                ..Default::default()
3220            },
3221            children: vec![leaf],
3222            intrinsic: None,
3223            source_path: None,
3224            window: None,
3225        };
3226        root.assign_ids(0);
3227
3228        let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
3229        let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
3230        let hits = paint_tree_with_hits(
3231            surface.canvas(),
3232            &root,
3233            &layout,
3234            &test_frame(400, 400),
3235            &NoopDispatcher,
3236        );
3237
3238        assert_eq!(hits.len(), 1);
3239        let h = &hits[0];
3240        assert!((h.rect.w - 200.0).abs() < 1.0, "w = {}", h.rect.w);
3241        assert!((h.rect.h - 160.0).abs() < 1.0, "h = {}", h.rect.h);
3242    }
3243}
3244
3245#[cfg(test)]
3246mod transform_origin_tests {
3247
3248    use super::*;
3249
3250    use crate::css::style::{
3251        Background, Color as CssColor, CssStyle, Display, FlexDirection, Position, Size as CSize,
3252        TransformFn, TransformOrigin,
3253    };
3254    use crate::css::taffy_bridge::ConversionContext;
3255    use crate::css::units::LengthPercentage as CLP;
3256    use crate::engine::box_tree::{BoxKind, BoxNode};
3257    use crate::engine::layout_pass::run_layout;
3258
3259    fn test_frame(w: u32, h: u32) -> PaintFrame {
3260        PaintFrame {
3261            light: Default::default(),
3262            time: 0.0,
3263            scenario_time: 0.0,
3264            frame_index: 0,
3265            fps: 30,
3266            video_width: w,
3267            video_height: h,
3268            scene_duration: 1.0,
3269            camera: None,
3270        }
3271    }
3272
3273    fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec<u8> {
3274        root.assign_ids(0);
3275        let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
3276        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
3277        paint_tree(
3278            surface.canvas(),
3279            root,
3280            &layout,
3281            &test_frame(w, h),
3282            &NoopDispatcher,
3283        );
3284        let info = skia_safe::ImageInfo::new(
3285            (w as i32, h as i32),
3286            skia_safe::ColorType::RGBA8888,
3287            skia_safe::AlphaType::Unpremul,
3288            None,
3289        );
3290        let mut buf = vec![0u8; (w * h * 4) as usize];
3291        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
3292        buf
3293    }
3294
3295    fn r(buf: &[u8], w: u32, x: u32, y: u32) -> u8 {
3296        buf[((y * w + x) * 4) as usize]
3297    }
3298
3299    fn count_red(buf: &[u8]) -> usize {
3300        buf.chunks(4)
3301            .filter(|px| px[0] > 200 && px[1] < 50 && px[2] < 50)
3302            .count()
3303    }
3304
3305    fn red_centroid_x(buf: &[u8], w: u32, h: u32) -> f32 {
3306        let mut sum_x = 0.0f64;
3307        let mut count = 0.0f64;
3308        for y in 0..h {
3309            for x in 0..w {
3310                if r(buf, w, x, y) > 200
3311                    && buf[((y * w + x) * 4 + 1) as usize] < 50
3312                    && buf[((y * w + x) * 4 + 2) as usize] < 50
3313                {
3314                    sum_x += x as f64;
3315                    count += 1.0;
3316                }
3317            }
3318        }
3319        if count == 0.0 {
3320            0.0
3321        } else {
3322            (sum_x / count) as f32
3323        }
3324    }
3325
3326    fn red_centroid_y(buf: &[u8], w: u32, h: u32) -> f32 {
3327        let mut sum_y = 0.0f64;
3328        let mut count = 0.0f64;
3329        for y in 0..h {
3330            for x in 0..w {
3331                if r(buf, w, x, y) > 200
3332                    && buf[((y * w + x) * 4 + 1) as usize] < 50
3333                    && buf[((y * w + x) * 4 + 2) as usize] < 50
3334                {
3335                    sum_y += y as f64;
3336                    count += 1.0;
3337                }
3338            }
3339        }
3340        if count == 0.0 {
3341            0.0
3342        } else {
3343            (sum_y / count) as f32
3344        }
3345    }
3346
3347    fn red_box(position: Position, x: f32, y: f32, w: f32, h: f32) -> BoxNode {
3348        BoxNode {
3349            id: 0,
3350            kind: BoxKind::Container,
3351            css: CssStyle {
3352                position: Some(position),
3353                left: Some(CLP::Px(x)),
3354                top: Some(CLP::Px(y)),
3355                width: Some(CSize::Length(CLP::Px(w))),
3356                height: Some(CSize::Length(CLP::Px(h))),
3357                background: Some(Background::Color(CssColor::String("#ff0000".into()))),
3358                ..Default::default()
3359            },
3360            children: vec![],
3361            intrinsic: None,
3362            source_path: None,
3363            window: None,
3364        }
3365    }
3366
3367    fn root_node(w: f32, h: f32, children: Vec<BoxNode>) -> BoxNode {
3368        BoxNode {
3369            id: 0,
3370            kind: BoxKind::Container,
3371            css: CssStyle {
3372                display: Some(Display::Flex),
3373                flex_direction: Some(FlexDirection::Column),
3374                width: Some(CSize::Length(CLP::Px(w))),
3375                height: Some(CSize::Length(CLP::Px(h))),
3376                ..Default::default()
3377            },
3378            children,
3379            intrinsic: None,
3380            source_path: None,
3381            window: None,
3382        }
3383    }
3384
3385    #[test]
3386    fn no_transform_origin_unchanged_non_regression() {
3387        let mut root = root_node(
3388            300.0,
3389            300.0,
3390            vec![{
3391                let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0);
3392                n.css.transform = Some(vec![TransformFn::Scale { x: 1.0, y: 1.0 }]);
3393                n
3394            }],
3395        );
3396        let buf = render_pixels(&mut root, 300, 300);
3397
3398        let cx = red_centroid_x(&buf, 300, 300);
3399        let cy = red_centroid_y(&buf, 300, 300);
3400        assert!((cx - 99.5).abs() < 2.0, "cx={cx}");
3401        assert!((cy - 99.5).abs() < 2.0, "cy={cy}");
3402    }
3403
3404    #[test]
3405    fn transform_origin_50pct_is_center_identity() {
3406        let make_root = |with_origin: bool| -> Vec<u8> {
3407            let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0);
3408            n.css.transform = Some(vec![TransformFn::Rotate { deg: 45.0 }]);
3409            if with_origin {
3410                n.css.transform_origin = Some(TransformOrigin {
3411                    x: Some(CLP::String("50%".into())),
3412                    y: Some(CLP::String("50%".into())),
3413                    z: None,
3414                });
3415            }
3416            let mut root = root_node(300.0, 300.0, vec![n]);
3417            render_pixels(&mut root, 300, 300)
3418        };
3419
3420        let without = make_root(false);
3421        let with_50 = make_root(true);
3422        assert_eq!(
3423            without, with_50,
3424            "transform-origin: 50% 50% must be byte-identical to absent origin"
3425        );
3426    }
3427
3428    #[test]
3429    fn rotate_90_left_top_vs_center_occupy_different_quadrants() {
3430        let make_root_with_origin = |ox: Option<CLP>, oy: Option<CLP>| -> Vec<u8> {
3431            let mut n = red_box(Position::Absolute, 100.0, 100.0, 100.0, 100.0);
3432            n.css.transform = Some(vec![TransformFn::Rotate { deg: 90.0 }]);
3433            if ox.is_some() || oy.is_some() {
3434                n.css.transform_origin = Some(TransformOrigin {
3435                    x: ox,
3436                    y: oy,
3437                    z: None,
3438                });
3439            }
3440            let mut root = root_node(400.0, 400.0, vec![n]);
3441            render_pixels(&mut root, 400, 400)
3442        };
3443
3444        let buf_center = make_root_with_origin(None, None);
3445        let buf_left_top = make_root_with_origin(
3446            Some(CLP::String("0%".into())),
3447            Some(CLP::String("0%".into())),
3448        );
3449
3450        let cx_center = red_centroid_x(&buf_center, 400, 400);
3451        let cy_center = red_centroid_y(&buf_center, 400, 400);
3452        let cx_lt = red_centroid_x(&buf_left_top, 400, 400);
3453        let cy_lt = red_centroid_y(&buf_left_top, 400, 400);
3454
3455        assert!(
3456            (cx_center - 150.0).abs() < 5.0,
3457            "center-pivot cx should be ~150, got {cx_center}"
3458        );
3459        assert!(
3460            (cy_center - 150.0).abs() < 5.0,
3461            "center-pivot cy should be ~150, got {cy_center}"
3462        );
3463
3464        assert!(
3465            cx_lt < cx_center - 50.0,
3466            "left-top pivot cx ({cx_lt}) should be well left of center-pivot cx ({cx_center})"
3467        );
3468        let dist = ((cx_lt - cx_center).powi(2) + (cy_lt - cy_center).powi(2)).sqrt();
3469        assert!(
3470            dist > 50.0,
3471            "pivots should produce clearly distinct positions (dist={dist})"
3472        );
3473    }
3474
3475    #[test]
3476    fn keyword_left_top_equals_zero_percent() {
3477        let make_root = |origin_x: CLP, origin_y: CLP| -> Vec<u8> {
3478            let mut n = red_box(Position::Absolute, 100.0, 100.0, 100.0, 100.0);
3479            n.css.transform = Some(vec![TransformFn::Rotate { deg: 45.0 }]);
3480            n.css.transform_origin = Some(TransformOrigin {
3481                x: Some(origin_x),
3482                y: Some(origin_y),
3483                z: None,
3484            });
3485            let mut root = root_node(400.0, 400.0, vec![n]);
3486            render_pixels(&mut root, 400, 400)
3487        };
3488
3489        let buf_kw = make_root(CLP::String("left".into()), CLP::String("top".into()));
3490        let buf_pct = make_root(CLP::String("0%".into()), CLP::String("0%".into()));
3491        assert_eq!(
3492            buf_kw, buf_pct,
3493            "keyword 'left top' must produce same pixels as '0% 0%'"
3494        );
3495    }
3496
3497    #[test]
3498    fn keyword_right_bottom_equals_100_percent() {
3499        let make_root = |origin_x: CLP, origin_y: CLP| -> Vec<u8> {
3500            let mut n = red_box(Position::Absolute, 50.0, 50.0, 100.0, 100.0);
3501            n.css.transform = Some(vec![TransformFn::Scale { x: 1.5, y: 1.5 }]);
3502            n.css.transform_origin = Some(TransformOrigin {
3503                x: Some(origin_x),
3504                y: Some(origin_y),
3505                z: None,
3506            });
3507            let mut root = root_node(400.0, 400.0, vec![n]);
3508            render_pixels(&mut root, 400, 400)
3509        };
3510
3511        let buf_kw = make_root(CLP::String("right".into()), CLP::String("bottom".into()));
3512        let buf_pct = make_root(CLP::String("100%".into()), CLP::String("100%".into()));
3513        assert_eq!(
3514            buf_kw, buf_pct,
3515            "keyword 'right bottom' must produce same pixels as '100% 100%'"
3516        );
3517    }
3518
3519    #[test]
3520    fn rotate_y_left_origin_left_edge_is_stable() {
3521        let make_root = |origin_x: Option<CLP>| -> Vec<u8> {
3522            let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0);
3523            n.css.transform = Some(vec![TransformFn::RotateY { deg: 45.0 }]);
3524            if let Some(ox) = origin_x {
3525                n.css.transform_origin = Some(TransformOrigin {
3526                    x: Some(ox),
3527                    y: Some(CLP::String("50%".into())),
3528                    z: None,
3529                });
3530            }
3531            let mut root = root_node(500.0, 500.0, vec![n]);
3532            render_pixels(&mut root, 500, 500)
3533        };
3534
3535        let buf_left = make_root(Some(CLP::String("0%".into())));
3536        let buf_center = make_root(None);
3537
3538        fn leftmost_red(buf: &[u8], w: u32, h: u32) -> Option<u32> {
3539            for x in 0..w {
3540                for y in 0..h {
3541                    if buf[((y * w + x) * 4) as usize] > 200
3542                        && buf[((y * w + x) * 4 + 1) as usize] < 50
3543                        && buf[((y * w + x) * 4 + 2) as usize] < 50
3544                    {
3545                        return Some(x);
3546                    }
3547                }
3548            }
3549            None
3550        }
3551
3552        let left_edge_left =
3553            leftmost_red(&buf_left, 500, 500).expect("no red pixels in left-origin render") as f32;
3554        let left_edge_center = leftmost_red(&buf_center, 500, 500)
3555            .expect("no red pixels in center-origin render") as f32;
3556
3557        assert!(
3558            left_edge_left > 90.0 && left_edge_left < 115.0,
3559            "left-origin left edge should be near 100, got {left_edge_left}"
3560        );
3561
3562        assert!(
3563            left_edge_center > left_edge_left + 20.0,
3564            "center-origin left edge ({left_edge_center}) should be further right than left-origin ({left_edge_left})"
3565        );
3566    }
3567
3568    #[test]
3569    fn different_perspective_and_transform_origins_no_panic() {
3570        use crate::css::units::Length;
3571        let mut n = red_box(Position::Absolute, 100.0, 100.0, 200.0, 200.0);
3572        n.css.transform = Some(vec![TransformFn::RotateY { deg: 30.0 }]);
3573        n.css.perspective = Some(Length::Px(800.0));
3574        n.css.transform_origin = Some(TransformOrigin {
3575            x: Some(CLP::String("100%".into())),
3576            y: Some(CLP::String("100%".into())),
3577            z: None,
3578        });
3579        n.css.perspective_origin = Some(TransformOrigin {
3580            x: Some(CLP::String("0%".into())),
3581            y: Some(CLP::String("0%".into())),
3582            z: None,
3583        });
3584        let mut root = root_node(500.0, 500.0, vec![n]);
3585        let buf = render_pixels(&mut root, 500, 500);
3586        let red_count = count_red(&buf);
3587        assert!(
3588            red_count > 0,
3589            "expected some red pixels with distinct origins"
3590        );
3591    }
3592
3593    #[test]
3594    fn translate_percent_resolves_against_own_axis_not_max_dimension() {
3595        let mut n = red_box(Position::Absolute, 0.0, 0.0, 200.0, 100.0);
3596        n.css.transform = Some(vec![TransformFn::Translate {
3597            x: CLP::String("50%".into()),
3598            y: CLP::String("50%".into()),
3599        }]);
3600        let mut root = root_node(400.0, 400.0, vec![n]);
3601        let buf = render_pixels(&mut root, 400, 400);
3602
3603        let mut min_x = u32::MAX;
3604        let mut max_x = 0u32;
3605        let mut min_y = u32::MAX;
3606        let mut max_y = 0u32;
3607        for y in 0..400u32 {
3608            for x in 0..400u32 {
3609                let i = ((y * 400 + x) * 4) as usize;
3610                if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 {
3611                    min_x = min_x.min(x);
3612                    max_x = max_x.max(x);
3613                    min_y = min_y.min(y);
3614                    max_y = max_y.max(y);
3615                }
3616            }
3617        }
3618        assert_ne!(min_x, u32::MAX, "expected some red pixels");
3619
3620        assert!((min_x as i32 - 100).abs() <= 2, "min_x={min_x}");
3621        assert!((max_x as i32 - 299).abs() <= 2, "max_x={max_x}");
3622        assert!(
3623            (min_y as i32 - 50).abs() <= 2,
3624            "min_y={min_y} (expected ~50; the max(w,h) bug would give ~100)"
3625        );
3626        assert!(
3627            (max_y as i32 - 149).abs() <= 2,
3628            "max_y={max_y} (expected ~149; the max(w,h) bug would give ~199)"
3629        );
3630    }
3631}
3632
3633#[cfg(test)]
3634mod glassmorphism_tests {
3635
3636    use super::*;
3637
3638    use crate::css::style::{
3639        Background, Color as CssColor, CssStyle, Display, FilterFn, FlexDirection, Position,
3640        Size as CSize,
3641    };
3642    use crate::css::taffy_bridge::ConversionContext;
3643    use crate::css::units::LengthPercentage as CLP;
3644    use crate::engine::box_tree::{BoxKind, BoxNode};
3645    use crate::engine::layout_pass::run_layout;
3646    use crate::schema::GradientBorder;
3647
3648    fn test_frame(w: u32, h: u32) -> PaintFrame {
3649        PaintFrame {
3650            light: Default::default(),
3651            time: 0.0,
3652            scenario_time: 0.0,
3653            frame_index: 0,
3654            fps: 30,
3655            video_width: w,
3656            video_height: h,
3657            scene_duration: 1.0,
3658            camera: None,
3659        }
3660    }
3661
3662    fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec<u8> {
3663        root.assign_ids(0);
3664        let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
3665        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
3666        paint_tree(
3667            surface.canvas(),
3668            root,
3669            &layout,
3670            &test_frame(w, h),
3671            &NoopDispatcher,
3672        );
3673        let info = skia_safe::ImageInfo::new(
3674            (w as i32, h as i32),
3675            skia_safe::ColorType::RGBA8888,
3676            skia_safe::AlphaType::Unpremul,
3677            None,
3678        );
3679        let mut buf = vec![0u8; (w * h * 4) as usize];
3680        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
3681        buf
3682    }
3683
3684    fn px(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8, u8) {
3685        let i = ((y * w + x) * 4) as usize;
3686        (buf[i], buf[i + 1], buf[i + 2], buf[i + 3])
3687    }
3688
3689    fn unique_colors_in(
3690        buf: &[u8],
3691        w: u32,
3692        x0: u32,
3693        y0: u32,
3694        x1: u32,
3695        y1: u32,
3696    ) -> std::collections::HashSet<(u8, u8, u8, u8)> {
3697        let mut set = std::collections::HashSet::new();
3698        for y in y0..y1 {
3699            for x in x0..x1 {
3700                set.insert(px(buf, w, x, y));
3701            }
3702        }
3703        set
3704    }
3705
3706    fn leaf(css: CssStyle) -> BoxNode {
3707        BoxNode {
3708            id: 0,
3709            kind: BoxKind::Container,
3710            css,
3711            children: vec![],
3712            intrinsic: None,
3713            source_path: None,
3714            window: None,
3715        }
3716    }
3717
3718    fn root_node(w: f32, h: f32, background: Option<&str>, children: Vec<BoxNode>) -> BoxNode {
3719        BoxNode {
3720            id: 0,
3721            kind: BoxKind::Container,
3722            css: CssStyle {
3723                display: Some(Display::Flex),
3724                flex_direction: Some(FlexDirection::Column),
3725                width: Some(CSize::Length(CLP::Px(w))),
3726                height: Some(CSize::Length(CLP::Px(h))),
3727                background: background.map(|c| Background::Color(CssColor::String(c.to_string()))),
3728                ..Default::default()
3729            },
3730            children,
3731            intrinsic: None,
3732            source_path: None,
3733            window: None,
3734        }
3735    }
3736
3737    fn abs_box(x: f32, y: f32, w: f32, h: f32, css_extra: CssStyle) -> BoxNode {
3738        let mut css = css_extra;
3739        css.position = Some(Position::Absolute);
3740        css.left = Some(CLP::Px(x));
3741        css.top = Some(CLP::Px(y));
3742        css.width = Some(CSize::Length(CLP::Px(w)));
3743        css.height = Some(CSize::Length(CLP::Px(h)));
3744        leaf(css)
3745    }
3746
3747    #[test]
3748    fn gradient_border_paints_both_colors_on_perimeter_center_intact() {
3749        let node = abs_box(
3750            100.0,
3751            100.0,
3752            200.0,
3753            200.0,
3754            CssStyle {
3755                gradient_border: Some(GradientBorder {
3756                    colors: vec!["#ff0000".into(), "#0000ff".into()],
3757                    width: 12.0,
3758                    angle: 90.0,
3759                }),
3760                ..Default::default()
3761            },
3762        );
3763        let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]);
3764        let buf = render_pixels(&mut root, 400, 400);
3765
3766        let left = px(&buf, 400, 106, 200);
3767        let right = px(&buf, 400, 294, 200);
3768
3769        let red_side = if left.0 > left.2 { left } else { right };
3770        let blue_side = if left.0 > left.2 { right } else { left };
3771        assert!(
3772            red_side.0 > 150 && red_side.2 < 100,
3773            "expected a red-dominant border edge, got {red_side:?}"
3774        );
3775        assert!(
3776            blue_side.2 > 150 && blue_side.0 < 100,
3777            "expected a blue-dominant border edge, got {blue_side:?}"
3778        );
3779        assert_ne!(
3780            left, right,
3781            "border edges must show different gradient stops"
3782        );
3783
3784        let center = px(&buf, 400, 200, 200);
3785        assert_eq!(
3786            center,
3787            (255, 255, 255, 255),
3788            "box centre must not be painted by the gradient border"
3789        );
3790
3791        let top_mid = px(&buf, 400, 200, 106);
3792        assert_ne!(
3793            top_mid,
3794            (255, 255, 255, 255),
3795            "top border edge must be painted"
3796        );
3797    }
3798
3799    #[test]
3800    fn gradient_border_respects_border_radius() {
3801        use crate::css::style::BorderRadius;
3802        let node = abs_box(
3803            100.0,
3804            100.0,
3805            200.0,
3806            200.0,
3807            CssStyle {
3808                border_radius: Some(BorderRadius::Uniform(CLP::Px(60.0))),
3809                gradient_border: Some(GradientBorder {
3810                    colors: vec!["#ff0000".into(), "#0000ff".into()],
3811                    width: 10.0,
3812                    angle: 90.0,
3813                }),
3814                ..Default::default()
3815            },
3816        );
3817        let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]);
3818        let buf = render_pixels(&mut root, 400, 400);
3819
3820        let corner = px(&buf, 400, 104, 104);
3821        assert_eq!(
3822            corner,
3823            (255, 255, 255, 255),
3824            "square corner must stay background with border-radius"
3825        );
3826        let left_mid = px(&buf, 400, 104, 200);
3827        let top_mid = px(&buf, 400, 200, 104);
3828        assert_ne!(left_mid, (255, 255, 255, 255), "left edge must be painted");
3829        assert_ne!(top_mid, (255, 255, 255, 255), "top edge must be painted");
3830    }
3831
3832    #[test]
3833    fn gradient_border_replaces_standard_border() {
3834        use crate::css::style::{BorderEdges, BorderStyle, Edges};
3835        let node = abs_box(
3836            100.0,
3837            100.0,
3838            200.0,
3839            200.0,
3840            CssStyle {
3841                border: Some(BorderEdges {
3842                    width: Some(Edges::Uniform(CLP::Px(10.0))),
3843                    style: Some(BorderStyle::Solid),
3844                    color: Some(CssColor::String("#00ff00".into())),
3845                    ..Default::default()
3846                }),
3847                gradient_border: Some(GradientBorder {
3848                    colors: vec!["#ff0000".into(), "#0000ff".into()],
3849                    width: 10.0,
3850                    angle: 90.0,
3851                }),
3852                ..Default::default()
3853            },
3854        );
3855        let mut root = root_node(400.0, 400.0, Some("#ffffff"), vec![node]);
3856        let buf = render_pixels(&mut root, 400, 400);
3857
3858        let green_pixels = buf
3859            .chunks(4)
3860            .filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60)
3861            .count();
3862        assert_eq!(
3863            green_pixels, 0,
3864            "standard border must not be painted when gradient-border is set"
3865        );
3866    }
3867
3868    fn gray_box_with_filter(filter: Option<Vec<FilterFn>>) -> BoxNode {
3869        abs_box(
3870            50.0,
3871            50.0,
3872            200.0,
3873            200.0,
3874            CssStyle {
3875                background: Some(Background::Color(CssColor::String("#808080".into()))),
3876                filter,
3877                ..Default::default()
3878            },
3879        )
3880    }
3881
3882    #[test]
3883    fn noise_filter_explodes_unique_color_count() {
3884        let mut root_plain = root_node(
3885            300.0,
3886            300.0,
3887            Some("#ffffff"),
3888            vec![gray_box_with_filter(None)],
3889        );
3890        let buf_plain = render_pixels(&mut root_plain, 300, 300);
3891
3892        let mut root_noise = root_node(
3893            300.0,
3894            300.0,
3895            Some("#ffffff"),
3896            vec![gray_box_with_filter(Some(vec![FilterFn::Noise {
3897                intensity: 0.5,
3898                seed: 42,
3899            }]))],
3900        );
3901        let buf_noise = render_pixels(&mut root_noise, 300, 300);
3902
3903        let plain = unique_colors_in(&buf_plain, 300, 70, 70, 230, 230);
3904        let noisy = unique_colors_in(&buf_noise, 300, 70, 70, 230, 230);
3905        assert!(
3906            plain.len() <= 4,
3907            "plain box interior should be near-uniform, got {} colors",
3908            plain.len()
3909        );
3910        assert!(
3911            noisy.len() > 50,
3912            "noise filter should explode the unique color count, got {}",
3913            noisy.len()
3914        );
3915    }
3916
3917    #[test]
3918    fn noise_same_seed_is_byte_identical_across_renders() {
3919        let render = || {
3920            let mut root = root_node(
3921                300.0,
3922                300.0,
3923                Some("#ffffff"),
3924                vec![gray_box_with_filter(Some(vec![FilterFn::Noise {
3925                    intensity: 0.4,
3926                    seed: 7,
3927                }]))],
3928            );
3929            render_pixels(&mut root, 300, 300)
3930        };
3931        let a = render();
3932        let b = render();
3933        assert_eq!(a, b, "same seed must produce byte-identical grain");
3934    }
3935
3936    #[test]
3937    fn noise_different_seeds_differ() {
3938        let render = |seed: u64| {
3939            let mut root = root_node(
3940                300.0,
3941                300.0,
3942                Some("#ffffff"),
3943                vec![gray_box_with_filter(Some(vec![FilterFn::Noise {
3944                    intensity: 0.4,
3945                    seed,
3946                }]))],
3947            );
3948            render_pixels(&mut root, 300, 300)
3949        };
3950        let a = render(1);
3951        let b = render(2);
3952        assert_ne!(a, b, "different seeds must produce different grain");
3953    }
3954
3955    #[test]
3956    fn backdrop_noise_grains_only_the_panel_region() {
3957        let panel = abs_box(
3958            50.0,
3959            50.0,
3960            100.0,
3961            100.0,
3962            CssStyle {
3963                backdrop_filter: Some(vec![FilterFn::Noise {
3964                    intensity: 0.5,
3965                    seed: 42,
3966                }]),
3967                ..Default::default()
3968            },
3969        );
3970        let mut root = root_node(300.0, 300.0, Some("#808080"), vec![panel]);
3971        let buf = render_pixels(&mut root, 300, 300);
3972
3973        let inside = unique_colors_in(&buf, 300, 60, 60, 140, 140);
3974        let outside = unique_colors_in(&buf, 300, 180, 180, 280, 280);
3975        assert!(
3976            inside.len() > 30,
3977            "panel interior should be grained, got {} colors",
3978            inside.len()
3979        );
3980        assert_eq!(
3981            outside.len(),
3982            1,
3983            "outside the panel must stay uniform, got {} colors",
3984            outside.len()
3985        );
3986    }
3987
3988    #[test]
3989    fn css_gradient_border_and_noise_deserialize() {
3990        let json = r##"{
3991            "gradient-border": { "colors": ["#ff0000", "#0000ff"], "width": 3, "angle": 45 },
3992            "filter": [{ "fn": "noise", "intensity": 0.3, "seed": 9 }],
3993            "backdrop-filter": [{ "fn": "noise" }]
3994        }"##;
3995        let s: CssStyle = serde_json::from_str(json).unwrap();
3996        let gb = s.gradient_border.expect("gradient-border parsed");
3997        assert_eq!(gb.colors.len(), 2);
3998        assert_eq!(gb.width, 3.0);
3999        assert_eq!(gb.angle, 45.0);
4000        assert!(matches!(
4001            s.filter.as_deref(),
4002            Some([FilterFn::Noise {
4003                intensity,
4004                seed: 9
4005            }]) if (intensity - 0.3).abs() < 1e-6
4006        ));
4007        assert!(matches!(
4008            s.backdrop_filter.as_deref(),
4009            Some([FilterFn::Noise {
4010                intensity,
4011                seed: 42
4012            }]) if (intensity - 0.15).abs() < 1e-6
4013        ));
4014    }
4015
4016    #[test]
4017    fn css_legacy_zombies_accepted() {
4018        let json = r##"{
4019            "backdrop-blur": 20,
4020            "inner-shadow": { "color": "#000000", "offset_x": 0, "offset_y": 2, "blur": 8 }
4021        }"##;
4022        let s: CssStyle = serde_json::from_str(json).unwrap();
4023        assert_eq!(s.backdrop_blur, Some(20.0));
4024        assert!(s.inner_shadow.is_some());
4025    }
4026}
4027
4028#[cfg(test)]
4029mod paint_order_tests {
4030
4031    use super::*;
4032
4033    use crate::css::style::{
4034        Background, BoxShadow, ClipPath, Color as CssColor, CssStyle, Display, FilterFn,
4035        FlexDirection, Overflow, Position, Size as CSize,
4036    };
4037    use crate::css::taffy_bridge::ConversionContext;
4038    use crate::css::units::{Length, LengthPercentage as CLP};
4039    use crate::engine::box_tree::{BoxKind, BoxNode};
4040    use crate::engine::layout_pass::run_layout;
4041    use crate::schema::{
4042        AnimationEffect, BurstConfig, ChromaticAberrationConfig, EasingType, ShatterConfig,
4043        ShatterMode, ShatterOrigin,
4044    };
4045
4046    fn test_frame(w: u32, h: u32) -> PaintFrame {
4047        PaintFrame {
4048            light: Default::default(),
4049            time: 0.0,
4050            scenario_time: 0.0,
4051            frame_index: 0,
4052            fps: 30,
4053            video_width: w,
4054            video_height: h,
4055            scene_duration: 1.0,
4056            camera: None,
4057        }
4058    }
4059
4060    fn render_pixels(root: &mut BoxNode, w: u32, h: u32) -> Vec<u8> {
4061        root.assign_ids(0);
4062        let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
4063        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
4064        paint_tree(
4065            surface.canvas(),
4066            root,
4067            &layout,
4068            &test_frame(w, h),
4069            &NoopDispatcher,
4070        );
4071        let info = skia_safe::ImageInfo::new(
4072            (w as i32, h as i32),
4073            skia_safe::ColorType::RGBA8888,
4074            skia_safe::AlphaType::Unpremul,
4075            None,
4076        );
4077        let mut buf = vec![0u8; (w * h * 4) as usize];
4078        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
4079        buf
4080    }
4081
4082    fn root_node(w: f32, h: f32, background: &str, children: Vec<BoxNode>) -> BoxNode {
4083        BoxNode {
4084            id: 0,
4085            kind: BoxKind::Container,
4086            css: CssStyle {
4087                display: Some(Display::Flex),
4088                flex_direction: Some(FlexDirection::Column),
4089                width: Some(CSize::Length(CLP::Px(w))),
4090                height: Some(CSize::Length(CLP::Px(h))),
4091                background: Some(Background::Color(CssColor::String(background.to_string()))),
4092                ..Default::default()
4093            },
4094            children,
4095            intrinsic: None,
4096            source_path: None,
4097            window: None,
4098        }
4099    }
4100
4101    fn count_red_in(buf: &[u8], w: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
4102        let mut n = 0;
4103        for y in y0..y1 {
4104            for x in x0..x1 {
4105                let i = ((y * w + x) * 4) as usize;
4106                if buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50 {
4107                    n += 1;
4108                }
4109            }
4110        }
4111        n
4112    }
4113
4114    fn clipped_square(clip: Option<ClipPath>) -> BoxNode {
4115        BoxNode {
4116            id: 0,
4117            kind: BoxKind::Container,
4118            css: CssStyle {
4119                position: Some(Position::Absolute),
4120                left: Some(CLP::Px(0.0)),
4121                top: Some(CLP::Px(0.0)),
4122                width: Some(CSize::Length(CLP::Px(400.0))),
4123                height: Some(CSize::Length(CLP::Px(400.0))),
4124                background: Some(Background::Color(CssColor::String("#ff0000".into()))),
4125                clip_path: clip,
4126                ..Default::default()
4127            },
4128            children: vec![],
4129            intrinsic: None,
4130            source_path: None,
4131            window: None,
4132        }
4133    }
4134
4135    fn is_red_at(clip: Option<ClipPath>, x: u32, y: u32) -> bool {
4136        let mut root = root_node(400.0, 400.0, "#000000", vec![clipped_square(clip)]);
4137        let buf = render_pixels(&mut root, 400, 400);
4138        let i = ((y * 400 + x) * 4) as usize;
4139        buf[i] > 200 && buf[i + 1] < 50 && buf[i + 2] < 50
4140    }
4141
4142    #[test]
4143    fn clip_path_inset_removes_the_region_outside_it() {
4144        assert!(
4145            is_red_at(None, 350, 200),
4146            "sanity: unclipped, x=350 is inside the square"
4147        );
4148        let inset = ClipPath::Inset {
4149            top: CLP::Px(0.0),
4150            right: CLP::Px(120.0),
4151            bottom: CLP::Px(0.0),
4152            left: CLP::Px(0.0),
4153            radius: None,
4154        };
4155        assert!(
4156            is_red_at(Some(inset.clone()), 200, 200),
4157            "inside the inset box must still paint"
4158        );
4159        assert!(
4160            !is_red_at(Some(inset), 330, 200),
4161            "inset right:120 must remove x=330 — this is the sample from the issue that \
4162             stayed painted while clip-path was read by nothing"
4163        );
4164    }
4165
4166    #[test]
4167    fn clip_path_circle_keeps_the_centre_and_drops_the_corner() {
4168        let circle = ClipPath::Circle {
4169            radius: CLP::Px(100.0),
4170            origin: None,
4171        };
4172        assert!(
4173            is_red_at(Some(circle.clone()), 200, 200),
4174            "the centre is inside a centred r=100 circle"
4175        );
4176        assert!(
4177            !is_red_at(Some(circle), 20, 20),
4178            "the top-left corner is outside a centred r=100 circle"
4179        );
4180    }
4181
4182    #[test]
4183    fn clip_path_ellipse_is_wider_than_it_is_tall() {
4184        let ellipse = ClipPath::Ellipse {
4185            rx: CLP::Px(180.0),
4186            ry: CLP::Px(40.0),
4187            origin: None,
4188        };
4189        assert!(
4190            is_red_at(Some(ellipse.clone()), 360, 200),
4191            "x=360 is within rx=180 of the centre"
4192        );
4193        assert!(
4194            !is_red_at(Some(ellipse), 200, 360),
4195            "y=360 is outside ry=40 of the centre"
4196        );
4197    }
4198
4199    #[test]
4200    fn clip_path_polygon_cuts_a_triangle() {
4201        let triangle = ClipPath::Polygon {
4202            points: vec![
4203                (CLP::Px(200.0), CLP::Px(0.0)),
4204                (CLP::Px(400.0), CLP::Px(400.0)),
4205                (CLP::Px(0.0), CLP::Px(400.0)),
4206            ],
4207        };
4208        assert!(
4209            is_red_at(Some(triangle.clone()), 200, 300),
4210            "low centre is inside the triangle"
4211        );
4212        assert!(
4213            !is_red_at(Some(triangle), 20, 20),
4214            "the top-left corner is outside the triangle"
4215        );
4216    }
4217
4218    #[test]
4219    fn clip_path_path_takes_svg_data_relative_to_the_box() {
4220        let left_half = ClipPath::Path {
4221            d: "M0 0 L200 0 L200 400 L0 400 Z".to_string(),
4222        };
4223        assert!(
4224            is_red_at(Some(left_half.clone()), 100, 200),
4225            "the left half stays"
4226        );
4227        assert!(
4228            !is_red_at(Some(left_half), 300, 200),
4229            "the right half is clipped away"
4230        );
4231    }
4232
4233    #[test]
4234    fn clip_path_blob_keeps_the_centre_and_drops_the_far_corner() {
4235        let blob = ClipPath::Blob {
4236            radius: CLP::Px(150.0),
4237            origin: None,
4238            lobes: 8,
4239            wobble: 0.15,
4240            seed: 11,
4241        };
4242        assert!(
4243            is_red_at(Some(blob.clone()), 200, 200),
4244            "the centre is inside a centred organic blob of radius 150"
4245        );
4246        assert!(
4247            !is_red_at(Some(blob), 20, 20),
4248            "the far corner, well beyond the blob's radius, is outside it"
4249        );
4250    }
4251
4252    #[test]
4253    fn clip_path_blob_reuses_the_wobbling_lobe_generator_instead_of_drawing_a_plain_circle() {
4254        let radius = 150.0;
4255        let circle = ClipPath::Circle {
4256            radius: CLP::Px(radius),
4257            origin: None,
4258        };
4259        let blob = ClipPath::Blob {
4260            radius: CLP::Px(radius),
4261            origin: None,
4262            lobes: 8,
4263            wobble: 0.45,
4264            seed: 11,
4265        };
4266        let mut saw_a_difference = false;
4267        for deg in (0..360).step_by(3) {
4268            let rad = (deg as f32).to_radians();
4269            let probe_r = radius * 0.98;
4270            let x = 200.0 + probe_r * rad.cos();
4271            let y = 200.0 + probe_r * rad.sin();
4272            if !(0.0..400.0).contains(&x) || !(0.0..400.0).contains(&y) {
4273                continue;
4274            }
4275            let (xi, yi) = (x.round() as u32, y.round() as u32);
4276            if is_red_at(Some(circle.clone()), xi, yi) != is_red_at(Some(blob.clone()), xi, yi) {
4277                saw_a_difference = true;
4278                break;
4279            }
4280        }
4281        assert!(
4282            saw_a_difference,
4283            "a wobble of 0.45 must make the blob's edge diverge from a perfect circle \
4284             somewhere around a ring just inside the nominal radius — if every sample agrees \
4285             with the circle, clip-path blob is not reusing the transition's wobbling-lobe \
4286             generator, just drawing a circle under a different name"
4287        );
4288    }
4289
4290    #[test]
4291    fn clip_path_none_paints_exactly_as_no_clip_path_at_all() {
4292        for (x, y) in [(20, 20), (200, 200), (380, 380)] {
4293            assert_eq!(
4294                is_red_at(Some(ClipPath::None), x, y),
4295                is_red_at(None, x, y),
4296                "clip-path: none must be indistinguishable from absent at ({x}, {y})"
4297            );
4298        }
4299    }
4300
4301    #[test]
4302    fn clip_path_clips_the_background_and_the_outset_shadow_too() {
4303        let mut node = clipped_square(Some(ClipPath::Inset {
4304            top: CLP::Px(0.0),
4305            right: CLP::Px(200.0),
4306            bottom: CLP::Px(0.0),
4307            left: CLP::Px(0.0),
4308            radius: None,
4309        }));
4310        node.css.width = Some(CSize::Length(CLP::Px(200.0)));
4311        node.css.height = Some(CSize::Length(CLP::Px(200.0)));
4312        node.css.left = Some(CLP::Px(100.0));
4313        node.css.top = Some(CLP::Px(100.0));
4314        node.css.background = Some(Background::Color(CssColor::String("#ffffff".into())));
4315        node.css.box_shadow = Some(vec![BoxShadow {
4316            offset_x: Length::Px(0.0),
4317            offset_y: Length::Px(0.0),
4318            blur: None,
4319            spread: Some(Length::Px(40.0)),
4320            color: Some(CssColor::String("#ff0000".into())),
4321            inset: None,
4322        }]);
4323
4324        let mut root = root_node(400.0, 400.0, "#000000", vec![node]);
4325        let buf = render_pixels(&mut root, 400, 400);
4326
4327        let right_of_the_cut = count_red_in(&buf, 400, 260, 100, 400, 300);
4328        assert_eq!(
4329            right_of_the_cut, 0,
4330            "clip-path clips the element itself, shadow included — unlike overflow:hidden, \
4331             which clips only the content and deliberately spares the node's own outset shadow"
4332        );
4333    }
4334
4335    #[test]
4336    fn animated_border_radius_reaches_the_paint_pass() {
4337        use crate::css::apply_animated_props;
4338        use crate::engine::animator::AnimatedProperties;
4339
4340        let mut css = CssStyle {
4341            position: Some(Position::Absolute),
4342            left: Some(CLP::Px(100.0)),
4343            top: Some(CLP::Px(50.0)),
4344            width: Some(CSize::Length(CLP::Px(200.0))),
4345            height: Some(CSize::Length(CLP::Px(200.0))),
4346            background: Some(Background::Color(CssColor::String("#000000".into()))),
4347            ..Default::default()
4348        };
4349        apply_animated_props(
4350            &mut css,
4351            &AnimatedProperties {
4352                border_radius: 100.0,
4353                ..AnimatedProperties::default()
4354            },
4355        );
4356
4357        let node = BoxNode {
4358            id: 0,
4359            kind: BoxKind::Container,
4360            css,
4361            children: vec![],
4362            intrinsic: None,
4363            source_path: None,
4364            window: None,
4365        };
4366        let mut root = root_node(400.0, 300.0, "#ffffff", vec![node]);
4367        let buf = render_pixels(&mut root, 400, 300);
4368
4369        let corner = probe(&buf, 400, 105, 55);
4370        assert_eq!(
4371            corner,
4372            (255, 255, 255),
4373            "a border-radius produced by an animation (not a literal style value) must round \
4374             the square's corner away — got {corner:?}, meaning the animated value never \
4375             reached css.border_radius"
4376        );
4377    }
4378
4379    fn bordered_box(w: f32, h: f32, style: BorderStyle) -> BoxNode {
4380        BoxNode {
4381            id: 0,
4382            kind: BoxKind::Container,
4383            css: CssStyle {
4384                position: Some(Position::Absolute),
4385                left: Some(CLP::Px(20.0)),
4386                top: Some(CLP::Px(20.0)),
4387                width: Some(CSize::Length(CLP::Px(w))),
4388                height: Some(CSize::Length(CLP::Px(h))),
4389                border: Some(BorderEdges {
4390                    width: Some(Edges::Uniform(CLP::Px(8.0))),
4391                    style: Some(style),
4392                    color: Some(CssColor::String("#000000".into())),
4393                    ..Default::default()
4394                }),
4395                ..Default::default()
4396            },
4397            children: vec![],
4398            intrinsic: None,
4399            source_path: None,
4400            window: None,
4401        }
4402    }
4403
4404    fn count_background_gaps_on_top_edge(buf: &[u8], w: u32, x0: u32, x1: u32, y: u32) -> usize {
4405        let mut gaps = 0;
4406        let mut in_gap = false;
4407        for x in x0..x1 {
4408            let i = ((y * w + x) * 4) as usize;
4409            let is_background = buf[i] > 200 && buf[i + 1] > 200 && buf[i + 2] > 200;
4410            if is_background {
4411                if !in_gap {
4412                    gaps += 1;
4413                }
4414                in_gap = true;
4415            } else {
4416                in_gap = false;
4417            }
4418        }
4419        gaps
4420    }
4421
4422    #[test]
4423    fn dashed_border_leaves_background_coloured_gaps_along_the_edge() {
4424        let mut solid_root = root_node(
4425            400.0,
4426            400.0,
4427            "#ffffff",
4428            vec![bordered_box(300.0, 100.0, BorderStyle::Solid)],
4429        );
4430        let solid_buf = render_pixels(&mut solid_root, 400, 400);
4431        let solid_gaps = count_background_gaps_on_top_edge(&solid_buf, 400, 40, 300, 24);
4432        assert_eq!(
4433            solid_gaps, 0,
4434            "a solid border's top edge must be painted over its full length — got {solid_gaps} \
4435             background-coloured gaps"
4436        );
4437
4438        let mut dashed_root = root_node(
4439            400.0,
4440            400.0,
4441            "#ffffff",
4442            vec![bordered_box(300.0, 100.0, BorderStyle::Dashed)],
4443        );
4444        let dashed_buf = render_pixels(&mut dashed_root, 400, 400);
4445        let dashed_gaps = count_background_gaps_on_top_edge(&dashed_buf, 400, 40, 300, 24);
4446        assert!(
4447            dashed_gaps > 0,
4448            "a dashed border must leave background-coloured gaps along its top edge — got \
4449             {dashed_gaps}, meaning border.style was read only to gate BorderStyle::None and \
4450             every other variant painted the same solid ring"
4451        );
4452    }
4453
4454    #[test]
4455    fn dotted_and_double_borders_are_not_painted_as_a_solid_fill() {
4456        for style in [BorderStyle::Dotted, BorderStyle::Double] {
4457            let mut root = root_node(
4458                400.0,
4459                400.0,
4460                "#ffffff",
4461                vec![bordered_box(300.0, 100.0, style)],
4462            );
4463            let buf = render_pixels(&mut root, 400, 400);
4464            let gaps = count_background_gaps_on_top_edge(&buf, 400, 40, 300, 24);
4465            assert!(
4466                gaps > 0,
4467                "{style:?} border must not paint a solid filled ring — got {gaps} \
4468                 background-coloured gaps along the top edge"
4469            );
4470        }
4471    }
4472
4473    fn material_tile(material: Option<Material>) -> BoxNode {
4474        BoxNode {
4475            id: 0,
4476            kind: BoxKind::Container,
4477            css: CssStyle {
4478                position: Some(Position::Absolute),
4479                left: Some(CLP::Px(100.0)),
4480                top: Some(CLP::Px(100.0)),
4481                width: Some(CSize::Length(CLP::Px(200.0))),
4482                height: Some(CSize::Length(CLP::Px(200.0))),
4483                background: Some(Background::Color(CssColor::String("#808080".into()))),
4484                material,
4485                ..Default::default()
4486            },
4487            children: vec![],
4488            intrinsic: None,
4489            source_path: None,
4490            window: None,
4491        }
4492    }
4493
4494    fn render_lit(node: BoxNode, light: Option<LightDirection>) -> Vec<u8> {
4495        let mut root = root_node(400.0, 400.0, "#000000", vec![node]);
4496        root.assign_ids(0);
4497        let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
4498        let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
4499        let mut frame = test_frame(400, 400);
4500        if let Some(l) = light {
4501            frame.light = l;
4502        }
4503        paint_tree(surface.canvas(), &root, &layout, &frame, &NoopDispatcher);
4504        let info = skia_safe::ImageInfo::new(
4505            (400, 400),
4506            skia_safe::ColorType::RGBA8888,
4507            skia_safe::AlphaType::Unpremul,
4508            None,
4509        );
4510        let mut buf = vec![0u8; 400 * 400 * 4];
4511        surface.read_pixels(&info, &mut buf, 400 * 4, (0, 0));
4512        buf
4513    }
4514
4515    fn luma_at(buf: &[u8], x: usize, y: usize) -> f32 {
4516        let i = (y * 400 + x) * 4;
4517        0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32
4518    }
4519
4520    fn morph_chain(progress: f32) -> BoxNode {
4521        let square = |inset: f32| ClipPath::Inset {
4522            top: CLP::Px(inset),
4523            right: CLP::Px(inset),
4524            bottom: CLP::Px(inset),
4525            left: CLP::Px(inset),
4526            radius: None,
4527        };
4528        BoxNode {
4529            id: 0,
4530            kind: BoxKind::Container,
4531            css: CssStyle {
4532                position: Some(Position::Absolute),
4533                left: Some(CLP::Px(0.0)),
4534                top: Some(CLP::Px(0.0)),
4535                width: Some(CSize::Length(CLP::Px(200.0))),
4536                height: Some(CSize::Length(CLP::Px(200.0))),
4537                background: Some(Background::Color(CssColor::String("#ff0000".into()))),
4538                clip_path: Some(ClipPath::Morph {
4539                    from: Box::new(square(0.0)),
4540                    to: Box::new(square(0.0)),
4541                    via: vec![square(80.0)],
4542                    progress,
4543                }),
4544                ..Default::default()
4545            },
4546            children: vec![],
4547            intrinsic: None,
4548            source_path: None,
4549            window: None,
4550        }
4551    }
4552
4553    fn red_pixels(buf: &[u8]) -> usize {
4554        buf.as_chunks::<4>()
4555            .0
4556            .iter()
4557            .filter(|px| px[0] > 200 && px[1] < 60)
4558            .count()
4559    }
4560
4561    #[test]
4562    fn a_morph_chain_passes_through_its_via_shape_at_the_midpoint() {
4563        let start = red_pixels(&render_lit(morph_chain(0.0), None));
4564        let middle = red_pixels(&render_lit(morph_chain(0.5), None));
4565        let end = red_pixels(&render_lit(morph_chain(1.0), None));
4566
4567        assert!(
4568            middle * 3 < start,
4569            "with one `via` shape, progress 0.5 lands exactly on it — an 80px inset of a \
4570             200px box, so far smaller than either end: start={start}px, middle={middle}px"
4571        );
4572        assert!(
4573            (start as i64 - end as i64).abs() < 40,
4574            "the chain starts and ends on the same shape here, so both ends must match: \
4575             start={start}px, end={end}px"
4576        );
4577    }
4578
4579    #[test]
4580    fn a_morph_chain_keeps_moving_between_its_legs() {
4581        let quarter = red_pixels(&render_lit(morph_chain(0.25), None));
4582        let middle = red_pixels(&render_lit(morph_chain(0.5), None));
4583        let three_quarters = red_pixels(&render_lit(morph_chain(0.75), None));
4584        assert!(
4585            quarter > middle && three_quarters > middle,
4586            "a quarter of the way along each leg is between the endpoints, not at one of \
4587             them: 0.25={quarter}px, 0.5={middle}px, 0.75={three_quarters}px"
4588        );
4589    }
4590
4591    #[test]
4592    fn an_empty_via_leaves_the_two_shape_morph_exactly_as_it_was() {
4593        let a = ClipPath::Circle {
4594            radius: CLP::Px(10.0),
4595            origin: None,
4596        };
4597        let b = ClipPath::Circle {
4598            radius: CLP::Px(90.0),
4599            origin: None,
4600        };
4601        let (from, to, t) = morph_leg(&a, &b, &[], 0.37);
4602        assert_eq!(from, &a);
4603        assert_eq!(to, &b);
4604        assert_eq!(
4605            t, 0.37,
4606            "with no `via`, the leg is the whole morph and progress passes through \
4607             untouched — which is what makes every morph written before this render \
4608             identically"
4609        );
4610    }
4611
4612    #[test]
4613    fn a_via_shape_splits_the_sweep_into_equal_legs() {
4614        let a = ClipPath::Circle {
4615            radius: CLP::Px(10.0),
4616            origin: None,
4617        };
4618        let mid = ClipPath::Circle {
4619            radius: CLP::Px(50.0),
4620            origin: None,
4621        };
4622        let b = ClipPath::Circle {
4623            radius: CLP::Px(90.0),
4624            origin: None,
4625        };
4626        let via = [mid.clone()];
4627
4628        let (f0, t0, p0) = morph_leg(&a, &b, &via, 0.25);
4629        assert_eq!((f0, t0), (&a, &mid), "the first half travels a -> via");
4630        assert!((p0 - 0.5).abs() < 1e-5, "halfway along that leg: {p0}");
4631
4632        let (f1, t1, p1) = morph_leg(&a, &b, &via, 0.75);
4633        assert_eq!((f1, t1), (&mid, &b), "the second half travels via -> b");
4634        assert!((p1 - 0.5).abs() < 1e-5, "halfway along that leg: {p1}");
4635    }
4636
4637    fn star_points() -> Vec<(CLP, CLP)> {
4638        (0..10)
4639            .map(|i| {
4640                let r = if i % 2 == 0 { 90.0f32 } else { 38.0 };
4641                let a = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0;
4642                (CLP::Px(100.0 + r * a.cos()), CLP::Px(100.0 + r * a.sin()))
4643            })
4644            .collect()
4645    }
4646
4647    fn star_tile(material: Material) -> BoxNode {
4648        BoxNode {
4649            id: 0,
4650            kind: BoxKind::Container,
4651            css: CssStyle {
4652                position: Some(Position::Absolute),
4653                left: Some(CLP::Px(0.0)),
4654                top: Some(CLP::Px(0.0)),
4655                width: Some(CSize::Length(CLP::Px(200.0))),
4656                height: Some(CSize::Length(CLP::Px(200.0))),
4657                background: Some(Background::Color(CssColor::String("#7C3AED".into()))),
4658                clip_path: Some(ClipPath::Polygon {
4659                    points: star_points(),
4660                }),
4661                material: Some(material),
4662                ..Default::default()
4663            },
4664            children: vec![],
4665            intrinsic: None,
4666            source_path: None,
4667            window: None,
4668        }
4669    }
4670
4671    /// Bright runs along one scanline: one per lit branch if the shading
4672    /// follows the silhouette, one for the whole box if it does not.
4673    fn bright_runs(buf: &[u8], y: usize) -> usize {
4674        let mut runs = 0;
4675        let mut inside = false;
4676        for x in 0..400 {
4677            let i = (y * 400 + x) * 4;
4678            let luma =
4679                0.299 * buf[i] as f32 + 0.587 * buf[i + 1] as f32 + 0.114 * buf[i + 2] as f32;
4680            let bright = luma > 150.0;
4681            if bright && !inside {
4682                runs += 1;
4683            }
4684            inside = bright;
4685        }
4686        runs
4687    }
4688
4689    #[test]
4690    fn inflated_lights_each_branch_where_glossy_lights_the_whole_box() {
4691        let glossy = render_lit(star_tile(Material::Preset(MaterialPreset::Glossy)), None);
4692        let inflated = render_lit(
4693            star_tile(Material::Tuned {
4694                preset: MaterialPreset::Inflated,
4695                intensity: 1.0,
4696                bevel: 10.0,
4697                softness: 0.2,
4698            }),
4699            None,
4700        );
4701
4702        let scanline = 78;
4703        let glossy_runs = bright_runs(&glossy, scanline);
4704        let inflated_runs = bright_runs(&inflated, scanline);
4705        assert!(
4706            inflated_runs > glossy_runs,
4707            "across a row cutting two branches, an inflated star must show more separate \
4708             bright runs than a glossy one, which lights the bounding box as a single band: \
4709             glossy={glossy_runs}, inflated={inflated_runs}"
4710        );
4711    }
4712
4713    #[test]
4714    fn inflated_paints_nothing_outside_the_silhouette() {
4715        let buf = render_lit(
4716            star_tile(Material::Tuned {
4717                preset: MaterialPreset::Inflated,
4718                intensity: 1.0,
4719                bevel: 14.0,
4720                softness: 0.5,
4721            }),
4722            None,
4723        );
4724        let corner = luma_at(&buf, 6, 6);
4725        assert_eq!(
4726            corner, 0.0,
4727            "the shading is derived from the silhouette and clipped to it — a star's \
4728             bounding-box corner must stay background"
4729        );
4730    }
4731
4732    #[test]
4733    fn inflated_at_zero_intensity_is_the_bare_shape() {
4734        let flat = render_lit(
4735            star_tile(Material::Tuned {
4736                preset: MaterialPreset::Inflated,
4737                intensity: 0.0,
4738                bevel: 14.0,
4739                softness: 0.5,
4740            }),
4741            None,
4742        );
4743        let none = {
4744            let mut node = star_tile(Material::Preset(MaterialPreset::Matte));
4745            node.css.material = None;
4746            render_lit(node, None)
4747        };
4748        assert_eq!(
4749            flat, none,
4750            "intensity 0 must be byte-identical to declaring no material at all"
4751        );
4752    }
4753
4754    fn without_trailing_plateau(values: &[i32]) -> &[i32] {
4755        let mut end = values.len();
4756        if end == 0 {
4757            return values;
4758        }
4759        let last = values[end - 1];
4760        while end > 0 && values[end - 1] == last {
4761            end -= 1;
4762        }
4763        &values[..end]
4764    }
4765
4766    fn flat_pair_count(values: &[i32]) -> usize {
4767        values.windows(2).filter(|w| w[0] == w[1]).count()
4768    }
4769
4770    #[test]
4771    fn a_wide_inflated_bevel_does_not_posterize_its_ramp_into_solid_flat_plateaus() {
4772        let bevel = 48.0f32;
4773        let mut node = material_tile(Some(Material::Tuned {
4774            preset: MaterialPreset::Inflated,
4775            intensity: 1.0,
4776            bevel,
4777            softness: 0.6,
4778        }));
4779        node.css.border_radius = Some(BorderRadius::Uniform(CLP::Px(100.0)));
4780        let buf = render_lit(node, None);
4781        let y = 200usize;
4782        let x_max = 100 + (bevel * 2.0) as usize;
4783        let lumas: Vec<i32> = (100..x_max).map(|x| luma_at(&buf, x, y) as i32).collect();
4784        let ramp = without_trailing_plateau(&lumas);
4785        let flat_pairs = flat_pair_count(ramp);
4786        assert!(
4787            flat_pairs <= 28,
4788            "a bevel of {bevel} across a 100px radius must not hold {flat_pairs} \
4789             adjacent same-luminance pixel pairs inside its shading ramp (out of {} \
4790             pairs sampled) — quantizing the shading alpha to 8 bits without dithering \
4791             posterizes a shallow gradient into wide flat plateaus, visible as \
4792             stair-stepped bands along the inner bevel",
4793            ramp.len().saturating_sub(1)
4794        );
4795    }
4796
4797    #[test]
4798    fn a_node_without_a_material_is_untouched() {
4799        let plain = render_lit(material_tile(None), None);
4800        let flat = render_lit(
4801            material_tile(Some(Material::Tuned {
4802                preset: MaterialPreset::Glossy,
4803                intensity: 0.0,
4804                bevel: 18.0,
4805                softness: 0.6,
4806            })),
4807            None,
4808        );
4809        assert_eq!(
4810            plain, flat,
4811            "intensity 0 must be byte-identical to declaring no material at all"
4812        );
4813    }
4814
4815    #[test]
4816    fn glossy_is_brighter_on_the_lit_side_than_opposite_it() {
4817        let buf = render_lit(
4818            material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
4819            None,
4820        );
4821        let lit = luma_at(&buf, 140, 140);
4822        let shaded = luma_at(&buf, 260, 260);
4823        assert!(
4824            lit > shaded + 20.0,
4825            "the default light is upper-left, so the upper-left of the tile must be clearly \
4826             brighter than the lower-right: lit={lit:.1}, shaded={shaded:.1}"
4827        );
4828    }
4829
4830    #[test]
4831    fn turning_the_light_around_turns_the_gradient_around() {
4832        let upper_left = render_lit(
4833            material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
4834            None,
4835        );
4836        let lower_right = render_lit(
4837            material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
4838            Some(LightDirection {
4839                x: 0.35,
4840                y: 0.8,
4841                ..Default::default()
4842            }),
4843        );
4844        assert!(
4845            luma_at(&upper_left, 140, 140) > luma_at(&upper_left, 260, 260),
4846            "sanity: the default light lights the upper-left"
4847        );
4848        assert!(
4849            luma_at(&lower_right, 260, 260) > luma_at(&lower_right, 140, 140),
4850            "a light declared from the lower-right must light the lower-right — this is what \
4851             makes two materials in one scene agree about where the light is"
4852        );
4853    }
4854
4855    #[test]
4856    fn matte_shades_without_a_highlight() {
4857        let matte = render_lit(
4858            material_tile(Some(Material::Preset(MaterialPreset::Matte))),
4859            None,
4860        );
4861        let plain = render_lit(material_tile(None), None);
4862        let lit_delta = luma_at(&matte, 140, 140) - luma_at(&plain, 140, 140);
4863        let shade_delta = luma_at(&plain, 260, 260) - luma_at(&matte, 260, 260);
4864        assert!(
4865            lit_delta <= 1.0,
4866            "matte returns no specular light, so its lit side must not brighten: +{lit_delta:.1}"
4867        );
4868        assert!(
4869            shade_delta > 10.0,
4870            "matte still takes light, so its far side must darken: -{shade_delta:.1}"
4871        );
4872    }
4873
4874    #[test]
4875    fn the_scene_light_intensity_scales_every_material_at_once() {
4876        let full = render_lit(
4877            material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
4878            None,
4879        );
4880        let dimmed = render_lit(
4881            material_tile(Some(Material::Preset(MaterialPreset::Glossy))),
4882            Some(LightDirection {
4883                intensity: 0.25,
4884                ..Default::default()
4885            }),
4886        );
4887        let plain = render_lit(material_tile(None), None);
4888        let full_lift = luma_at(&full, 140, 140) - luma_at(&plain, 140, 140);
4889        let dim_lift = luma_at(&dimmed, 140, 140) - luma_at(&plain, 140, 140);
4890        assert!(
4891            dim_lift > 0.0 && dim_lift < full_lift * 0.6,
4892            "a quarter-intensity light must still lift, but far less: full=+{full_lift:.1}, \
4893             dimmed=+{dim_lift:.1}"
4894        );
4895    }
4896
4897    #[test]
4898    fn a_material_never_paints_outside_its_clip_path() {
4899        let mut node = material_tile(Some(Material::Preset(MaterialPreset::Glossy)));
4900        node.css.background = None;
4901        node.css.clip_path = Some(ClipPath::Inset {
4902            top: CLP::Px(0.0),
4903            right: CLP::Px(100.0),
4904            bottom: CLP::Px(0.0),
4905            left: CLP::Px(0.0),
4906            radius: None,
4907        });
4908        let buf = render_lit(node, None);
4909        assert!(
4910            luma_at(&buf, 120, 200) > 1.0,
4911            "sanity: the material paints inside the clip"
4912        );
4913        assert_eq!(
4914            luma_at(&buf, 260, 200),
4915            0.0,
4916            "the material follows the node's silhouette — clip-path included — and must not \
4917             spill into the region the clip removed"
4918        );
4919    }
4920
4921    fn shot_camera(rotate_y: f32, perspective: f32) -> PlaneCamera {
4922        PlaneCamera {
4923            pan_x: 0.0,
4924            pan_y: 0.0,
4925            zoom: 1.0,
4926            rotation: 0.0,
4927            origin_x: 200.0,
4928            origin_y: 200.0,
4929            focus: 1.0,
4930            aperture: 0.0,
4931            rotate_x: 0.0,
4932            rotate_y,
4933            perspective,
4934        }
4935    }
4936
4937    fn ink_x_extent(buf: &[u8]) -> (usize, usize) {
4938        let mut lo = usize::MAX;
4939        let mut hi = 0usize;
4940        for y in 0..400 {
4941            for x in 0..400 {
4942                let i = (y * 400 + x) * 4;
4943                if buf[i] > 150 && buf[i + 1] < 90 {
4944                    lo = lo.min(x);
4945                    hi = hi.max(x);
4946                }
4947            }
4948        }
4949        (lo, hi)
4950    }
4951
4952    #[test]
4953    fn a_camera_tilt_of_zero_renders_exactly_as_no_camera() {
4954        let plain = render_with_camera(plane_at_depth(Some(2.0)), None);
4955        let flat = render_with_camera(plane_at_depth(Some(2.0)), Some(shot_camera(0.0, 1400.0)));
4956        assert_eq!(
4957            plain, flat,
4958            "a camera declaring perspective but no tilt must not move a pixel — every \
4959             scenario written before this declares neither"
4960        );
4961    }
4962
4963    #[test]
4964    fn a_camera_tilt_turns_the_whole_shot() {
4965        let flat = render_with_camera(plane_at_depth(Some(1.0)), None);
4966        let tilted = render_with_camera(plane_at_depth(Some(1.0)), Some(shot_camera(24.0, 1400.0)));
4967        assert_ne!(flat, tilted, "a 24-degree tilt must change the frame");
4968    }
4969
4970    #[test]
4971    fn a_deeper_plane_swings_further_than_a_near_one() {
4972        let near = render_with_camera(plane_at_depth(Some(0.5)), Some(shot_camera(24.0, 1400.0)));
4973        let far = render_with_camera(plane_at_depth(Some(3.0)), Some(shot_camera(24.0, 1400.0)));
4974        let resting = ink_x_extent(&render_with_camera(plane_at_depth(Some(1.0)), None));
4975
4976        let near_shift = (ink_x_extent(&near).0 as i64 - resting.0 as i64).abs();
4977        let far_shift = (ink_x_extent(&far).0 as i64 - resting.0 as i64).abs();
4978        assert!(
4979            far_shift > near_shift,
4980            "the tilt is scaled by style.depth, the same rule parallax and focus follow, so a \
4981             plane at depth 3 must swing further than one at 0.5: near={near_shift}px, \
4982             far={far_shift}px"
4983        );
4984    }
4985
4986    #[test]
4987    fn perspective_zero_is_an_orthographic_tilt() {
4988        let ortho = render_with_camera(plane_at_depth(Some(2.0)), Some(shot_camera(24.0, 0.0)));
4989        let projected =
4990            render_with_camera(plane_at_depth(Some(2.0)), Some(shot_camera(24.0, 900.0)));
4991        assert_ne!(
4992            ortho, projected,
4993            "perspective 0 means no vanishing point at all, which is a different image from \
4994             one projected at 900px"
4995        );
4996    }
4997
4998    fn dof_camera(focus: f32, aperture: f32) -> PlaneCamera {
4999        PlaneCamera {
5000            pan_x: 0.0,
5001            pan_y: 0.0,
5002            zoom: 1.0,
5003            rotation: 0.0,
5004            origin_x: 200.0,
5005            origin_y: 200.0,
5006            focus,
5007            aperture,
5008            rotate_x: 0.0,
5009            rotate_y: 0.0,
5010            perspective: 0.0,
5011        }
5012    }
5013
5014    fn plane_at_depth(depth: Option<f32>) -> BoxNode {
5015        BoxNode {
5016            id: 0,
5017            kind: BoxKind::Container,
5018            css: CssStyle {
5019                position: Some(Position::Absolute),
5020                left: Some(CLP::Px(150.0)),
5021                top: Some(CLP::Px(150.0)),
5022                width: Some(CSize::Length(CLP::Px(100.0))),
5023                height: Some(CSize::Length(CLP::Px(100.0))),
5024                background: Some(Background::Color(CssColor::String("#ff0000".into()))),
5025                depth,
5026                ..Default::default()
5027            },
5028            children: vec![],
5029            intrinsic: None,
5030            source_path: None,
5031            window: None,
5032        }
5033    }
5034
5035    fn render_with_camera(node: BoxNode, camera: Option<PlaneCamera>) -> Vec<u8> {
5036        let mut root = root_node(400.0, 400.0, "#000000", vec![node]);
5037        root.assign_ids(0);
5038        let layout = run_layout(&root, (400.0, 400.0), &ConversionContext::default());
5039        let mut surface = skia_safe::surfaces::raster_n32_premul((400, 400)).unwrap();
5040        let mut frame = test_frame(400, 400);
5041        frame.camera = camera;
5042        paint_tree(surface.canvas(), &root, &layout, &frame, &NoopDispatcher);
5043        let info = skia_safe::ImageInfo::new(
5044            (400, 400),
5045            skia_safe::ColorType::RGBA8888,
5046            skia_safe::AlphaType::Unpremul,
5047            None,
5048        );
5049        let mut buf = vec![0u8; 400 * 400 * 4];
5050        surface.read_pixels(&info, &mut buf, 400 * 4, (0, 0));
5051        buf
5052    }
5053
5054    /// How many pixels along the plane's top edge are neither background nor
5055    /// full red — the width of the gradient a blur leaves behind.
5056    fn edge_softness(buf: &[u8]) -> usize {
5057        (120..190)
5058            .filter(|y| {
5059                let i = (y * 400 + 200) * 4;
5060                let (r, g) = (buf[i], buf[i + 1]);
5061                r > 12 && r < 240 && g < 60
5062            })
5063            .count()
5064    }
5065
5066    #[test]
5067    fn an_aperture_of_zero_renders_exactly_as_no_camera_at_all() {
5068        let sharp = render_with_camera(plane_at_depth(Some(2.0)), None);
5069        let zero_aperture =
5070            render_with_camera(plane_at_depth(Some(2.0)), Some(dof_camera(1.0, 0.0)));
5071        assert_eq!(
5072            sharp, zero_aperture,
5073            "aperture 0 must be byte-identical to no depth of field: every scenario written \
5074             before this one declares no aperture"
5075        );
5076    }
5077
5078    #[test]
5079    fn a_plane_on_the_focus_distance_stays_sharp() {
5080        let no_dof = render_with_camera(plane_at_depth(Some(2.0)), None);
5081        let focused = render_with_camera(plane_at_depth(Some(2.0)), Some(dof_camera(2.0, 8.0)));
5082        assert_eq!(
5083            no_dof, focused,
5084            "a plane sitting exactly on the focus distance is in focus whatever the aperture"
5085        );
5086    }
5087
5088    #[test]
5089    fn a_plane_away_from_the_focus_distance_is_blurred() {
5090        let sharp_softness = edge_softness(&render_with_camera(plane_at_depth(Some(3.0)), None));
5091        let blurred_softness = edge_softness(&render_with_camera(
5092            plane_at_depth(Some(3.0)),
5093            Some(dof_camera(1.0, 6.0)),
5094        ));
5095        assert!(
5096            blurred_softness > sharp_softness + 8,
5097            "a plane two depth units off focus must show a measurably soft edge: \
5098             sharp={sharp_softness}px, defocused={blurred_softness}px"
5099        );
5100    }
5101
5102    #[test]
5103    fn the_further_from_focus_the_softer_the_edge() {
5104        let near = edge_softness(&render_with_camera(
5105            plane_at_depth(Some(2.0)),
5106            Some(dof_camera(1.0, 6.0)),
5107        ));
5108        let far = edge_softness(&render_with_camera(
5109            plane_at_depth(Some(4.0)),
5110            Some(dof_camera(1.0, 6.0)),
5111        ));
5112        assert!(
5113            far > near,
5114            "softness must grow with distance from the focal plane: \
5115             1 unit off={near}px, 3 units off={far}px"
5116        );
5117    }
5118
5119    #[test]
5120    fn focus_is_symmetric_in_front_of_and_behind_the_focal_plane() {
5121        let in_front = edge_softness(&render_with_camera(
5122            plane_at_depth(Some(1.0)),
5123            Some(dof_camera(3.0, 6.0)),
5124        ));
5125        let behind = edge_softness(&render_with_camera(
5126            plane_at_depth(Some(5.0)),
5127            Some(dof_camera(3.0, 6.0)),
5128        ));
5129        assert_eq!(
5130            in_front, behind,
5131            "two units in front and two units behind defocus by the same amount"
5132        );
5133    }
5134
5135    #[test]
5136    fn a_node_with_no_declared_depth_sits_on_the_default_focal_plane() {
5137        let no_dof = render_with_camera(plane_at_depth(None), None);
5138        let with_dof = render_with_camera(plane_at_depth(None), Some(dof_camera(1.0, 10.0)));
5139        assert_eq!(
5140            no_dof, with_dof,
5141            "depth defaults to 1.0 and focus defaults to 1.0, so a scenario that declares \
5142             neither is focused on everything it has"
5143        );
5144    }
5145
5146    #[test]
5147    fn depth_of_field_sigma_grows_linearly_with_the_aperture() {
5148        let cam = dof_camera(1.0, 4.0);
5149        assert_eq!(depth_of_field_sigma(&cam, 1.0), 0.0);
5150        assert_eq!(depth_of_field_sigma(&cam, 2.0), 4.0);
5151        assert_eq!(depth_of_field_sigma(&cam, 3.0), 8.0);
5152        assert_eq!(depth_of_field_sigma(&dof_camera(1.0, 0.0), 9.0), 0.0);
5153    }
5154
5155    fn card_with_shadow(overflow_hidden: bool) -> BoxNode {
5156        let mut css = CssStyle {
5157            position: Some(Position::Absolute),
5158            left: Some(CLP::Px(50.0)),
5159            top: Some(CLP::Px(50.0)),
5160            width: Some(CSize::Length(CLP::Px(100.0))),
5161            height: Some(CSize::Length(CLP::Px(100.0))),
5162            background: Some(Background::Color(CssColor::String("#ffffff".into()))),
5163            box_shadow: Some(vec![BoxShadow {
5164                offset_x: Length::Px(0.0),
5165                offset_y: Length::Px(0.0),
5166                blur: None,
5167                spread: Some(Length::Px(20.0)),
5168                color: Some(CssColor::String("#ff0000".into())),
5169                inset: None,
5170            }]),
5171            ..Default::default()
5172        };
5173        if overflow_hidden {
5174            css.overflow = Some(Overflow::Hidden);
5175        }
5176        BoxNode {
5177            id: 0,
5178            kind: BoxKind::Container,
5179            css,
5180            children: vec![],
5181            intrinsic: None,
5182            source_path: None,
5183            window: None,
5184        }
5185    }
5186
5187    #[test]
5188    fn overflow_hidden_does_not_clip_own_outset_box_shadow() {
5189        let without = {
5190            let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(false)]);
5191            render_pixels(&mut root, 200, 200)
5192        };
5193        let with_hidden = {
5194            let mut root = root_node(200.0, 200.0, "#000000", vec![card_with_shadow(true)]);
5195            render_pixels(&mut root, 200, 200)
5196        };
5197
5198        let probe = |buf: &[u8], x: usize, y: usize| -> (u8, u8, u8) {
5199            let i = (y * 200 + x) * 4;
5200            (buf[i], buf[i + 1], buf[i + 2])
5201        };
5202
5203        let above_plain = probe(&without, 100, 45);
5204        let below_plain = probe(&without, 100, 155);
5205        assert!(
5206            above_plain.0 > 200 && above_plain.1 < 50,
5207            "sanity: shadow halo must be visible without overflow, got {above_plain:?}"
5208        );
5209        assert!(
5210            below_plain.0 > 200 && below_plain.1 < 50,
5211            "sanity: shadow halo must be visible without overflow, got {below_plain:?}"
5212        );
5213
5214        let above_hidden = probe(&with_hidden, 100, 45);
5215        let below_hidden = probe(&with_hidden, 100, 155);
5216        assert!(
5217            above_hidden.0 > 200 && above_hidden.1 < 50,
5218            "overflow:hidden must not erase the node's own outset shadow, got {above_hidden:?}"
5219        );
5220        assert!(
5221            below_hidden.0 > 200 && below_hidden.1 < 50,
5222            "overflow:hidden must not erase the node's own outset shadow, got {below_hidden:?}"
5223        );
5224
5225        let halo_count_plain = count_red_in(&without, 200, 25, 25, 175, 175);
5226        let halo_count_hidden = count_red_in(&with_hidden, 200, 25, 25, 175, 175);
5227        assert_eq!(
5228            halo_count_plain, halo_count_hidden,
5229            "halo pixel count must be identical with/without overflow:hidden \
5230             (plain={halo_count_plain}, hidden={halo_count_hidden})"
5231        );
5232    }
5233
5234    #[test]
5235    fn an_outset_box_shadow_does_not_paint_under_a_transparent_box() {
5236        let transparent_card = BoxNode {
5237            id: 0,
5238            kind: BoxKind::Container,
5239            css: CssStyle {
5240                position: Some(Position::Absolute),
5241                left: Some(CLP::Px(50.0)),
5242                top: Some(CLP::Px(50.0)),
5243                width: Some(CSize::Length(CLP::Px(100.0))),
5244                height: Some(CSize::Length(CLP::Px(100.0))),
5245                box_shadow: Some(vec![BoxShadow {
5246                    offset_x: Length::Px(0.0),
5247                    offset_y: Length::Px(0.0),
5248                    blur: None,
5249                    spread: Some(Length::Px(20.0)),
5250                    color: Some(CssColor::String("#ff0000".into())),
5251                    inset: None,
5252                }]),
5253                ..Default::default()
5254            },
5255            children: vec![],
5256            intrinsic: None,
5257            source_path: None,
5258            window: None,
5259        };
5260        let mut root = root_node(200.0, 200.0, "#000000", vec![transparent_card]);
5261        let buf = render_pixels(&mut root, 200, 200);
5262
5263        let probe = |x: usize, y: usize| -> (u8, u8, u8) {
5264            let i = (y * 200 + x) * 4;
5265            (buf[i], buf[i + 1], buf[i + 2])
5266        };
5267
5268        let centre = probe(100, 100);
5269        assert!(
5270            centre.0 < 50 && centre.1 < 50 && centre.2 < 50,
5271            "a box with no background of its own must leave its border-box area showing the \
5272             root's black background, not the shadow bleeding through in red — the shadow is \
5273             clipped to outside the border edge in CSS regardless of what (if anything) the \
5274             box itself paints — got {centre:?}"
5275        );
5276
5277        let halo = probe(100, 45);
5278        assert!(
5279            halo.0 > 200 && halo.1 < 50,
5280            "the shadow halo outside the box's own footprint must still be painted, got \
5281             {halo:?}"
5282        );
5283    }
5284
5285    #[test]
5286    fn filter_layer_bounds_do_not_clip_blur_bleed() {
5287        let n = BoxNode {
5288            id: 0,
5289            kind: BoxKind::Container,
5290            css: CssStyle {
5291                position: Some(Position::Absolute),
5292                left: Some(CLP::Px(120.0)),
5293                top: Some(CLP::Px(120.0)),
5294                width: Some(CSize::Length(CLP::Px(60.0))),
5295                height: Some(CSize::Length(CLP::Px(60.0))),
5296                background: Some(Background::Color(CssColor::String("#ff0000".into()))),
5297                opacity: Some(0.999),
5298                filter: Some(vec![FilterFn::Blur {
5299                    radius: Some(Length::Px(24.0)),
5300                    radius_x: None,
5301                    radius_y: None,
5302                }]),
5303                ..Default::default()
5304            },
5305            children: vec![],
5306            intrinsic: None,
5307            source_path: None,
5308            window: None,
5309        };
5310        let mut root = root_node(300.0, 300.0, "#000000", vec![n]);
5311        let buf = render_pixels(&mut root, 300, 300);
5312
5313        let probe = |x: usize, y: usize| -> u8 {
5314            let i = (y * 300 + x) * 4;
5315            buf[i]
5316        };
5317        let bled = probe(112, 150);
5318        assert!(
5319            bled > 15,
5320            "blur must bleed past the box edge under bounded SaveLayerRec, got r={bled}"
5321        );
5322        let far = probe(20, 20);
5323        assert_eq!(far, 0, "far corner must stay untouched, got r={far}");
5324    }
5325
5326    fn render_pixels_at(root: &mut BoxNode, w: u32, h: u32, time: f64) -> Vec<u8> {
5327        root.assign_ids(0);
5328        let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
5329        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
5330        let frame = PaintFrame {
5331            light: Default::default(),
5332            time,
5333            ..test_frame(w, h)
5334        };
5335        paint_tree(surface.canvas(), root, &layout, &frame, &NoopDispatcher);
5336        let info = skia_safe::ImageInfo::new(
5337            (w as i32, h as i32),
5338            skia_safe::ColorType::RGBA8888,
5339            skia_safe::AlphaType::Unpremul,
5340            None,
5341        );
5342        let mut buf = vec![0u8; (w * h * 4) as usize];
5343        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
5344        buf
5345    }
5346
5347    fn white_square(animation: Vec<AnimationEffect>) -> BoxNode {
5348        BoxNode {
5349            id: 0,
5350            kind: BoxKind::Container,
5351            css: CssStyle {
5352                position: Some(Position::Absolute),
5353                left: Some(CLP::Px(100.0)),
5354                top: Some(CLP::Px(100.0)),
5355                width: Some(CSize::Length(CLP::Px(120.0))),
5356                height: Some(CSize::Length(CLP::Px(120.0))),
5357                background: Some(Background::Color(CssColor::String("#ffffff".into()))),
5358                animation,
5359                ..Default::default()
5360            },
5361            children: vec![],
5362            intrinsic: None,
5363            source_path: None,
5364            window: None,
5365        }
5366    }
5367
5368    fn probe(buf: &[u8], w: u32, x: u32, y: u32) -> (u8, u8, u8) {
5369        let i = ((y * w + x) * 4) as usize;
5370        (buf[i], buf[i + 1], buf[i + 2])
5371    }
5372
5373    #[test]
5374    fn chromatic_aberration_shows_a_red_fringe_on_one_edge_and_a_cyan_fringe_on_the_other() {
5375        let cfg = ChromaticAberrationConfig {
5376            delay: 0.0,
5377            duration: 0.6,
5378            amount: 10.0,
5379            easing: EasingType::Linear,
5380        };
5381        let mut root = root_node(
5382            400.0,
5383            400.0,
5384            "#000000",
5385            vec![white_square(vec![AnimationEffect::ChromaticAberration(
5386                cfg,
5387            )])],
5388        );
5389        let buf = render_pixels_at(&mut root, 400, 400, 0.3);
5390
5391        let left_edge = probe(&buf, 400, 100, 160);
5392        let right_edge = probe(&buf, 400, 220, 160);
5393        assert!(
5394            left_edge.0 > 200 && left_edge.1 < 50 && left_edge.2 < 50,
5395            "expected a red-leaning fringe on the left edge mid-flight, got {left_edge:?}"
5396        );
5397        assert!(
5398            right_edge.0 < 50 && right_edge.1 > 200 && right_edge.2 > 200,
5399            "expected a cyan-leaning fringe on the right edge mid-flight, got {right_edge:?}"
5400        );
5401    }
5402
5403    #[test]
5404    fn chromatic_aberration_is_gone_by_the_end_of_the_animation() {
5405        let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5406        let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
5407
5408        let cfg = ChromaticAberrationConfig {
5409            delay: 0.0,
5410            duration: 0.6,
5411            amount: 10.0,
5412            easing: EasingType::Linear,
5413        };
5414        let mut animated = root_node(
5415            400.0,
5416            400.0,
5417            "#000000",
5418            vec![white_square(vec![AnimationEffect::ChromaticAberration(
5419                cfg,
5420            )])],
5421        );
5422        let at_end = render_pixels_at(&mut animated, 400, 400, 0.6);
5423
5424        assert_eq!(
5425            baseline, at_end,
5426            "the node must be pixel-identical to one with no effect at all once the \
5427             animation's duration has elapsed — no permanent fringe left behind"
5428        );
5429    }
5430
5431    #[test]
5432    fn a_node_without_the_effect_is_untouched() {
5433        let mut root_a = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5434        let a = render_pixels_at(&mut root_a, 400, 400, 0.0);
5435        let mut root_b = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5436        let b = render_pixels_at(&mut root_b, 400, 400, 5.0);
5437        assert_eq!(
5438            a, b,
5439            "a node with no chromatic_aberration effect must not vary with time"
5440        );
5441
5442        let left_edge = probe(&a, 400, 100, 160);
5443        let right_edge = probe(&a, 400, 219, 160);
5444        assert_eq!(
5445            left_edge,
5446            (255, 255, 255),
5447            "no effect means no fringe on the left edge either, got {left_edge:?}"
5448        );
5449        assert_eq!(
5450            right_edge,
5451            (255, 255, 255),
5452            "no effect means no fringe on the right edge either, got {right_edge:?}"
5453        );
5454    }
5455
5456    fn shatter_cfg(mode: ShatterMode) -> ShatterConfig {
5457        ShatterConfig {
5458            delay: 0.2,
5459            duration: 0.6,
5460            mode,
5461            pieces: 16,
5462            seed: 42,
5463            origin: ShatterOrigin::default(),
5464            spread: 1.0,
5465            spin: 90.0,
5466            depth: 0.4,
5467            fade: true,
5468        }
5469    }
5470
5471    fn any_ink_in_band(buf: &[u8], w: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> bool {
5472        for y in y0..y1 {
5473            for x in x0..x1 {
5474                let i = ((y * w + x) * 4) as usize;
5475                if buf[i] > 20 || buf[i + 1] > 20 || buf[i + 2] > 20 {
5476                    return true;
5477                }
5478            }
5479        }
5480        false
5481    }
5482
5483    fn burst_cfg() -> BurstConfig {
5484        BurstConfig {
5485            delay: 0.2,
5486            duration: 0.6,
5487            count: 4,
5488            length: 60.0,
5489            gap: 14.0,
5490            width: 6.0,
5491            color: "#ff0000".to_string(),
5492            seed: 5,
5493            jitter: 0.0,
5494        }
5495    }
5496
5497    fn burst_node(cfg: BurstConfig) -> BoxNode {
5498        white_square(vec![AnimationEffect::Burst(cfg)])
5499    }
5500
5501    #[test]
5502    fn burst_is_pixel_identical_to_no_effect_before_delay_and_at_or_after_the_end() {
5503        let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5504        let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
5505
5506        let cfg = burst_cfg();
5507        let mut before = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]);
5508        assert_eq!(
5509            baseline,
5510            render_pixels_at(&mut before, 400, 400, 0.0),
5511            "before delay a burst must contribute nothing at all — pixel-identical to a node \
5512             with no burst in its animation list"
5513        );
5514
5515        let mut at_end = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]);
5516        assert_eq!(
5517            baseline,
5518            render_pixels_at(&mut at_end, 400, 400, 0.8),
5519            "at delay + duration the window is already closed — no stroke may survive the last \
5520             frame and bleed into the next scene"
5521        );
5522
5523        let mut after = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg)]);
5524        assert_eq!(
5525            baseline,
5526            render_pixels_at(&mut after, 400, 400, 5.0),
5527            "long after the window the node is plain again"
5528        );
5529    }
5530
5531    #[test]
5532    fn burst_paints_ink_outside_the_nodes_own_box_at_full_extension() {
5533        let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5534        let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
5535        assert!(
5536            !any_ink_in_band(&baseline, 400, 226, 155, 290, 166),
5537            "the band to the right of the square must be empty without a burst"
5538        );
5539
5540        let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]);
5541        let mid = render_pixels_at(&mut bursting, 400, 400, 0.5);
5542        assert!(
5543            any_ink_in_band(&mid, 400, 226, 155, 290, 166),
5544            "at the halfway point the head is at the far end of its track — a stroke must be \
5545             painted well outside the node's own box"
5546        );
5547    }
5548
5549    #[test]
5550    fn burst_leaves_the_node_itself_untouched() {
5551        let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5552        let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
5553        let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]);
5554        let mid = render_pixels_at(&mut bursting, 400, 400, 0.5);
5555
5556        for y in 100..220 {
5557            for x in 100..220 {
5558                let i = ((y * 400 + x) * 4) as usize;
5559                assert_eq!(
5560                    (baseline[i], baseline[i + 1], baseline[i + 2]),
5561                    (mid[i], mid[i + 1], mid[i + 2]),
5562                    "gap keeps every stroke off the box — pixel ({x}, {y}) inside the node \
5563                     changed"
5564                );
5565            }
5566        }
5567    }
5568
5569    #[test]
5570    fn burst_strokes_sit_on_the_rays_count_asks_for_and_nowhere_else() {
5571        let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]);
5572        let mid = render_pixels_at(&mut bursting, 400, 400, 0.5);
5573
5574        assert!(
5575            any_ink_in_band(&mid, 400, 240, 156, 260, 165),
5576            "count: 4 with no jitter puts a stroke straight to the right of the centre"
5577        );
5578        assert!(
5579            any_ink_in_band(&mid, 400, 156, 240, 165, 260),
5580            "count: 4 with no jitter puts a stroke straight below the centre"
5581        );
5582        assert!(
5583            !any_ink_in_band(&mid, 400, 230, 230, 272, 272),
5584            "count: 4 leaves the diagonals empty — the ring is not a halo"
5585        );
5586    }
5587
5588    #[test]
5589    fn burst_two_renders_of_the_same_instant_are_byte_identical() {
5590        let cfg = BurstConfig {
5591            jitter: 0.9,
5592            count: 24,
5593            ..burst_cfg()
5594        };
5595        let mut first = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]);
5596        let mut second = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg)]);
5597        assert_eq!(
5598            render_pixels_at(&mut first, 400, 400, 0.45),
5599            render_pixels_at(&mut second, 400, 400, 0.45),
5600            "every stroke's angle, length and phase comes from (seed, index) alone — the same \
5601             instant must render byte-identically for the parallel renderer"
5602        );
5603    }
5604
5605    #[test]
5606    fn shatter_out_is_pixel_identical_to_no_effect_before_delay_and_after_duration() {
5607        let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5608        let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
5609
5610        let cfg = shatter_cfg(ShatterMode::Out);
5611        let mut before = root_node(
5612            400.0,
5613            400.0,
5614            "#000000",
5615            vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])],
5616        );
5617        let before_delay = render_pixels_at(&mut before, 400, 400, 0.0);
5618        assert_eq!(
5619            baseline, before_delay,
5620            "before delay, mode: out must contribute nothing at all — pixel-identical to a \
5621             node with no shatter effect in its animation list"
5622        );
5623
5624        let mut after = root_node(
5625            400.0,
5626            400.0,
5627            "#000000",
5628            vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
5629        );
5630        let after_duration = render_pixels_at(&mut after, 400, 400, 5.0);
5631        assert_eq!(
5632            baseline, after_duration,
5633            "mode: out must short-circuit back to the plain node once delay + duration has \
5634             elapsed — no shards left hanging"
5635        );
5636    }
5637
5638    #[test]
5639    fn shatter_in_is_pixel_identical_to_no_effect_before_delay_and_after_duration() {
5640        let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5641        let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
5642
5643        let cfg = shatter_cfg(ShatterMode::In);
5644        let mut before = root_node(
5645            400.0,
5646            400.0,
5647            "#000000",
5648            vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])],
5649        );
5650        let before_delay = render_pixels_at(&mut before, 400, 400, 0.0);
5651        assert_eq!(
5652            baseline, before_delay,
5653            "mode: in must also render as the plain node before delay — the mirror still \
5654             short-circuits outside its own window"
5655        );
5656
5657        let mut after = root_node(
5658            400.0,
5659            400.0,
5660            "#000000",
5661            vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
5662        );
5663        let after_duration = render_pixels_at(&mut after, 400, 400, 5.0);
5664        assert_eq!(
5665            baseline, after_duration,
5666            "mode: in converges back to the plain node by delay + duration"
5667        );
5668    }
5669
5670    #[test]
5671    fn shatter_hold_freezes_past_the_window_instead_of_reverting() {
5672        let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5673        let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
5674
5675        let cfg = shatter_cfg(ShatterMode::Hold);
5676        let mut held = root_node(
5677            400.0,
5678            400.0,
5679            "#000000",
5680            vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
5681        );
5682        let long_after = render_pixels_at(&mut held, 400, 400, 5.0);
5683        assert_ne!(
5684            baseline, long_after,
5685            "mode: hold must never converge back to the plain node, unlike out/in — the \
5686             shards stay frozen in suspension"
5687        );
5688    }
5689
5690    #[test]
5691    fn shatter_two_renders_of_the_same_instant_are_byte_identical() {
5692        let cfg = shatter_cfg(ShatterMode::Out);
5693        let mut root_a = root_node(
5694            400.0,
5695            400.0,
5696            "#000000",
5697            vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])],
5698        );
5699        let a = render_pixels_at(&mut root_a, 400, 400, 0.5);
5700
5701        let mut root_b = root_node(
5702            400.0,
5703            400.0,
5704            "#000000",
5705            vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
5706        );
5707        let b = render_pixels_at(&mut root_b, 400, 400, 0.5);
5708
5709        assert_eq!(
5710            a, b,
5711            "seed + instant must fully determine every shard — two renders of the same frame \
5712             must be byte-identical"
5713        );
5714    }
5715
5716    #[test]
5717    fn shatter_paints_ink_outside_the_nodes_own_box_where_an_intact_node_does_not() {
5718        let band = (221u32, 100u32, 320u32, 220u32);
5719
5720        let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5721        let intact = render_pixels_at(&mut plain, 400, 400, 0.0);
5722        assert!(
5723            !any_ink_in_band(&intact, 400, band.0, band.1, band.2, band.3),
5724            "sanity: an intact node must not paint outside its own box"
5725        );
5726
5727        let cfg = shatter_cfg(ShatterMode::Out);
5728        let mut shattered = root_node(
5729            400.0,
5730            400.0,
5731            "#000000",
5732            vec![white_square(vec![AnimationEffect::Shatter(cfg)])],
5733        );
5734        let mid_flight = render_pixels_at(&mut shattered, 400, 400, 0.5);
5735        assert!(
5736            any_ink_in_band(&mid_flight, 400, band.0, band.1, band.2, band.3),
5737            "a shattered node mid-flight must have ink outside its own box — a shard has \
5738             physically left the node's rect"
5739        );
5740    }
5741
5742    #[test]
5743    #[ignore]
5744    fn shatter_per_frame_cost_vs_the_plain_node() {
5745        let frames = 200;
5746        let cfg = shatter_cfg(ShatterMode::Out);
5747
5748        let time_plain = || {
5749            let start = std::time::Instant::now();
5750            for i in 0..frames {
5751                let mut root = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
5752                let _ = render_pixels_at(&mut root, 400, 400, i as f64 / 30.0);
5753            }
5754            start.elapsed()
5755        };
5756        let time_shatter = |cfg: &ShatterConfig| {
5757            let start = std::time::Instant::now();
5758            for i in 0..frames {
5759                let mut root = root_node(
5760                    400.0,
5761                    400.0,
5762                    "#000000",
5763                    vec![white_square(vec![AnimationEffect::Shatter(cfg.clone())])],
5764                );
5765                let _ = render_pixels_at(&mut root, 400, 400, i as f64 / 30.0);
5766            }
5767            start.elapsed()
5768        };
5769
5770        time_plain();
5771        time_shatter(&cfg);
5772
5773        let plain_elapsed = time_plain().min(time_plain());
5774        let shatter_elapsed = time_shatter(&cfg).min(time_shatter(&cfg));
5775
5776        println!(
5777            "plain: {:?}/frame, shatter (16 pieces, 120x120 box): {:?}/frame, ratio {:.1}x",
5778            plain_elapsed / frames,
5779            shatter_elapsed / frames,
5780            shatter_elapsed.as_secs_f64() / plain_elapsed.as_secs_f64().max(1e-9)
5781        );
5782
5783        let heavy_cfg = ShatterConfig { pieces: 64, ..cfg };
5784        let heavy_elapsed = time_shatter(&heavy_cfg).min(time_shatter(&heavy_cfg));
5785        println!(
5786            "shatter (64 pieces, 120x120 box): {:?}/frame, ratio {:.1}x",
5787            heavy_elapsed / frames,
5788            heavy_elapsed.as_secs_f64() / plain_elapsed.as_secs_f64().max(1e-9)
5789        );
5790    }
5791
5792    #[test]
5793    fn clip_path_morph_interpolates_a_circles_radius_between_two_keyframes() {
5794        let morph_at = |progress: f32| ClipPath::Morph {
5795            from: Box::new(ClipPath::Circle {
5796                radius: CLP::Px(50.0),
5797                origin: None,
5798            }),
5799            to: Box::new(ClipPath::Circle {
5800                radius: CLP::Px(150.0),
5801                origin: None,
5802            }),
5803            via: Vec::new(),
5804            progress,
5805        };
5806
5807        assert!(
5808            is_red_at(Some(morph_at(0.0)), 230, 200),
5809            "progress 0: 30px from centre must be inside the `from` circle (r=50)"
5810        );
5811        assert!(
5812            !is_red_at(Some(morph_at(0.0)), 280, 200),
5813            "progress 0 must match the `from` shape exactly (r=50), not something in between"
5814        );
5815
5816        assert!(
5817            is_red_at(Some(morph_at(1.0)), 280, 200),
5818            "progress 1 must match the `to` shape's larger radius (r=150)"
5819        );
5820
5821        assert!(
5822            is_red_at(Some(morph_at(0.5)), 280, 200),
5823            "progress 0.5 interpolates the radius to 100 (halfway between 50 and 150); 80px \
5824             from centre must be inside"
5825        );
5826        assert!(
5827            !is_red_at(Some(morph_at(0.5)), 350, 200),
5828            "150px from centre must be outside the interpolated radius of 100"
5829        );
5830    }
5831
5832    #[test]
5833    fn clip_path_morph_supports_inset_and_ellipse_too() {
5834        let inset_morph_at = |progress: f32| ClipPath::Morph {
5835            from: Box::new(ClipPath::Inset {
5836                top: CLP::Px(0.0),
5837                right: CLP::Px(200.0),
5838                bottom: CLP::Px(0.0),
5839                left: CLP::Px(0.0),
5840                radius: None,
5841            }),
5842            to: Box::new(ClipPath::Inset {
5843                top: CLP::Px(0.0),
5844                right: CLP::Px(0.0),
5845                bottom: CLP::Px(0.0),
5846                left: CLP::Px(0.0),
5847                radius: None,
5848            }),
5849            via: Vec::new(),
5850            progress,
5851        };
5852        assert!(
5853            !is_red_at(Some(inset_morph_at(0.0)), 380, 200),
5854            "an inset morph at progress 0 must match the `from` shape (right:200 cuts x=380)"
5855        );
5856        assert!(
5857            is_red_at(Some(inset_morph_at(1.0)), 380, 200),
5858            "an inset morph at progress 1 must match the `to` shape (the full box, right:0)"
5859        );
5860
5861        let ellipse_morph_at = |progress: f32| ClipPath::Morph {
5862            from: Box::new(ClipPath::Ellipse {
5863                rx: CLP::Px(10.0),
5864                ry: CLP::Px(10.0),
5865                origin: None,
5866            }),
5867            to: Box::new(ClipPath::Ellipse {
5868                rx: CLP::Px(180.0),
5869                ry: CLP::Px(40.0),
5870                origin: None,
5871            }),
5872            via: Vec::new(),
5873            progress,
5874        };
5875        assert!(
5876            !is_red_at(Some(ellipse_morph_at(0.0)), 360, 200),
5877            "an ellipse morph at progress 0 must match the `from` shape (rx=10 excludes x=360)"
5878        );
5879        assert!(
5880            is_red_at(Some(ellipse_morph_at(1.0)), 360, 200),
5881            "an ellipse morph at progress 1 must match the `to` shape's rx=180"
5882        );
5883    }
5884
5885    #[test]
5886    fn clip_path_morph_interpolates_polygon_points_when_vertex_counts_match() {
5887        let big = ClipPath::Polygon {
5888            points: vec![
5889                (CLP::Px(0.0), CLP::Px(0.0)),
5890                (CLP::Px(400.0), CLP::Px(0.0)),
5891                (CLP::Px(400.0), CLP::Px(400.0)),
5892                (CLP::Px(0.0), CLP::Px(400.0)),
5893            ],
5894        };
5895        let small = ClipPath::Polygon {
5896            points: vec![
5897                (CLP::Px(150.0), CLP::Px(150.0)),
5898                (CLP::Px(250.0), CLP::Px(150.0)),
5899                (CLP::Px(250.0), CLP::Px(250.0)),
5900                (CLP::Px(150.0), CLP::Px(250.0)),
5901            ],
5902        };
5903        let morph = ClipPath::Morph {
5904            from: Box::new(big),
5905            to: Box::new(small),
5906            via: Vec::new(),
5907            progress: 1.0,
5908        };
5909        assert!(
5910            is_red_at(Some(morph.clone()), 200, 200),
5911            "the shrunk square's centre must stay inside at progress 1"
5912        );
5913        assert!(
5914            !is_red_at(Some(morph), 20, 20),
5915            "the corner must be outside the fully-morphed small square at progress 1"
5916        );
5917    }
5918
5919    #[test]
5920    fn clip_path_morph_between_mismatched_kinds_leaves_the_node_unclipped() {
5921        let mismatched = ClipPath::Morph {
5922            from: Box::new(ClipPath::Circle {
5923                radius: CLP::Px(50.0),
5924                origin: None,
5925            }),
5926            to: Box::new(ClipPath::Polygon {
5927                points: vec![
5928                    (CLP::Px(0.0), CLP::Px(0.0)),
5929                    (CLP::Px(400.0), CLP::Px(0.0)),
5930                    (CLP::Px(200.0), CLP::Px(400.0)),
5931                ],
5932            }),
5933            via: Vec::new(),
5934            progress: 0.5,
5935        };
5936        for (x, y) in [(20, 20), (200, 200), (380, 380)] {
5937            assert_eq!(
5938                is_red_at(Some(mismatched.clone()), x, y),
5939                is_red_at(None, x, y),
5940                "a clip-path morph between two different kinds cannot be interpolated — it \
5941                 must leave the node unclipped rather than silently snap between the two \
5942                 shapes, at ({x}, {y})"
5943            );
5944        }
5945    }
5946
5947    #[test]
5948    fn clip_path_morph_between_polygons_with_different_vertex_counts_leaves_the_node_unclipped() {
5949        let triangle = ClipPath::Polygon {
5950            points: vec![
5951                (CLP::Px(200.0), CLP::Px(0.0)),
5952                (CLP::Px(400.0), CLP::Px(400.0)),
5953                (CLP::Px(0.0), CLP::Px(400.0)),
5954            ],
5955        };
5956        let square = ClipPath::Polygon {
5957            points: vec![
5958                (CLP::Px(0.0), CLP::Px(0.0)),
5959                (CLP::Px(400.0), CLP::Px(0.0)),
5960                (CLP::Px(400.0), CLP::Px(400.0)),
5961                (CLP::Px(0.0), CLP::Px(400.0)),
5962            ],
5963        };
5964        let mismatched = ClipPath::Morph {
5965            from: Box::new(triangle),
5966            to: Box::new(square),
5967            via: Vec::new(),
5968            progress: 0.5,
5969        };
5970        for (x, y) in [(20, 20), (200, 200), (380, 380)] {
5971            assert_eq!(
5972                is_red_at(Some(mismatched.clone()), x, y),
5973                is_red_at(None, x, y),
5974                "a polygon vertex-count mismatch (3 vs 4) cannot be interpolated point-by-point \
5975                 — it must leave the node unclipped, at ({x}, {y})"
5976            );
5977        }
5978    }
5979
5980    #[test]
5981    fn animated_clip_path_progress_reaches_the_paint_pass() {
5982        use crate::css::apply_animated_props;
5983        use crate::engine::animator::AnimatedProperties;
5984
5985        let mut css = CssStyle {
5986            position: Some(Position::Absolute),
5987            left: Some(CLP::Px(0.0)),
5988            top: Some(CLP::Px(0.0)),
5989            width: Some(CSize::Length(CLP::Px(400.0))),
5990            height: Some(CSize::Length(CLP::Px(400.0))),
5991            background: Some(Background::Color(CssColor::String("#ff0000".into()))),
5992            clip_path: Some(ClipPath::Morph {
5993                from: Box::new(ClipPath::Circle {
5994                    radius: CLP::Px(50.0),
5995                    origin: None,
5996                }),
5997                to: Box::new(ClipPath::Circle {
5998                    radius: CLP::Px(150.0),
5999                    origin: None,
6000                }),
6001                via: Vec::new(),
6002                progress: 0.0,
6003            }),
6004            ..Default::default()
6005        };
6006        apply_animated_props(
6007            &mut css,
6008            &AnimatedProperties {
6009                clip_path_progress: 1.0,
6010                ..AnimatedProperties::default()
6011            },
6012        );
6013
6014        let node = BoxNode {
6015            id: 0,
6016            kind: BoxKind::Container,
6017            css,
6018            children: vec![],
6019            intrinsic: None,
6020            source_path: None,
6021            window: None,
6022        };
6023        let mut root = root_node(400.0, 400.0, "#000000", vec![node]);
6024        let buf = render_pixels(&mut root, 400, 400);
6025
6026        let inside_the_grown_circle = probe(&buf, 400, 280, 200);
6027        assert_eq!(
6028            inside_the_grown_circle,
6029            (255, 0, 0),
6030            "clip_path_progress written by an animation must reach the morph's `progress` \
6031             field — got {inside_the_grown_circle:?}, expected the fully-swept 150px `to` \
6032             circle to cover this point (80px from centre)"
6033        );
6034    }
6035}
6036
6037#[cfg(test)]
6038mod tests {
6039    use super::*;
6040
6041    #[test]
6042    fn parse_hex_3_and_6() {
6043        let c = parse_color_string("#fff").unwrap();
6044        assert_eq!(c, SColor::from_argb(255, 255, 255, 255));
6045        let c = parse_color_string("#102030").unwrap();
6046        assert_eq!(c, SColor::from_argb(255, 0x10, 0x20, 0x30));
6047    }
6048
6049    #[test]
6050    fn parse_hex_with_alpha() {
6051        let c = parse_color_string("#80ffffff").unwrap();
6052        assert_eq!(c.a(), 0xff);
6053    }
6054
6055    #[test]
6056    fn parse_rgb_string() {
6057        let c = parse_color_string("rgb(10, 20, 30)").unwrap();
6058        assert_eq!(c, SColor::from_argb(255, 10, 20, 30));
6059    }
6060
6061    #[test]
6062    fn parse_rgba_string() {
6063        let c = parse_color_string("rgba(10, 20, 30, 0.5)").unwrap();
6064        assert_eq!(c.r(), 10);
6065        assert_eq!(c.a(), 128);
6066    }
6067
6068    #[test]
6069    fn parse_named_colors() {
6070        assert_eq!(parse_color_string("red").unwrap(), SColor::RED);
6071        assert_eq!(
6072            parse_color_string("transparent").unwrap(),
6073            SColor::TRANSPARENT
6074        );
6075    }
6076
6077    #[test]
6078    fn parse_white_forms_all_agree() {
6079        let white = SColor::from_argb(255, 255, 255, 255);
6080        assert_eq!(parse_color_string("#fff").unwrap(), white);
6081        assert_eq!(parse_color_string("#FFF").unwrap(), white);
6082        assert_eq!(parse_color_string("white").unwrap(), white);
6083        assert_eq!(parse_color_string("WHITE").unwrap(), white);
6084        assert_eq!(parse_color_string("rgb(255,255,255)").unwrap(), white);
6085        assert_eq!(parse_color_string("rgb(255, 255, 255)").unwrap(), white);
6086    }
6087
6088    #[test]
6089    fn parse_color_string_supports_extended_named_set() {
6090        assert!(parse_color_string("rebeccapurple").is_some());
6091        assert!(parse_color_string("cornflowerblue").is_some());
6092        assert!(parse_color_string("dodgerblue").is_some());
6093    }
6094
6095    #[test]
6096    fn parse_color_string_supports_hsl() {
6097        assert_eq!(
6098            parse_color_string("hsl(0, 100%, 50%)").unwrap(),
6099            SColor::from_argb(255, 255, 0, 0)
6100        );
6101    }
6102
6103    #[test]
6104    fn parse_color_unresolvable_string_is_not_black() {
6105        let c = parse_color(&Color::String("not-a-color".to_string()));
6106        assert_ne!(c, SColor::BLACK);
6107        assert_eq!(c, SColor::from_argb(255, 255, 0, 255));
6108    }
6109}
6110
6111#[cfg(test)]
6112mod animated_transform_tests {
6113    use super::*;
6114    use crate::css::units::LengthPercentage as CLP;
6115
6116    fn layout(w: f32, h: f32) -> BoxLayout {
6117        BoxLayout {
6118            x: 0.0,
6119            y: 0.0,
6120            width: w,
6121            height: h,
6122            ..Default::default()
6123        }
6124    }
6125
6126    #[test]
6127    fn no_transform_is_identity_with_full_opacity() {
6128        let css = CssStyle::default();
6129        let (tx, ty, scale, rotation, opacity) =
6130            animated_transform(&css, &layout(100.0, 100.0), (1920.0, 1080.0));
6131        assert_eq!(
6132            (tx, ty, scale, rotation, opacity),
6133            (0.0, 0.0, 1.0, 0.0, 1.0)
6134        );
6135    }
6136
6137    #[test]
6138    fn single_translate_and_opacity_roundtrip() {
6139        let css = CssStyle {
6140            transform: Some(vec![TransformFn::Translate {
6141                x: CLP::Px(42.0),
6142                y: CLP::Px(-7.0),
6143            }]),
6144            opacity: Some(0.5),
6145            ..Default::default()
6146        };
6147        let (tx, ty, _scale, _rotation, opacity) =
6148            animated_transform(&css, &layout(100.0, 100.0), (1920.0, 1080.0));
6149        assert_eq!(tx, 42.0);
6150        assert_eq!(ty, -7.0);
6151        assert_eq!(opacity, 0.5);
6152    }
6153
6154    #[test]
6155    fn uniform_scale_and_rotation() {
6156        let css = CssStyle {
6157            transform: Some(vec![
6158                TransformFn::Scale { x: 2.0, y: 2.0 },
6159                TransformFn::Rotate { deg: 30.0 },
6160            ]),
6161            ..Default::default()
6162        };
6163        let (_tx, _ty, scale, rotation, _opacity) =
6164            animated_transform(&css, &layout(100.0, 100.0), (1920.0, 1080.0));
6165        assert_eq!(scale, 2.0);
6166        assert_eq!(rotation, 30.0);
6167    }
6168
6169    #[test]
6170    fn an_orbiting_node_at_two_different_frames_reports_two_different_positions() {
6171        let orbit = |angle_deg: f32| {
6172            let mut css = CssStyle::default();
6173            let (s, c) = angle_deg.to_radians().sin_cos();
6174            css.transform = Some(vec![TransformFn::Translate {
6175                x: CLP::Px(c * 100.0),
6176                y: CLP::Px(s * 100.0),
6177            }]);
6178            css
6179        };
6180        let l = layout(10.0, 10.0);
6181        let (tx0, ty0, ..) = animated_transform(&orbit(0.0), &l, (1920.0, 1080.0));
6182        let (tx1, ty1, ..) = animated_transform(&orbit(90.0), &l, (1920.0, 1080.0));
6183        assert!((tx0 - tx1).abs() > 1.0 || (ty0 - ty1).abs() > 1.0);
6184    }
6185}
6186
6187#[cfg(test)]
6188mod layout_surface_tests {
6189    use super::*;
6190
6191    use crate::css::style::{
6192        Background, Color as CssColor, CssStyle, Display, FlexDirection, GridTrack, LayoutSurface,
6193        Size as CSize, SurfaceAngle,
6194    };
6195    use crate::css::taffy_bridge::ConversionContext;
6196    use crate::css::units::LengthPercentage as CLP;
6197    use crate::engine::box_tree::{BoxKind, BoxNode};
6198    use crate::engine::layout_pass::run_layout;
6199
6200    fn test_frame(w: u32, h: u32) -> PaintFrame {
6201        PaintFrame {
6202            light: Default::default(),
6203            time: 0.0,
6204            scenario_time: 0.0,
6205            frame_index: 0,
6206            fps: 30,
6207            video_width: w,
6208            video_height: h,
6209            scene_duration: 1.0,
6210            camera: None,
6211        }
6212    }
6213
6214    fn render_pixels(root: &mut BoxNode, w: u32, h: u32, time: f64) -> Vec<u8> {
6215        root.assign_ids(0);
6216        let layout = run_layout(root, (w as f32, h as f32), &ConversionContext::default());
6217        let mut surface = skia_safe::surfaces::raster_n32_premul((w as i32, h as i32)).unwrap();
6218        let mut frame = test_frame(w, h);
6219        frame.time = time;
6220        paint_tree(surface.canvas(), root, &layout, &frame, &NoopDispatcher);
6221        let info = skia_safe::ImageInfo::new(
6222            (w as i32, h as i32),
6223            skia_safe::ColorType::RGBA8888,
6224            skia_safe::AlphaType::Unpremul,
6225            None,
6226        );
6227        let mut buf = vec![0u8; (w * h * 4) as usize];
6228        surface.read_pixels(&info, &mut buf, (w * 4) as usize, (0, 0));
6229        buf
6230    }
6231
6232    fn colored_cell(color: &str) -> BoxNode {
6233        BoxNode {
6234            id: 0,
6235            kind: BoxKind::Container,
6236            css: CssStyle {
6237                background: Some(Background::Color(CssColor::String(color.to_string()))),
6238                ..Default::default()
6239            },
6240            children: vec![],
6241            intrinsic: None,
6242            source_path: None,
6243            window: None,
6244        }
6245    }
6246
6247    fn three_column_grid(w: f32, h: f32, layout_surface: Option<LayoutSurface>) -> BoxNode {
6248        BoxNode {
6249            id: 0,
6250            kind: BoxKind::Container,
6251            css: CssStyle {
6252                display: Some(Display::Grid),
6253                width: Some(CSize::Length(CLP::Px(w))),
6254                height: Some(CSize::Length(CLP::Px(h))),
6255                grid_template_columns: Some(vec![
6256                    GridTrack::Fr(1.0),
6257                    GridTrack::Fr(1.0),
6258                    GridTrack::Fr(1.0),
6259                ]),
6260                layout_surface,
6261                ..Default::default()
6262            },
6263            children: vec![
6264                colored_cell("#ff0000"),
6265                colored_cell("#00ff00"),
6266                colored_cell("#0000ff"),
6267            ],
6268            intrinsic: None,
6269            source_path: None,
6270            window: None,
6271        }
6272    }
6273
6274    fn n_column_grid(
6275        w: f32,
6276        h: f32,
6277        n: usize,
6278        first_color: &str,
6279        layout_surface: Option<LayoutSurface>,
6280    ) -> BoxNode {
6281        let mut children = vec![colored_cell(first_color)];
6282        for _ in 1..n {
6283            children.push(colored_cell("#333333"));
6284        }
6285        BoxNode {
6286            id: 0,
6287            kind: BoxKind::Container,
6288            css: CssStyle {
6289                display: Some(Display::Grid),
6290                width: Some(CSize::Length(CLP::Px(w))),
6291                height: Some(CSize::Length(CLP::Px(h))),
6292                grid_template_columns: Some(vec![GridTrack::Fr(1.0); n]),
6293                layout_surface,
6294                ..Default::default()
6295            },
6296            children,
6297            intrinsic: None,
6298            source_path: None,
6299            window: None,
6300        }
6301    }
6302
6303    fn root_with(child: BoxNode, w: f32, h: f32) -> BoxNode {
6304        BoxNode {
6305            id: 0,
6306            kind: BoxKind::Container,
6307            css: CssStyle {
6308                display: Some(Display::Flex),
6309                flex_direction: Some(FlexDirection::Column),
6310                width: Some(CSize::Length(CLP::Px(w))),
6311                height: Some(CSize::Length(CLP::Px(h))),
6312                background: Some(Background::Color(CssColor::String("#000000".into()))),
6313                ..Default::default()
6314            },
6315            children: vec![child],
6316            intrinsic: None,
6317            source_path: None,
6318            window: None,
6319        }
6320    }
6321
6322    fn channel_extent(
6323        buf: &[u8],
6324        w: u32,
6325        h: u32,
6326        is_match: impl Fn(u8, u8, u8) -> bool,
6327    ) -> Option<(usize, usize)> {
6328        let mut lo = usize::MAX;
6329        let mut hi = 0usize;
6330        let mut found = false;
6331        for y in 0..h {
6332            for x in 0..w {
6333                let i = ((y * w + x) * 4) as usize;
6334                if is_match(buf[i], buf[i + 1], buf[i + 2]) {
6335                    lo = lo.min(x as usize);
6336                    hi = hi.max(x as usize);
6337                    found = true;
6338                }
6339            }
6340        }
6341        found.then_some((lo, hi))
6342    }
6343
6344    fn is_red(r: u8, g: u8, b: u8) -> bool {
6345        r > 150 && g < 80 && b < 80
6346    }
6347    fn is_green(r: u8, g: u8, b: u8) -> bool {
6348        g > 150 && r < 80 && b < 80
6349    }
6350    fn is_blue(r: u8, g: u8, b: u8) -> bool {
6351        b > 150 && r < 80 && g < 80
6352    }
6353
6354    fn sphere(radius: f32, arc_x: f32, arc_y: f32, perspective: Option<f32>) -> LayoutSurface {
6355        LayoutSurface::Sphere {
6356            radius,
6357            arc_x,
6358            arc_y,
6359            perspective,
6360            rotate_x: None,
6361            rotate_y: None,
6362        }
6363    }
6364
6365    #[test]
6366    fn no_layout_surface_leaves_a_flat_grid_byte_identical() {
6367        let mut a = root_with(three_column_grid(900.0, 300.0, None), 900.0, 300.0);
6368        let mut b = root_with(three_column_grid(900.0, 300.0, None), 900.0, 300.0);
6369        let out_a = render_pixels(&mut a, 900, 300, 0.0);
6370        let out_b = render_pixels(&mut b, 900, 300, 0.0);
6371        assert_eq!(
6372            out_a, out_b,
6373            "a container with no layout-surface must render the exact same bytes on every run \
6374             (no feature-flag leakage into the default path)"
6375        );
6376
6377        let widths: Vec<usize> = [is_red, is_green, is_blue]
6378            .iter()
6379            .map(|f| {
6380                let (lo, hi) = channel_extent(&out_a, 900, 300, *f).expect("cell must be visible");
6381                hi - lo + 1
6382            })
6383            .collect();
6384        assert_eq!(
6385            widths[0], widths[1],
6386            "flat grid: every column must be exactly as wide as the others"
6387        );
6388        assert_eq!(
6389            widths[1], widths[2],
6390            "flat grid: every column must be exactly as wide as the others"
6391        );
6392    }
6393
6394    #[test]
6395    fn a_sphere_surface_makes_the_side_columns_narrower_than_the_centre_one() {
6396        let mut flat = root_with(three_column_grid(900.0, 300.0, None), 900.0, 300.0);
6397        let mut curved = root_with(
6398            three_column_grid(900.0, 300.0, Some(sphere(700.0, 140.0, 0.0, Some(1400.0)))),
6399            900.0,
6400            300.0,
6401        );
6402        let flat_out = render_pixels(&mut flat, 900, 300, 0.0);
6403        let curved_out = render_pixels(&mut curved, 900, 300, 0.0);
6404
6405        assert_ne!(
6406            flat_out, curved_out,
6407            "declaring a layout-surface on the container must change the rendered frame"
6408        );
6409
6410        let (red_lo, red_hi) =
6411            channel_extent(&curved_out, 900, 300, is_red).expect("left column must be visible");
6412        let (green_lo, green_hi) =
6413            channel_extent(&curved_out, 900, 300, is_green).expect("centre column must be visible");
6414        let red_w = red_hi - red_lo + 1;
6415        let green_w = green_hi - green_lo + 1;
6416
6417        assert!(
6418            red_w < green_w,
6419            "a column near the arc's edge must foreshorten more than the centre column: \
6420             edge={red_w}px, centre={green_w}px"
6421        );
6422    }
6423
6424    #[test]
6425    fn layout_surface_child_delta_is_zero_at_the_centre_of_the_arc() {
6426        let (dx, dy, dz) = layout_surface_child_delta(
6427            SurfaceKind::Sphere,
6428            1000.0,
6429            120.0,
6430            60.0,
6431            0.0,
6432            0.0,
6433            400.0,
6434            200.0,
6435        );
6436        assert_eq!((dx, dy, dz), (0.0, 0.0, 0.0));
6437    }
6438
6439    #[test]
6440    fn sphere_delta_recedes_in_z_away_from_the_centre() {
6441        let centre = layout_surface_child_delta(
6442            SurfaceKind::Sphere,
6443            1000.0,
6444            140.0,
6445            0.0,
6446            0.0,
6447            0.0,
6448            400.0,
6449            200.0,
6450        );
6451        let mid = layout_surface_child_delta(
6452            SurfaceKind::Sphere,
6453            1000.0,
6454            140.0,
6455            0.0,
6456            0.5,
6457            0.0,
6458            400.0,
6459            200.0,
6460        );
6461        let edge = layout_surface_child_delta(
6462            SurfaceKind::Sphere,
6463            1000.0,
6464            140.0,
6465            0.0,
6466            1.0,
6467            0.0,
6468            400.0,
6469            200.0,
6470        );
6471        assert_eq!(
6472            centre.2, 0.0,
6473            "the centre of the arc sits at the reference depth"
6474        );
6475        assert!(
6476            mid.2 < 0.0 && edge.2 < mid.2,
6477            "depth must recede monotonically away from the centre: mid={:?} edge={:?}",
6478            mid.2,
6479            edge.2
6480        );
6481    }
6482
6483    #[test]
6484    fn adjacent_cell_gaps_shrink_towards_the_edge_of_the_arc() {
6485        let half_w = 450.0;
6486        let x_at = |nu: f32| {
6487            let (dx, _, _) = layout_surface_child_delta(
6488                SurfaceKind::Sphere,
6489                700.0,
6490                150.0,
6491                0.0,
6492                nu,
6493                0.0,
6494                half_w,
6495                1.0,
6496            );
6497            nu * half_w + dx
6498        };
6499        let centre_gap = (x_at(0.1) - x_at(-0.1)).abs();
6500        let edge_gap = (x_at(1.0) - x_at(0.8)).abs();
6501        assert!(
6502            edge_gap < centre_gap,
6503            "a projected grid's cells must not all be the same width — a step near the limb \
6504             must map to a smaller screen gap than the same step at the centre: \
6505             centre_gap={centre_gap:.2}px, edge_gap={edge_gap:.2}px"
6506        );
6507    }
6508
6509    #[test]
6510    fn cylinder_ignores_the_vertical_axis() {
6511        let (_, dy, _) = layout_surface_child_delta(
6512            SurfaceKind::Cylinder,
6513            1000.0,
6514            140.0,
6515            999.0,
6516            0.3,
6517            0.7,
6518            400.0,
6519            200.0,
6520        );
6521        assert_eq!(
6522            dy, 0.0,
6523            "a cylinder only wraps its arc-x — a child's vertical position must be untouched"
6524        );
6525    }
6526
6527    #[test]
6528    fn a_cylinder_edge_column_foreshortens_towards_the_expected_width_with_no_perspective_at_all() {
6529        let mut root = root_with(
6530            n_column_grid(
6531                1800.0,
6532                300.0,
6533                9,
6534                "#ff0000",
6535                Some(LayoutSurface::Cylinder {
6536                    radius: 900.0,
6537                    arc_x: 120.0,
6538                    perspective: None,
6539                    rotate_x: None,
6540                    rotate_y: None,
6541                }),
6542            ),
6543            1800.0,
6544            300.0,
6545        );
6546        let out = render_pixels(&mut root, 1800, 300, 0.0);
6547        let (lo, hi) =
6548            channel_extent(&out, 1800, 300, is_red).expect("the edge column must be visible");
6549        let width = hi - lo + 1;
6550        assert!(
6551            (100..=140).contains(&width),
6552            "a 200px-wide edge column ~53 degrees off-axis on a layout-surface cylinder must \
6553             rotate to face the surface and foreshorten to roughly 120px even with no \
6554             `perspective` set at all — measured {width}px, which is what an unrotated cell \
6555             that is only translated and depth-shifted would still measure (no narrower than \
6556             its flat 200px)"
6557        );
6558    }
6559
6560    #[test]
6561    fn rotate_y_animates_linearly_from_scene_start_to_the_scene_duration() {
6562        let angle = SurfaceAngle::Animated {
6563            from: -20.0,
6564            to: 40.0,
6565            duration: None,
6566        };
6567        assert_eq!(angle.value_at(0.0, 2.0), -20.0);
6568        assert_eq!(angle.value_at(2.0, 2.0), 40.0);
6569        assert!((angle.value_at(1.0, 2.0) - 10.0).abs() < 0.001);
6570    }
6571}