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 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 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}