Skip to main content

pax_runtime/engine/
layer_tiling.rs

1use serde::Serialize;
2
3const DEFAULT_SCROLLER_TARGET_TILE_BACKING_DIMENSION: f64 = 2496.0;
4const DEFAULT_MIN_LOGICAL_TILE_SIZE: f64 = 256.0;
5const DEFAULT_TILE_OVERSCAN_COLUMNS: i32 = 0;
6const DEFAULT_TILE_OVERSCAN_ROWS: i32 = 0;
7const DEFAULT_MIN_UNTILED_RENDER_DPR: f64 = 1.0;
8const DEFAULT_PREWARM_VIEWPORT_PAD_X_MULTIPLIER: f64 = 1.0;
9const DEFAULT_PREWARM_VIEWPORT_PAD_Y_MULTIPLIER: f64 = 1.5;
10const DEFAULT_PREWARM_VIEWPORT_PAD_MIN_X: f64 = 512.0;
11const DEFAULT_PREWARM_VIEWPORT_PAD_MIN_Y: f64 = 512.0;
12
13#[derive(Clone, Copy, Debug)]
14pub struct ScrollerTilingPolicy {
15    pub target_tile_backing_dimension: f64,
16    pub max_tile_backing_width: Option<f64>,
17    pub max_tile_backing_height: Option<f64>,
18    pub max_tile_backing_area: Option<f64>,
19    pub min_logical_tile_size: f64,
20    pub tile_overscan_columns: i32,
21    pub tile_overscan_rows: i32,
22    pub min_untiled_render_dpr: f64,
23    pub prewarm_viewport_pad_x_multiplier: f64,
24    pub prewarm_viewport_pad_y_multiplier: f64,
25    pub prewarm_viewport_pad_min_x: f64,
26    pub prewarm_viewport_pad_min_y: f64,
27    pub max_surfaces_per_layer: Option<usize>,
28}
29
30impl Default for ScrollerTilingPolicy {
31    fn default() -> Self {
32        Self {
33            target_tile_backing_dimension: DEFAULT_SCROLLER_TARGET_TILE_BACKING_DIMENSION,
34            max_tile_backing_width: None,
35            max_tile_backing_height: None,
36            max_tile_backing_area: None,
37            min_logical_tile_size: DEFAULT_MIN_LOGICAL_TILE_SIZE,
38            tile_overscan_columns: DEFAULT_TILE_OVERSCAN_COLUMNS,
39            tile_overscan_rows: DEFAULT_TILE_OVERSCAN_ROWS,
40            min_untiled_render_dpr: DEFAULT_MIN_UNTILED_RENDER_DPR,
41            prewarm_viewport_pad_x_multiplier: DEFAULT_PREWARM_VIEWPORT_PAD_X_MULTIPLIER,
42            prewarm_viewport_pad_y_multiplier: DEFAULT_PREWARM_VIEWPORT_PAD_Y_MULTIPLIER,
43            prewarm_viewport_pad_min_x: DEFAULT_PREWARM_VIEWPORT_PAD_MIN_X,
44            prewarm_viewport_pad_min_y: DEFAULT_PREWARM_VIEWPORT_PAD_MIN_Y,
45            max_surfaces_per_layer: None,
46        }
47    }
48}
49
50impl ScrollerTilingPolicy {
51    // Canvas tiles and native residency share the warm-window policy, without
52    // tying user-facing viewport proximity events to platform tiling choices.
53    pub(crate) fn prewarm_padding(
54        self,
55        viewport_width: f64,
56        viewport_height: f64,
57        horizontal_scrollable: bool,
58        vertical_scrollable: bool,
59    ) -> (f64, f64) {
60        (
61            if horizontal_scrollable {
62                (viewport_width * self.prewarm_viewport_pad_x_multiplier)
63                    .max(self.prewarm_viewport_pad_min_x)
64            } else {
65                0.0
66            },
67            if vertical_scrollable {
68                (viewport_height * self.prewarm_viewport_pad_y_multiplier)
69                    .max(self.prewarm_viewport_pad_min_y)
70            } else {
71                0.0
72            },
73        )
74    }
75}
76
77#[derive(Clone, Debug, Serialize)]
78#[serde(rename_all = "camelCase")]
79/// One physical canvas surface used to render a logical Pax layer tile.
80pub struct SurfaceCanvasDescriptor {
81    pub id: String,
82    pub key: String,
83    pub left: f64,
84    pub top: f64,
85    pub width: f64,
86    pub height: f64,
87    pub replay_priority: i32,
88    pub surface_signature: String,
89    pub transform_signature: String,
90    pub host_signature: String,
91}
92
93#[derive(Clone, Debug, Serialize)]
94#[serde(rename_all = "camelCase")]
95/// Canvas tiling plan for one logical render layer.
96pub struct LayerCanvasPlan {
97    pub layer_id: usize,
98    pub active: bool,
99    pub surfaces: Vec<SurfaceCanvasDescriptor>,
100}
101
102#[derive(Clone, Copy, Debug)]
103struct LogicalTileDimensions {
104    width: f64,
105    height: f64,
106}
107
108/// Build a one-surface plan for layers that do not need tiling.
109pub fn single_surface_plan(
110    layer_id: usize,
111    host_signature: String,
112    width: f64,
113    height: f64,
114) -> LayerCanvasPlan {
115    let width = width.max(0.0);
116    let height = height.max(0.0);
117    LayerCanvasPlan {
118        layer_id,
119        active: true,
120        surfaces: vec![SurfaceCanvasDescriptor {
121            id: layer_id.to_string(),
122            key: "single".to_string(),
123            left: 0.0,
124            top: 0.0,
125            width,
126            height,
127            replay_priority: 0,
128            surface_signature: format!("single:{width}x{height}@{host_signature}"),
129            transform_signature: "0,0".to_string(),
130            host_signature,
131        }],
132    }
133}
134
135/// Build a tile window for a scrollable vector layer.
136pub fn scroller_canvas_plan(
137    layer_id: usize,
138    host_signature: String,
139    content_width: f64,
140    content_height: f64,
141    viewport_width: f64,
142    viewport_height: f64,
143    scroll_x: f64,
144    scroll_y: f64,
145    device_pixel_ratio: f64,
146) -> LayerCanvasPlan {
147    scroller_canvas_plan_with_policy(
148        layer_id,
149        host_signature,
150        content_width,
151        content_height,
152        viewport_width,
153        viewport_height,
154        scroll_x,
155        scroll_y,
156        device_pixel_ratio,
157        ScrollerTilingPolicy::default(),
158    )
159}
160
161pub fn scroller_canvas_plan_with_policy(
162    layer_id: usize,
163    host_signature: String,
164    content_width: f64,
165    content_height: f64,
166    viewport_width: f64,
167    viewport_height: f64,
168    scroll_x: f64,
169    scroll_y: f64,
170    device_pixel_ratio: f64,
171    policy: ScrollerTilingPolicy,
172) -> LayerCanvasPlan {
173    let content_width = content_width.max(0.0);
174    let content_height = content_height.max(0.0);
175    let viewport_width = clamp_dimension(viewport_width, content_width);
176    let viewport_height = clamp_dimension(viewport_height, content_height);
177    let scroll_x = clamp_offset(scroll_x, content_width, viewport_width);
178    let scroll_y = clamp_offset(scroll_y, content_height, viewport_height);
179    let dpr = device_pixel_ratio.max(1.0);
180
181    if can_render_as_single_surface(content_width, content_height, dpr, policy) {
182        return single_surface_plan(layer_id, host_signature, content_width, content_height);
183    }
184
185    let horizontal_scrollable = content_width > viewport_width + 0.5;
186    let vertical_scrollable = content_height > viewport_height + 0.5;
187    let tile_dimensions = compute_logical_tile_dimensions(
188        content_width,
189        content_height,
190        horizontal_scrollable,
191        vertical_scrollable,
192        dpr,
193        policy,
194    );
195    if content_width <= tile_dimensions.width && content_height <= tile_dimensions.height {
196        return single_surface_plan(layer_id, host_signature, content_width, content_height);
197    }
198
199    let max_column = ((content_width / tile_dimensions.width).ceil() as i32 - 1).max(0);
200    let max_row = ((content_height / tile_dimensions.height).ceil() as i32 - 1).max(0);
201    let (pad_x, pad_y) = policy.prewarm_padding(
202        viewport_width,
203        viewport_height,
204        horizontal_scrollable,
205        vertical_scrollable,
206    );
207    let padded_viewport_width = viewport_width + pad_x * 2.0;
208    let padded_viewport_height = viewport_height + pad_y * 2.0;
209    let padded_scroll_x = clamp_offset(scroll_x - pad_x, content_width, padded_viewport_width);
210    let padded_scroll_y = clamp_offset(scroll_y - pad_y, content_height, padded_viewport_height);
211    let overscan_columns = if horizontal_scrollable {
212        policy.tile_overscan_columns.max(0)
213    } else {
214        0
215    };
216    let overscan_rows = if vertical_scrollable {
217        policy.tile_overscan_rows.max(0)
218    } else {
219        0
220    };
221    let active_columns = (max_column + 1).min(
222        visible_tile_span(padded_viewport_width, tile_dimensions.width, true)
223            + overscan_columns * 2,
224    );
225    let active_rows = (max_row + 1).min(
226        visible_tile_span(padded_viewport_height, tile_dimensions.height, true) + overscan_rows * 2,
227    );
228    let min_active_columns = (max_column + 1).min(visible_tile_span(
229        viewport_width,
230        tile_dimensions.width,
231        true,
232    ));
233    let min_active_rows = (max_row + 1).min(visible_tile_span(
234        viewport_height,
235        tile_dimensions.height,
236        true,
237    ));
238    let (active_columns, active_rows) = clamp_active_tile_window(
239        active_columns,
240        active_rows,
241        min_active_columns,
242        min_active_rows,
243        horizontal_scrollable,
244        vertical_scrollable,
245        policy.max_surfaces_per_layer,
246    );
247    let start_column = clamp_window_start(
248        (padded_scroll_x / tile_dimensions.width).floor() as i32 - overscan_columns,
249        max_column,
250        active_columns,
251    );
252    let start_row = clamp_window_start(
253        (padded_scroll_y / tile_dimensions.height).floor() as i32 - overscan_rows,
254        max_row,
255        active_rows,
256    );
257    let end_column = max_column.min(start_column + active_columns - 1);
258    let end_row = max_row.min(start_row + active_rows - 1);
259
260    let mut surfaces = Vec::new();
261    for row in start_row..=end_row {
262        for column in start_column..=end_column {
263            let slot_column = column.rem_euclid(active_columns);
264            let slot_row = row.rem_euclid(active_rows);
265            let left = column as f64 * tile_dimensions.width;
266            let top = row as f64 * tile_dimensions.height;
267            let width = (content_width - left).min(tile_dimensions.width).max(1.0);
268            let height = (content_height - top).min(tile_dimensions.height).max(1.0);
269            let replay_priority = tile_replay_priority(
270                left,
271                top,
272                width,
273                height,
274                scroll_x,
275                scroll_y,
276                viewport_width,
277                viewport_height,
278                tile_dimensions.width,
279                tile_dimensions.height,
280            );
281            let key = format!("{slot_column}:{slot_row}");
282            let id = format!("layer-{layer_id}-tile-{slot_column}-{slot_row}");
283            let surface_signature = format!(
284                "tile:{slot_column},{slot_row}:{width}x{height}:{viewport_width}x{viewport_height}:{host_signature}"
285            );
286            let transform_signature = format!("{left},{top}");
287            surfaces.push(SurfaceCanvasDescriptor {
288                id,
289                key,
290                left,
291                top,
292                width,
293                height,
294                replay_priority,
295                surface_signature,
296                transform_signature,
297                host_signature: host_signature.clone(),
298            });
299        }
300    }
301    surfaces.sort_by(|left, right| {
302        left.key
303            .cmp(&right.key)
304            .then_with(|| left.transform_signature.cmp(&right.transform_signature))
305    });
306
307    LayerCanvasPlan {
308        layer_id,
309        active: true,
310        surfaces,
311    }
312}
313
314fn compute_logical_tile_size(device_pixel_ratio: f64, policy: ScrollerTilingPolicy) -> f64 {
315    let tile_size = (policy.target_tile_backing_dimension.max(1.0) / device_pixel_ratio).floor();
316    tile_size.max(policy.min_logical_tile_size.max(1.0))
317}
318
319fn compute_logical_tile_dimensions(
320    content_width: f64,
321    content_height: f64,
322    horizontal_scrollable: bool,
323    vertical_scrollable: bool,
324    device_pixel_ratio: f64,
325    policy: ScrollerTilingPolicy,
326) -> LogicalTileDimensions {
327    let target_tile_size = compute_logical_tile_size(device_pixel_ratio, policy);
328    let max_width = max_logical_tile_width(device_pixel_ratio, policy);
329    let max_height = max_logical_tile_height(device_pixel_ratio, policy);
330    let mut width = target_tile_size.min(max_width).max(1.0);
331    let mut height = target_tile_size.min(max_height).max(1.0);
332
333    if vertical_scrollable && !horizontal_scrollable {
334        width = content_width.max(1.0).min(max_width).max(1.0);
335        height = constrain_axis_by_area(height, width, device_pixel_ratio, policy);
336    } else if horizontal_scrollable && !vertical_scrollable {
337        height = content_height.max(1.0).min(max_height).max(1.0);
338        width = constrain_axis_by_area(width, height, device_pixel_ratio, policy);
339    } else {
340        (width, height) = constrain_tile_area(width, height, device_pixel_ratio, policy);
341    }
342
343    LogicalTileDimensions { width, height }
344}
345
346fn can_render_as_single_surface(
347    content_width: f64,
348    content_height: f64,
349    desired_dpr: f64,
350    policy: ScrollerTilingPolicy,
351) -> bool {
352    let safe_width = content_width.max(1.0);
353    let safe_height = content_height.max(1.0);
354    let backing_width = max_tile_backing_width(policy);
355    let backing_height = max_tile_backing_height(policy);
356    let backing_area = max_tile_backing_area(policy);
357    let single_surface_dpr = desired_dpr
358        .min(backing_width / safe_width)
359        .min(backing_height / safe_height)
360        .min((backing_area / (safe_width * safe_height)).sqrt());
361    // Tiling is the allocation escape hatch, not reduced visual resolution.
362    // A chassis may require a stricter floor, but never less than screen density.
363    single_surface_dpr >= desired_dpr.max(policy.min_untiled_render_dpr.max(0.0))
364}
365
366fn max_tile_backing_width(policy: ScrollerTilingPolicy) -> f64 {
367    finite_positive_or(
368        policy.max_tile_backing_width,
369        policy.target_tile_backing_dimension,
370    )
371}
372
373fn max_tile_backing_height(policy: ScrollerTilingPolicy) -> f64 {
374    finite_positive_or(
375        policy.max_tile_backing_height,
376        policy.target_tile_backing_dimension,
377    )
378}
379
380fn max_tile_backing_area(policy: ScrollerTilingPolicy) -> f64 {
381    finite_positive_or(
382        policy.max_tile_backing_area,
383        max_tile_backing_width(policy) * max_tile_backing_height(policy),
384    )
385}
386
387fn finite_positive_or(value: Option<f64>, fallback: f64) -> f64 {
388    value
389        .filter(|value| value.is_finite() && *value > 0.0)
390        .unwrap_or(fallback)
391        .max(1.0)
392}
393
394fn max_logical_tile_width(device_pixel_ratio: f64, policy: ScrollerTilingPolicy) -> f64 {
395    (max_tile_backing_width(policy) / device_pixel_ratio.max(1.0))
396        .floor()
397        .max(1.0)
398}
399
400fn max_logical_tile_height(device_pixel_ratio: f64, policy: ScrollerTilingPolicy) -> f64 {
401    (max_tile_backing_height(policy) / device_pixel_ratio.max(1.0))
402        .floor()
403        .max(1.0)
404}
405
406fn backing_extent(logical_extent: f64, device_pixel_ratio: f64) -> f64 {
407    (logical_extent.max(1.0) * device_pixel_ratio.max(1.0))
408        .ceil()
409        .max(1.0)
410}
411
412fn backing_area(width: f64, height: f64, device_pixel_ratio: f64) -> f64 {
413    backing_extent(width, device_pixel_ratio) * backing_extent(height, device_pixel_ratio)
414}
415
416fn constrain_axis_by_area(
417    axis: f64,
418    cross_axis: f64,
419    device_pixel_ratio: f64,
420    policy: ScrollerTilingPolicy,
421) -> f64 {
422    let max_area = max_tile_backing_area(policy);
423    let cross_backing = backing_extent(cross_axis, device_pixel_ratio);
424    let max_axis = (max_area / cross_backing / device_pixel_ratio.max(1.0))
425        .floor()
426        .max(1.0);
427    axis.min(max_axis).max(1.0)
428}
429
430fn constrain_tile_area(
431    mut width: f64,
432    mut height: f64,
433    device_pixel_ratio: f64,
434    policy: ScrollerTilingPolicy,
435) -> (f64, f64) {
436    let max_area = max_tile_backing_area(policy);
437    let area = backing_area(width, height, device_pixel_ratio);
438    if area <= max_area {
439        return (width, height);
440    }
441
442    let scale = (max_area / area).sqrt().min(1.0);
443    width = (width * scale).floor().max(1.0);
444    height = (height * scale).floor().max(1.0);
445    if backing_area(width, height, device_pixel_ratio) > max_area {
446        height = constrain_axis_by_area(height, width, device_pixel_ratio, policy);
447    }
448    if backing_area(width, height, device_pixel_ratio) > max_area {
449        width = constrain_axis_by_area(width, height, device_pixel_ratio, policy);
450    }
451    (width.max(1.0), height.max(1.0))
452}
453
454fn tile_replay_priority(
455    tile_left: f64,
456    tile_top: f64,
457    tile_width: f64,
458    tile_height: f64,
459    viewport_left: f64,
460    viewport_top: f64,
461    viewport_width: f64,
462    viewport_height: f64,
463    tile_step_width: f64,
464    tile_step_height: f64,
465) -> i32 {
466    let tile_right = tile_left + tile_width;
467    let tile_bottom = tile_top + tile_height;
468    let viewport_right = viewport_left + viewport_width;
469    let viewport_bottom = viewport_top + viewport_height;
470
471    let intersects = tile_right >= viewport_left
472        && tile_left <= viewport_right
473        && tile_bottom >= viewport_top
474        && tile_top <= viewport_bottom;
475    if intersects {
476        return 0;
477    }
478
479    let dx = if tile_right < viewport_left {
480        viewport_left - tile_right
481    } else if tile_left > viewport_right {
482        tile_left - viewport_right
483    } else {
484        0.0
485    };
486    let dy = if tile_bottom < viewport_top {
487        viewport_top - tile_bottom
488    } else if tile_top > viewport_bottom {
489        tile_top - viewport_bottom
490    } else {
491        0.0
492    };
493
494    let x_priority = if dx > 0.0 {
495        (dx / tile_step_width.max(1.0)).ceil() as i32
496    } else {
497        0
498    };
499    let y_priority = if dy > 0.0 {
500        (dy / tile_step_height.max(1.0)).ceil() as i32
501    } else {
502        0
503    };
504    (x_priority.max(y_priority) + 1).max(1)
505}
506
507fn clamp_active_tile_window(
508    mut active_columns: i32,
509    mut active_rows: i32,
510    min_active_columns: i32,
511    min_active_rows: i32,
512    horizontal_scrollable: bool,
513    vertical_scrollable: bool,
514    max_surfaces: Option<usize>,
515) -> (i32, i32) {
516    let Some(max_surfaces) = max_surfaces else {
517        return (active_columns.max(1), active_rows.max(1));
518    };
519    let max_surfaces = max_surfaces.max(1) as i32;
520
521    // First trim only the axis that can actually scroll. This preserves visible-axis coverage
522    // whenever the platform cap is large enough to fit the viewport tile span.
523    while active_columns * active_rows > max_surfaces {
524        let can_trim_rows = vertical_scrollable && active_rows > min_active_rows.max(1);
525        let can_trim_columns = horizontal_scrollable && active_columns > min_active_columns.max(1);
526        if can_trim_rows && (!can_trim_columns || active_rows >= active_columns) {
527            active_rows -= 1;
528            continue;
529        }
530        if can_trim_columns {
531            active_columns -= 1;
532            continue;
533        }
534        if can_trim_rows {
535            active_rows -= 1;
536            continue;
537        }
538        break;
539    }
540
541    // A too-small platform cap is a hard constraint. Fall back to shrinking whichever dimension
542    // is larger rather than allowing a plan the chassis cannot materialize.
543    while active_columns * active_rows > max_surfaces && (active_columns > 1 || active_rows > 1) {
544        if active_rows >= active_columns && active_rows > 1 {
545            active_rows -= 1;
546        } else if active_columns > 1 {
547            active_columns -= 1;
548        } else {
549            active_rows -= 1;
550        }
551    }
552
553    (active_columns.max(1), active_rows.max(1))
554}
555
556fn clamp_dimension(value: f64, fallback: f64) -> f64 {
557    if value.is_finite() {
558        value.max(0.0).min(fallback.max(0.0))
559    } else {
560        fallback.max(0.0)
561    }
562}
563
564fn clamp_offset(value: f64, content: f64, viewport: f64) -> f64 {
565    if content <= viewport {
566        return 0.0;
567    }
568    if !value.is_finite() {
569        return 0.0;
570    }
571    value.max(0.0).min((content - viewport).max(0.0))
572}
573
574fn visible_tile_span(viewport_size: f64, tile_size: f64, include_extra_slot: bool) -> i32 {
575    let base = (viewport_size.max(0.0) / tile_size).ceil() as i32;
576    let extra = if include_extra_slot { 1 } else { 0 };
577    (base + extra).max(1)
578}
579
580fn clamp_window_start(value: i32, max_index: i32, window_size: i32) -> i32 {
581    clamp_index(value, (max_index - window_size + 1).max(0))
582}
583
584fn clamp_index(value: i32, max: i32) -> i32 {
585    value.max(0).min(max)
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn single_surface_must_fit_at_requested_screen_density() {
594        let policy = ScrollerTilingPolicy::default();
595        assert!(can_render_as_single_surface(2000.0, 600.0, 1.0, policy));
596        assert!(!can_render_as_single_surface(2000.0, 600.0, 2.0, policy));
597        assert!(!can_render_as_single_surface(1000.0, 600.0, 3.0, policy));
598        assert!(can_render_as_single_surface(1248.0, 600.0, 2.0, policy));
599    }
600
601    #[test]
602    fn screen_density_tiles_stay_within_physical_budgets_and_cover_the_viewport() {
603        for dpr in [1.0, 1.25, 1.5, 2.0, 3.0] {
604            for cap in [2048.0, 2496.0, 4096.0] {
605                let mut policy = ScrollerTilingPolicy::default();
606                policy.target_tile_backing_dimension = cap;
607                policy.max_tile_backing_width = Some(cap);
608                policy.max_tile_backing_height = Some(cap);
609                policy.max_tile_backing_area = Some(cap * cap);
610                for (width, height, viewport_w, viewport_h) in [
611                    (20000.0, 534.0, 1322.0, 534.0),
612                    (1322.0, 20000.0, 1322.0, 930.0),
613                    (20000.0, 20000.0, 390.0, 844.0),
614                    (2000.0, 1200.0, 2000.0, 1200.0),
615                ] {
616                    for fraction in [0.0, 0.5, 1.0] {
617                        let x = (width - viewport_w) * fraction;
618                        let y = (height - viewport_h) * fraction;
619                        let plan = scroller_canvas_plan_with_policy(
620                            1,
621                            "density-test".into(),
622                            width,
623                            height,
624                            viewport_w,
625                            viewport_h,
626                            x,
627                            y,
628                            dpr,
629                            policy,
630                        );
631                        for tile in &plan.surfaces {
632                            assert!(backing_extent(tile.width, dpr) <= cap);
633                            assert!(backing_extent(tile.height, dpr) <= cap);
634                            assert!(backing_area(tile.width, tile.height, dpr) <= cap * cap);
635                        }
636                        // Sample all four edges and the interior, including after tile turnover.
637                        for u in [0.001, 0.25, 0.5, 0.75, 0.999] {
638                            for v in [0.001, 0.25, 0.5, 0.75, 0.999] {
639                                let px = x + viewport_w * u;
640                                let py = y + viewport_h * v;
641                                assert!(
642                                    plan.surfaces.iter().any(|tile| {
643                                        px >= tile.left
644                                            && px <= tile.left + tile.width
645                                            && py >= tile.top
646                                            && py <= tile.top + tile.height
647                                    }),
648                                    "uncovered ({px}, {py}) at {dpr}x, cap={cap}"
649                                );
650                            }
651                        }
652                    }
653                }
654            }
655        }
656    }
657
658    #[test]
659    fn vertical_only_scroller_does_not_add_horizontal_overscan() {
660        let mut policy = ScrollerTilingPolicy::default();
661        policy.tile_overscan_columns = 3;
662        policy.tile_overscan_rows = 0;
663        let plan = scroller_canvas_plan_with_policy(
664            1,
665            "test".to_string(),
666            600.0,
667            10_000.0,
668            600.0,
669            600.0,
670            0.0,
671            0.0,
672            1.0,
673            policy,
674        );
675
676        assert!(plan.surfaces.iter().all(|surface| surface.left == 0.0));
677    }
678
679    #[test]
680    fn vertical_only_scroller_uses_full_width_tiles_when_cap_allows() {
681        let mut policy = ScrollerTilingPolicy::default();
682        policy.target_tile_backing_dimension = 4096.0;
683        policy.max_tile_backing_width = Some(4096.0);
684        policy.max_tile_backing_height = Some(4096.0);
685        policy.max_tile_backing_area = Some(4096.0 * 4096.0);
686        policy.prewarm_viewport_pad_y_multiplier = 0.5;
687        policy.prewarm_viewport_pad_min_y = 384.0;
688        let plan = scroller_canvas_plan_with_policy(
689            1,
690            "test".to_string(),
691            1376.0,
692            120_000.0,
693            1376.0,
694            904.0,
695            0.0,
696            0.0,
697            2.0,
698            policy,
699        );
700
701        assert!(plan.surfaces.iter().all(|surface| surface.left == 0.0));
702        assert!(plan.surfaces.iter().all(|surface| surface.width == 1376.0));
703        assert!(plan.surfaces.len() <= 3);
704    }
705
706    #[test]
707    fn non_scrolling_axis_still_splits_when_backing_cap_requires_it() {
708        let mut policy = ScrollerTilingPolicy::default();
709        policy.target_tile_backing_dimension = 2048.0;
710        policy.max_tile_backing_width = Some(2048.0);
711        policy.max_tile_backing_height = Some(2048.0);
712        policy.max_tile_backing_area = Some(2048.0 * 2048.0);
713        let plan = scroller_canvas_plan_with_policy(
714            1,
715            "test".to_string(),
716            3000.0,
717            20_000.0,
718            3000.0,
719            600.0,
720            0.0,
721            0.0,
722            1.0,
723            policy,
724        );
725
726        assert!(plan.surfaces.iter().any(|surface| surface.left > 0.0));
727        assert!(plan.surfaces.iter().all(|surface| surface.width <= 2048.0));
728    }
729
730    #[test]
731    fn tile_area_cap_reduces_scroll_axis_extent() {
732        let mut policy = ScrollerTilingPolicy::default();
733        policy.target_tile_backing_dimension = 4096.0;
734        policy.max_tile_backing_width = Some(4096.0);
735        policy.max_tile_backing_height = Some(4096.0);
736        policy.max_tile_backing_area = Some(4_000_000.0);
737        let plan = scroller_canvas_plan_with_policy(
738            1,
739            "test".to_string(),
740            3000.0,
741            20_000.0,
742            3000.0,
743            600.0,
744            0.0,
745            0.0,
746            1.0,
747            policy,
748        );
749
750        assert!(plan
751            .surfaces
752            .iter()
753            .all(|surface| { (surface.width.ceil() * surface.height.ceil()) <= 4_000_000.0 }));
754        assert!(plan.surfaces.iter().all(|surface| surface.height <= 1333.0));
755    }
756
757    #[test]
758    fn vertical_overscan_is_axis_gated() {
759        let mut policy = ScrollerTilingPolicy::default();
760        policy.tile_overscan_rows = 1;
761        let vertical_plan = scroller_canvas_plan_with_policy(
762            1,
763            "test".to_string(),
764            600.0,
765            10_000.0,
766            600.0,
767            600.0,
768            0.0,
769            0.0,
770            1.0,
771            policy,
772        );
773        let horizontal_plan = scroller_canvas_plan_with_policy(
774            1,
775            "test".to_string(),
776            10_000.0,
777            600.0,
778            600.0,
779            600.0,
780            0.0,
781            0.0,
782            1.0,
783            policy,
784        );
785
786        assert!(vertical_plan.surfaces.len() > horizontal_plan.surfaces.len());
787    }
788
789    #[test]
790    fn max_surfaces_clamps_requested_tile_window() {
791        let mut policy = ScrollerTilingPolicy::default();
792        policy.tile_overscan_rows = 2;
793        policy.max_surfaces_per_layer = Some(3);
794        let plan = scroller_canvas_plan_with_policy(
795            1,
796            "test".to_string(),
797            600.0,
798            20_000.0,
799            600.0,
800            600.0,
801            0.0,
802            5_000.0,
803            1.0,
804            policy,
805        );
806
807        assert!(plan.surfaces.len() <= 3);
808    }
809
810    #[test]
811    fn overlapping_vertical_tile_keeps_physical_slot_key() {
812        let policy = ScrollerTilingPolicy::default();
813        let first = scroller_canvas_plan_with_policy(
814            1,
815            "test".to_string(),
816            600.0,
817            20_000.0,
818            600.0,
819            600.0,
820            0.0,
821            0.0,
822            1.0,
823            policy,
824        );
825        let second = scroller_canvas_plan_with_policy(
826            1,
827            "test".to_string(),
828            600.0,
829            20_000.0,
830            600.0,
831            600.0,
832            0.0,
833            3_200.0,
834            1.0,
835            policy,
836        );
837
838        let overlapping_surface = first
839            .surfaces
840            .iter()
841            .find(|surface| surface.top > 0.0)
842            .expect("first plan should have a second row");
843        let matching_surface = second
844            .surfaces
845            .iter()
846            .find(|surface| surface.top == overlapping_surface.top)
847            .expect("second plan should keep the overlapping row warm");
848
849        assert_eq!(matching_surface.key, overlapping_surface.key);
850        assert_eq!(matching_surface.id, overlapping_surface.id);
851    }
852
853    #[test]
854    fn replay_priority_prefers_visible_tiles() {
855        let policy = ScrollerTilingPolicy::default();
856        let plan = scroller_canvas_plan_with_policy(
857            1,
858            "test".to_string(),
859            600.0,
860            20_000.0,
861            600.0,
862            600.0,
863            0.0,
864            5_000.0,
865            1.0,
866            policy,
867        );
868
869        let visible_tiles = plan
870            .surfaces
871            .iter()
872            .filter(|surface| surface.top + surface.height >= 5_000.0 && surface.top <= 5_600.0)
873            .count();
874        assert!(visible_tiles > 0);
875        assert!(plan
876            .surfaces
877            .iter()
878            .filter(|surface| surface.top + surface.height >= 5_000.0 && surface.top <= 5_600.0)
879            .all(|surface| surface.replay_priority == 0));
880        assert!(plan
881            .surfaces
882            .iter()
883            .any(|surface| surface.replay_priority > 0));
884    }
885}